Skip to content

Commit 4b8c6be

Browse files
aayush3011Aayush Kataria
andauthored
Adding conflict resolution (#10)
* Adding conflict resolution * Resolving comments * Resolving comments * Resolving comments * Resolving comments * Resolving comments * Resolving comments --------- Co-authored-by: Aayush Kataria <aayushkataria@Aayushs-MacBook-Pro-2.local>
1 parent 763ae6e commit 4b8c6be

39 files changed

Lines changed: 3066 additions & 665 deletions

Docs/concepts.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,33 @@ Prompts for summarization and fact extraction live in `azure_functions/prompts/`
114114

115115
---
116116

117+
## Memory Reconciliation
118+
119+
The `reconcile_memories(user_id, n=50)` pipeline step reads up to N most-recent active facts for a user and asks the LLM to identify two orthogonal outcomes in one pass:
120+
121+
- **Duplicates** — two or more facts that restate the same claim in different words. Resolution: collapse into one merged fact; the originals are soft-deleted with `supersede_reason="duplicate"` and `superseded_by` set to the merged fact's id.
122+
- **Contradictions** — two facts that assert opposing claims about the same subject. Resolution: keep the winner (more recent first, higher confidence as tiebreaker), soft-delete the loser with `supersede_reason="contradiction"` and `superseded_by` set to the winner.
123+
124+
### Why one pass
125+
126+
Detecting contradictions semantically requires the LLM to see the candidate pool as a whole — paraphrased ("user prefers aisle seats") and contradictory ("user is vegetarian" vs "user loves steak") facts often have very different embedding vectors and would never co-occur in any cosine cluster. Putting all N candidates into one prompt lets the LLM do the semantic reasoning across both axes simultaneously. The pipeline returns `{"kept": int, "merged": int, "contradicted": int}`.
127+
128+
### Loser preservation
129+
130+
Soft-deleted facts stay in the container with their `supersede_reason`, `superseded_at`, and `superseded_by` fields populated. Default reads (`get_memories`, `search_cosmos`) filter them out via `superseded_by IS NULL`. To inspect the audit trail (e.g. "show everything that ever applied to this user"), opt out of the filter at the query level.
131+
132+
### Write-time exact dedup
133+
134+
Each fact written by `extract_memories` carries a `content_hash` (SHA-256 of normalized content, truncated to 32 hex chars; lowercase, whitespace-collapsed). Before upserting a freshly-extracted fact, the pipeline checks the hash against existing active facts and short-circuits if a match exists, incrementing the `exact_dedup_skipped` metric. This catches identical re-extractions cheaply without an LLM call.
135+
136+
### Tunable
137+
138+
`DEDUP_EVERY_N` (default 5) controls how often `reconcile_memories` runs in the auto-trigger path. Set to `0` to disable. The candidate cap `n` (default 50) is tunable per call; larger values give the LLM a wider view at higher token cost.
139+
140+
> **Indexing note.** The reconcile pool query orders by `created_at` (matching the prompt's "more recent first" tiebreaker). Cosmos's default indexing policy includes every property, so this works out of the box. If you customize the indexing policy to reduce write RU, ensure `/created_at/?` remains indexed or the query will fail with a 400 (`Order-by over a non-indexed path`).
141+
142+
---
143+
117144
## Automatic Processing (Change Feed)
118145

119146
In addition to on-demand processing via the SDK, the toolkit includes a Cosmos DB change feed trigger that **automatically** starts processing orchestrations when enough new turns have been written.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,28 @@ results = memory.search_cosmos("user preferences", user_id="u1", min_confidence=
205205
high_conf_facts = memory.get_memories(user_id="u1", memory_type="fact", min_confidence=0.7)
206206
```
207207

208+
### Memory Reconciliation
209+
210+
`reconcile(user_id, n=50)` (on the public client; underlying pipeline method is `ProcessingPipeline.reconcile_memories`) 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, with `n` taken from `DEDUP_POOL_SIZE`. 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. To bound LLM cost more tightly: raise `DEDUP_EVERY_N` (lower frequency — reconcile fires every Nth extraction, so a *higher* N means *less often*), lower `DEDUP_POOL_SIZE` (smaller per-call pool), or override `n` per call when invoking `reconcile()` directly.
213+
214+
| New `MemoryRecord` field | Meaning |
215+
|---|---|
216+
| `content_hash` | SHA-256 of normalized content; enables write-time exact-dedup short-circuit |
217+
| `supersede_reason` | `"duplicate"` or `"contradiction"` (None for live records) |
218+
| `superseded_at` | ISO timestamp when the supersede happened (None for live records) |
219+
| `superseded_by` | Id of the record that replaced this one (existing field) |
220+
208221
### Auto-trigger (per-turn extraction)
209222

210223
By default, the **InProcess processor** runs each pipeline step independently as its own threshold trips inside `push_to_cosmos()`:
211224

212225
| Env var | Default | Step that fires | Async behavior |
213226
|---|---|---|---|
214227
| `FACT_EXTRACTION_EVERY_N` | `1` (every turn) | `process_extract_memories` | scheduled via `asyncio.create_task` |
215-
| `DEDUP_EVERY_N` | `5` | `process_dedup` (fires every Nth extract → effectively every `FACT_EXTRACTION_EVERY_N × DEDUP_EVERY_N` turns) | scheduled via `asyncio.create_task` |
228+
| `DEDUP_EVERY_N` | `5` | `process_reconcile` (fires every Nth extract → effectively every `FACT_EXTRACTION_EVERY_N × DEDUP_EVERY_N` turns) | scheduled via `asyncio.create_task` |
229+
| `DEDUP_POOL_SIZE` | `50` | pool size (`n`) passed to `process_reconcile` from the auto-trigger; hard-capped at `500` | n/a (per-call) |
216230
| `THREAD_SUMMARY_EVERY_N` | `10` | `process_thread_summary` | scheduled via `asyncio.create_task` |
217231
| `USER_SUMMARY_EVERY_N` | `20` | `process_user_summary` | scheduled via `asyncio.create_task` |
218232

Samples/Demo.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"5. **Memory Extraction** – `extract_memories()` (facts + episodic + procedural)\n",
1717
"6. **User Summary** – `generate_user_summary()` (cross-thread profile)\n",
1818
"7. **Vector / hybrid search** – `search_cosmos()`\n",
19-
"8. **Tagging, salience & deduplication** – tag mutation, salience filter, `deduplicate_facts()`\n",
19+
"8. **Tagging, salience & deduplication** – tag mutation, salience filter, `reconcile()`\n",
2020
"9. **Automatic processing (Change Feed)** – optional Azure Function for background processing\n",
2121
"\n",
2222
"> 💡 **Tip:** the synchronous `CosmosMemoryClient` accepts an optional `processor=` kwarg (defaults to `InProcessProcessor`). Pass `DurableFunctionProcessor()` to delegate summarization to the sibling Azure Function app — see `Samples/scenario_remote_processor.py`.\n"
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Scenario: Memory reconciliation — duplicate merging and contradiction resolution.
2+
3+
Demonstrates how `reconcile_memories` collapses paraphrased facts and resolves
4+
semantic contradictions in a single LLM pass:
5+
6+
1. Seed paraphrased facts about a user (will be merged).
7+
2. Seed contradicting facts about the same subject (one will win, one will lose).
8+
3. Run reconcile() and print the {kept, merged, contradicted} stats.
9+
4. Show the live state — paraphrased duplicates collapsed, contradiction loser hidden.
10+
5. Show the audit trail (include_superseded=True) — soft-deleted records carry
11+
supersede_reason, superseded_at, and superseded_by pointing at the survivor.
12+
13+
Requirements:
14+
- Azure Cosmos DB account with vector-search enabled.
15+
- Azure AI Foundry endpoint with chat + embeddings deployments.
16+
- Environment variables (.env supported via python-dotenv):
17+
COSMOS_DB_ENDPOINT
18+
COSMOS_DB_KEY (optional, falls back to DefaultAzureCredential)
19+
AI_FOUNDRY_ENDPOINT
20+
AI_FOUNDRY_EMBEDDING_DEPLOYMENT_NAME
21+
AI_FOUNDRY_CHAT_DEPLOYMENT_NAME
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import os
27+
import sys
28+
import uuid
29+
30+
from dotenv import load_dotenv
31+
32+
from agent_memory_toolkit import CosmosMemoryClient
33+
34+
load_dotenv()
35+
36+
# ---------------------------------------------------------------------------
37+
# Helpers
38+
# ---------------------------------------------------------------------------
39+
40+
DIVIDER = "-" * 60
41+
42+
43+
def banner(title: str) -> None:
44+
"""Print a section banner."""
45+
print(f"\n{DIVIDER}")
46+
print(f" {title}")
47+
print(DIVIDER)
48+
49+
50+
def print_facts(facts: list[dict]) -> None:
51+
"""Pretty-print fact records (id + content)."""
52+
if not facts:
53+
print(" (none)")
54+
return
55+
for f in facts:
56+
print(f" id={f['id']} content: {f.get('content', '')}")
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Main
61+
# ---------------------------------------------------------------------------
62+
63+
PARAPHRASED_FACTS = [
64+
"User prefers aisle seats on flights.",
65+
"User likes aisle seats when flying.",
66+
"User always picks aisle when booking flights.",
67+
]
68+
69+
CONTRADICTING_FACTS = [
70+
"User is strictly vegetarian and avoids all meat.",
71+
"User loves a good ribeye steak.",
72+
]
73+
74+
75+
def main() -> None:
76+
required = ["COSMOS_DB_ENDPOINT", "AI_FOUNDRY_ENDPOINT"]
77+
missing = [v for v in required if not os.environ.get(v)]
78+
if missing:
79+
print(f"ERROR: missing env vars: {', '.join(missing)}")
80+
sys.exit(1)
81+
82+
mem = CosmosMemoryClient(
83+
cosmos_endpoint=os.environ["COSMOS_DB_ENDPOINT"],
84+
cosmos_key=os.environ.get("COSMOS_DB_KEY") or None,
85+
cosmos_database=os.environ.get("COSMOS_DB_DATABASE", "ai_memory"),
86+
cosmos_container=os.environ.get("COSMOS_DB_CONTAINER", "memories"),
87+
ai_foundry_endpoint=os.environ["AI_FOUNDRY_ENDPOINT"],
88+
ai_foundry_api_key=os.environ.get("AI_FOUNDRY_API_KEY") or None,
89+
embedding_deployment_name=os.environ.get("AI_FOUNDRY_EMBEDDING_DEPLOYMENT_NAME", "text-embedding-3-large"),
90+
chat_deployment_name=os.environ.get("AI_FOUNDRY_CHAT_DEPLOYMENT_NAME", "gpt-4o-mini"),
91+
use_default_credential=True,
92+
)
93+
print("Connected to Cosmos DB.")
94+
95+
unique_user_id = f"reconcile-demo-{uuid.uuid4().hex[:8]}"
96+
unique_thread_id = f"reconcile-demo-thread-{uuid.uuid4().hex[:8]}"
97+
print(f"User ID: {unique_user_id}")
98+
print(f"Thread ID: {unique_thread_id}")
99+
100+
try:
101+
banner("1. Seeding paraphrased facts (duplicates)")
102+
for content in PARAPHRASED_FACTS:
103+
mem.add_cosmos(
104+
user_id=unique_user_id,
105+
role="user",
106+
content=content,
107+
memory_type="fact",
108+
thread_id=unique_thread_id,
109+
salience=0.7,
110+
)
111+
print(f" + {content}")
112+
113+
banner("2. Seeding contradicting facts")
114+
for content in CONTRADICTING_FACTS:
115+
mem.add_cosmos(
116+
user_id=unique_user_id,
117+
role="user",
118+
content=content,
119+
memory_type="fact",
120+
thread_id=unique_thread_id,
121+
salience=0.7,
122+
)
123+
print(f" + {content}")
124+
125+
banner("3. Active facts before reconcile")
126+
before = mem.get_memories(user_id=unique_user_id, memory_type="fact")
127+
print_facts(before)
128+
129+
banner("4. Running reconcile_memories")
130+
stats = mem.reconcile(user_id=unique_user_id)
131+
print(f" stats: {dict(stats)}")
132+
133+
banner("5. Active facts after reconcile (duplicates merged, contradictions resolved)")
134+
after = mem.get_memories(user_id=unique_user_id, memory_type="fact")
135+
print_facts(after)
136+
137+
banner("6. Audit trail (soft-deleted records)")
138+
all_facts = mem.get_memories(
139+
user_id=unique_user_id,
140+
memory_type="fact",
141+
include_superseded=True,
142+
)
143+
soft_deleted = [f for f in all_facts if f.get("supersede_reason")]
144+
if not soft_deleted:
145+
print(" (no soft-deleted records)")
146+
for f in soft_deleted:
147+
print(
148+
f" id={f['id']} reason={f.get('supersede_reason')} "
149+
f"superseded_at={f.get('superseded_at')} "
150+
f"superseded_by={f.get('superseded_by')}"
151+
)
152+
print(f" content: {f.get('content', '')}")
153+
154+
finally:
155+
banner("7. Cleanup")
156+
try:
157+
all_records = mem.get_memories(
158+
user_id=unique_user_id,
159+
include_superseded=True,
160+
)
161+
deleted = 0
162+
for rec in all_records:
163+
try:
164+
mem.delete_cosmos(
165+
memory_id=rec["id"],
166+
thread_id=rec.get("thread_id", unique_thread_id),
167+
user_id=unique_user_id,
168+
)
169+
deleted += 1
170+
except Exception as exc: # pragma: no cover - best effort cleanup
171+
print(f" WARN: failed to delete {rec.get('id')}: {exc}")
172+
print(f" Deleted {deleted} record(s) for user {unique_user_id}")
173+
except Exception as exc: # pragma: no cover - best effort cleanup
174+
print(f" WARN: cleanup failed: {exc}")
175+
176+
177+
if __name__ == "__main__":
178+
main()

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/aio/cosmos_memory_client.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,10 +1607,10 @@ async def _run_auto_trigger_steps(
16071607

16081608
if fire_dedup:
16091609
try:
1610-
await processor.process_dedup(user_id=user_id)
1610+
await processor.process_reconcile(user_id=user_id)
16111611
except Exception as exc:
16121612
logger.warning(
1613-
"Auto-trigger process_dedup failed for %s: %s",
1613+
"Auto-trigger process_reconcile failed for %s: %s",
16141614
user_id,
16151615
exc,
16161616
)
@@ -1619,7 +1619,7 @@ async def _run_auto_trigger_steps(
16191619
thread_counter_id(user_id, thread_id),
16201620
user_id,
16211621
thread_id,
1622-
f"process_dedup: {exc!r}",
1622+
f"process_reconcile: {exc!r}",
16231623
)
16241624

16251625
if fire_summary:
@@ -1721,20 +1721,27 @@ async def generate_user_summary(
17211721
self._require_pipeline()
17221722
return await asyncio.to_thread(self._pipeline.generate_user_summary, user_id, thread_ids, recent_k)
17231723

1724-
async def deduplicate_facts(
1724+
async def reconcile(
17251725
self,
17261726
user_id: str,
1727-
similarity_threshold: float = 0.9,
1728-
max_facts: int = 200,
1727+
n: Optional[int] = None,
17291728
) -> dict[str, int]:
1730-
"""Run LLM-based dedup on user's facts.
1729+
"""Reconcile a user's facts via the contradiction-aware dedup pass.
1730+
1731+
``n`` defaults to the ``DEDUP_POOL_SIZE`` env var (via
1732+
:func:`agent_memory_toolkit.thresholds.get_dedup_pool_size`) so
1733+
explicit calls honour the same operator knob the auto-trigger
1734+
path uses. Pass an integer to override.
17311735
17321736
Pipeline calls are dispatched to a worker thread via
17331737
:func:`asyncio.to_thread` to avoid blocking the event loop.
17341738
"""
1739+
from ..thresholds import get_dedup_pool_size
1740+
17351741
await self._require_cosmos()
17361742
self._require_pipeline()
1737-
return await asyncio.to_thread(self._pipeline.deduplicate_facts, user_id, similarity_threshold, max_facts)
1743+
pool = n if n is not None else get_dedup_pool_size()
1744+
return await asyncio.to_thread(self._pipeline.reconcile_memories, user_id, pool)
17381745

17391746
# ------------------------------------------------------------------
17401747
# Processor delegation

agent_memory_toolkit/aio/processors/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ async def process_user_summary(
4949
thread_ids: Optional[list[str]] = None,
5050
) -> UserSummaryResult: ...
5151

52-
async def process_dedup(
52+
async def process_reconcile(
5353
self,
5454
*,
5555
user_id: str,

agent_memory_toolkit/aio/processors/durable.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ async def process_thread(
3333
thread_id,
3434
len(turns) if turns else 0,
3535
)
36-
return ProcessThreadResult(thread_summary=None, extracted_counts={}, deduplicated_count=0, elapsed_ms=0)
36+
return ProcessThreadResult(thread_summary=None, extracted_counts={}, reconciled_count=0, elapsed_ms=0)
3737

3838
async def process_extract_memories(
3939
self,
@@ -73,8 +73,11 @@ async def process_user_summary(
7373
)
7474
return UserSummaryResult(summary=None)
7575

76-
async def process_dedup(self, *, user_id: str) -> int:
77-
logger.debug("AsyncDurableFunctionProcessor.process_dedup no-op user_id=%s", user_id)
76+
async def process_reconcile(self, *, user_id: str) -> int:
77+
logger.debug(
78+
"AsyncDurableFunctionProcessor.process_reconcile no-op user_id=%s",
79+
user_id,
80+
)
7881
return 0
7982

8083
async def generate_user_summary(

0 commit comments

Comments
 (0)