Skip to content

Commit a062199

Browse files
authored
refactor(memory): redesign extraction/dedup flow and add conflict-aware delete handling (#225)
- refactor session memory pipeline across extractor, deduplicator, and compressor - introduce richer dedup decisions and per-memory actions (skip/create/none, merge/delete) - improve dedup prompt contracts and memory merge/extraction templates - update storage schema/validation/vector backend to support new memory behavior - add examples/memory_demo.py and refresh session concept docs for the new flow
1 parent 680d94c commit a062199

File tree

11 files changed

+635
-115
lines changed

11 files changed

+635
-115
lines changed

docs/en/concepts/08-session.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,19 +136,20 @@ Messages → LLM Extract → Candidate Memories
136136
137137
Vector Pre-filter → Find Similar Memories
138138
139-
LLM Dedup Decision → CREATE/UPDATE/MERGE/SKIP
139+
LLM Dedup Decision → candidate(skip/create/none) + item(merge/delete)
140140
141141
Write to AGFS → Vectorize
142142
```
143143

144144
### Dedup Decisions
145145

146-
| Decision | Description |
147-
|----------|-------------|
148-
| `CREATE` | New memory, create directly |
149-
| `UPDATE` | Update existing memory |
150-
| `MERGE` | Merge multiple memories |
151-
| `SKIP` | Duplicate, skip |
146+
| Level | Decision | Description |
147+
|------|----------|-------------|
148+
| Candidate | `skip` | Candidate is duplicate, skip and do nothing |
149+
| Candidate | `create` | Create candidate memory (optionally delete conflicting existing memories first) |
150+
| Candidate | `none` | Do not create candidate; resolve existing memories by item decisions |
151+
| Per-existing item | `merge` | Merge candidate content into specified existing memory |
152+
| Per-existing item | `delete` | Delete specified conflicting existing memory |
152153

153154
## Storage Structure
154155

examples/memory_demo.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""OpenViking memory dedup demo with REAL LLM decisions (no fake/mock).
2+
3+
This demo focuses on the user preference scenario:
4+
1) "I like apples." -> expected create
5+
2) "I like strawberries." -> expected create
6+
3) "I like Fuji apples." -> expected merge/none with apple memory
7+
4) "I do not like fruits anymore." -> expected delete old positive fruit preferences
8+
5) repeat negative preference -> expected skip/none
9+
10+
The script prints commit and find results after each round so you can inspect
11+
whether memory handling is reasonable.
12+
13+
Usage:
14+
export OPENVIKING_CONFIG_FILE=ov.conf
15+
python examples/memory_dedup_cases_demo.py
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import argparse
21+
import os
22+
import shutil
23+
from dataclasses import dataclass
24+
from pathlib import Path
25+
from typing import Any, Iterable, List
26+
27+
from openviking.message.part import TextPart
28+
from openviking.sync_client import SyncOpenViking
29+
30+
31+
@dataclass
32+
class RoundCase:
33+
title: str
34+
user_text: str
35+
expected: str
36+
37+
38+
def _print_section(title: str, body: str = "") -> None:
39+
print("\n" + "=" * 80)
40+
print(title)
41+
if body:
42+
print("-" * 80)
43+
print(body)
44+
45+
46+
def _safe_list(items: Iterable[Any]) -> list:
47+
try:
48+
return list(items)
49+
except Exception:
50+
return []
51+
52+
53+
def _format_find_result(result: Any, max_items: int = 6) -> str:
54+
memories = _safe_list(getattr(result, "memories", []))
55+
if not memories:
56+
return "(no memory hit)"
57+
58+
lines: List[str] = []
59+
for i, mem in enumerate(memories[:max_items], 1):
60+
score = getattr(mem, "score", None)
61+
score_s = "n/a" if score is None else f"{float(score):.4f}"
62+
abstract = getattr(mem, "abstract", "") or ""
63+
uri = getattr(mem, "uri", "")
64+
lines.append(f"{i}. score={score_s} | {abstract} | {uri}")
65+
return "\n".join(lines)
66+
67+
68+
def main() -> int:
69+
parser = argparse.ArgumentParser(description="OpenViking real-LLM dedup cases demo")
70+
parser.add_argument(
71+
"--path",
72+
default="./ov_data_dedup_cases_demo",
73+
help="Fixed demo storage path. This script clears it at startup.",
74+
)
75+
parser.add_argument(
76+
"--wait-timeout",
77+
type=float,
78+
default=60.0,
79+
help="Queue wait timeout in seconds.",
80+
)
81+
args = parser.parse_args()
82+
83+
if not os.environ.get("OPENVIKING_CONFIG_FILE"):
84+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
85+
cfg = os.path.join(repo_root, "ov.conf")
86+
if os.path.exists(cfg):
87+
os.environ["OPENVIKING_CONFIG_FILE"] = cfg
88+
89+
data_path = Path(args.path)
90+
if data_path.exists():
91+
shutil.rmtree(data_path)
92+
data_path.mkdir(parents=True, exist_ok=True)
93+
94+
client = SyncOpenViking(path=str(data_path))
95+
client.initialize()
96+
97+
try:
98+
client.is_healthy()
99+
sess_info = client.create_session()
100+
session_id = sess_info["session_id"]
101+
sess = client.session(session_id)
102+
103+
rounds = [
104+
RoundCase(
105+
title="Round 1",
106+
user_text=(
107+
"我是一名程序员。"
108+
"我爱吃苹果。"
109+
"我爱吃草莓。"
110+
"我每天早上7点起床。"
111+
"我通勤主要骑共享单车。"
112+
"我习惯在周末整理书桌。"
113+
"我最常用的云盘是Dropbox。"
114+
"我对坚果过敏,尤其是腰果。"
115+
"我最近在学西班牙语。"
116+
"我喜欢在雨天听爵士乐。"
117+
"我的常用笔记软件是Obsidian。"
118+
"我每周三晚上会去游泳。"
119+
"我偏好27英寸的外接显示器。"
120+
),
121+
expected="Expected dedup: create multiple unrelated memories",
122+
),
123+
RoundCase(
124+
title="Round 2",
125+
user_text="我爱吃红富士苹果。我是外卖员。",
126+
expected="Expected dedup: none+merge (细化苹果偏好)",
127+
),
128+
RoundCase(
129+
title="Round 3",
130+
user_text="我不爱吃水果了,把之前关于喜欢水果的偏好作废。",
131+
expected="Expected dedup: delete old positive fruit preferences",
132+
),
133+
]
134+
135+
queries = [
136+
"我喜欢吃什么?",
137+
"我是做什么工作的?",
138+
]
139+
140+
for r in rounds:
141+
sess.add_message("user", parts=[TextPart(text=r.user_text)])
142+
sess.add_message("assistant", parts=[TextPart(text="收到。")])
143+
commit_result = sess.commit()
144+
145+
_print_section(
146+
f"{r.title} commit",
147+
body=(f"user={r.user_text}\n{r.expected}\ncommit={commit_result}"),
148+
)
149+
150+
try:
151+
client.wait_processed(timeout=args.wait_timeout)
152+
except Exception:
153+
pass
154+
155+
for q in queries:
156+
try:
157+
result = client.find(q, target_uri="viking://user/memories", limit=8)
158+
_print_section(f"{r.title} find: {q}", body=_format_find_result(result))
159+
except Exception as e:
160+
_print_section(f"{r.title} find: {q} (failed)", body=str(e))
161+
162+
_print_section(
163+
"Done",
164+
body=(
165+
"Check whether the later rounds are dominated by negative fruit preference.\n"
166+
"If old positive fruit preferences disappear or stop ranking high, delete likely worked."
167+
),
168+
)
169+
finally:
170+
try:
171+
client.close()
172+
except Exception:
173+
pass
174+
175+
return 0
176+
177+
178+
if __name__ == "__main__":
179+
raise SystemExit(main())

openviking/prompts/templates/compression/dedup_decision.yaml

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
metadata:
22
id: "compression.dedup_decision"
33
name: "Memory Deduplication Decision"
4-
description: "Decide whether new memory should CREATE/MERGE/SKIP"
5-
version: "2.0.0"
4+
description: "Decide candidate action (skip/create/none) and per-memory actions (merge/delete)"
5+
version: "3.3.1"
66
language: "en"
77
category: "compression"
88

@@ -25,25 +25,79 @@ variables:
2525
required: true
2626

2727
template: |
28-
Determine how to handle this candidate memory.
28+
You are deciding how to update long-term memory with:
29+
1) one candidate memory (new fact)
30+
2) existing similar memories (retrieved from store)
2931
30-
**Candidate Memory**:
31-
Abstract: {{ candidate_abstract }}
32-
Overview: {{ candidate_overview }}
33-
Content: {{ candidate_content }}
32+
Candidate memory:
33+
- Abstract: {{ candidate_abstract }}
34+
- Overview: {{ candidate_overview }}
35+
- Content: {{ candidate_content }}
3436
35-
**Existing Similar Memories**:
37+
Existing similar memories:
3638
{{ existing_memories }}
3739
38-
Please decide:
39-
- SKIP: Candidate memory duplicates existing memories, no need to save
40-
- CREATE: This is completely new information, should be created
41-
- MERGE: Candidate memory should be merged with existing memories
40+
Goal:
41+
Keep memory consistent and useful while minimizing destructive edits.
4242
43-
Return JSON format:
43+
Candidate-level decision:
44+
- skip:
45+
Use only when candidate adds no useful new information (duplicate, paraphrase,
46+
or too weak/uncertain). No memory should change.
47+
- create:
48+
Use when candidate is a valid new memory that should be stored as a separate item.
49+
It may optionally delete fully-invalidated existing memories.
50+
- none:
51+
Use when candidate itself should not be stored, but existing memories should be
52+
reconciled with per-item actions.
53+
54+
Existing-memory per-item action:
55+
- merge:
56+
Existing memory and candidate are about the same subject and should be unified.
57+
Use for refinement, correction, partial conflict, or complementary details.
58+
- delete:
59+
Existing memory must be removed only if candidate fully invalidates the entire
60+
existing memory (not just one sub-part).
61+
62+
Critical delete boundary:
63+
- If conflict is partial (some statements conflict, others remain valid), DO NOT delete.
64+
Use merge instead so non-conflicting information is preserved.
65+
- Delete only when the whole existing memory is obsolete/invalidated.
66+
- Topic/facet mismatch must never be deleted. If candidate is about one facet
67+
(for example any single preference facet), existing memories from other facets
68+
must be omitted from list (treated as unchanged).
69+
70+
Decision guidance:
71+
- Prefer skip when candidate is redundant.
72+
- Prefer none+merge for same-subject updates and partial contradictions.
73+
- Prefer create for clearly new independent memory.
74+
- If uncertain, choose non-destructive behavior (skip or merge), not delete.
75+
76+
Practical checklist before emitting each list item:
77+
1) Is existing memory about the same topic/facet as candidate?
78+
2) If no, do not include it in list.
79+
3) If yes and candidate only updates part of it, use merge.
80+
4) Use delete only when candidate explicitly invalidates the whole existing memory.
81+
82+
Hard constraints:
83+
- If decision is "skip", do not return "list".
84+
- If any list item uses "merge", decision must be "none".
85+
- If decision is "create", list can be empty or contain delete items only.
86+
- Use uri exactly from existing memories list.
87+
- Omit unchanged existing memories from list.
88+
- Return JSON only, no prose.
89+
90+
Return JSON in this exact structure:
4491
{
45-
"decision": "skip|create|merge",
46-
"reason": "Decision reason"
92+
"decision": "skip|create|none",
93+
"reason": "short reason",
94+
"list": [
95+
{
96+
"uri": "<existing memory uri>",
97+
"decide": "merge|delete",
98+
"reason": "short reason (for delete, explain full invalidation)"
99+
}
100+
]
47101
}
48102
49103
llm_config:

openviking/prompts/templates/compression/memory_extraction.yaml

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ metadata:
22
id: "compression.memory_extraction"
33
name: "Memory Extraction (Three-Level)"
44
description: "Extract memories from session context using L0/L1/L2 three-level structure"
5-
version: "5.0.0"
5+
version: "5.1.0"
66
language: "en"
77
category: "compression"
88

@@ -90,6 +90,15 @@ template: |
9090
- Characteristics: Changeable choices, styles
9191
- Test: Can it be described as "User prefers/likes..."
9292
93+
### Preference Granularity (Important)
94+
- For category `preferences`, each memory item should represent one independently
95+
updatable preference unit (single facet).
96+
- Do NOT mix unrelated preference facets in one memory item.
97+
Examples of different facets: food, commute, schedule, tools, music, study habits.
98+
- If the conversation contains multiple facets, output multiple `preferences` items.
99+
- This granularity is required so future updates/conflicts can affect only the
100+
relevant memory without damaging unrelated preferences.
101+
93102
**entities** - Entities (continuously existing nouns)
94103
- Core: Describes "current state of something"
95104
- Characteristics: Entities with lifecycle (person/project/organization)
@@ -166,6 +175,43 @@ template: |
166175
```
167176
❌ **Bad**: abstract says "Code preferences" (too general) or "No type hints" (too specific, cannot merge other style preferences)
168177
178+
## preferences Granularity Example
179+
❌ **Bad (mixed facets in one memory)**:
180+
```json
181+
{
182+
"category": "preferences",
183+
"abstract": "User preferences: likes apples, commutes by bike, uses Obsidian",
184+
"overview": "Mixed food/commute/tool preferences",
185+
"content": "User likes apples, usually commutes by bike, and prefers Obsidian."
186+
}
187+
```
188+
189+
✅ **Good (split by independently updatable facets)**:
190+
```json
191+
{
192+
"memories": [
193+
{
194+
"category": "preferences",
195+
"abstract": "Food preference: Likes apples",
196+
"overview": "## Preference Domain\\n- **Domain**: Food\\n\\n## Specific Preference\\n- Likes apples",
197+
"content": "User shows a food preference for apples."
198+
},
199+
{
200+
"category": "preferences",
201+
"abstract": "Commute preference: Usually rides a bike",
202+
"overview": "## Preference Domain\\n- **Domain**: Commute\\n\\n## Specific Preference\\n- Usually rides a bike",
203+
"content": "User usually commutes by bike."
204+
},
205+
{
206+
"category": "preferences",
207+
"abstract": "Tool preference: Uses Obsidian for notes",
208+
"overview": "## Preference Domain\\n- **Domain**: Tools\\n\\n## Specific Preference\\n- Uses Obsidian for notes",
209+
"content": "User prefers Obsidian as note-taking software."
210+
}
211+
]
212+
}
213+
```
214+
169215
## entities Example (Merge type)
170216
✅ **Good**:
171217
```json
@@ -228,8 +274,7 @@ template: |
228274
- The values of "abstract", "overview", and "content" MUST be written in {{ output_language }} (if output_language is "auto", use the dominant language in recent_messages).
229275
- Only extract truly valuable personalized information
230276
- If nothing worth recording, return {"memories": []}
231-
- Preferences should be aggregated by topic, similar preferences should be merged into one memory
277+
- For preferences, keep each memory as one independently updatable facet; do not combine unrelated facets in one memory
232278
233279
llm_config:
234280
temperature: 0.0
235-

0 commit comments

Comments
 (0)