Skip to content

Commit cb193c3

Browse files
authored
Merge pull request #22 from brianmeyer/codex/rec-192-193-hardening
Harden wrapped media tag parsing and batch tag ordering
2 parents 1c655eb + d1dac23 commit cb193c3

8 files changed

Lines changed: 183 additions & 20 deletions

File tree

src/recallforge/search.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
"""
1616

1717
import concurrent.futures
18+
import copy
1819
import json
1920
import logging
2021
import os
2122
import re
2223
import time
23-
from dataclasses import dataclass, field
24+
from dataclasses import dataclass, field, replace
2425
from hashlib import sha256
2526
from typing import List, Dict, Any, Optional, Union
2627

@@ -99,6 +100,20 @@ def _log_stage_metrics(
99100
logger.debug("stage_metrics " + " ".join(log_parts))
100101

101102

103+
def _canonicalize_memory_result_paths(result: "HybridResult", canonical_root_path: Optional[str]) -> tuple[str, str]:
104+
"""Return a stable result filepath plus display path for rolled memory hits."""
105+
if not canonical_root_path:
106+
return result.filepath, result.display_path
107+
108+
if str(result.filepath or "").startswith("recallforge://"):
109+
return (
110+
f"recallforge://{result.collection}/{canonical_root_path}",
111+
f"{result.collection}/{canonical_root_path}",
112+
)
113+
114+
return canonical_root_path, canonical_root_path
115+
116+
102117
# Intent-to-weight mappings for RRF fusion
103118
# Each intent maps source names to weight multipliers
104119
INTENT_WEIGHTS: Dict[str, Dict[str, float]] = {
@@ -132,6 +147,8 @@ class SearchAudit:
132147
blend_weights: Dict[str, float] = field(default_factory=dict) # rrf_weight, rerank_weight
133148
media_compensation_applied: bool = False # Whether media boost was applied in RRF
134149
memory_rollup_boost: float = 1.0 # Multiplier applied when sibling assets are rolled up
150+
memory_primary_evidence_path: Optional[str] = None
151+
memory_supporting_paths: List[str] = field(default_factory=list)
135152
final_blended_score: float = 0.0
136153

137154

@@ -157,6 +174,8 @@ class HybridResult:
157174
memory_role: str = "root"
158175
memory_root_path: Optional[str] = None
159176
memory_hit_count: int = 1
177+
memory_primary_evidence_path: Optional[str] = None
178+
memory_supporting_paths: Optional[List[str]] = None
160179
tags: Optional[List[str]] = None
161180
audit: Optional[SearchAudit] = None # Per-result audit trail
162181

@@ -1239,15 +1258,57 @@ def _merge_tags(items: List[HybridResult]) -> Optional[List[str]]:
12391258
rolled: List[HybridResult] = []
12401259
for key in order:
12411260
group = sorted(grouped[key], key=lambda item: item.score, reverse=True)
1242-
representative = group[0]
1261+
top_hit = group[0]
1262+
root_candidate = next(
1263+
(item for item in group if item.memory_role == "root"),
1264+
None,
1265+
)
1266+
representative = replace(root_candidate or top_hit)
1267+
representative.score = top_hit.score
1268+
representative.rrf_rank = top_hit.rrf_rank
1269+
representative.rerank_score = top_hit.rerank_score
1270+
representative.source = top_hit.source
1271+
representative.audit = copy.deepcopy(top_hit.audit) if top_hit.audit else None
1272+
representative.context = representative.context or top_hit.context
1273+
representative.body = representative.body or top_hit.body
1274+
representative.hash = representative.hash or top_hit.hash
1275+
representative.docid = representative.docid or top_hit.docid
1276+
representative.modified_at = representative.modified_at or top_hit.modified_at
1277+
representative.body_length = representative.body_length or top_hit.body_length
1278+
1279+
canonical_path = representative.memory_root_path or top_hit.memory_root_path
1280+
if canonical_path:
1281+
representative.filepath, representative.display_path = _canonicalize_memory_result_paths(
1282+
representative,
1283+
canonical_path,
1284+
)
1285+
if not root_candidate:
1286+
representative.title = os.path.basename(canonical_path)
1287+
representative.memory_root_path = canonical_path
1288+
else:
1289+
representative.memory_root_path = representative.filepath
1290+
1291+
representative.memory_role = "root"
12431292
representative.memory_hit_count = len(group)
12441293
representative.tags = _merge_tags(group)
1294+
representative.memory_primary_evidence_path = top_hit.filepath
1295+
representative.memory_supporting_paths = [
1296+
item.filepath
1297+
for item in group
1298+
if item.filepath not in {representative.filepath, top_hit.filepath}
1299+
][:5]
12451300
memory_rollup_boost = 1.0
12461301
if len(group) > 1:
12471302
memory_rollup_boost += min(0.15, 0.03 * (len(group) - 1))
12481303
representative.score *= memory_rollup_boost
12491304
if representative.audit:
1305+
representative.audit.filepath = representative.filepath
1306+
representative.audit.content_type = representative.content_type
12501307
representative.audit.memory_rollup_boost = memory_rollup_boost
1308+
representative.audit.memory_primary_evidence_path = top_hit.filepath
1309+
representative.audit.memory_supporting_paths = list(
1310+
representative.memory_supporting_paths or []
1311+
)
12511312
representative.audit.final_blended_score = representative.score
12521313
rolled.append(representative)
12531314

@@ -1570,7 +1631,7 @@ def run_single_query(q: BatchQuery) -> List[tuple]:
15701631
return [(r, r.score) for r in results]
15711632

15721633
# Run all queries in parallel
1573-
all_results: Dict[int, List[tuple]] = {}
1634+
all_results: List[List[tuple]] = [[] for _ in batch_queries]
15741635
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
15751636
future_to_idx = {
15761637
executor.submit(run_single_query, q): i
@@ -1587,7 +1648,7 @@ def run_single_query(q: BatchQuery) -> List[tuple]:
15871648
# Merge results using RRF with best-score-wins
15881649
merged: Dict[str, Dict[str, Any]] = {}
15891650

1590-
for idx, results in all_results.items():
1651+
for idx, results in enumerate(all_results):
15911652
weight = batch_queries[idx].weight
15921653
for rank, (result, score) in enumerate(results):
15931654
filepath = result.filepath

src/recallforge/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,8 @@ async def _handle_explain_results(arguments: dict, backend, storage) -> list[Tex
10071007
"memory_role": getattr(r, "memory_role", "root"),
10081008
"memory_root_path": getattr(r, "memory_root_path", None),
10091009
"memory_hit_count": getattr(r, "memory_hit_count", 1),
1010+
"memory_primary_evidence_path": getattr(r, "memory_primary_evidence_path", None),
1011+
"memory_supporting_paths": getattr(r, "memory_supporting_paths", None),
10101012
"tags": getattr(r, "tags", None),
10111013
}
10121014

@@ -1029,6 +1031,8 @@ async def _handle_explain_results(arguments: dict, backend, storage) -> list[Tex
10291031
"memory_rollup": {
10301032
"memory_hit_count": getattr(r, "memory_hit_count", 1),
10311033
"boost": round(r.audit.memory_rollup_boost, 6),
1034+
"primary_evidence_path": r.audit.memory_primary_evidence_path,
1035+
"supporting_paths": list(r.audit.memory_supporting_paths),
10321036
},
10331037
}
10341038
else:

src/recallforge/storage/indexing_ops.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def _parse_generated_media_tags(self, raw: str) -> List[str]:
132132
if not text:
133133
return []
134134

135-
fenced_match = re.match(r"^```(?:[A-Za-z0-9_+-]+)?\s*\n?(.*?)\n?```$", text, flags=re.DOTALL)
135+
fenced_match = re.search(r"```(?:[A-Za-z0-9_+-]+)?\s*(.*?)\s*```", text, flags=re.DOTALL)
136136
if fenced_match:
137137
text = fenced_match.group(1).strip()
138138

tests/test_config_tools.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,8 @@ async def test_explain_results_surfaces_memory_rollup_provenance(self):
510510
memory_role="root",
511511
memory_root_path="notes/demo.md",
512512
memory_hit_count=3,
513+
memory_primary_evidence_path="notes/demo.md::section:0001",
514+
memory_supporting_paths=["notes/demo.md::image:0001"],
513515
audit=SearchAudit(
514516
filepath="notes/demo.md",
515517
content_type="text",
@@ -521,6 +523,8 @@ async def test_explain_results_surfaces_memory_rollup_provenance(self):
521523
blend_weights={"rrf": 0.8, "rerank": 0.2},
522524
media_compensation_applied=False,
523525
memory_rollup_boost=1.06,
526+
memory_primary_evidence_path="notes/demo.md::section:0001",
527+
memory_supporting_paths=["notes/demo.md::image:0001"],
524528
final_blended_score=0.8123,
525529
),
526530
)
@@ -536,8 +540,18 @@ async def test_explain_results_surfaces_memory_rollup_provenance(self):
536540
self.assertEqual(explained["memory_id"], "mem-123")
537541
self.assertEqual(explained["memory_hit_count"], 3)
538542
self.assertEqual(explained["memory_root_path"], "notes/demo.md")
543+
self.assertEqual(explained["memory_primary_evidence_path"], "notes/demo.md::section:0001")
544+
self.assertEqual(explained["memory_supporting_paths"], ["notes/demo.md::image:0001"])
539545
self.assertEqual(explained["provenance"]["memory_rollup"]["memory_hit_count"], 3)
540546
self.assertAlmostEqual(explained["provenance"]["memory_rollup"]["boost"], 1.06)
547+
self.assertEqual(
548+
explained["provenance"]["memory_rollup"]["primary_evidence_path"],
549+
"notes/demo.md::section:0001",
550+
)
551+
self.assertEqual(
552+
explained["provenance"]["memory_rollup"]["supporting_paths"],
553+
["notes/demo.md::image:0001"],
554+
)
541555

542556
async def test_search_file_path_routes_through_text_query(self):
543557
backend = _make_backend()

tests/test_search_batch.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import os
88
import sys
9+
import time
910
import unittest
1011
from dataclasses import dataclass
1112
from typing import List, Dict, Any, Optional
@@ -567,12 +568,11 @@ def test_same_document_merges_tags_deterministically(self):
567568
],
568569
]
569570

570-
call_idx = [0]
571-
572571
def mock_search(self, query):
573-
idx = call_idx[0]
574-
call_idx[0] += 1
575-
return results_list[idx]
572+
if query == "query one":
573+
time.sleep(0.05)
574+
return results_list[0]
575+
return results_list[1]
576576

577577
with patch.object(HybridSearcher, '__init__', lambda self, **kwargs: None):
578578
with patch.object(HybridSearcher, 'search', mock_search):

tests/test_search_pipeline.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,54 @@ def test_memory_rollup_merges_tags_from_sibling_assets(self):
457457
self.assertEqual(len(rolled), 1)
458458
self.assertEqual(rolled[0].tags, ["diagram", "meeting notes"])
459459

460+
def test_memory_rollup_preserves_collection_qualified_filepath(self):
461+
searcher = HybridSearcher(backend=StubBackend(), storage=StubStorage(), limit=12)
462+
463+
root = _make_search_result("recallforge://alpha/memories/demo.mp4", 0.2, "vec", "video")
464+
child = _make_search_result(
465+
"recallforge://alpha/memories/demo.mp4::transcript:0001",
466+
0.9,
467+
"fts",
468+
"text",
469+
)
470+
sibling = _make_search_result(
471+
"recallforge://alpha/memories/demo.mp4::frame:0001",
472+
0.6,
473+
"vec",
474+
"image",
475+
)
476+
for item in (root, child, sibling):
477+
item.collection = "alpha"
478+
item.memory_id = "memory-evidence"
479+
item.memory_root_path = "memories/demo.mp4"
480+
root.memory_role = "root"
481+
child.memory_role = "child"
482+
sibling.memory_role = "child"
483+
root.body = "Canonical demo video summary."
484+
485+
blended = searcher._blend_scores(
486+
[child, sibling, root],
487+
{
488+
child.filepath: 0.9,
489+
sibling.filepath: 0.6,
490+
root.filepath: 0.2,
491+
},
492+
)
493+
494+
self.assertEqual(len(blended), 1)
495+
result = blended[0]
496+
self.assertEqual(result.filepath, "recallforge://alpha/memories/demo.mp4")
497+
self.assertEqual(result.display_path, "alpha/memories/demo.mp4")
498+
self.assertEqual(result.memory_root_path, "memories/demo.mp4")
499+
self.assertEqual(
500+
result.memory_primary_evidence_path,
501+
"recallforge://alpha/memories/demo.mp4::transcript:0001",
502+
)
503+
self.assertEqual(
504+
result.memory_supporting_paths,
505+
["recallforge://alpha/memories/demo.mp4::frame:0001"],
506+
)
507+
460508

461509
class TestParallelSearchTaskCapture(unittest.TestCase):
462510
def test_parallel_search_captures_original_vector(self):

tests/test_storage.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,33 @@ def fenced_json(_prompt: str, max_tokens: int = 60) -> str:
13621362
["diagram", "hidden layers", "neural network"],
13631363
)
13641364

1365+
def test_generated_media_tags_extract_fenced_json_from_wrapped_text(self):
1366+
embedder = CaptioningEmbedder()
1367+
1368+
def wrapped_fenced_json(_prompt: str, max_tokens: int = 60) -> str:
1369+
return (
1370+
"Here are the tags:\n"
1371+
"```json\n"
1372+
'["diagram", "hidden layers", "neural network"]\n'
1373+
"```"
1374+
)
1375+
1376+
embedder.generate_text = wrapped_fenced_json
1377+
1378+
self.backend.index_image(
1379+
path=self.image_path,
1380+
collection="test",
1381+
embed_func=embedder,
1382+
caption_media=True,
1383+
)
1384+
1385+
rows = self.backend._embeddings_table.search().where("content_type = 'image'").to_list()
1386+
self.assertEqual(len(rows), 1)
1387+
self.assertEqual(
1388+
json.loads(rows[0].get("tags") or "[]"),
1389+
["diagram", "hidden layers", "neural network"],
1390+
)
1391+
13651392
def test_index_video_keeps_parent_memory_and_links_children(self):
13661393
embedder = CaptioningEmbedder()
13671394
logical_path = str(Path(self.video_path).expanduser().resolve())

tests/test_watch_folder.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ def embed_image(self, image):
3939
return [0.0] * 8
4040

4141

42+
def _wait_until(predicate, timeout: float = 3.0, interval: float = 0.05) -> bool:
43+
deadline = time.time() + timeout
44+
while time.time() < deadline:
45+
if predicate():
46+
return True
47+
time.sleep(interval)
48+
return predicate()
49+
50+
4251
def test_watch_folder_create_and_modify(tmp_path):
4352
storage = FakeStorage()
4453
backend = FakeBackend()
@@ -58,15 +67,16 @@ def test_watch_folder_create_and_modify(tmp_path):
5867

5968
f = watched / "note.md"
6069
f.write_text("v1", encoding="utf-8")
61-
time.sleep(0.35)
6270

6371
f.write_text("v2", encoding="utf-8")
64-
time.sleep(0.35)
72+
assert _wait_until(
73+
lambda: bool(storage.upserts) and storage.upserts[-1]["text"] == "v2"
74+
)
6575

6676
daemon.stop_watch(watch_id)
6777

68-
assert len(storage.upserts) >= 2
69-
assert storage.upserts[0]["path"] == "note.md"
78+
assert storage.upserts
79+
assert storage.upserts[-1]["path"] == "note.md"
7080
assert storage.upserts[-1]["text"] == "v2"
7181

7282

@@ -89,10 +99,9 @@ def test_watch_folder_delete(tmp_path):
8999
)
90100

91101
watch_id = daemon.start_watch(config)
92-
time.sleep(0.2)
93102

94103
f.unlink()
95-
time.sleep(0.35)
104+
assert _wait_until(lambda: ("old.md", "default", False) in storage.deletes)
96105

97106
daemon.stop_watch(watch_id)
98107

@@ -145,9 +154,9 @@ def test_watch_folder_image_uses_logical_path(tmp_path):
145154

146155
image_path = watched / "diagram.png"
147156
image_path.write_bytes(b"fake image bytes")
148-
time.sleep(1.0)
157+
assert _wait_until(lambda: bool(storage.image_indexes))
149158
image_path.unlink()
150-
time.sleep(1.0)
159+
assert _wait_until(lambda: ("diagram.png", "default", False) in storage.deletes)
151160

152161
daemon.stop_watch(watch_id)
153162

@@ -175,9 +184,9 @@ def test_watch_folder_document_uses_logical_path_and_child_cleanup(tmp_path):
175184

176185
document_path = watched / "notes.docx"
177186
document_path.write_bytes(b"placeholder")
178-
time.sleep(1.0)
187+
assert _wait_until(lambda: bool(storage.document_indexes))
179188
document_path.unlink()
180-
time.sleep(1.0)
189+
assert _wait_until(lambda: ("notes.docx", "default", True) in storage.deletes)
181190

182191
daemon.stop_watch(watch_id)
183192

0 commit comments

Comments
 (0)