@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,508 @@
1
+ """Deterministic harness-memory integration evals.
2
+
3
+ These checks model the contract that a harness should follow after reading the
4
+ OpenClaw skill: use this MCP for people/network evidence, then apply the
5
+ harness's own user memory for style, goals, and task constraints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal
12
+
13
+ from people_network_memory.config import PeopleMemoryConfig
14
+ from people_network_memory.harness_adapters.openclaw.smoke import (
15
+ route_prompt_for_openclaw_skill,
16
+ )
17
+ from people_network_memory.mcp_server.runtime import build_runtime
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class HarnessIntegrationCase:
22
+ case_id: str
23
+ prompt: str
24
+ harness_memory: list[str]
25
+ expected_mode: Literal["recall", "brief"] = "recall"
26
+ sensitivity_policy: Literal["personal", "strict", "task_aware"] = "personal"
27
+ output_context: Literal["private", "shareable"] = "private"
28
+ expected_social_terms: list[str] = field(default_factory=list)
29
+ expected_user_memory_terms: list[str] = field(default_factory=list)
30
+ forbidden_social_fact_terms: list[str] = field(default_factory=list)
31
+ require_get_person: bool = False
32
+ require_follow_up: bool = False
33
+ require_no_named_person_evidence: str | None = None
34
+ expect_conflict_resolution: bool = False
35
+
36
+ def to_json(self) -> dict[str, Any]:
37
+ return {
38
+ "case_id": self.case_id,
39
+ "prompt": self.prompt,
40
+ "harness_memory": self.harness_memory,
41
+ "expected_mode": self.expected_mode,
42
+ "sensitivity_policy": self.sensitivity_policy,
43
+ "output_context": self.output_context,
44
+ "expected_social_terms": self.expected_social_terms,
45
+ "expected_user_memory_terms": self.expected_user_memory_terms,
46
+ "forbidden_social_fact_terms": self.forbidden_social_fact_terms,
47
+ "require_get_person": self.require_get_person,
48
+ "require_follow_up": self.require_follow_up,
49
+ "require_no_named_person_evidence": self.require_no_named_person_evidence,
50
+ "expect_conflict_resolution": self.expect_conflict_resolution,
51
+ }
52
+
53
+
54
+ HARNESS_INTEGRATION_CASES = [
55
+ HarnessIntegrationCase(
56
+ case_id="dinner_group_curates_social_graph_with_user_preferences",
57
+ prompt=(
58
+ "Who should I invite to dinner in Shanghai for robotics and founder intros? "
59
+ "Use what you know about my preferences."
60
+ ),
61
+ harness_memory=[
62
+ "The user prefers small dinners, practical robotics conversations, and warm founder introductions."
63
+ ],
64
+ expected_social_terms=["Shanghai", "robotics", "founder intros"],
65
+ expected_user_memory_terms=["small dinners", "practical robotics conversations"],
66
+ require_get_person=True,
67
+ ),
68
+ HarnessIntegrationCase(
69
+ case_id="shareable_intro_uses_style_memory_without_sensitive_claims",
70
+ prompt="Draft a warm intro message to Alice Zhang about a robotics founder dinner.",
71
+ harness_memory=[
72
+ "The user prefers concise warm messages and does not want sensitive claims in shareable drafts."
73
+ ],
74
+ sensitivity_policy="task_aware",
75
+ output_context="shareable",
76
+ expected_social_terms=["Alice Zhang", "Tencent Robotics", "robotics"],
77
+ expected_user_memory_terms=["concise warm messages"],
78
+ forbidden_social_fact_terms=["may leave Ant Group"],
79
+ require_get_person=True,
80
+ ),
81
+ HarnessIntegrationCase(
82
+ case_id="conflict_prefers_mcp_for_person_fact",
83
+ prompt="Brief me on Alice Zhang's current company before I meet her.",
84
+ harness_memory=["Older harness memory says Alice Zhang works at ByteDance AI Lab."],
85
+ expected_mode="brief",
86
+ expected_social_terms=["Alice Zhang", "Tencent Robotics"],
87
+ expected_user_memory_terms=["Older harness memory"],
88
+ forbidden_social_fact_terms=["ByteDance AI Lab"],
89
+ require_get_person=True,
90
+ expect_conflict_resolution=True,
91
+ ),
92
+ HarnessIntegrationCase(
93
+ case_id="missing_social_graph_evidence_is_separated_from_user_memory",
94
+ prompt="Should I invite Kai Wu to the robotics dinner?",
95
+ harness_memory=["The user likes robotics founders and prefers warm intros."],
96
+ expected_social_terms=[],
97
+ expected_user_memory_terms=["robotics founders", "warm intros"],
98
+ require_no_named_person_evidence="Kai Wu",
99
+ ),
100
+ HarnessIntegrationCase(
101
+ case_id="promise_query_uses_follow_up_plus_user_style",
102
+ prompt="What did I promise Alice Zhang and how should I phrase the reminder in my usual style?",
103
+ harness_memory=["The user prefers concise action-item reminders."],
104
+ expected_social_terms=["founder contacts"],
105
+ expected_user_memory_terms=["concise action-item reminders"],
106
+ require_follow_up=True,
107
+ require_get_person=True,
108
+ ),
109
+ ]
110
+
111
+
112
+ def run_harness_memory_integration_eval() -> dict[str, Any]:
113
+ runtime = build_runtime(PeopleMemoryConfig(test_mode=True))
114
+ try:
115
+ _seed_social_memory(runtime.tools)
116
+ cases = [_run_case(runtime.tools, case) for case in HARNESS_INTEGRATION_CASES]
117
+ return {
118
+ "ok": all(case["ok"] for case in cases),
119
+ "checked": len(cases),
120
+ "passed": sum(1 for case in cases if case["ok"]),
121
+ "failed": sum(1 for case in cases if not case["ok"]),
122
+ "cases": cases,
123
+ }
124
+ finally:
125
+ runtime.close()
126
+
127
+
128
+ def _seed_social_memory(tools: Any) -> None:
129
+ alice_record = tools.record_interaction(
130
+ {
131
+ "source_text": (
132
+ "Met Alice Zhang at Blue Bottle Coffee in Shanghai. We discussed robotics "
133
+ "hiring and founder intros. Alice Zhang works at Tencent Robotics as product lead."
134
+ ),
135
+ "place": "Blue Bottle Coffee Shanghai",
136
+ "participants": [{"person": {"label": "Alice Zhang"}}],
137
+ "topics": ["robotics hiring", "founder intros"],
138
+ "direct_facts": [
139
+ {
140
+ "subject": {"label": "Alice Zhang"},
141
+ "predicate": "works_at",
142
+ "value": "Tencent Robotics",
143
+ "metadata": {"role": "product lead"},
144
+ },
145
+ {
146
+ "subject": {"label": "Alice Zhang"},
147
+ "predicate": "interest",
148
+ "value": "robotics hiring",
149
+ },
150
+ {
151
+ "subject": {"label": "Alice Zhang"},
152
+ "predicate": "interest",
153
+ "value": "founder intros",
154
+ },
155
+ ],
156
+ }
157
+ )
158
+ alice_id = alice_record["person_ref_map"]["Alice Zhang"]
159
+ bob_record = tools.record_interaction(
160
+ {
161
+ "source_text": (
162
+ "Met Bob Li at Zhangjiang Cafe in Shanghai. We discussed robot learning "
163
+ "and founder intros. Bob Li works at Ant Group."
164
+ ),
165
+ "place": "Zhangjiang Cafe Shanghai",
166
+ "participants": [{"person": {"label": "Bob Li"}}],
167
+ "topics": ["robot learning", "founder intros"],
168
+ "direct_facts": [
169
+ {
170
+ "subject": {"label": "Bob Li"},
171
+ "predicate": "works_at",
172
+ "value": "Ant Group",
173
+ },
174
+ {
175
+ "subject": {"label": "Bob Li"},
176
+ "predicate": "interest",
177
+ "value": "robot learning",
178
+ },
179
+ {
180
+ "subject": {"label": "Bob Li"},
181
+ "predicate": "interest",
182
+ "value": "founder intros",
183
+ },
184
+ ],
185
+ }
186
+ )
187
+ bob_id = bob_record["person_ref_map"]["Bob Li"]
188
+ tools.record_interaction(
189
+ {
190
+ "source_text": (
191
+ "Had dinner with Fiona Chen in Shanghai. We discussed Japanese food and "
192
+ "founder intros. Fiona Chen knows Alice Zhang."
193
+ ),
194
+ "place": "Shanghai",
195
+ "interaction_type": "dinner",
196
+ "participants": [{"person": {"label": "Fiona Chen"}}],
197
+ "mentioned_people": [
198
+ {
199
+ "person": {"label": "Alice Zhang", "person_id": alice_id},
200
+ "mentioned_by": {"label": "Fiona Chen"},
201
+ "context": "Fiona Chen knows Alice Zhang.",
202
+ }
203
+ ],
204
+ "topics": ["Japanese food", "founder intros"],
205
+ "relationships": [
206
+ {
207
+ "source": {"label": "Fiona Chen"},
208
+ "target": {"label": "Alice Zhang", "person_id": alice_id},
209
+ "relationship_type": "knows",
210
+ }
211
+ ],
212
+ }
213
+ )
214
+ tools.record_interaction(
215
+ {
216
+ "source_text": (
217
+ "Alice Zhang said Bob Li may leave Ant Group. Treat this as sensitive "
218
+ "secondhand context, not as a shareable fact."
219
+ ),
220
+ "participants": [{"person": {"label": "Alice Zhang", "person_id": alice_id}}],
221
+ "mentioned_people": [
222
+ {
223
+ "person": {"label": "Bob Li", "person_id": bob_id},
224
+ "mentioned_by": {"label": "Alice Zhang", "person_id": alice_id},
225
+ "context": "Alice Zhang said Bob Li may leave Ant Group.",
226
+ }
227
+ ],
228
+ "attributed_claims": [
229
+ {
230
+ "speaker": {"label": "Alice Zhang", "person_id": alice_id},
231
+ "subject": {"label": "Bob Li", "person_id": bob_id},
232
+ "claim_text": "Alice Zhang said Bob Li may leave Ant Group.",
233
+ "sensitivity": ["sensitive", "secondhand", "unverified"],
234
+ }
235
+ ],
236
+ }
237
+ )
238
+ tools.record_interaction(
239
+ {
240
+ "source_text": (
241
+ "Promised Alice Zhang I would send two founder contacts next week."
242
+ ),
243
+ "participants": [{"person": {"label": "Alice Zhang", "person_id": alice_id}}],
244
+ "follow_ups": [
245
+ {
246
+ "description": "Follow up with Alice Zhang by sending two founder contacts.",
247
+ "related_people": [{"label": "Alice Zhang", "person_id": alice_id}],
248
+ }
249
+ ],
250
+ }
251
+ )
252
+
253
+
254
+ def _run_case(tools: Any, case: HarnessIntegrationCase) -> dict[str, Any]:
255
+ route = route_prompt_for_openclaw_skill(case.prompt)
256
+ retrieve_payload = {
257
+ "query": case.prompt,
258
+ "mode": case.expected_mode,
259
+ "limit": 8,
260
+ "sensitivity_policy": case.sensitivity_policy,
261
+ "output_context": case.output_context,
262
+ }
263
+ retrieval = tools.retrieve_network_context(retrieve_payload)
264
+ person_cards = _get_shortlisted_cards(tools, retrieval, enabled=case.require_get_person)
265
+ simulated = _simulate_harness_answer(case, retrieval, person_cards)
266
+ checks = _checks(case, route, retrieval, person_cards, simulated)
267
+ return {
268
+ "ok": all(check["ok"] for check in checks),
269
+ "case": case.to_json(),
270
+ "route": route,
271
+ "tool_calls": _tool_calls(retrieve_payload, person_cards),
272
+ "retrieval_result_count": len(retrieval["results"]),
273
+ "top_results": [_compact_result(item) for item in retrieval["results"][:5]],
274
+ "simulated_harness_answer": simulated,
275
+ "checks": checks,
276
+ }
277
+
278
+
279
+ def _get_shortlisted_cards(tools: Any, retrieval: dict[str, Any], *, enabled: bool) -> list[dict[str, Any]]:
280
+ if not enabled:
281
+ return []
282
+ person_ids: list[str] = []
283
+ for result in retrieval["results"]:
284
+ for person_id in result.get("person_ids", []):
285
+ if person_id not in person_ids:
286
+ person_ids.append(person_id)
287
+ cards: list[dict[str, Any]] = []
288
+ for person_id in person_ids[:5]:
289
+ card = tools.get_person({"person_id": person_id})
290
+ if card.get("found"):
291
+ cards.append(card)
292
+ return cards
293
+
294
+
295
+ def _simulate_harness_answer(
296
+ case: HarnessIntegrationCase,
297
+ retrieval: dict[str, Any],
298
+ person_cards: list[dict[str, Any]],
299
+ ) -> dict[str, Any]:
300
+ evidence_text = _joined_retrieval_text(retrieval, person_cards)
301
+ social_terms = [term for term in case.expected_social_terms if _contains(evidence_text, term)]
302
+ user_memory_text = " ".join(case.harness_memory)
303
+ user_memory_terms = [
304
+ term for term in case.expected_user_memory_terms if _contains(user_memory_text, term)
305
+ ]
306
+ forbidden_as_social_fact = [
307
+ term for term in case.forbidden_social_fact_terms if _contains(evidence_text, term)
308
+ ]
309
+ shortlisted_people = [
310
+ {
311
+ "person_id": card["person_id"],
312
+ "display_name": card["display_name"],
313
+ "headline": card.get("headline"),
314
+ "why": _person_reason(card),
315
+ }
316
+ for card in person_cards
317
+ ]
318
+ named_missing = case.require_no_named_person_evidence
319
+ no_named_person_evidence = (
320
+ bool(named_missing)
321
+ and not any(_contains(_result_text(item), named_missing or "") for item in retrieval["results"])
322
+ )
323
+ conflict_resolution = None
324
+ if case.expect_conflict_resolution:
325
+ conflict_resolution = {
326
+ "person_fact_source": "mcp_evidence",
327
+ "preferred_fact": "Alice Zhang works at Tencent Robotics.",
328
+ "conflicting_harness_memory": "Older harness memory says Alice Zhang works at ByteDance AI Lab.",
329
+ "rule": "prefer MCP evidence for social-memory facts",
330
+ }
331
+ return {
332
+ "answer_strategy": (
333
+ "Use MCP evidence for people/network facts, then apply harness user memory "
334
+ "for tone, preferences, current goals, and task constraints."
335
+ ),
336
+ "social_evidence_terms_used": social_terms,
337
+ "user_memory_terms_used": user_memory_terms,
338
+ "shortlisted_people": shortlisted_people,
339
+ "forbidden_terms_seen_only_as_retrieved_context": forbidden_as_social_fact,
340
+ "no_named_person_evidence": no_named_person_evidence,
341
+ "conflict_resolution": conflict_resolution,
342
+ "source_boundary": {
343
+ "people_network_memory": "MCP retrieval and person cards",
344
+ "user_memory": "harness memory supplied with the case",
345
+ },
346
+ }
347
+
348
+
349
+ def _checks(
350
+ case: HarnessIntegrationCase,
351
+ route: dict[str, Any],
352
+ retrieval: dict[str, Any],
353
+ person_cards: list[dict[str, Any]],
354
+ simulated: dict[str, Any],
355
+ ) -> list[dict[str, Any]]:
356
+ checks = [
357
+ _check(
358
+ "routes_to_retrieval",
359
+ route.get("tool") == "retrieve_network_context"
360
+ and route.get("mode", "recall") == case.expected_mode,
361
+ {"route": route, "expected_mode": case.expected_mode},
362
+ ),
363
+ _check(
364
+ "uses_harness_user_memory_terms",
365
+ set(case.expected_user_memory_terms).issubset(simulated["user_memory_terms_used"]),
366
+ {
367
+ "expected": case.expected_user_memory_terms,
368
+ "used": simulated["user_memory_terms_used"],
369
+ },
370
+ ),
371
+ _check(
372
+ "uses_mcp_social_evidence_terms",
373
+ set(case.expected_social_terms).issubset(simulated["social_evidence_terms_used"]),
374
+ {
375
+ "expected": case.expected_social_terms,
376
+ "used": simulated["social_evidence_terms_used"],
377
+ },
378
+ ),
379
+ ]
380
+ if case.require_get_person:
381
+ checks.append(
382
+ _check(
383
+ "hydrates_shortlisted_people",
384
+ bool(person_cards),
385
+ {"card_count": len(person_cards)},
386
+ )
387
+ )
388
+ if case.require_follow_up:
389
+ checks.append(
390
+ _check(
391
+ "prioritizes_follow_up_results",
392
+ any(item.get("kind") == "follow_up" for item in retrieval["results"][:3]),
393
+ {"top_kinds": [item.get("kind") for item in retrieval["results"][:3]]},
394
+ )
395
+ )
396
+ if case.forbidden_social_fact_terms:
397
+ checks.append(
398
+ _check(
399
+ "does_not_promote_forbidden_harness_or_sensitive_terms_as_social_fact",
400
+ not simulated["forbidden_terms_seen_only_as_retrieved_context"],
401
+ {
402
+ "forbidden": case.forbidden_social_fact_terms,
403
+ "seen": simulated["forbidden_terms_seen_only_as_retrieved_context"],
404
+ },
405
+ )
406
+ )
407
+ if case.require_no_named_person_evidence:
408
+ checks.append(
409
+ _check(
410
+ "separates_missing_social_graph_evidence_from_user_memory",
411
+ simulated["no_named_person_evidence"],
412
+ {"missing_name": case.require_no_named_person_evidence},
413
+ )
414
+ )
415
+ if case.expect_conflict_resolution:
416
+ checks.append(
417
+ _check(
418
+ "conflict_prefers_mcp_for_social_fact",
419
+ bool(simulated["conflict_resolution"])
420
+ and _contains(_joined_retrieval_text(retrieval, person_cards), "Tencent Robotics"),
421
+ {"conflict_resolution": simulated["conflict_resolution"]},
422
+ )
423
+ )
424
+ return checks
425
+
426
+
427
+ def _tool_calls(retrieve_payload: dict[str, Any], person_cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
428
+ calls = [{"tool": "retrieve_network_context", "payload": retrieve_payload}]
429
+ calls.extend(
430
+ {"tool": "get_person", "payload": {"person_id": card["person_id"]}}
431
+ for card in person_cards
432
+ )
433
+ return calls
434
+
435
+
436
+ def _compact_result(item: dict[str, Any]) -> dict[str, Any]:
437
+ return {
438
+ "kind": item.get("kind"),
439
+ "title": item.get("title"),
440
+ "matched_text": item.get("matched_text"),
441
+ "why_matched": item.get("why_matched"),
442
+ "person_ids": item.get("person_ids", []),
443
+ "evidence": [
444
+ {
445
+ "source_text": evidence.get("source_text"),
446
+ "recorded_at": evidence.get("recorded_at"),
447
+ }
448
+ for evidence in item.get("evidence", [])[:2]
449
+ ],
450
+ }
451
+
452
+
453
+ def _check(name: str, ok: bool, detail: dict[str, Any]) -> dict[str, Any]:
454
+ return {"name": name, "ok": bool(ok), "detail": detail}
455
+
456
+
457
+ def _joined_retrieval_text(retrieval: dict[str, Any], person_cards: list[dict[str, Any]]) -> str:
458
+ result_text = " ".join(_result_text(item) for item in retrieval["results"])
459
+ card_text = " ".join(_card_text(card) for card in person_cards)
460
+ return f"{result_text} {card_text}"
461
+
462
+
463
+ def _result_text(item: dict[str, Any]) -> str:
464
+ evidence_text = " ".join(
465
+ str(evidence.get("source_text", "")) for evidence in item.get("evidence", [])
466
+ )
467
+ return " ".join(
468
+ [
469
+ str(item.get("title", "")),
470
+ str(item.get("matched_text", "")),
471
+ str(item.get("why_matched", "")),
472
+ evidence_text,
473
+ ]
474
+ )
475
+
476
+
477
+ def _card_text(card: dict[str, Any]) -> str:
478
+ work = " ".join(str(item.get("organization", "")) for item in card.get("work_history", []))
479
+ interests = " ".join(str(item) for item in card.get("interests", []))
480
+ follow_ups = " ".join(str(item.get("description", "")) for item in card.get("open_follow_ups", []))
481
+ return " ".join(
482
+ [
483
+ str(card.get("display_name", "")),
484
+ str(card.get("headline", "")),
485
+ str(card.get("summary", "")),
486
+ work,
487
+ interests,
488
+ follow_ups,
489
+ ]
490
+ )
491
+
492
+
493
+ def _person_reason(card: dict[str, Any]) -> str:
494
+ parts = []
495
+ if card.get("work_history"):
496
+ parts.append(
497
+ "works at "
498
+ + ", ".join(str(item.get("organization", "")) for item in card["work_history"][:2])
499
+ )
500
+ if card.get("interests"):
501
+ parts.append("interests: " + ", ".join(str(item) for item in card["interests"][:3]))
502
+ if card.get("open_follow_ups"):
503
+ parts.append("has open follow-up")
504
+ return "; ".join(parts) or "matched retrieved social-memory evidence"
505
+
506
+
507
+ def _contains(text: str, term: str) -> bool:
508
+ return term.lower() in text.lower()