Skip to content

Commit 1968319

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
Adding conflict resolution
1 parent 763ae6e commit 1968319

33 files changed

Lines changed: 1341 additions & 627 deletions

Docs/concepts.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,31 @@ 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+
---
141+
117142
## Automatic Processing (Change Feed)
118143

119144
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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,25 @@ 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_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+
| New `MemoryRecord` field | Meaning |
213+
|---|---|
214+
| `content_hash` | SHA-256 of normalized content; enables write-time exact-dedup short-circuit |
215+
| `supersede_reason` | `"duplicate"` or `"contradiction"` (None for live records) |
216+
| `superseded_at` | ISO timestamp when the supersede happened (None for live records) |
217+
| `superseded_by` | Id of the record that replaced this one (existing field) |
218+
208219
### Auto-trigger (per-turn extraction)
209220

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

212223
| Env var | Default | Step that fires | Async behavior |
213224
|---|---|---|---|
214225
| `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` |
226+
| `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` |
216227
| `THREAD_SUMMARY_EVERY_N` | `10` | `process_thread_summary` | scheduled via `asyncio.create_task` |
217228
| `USER_SUMMARY_EVERY_N` | `20` | `process_user_summary` | scheduled via `asyncio.create_task` |
218229

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

Lines changed: 7 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,19 @@ 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: int = 50,
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.
17311730
17321731
Pipeline calls are dispatched to a worker thread via
17331732
:func:`asyncio.to_thread` to avoid blocking the event loop.
17341733
"""
17351734
await self._require_cosmos()
17361735
self._require_pipeline()
1737-
return await asyncio.to_thread(self._pipeline.deduplicate_facts, user_id, similarity_threshold, max_facts)
1736+
return await asyncio.to_thread(self._pipeline.reconcile_memories, user_id, n)
17381737

17391738
# ------------------------------------------------------------------
17401739
# Processor delegation

agent_memory_toolkit/aio/processors/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,11 @@ 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,
56+
n: int = 50,
5657
) -> int: ...
5758

5859
async def generate_user_summary(

agent_memory_toolkit/aio/processors/durable.py

Lines changed: 7 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,12 @@ 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, n: int = 50) -> int:
77+
logger.debug(
78+
"AsyncDurableFunctionProcessor.process_reconcile no-op user_id=%s n=%d",
79+
user_id,
80+
n,
81+
)
7882
return 0
7983

8084
async def generate_user_summary(

agent_memory_toolkit/aio/processors/inprocess.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ async def process_thread(
6666

6767
thread_summary = await asyncio.to_thread(self._pipeline.generate_thread_summary, user_id, thread_id)
6868
extracted = await asyncio.to_thread(self._pipeline.extract_memories, user_id, thread_id)
69-
dedup = await asyncio.to_thread(self._pipeline.deduplicate_facts, user_id)
69+
reconciled = await asyncio.to_thread(self._pipeline.reconcile_memories, user_id)
7070

71-
deduped_count = self._extract_dedup_count(dedup)
71+
deduped_count = self._extract_reconcile_count(reconciled)
7272

7373
extracted_counts: dict[str, int] = (
7474
{k: v for k, v in extracted.items() if isinstance(v, int)} if isinstance(extracted, dict) else {}
@@ -78,7 +78,7 @@ async def process_thread(
7878
return ProcessThreadResult(
7979
thread_summary=thread_summary if isinstance(thread_summary, dict) else None,
8080
extracted_counts=extracted_counts,
81-
deduplicated_count=deduped_count,
81+
reconciled_count=deduped_count,
8282
elapsed_ms=elapsed_ms,
8383
)
8484

@@ -109,24 +109,24 @@ async def process_user_summary(
109109
summary = await asyncio.to_thread(self._pipeline.generate_user_summary, user_id, thread_ids)
110110
return UserSummaryResult(summary=summary if isinstance(summary, dict) else None)
111111

112-
async def process_dedup(self, *, user_id: str) -> int:
113-
dedup = await asyncio.to_thread(self._pipeline.deduplicate_facts, user_id)
114-
return self._extract_dedup_count(dedup)
112+
async def process_reconcile(self, *, user_id: str, n: int = 50) -> int:
113+
reconciled = await asyncio.to_thread(self._pipeline.reconcile_memories, user_id, n)
114+
return self._extract_reconcile_count(reconciled)
115115

116116
@staticmethod
117-
def _extract_dedup_count(dedup: Any) -> int:
118-
"""Sum the merged + superseded facts from a ``deduplicate_facts`` result.
117+
def _extract_reconcile_count(reconciled: Any) -> int:
118+
"""Sum ``merged + contradicted`` from a ``reconcile_memories`` result.
119119
120-
``ProcessingPipeline.deduplicate_facts`` returns a dict with
121-
``{"kept", "merged", "superseded"}`` — both ``merged`` and
122-
``superseded`` represent facts that were consolidated, so they
123-
contribute to the dedup-count metric.
120+
``ProcessingPipeline.reconcile_memories`` returns a dict with
121+
``{"kept", "merged", "contradicted"}`` — both ``merged`` and
122+
``contradicted`` represent facts that were consolidated or retired,
123+
so they contribute to the dedup-count metric.
124124
"""
125-
if not isinstance(dedup, dict):
125+
if not isinstance(reconciled, dict):
126126
return 0
127-
merged = dedup.get("merged", 0) if isinstance(dedup.get("merged"), int) else 0
128-
superseded = dedup.get("superseded", 0) if isinstance(dedup.get("superseded"), int) else 0
129-
return merged + superseded
127+
merged = reconciled.get("merged", 0) if isinstance(reconciled.get("merged"), int) else 0
128+
contradicted = reconciled.get("contradicted", 0) if isinstance(reconciled.get("contradicted"), int) else 0
129+
return merged + contradicted
130130

131131
async def generate_user_summary(
132132
self,

0 commit comments

Comments
 (0)