@reconcrap/people-network-memory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +476 -0
  2. package/docs/mcp_tools.md +138 -0
  3. package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
  4. package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
  5. package/harness_adapters/openclaw/mcp.template.json +14 -0
  6. package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
  7. package/package.json +30 -0
  8. package/pyproject.toml +26 -0
  9. package/scripts/install_windows.ps1 +92 -0
  10. package/scripts/npm/people-memory.js +276 -0
  11. package/scripts/people_memory_bootstrap.py +247 -0
  12. package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
  13. package/scripts/run_tests_with_artifacts.ps1 +307 -0
  14. package/src/people_network_memory/__init__.py +6 -0
  15. package/src/people_network_memory/application/__init__.py +16 -0
  16. package/src/people_network_memory/application/normalization.py +1441 -0
  17. package/src/people_network_memory/application/services.py +921 -0
  18. package/src/people_network_memory/cli.py +1212 -0
  19. package/src/people_network_memory/config.py +268 -0
  20. package/src/people_network_memory/domain/__init__.py +55 -0
  21. package/src/people_network_memory/domain/identity.py +77 -0
  22. package/src/people_network_memory/domain/models.py +355 -0
  23. package/src/people_network_memory/fixtures/__init__.py +6 -0
  24. package/src/people_network_memory/fixtures/eval.py +398 -0
  25. package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
  26. package/src/people_network_memory/fixtures/generator.py +290 -0
  27. package/src/people_network_memory/fixtures/report.py +252 -0
  28. package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
  29. package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
  30. package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
  31. package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
  32. package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
  33. package/src/people_network_memory/harness_adapters/__init__.py +2 -0
  34. package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
  35. package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
  36. package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
  37. package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
  38. package/src/people_network_memory/infrastructure/__init__.py +2 -0
  39. package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
  40. package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
  41. package/src/people_network_memory/infrastructure/embeddings.py +155 -0
  42. package/src/people_network_memory/infrastructure/file_store.py +129 -0
  43. package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
  44. package/src/people_network_memory/infrastructure/id_generator.py +40 -0
  45. package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
  46. package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
  47. package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
  48. package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
  49. package/src/people_network_memory/infrastructure/redaction.py +21 -0
  50. package/src/people_network_memory/infrastructure/release_check.py +186 -0
  51. package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
  52. package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
  53. package/src/people_network_memory/mcp_server/__init__.py +2 -0
  54. package/src/people_network_memory/mcp_server/contracts.py +85 -0
  55. package/src/people_network_memory/mcp_server/runtime.py +133 -0
  56. package/src/people_network_memory/mcp_server/tools.py +588 -0
  57. package/src/people_network_memory/ports/__init__.py +2 -0
  58. package/src/people_network_memory/ports/errors.py +25 -0
  59. package/src/people_network_memory/ports/interfaces.py +103 -0
  60. package/src/people_network_memory/projection/__init__.py +6 -0
  61. package/src/people_network_memory/projection/builders.py +46 -0
@@ -0,0 +1,155 @@
1
+ """Embedding clients for local and OpenAI-compatible providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from people_network_memory.config import PeopleMemoryConfig
11
+ from people_network_memory.ports.errors import ConfigError, PeopleMemoryError
12
+
13
+
14
+ class EmbeddingError(PeopleMemoryError):
15
+ """Embedding provider request failed."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class EmbeddingCheck:
20
+ ok: bool
21
+ provider: str
22
+ model: str
23
+ base_url: str
24
+ dimension: int | None = None
25
+ error: str | None = None
26
+
27
+ def to_json(self) -> dict[str, object]:
28
+ payload: dict[str, object] = {
29
+ "ok": self.ok,
30
+ "provider": self.provider,
31
+ "model": self.model,
32
+ "base_url": self.base_url,
33
+ }
34
+ if self.dimension is not None:
35
+ payload["dimension"] = self.dimension
36
+ if self.error:
37
+ payload["error"] = self.error
38
+ return payload
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class EmbeddingSettings:
43
+ provider: str
44
+ base_url: str
45
+ model: str
46
+ api_key: str
47
+ dimension: int | None = None
48
+
49
+ @classmethod
50
+ def from_config(cls, config: PeopleMemoryConfig) -> "EmbeddingSettings":
51
+ provider = (config.embedding_provider or "").strip().lower()
52
+ if provider in {"ollama", "local_ollama"}:
53
+ return cls(
54
+ provider="ollama",
55
+ base_url=config.embedding_base_url or "http://localhost:11434/v1",
56
+ model=config.embedding_model or "nomic-embed-text",
57
+ api_key=config.embedding_api_key or "ollama",
58
+ dimension=config.embedding_dim or 768,
59
+ )
60
+ if provider in {"openai_compatible", "ark", "doubao", "volcengine"}:
61
+ missing = []
62
+ if not config.embedding_base_url:
63
+ missing.append("PEOPLE_MEMORY_EMBEDDING_BASE_URL")
64
+ if not config.embedding_model:
65
+ missing.append("PEOPLE_MEMORY_EMBEDDING_MODEL")
66
+ if not config.embedding_api_key:
67
+ missing.append("PEOPLE_MEMORY_EMBEDDING_API_KEY")
68
+ if missing:
69
+ raise ConfigError(
70
+ "OpenAI-compatible embeddings require: " + ", ".join(missing)
71
+ )
72
+ return cls(
73
+ provider="openai_compatible" if provider in {"ark", "doubao", "volcengine"} else provider,
74
+ base_url=config.embedding_base_url,
75
+ model=config.embedding_model,
76
+ api_key=config.embedding_api_key,
77
+ dimension=config.embedding_dim,
78
+ )
79
+ raise ConfigError(
80
+ "Set PEOPLE_MEMORY_EMBEDDING_PROVIDER to `ollama`, `openai_compatible`, "
81
+ "or a supported OpenAI-compatible alias such as `volcengine`."
82
+ )
83
+
84
+
85
+ class OpenAICompatibleEmbeddingClient:
86
+ def __init__(self, settings: EmbeddingSettings, *, timeout_seconds: float = 60.0) -> None:
87
+ self._settings = settings
88
+ self._timeout = timeout_seconds
89
+
90
+ @property
91
+ def settings(self) -> EmbeddingSettings:
92
+ return self._settings
93
+
94
+ def embed(self, texts: list[str]) -> list[list[float]]:
95
+ if not texts:
96
+ return []
97
+ url = self._settings.base_url.rstrip("/") + "/embeddings"
98
+ headers = {
99
+ "Authorization": f"Bearer {self._settings.api_key}",
100
+ "Content-Type": "application/json",
101
+ }
102
+ payload = {"model": self._settings.model, "input": texts}
103
+ try:
104
+ response = httpx.post(url, headers=headers, json=payload, timeout=self._timeout)
105
+ response.raise_for_status()
106
+ except httpx.HTTPStatusError as exc:
107
+ detail = _safe_error_detail(exc.response)
108
+ raise EmbeddingError(f"embedding provider returned HTTP {exc.response.status_code}: {detail}") from exc
109
+ except httpx.HTTPError as exc:
110
+ raise EmbeddingError(f"embedding provider request failed: {exc}") from exc
111
+ data = response.json()
112
+ return _parse_embeddings(data)
113
+
114
+
115
+ def check_embedding_provider(config: PeopleMemoryConfig, sample: str) -> EmbeddingCheck:
116
+ settings = EmbeddingSettings.from_config(config)
117
+ client = OpenAICompatibleEmbeddingClient(settings)
118
+ try:
119
+ vectors = client.embed([sample])
120
+ dimension = len(vectors[0]) if vectors else 0
121
+ return EmbeddingCheck(
122
+ ok=dimension > 0,
123
+ provider=settings.provider,
124
+ model=settings.model,
125
+ base_url=settings.base_url,
126
+ dimension=dimension,
127
+ )
128
+ except PeopleMemoryError as exc:
129
+ return EmbeddingCheck(
130
+ ok=False,
131
+ provider=settings.provider,
132
+ model=settings.model,
133
+ base_url=settings.base_url,
134
+ error=str(exc),
135
+ )
136
+
137
+
138
+ def _parse_embeddings(data: dict[str, Any]) -> list[list[float]]:
139
+ raw_items = data.get("data")
140
+ if not isinstance(raw_items, list):
141
+ raise EmbeddingError("embedding provider response missing `data` list")
142
+ vectors: list[list[float]] = []
143
+ for item in raw_items:
144
+ embedding = item.get("embedding") if isinstance(item, dict) else None
145
+ if not isinstance(embedding, list):
146
+ raise EmbeddingError("embedding provider response item missing `embedding` list")
147
+ vectors.append([float(value) for value in embedding])
148
+ return vectors
149
+
150
+
151
+ def _safe_error_detail(response: httpx.Response) -> str:
152
+ text = response.text
153
+ if len(text) > 500:
154
+ text = text[:500] + "..."
155
+ return text
@@ -0,0 +1,129 @@
1
+ """Durable local JSON adapter.
2
+
3
+ This is a pragmatic local-first backend for development and early use while the
4
+ Graphiti spike remains gated. It preserves the same ports as the in-memory
5
+ adapter and keeps the Graphiti boundary untouched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from people_network_memory.config import PeopleMemoryConfig
15
+ from people_network_memory.domain.models import (
16
+ PersonMemoryRecord,
17
+ ReviewItem,
18
+ SocialInteraction,
19
+ )
20
+ from people_network_memory.infrastructure.in_memory_store import InMemoryPeopleStore
21
+
22
+
23
+ class JsonPeopleStore(InMemoryPeopleStore):
24
+ def __init__(self, path: Path) -> None:
25
+ super().__init__()
26
+ self._path = path
27
+ self._load()
28
+
29
+ @classmethod
30
+ def from_config(cls, config: PeopleMemoryConfig) -> "JsonPeopleStore":
31
+ return cls(local_json_path(config))
32
+
33
+ @property
34
+ def path(self) -> Path:
35
+ return self._path
36
+
37
+ @classmethod
38
+ def validate_export_payload(cls, payload: dict[str, Any]) -> dict[str, Any]:
39
+ people = [
40
+ PersonMemoryRecord.model_validate(item).model_dump(mode="json")
41
+ for item in payload.get("people", [])
42
+ ]
43
+ review_items = [
44
+ ReviewItem.model_validate(item).model_dump(mode="json")
45
+ for item in payload.get("review_items", [])
46
+ ]
47
+ interactions = [
48
+ SocialInteraction.model_validate(item).model_dump(mode="json")
49
+ for item in payload.get("interactions", [])
50
+ ]
51
+ return {
52
+ "people": people,
53
+ "review_items": review_items,
54
+ "interactions": interactions,
55
+ }
56
+
57
+ @classmethod
58
+ def restore_to_path(cls, path: Path, payload: dict[str, Any]) -> None:
59
+ validated = cls.validate_export_payload(payload)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ path.write_text(
62
+ json.dumps(validated, ensure_ascii=False, indent=2, sort_keys=True),
63
+ encoding="utf-8",
64
+ )
65
+
66
+ def save_interaction(self, interaction: SocialInteraction, identity_map: dict[str, str | None]):
67
+ result = super().save_interaction(interaction, identity_map)
68
+ self._flush()
69
+ return result
70
+
71
+ def add_review_item(self, item: ReviewItem) -> None:
72
+ super().add_review_item(item)
73
+ self._flush()
74
+
75
+ def update_review_item(self, item: ReviewItem) -> ReviewItem:
76
+ updated = super().update_review_item(item)
77
+ self._flush()
78
+ return updated
79
+
80
+ def merge_people(
81
+ self, *, source_person_id: str, target_person_id: str, note: str | None = None
82
+ ) -> PersonMemoryRecord:
83
+ record = super().merge_people(
84
+ source_person_id=source_person_id,
85
+ target_person_id=target_person_id,
86
+ note=note,
87
+ )
88
+ self._flush()
89
+ return record
90
+
91
+ def _load(self) -> None:
92
+ if not self._path.exists():
93
+ return
94
+ data = json.loads(self._path.read_text(encoding="utf-8"))
95
+ self.people = {
96
+ item["person_id"]: PersonMemoryRecord.model_validate(item)
97
+ for item in data.get("people", [])
98
+ }
99
+ self.review_items = [
100
+ ReviewItem.model_validate(item) for item in data.get("review_items", [])
101
+ ]
102
+ self.interactions = {
103
+ f"interaction_{index:04d}": SocialInteraction.model_validate(item)
104
+ for index, item in enumerate(data.get("interactions", []), start=1)
105
+ }
106
+ self.evidence = {
107
+ evidence.evidence_id: evidence
108
+ for person in self.people.values()
109
+ for evidence in person.evidence
110
+ }
111
+ self._reserve_loaded_ids()
112
+
113
+ def _reserve_loaded_ids(self) -> None:
114
+ self._ids.reserve_existing_ids("person", self.people)
115
+ self._ids.reserve_existing_ids("review", [item.review_id for item in self.review_items])
116
+ self._ids.reserve_existing_ids("evidence", self.evidence)
117
+ self._ids.reserve_existing_ids("interaction", self.interactions)
118
+ self._ids.reserve_next_value("interaction", len(self.interactions) + 1)
119
+
120
+ def _flush(self) -> None:
121
+ self._path.parent.mkdir(parents=True, exist_ok=True)
122
+ self._path.write_text(
123
+ json.dumps(self.export_data(), ensure_ascii=False, indent=2, sort_keys=True),
124
+ encoding="utf-8",
125
+ )
126
+
127
+
128
+ def local_json_path(config: PeopleMemoryConfig) -> Path:
129
+ return Path(config.data_path).expanduser() / "people-memory.json"
@@ -0,0 +1,212 @@
1
+ """Promotion gate for making Graphiti/Kuzu the recommended backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from people_network_memory.config import PeopleMemoryConfig
11
+ from people_network_memory.fixtures.eval import evaluate_services
12
+ from people_network_memory.fixtures.generator import generate_mock_dataset
13
+ from people_network_memory.infrastructure.diagnostics import graphiti_spike_checks
14
+ from people_network_memory.infrastructure.embeddings import check_embedding_provider
15
+ from people_network_memory.ports.errors import PeopleMemoryError
16
+
17
+
18
+ def run_graphiti_promotion_gate(
19
+ config: PeopleMemoryConfig,
20
+ *,
21
+ seed: int = 42,
22
+ max_interactions: int | None = None,
23
+ max_queries: int | None = None,
24
+ isolated: bool = True,
25
+ include_cases: bool = False,
26
+ failures_only: bool = True,
27
+ skip_live: bool = False,
28
+ skip_embedding_check: bool = False,
29
+ ) -> dict[str, Any]:
30
+ """Run the repeatable evidence gate for Graphiti/Kuzu promotion.
31
+
32
+ The gate is intentionally stricter than a smoke test: static checks must
33
+ pass, live embedding must work unless skipped, and the fixture eval must
34
+ meet V1 recall/evidence/sensitivity thresholds before promotion is green.
35
+ """
36
+
37
+ if config.backend != "graphiti":
38
+ config = replace(config, backend="graphiti")
39
+
40
+ static_checks = [check.to_json() for check in graphiti_spike_checks(config)]
41
+ static_ok = all(bool(check["ok"]) for check in static_checks)
42
+ embedding_payload = _embedding_gate(config, skip=skip_embedding_check)
43
+ eval_payload: dict[str, Any]
44
+
45
+ if skip_live:
46
+ eval_payload = {
47
+ "ok": False,
48
+ "skipped": True,
49
+ "reason": "live Graphiti/Kuzu fixture eval was skipped",
50
+ }
51
+ elif not static_ok:
52
+ eval_payload = {
53
+ "ok": False,
54
+ "skipped": True,
55
+ "reason": "static Graphiti/Kuzu checks did not pass",
56
+ }
57
+ elif not embedding_payload["ok"]:
58
+ eval_payload = {
59
+ "ok": False,
60
+ "skipped": True,
61
+ "reason": "embedding provider check did not pass",
62
+ }
63
+ else:
64
+ eval_payload = _run_live_eval(
65
+ config,
66
+ seed=seed,
67
+ max_interactions=max_interactions,
68
+ max_queries=max_queries,
69
+ isolated=isolated,
70
+ include_cases=include_cases,
71
+ failures_only=failures_only,
72
+ )
73
+
74
+ checked = int(eval_payload.get("checked", 0) or 0)
75
+ ingested = int(eval_payload.get("ingested_interactions", 0) or 0)
76
+ fixture_summary = eval_payload.get("fixture_summary", {})
77
+ target_queries = (
78
+ int(fixture_summary.get("eval_queries", 40))
79
+ if isinstance(fixture_summary, dict)
80
+ else 40
81
+ )
82
+ target_interactions = (
83
+ int(fixture_summary.get("interactions", 90))
84
+ if isinstance(fixture_summary, dict)
85
+ else 90
86
+ )
87
+ bounded_run = max_queries is not None or max_interactions is not None
88
+ full_eval = not bounded_run and checked >= min(40, target_queries)
89
+ full_ingest = not bounded_run and ingested >= min(60, target_interactions)
90
+ promotion_ok = (
91
+ static_ok
92
+ and bool(embedding_payload["ok"])
93
+ and bool(eval_payload.get("passes_v1_thresholds"))
94
+ and full_eval
95
+ and full_ingest
96
+ )
97
+ return {
98
+ "ok": promotion_ok,
99
+ "gate": "graphiti_kuzu_promotion",
100
+ "current_default_backend": "local_json",
101
+ "candidate_backend": "graphiti",
102
+ "graph_backend_kind": config.graph_backend_kind,
103
+ "isolated": isolated,
104
+ "bounded_run": bounded_run,
105
+ "seed": seed,
106
+ "checked": checked,
107
+ "passes_v1_thresholds": bool(eval_payload.get("passes_v1_thresholds")),
108
+ "static_checks": static_checks,
109
+ "embedding_check": embedding_payload,
110
+ "fixture_eval": eval_payload,
111
+ "promotion_requirements": {
112
+ "static_checks": "all pass",
113
+ "embedding_check": "pass",
114
+ "recall_at_3": ">= 0.70",
115
+ "recall_at_5": ">= 0.85",
116
+ "returned_result_evidence_rate": "1.0",
117
+ "sensitive_leaks": "0",
118
+ "full_eval": ">= 40 checked queries; bounded diagnostic runs cannot promote",
119
+ "full_ingest": ">= 60 ingested interactions; bounded diagnostic runs cannot promote",
120
+ },
121
+ "promotion_status": _promotion_status(
122
+ promotion_ok=promotion_ok,
123
+ bounded_run=bounded_run,
124
+ eval_passed=bool(eval_payload.get("passes_v1_thresholds")),
125
+ ),
126
+ "next_step": (
127
+ "Graphiti/Kuzu can be recommended for advanced recall while local_json remains the export fallback."
128
+ if promotion_ok
129
+ else "Bounded Graphiti/Kuzu diagnostic passed; run the unbounded gate before promotion."
130
+ if bounded_run and bool(eval_payload.get("passes_v1_thresholds"))
131
+ else "Keep local_json as the default and fix failed gate items before promoting Graphiti/Kuzu."
132
+ ),
133
+ }
134
+
135
+
136
+ def _promotion_status(
137
+ *, promotion_ok: bool, bounded_run: bool, eval_passed: bool
138
+ ) -> str:
139
+ if promotion_ok:
140
+ return "ready_to_recommend"
141
+ if bounded_run and eval_passed:
142
+ return "bounded_pass_not_promotable"
143
+ return "keep_gated"
144
+
145
+
146
+ def _embedding_gate(config: PeopleMemoryConfig, *, skip: bool) -> dict[str, Any]:
147
+ if skip:
148
+ return {
149
+ "ok": False,
150
+ "skipped": True,
151
+ "reason": "embedding provider check was skipped",
152
+ }
153
+ try:
154
+ return check_embedding_provider(config, "Alice likes robotics and coffee.").to_json()
155
+ except PeopleMemoryError as exc:
156
+ return {"ok": False, "error": str(exc)}
157
+
158
+
159
+ def _run_live_eval(
160
+ config: PeopleMemoryConfig,
161
+ *,
162
+ seed: int,
163
+ max_interactions: int | None,
164
+ max_queries: int | None,
165
+ isolated: bool,
166
+ include_cases: bool,
167
+ failures_only: bool,
168
+ ) -> dict[str, Any]:
169
+ from people_network_memory.mcp_server.runtime import build_runtime
170
+
171
+ temp_dir: tempfile.TemporaryDirectory[str] | None = None
172
+ if isolated:
173
+ temp_dir = tempfile.TemporaryDirectory()
174
+ config = replace(
175
+ config,
176
+ data_path=temp_dir.name,
177
+ graphiti_kuzu_path=str(Path(temp_dir.name) / "graphiti.kuzu"),
178
+ )
179
+
180
+ runtime = None
181
+ try:
182
+ dataset = generate_mock_dataset(seed=seed)
183
+ runtime = build_runtime(config)
184
+ result = evaluate_services(
185
+ dataset,
186
+ record_service=runtime.record_service,
187
+ retrieve_service=runtime.retrieve_service,
188
+ max_interactions=max_interactions,
189
+ max_queries=max_queries,
190
+ only_answerable=True,
191
+ )
192
+ payload = result.to_json(
193
+ include_cases=include_cases or failures_only,
194
+ failures_only=failures_only,
195
+ )
196
+ payload.update(
197
+ {
198
+ "ok": bool(payload["passes_v1_thresholds"]),
199
+ "backend": "graphiti",
200
+ "fixture_summary": dataset.summary(),
201
+ }
202
+ )
203
+ return payload
204
+ except PeopleMemoryError as exc:
205
+ return {"ok": False, "error": str(exc)}
206
+ except Exception as exc: # pragma: no cover - live provider defensive path
207
+ return {"ok": False, "error": f"Unexpected Graphiti gate failure: {exc}"}
208
+ finally:
209
+ if runtime is not None:
210
+ runtime.close()
211
+ if temp_dir is not None:
212
+ temp_dir.cleanup()
@@ -0,0 +1,40 @@
1
+ """Deterministic and random ID generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from collections.abc import Iterable
8
+
9
+
10
+ ID_PATTERN = re.compile(r"^(?P<prefix>.+)_(?P<number>\d+)$")
11
+
12
+
13
+ class SequentialIdGenerator:
14
+ def __init__(self) -> None:
15
+ self._next_values: dict[str, int] = {}
16
+
17
+ def new_id(self, prefix: str) -> str:
18
+ value = self._next_values.get(prefix, 1)
19
+ self._next_values[prefix] = value + 1
20
+ return f"{prefix}_{value:04d}"
21
+
22
+ def reserve_existing_ids(self, prefix: str, ids: Iterable[str]) -> None:
23
+ max_value = 0
24
+ for item_id in ids:
25
+ match = ID_PATTERN.match(item_id)
26
+ if not match or match.group("prefix") != prefix:
27
+ continue
28
+ max_value = max(max_value, int(match.group("number")))
29
+ if max_value:
30
+ self.reserve_next_value(prefix, max_value + 1)
31
+
32
+ def reserve_next_value(self, prefix: str, next_value: int) -> None:
33
+ if next_value < 1:
34
+ return
35
+ self._next_values[prefix] = max(self._next_values.get(prefix, 1), next_value)
36
+
37
+
38
+ class UuidGenerator:
39
+ def new_id(self, prefix: str) -> str:
40
+ return f"{prefix}_{uuid.uuid4().hex[:12]}"