@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,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)
|