@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.
- package/README.md +476 -0
- package/docs/mcp_tools.md +138 -0
- package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
- package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
- package/harness_adapters/openclaw/mcp.template.json +14 -0
- package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
- package/package.json +30 -0
- package/pyproject.toml +26 -0
- package/scripts/install_windows.ps1 +92 -0
- package/scripts/npm/people-memory.js +276 -0
- package/scripts/people_memory_bootstrap.py +247 -0
- package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
- package/scripts/run_tests_with_artifacts.ps1 +307 -0
- package/src/people_network_memory/__init__.py +6 -0
- package/src/people_network_memory/application/__init__.py +16 -0
- package/src/people_network_memory/application/normalization.py +1441 -0
- package/src/people_network_memory/application/services.py +921 -0
- package/src/people_network_memory/cli.py +1212 -0
- package/src/people_network_memory/config.py +268 -0
- package/src/people_network_memory/domain/__init__.py +55 -0
- package/src/people_network_memory/domain/identity.py +77 -0
- package/src/people_network_memory/domain/models.py +355 -0
- package/src/people_network_memory/fixtures/__init__.py +6 -0
- package/src/people_network_memory/fixtures/eval.py +398 -0
- package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
- package/src/people_network_memory/fixtures/generator.py +290 -0
- package/src/people_network_memory/fixtures/report.py +252 -0
- package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
- package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
- package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
- package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
- package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
- package/src/people_network_memory/harness_adapters/__init__.py +2 -0
- package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
- package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
- package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
- package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
- package/src/people_network_memory/infrastructure/__init__.py +2 -0
- package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
- package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
- package/src/people_network_memory/infrastructure/embeddings.py +155 -0
- package/src/people_network_memory/infrastructure/file_store.py +129 -0
- package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
- package/src/people_network_memory/infrastructure/id_generator.py +40 -0
- package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
- package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
- package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
- package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
- package/src/people_network_memory/infrastructure/redaction.py +21 -0
- package/src/people_network_memory/infrastructure/release_check.py +186 -0
- package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
- package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
- package/src/people_network_memory/mcp_server/__init__.py +2 -0
- package/src/people_network_memory/mcp_server/contracts.py +85 -0
- package/src/people_network_memory/mcp_server/runtime.py +133 -0
- package/src/people_network_memory/mcp_server/tools.py +588 -0
- package/src/people_network_memory/ports/__init__.py +2 -0
- package/src/people_network_memory/ports/errors.py +25 -0
- package/src/people_network_memory/ports/interfaces.py +103 -0
- package/src/people_network_memory/projection/__init__.py +6 -0
- 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,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."""
|