Skip to content

Commit 2b9ee5b

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
Resolving comments
1 parent 1968319 commit 2b9ee5b

6 files changed

Lines changed: 251 additions & 64 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ high_conf_facts = memory.get_memories(user_id="u1", memory_type="fact", min_conf
207207

208208
### Memory Reconciliation
209209

210-
`reconcile_memories(user_id, n=50)` collapses paraphrased duplicates and resolves semantic contradictions in a single LLM pass over the N most-recent active facts. Both outcomes soft-delete the loser with a `supersede_reason` of `"duplicate"` or `"contradiction"`. See [docs/concepts.md](docs/concepts.md#memory-reconciliation) for details.
210+
`reconcile_memories(user_id, n=50)` collapses paraphrased duplicates and resolves semantic contradictions in a single LLM pass over the N most-recent active facts. Both outcomes soft-delete the loser with a `supersede_reason` of `"duplicate"` or `"contradiction"`. See [Docs/concepts.md](Docs/concepts.md#memory-reconciliation) for details.
211+
212+
> **Cost note.** Each reconciliation makes one LLM call covering up to `n` facts (default 50, hard cap 500). With auto-trigger, this fires every `FACT_EXTRACTION_EVERY_N × DEDUP_EVERY_N` turns per user. The previous cosine-cluster pre-filter was removed deliberately — it could not catch semantic contradictions like "vegetarian" vs "ribeye steak" — so the LLM is now invoked whenever there are ≥ 2 active facts. Tune `DEDUP_EVERY_N` upward (or override `n` per call) if you need to bound LLM cost more tightly.
211213
212214
| New `MemoryRecord` field | Meaning |
213215
|---|---|

agent_memory_toolkit/_utils.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import hashlib
1010
import os
11+
import re
1112
import uuid
1213
from datetime import datetime, timezone
1314
from typing import Any, Optional
@@ -37,10 +38,30 @@
3738
# ---------------------------------------------------------------------------
3839

3940

41+
_WHITESPACE_RE = re.compile(r"\s+")
42+
43+
44+
def _normalize_for_hash(text: str) -> str:
45+
"""Lowercase + collapse whitespace for write-time exact-dedup.
46+
47+
Deliberately conservative: lowercase, strip, and collapse internal runs
48+
of whitespace to a single space. Punctuation and word order still matter.
49+
The point is to catch *identical* re-extractions cheaply — paraphrases
50+
are handled by the reconciliation LLM pass.
51+
"""
52+
return _WHITESPACE_RE.sub(" ", text.strip().lower())
53+
54+
4055
def compute_content_hash(content: str) -> str:
41-
"""SHA-256 of whitespace-normalized content. Does NOT lowercase."""
42-
normalized = " ".join(content.split())
43-
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
56+
"""SHA-256 of normalized text, truncated to 32 hex chars.
57+
58+
Normalization: lowercase + whitespace collapse (see ``_normalize_for_hash``).
59+
32 chars (128 bits) is plenty for collision avoidance within a single
60+
user's memory set and keeps the field compact in Cosmos documents.
61+
Used uniformly across facts, procedural, and episodic memories so the
62+
``content_hash`` field has a single, stable shape regardless of type.
63+
"""
64+
return hashlib.sha256(_normalize_for_hash(content).encode("utf-8")).hexdigest()[:32]
4465

4566

4667
# ---------------------------------------------------------------------------

agent_memory_toolkit/pipeline.py

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import json
1111
import logging
1212
import os
13-
import re
1413
from collections import defaultdict
1514
from datetime import datetime, timezone
1615
from typing import Any, Literal, Optional
@@ -21,31 +20,10 @@
2120
logger = logging.getLogger(__name__)
2221

2322

24-
# ---------------------------------------------------------------------------
25-
# Reconciliation hashing helpers
26-
# ---------------------------------------------------------------------------
27-
28-
_WHITESPACE_RE = re.compile(r"\s+")
29-
30-
31-
def _normalize_for_hash(text: str) -> str:
32-
"""Lowercase + collapse whitespace for write-time exact-dedup.
33-
34-
Deliberately conservative: lowercase, strip, and collapse internal runs
35-
of whitespace to a single space. Punctuation and word order still matter.
36-
The point is to catch *identical* re-extractions cheaply — paraphrases
37-
are handled by the reconciliation LLM pass.
38-
"""
39-
return _WHITESPACE_RE.sub(" ", text.strip().lower())
40-
41-
42-
def _content_hash(text: str) -> str:
43-
"""SHA-256 of the normalized text, truncated to 32 hex chars.
44-
45-
32 chars (128 bits) is plenty for collision avoidance within a single
46-
user's fact set and keeps the field compact in Cosmos documents.
47-
"""
48-
return hashlib.sha256(_normalize_for_hash(text).encode("utf-8")).hexdigest()[:32]
23+
def _max_or_none(values: Any) -> Optional[float]:
24+
"""Return max of numeric values, ignoring None / non-numeric. None if empty."""
25+
nums = [float(v) for v in values if isinstance(v, (int, float))]
26+
return max(nums) if nums else None
4927

5028

5129
class ProcessingPipeline:
@@ -478,7 +456,7 @@ def extract_memories(
478456
# content already exists are skipped before embedding/upsert.
479457
# UPDATEs go through unchanged - they explicitly target an old
480458
# record by id and need to write the supersession link.
481-
new_content_hash = _content_hash(text)
459+
new_content_hash = compute_content_hash(text)
482460
if action == "ADD" and new_content_hash in existing_hashes:
483461
logger.debug(
484462
"extract_memories: skipping exact-dup fact hash=%s user_id=%s thread_id=%s",
@@ -488,8 +466,7 @@ def extract_memories(
488466
)
489467
exact_dedup_skipped += 1
490468
continue
491-
content_hash = compute_content_hash(text)
492-
det_id = f"fact_{hashlib.sha256(f'{user_id}:{thread_id}:{content_hash}'.encode()).hexdigest()[:16]}"
469+
det_id = f"fact_{hashlib.sha256(f'{user_id}:{thread_id}:{new_content_hash}'.encode()).hexdigest()[:16]}"
493470

494471
topic_tags = [f"topic:{t}" for t in fact.get("tags", [])]
495472
tags = ["sys:fact", "sys:auto-extracted"] + topic_tags
@@ -1055,6 +1032,8 @@ def reconcile_memories(self, user_id: str, n: int = 50) -> dict[str, int]:
10551032
raise ValidationError("user_id is required")
10561033
if not isinstance(n, int) or isinstance(n, bool) or n < 1:
10571034
raise ValidationError(f"n must be a positive integer, got {n!r}")
1035+
if n > 500:
1036+
raise ValidationError(f"n must be <= 500 to bound prompt size and LLM cost, got {n}")
10581037

10591038
logger.info("reconcile_memories started user_id=%s n=%d", user_id, n)
10601039

@@ -1107,10 +1086,10 @@ def reconcile_memories(self, user_id: str, n: int = 50) -> dict[str, int]:
11071086

11081087
duplicate_groups = parsed.get("duplicate_groups", []) or []
11091088
contradicted_pairs = parsed.get("contradicted_pairs", []) or []
1110-
# ``kept_ids`` is informational only; the actual ``kept`` count is
1111-
# derived from len(facts) - merged - contradicted to match what the
1112-
# pipeline really did, not what the LLM said.
1113-
_ = parsed.get("kept_ids", []) or []
1089+
# ``kept_ids`` from the LLM is used below as a cross-check for
1090+
# accounting drift (hallucinated IDs, double-counting). The actual
1091+
# kept count is computed from facts minus consumed losers.
1092+
llm_kept_ids = list(parsed.get("kept_ids", []) or [])
11141093

11151094
facts_by_id: dict[str, dict[str, Any]] = {f["id"]: f for f in facts}
11161095

@@ -1140,6 +1119,11 @@ def reconcile_memories(self, user_id: str, n: int = 50) -> dict[str, int]:
11401119
)
11411120
continue
11421121

1122+
# Sort source_docs by Cosmos _ts DESC so the merged record's
1123+
# partition (thread_id) is picked deterministically from the
1124+
# newest source — independent of the LLM's source_ids order.
1125+
source_docs.sort(key=lambda d: d.get("_ts", 0), reverse=True)
1126+
11431127
# Union tags across all source docs (preserve order, dedupe).
11441128
merged_tags: list[str] = []
11451129
seen_tags: set[str] = set()
@@ -1160,26 +1144,38 @@ def reconcile_memories(self, user_id: str, n: int = 50) -> dict[str, int]:
11601144
seen_smi.add(smi)
11611145
merged_source_memory_ids.append(smi)
11621146

1163-
# Facts come back newest-first (ORDER BY c._ts DESC), so the
1164-
# first source doc is the most recent — pick its thread_id.
1147+
# Newest source's thread_id wins (after _ts-desc sort above).
11651148
recent_thread_id = source_docs[0].get("thread_id", "")
11661149

1167-
confidence_val = group.get("confidence")
1168-
salience_val = group.get("salience")
1150+
# If LLM omitted confidence/salience, fall back to max across
1151+
# the source docs so merged facts don't silently drop below
1152+
# min_confidence / min_salience filters.
1153+
llm_conf = group.get("confidence")
1154+
confidence_val = (
1155+
float(llm_conf)
1156+
if isinstance(llm_conf, (int, float)) and llm_conf > 0
1157+
else _max_or_none(src.get("confidence") for src in source_docs)
1158+
)
1159+
llm_sal = group.get("salience")
1160+
salience_val = (
1161+
float(llm_sal)
1162+
if isinstance(llm_sal, (int, float)) and llm_sal > 0
1163+
else _max_or_none(src.get("salience") for src in source_docs)
1164+
)
11691165

11701166
try:
11711167
merged_record = MemoryRecord(
11721168
user_id=user_id,
11731169
role="system",
11741170
memory_type=MemoryType.fact,
11751171
content=merged_content,
1176-
thread_id=recent_thread_id or "__reconciled__",
1172+
thread_id=recent_thread_id or f"__reconciled__:{user_id}",
11771173
confidence=confidence_val,
11781174
salience=salience_val,
11791175
supersedes_ids=list(source_ids),
11801176
source_memory_ids=merged_source_memory_ids,
11811177
tags=merged_tags,
1182-
content_hash=_content_hash(merged_content),
1178+
content_hash=compute_content_hash(merged_content),
11831179
)
11841180
except Exception:
11851181
logger.exception(
@@ -1274,7 +1270,30 @@ def reconcile_memories(self, user_id: str, n: int = 50) -> dict[str, int]:
12741270
if self._mark_superseded(loser_doc, resolved_winner, reason="contradiction"):
12751271
contradicted += 1
12761272

1277-
kept = max(0, len(facts) - merged - contradicted)
1273+
# Count kept directly: facts the pipeline did NOT consume as a
1274+
# duplicate-source or contradiction-loser. Cross-check against the
1275+
# LLM's kept_ids and warn on mismatch (catches hallucinated IDs
1276+
# and double-counting).
1277+
consumed_ids: set[str] = set()
1278+
for group in duplicate_groups:
1279+
for sid in group.get("source_ids") or []:
1280+
if sid in facts_by_id:
1281+
consumed_ids.add(sid)
1282+
for pair in contradicted_pairs:
1283+
lid = pair.get("loser_id")
1284+
if lid and lid in facts_by_id:
1285+
consumed_ids.add(lid)
1286+
kept_actual = {fid for fid in facts_by_id.keys() if fid not in consumed_ids}
1287+
kept = len(kept_actual)
1288+
llm_kept_set = {kid for kid in llm_kept_ids if kid in facts_by_id}
1289+
if llm_kept_set != kept_actual:
1290+
logger.warning(
1291+
"reconcile_memories: kept_ids mismatch (llm=%d valid=%d, pipeline=%d). "
1292+
"Possible LLM hallucinated IDs or double-counted facts.",
1293+
len(llm_kept_ids),
1294+
len(llm_kept_set),
1295+
kept,
1296+
)
12781297
result = {"kept": kept, "merged": merged, "contradicted": contradicted}
12791298
logger.info("reconcile_memories completed: %s", result)
12801299
return result

agent_memory_toolkit/prompts/dedup.prompty

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,17 @@ You must output ONLY valid JSON matching this exact schema. No preamble, no expl
100100
```json
101101
{
102102
"duplicate_groups": [
103-
{"merged_content": "string", "source_ids": ["id1", "id2"], "confidence": 0.0, "salience": 0.0}
103+
{"merged_content": "<string>", "source_ids": ["<id1>", "<id2>"], "confidence": "<max source confidence, float 0..1>", "salience": "<max source salience, float 0..1>"}
104104
],
105105
"contradicted_pairs": [
106-
{"winner_id": "string", "loser_id": "string", "reason": "string"}
106+
{"winner_id": "<id>", "loser_id": "<id>", "reason": "<string>"}
107107
],
108-
"kept_ids": ["id3", "id4"]
108+
"kept_ids": ["<id>", "<id>"]
109109
}
110110
```
111111

112+
The `confidence` and `salience` values **must** be real numbers between 0 and 1 in the actual output — the angle-bracket text above is a placeholder for the schema. Compute them as the maximum across source facts in the group. Do **not** echo the literal placeholder text. If you cannot determine confidence or salience, omit the field entirely (the pipeline will fall back to `max(source.*)` from the source records).
113+
112114
If a bucket is empty, emit it as an empty array (`[]`) rather than omitting the key.
113115

114116
## Worked Example

0 commit comments

Comments
 (0)