@reconcrap/people-network-memory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +476 -0
  2. package/docs/mcp_tools.md +138 -0
  3. package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
  4. package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
  5. package/harness_adapters/openclaw/mcp.template.json +14 -0
  6. package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
  7. package/package.json +30 -0
  8. package/pyproject.toml +26 -0
  9. package/scripts/install_windows.ps1 +92 -0
  10. package/scripts/npm/people-memory.js +276 -0
  11. package/scripts/people_memory_bootstrap.py +247 -0
  12. package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
  13. package/scripts/run_tests_with_artifacts.ps1 +307 -0
  14. package/src/people_network_memory/__init__.py +6 -0
  15. package/src/people_network_memory/application/__init__.py +16 -0
  16. package/src/people_network_memory/application/normalization.py +1441 -0
  17. package/src/people_network_memory/application/services.py +921 -0
  18. package/src/people_network_memory/cli.py +1212 -0
  19. package/src/people_network_memory/config.py +268 -0
  20. package/src/people_network_memory/domain/__init__.py +55 -0
  21. package/src/people_network_memory/domain/identity.py +77 -0
  22. package/src/people_network_memory/domain/models.py +355 -0
  23. package/src/people_network_memory/fixtures/__init__.py +6 -0
  24. package/src/people_network_memory/fixtures/eval.py +398 -0
  25. package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
  26. package/src/people_network_memory/fixtures/generator.py +290 -0
  27. package/src/people_network_memory/fixtures/report.py +252 -0
  28. package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
  29. package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
  30. package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
  31. package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
  32. package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
  33. package/src/people_network_memory/harness_adapters/__init__.py +2 -0
  34. package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
  35. package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
  36. package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
  37. package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
  38. package/src/people_network_memory/infrastructure/__init__.py +2 -0
  39. package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
  40. package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
  41. package/src/people_network_memory/infrastructure/embeddings.py +155 -0
  42. package/src/people_network_memory/infrastructure/file_store.py +129 -0
  43. package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
  44. package/src/people_network_memory/infrastructure/id_generator.py +40 -0
  45. package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
  46. package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
  47. package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
  48. package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
  49. package/src/people_network_memory/infrastructure/redaction.py +21 -0
  50. package/src/people_network_memory/infrastructure/release_check.py +186 -0
  51. package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
  52. package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
  53. package/src/people_network_memory/mcp_server/__init__.py +2 -0
  54. package/src/people_network_memory/mcp_server/contracts.py +85 -0
  55. package/src/people_network_memory/mcp_server/runtime.py +133 -0
  56. package/src/people_network_memory/mcp_server/tools.py +588 -0
  57. package/src/people_network_memory/ports/__init__.py +2 -0
  58. package/src/people_network_memory/ports/errors.py +25 -0
  59. package/src/people_network_memory/ports/interfaces.py +103 -0
  60. package/src/people_network_memory/projection/__init__.py +6 -0
  61. package/src/people_network_memory/projection/builders.py +46 -0
@@ -0,0 +1,921 @@
1
+ """Application services orchestrating domain policies and ports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from datetime import datetime, timezone
8
+ from typing import Literal
9
+
10
+ from people_network_memory.domain.identity import IdentityDecision, choose_identity
11
+ from people_network_memory.domain.models import (
12
+ CapturedPersonSummary,
13
+ CaptureSummary,
14
+ Evidence,
15
+ FollowUpTask,
16
+ HarnessContextRequest,
17
+ IdentityAdvice,
18
+ OpportunityTimeHint,
19
+ PersonCard,
20
+ PersonRef,
21
+ PostCaptureOpportunity,
22
+ RecordInteractionResult,
23
+ RelationshipAssertion,
24
+ RetrievalResponse,
25
+ ReviewItem,
26
+ SensitivityLabel,
27
+ SocialInteraction,
28
+ )
29
+ from people_network_memory.application.normalization import normalize_interaction
30
+ from people_network_memory.ports.errors import PersistenceError
31
+ from people_network_memory.ports.interfaces import (
32
+ GraphMemoryStore,
33
+ GraphSearch,
34
+ IdentityAdvisor,
35
+ IdentityIndex,
36
+ IdGenerator,
37
+ InteractionExtractor,
38
+ PersonProjector,
39
+ RetrievalJudge,
40
+ ReviewQueue,
41
+ )
42
+
43
+
44
+ SensitivityPolicy = Literal["personal", "strict", "task_aware"]
45
+ OutputContext = Literal["private", "shareable"]
46
+
47
+
48
+ class ResolveIdentityService:
49
+ def __init__(self, identity_index: IdentityIndex) -> None:
50
+ self._identity_index = identity_index
51
+
52
+ def candidates(self, ref: PersonRef) -> list:
53
+ return self._identity_index.find_identity_candidates(ref)
54
+
55
+ def resolve(self, ref: PersonRef) -> IdentityDecision:
56
+ candidates = self.candidates(ref)
57
+ return choose_identity(ref, candidates)
58
+
59
+
60
+ class RecordInteractionService:
61
+ def __init__(
62
+ self,
63
+ *,
64
+ memory_store: GraphMemoryStore,
65
+ identity_index: IdentityIndex,
66
+ review_queue: ReviewQueue,
67
+ id_generator: IdGenerator,
68
+ interaction_extractor: InteractionExtractor | None = None,
69
+ identity_advisor: IdentityAdvisor | None = None,
70
+ ) -> None:
71
+ self._memory_store = memory_store
72
+ self._identity = ResolveIdentityService(identity_index)
73
+ self._review_queue = review_queue
74
+ self._ids = id_generator
75
+ self._interaction_extractor = interaction_extractor
76
+ self._identity_advisor = identity_advisor
77
+
78
+ def record(self, interaction: SocialInteraction) -> RecordInteractionResult:
79
+ interaction = self._extract_interaction(interaction)
80
+ interaction = normalize_interaction(interaction)
81
+ identity_map: dict[str, str | None] = {}
82
+ identity_decisions: dict[str, IdentityDecision] = {}
83
+ pending_reviews: list[tuple[str, ReviewItem]] = []
84
+ for ref in self._iter_person_refs(interaction):
85
+ key = self._ref_key(ref)
86
+ if key in identity_decisions:
87
+ continue
88
+ if _explicit_separate_person_reference(interaction.source_text, ref):
89
+ candidates = self._identity.candidates(ref)
90
+ decision = IdentityDecision(
91
+ kind="provisional",
92
+ reason="explicit different-person reference",
93
+ candidates=tuple(candidates),
94
+ )
95
+ else:
96
+ decision = self._identity.resolve(ref)
97
+ decision = self._apply_identity_advice(interaction, ref, decision)
98
+ identity_decisions[key] = decision
99
+ identity_map[key] = decision.person_id
100
+ if decision.kind == "review":
101
+ pending_reviews.append(
102
+ (
103
+ key,
104
+ ReviewItem(
105
+ review_id=self._ids.new_id("review"),
106
+ kind="identity",
107
+ message=f"Ambiguous person reference: {ref.label}",
108
+ candidates=list(decision.candidates),
109
+ source_text=interaction.source_text,
110
+ created_at=datetime.now(timezone.utc),
111
+ ),
112
+ )
113
+ )
114
+ elif decision.kind == "provisional" and decision.candidates:
115
+ pending_reviews.append(
116
+ (
117
+ key,
118
+ ReviewItem(
119
+ review_id=self._ids.new_id("review"),
120
+ kind="identity",
121
+ message=(
122
+ f"Recorded separate person for {decision.reason}: "
123
+ f"{ref.label}"
124
+ ),
125
+ candidates=list(decision.candidates),
126
+ source_text=interaction.source_text,
127
+ created_at=datetime.now(timezone.utc),
128
+ ),
129
+ )
130
+ )
131
+ try:
132
+ result = self._memory_store.save_interaction(interaction, identity_map)
133
+ except Exception as exc: # pragma: no cover - defensive boundary
134
+ raise PersistenceError(str(exc)) from exc
135
+ review_items = [
136
+ item.model_copy(update={"subject_person_id": result.person_ref_map.get(key)})
137
+ for key, item in pending_reviews
138
+ ]
139
+ for item in review_items:
140
+ self._review_queue.add_review_item(item)
141
+ enrichment_items = self._second_degree_enrichment_reviews(
142
+ interaction, result, identity_decisions
143
+ )
144
+ for item in enrichment_items:
145
+ self._review_queue.add_review_item(item)
146
+ merged_reviews = [*result.needs_review, *review_items, *enrichment_items]
147
+ opportunities = _post_capture_opportunities(
148
+ interaction=interaction,
149
+ result=result,
150
+ reviews=merged_reviews,
151
+ )
152
+ context_requests = _unique_context_requests(
153
+ request
154
+ for opportunity in opportunities
155
+ for request in opportunity.harness_context_requests
156
+ )
157
+ return result.model_copy(
158
+ update={
159
+ "needs_review": merged_reviews,
160
+ "capture_summary": _capture_summary(interaction, result, merged_reviews),
161
+ "captured_follow_ups": interaction.follow_ups,
162
+ "post_capture_opportunities": opportunities,
163
+ "harness_context_requests": context_requests,
164
+ }
165
+ )
166
+
167
+ def _extract_interaction(self, interaction: SocialInteraction) -> SocialInteraction:
168
+ if not self._interaction_extractor:
169
+ return interaction
170
+ try:
171
+ return self._interaction_extractor.extract(interaction)
172
+ except Exception:
173
+ return interaction
174
+
175
+ def _iter_person_refs(self, interaction: SocialInteraction) -> list[PersonRef]:
176
+ refs: list[PersonRef] = []
177
+ refs.extend(participant.person for participant in interaction.participants)
178
+ refs.extend(mentioned.person for mentioned in interaction.mentioned_people)
179
+ for claim in interaction.attributed_claims:
180
+ if claim.speaker:
181
+ refs.append(claim.speaker)
182
+ if claim.subject:
183
+ refs.append(claim.subject)
184
+ for fact in interaction.direct_facts:
185
+ refs.append(fact.subject)
186
+ for follow_up in interaction.follow_ups:
187
+ refs.extend(follow_up.related_people)
188
+ for relationship in interaction.relationships:
189
+ refs.extend([relationship.source, relationship.target])
190
+ return refs
191
+
192
+ def _ref_key(self, ref: PersonRef) -> str:
193
+ return ref.person_id or ref.email or ref.phone or ref.label
194
+
195
+ def _apply_identity_advice(
196
+ self,
197
+ interaction: SocialInteraction,
198
+ ref: PersonRef,
199
+ decision: IdentityDecision,
200
+ ) -> IdentityDecision:
201
+ if (
202
+ self._identity_advisor is None
203
+ or ref.person_id
204
+ or ref.email
205
+ or ref.phone
206
+ or not decision.candidates
207
+ ):
208
+ return decision
209
+ if _is_single_generated_cjk_alias_link(ref, decision):
210
+ return decision
211
+ try:
212
+ advice = self._identity_advisor.advise(
213
+ interaction=interaction,
214
+ ref=ref,
215
+ candidates=list(decision.candidates),
216
+ )
217
+ except Exception:
218
+ return decision
219
+ if decision.kind != "link":
220
+ return decision
221
+ if advice.recommendation == "different_person":
222
+ return IdentityDecision(
223
+ kind="provisional",
224
+ reason=_identity_advice_reason(advice),
225
+ candidates=decision.candidates,
226
+ )
227
+ if advice.recommendation == "ambiguous_needs_review":
228
+ return IdentityDecision(
229
+ kind="review",
230
+ reason=_identity_advice_reason(advice),
231
+ candidates=decision.candidates,
232
+ )
233
+ return decision
234
+
235
+ def _second_degree_enrichment_reviews(
236
+ self,
237
+ interaction: SocialInteraction,
238
+ result: RecordInteractionResult,
239
+ identity_decisions: dict[str, IdentityDecision],
240
+ ) -> list[ReviewItem]:
241
+ participant_keys = {
242
+ self._ref_key(participant.person) for participant in interaction.participants
243
+ }
244
+ relationship_targets = _relationship_targets(interaction.relationships)
245
+ created = set(result.created_people)
246
+ seen_person_ids: set[str] = set()
247
+ items: list[ReviewItem] = []
248
+ for ref, relationship_type, source_label in relationship_targets:
249
+ key = self._ref_key(ref)
250
+ if key in participant_keys:
251
+ continue
252
+ person_id = result.person_ref_map.get(key)
253
+ if not person_id or person_id not in created or person_id in seen_person_ids:
254
+ continue
255
+ decision = identity_decisions.get(key)
256
+ if decision and decision.kind != "provisional":
257
+ continue
258
+ seen_person_ids.add(person_id)
259
+ items.append(
260
+ ReviewItem(
261
+ review_id=self._ids.new_id("review"),
262
+ kind="relationship",
263
+ message=(
264
+ f"Created a provisional card for {ref.label} because "
265
+ f"{_relationship_phrase(source_label, relationship_type)}. "
266
+ "Add details later if useful, or mark this as second-degree only."
267
+ ),
268
+ subject_person_id=person_id,
269
+ source_text=interaction.source_text,
270
+ created_at=datetime.now(timezone.utc),
271
+ )
272
+ )
273
+ return items
274
+
275
+
276
+ def _relationship_targets(
277
+ relationships: list[RelationshipAssertion],
278
+ ) -> list[tuple[PersonRef, str, str]]:
279
+ return [
280
+ (relationship.target, relationship.relationship_type, relationship.source.label)
281
+ for relationship in relationships
282
+ ]
283
+
284
+
285
+ def _relationship_phrase(source_label: str, relationship_type: str) -> str:
286
+ if relationship_type == "friends_with":
287
+ return f"{source_label} is friends with them"
288
+ if relationship_type == "introduced_by":
289
+ return f"{source_label} introduced you to them"
290
+ if relationship_type == "knows":
291
+ return f"{source_label} knows them"
292
+ if relationship_type == "works_with":
293
+ return f"{source_label} works with them"
294
+ return f"{source_label} {relationship_type.replace('_', ' ')} them"
295
+
296
+
297
+ def _identity_advice_reason(advice: IdentityAdvice) -> str:
298
+ reasons = "; ".join(advice.reasons[:2]).strip()
299
+ suffix = f": {reasons}" if reasons else ""
300
+ return f"identity advisor suggested {advice.recommendation}{suffix}"
301
+
302
+
303
+ def _is_single_generated_cjk_alias_link(
304
+ ref: PersonRef, decision: IdentityDecision
305
+ ) -> bool:
306
+ if (
307
+ decision.kind != "link"
308
+ or decision.reason != "single exact name or alias match"
309
+ or len(decision.candidates) != 1
310
+ ):
311
+ return False
312
+ candidate = decision.candidates[0]
313
+ if candidate.person_id != decision.person_id or not candidate.exact_name_match:
314
+ return False
315
+ label = re.sub(r"\s+", "", ref.label.strip())
316
+ return label in _generated_cjk_name_aliases(candidate.display_name)
317
+
318
+
319
+ def _generated_cjk_name_aliases(label: str) -> set[str]:
320
+ original = label.strip()
321
+ compact = re.sub(r"\s+", "", original)
322
+ if compact.startswith("测试"):
323
+ compact = compact.removeprefix("测试")
324
+ if not re.fullmatch(r"[\u4e00-\u9fff]{3,4}", compact):
325
+ return set()
326
+ aliases = [compact, compact[-2:]]
327
+ return {alias for alias in aliases if alias and alias != original}
328
+
329
+
330
+ def _ref_key(ref: PersonRef) -> str:
331
+ return ref.person_id or ref.email or ref.phone or ref.label
332
+
333
+
334
+ def _explicit_separate_person_reference(source_text: str, ref: PersonRef) -> bool:
335
+ if ref.person_id or ref.email or ref.phone:
336
+ return False
337
+ label = ref.label.strip()
338
+ if not label:
339
+ return False
340
+ escaped = re.escape(label)
341
+ patterns = [
342
+ rf"\b(?:another|a\s+different|different|new)\s+{escaped}\b",
343
+ rf"\b{escaped}\b[^.;。]*\b(?:is|was|are|were)?\s*(?:a\s+)?different person\b",
344
+ rf"\bdifferent person\s+from\s+{escaped}\b",
345
+ rf"(?:另一个|另外一个|另一位|另位|不同的|新的)\s*{escaped}",
346
+ rf"{escaped}[^。;;,.,]*(?:不同的人|不是同一个人|不是一个人)",
347
+ rf"和[^。;;,.,]*{escaped}[^。;;,.,]*(?:不同的人|不是同一个人|不是一个人)",
348
+ rf"{escaped}[^。;;,.,]*(?:不是|并不是)[^。;;,.,]*(?:同一个人|一个人)",
349
+ ]
350
+ return any(re.search(pattern, source_text, flags=re.IGNORECASE) for pattern in patterns)
351
+
352
+
353
+ def _capture_summary(
354
+ interaction: SocialInteraction,
355
+ result: RecordInteractionResult,
356
+ reviews: list[ReviewItem],
357
+ ) -> CaptureSummary:
358
+ people: dict[str, dict[str, object]] = {}
359
+
360
+ def add_person(ref: PersonRef, role: str) -> None:
361
+ person_id = result.person_ref_map.get(_ref_key(ref)) or ref.person_id
362
+ if not person_id:
363
+ return
364
+ entry = people.setdefault(
365
+ person_id,
366
+ {
367
+ "labels": [],
368
+ "aliases": [],
369
+ "roles": [],
370
+ },
371
+ )
372
+ labels = entry["labels"]
373
+ aliases = entry["aliases"]
374
+ roles = entry["roles"]
375
+ if isinstance(labels, list) and ref.label not in labels:
376
+ labels.append(ref.label)
377
+ if isinstance(aliases, list):
378
+ for alias in ref.aliases:
379
+ if alias != ref.label and alias not in aliases:
380
+ aliases.append(alias)
381
+ if isinstance(roles, list) and role not in roles:
382
+ roles.append(role)
383
+
384
+ for participant in interaction.participants:
385
+ add_person(participant.person, "participant")
386
+ for mentioned in interaction.mentioned_people:
387
+ add_person(mentioned.person, "mentioned")
388
+ if mentioned.mentioned_by:
389
+ add_person(mentioned.mentioned_by, "speaker")
390
+ for claim in interaction.attributed_claims:
391
+ if claim.speaker:
392
+ add_person(claim.speaker, "speaker")
393
+ if claim.subject:
394
+ add_person(claim.subject, "claim_subject")
395
+ for fact in interaction.direct_facts:
396
+ add_person(fact.subject, "direct_fact_subject")
397
+ for follow_up in interaction.follow_ups:
398
+ for ref in follow_up.related_people:
399
+ add_person(ref, "follow_up")
400
+ for relationship in interaction.relationships:
401
+ add_person(relationship.source, "relationship_source")
402
+ add_person(relationship.target, "relationship_target")
403
+
404
+ created = set(result.created_people)
405
+ updated = set(result.updated_people)
406
+ person_summaries: list[CapturedPersonSummary] = []
407
+ for person_id, entry in people.items():
408
+ labels = [str(label) for label in entry.get("labels", [])]
409
+ roles = [str(role) for role in entry.get("roles", [])]
410
+ action: Literal["created", "updated", "linked"] = "linked"
411
+ if person_id in created:
412
+ action = "created"
413
+ elif person_id in updated:
414
+ action = "updated"
415
+ label = _display_label(labels)
416
+ person_summaries.append(
417
+ CapturedPersonSummary(
418
+ person_id=person_id,
419
+ label=label,
420
+ action=action,
421
+ roles=roles,
422
+ correction_hint=(
423
+ f"If {label} was matched to the wrong card, say "
424
+ f"'different person: {label}'."
425
+ ),
426
+ )
427
+ )
428
+
429
+ details: list[str] = []
430
+ if interaction.place:
431
+ details.append(f"Place: {interaction.place}")
432
+ if interaction.topics:
433
+ details.append("Topics: " + ", ".join(interaction.topics))
434
+ for _person_id, entry in people.items():
435
+ labels = [str(label) for label in entry.get("labels", [])]
436
+ aliases = [str(alias) for alias in entry.get("aliases", [])]
437
+ if labels and aliases:
438
+ details.append(f"Aliases: {_display_label(labels)} also known as {', '.join(aliases)}")
439
+ for mentioned in interaction.mentioned_people:
440
+ if mentioned.mentioned_by:
441
+ details.append(f"{mentioned.mentioned_by.label} mentioned {mentioned.person.label}")
442
+ else:
443
+ details.append(f"Mentioned {mentioned.person.label}")
444
+ for claim in interaction.attributed_claims:
445
+ details.append(f"Attributed claim: {claim.claim_text}")
446
+ for relationship in interaction.relationships:
447
+ details.append(
448
+ f"Relationship: {relationship.source.label} "
449
+ f"{relationship.relationship_type.replace('_', ' ')} {relationship.target.label}"
450
+ )
451
+ for fact in interaction.direct_facts:
452
+ details.append(f"Fact: {fact.subject.label} {fact.predicate} {fact.value}")
453
+
454
+ correction_hint = (
455
+ "Show this brief summary to the user. If any person, fact, or follow-up "
456
+ "is wrong, the user can correct it immediately with phrases like "
457
+ "'different person', 'same person', or 'remove that follow-up'."
458
+ )
459
+ if reviews:
460
+ correction_hint = (
461
+ "Show this brief summary and the review items. Do not merge identities "
462
+ "until the user confirms which person is meant."
463
+ )
464
+ return CaptureSummary(
465
+ people=sorted(person_summaries, key=lambda item: item.label.lower()),
466
+ details=details,
467
+ follow_ups=[follow_up.description for follow_up in interaction.follow_ups],
468
+ reviews=[review.message for review in reviews],
469
+ correction_hint=correction_hint,
470
+ )
471
+
472
+
473
+ def _display_label(labels: list[str]) -> str:
474
+ if not labels:
475
+ return "Unknown person"
476
+ return sorted(labels, key=lambda label: (len(label.split()), len(label)), reverse=True)[0]
477
+
478
+
479
+ def _post_capture_opportunities(
480
+ *,
481
+ interaction: SocialInteraction,
482
+ result: RecordInteractionResult,
483
+ reviews: list[ReviewItem],
484
+ ) -> list[PostCaptureOpportunity]:
485
+ opportunities: list[PostCaptureOpportunity] = []
486
+ evidence = result.evidence
487
+ for follow_up in interaction.follow_ups:
488
+ related_people = _unique_person_refs(follow_up.related_people)
489
+ people_text = ", ".join(ref.label for ref in related_people)
490
+ summary = follow_up.description
491
+ if people_text and people_text.lower() not in summary.lower():
492
+ summary = f"{summary} ({people_text})"
493
+ time_hint = _time_hint_for_follow_up(follow_up)
494
+ opportunities.append(
495
+ PostCaptureOpportunity(
496
+ kind="open_loop",
497
+ priority="high",
498
+ summary=summary,
499
+ user_value="Prevent a social promise or next action from being forgotten.",
500
+ recommended_default="execute_if_supported",
501
+ possible_next_steps=[
502
+ "create_private_one_time_reminder_without_asking",
503
+ "create_private_reminder",
504
+ "search_harness_memory_for_relevant_context",
505
+ "draft_follow_up_message_without_sending",
506
+ "add_to_private_task_manager",
507
+ ],
508
+ risk_level="low",
509
+ reversibility="easy",
510
+ external_visibility="private",
511
+ requires_confirmation=False,
512
+ undo_hint="Delete or dismiss the reminder/task if the user says it is unnecessary.",
513
+ time_hint=time_hint,
514
+ related_people=related_people,
515
+ harness_context_requests=[
516
+ HarnessContextRequest(
517
+ kind="search_harness_memory",
518
+ query=_context_query(
519
+ [follow_up.description, interaction.source_text, people_text]
520
+ ),
521
+ purpose=(
522
+ "Find user-specific context, preferences, related people, or "
523
+ "prior events that can make this follow-up more useful."
524
+ ),
525
+ ),
526
+ HarnessContextRequest(
527
+ kind="inspect_available_tools",
528
+ query="private reminder task calendar cron draft message",
529
+ purpose=(
530
+ "Choose the best available low-risk reversible harness action "
531
+ "for the captured follow-up."
532
+ ),
533
+ ),
534
+ ],
535
+ evidence=evidence,
536
+ )
537
+ )
538
+ opportunities.extend(_mentioned_person_opportunities(interaction, result))
539
+ opportunities.extend(_review_opportunities(reviews, evidence))
540
+ return _unique_opportunities(opportunities)
541
+
542
+
543
+ def _mentioned_person_opportunities(
544
+ interaction: SocialInteraction, result: RecordInteractionResult
545
+ ) -> list[PostCaptureOpportunity]:
546
+ participant_keys = {
547
+ _ref_key(participant.person).lower() for participant in interaction.participants
548
+ }
549
+ opportunities: list[PostCaptureOpportunity] = []
550
+ for item in interaction.mentioned_people:
551
+ key = _ref_key(item.person).lower()
552
+ if key in participant_keys:
553
+ continue
554
+ person_id = result.person_ref_map.get(_ref_key(item.person))
555
+ if person_id not in result.created_people:
556
+ continue
557
+ people = [item.person]
558
+ if item.mentioned_by:
559
+ people.append(item.mentioned_by)
560
+ summary = f"{item.person.label} was mentioned but was not present in this interaction."
561
+ if item.mentioned_by:
562
+ summary = f"{item.mentioned_by.label} mentioned {item.person.label}."
563
+ opportunities.append(
564
+ PostCaptureOpportunity(
565
+ kind="context_enrichment",
566
+ priority="medium",
567
+ summary=summary,
568
+ user_value=(
569
+ "Connect second-degree people to existing harness memory or gather "
570
+ "enough context to make the network graph more useful later."
571
+ ),
572
+ recommended_default="execute_if_supported",
573
+ possible_next_steps=[
574
+ "search_harness_memory_for_this_person",
575
+ "link_to_related_events_or_projects",
576
+ "mark_as_second_degree_until_user_adds_details",
577
+ "ask_user_later_if_full_card_is_needed",
578
+ ],
579
+ risk_level="low",
580
+ reversibility="easy",
581
+ external_visibility="private",
582
+ requires_confirmation=False,
583
+ undo_hint="Dismiss or merge the provisional person card if it is not useful.",
584
+ related_people=_unique_person_refs(people),
585
+ harness_context_requests=[
586
+ HarnessContextRequest(
587
+ kind="search_harness_memory",
588
+ query=_context_query([item.person.label, interaction.source_text]),
589
+ purpose=(
590
+ "Check whether the harness already knows useful context about "
591
+ "this mentioned person, without treating it as confirmed graph evidence."
592
+ ),
593
+ )
594
+ ],
595
+ evidence=result.evidence,
596
+ )
597
+ )
598
+ return opportunities
599
+
600
+
601
+ def _review_opportunities(
602
+ reviews: list[ReviewItem], evidence: list[Evidence]
603
+ ) -> list[PostCaptureOpportunity]:
604
+ opportunities: list[PostCaptureOpportunity] = []
605
+ for review in reviews:
606
+ if review.kind == "identity":
607
+ opportunities.append(
608
+ PostCaptureOpportunity(
609
+ kind="identity_review",
610
+ priority="medium",
611
+ summary=review.message,
612
+ user_value="Avoid accidentally merging two different people.",
613
+ recommended_default="ask_first",
614
+ possible_next_steps=[
615
+ "show_identity_review_to_user",
616
+ "defer_until_more_evidence",
617
+ ],
618
+ risk_level="medium",
619
+ reversibility="manual",
620
+ external_visibility="private",
621
+ requires_confirmation=True,
622
+ undo_hint="Identity merges should stay reviewable and should not be automatic.",
623
+ related_people=[],
624
+ evidence=list(evidence),
625
+ )
626
+ )
627
+ return opportunities
628
+
629
+
630
+ def _time_hint_for_follow_up(follow_up: FollowUpTask) -> OpportunityTimeHint | None:
631
+ text = _extract_time_hint(follow_up.description)
632
+ if follow_up.due_at is None and not text:
633
+ return None
634
+ default_assumption = None
635
+ if follow_up.due_at is not None:
636
+ default_assumption = (
637
+ "Create a private one-time reminder/task for this date without asking; "
638
+ "the user can undo it by deleting or dismissing the reminder."
639
+ )
640
+ elif text:
641
+ default_assumption = (
642
+ "Use the harness or user's normal private reminder convention unless "
643
+ "the action is costly."
644
+ )
645
+ return OpportunityTimeHint(
646
+ text=text,
647
+ normalized_date=follow_up.due_at,
648
+ needs_interpretation=follow_up.due_at is None and bool(text),
649
+ default_assumption=default_assumption,
650
+ )
651
+
652
+
653
+ def _extract_time_hint(text: str) -> str | None:
654
+ lowered = text.lower()
655
+ zh_match = re.search(
656
+ r"(下(?:周|星期|礼拜)[一二三四五六日天](?:上午|下午|晚上|早上)?\s*\d{1,2}点?)",
657
+ text,
658
+ )
659
+ if zh_match:
660
+ return zh_match.group(1).replace(" ", "")
661
+ for phrase in [
662
+ "today",
663
+ "tomorrow",
664
+ "next week",
665
+ "this week",
666
+ "next month",
667
+ "later this week",
668
+ "by end of next week",
669
+ ]:
670
+ if phrase in lowered:
671
+ return phrase
672
+ return None
673
+
674
+
675
+ def _unique_person_refs(refs: list[PersonRef]) -> list[PersonRef]:
676
+ seen: set[str] = set()
677
+ result: list[PersonRef] = []
678
+ for ref in refs:
679
+ key = _ref_key(ref).lower()
680
+ if key in seen:
681
+ continue
682
+ seen.add(key)
683
+ result.append(ref)
684
+ return result
685
+
686
+
687
+ def _unique_context_requests(requests: Iterable[HarnessContextRequest]) -> list[HarnessContextRequest]:
688
+ seen: set[tuple[str, str]] = set()
689
+ result: list[HarnessContextRequest] = []
690
+ for request in requests:
691
+ key = (request.kind, request.query.lower())
692
+ if key in seen:
693
+ continue
694
+ seen.add(key)
695
+ result.append(request)
696
+ return result
697
+
698
+
699
+ def _unique_opportunities(opportunities: list[PostCaptureOpportunity]) -> list[PostCaptureOpportunity]:
700
+ seen: set[tuple[str, str]] = set()
701
+ result: list[PostCaptureOpportunity] = []
702
+ for opportunity in opportunities:
703
+ key = (opportunity.kind, opportunity.summary.lower())
704
+ if key in seen:
705
+ continue
706
+ seen.add(key)
707
+ result.append(opportunity)
708
+ return result
709
+
710
+
711
+ def _context_query(parts: list[str]) -> str:
712
+ return " ".join(part.strip() for part in parts if part and part.strip())
713
+
714
+
715
+ class RetrieveContextService:
716
+ def __init__(
717
+ self,
718
+ *,
719
+ graph_search: GraphSearch,
720
+ review_queue: ReviewQueue,
721
+ default_sensitivity_policy: SensitivityPolicy = "personal",
722
+ retrieval_judge: RetrievalJudge | None = None,
723
+ ) -> None:
724
+ self._graph_search = graph_search
725
+ self._review_queue = review_queue
726
+ self._default_sensitivity_policy = default_sensitivity_policy
727
+ self._retrieval_judge = retrieval_judge
728
+
729
+ def retrieve(
730
+ self,
731
+ query: str,
732
+ *,
733
+ limit: int = 10,
734
+ include_sensitive: bool | None = None,
735
+ mode: str = "recall",
736
+ sensitivity_policy: SensitivityPolicy | None = None,
737
+ output_context: OutputContext = "private",
738
+ ) -> RetrievalResponse:
739
+ policy = sensitivity_policy or self._default_sensitivity_policy
740
+ include_sensitive_result = _should_include_sensitive(
741
+ include_sensitive=include_sensitive,
742
+ policy=policy,
743
+ output_context=output_context,
744
+ )
745
+ candidate_limit = max(limit * 4, limit)
746
+ results = self._graph_search.search(
747
+ query,
748
+ limit=candidate_limit,
749
+ include_sensitive=include_sensitive_result,
750
+ mode=mode,
751
+ )
752
+ results = _dedupe_retrieval_items(results)
753
+ if self._retrieval_judge:
754
+ results = _diversify_retrieval_items(
755
+ self._retrieval_judge.judge(query, results, limit=candidate_limit),
756
+ limit=limit,
757
+ )
758
+ else:
759
+ results = _diversify_retrieval_items(results, limit=limit)
760
+ missing_info: list[str] = []
761
+ if not results:
762
+ missing_info.append("No matching people, interactions, or facts were found.")
763
+ return RetrievalResponse(
764
+ query=query,
765
+ mode="brief" if mode == "brief" else "recall",
766
+ sensitivity_policy=policy,
767
+ output_context=output_context,
768
+ sensitive_results_included=any(
769
+ _has_sensitive_label(item.sensitivity) for item in results
770
+ ),
771
+ results=results,
772
+ missing_info=missing_info,
773
+ needs_review=self._review_queue.list_review_items(status="open"),
774
+ )
775
+
776
+
777
+ def _dedupe_retrieval_items(items: list[RetrievalItem]) -> list[RetrievalItem]:
778
+ seen: set[str] = set()
779
+ result: list[RetrievalItem] = []
780
+ for item in items:
781
+ if item.item_id in seen:
782
+ continue
783
+ seen.add(item.item_id)
784
+ result.append(item)
785
+ return result
786
+
787
+
788
+ def _diversify_retrieval_items(
789
+ items: list[RetrievalItem], *, limit: int
790
+ ) -> list[RetrievalItem]:
791
+ if limit <= 0:
792
+ return []
793
+ selected: list[RetrievalItem] = []
794
+ selected_ids: set[str] = set()
795
+ seen_people: set[str] = set()
796
+ for item in items:
797
+ key = _retrieval_person_key(item)
798
+ if key in seen_people:
799
+ continue
800
+ seen_people.add(key)
801
+ selected.append(item)
802
+ selected_ids.add(item.item_id)
803
+ if len(selected) >= limit:
804
+ return selected
805
+ for item in items:
806
+ if item.item_id in selected_ids:
807
+ continue
808
+ selected.append(item)
809
+ if len(selected) >= limit:
810
+ break
811
+ return selected
812
+
813
+
814
+ def _retrieval_person_key(item: RetrievalItem) -> str:
815
+ if item.person_ids:
816
+ return "|".join(sorted(item.person_ids))
817
+ return item.item_id
818
+
819
+
820
+ class BuildPersonCardService:
821
+ def __init__(self, *, memory_store: GraphMemoryStore, projector: PersonProjector) -> None:
822
+ self._memory_store = memory_store
823
+ self._projector = projector
824
+
825
+ def get_person(self, person_id: str) -> PersonCard | None:
826
+ record = self._memory_store.get_person_memory(person_id)
827
+ if record is None:
828
+ return None
829
+ return self._projector.build_card(record)
830
+
831
+ def get_people_by_name(self, name: str) -> list[PersonCard]:
832
+ return [
833
+ self._projector.build_card(record)
834
+ for record in self._memory_store.find_person_memory_by_name(name)
835
+ ]
836
+
837
+
838
+ class ReviewWorkflowService:
839
+ def __init__(
840
+ self,
841
+ *,
842
+ memory_store: GraphMemoryStore,
843
+ review_queue: ReviewQueue,
844
+ ) -> None:
845
+ self._memory_store = memory_store
846
+ self._review_queue = review_queue
847
+
848
+ def list_reviews(self, *, status: str | None = "open") -> list[ReviewItem]:
849
+ if status == "all":
850
+ status = None
851
+ return self._review_queue.list_review_items(status=status)
852
+
853
+ def resolve_identity_review(
854
+ self,
855
+ *,
856
+ review_id: str,
857
+ source_person_id: str,
858
+ target_person_id: str,
859
+ note: str | None = None,
860
+ ) -> ReviewItem:
861
+ reviews = self._review_queue.list_review_items(status=None)
862
+ review = next((item for item in reviews if item.review_id == review_id), None)
863
+ if review is None:
864
+ raise PersistenceError(f"Review item not found: {review_id}")
865
+ if review.status != "open":
866
+ raise PersistenceError(f"Review item is not open: {review_id}")
867
+ if review.kind != "identity":
868
+ raise PersistenceError(f"Review item is not an identity review: {review_id}")
869
+ self._memory_store.merge_people(
870
+ source_person_id=source_person_id,
871
+ target_person_id=target_person_id,
872
+ note=note,
873
+ )
874
+ resolved = review.model_copy(
875
+ update={
876
+ "status": "resolved",
877
+ "resolved_to_person_id": target_person_id,
878
+ "resolved_at": datetime.now(timezone.utc),
879
+ "resolution_note": note,
880
+ }
881
+ )
882
+ return self._review_queue.update_review_item(resolved)
883
+
884
+ def dismiss_review(self, *, review_id: str, note: str | None = None) -> ReviewItem:
885
+ reviews = self._review_queue.list_review_items(status=None)
886
+ review = next((item for item in reviews if item.review_id == review_id), None)
887
+ if review is None:
888
+ raise PersistenceError(f"Review item not found: {review_id}")
889
+ if review.status != "open":
890
+ raise PersistenceError(f"Review item is not open: {review_id}")
891
+ dismissed = review.model_copy(
892
+ update={
893
+ "status": "dismissed",
894
+ "resolved_at": datetime.now(timezone.utc),
895
+ "resolution_note": note,
896
+ }
897
+ )
898
+ return self._review_queue.update_review_item(dismissed)
899
+
900
+
901
+ def _should_include_sensitive(
902
+ *,
903
+ include_sensitive: bool | None,
904
+ policy: SensitivityPolicy,
905
+ output_context: OutputContext,
906
+ ) -> bool:
907
+ if include_sensitive is not None:
908
+ return include_sensitive
909
+ if policy == "strict":
910
+ return False
911
+ if policy == "task_aware":
912
+ return output_context == "private"
913
+ return True
914
+
915
+
916
+ def _has_sensitive_label(labels: list[SensitivityLabel]) -> bool:
917
+ sensitive = {
918
+ SensitivityLabel.SENSITIVE,
919
+ SensitivityLabel.DO_NOT_SURFACE_UNPROMPTED,
920
+ }
921
+ return any(label in sensitive for label in labels)