@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,268 @@
|
|
|
1
|
+
"""Runtime configuration and validation.
|
|
2
|
+
|
|
3
|
+
This module intentionally lives outside the domain/application layers because it
|
|
4
|
+
reads environment variables and filesystem-like paths.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from people_network_memory.ports.errors import ConfigError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
BackendKind = Literal["inmemory", "local_json", "graphiti"]
|
|
17
|
+
SensitivityPolicy = Literal["personal", "strict", "task_aware"]
|
|
18
|
+
RetrievalJudgeKind = Literal["off", "llm"]
|
|
19
|
+
IngestionExtractorKind = Literal["off", "llm"]
|
|
20
|
+
IdentityAdvisorKind = Literal["off", "llm"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class PeopleMemoryConfig:
|
|
25
|
+
backend: BackendKind = "inmemory"
|
|
26
|
+
graph_backend_kind: Literal["kuzu", "neo4j", "falkordb"] = "kuzu"
|
|
27
|
+
graph_backend_url: str | None = None
|
|
28
|
+
graphiti_kuzu_path: str = "~/.people-network-memory/graphiti.kuzu"
|
|
29
|
+
embedding_provider: str | None = None
|
|
30
|
+
embedding_base_url: str | None = None
|
|
31
|
+
embedding_model: str | None = None
|
|
32
|
+
embedding_api_key: str | None = None
|
|
33
|
+
embedding_dim: int | None = None
|
|
34
|
+
llm_provider: str | None = None
|
|
35
|
+
llm_base_url: str | None = None
|
|
36
|
+
llm_model: str | None = None
|
|
37
|
+
llm_api_key: str | None = None
|
|
38
|
+
llm_response_format: Literal["none", "json_object", "json_schema"] = "none"
|
|
39
|
+
ingestion_extractor: IngestionExtractorKind = "llm"
|
|
40
|
+
ingestion_extractor_timeout_seconds: float = 30.0
|
|
41
|
+
identity_advisor: IdentityAdvisorKind = "off"
|
|
42
|
+
identity_advisor_timeout_seconds: float = 30.0
|
|
43
|
+
retrieval_judge: RetrievalJudgeKind = "off"
|
|
44
|
+
retrieval_judge_timeout_seconds: float = 30.0
|
|
45
|
+
sensitivity_policy: SensitivityPolicy = "personal"
|
|
46
|
+
data_path: str = "~/.people-network-memory"
|
|
47
|
+
telemetry_enabled: bool = False
|
|
48
|
+
graphiti_add_timeout_seconds: float = 240.0
|
|
49
|
+
graphiti_search_timeout_seconds: float = 120.0
|
|
50
|
+
graphiti_retry_attempts: int = 3
|
|
51
|
+
test_mode: bool = False
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_env(cls, *, test_mode: bool = False) -> "PeopleMemoryConfig":
|
|
55
|
+
local_env = _load_local_env()
|
|
56
|
+
backend = _env(
|
|
57
|
+
"PEOPLE_MEMORY_BACKEND",
|
|
58
|
+
local_env,
|
|
59
|
+
"inmemory" if test_mode else "local_json",
|
|
60
|
+
).strip().lower()
|
|
61
|
+
if backend not in {"inmemory", "local_json", "graphiti"}:
|
|
62
|
+
raise ConfigError(f"Unsupported PEOPLE_MEMORY_BACKEND: {backend}")
|
|
63
|
+
llm_response_format = _env(
|
|
64
|
+
"PEOPLE_MEMORY_LLM_RESPONSE_FORMAT",
|
|
65
|
+
local_env,
|
|
66
|
+
"none",
|
|
67
|
+
).strip().lower()
|
|
68
|
+
if llm_response_format not in {"none", "json_object", "json_schema"}:
|
|
69
|
+
raise ConfigError(
|
|
70
|
+
"Unsupported PEOPLE_MEMORY_LLM_RESPONSE_FORMAT: "
|
|
71
|
+
f"{llm_response_format}"
|
|
72
|
+
)
|
|
73
|
+
sensitivity_policy = _env(
|
|
74
|
+
"PEOPLE_MEMORY_SENSITIVITY_POLICY",
|
|
75
|
+
local_env,
|
|
76
|
+
"personal",
|
|
77
|
+
).strip().lower()
|
|
78
|
+
if sensitivity_policy not in {"personal", "strict", "task_aware"}:
|
|
79
|
+
raise ConfigError(
|
|
80
|
+
"Unsupported PEOPLE_MEMORY_SENSITIVITY_POLICY: "
|
|
81
|
+
f"{sensitivity_policy}"
|
|
82
|
+
)
|
|
83
|
+
retrieval_judge = _env(
|
|
84
|
+
"PEOPLE_MEMORY_RETRIEVAL_JUDGE",
|
|
85
|
+
local_env,
|
|
86
|
+
"off",
|
|
87
|
+
).strip().lower()
|
|
88
|
+
if retrieval_judge not in {"off", "llm"}:
|
|
89
|
+
raise ConfigError(
|
|
90
|
+
"Unsupported PEOPLE_MEMORY_RETRIEVAL_JUDGE: "
|
|
91
|
+
f"{retrieval_judge}"
|
|
92
|
+
)
|
|
93
|
+
ingestion_extractor = _env(
|
|
94
|
+
"PEOPLE_MEMORY_INGESTION_EXTRACTOR",
|
|
95
|
+
local_env,
|
|
96
|
+
"llm",
|
|
97
|
+
).strip().lower()
|
|
98
|
+
if ingestion_extractor not in {"off", "llm"}:
|
|
99
|
+
raise ConfigError(
|
|
100
|
+
"Unsupported PEOPLE_MEMORY_INGESTION_EXTRACTOR: "
|
|
101
|
+
f"{ingestion_extractor}"
|
|
102
|
+
)
|
|
103
|
+
identity_advisor = _env(
|
|
104
|
+
"PEOPLE_MEMORY_IDENTITY_ADVISOR",
|
|
105
|
+
local_env,
|
|
106
|
+
"off",
|
|
107
|
+
).strip().lower()
|
|
108
|
+
if identity_advisor not in {"off", "llm"}:
|
|
109
|
+
raise ConfigError(
|
|
110
|
+
"Unsupported PEOPLE_MEMORY_IDENTITY_ADVISOR: "
|
|
111
|
+
f"{identity_advisor}"
|
|
112
|
+
)
|
|
113
|
+
return cls(
|
|
114
|
+
backend=backend, # type: ignore[arg-type]
|
|
115
|
+
graph_backend_kind=_env("PEOPLE_MEMORY_GRAPH_BACKEND", local_env, "kuzu").strip().lower(), # type: ignore[arg-type]
|
|
116
|
+
graph_backend_url=_env("PEOPLE_MEMORY_GRAPH_BACKEND_URL", local_env),
|
|
117
|
+
graphiti_kuzu_path=_env(
|
|
118
|
+
"PEOPLE_MEMORY_GRAPHITI_KUZU_PATH",
|
|
119
|
+
local_env,
|
|
120
|
+
"~/.people-network-memory/graphiti.kuzu",
|
|
121
|
+
),
|
|
122
|
+
embedding_provider=_env("PEOPLE_MEMORY_EMBEDDING_PROVIDER", local_env),
|
|
123
|
+
embedding_base_url=_env("PEOPLE_MEMORY_EMBEDDING_BASE_URL", local_env),
|
|
124
|
+
embedding_model=_env("PEOPLE_MEMORY_EMBEDDING_MODEL", local_env),
|
|
125
|
+
embedding_api_key=_env("PEOPLE_MEMORY_EMBEDDING_API_KEY", local_env),
|
|
126
|
+
embedding_dim=_optional_int(_env("PEOPLE_MEMORY_EMBEDDING_DIM", local_env)),
|
|
127
|
+
llm_provider=_env("PEOPLE_MEMORY_LLM_PROVIDER", local_env),
|
|
128
|
+
llm_base_url=_env("PEOPLE_MEMORY_LLM_BASE_URL", local_env),
|
|
129
|
+
llm_model=_env("PEOPLE_MEMORY_LLM_MODEL", local_env),
|
|
130
|
+
llm_api_key=_env("PEOPLE_MEMORY_LLM_API_KEY", local_env),
|
|
131
|
+
llm_response_format=llm_response_format, # type: ignore[arg-type]
|
|
132
|
+
ingestion_extractor=ingestion_extractor, # type: ignore[arg-type]
|
|
133
|
+
ingestion_extractor_timeout_seconds=_optional_float(
|
|
134
|
+
_env("PEOPLE_MEMORY_INGESTION_EXTRACTOR_TIMEOUT_SECONDS", local_env)
|
|
135
|
+
)
|
|
136
|
+
or 30.0,
|
|
137
|
+
identity_advisor=identity_advisor, # type: ignore[arg-type]
|
|
138
|
+
identity_advisor_timeout_seconds=_optional_float(
|
|
139
|
+
_env("PEOPLE_MEMORY_IDENTITY_ADVISOR_TIMEOUT_SECONDS", local_env)
|
|
140
|
+
)
|
|
141
|
+
or 30.0,
|
|
142
|
+
retrieval_judge=retrieval_judge, # type: ignore[arg-type]
|
|
143
|
+
retrieval_judge_timeout_seconds=_optional_float(
|
|
144
|
+
_env("PEOPLE_MEMORY_RETRIEVAL_JUDGE_TIMEOUT_SECONDS", local_env)
|
|
145
|
+
)
|
|
146
|
+
or 30.0,
|
|
147
|
+
sensitivity_policy=sensitivity_policy, # type: ignore[arg-type]
|
|
148
|
+
data_path=_env("PEOPLE_MEMORY_DATA_PATH", local_env, "~/.people-network-memory"),
|
|
149
|
+
telemetry_enabled=_env("GRAPHITI_TELEMETRY_ENABLED", local_env, "false").lower()
|
|
150
|
+
in {"1", "true", "yes"},
|
|
151
|
+
graphiti_add_timeout_seconds=_optional_float(
|
|
152
|
+
_env("PEOPLE_MEMORY_GRAPHITI_ADD_TIMEOUT_SECONDS", local_env)
|
|
153
|
+
)
|
|
154
|
+
or 240.0,
|
|
155
|
+
graphiti_search_timeout_seconds=_optional_float(
|
|
156
|
+
_env("PEOPLE_MEMORY_GRAPHITI_SEARCH_TIMEOUT_SECONDS", local_env)
|
|
157
|
+
)
|
|
158
|
+
or 120.0,
|
|
159
|
+
graphiti_retry_attempts=_optional_int(
|
|
160
|
+
_env("PEOPLE_MEMORY_GRAPHITI_RETRY_ATTEMPTS", local_env)
|
|
161
|
+
)
|
|
162
|
+
or 3,
|
|
163
|
+
test_mode=test_mode,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def validate_runtime(self) -> None:
|
|
167
|
+
if self.backend in {"inmemory", "local_json"}:
|
|
168
|
+
self._validate_retrieval_judge()
|
|
169
|
+
self.validate_ingestion_extractor()
|
|
170
|
+
self.validate_identity_advisor()
|
|
171
|
+
return
|
|
172
|
+
missing: list[str] = []
|
|
173
|
+
if self.graph_backend_kind not in {"kuzu", "neo4j", "falkordb"}:
|
|
174
|
+
missing.append("PEOPLE_MEMORY_GRAPH_BACKEND")
|
|
175
|
+
if self.graph_backend_kind != "kuzu" and not self.graph_backend_url:
|
|
176
|
+
missing.append("PEOPLE_MEMORY_GRAPH_BACKEND_URL")
|
|
177
|
+
if not self.embedding_provider:
|
|
178
|
+
missing.append("PEOPLE_MEMORY_EMBEDDING_PROVIDER")
|
|
179
|
+
if missing:
|
|
180
|
+
raise ConfigError(
|
|
181
|
+
"Graphiti backend requires: " + ", ".join(sorted(missing))
|
|
182
|
+
)
|
|
183
|
+
self._validate_retrieval_judge()
|
|
184
|
+
self.validate_ingestion_extractor()
|
|
185
|
+
self.validate_identity_advisor()
|
|
186
|
+
|
|
187
|
+
def _validate_retrieval_judge(self) -> None:
|
|
188
|
+
if self.retrieval_judge != "llm":
|
|
189
|
+
return
|
|
190
|
+
self._validate_llm_settings("LLM retrieval judge")
|
|
191
|
+
|
|
192
|
+
def validate_ingestion_extractor(self) -> None:
|
|
193
|
+
if self.ingestion_extractor != "llm":
|
|
194
|
+
return
|
|
195
|
+
# Capture extraction is intentionally best-effort: V1 defaults it to
|
|
196
|
+
# on, but a fresh install without LLM credentials must still boot and
|
|
197
|
+
# fall back to deterministic normalization.
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
def validate_identity_advisor(self) -> None:
|
|
201
|
+
if self.identity_advisor != "llm":
|
|
202
|
+
return
|
|
203
|
+
# Identity advice is a best-effort brake. Without LLM credentials the
|
|
204
|
+
# runtime falls back to deterministic identity policy.
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
def has_llm_settings(self) -> bool:
|
|
208
|
+
return bool(self.llm_base_url and self.llm_model and self.llm_api_key)
|
|
209
|
+
|
|
210
|
+
def _validate_llm_settings(self, feature_name: str) -> None:
|
|
211
|
+
missing: list[str] = []
|
|
212
|
+
if not self.llm_base_url:
|
|
213
|
+
missing.append("PEOPLE_MEMORY_LLM_BASE_URL")
|
|
214
|
+
if not self.llm_model:
|
|
215
|
+
missing.append("PEOPLE_MEMORY_LLM_MODEL")
|
|
216
|
+
if not self.llm_api_key:
|
|
217
|
+
missing.append("PEOPLE_MEMORY_LLM_API_KEY")
|
|
218
|
+
if missing:
|
|
219
|
+
raise ConfigError(
|
|
220
|
+
f"{feature_name} requires: " + ", ".join(sorted(missing))
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _optional_int(value: str | None) -> int | None:
|
|
225
|
+
if not value:
|
|
226
|
+
return None
|
|
227
|
+
return int(value)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _optional_float(value: str | None) -> float | None:
|
|
231
|
+
if not value:
|
|
232
|
+
return None
|
|
233
|
+
return float(value)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _env(name: str, local_env: dict[str, str], default: str | None = None) -> str | None:
|
|
237
|
+
return os.getenv(name) or local_env.get(name) or default
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _load_local_env() -> dict[str, str]:
|
|
241
|
+
if os.getenv("PEOPLE_MEMORY_DISABLE_LOCAL_ENV", "").lower() in {"1", "true", "yes"}:
|
|
242
|
+
return {}
|
|
243
|
+
path = os.getenv("PEOPLE_MEMORY_LOCAL_ENV_FILE")
|
|
244
|
+
candidates = [Path(path).expanduser()] if path else []
|
|
245
|
+
candidates.append(Path.cwd() / ".people-network-memory" / "local.env.json")
|
|
246
|
+
for candidate in candidates:
|
|
247
|
+
if not candidate.exists():
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
payload = json.loads(candidate.read_text(encoding="utf-8"))
|
|
251
|
+
except json.JSONDecodeError as exc:
|
|
252
|
+
raise ConfigError(f"Invalid local env JSON at {candidate}: {exc}") from exc
|
|
253
|
+
if not isinstance(payload, dict):
|
|
254
|
+
raise ConfigError(f"Local env JSON must be an object: {candidate}")
|
|
255
|
+
return {
|
|
256
|
+
str(key): str(value)
|
|
257
|
+
for key, value in payload.items()
|
|
258
|
+
if _allowed_local_env_name(str(key)) and isinstance(value, str) and value
|
|
259
|
+
}
|
|
260
|
+
return {}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _allowed_local_env_name(name: str) -> bool:
|
|
264
|
+
return (
|
|
265
|
+
name.startswith("PEOPLE_MEMORY_")
|
|
266
|
+
or name == "GRAPHITI_TELEMETRY_ENABLED"
|
|
267
|
+
or name == "VOLCENGINE_" + "API" + "_KEY"
|
|
268
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Pure domain models and policies."""
|
|
2
|
+
|
|
3
|
+
from people_network_memory.domain.models import (
|
|
4
|
+
AttributedClaim,
|
|
5
|
+
CaptureSummary,
|
|
6
|
+
CapturedPersonSummary,
|
|
7
|
+
ContactMethod,
|
|
8
|
+
DirectFact,
|
|
9
|
+
EducationRecord,
|
|
10
|
+
Evidence,
|
|
11
|
+
FollowUpTask,
|
|
12
|
+
HarnessContextRequest,
|
|
13
|
+
ImportantDate,
|
|
14
|
+
MentionedPerson,
|
|
15
|
+
Participant,
|
|
16
|
+
PersonCard,
|
|
17
|
+
PersonMemoryRecord,
|
|
18
|
+
PersonProfileUpdate,
|
|
19
|
+
PersonRef,
|
|
20
|
+
PostCaptureOpportunity,
|
|
21
|
+
RelationshipAssertion,
|
|
22
|
+
RetrievalItem,
|
|
23
|
+
ReviewItem,
|
|
24
|
+
SensitivityLabel,
|
|
25
|
+
SocialInteraction,
|
|
26
|
+
OpportunityTimeHint,
|
|
27
|
+
WorkHistoryRecord,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AttributedClaim",
|
|
32
|
+
"CaptureSummary",
|
|
33
|
+
"CapturedPersonSummary",
|
|
34
|
+
"ContactMethod",
|
|
35
|
+
"DirectFact",
|
|
36
|
+
"EducationRecord",
|
|
37
|
+
"Evidence",
|
|
38
|
+
"FollowUpTask",
|
|
39
|
+
"HarnessContextRequest",
|
|
40
|
+
"ImportantDate",
|
|
41
|
+
"MentionedPerson",
|
|
42
|
+
"Participant",
|
|
43
|
+
"PersonCard",
|
|
44
|
+
"PersonMemoryRecord",
|
|
45
|
+
"PersonProfileUpdate",
|
|
46
|
+
"PersonRef",
|
|
47
|
+
"PostCaptureOpportunity",
|
|
48
|
+
"RelationshipAssertion",
|
|
49
|
+
"RetrievalItem",
|
|
50
|
+
"ReviewItem",
|
|
51
|
+
"SensitivityLabel",
|
|
52
|
+
"SocialInteraction",
|
|
53
|
+
"OpportunityTimeHint",
|
|
54
|
+
"WorkHistoryRecord",
|
|
55
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Conservative identity policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from people_network_memory.domain.models import IdentityCandidate, PersonRef
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
IdentityDecisionKind = Literal["link", "review", "provisional"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class IdentityDecision:
|
|
16
|
+
kind: IdentityDecisionKind
|
|
17
|
+
person_id: str | None = None
|
|
18
|
+
reason: str = ""
|
|
19
|
+
candidates: tuple[IdentityCandidate, ...] = ()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def choose_identity(ref: PersonRef, candidates: list[IdentityCandidate]) -> IdentityDecision:
|
|
23
|
+
"""Apply the V1 conservative identity policy.
|
|
24
|
+
|
|
25
|
+
Exact user-provided IDs link directly. Exact email/phone matches can link.
|
|
26
|
+
Ambiguous name/company matches go to review. Unknown people become provisional.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
if ref.person_id:
|
|
30
|
+
return IdentityDecision(kind="link", person_id=ref.person_id, reason="explicit person_id")
|
|
31
|
+
exact = [candidate for candidate in candidates if candidate.exact_identifier_match]
|
|
32
|
+
if len(exact) == 1:
|
|
33
|
+
return IdentityDecision(
|
|
34
|
+
kind="link",
|
|
35
|
+
person_id=exact[0].person_id,
|
|
36
|
+
reason="single exact email/phone match",
|
|
37
|
+
candidates=tuple(exact),
|
|
38
|
+
)
|
|
39
|
+
exact_name = [
|
|
40
|
+
candidate
|
|
41
|
+
for candidate in candidates
|
|
42
|
+
if candidate.exact_name_match
|
|
43
|
+
or _normal_label(candidate.display_name) == _normal_label(ref.label)
|
|
44
|
+
]
|
|
45
|
+
if len(exact_name) == 1:
|
|
46
|
+
return IdentityDecision(
|
|
47
|
+
kind="link",
|
|
48
|
+
person_id=exact_name[0].person_id,
|
|
49
|
+
reason="single exact name or alias match",
|
|
50
|
+
candidates=tuple(exact_name),
|
|
51
|
+
)
|
|
52
|
+
if len(exact_name) > 1:
|
|
53
|
+
return IdentityDecision(
|
|
54
|
+
kind="review",
|
|
55
|
+
reason="multiple exact name or alias matches",
|
|
56
|
+
candidates=tuple(exact_name),
|
|
57
|
+
)
|
|
58
|
+
strong = [candidate for candidate in candidates if candidate.score >= 0.82]
|
|
59
|
+
if len(strong) == 1 and (ref.email or ref.phone):
|
|
60
|
+
return IdentityDecision(
|
|
61
|
+
kind="link",
|
|
62
|
+
person_id=strong[0].person_id,
|
|
63
|
+
reason="strong match with identifier hint",
|
|
64
|
+
candidates=tuple(strong),
|
|
65
|
+
)
|
|
66
|
+
reviewable = [candidate for candidate in candidates if candidate.score >= 0.75]
|
|
67
|
+
if reviewable:
|
|
68
|
+
return IdentityDecision(
|
|
69
|
+
kind="review",
|
|
70
|
+
reason="ambiguous high-confidence person reference",
|
|
71
|
+
candidates=tuple(reviewable),
|
|
72
|
+
)
|
|
73
|
+
return IdentityDecision(kind="provisional", reason="no existing candidate")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normal_label(value: str) -> str:
|
|
77
|
+
return " ".join(value.casefold().split())
|