@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,588 @@
1
+ """Stable tool contract for the V1 MCP surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from people_network_memory.application.services import (
9
+ BuildPersonCardService,
10
+ RecordInteractionService,
11
+ RetrieveContextService,
12
+ )
13
+ from people_network_memory.domain.models import SocialInteraction
14
+ from people_network_memory.mcp_server.contracts import (
15
+ GetPersonInput,
16
+ RetrieveNetworkContextInput,
17
+ public_tool_names,
18
+ )
19
+
20
+
21
+ class PeopleMemoryTools:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ record_interaction_service: RecordInteractionService,
26
+ retrieve_context_service: RetrieveContextService,
27
+ build_person_card_service: BuildPersonCardService,
28
+ ) -> None:
29
+ self._record = record_interaction_service
30
+ self._retrieve = retrieve_context_service
31
+ self._card = build_person_card_service
32
+
33
+ def tool_names(self) -> list[str]:
34
+ return public_tool_names()
35
+
36
+ def record_interaction(self, payload: dict[str, Any]) -> dict[str, Any]:
37
+ payload = _coerce_record_interaction_payload(payload)
38
+ interaction = SocialInteraction.model_validate(payload)
39
+ result = self._record.record(interaction)
40
+ return result.model_dump(mode="json")
41
+
42
+ def retrieve_network_context(self, payload: dict[str, Any]) -> dict[str, Any]:
43
+ request = RetrieveNetworkContextInput.model_validate(payload)
44
+ response = self._retrieve.retrieve(
45
+ request.query.strip(),
46
+ limit=request.limit,
47
+ include_sensitive=request.include_sensitive,
48
+ sensitivity_policy=request.sensitivity_policy,
49
+ output_context=request.output_context,
50
+ mode=request.mode,
51
+ )
52
+ data = response.model_dump(mode="json")
53
+ resolved_card = self._duplicate_query_name_resolution(request.query.strip())
54
+ if resolved_card is None:
55
+ resolved_card = self._single_query_name_resolution(request.query.strip())
56
+ if resolved_card is not None:
57
+ data["results"] = _filter_results_to_person(
58
+ data["results"], resolved_card.person_id
59
+ )
60
+ data["results"] = _augment_resolved_person_results(
61
+ query=request.query.strip(),
62
+ results=data["results"],
63
+ card=resolved_card,
64
+ )
65
+ ambiguity = None
66
+ if resolved_card is None:
67
+ ambiguity = self._duplicate_profile_name_ambiguity(
68
+ data["results"]
69
+ ) or self._duplicate_query_name_ambiguity(request.query.strip())
70
+ if ambiguity:
71
+ data.update(ambiguity)
72
+ candidate_ids = {
73
+ str(candidate["person_id"])
74
+ for candidate in data["candidates"]
75
+ if candidate.get("person_id")
76
+ }
77
+ candidate_results = [
78
+ item
79
+ for item in data["results"]
80
+ if any(person_id in candidate_ids for person_id in item["person_ids"])
81
+ ]
82
+ data["candidate_results"] = _candidate_results(
83
+ candidates=data["candidates"],
84
+ results=candidate_results,
85
+ )
86
+ data["results"] = [
87
+ _ambiguity_result(
88
+ query=request.query.strip(),
89
+ candidates=data["candidates"],
90
+ score=999.0,
91
+ ),
92
+ *candidate_results,
93
+ ]
94
+ data["final_answer_instruction"] = (
95
+ "Start the final answer by saying multiple people match the queried name. "
96
+ "List every candidate with person_id and distinguishing details. "
97
+ "Do not start with a yes/no answer and do not answer as if one candidate was chosen. "
98
+ "If evidence applies to only one candidate, say that candidate-specific evidence after listing all candidates."
99
+ )
100
+ data["missing_info"].insert(
101
+ 0,
102
+ "Multiple person cards matched this full-name query. You must list all candidates; do not infer that the user probably means only one candidate.",
103
+ )
104
+ if not candidate_results and len(data["missing_info"]) == 1:
105
+ data["missing_info"].append(
106
+ "No matching retrieval items were found for the ambiguous candidates."
107
+ )
108
+ return data
109
+
110
+ def get_person(self, payload: dict[str, Any]) -> dict[str, Any]:
111
+ request = GetPersonInput.model_validate(payload)
112
+ person_id = request.person_id.strip() if request.person_id else ""
113
+ if not person_id and request.name:
114
+ name = request.name.strip()
115
+ exact_cards = self._card.get_people_by_name(name)
116
+ if len(exact_cards) == 1:
117
+ data = exact_cards[0].model_dump(mode="json")
118
+ data["found"] = True
119
+ return data
120
+ if len(exact_cards) > 1:
121
+ return {
122
+ "person_id": None,
123
+ "name": name,
124
+ "found": False,
125
+ "ambiguous": True,
126
+ "result_type": "ambiguous_person_name",
127
+ "answer_policy": (
128
+ "Do not choose one candidate. Present every candidate "
129
+ "and ask the user to clarify by person_id or distinguishing details."
130
+ ),
131
+ "missing_info": [
132
+ "Multiple person cards matched this name. You must list all candidates; do not answer as if one person matched."
133
+ ],
134
+ "candidates": [
135
+ _candidate_payload_from_card(card) for card in exact_cards
136
+ ],
137
+ }
138
+ person_id = self._find_person_id_by_name(name)
139
+ if not person_id:
140
+ return {
141
+ "person_id": None,
142
+ "name": name,
143
+ "found": False,
144
+ "missing_info": ["No person card found for this name."],
145
+ "candidates": [],
146
+ }
147
+ card = self._card.get_person(person_id)
148
+ if card is None:
149
+ return {
150
+ "person_id": person_id,
151
+ "name": request.name.strip() if request.name else None,
152
+ "found": False,
153
+ "missing_info": ["No person card found for this person_id."],
154
+ "candidates": [],
155
+ }
156
+ data = card.model_dump(mode="json")
157
+ data["found"] = True
158
+ return data
159
+
160
+ def _find_person_id_by_name(self, name: str) -> str | None:
161
+ response = self._retrieve.retrieve(name, limit=5, mode="recall")
162
+ person_ids: list[str] = []
163
+ for item in response.results:
164
+ for person_id in item.person_ids:
165
+ if person_id not in person_ids:
166
+ person_ids.append(person_id)
167
+ if len(person_ids) != 1:
168
+ return None
169
+ return person_ids[0]
170
+
171
+ def _duplicate_profile_name_ambiguity(
172
+ self, results: list[dict[str, Any]]
173
+ ) -> dict[str, Any] | None:
174
+ profile_person_ids_by_name: dict[str, set[str]] = {}
175
+ for item in results:
176
+ title = str(item.get("title") or "")
177
+ if not title.startswith("Profile summary for "):
178
+ continue
179
+ name = title.removeprefix("Profile summary for ").strip()
180
+ if not name:
181
+ continue
182
+ person_ids = {
183
+ str(person_id)
184
+ for person_id in item.get("person_ids", [])
185
+ if person_id
186
+ }
187
+ profile_person_ids_by_name.setdefault(name, set()).update(person_ids)
188
+
189
+ for name, profile_person_ids in profile_person_ids_by_name.items():
190
+ cards = self._card.get_people_by_name(name)
191
+ if len(cards) <= 1:
192
+ continue
193
+ card_ids = {card.person_id for card in cards}
194
+ if len(profile_person_ids & card_ids) <= 1:
195
+ continue
196
+ return {
197
+ "ambiguous": True,
198
+ "result_type": "ambiguous_person_name",
199
+ "answer_policy": (
200
+ "Do not choose one candidate or say the user probably means one candidate. "
201
+ "Present every candidate first with person_id and distinguishing details, "
202
+ "then ask the user to clarify if needed."
203
+ ),
204
+ "candidates": [_candidate_payload_from_card(card) for card in cards],
205
+ }
206
+ return None
207
+
208
+ def _duplicate_query_name_ambiguity(self, query: str) -> dict[str, Any] | None:
209
+ for candidate_name in _query_name_candidates(query):
210
+ cards = self._card.get_people_by_name(candidate_name)
211
+ if len(cards) <= 1:
212
+ continue
213
+ narrowed_cards = _narrow_cards_by_query_qualifiers(query, cards)
214
+ if len(narrowed_cards) == 1:
215
+ continue
216
+ return {
217
+ "ambiguous": True,
218
+ "result_type": "ambiguous_person_name",
219
+ "answer_policy": (
220
+ "Do not choose one candidate or say the user probably means one candidate. "
221
+ "Present every candidate first with person_id and distinguishing details, "
222
+ "then ask the user to clarify if needed."
223
+ ),
224
+ "candidates": [_candidate_payload_from_card(card) for card in cards],
225
+ }
226
+ return None
227
+
228
+ def _duplicate_query_name_resolution(self, query: str) -> Any | None:
229
+ for candidate_name in _query_name_candidates(query):
230
+ cards = self._card.get_people_by_name(candidate_name)
231
+ if len(cards) <= 1:
232
+ continue
233
+ narrowed_cards = _narrow_cards_by_query_qualifiers(query, cards)
234
+ if len(narrowed_cards) == 1:
235
+ return narrowed_cards[0]
236
+ return None
237
+
238
+ def _single_query_name_resolution(self, query: str) -> Any | None:
239
+ for candidate_name in _query_name_candidates(query):
240
+ cards = self._card.get_people_by_name(candidate_name)
241
+ if len(cards) > 1:
242
+ return None
243
+ if len(cards) == 1:
244
+ return cards[0]
245
+ return None
246
+
247
+
248
+ def _coerce_record_interaction_payload(payload: dict[str, Any]) -> dict[str, Any]:
249
+ coerced = dict(payload)
250
+ if "location" in coerced and "place" not in coerced:
251
+ coerced["place"] = coerced.pop("location")
252
+ if "date" in coerced and "occurred_at" not in coerced:
253
+ date_value = str(coerced.pop("date")).strip()
254
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_value):
255
+ coerced["occurred_at"] = f"{date_value}T00:00:00+00:00"
256
+ for field_name in ["participants"]:
257
+ if isinstance(coerced.get(field_name), list):
258
+ coerced[field_name] = [
259
+ _coerce_participant(item) for item in coerced[field_name]
260
+ ]
261
+ if isinstance(coerced.get("mentioned_people"), list):
262
+ coerced["mentioned_people"] = [
263
+ _coerce_mentioned_person(item) for item in coerced["mentioned_people"]
264
+ ]
265
+ if isinstance(coerced.get("follow_ups"), list):
266
+ coerced["follow_ups"] = [
267
+ _coerce_follow_up(item) for item in coerced["follow_ups"]
268
+ ]
269
+ return coerced
270
+
271
+
272
+ def _coerce_participant(item: Any) -> Any:
273
+ if isinstance(item, str):
274
+ return {"person": {"label": item}}
275
+ if not isinstance(item, dict):
276
+ return item
277
+ if "person" in item:
278
+ return {**item, "person": _coerce_person_ref(item["person"])}
279
+ if "name" in item:
280
+ role = item.get("role", "participant")
281
+ return {"person": {"label": item["name"]}, "role": role}
282
+ return item
283
+
284
+
285
+ def _coerce_mentioned_person(item: Any) -> Any:
286
+ if isinstance(item, str):
287
+ return {"person": {"label": item}}
288
+ if not isinstance(item, dict):
289
+ return item
290
+ coerced = dict(item)
291
+ if "person" in coerced:
292
+ coerced["person"] = _coerce_person_ref(coerced["person"])
293
+ elif "name" in coerced:
294
+ coerced["person"] = {"label": coerced.pop("name")}
295
+ if "mentioned_by" in coerced:
296
+ coerced["mentioned_by"] = _coerce_person_ref(coerced["mentioned_by"])
297
+ return coerced
298
+
299
+
300
+ def _coerce_follow_up(item: Any) -> Any:
301
+ if isinstance(item, str):
302
+ return {"description": item}
303
+ if not isinstance(item, dict):
304
+ return item
305
+ coerced = dict(item)
306
+ if "what" in coerced and "description" not in coerced:
307
+ coerced["description"] = coerced.pop("what")
308
+ if "due" in coerced and "due_at" not in coerced:
309
+ coerced["due_at"] = coerced.pop("due")
310
+ if isinstance(coerced.get("related_people"), list):
311
+ coerced["related_people"] = [
312
+ _coerce_person_ref(ref) for ref in coerced["related_people"]
313
+ ]
314
+ return coerced
315
+
316
+
317
+ def _coerce_person_ref(item: Any) -> Any:
318
+ if isinstance(item, str):
319
+ return {"label": item}
320
+ if isinstance(item, dict) and "name" in item and "label" not in item:
321
+ coerced = dict(item)
322
+ coerced["label"] = coerced.pop("name")
323
+ return coerced
324
+ return item
325
+
326
+
327
+ def _query_name_candidates(query: str) -> list[str]:
328
+ candidates: list[str] = []
329
+ for run in re.findall(r"[\u4e00-\u9fff]{2,20}", query):
330
+ max_width = min(8, len(run))
331
+ for width in range(max_width, 1, -1):
332
+ for start in range(0, len(run) - width + 1):
333
+ candidates.append(run[start : start + width])
334
+
335
+ ascii_tokens = re.findall(r"[A-Za-z][A-Za-z0-9_-]*", query)
336
+ max_width = min(5, len(ascii_tokens))
337
+ for width in range(max_width, 1, -1):
338
+ for start in range(0, len(ascii_tokens) - width + 1):
339
+ candidates.append(" ".join(ascii_tokens[start : start + width]))
340
+
341
+ seen: set[str] = set()
342
+ unique: list[str] = []
343
+ for candidate in sorted(candidates, key=len, reverse=True):
344
+ normalized = candidate.casefold()
345
+ if normalized in seen:
346
+ continue
347
+ seen.add(normalized)
348
+ unique.append(candidate)
349
+ return unique
350
+
351
+
352
+ def _filter_results_to_person(
353
+ results: list[dict[str, Any]], person_id: str
354
+ ) -> list[dict[str, Any]]:
355
+ return [
356
+ item
357
+ for item in results
358
+ if person_id in {str(value) for value in item.get("person_ids", [])}
359
+ ]
360
+
361
+
362
+ def _augment_resolved_person_results(
363
+ *, query: str, results: list[dict[str, Any]], card: Any
364
+ ) -> list[dict[str, Any]]:
365
+ augmented = list(results)
366
+ seen_texts = {str(item.get("matched_text") or "") for item in augmented}
367
+ if _query_mentions_follow_up_status(query):
368
+ follow_up_result = _resolved_person_follow_up_result(card)
369
+ if follow_up_result["matched_text"] not in seen_texts:
370
+ augmented.append(follow_up_result)
371
+ seen_texts.add(follow_up_result["matched_text"])
372
+ for index, source_text in enumerate(getattr(card, "recent_interactions", []), 1):
373
+ if not source_text or source_text in seen_texts:
374
+ continue
375
+ if not _should_add_resolved_recent_interaction(query, source_text):
376
+ continue
377
+ augmented.append(
378
+ {
379
+ "item_id": f"card-recent:{card.person_id}:{index}",
380
+ "kind": "interaction",
381
+ "title": f"Interaction with {card.display_name}",
382
+ "matched_text": source_text,
383
+ "score": _resolved_person_interaction_score(query, source_text),
384
+ "why_matched": (
385
+ "Recent interaction for the person identified by explicit query qualifiers."
386
+ ),
387
+ "person_ids": [card.person_id],
388
+ "sensitivity": [],
389
+ "evidence": [],
390
+ "is_secondhand": False,
391
+ }
392
+ )
393
+ seen_texts.add(source_text)
394
+ return sorted(
395
+ augmented,
396
+ key=lambda item: float(item.get("score") or 0.0),
397
+ reverse=True,
398
+ )
399
+
400
+
401
+ def _resolved_person_interaction_score(query: str, text: str) -> float:
402
+ compact_query = _compact_match_text(query)
403
+ compact_text = _compact_match_text(text)
404
+ score = 1.0
405
+ if "资料" in compact_query and "资料" in compact_text:
406
+ score += 2.0
407
+ if "发" in compact_query and "发" in compact_text:
408
+ score += 2.0
409
+ if "已经" in compact_text and "发" in compact_text:
410
+ score += 3.0
411
+ return score
412
+
413
+
414
+ def _should_add_resolved_recent_interaction(query: str, text: str) -> bool:
415
+ compact_query = _compact_match_text(query)
416
+ compact_text = _compact_match_text(text)
417
+ if "资料" in compact_query and "资料" in compact_text:
418
+ return True
419
+ if _query_mentions_follow_up_status(query) and any(
420
+ term in compact_text for term in ["已经", "约好", "答应", "承诺", "待跟进"]
421
+ ):
422
+ return True
423
+ return False
424
+
425
+
426
+ def _query_mentions_follow_up_status(query: str) -> bool:
427
+ compact_query = _compact_match_text(query)
428
+ return any(
429
+ term in compact_query
430
+ for term in ["待跟进", "还需要", "需要发", "需要给", "followup", "follow-up"]
431
+ )
432
+
433
+
434
+ def _resolved_person_follow_up_result(card: Any) -> dict[str, Any]:
435
+ open_follow_ups = list(getattr(card, "open_follow_ups", []) or [])
436
+ label = _card_label_with_aliases(card)
437
+ if open_follow_ups:
438
+ descriptions = [
439
+ str(getattr(item, "description", "") or item)
440
+ for item in open_follow_ups
441
+ ]
442
+ matched_text = (
443
+ f"{label}({card.person_id})当前有"
444
+ f"{len(open_follow_ups)}个待跟进事项:" + ";".join(descriptions)
445
+ )
446
+ else:
447
+ matched_text = f"{label}({card.person_id})当前没有待跟进事项。"
448
+ return {
449
+ "item_id": f"follow-up-status:{card.person_id}",
450
+ "kind": "follow_up",
451
+ "title": f"Open follow-ups for {card.display_name}",
452
+ "matched_text": matched_text,
453
+ "score": 9.0,
454
+ "why_matched": "Follow-up status for the person identified by the query.",
455
+ "person_ids": [card.person_id],
456
+ "sensitivity": [],
457
+ "evidence": [],
458
+ "is_secondhand": False,
459
+ }
460
+
461
+
462
+ def _card_label_with_aliases(card: Any) -> str:
463
+ aliases = [str(alias) for alias in getattr(card, "aliases", []) if alias]
464
+ if not aliases:
465
+ return str(card.display_name)
466
+ return f"{card.display_name},别名:" + "、".join(aliases)
467
+
468
+
469
+ def _narrow_cards_by_query_qualifiers(query: str, cards: list[Any]) -> list[Any]:
470
+ scored_cards = [
471
+ (_card_query_qualifier_score(query, card), card)
472
+ for card in cards
473
+ ]
474
+ positive_scores = [score for score, _card in scored_cards if score > 0]
475
+ if not positive_scores:
476
+ return []
477
+ winning_score = max(positive_scores)
478
+ return [card for score, card in scored_cards if score == winning_score]
479
+
480
+
481
+ def _card_query_qualifier_score(query: str, card: Any) -> int:
482
+ compact_query = _compact_match_text(query)
483
+ score = 0
484
+ for term, weight in _card_query_qualifier_terms(card):
485
+ compact_term = _compact_match_text(term)
486
+ if len(compact_term) < 2:
487
+ continue
488
+ if compact_term in compact_query:
489
+ score += weight
490
+ return score
491
+
492
+
493
+ def _card_query_qualifier_terms(card: Any) -> list[tuple[str, int]]:
494
+ terms: list[tuple[str, int]] = []
495
+ for work in getattr(card, "work_history", []):
496
+ for field_name in ["organization", "role", "department", "location"]:
497
+ value = getattr(work, field_name, None)
498
+ if value:
499
+ terms.append((str(value), 10))
500
+ for value in [
501
+ getattr(card, "headline", None),
502
+ getattr(card, "summary", None),
503
+ ]:
504
+ if value:
505
+ terms.append((str(value), 3))
506
+ for value in getattr(card, "interests", []):
507
+ if value:
508
+ terms.append((str(value), 2))
509
+ return terms
510
+
511
+
512
+ def _compact_match_text(text: str) -> str:
513
+ return re.sub(r"\s+", "", text.casefold())
514
+
515
+
516
+ def _ambiguity_result(
517
+ *, query: str, candidates: list[dict[str, Any]], score: float
518
+ ) -> dict[str, Any]:
519
+ candidate_lines = []
520
+ person_ids = []
521
+ for candidate in candidates:
522
+ person_id = str(candidate.get("person_id") or "")
523
+ if person_id:
524
+ person_ids.append(person_id)
525
+ candidate_lines.append(
526
+ " - ".join(
527
+ part
528
+ for part in [
529
+ person_id,
530
+ str(candidate.get("display_name") or ""),
531
+ str(candidate.get("headline") or ""),
532
+ ]
533
+ if part
534
+ )
535
+ )
536
+ matched_text = (
537
+ f"Ambiguous people-memory query: {query}. "
538
+ "Do not answer this as a single-person yes/no or single-person fact. "
539
+ "List every candidate first and ask which one if needed. Candidates: "
540
+ + " | ".join(candidate_lines)
541
+ )
542
+ return {
543
+ "item_id": f"ambiguity:{abs(hash(matched_text))}",
544
+ "kind": "fact",
545
+ "title": "Ambiguous person name",
546
+ "matched_text": matched_text,
547
+ "score": score,
548
+ "why_matched": "Multiple person cards share the queried display name.",
549
+ "person_ids": person_ids,
550
+ "sensitivity": [],
551
+ "evidence": [],
552
+ "is_secondhand": False,
553
+ }
554
+
555
+
556
+ def _candidate_results(
557
+ *, candidates: list[dict[str, Any]], results: list[dict[str, Any]]
558
+ ) -> list[dict[str, Any]]:
559
+ grouped: list[dict[str, Any]] = []
560
+ for candidate in candidates:
561
+ person_id = str(candidate.get("person_id") or "")
562
+ grouped.append(
563
+ {
564
+ "person_id": person_id,
565
+ "display_name": candidate.get("display_name"),
566
+ "headline": candidate.get("headline"),
567
+ "matched_results": [
568
+ item
569
+ for item in results
570
+ if person_id and person_id in item.get("person_ids", [])
571
+ ],
572
+ }
573
+ )
574
+ return grouped
575
+
576
+
577
+ def _candidate_payload_from_card(card: Any) -> dict[str, Any]:
578
+ return {
579
+ "person_id": card.person_id,
580
+ "display_name": card.display_name,
581
+ "aliases": getattr(card, "aliases", []),
582
+ "headline": card.headline,
583
+ "summary": card.summary,
584
+ "work_history": [item.model_dump(mode="json") for item in card.work_history],
585
+ "interests": card.interests,
586
+ "preferences": card.preferences,
587
+ "recent_interactions": card.recent_interactions,
588
+ }
@@ -0,0 +1,2 @@
1
+ """Application ports."""
2
+
@@ -0,0 +1,25 @@
1
+ """Typed errors shared across non-domain layers."""
2
+
3
+
4
+ class PeopleMemoryError(Exception):
5
+ """Base class for expected product errors."""
6
+
7
+
8
+ class ConfigError(PeopleMemoryError):
9
+ """Runtime configuration is invalid or incomplete."""
10
+
11
+
12
+ class BackendUnavailableError(PeopleMemoryError):
13
+ """The configured memory backend cannot be reached."""
14
+
15
+
16
+ class SearchError(PeopleMemoryError):
17
+ """Search failed in the configured backend."""
18
+
19
+
20
+ class PersistenceError(PeopleMemoryError):
21
+ """A write operation failed."""
22
+
23
+
24
+ class HarnessInstallError(PeopleMemoryError):
25
+ """A harness adapter could not be installed or inspected."""