dsh-plugin-xujie-memory 0.5.0 → 0.7.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/ACCEPTANCE.md +37 -0
- package/BUILD-PROVENANCE.json +53 -0
- package/CHANGELOG.md +40 -1
- package/LICENSE +21 -21
- package/README.md +67 -17
- package/README_EN.md +54 -8
- package/cordis.patch.yml +49 -49
- package/lib/client.js +17219 -0
- package/lib/controller.js +307 -0
- package/lib/index.js +200 -14
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +15104 -0
- package/lib/typert.remote-client.d.ts +40 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +15093 -0
- package/lib/types.d.ts +13 -0
- package/lib/types.js +1 -0
- package/package.json +49 -5
- package/python/specs/characters/aloof.json +14 -14
- package/python/specs/characters/gentle.json +14 -14
- package/python/specs/characters/tsundere.json +14 -14
- package/python/specs/debt.v1.json +31 -31
- package/python/specs/nickname.v1.json +16 -16
- package/python/specs/preference.v1.json +16 -16
- package/python/xujie_memory/__init__.py +123 -123
- package/python/xujie_memory/admission.py +211 -211
- package/python/xujie_memory/audit.py +377 -377
- package/python/xujie_memory/benchmark.py +281 -281
- package/python/xujie_memory/character.py +199 -199
- package/python/xujie_memory/console.py +41 -41
- package/python/xujie_memory/consolidate.py +356 -356
- package/python/xujie_memory/due.py +121 -121
- package/python/xujie_memory/emotion.py +573 -573
- package/python/xujie_memory/engine.py +1333 -1312
- package/python/xujie_memory/errors.py +98 -98
- package/python/xujie_memory/importance.py +104 -104
- package/python/xujie_memory/judge.py +350 -350
- package/python/xujie_memory/management.py +203 -0
- package/python/xujie_memory/memory.py +71 -10
- package/python/xujie_memory/orchestrator.py +690 -666
- package/python/xujie_memory/phrasing.py +292 -292
- package/python/xujie_memory/privacy.py +105 -0
- package/python/xujie_memory/schema.sql +492 -480
- package/python/xujie_memory/server.py +76 -0
- package/python/xujie_memory/specs.py +170 -170
- package/python/xujie_memory/summary.py +238 -238
- package/scripts/build.mjs +19 -0
- package/scripts/prepack.mjs +9 -0
- package/scripts/test-all.mjs +9 -0
- package/scripts/test-node.mjs +10 -0
- package/src/client/MemoryPanel.tsx +536 -0
- package/src/client/NeuralField.tsx +88 -0
- package/src/client/css.d.ts +4 -0
- package/src/client/icons.tsx +28 -0
- package/src/client/index.tsx +59 -0
- package/src/client/memory-adapter.ts +67 -0
- package/src/client/model.ts +79 -0
- package/src/client/neural/engine.ts +999 -0
- package/src/client/neural/morphology.ts +219 -0
- package/src/client/panel.css +337 -0
- package/src/client/types.ts +6 -0
- package/src/controller.ts +88 -0
- package/src/generated/typert.host.js +449 -0
- package/src/generated/typert.remote-client.js +438 -0
- package/src/index.ts +2 -0
- package/src/types.ts +13 -0
- package/test/cordis-fixture.mjs +5 -0
- package/test/management_regression.py +187 -0
- package/test/node-regression.entry.mjs +52 -0
- package/vendor/DSH-LICENSE.txt +21 -0
- package/vendor/THIRD-PARTY.md +6 -0
- package/vendor/dsh-protocol/index.ts +484 -0
- package/vendor/dsh-protocol/json-value.ts +53 -0
- package/vendor/dsh-protocol/owned-value.ts +39 -0
- package/vendor/dsh-protocol/principal.ts +17 -0
- package/vendor/dsh-protocol/remote-access.ts +38 -0
- package/vendor/dsh-protocol/remote-error.ts +49 -0
- package/vendor/dsh-protocol/remote-info.ts +161 -0
- package/vendor/dsh-protocol/types.ts +939 -0
- package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
- package/vendor/licenses/zod-MIT.txt +21 -0
|
@@ -53,6 +53,7 @@ from . import __version__
|
|
|
53
53
|
from .encoder import RuleEncoder
|
|
54
54
|
from .engine import Engine, SpecRegistry
|
|
55
55
|
from .memory import Memory
|
|
56
|
+
from .management import Management, ManagementError
|
|
56
57
|
from .orchestrator import Orchestrator, TurnBudget
|
|
57
58
|
from .profile import ProfileStore
|
|
58
59
|
|
|
@@ -95,6 +96,8 @@ class MemoryService:
|
|
|
95
96
|
self.db_path = str(db_path)
|
|
96
97
|
self.engine = Engine(Path(db_path), SpecRegistry.from_dir(Path(specs_dir)))
|
|
97
98
|
self.profiles = ProfileStore(self.engine)
|
|
99
|
+
from .privacy import repair_legacy
|
|
100
|
+
repair_legacy(self.engine, self.profiles)
|
|
98
101
|
self.orchestrator = Orchestrator(
|
|
99
102
|
self.engine,
|
|
100
103
|
profiles=self.profiles,
|
|
@@ -106,6 +109,9 @@ class MemoryService:
|
|
|
106
109
|
# `INSERT OR IGNORE` 兜住了,这里只是避免无谓的重复计算。
|
|
107
110
|
self._scopes: dict[str, str] = {}
|
|
108
111
|
self._lock = threading.Lock()
|
|
112
|
+
# Serialize HTTP writes with reads of dependent projections. In
|
|
113
|
+
# particular, a /turn cannot republish a half-edited graph.
|
|
114
|
+
self.request_lock = threading.RLock()
|
|
109
115
|
|
|
110
116
|
# ------------------------------------------------------------ scope
|
|
111
117
|
def scope_of(self, session: str, user: str = "user") -> str:
|
|
@@ -212,6 +218,19 @@ class MemoryService:
|
|
|
212
218
|
return {"ok": True, "session": session,
|
|
213
219
|
"points": Memory(self.engine).curve(scope, trace_id, points)}
|
|
214
220
|
|
|
221
|
+
def memory_forget(self, session: str, trace_id: str, *,
|
|
222
|
+
user: str = "user") -> dict[str, Any]:
|
|
223
|
+
"""删一条记忆。主 AI 主动删时用;用户说"忘掉这条"也走这里。"""
|
|
224
|
+
scope = self.scope_of(session, user)
|
|
225
|
+
return {"ok": True, "session": session,
|
|
226
|
+
**Memory(self.engine).forget(scope, trace_id)}
|
|
227
|
+
|
|
228
|
+
def memory_stats(self, session: str, *, user: str = "user") -> dict[str, Any]:
|
|
229
|
+
"""库里有多少条记忆、多少条被删过、多少条锚住——透明化用。"""
|
|
230
|
+
scope = self.scope_of(session, user)
|
|
231
|
+
return {"ok": True, "session": session,
|
|
232
|
+
**Memory(self.engine).stats(scope)}
|
|
233
|
+
|
|
215
234
|
def close(self) -> None:
|
|
216
235
|
self.engine.close()
|
|
217
236
|
|
|
@@ -329,7 +348,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
329
348
|
self._json(400, {"ok": False, "error": f"bad_json: {exc}"})
|
|
330
349
|
return
|
|
331
350
|
|
|
351
|
+
self.service.request_lock.acquire()
|
|
332
352
|
try:
|
|
353
|
+
if route.startswith("/manage/"):
|
|
354
|
+
payload = self._manage(route, body)
|
|
355
|
+
self._json(200, payload)
|
|
356
|
+
return
|
|
333
357
|
session = _str_field(body, "session")
|
|
334
358
|
if not session:
|
|
335
359
|
self._json(400, {"ok": False, "error": "missing_session"})
|
|
@@ -366,6 +390,24 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
366
390
|
user=user,
|
|
367
391
|
limit=int(_num_field(body, "limit", 5, 1, 50)),
|
|
368
392
|
)
|
|
393
|
+
elif route == "/memory/stats":
|
|
394
|
+
payload = self.service.memory_stats(session, user=user)
|
|
395
|
+
elif route == "/memory/forget":
|
|
396
|
+
trace_id = _str_field(body, "trace_id")
|
|
397
|
+
if not trace_id:
|
|
398
|
+
raise BadRequest("missing_trace_id")
|
|
399
|
+
try:
|
|
400
|
+
payload = self.service.memory_forget(session, trace_id, user=user)
|
|
401
|
+
except ValueError as exc:
|
|
402
|
+
# 库里没有这条 / 不属于这个 scope:调用方参数问题,不是服务端崩了。
|
|
403
|
+
raise BadRequest(str(exc)) from exc
|
|
404
|
+
elif route == "/memory/edit":
|
|
405
|
+
payload = Management(self.service).edit(
|
|
406
|
+
self.service.scope_of(session, user),
|
|
407
|
+
_str_field(body, "trace_id"),
|
|
408
|
+
_str_field(body, "content", limit=MAX_TEXT_CHARS),
|
|
409
|
+
_str_field(body, "expected_hash"),
|
|
410
|
+
)
|
|
369
411
|
elif route in ("/memory/mark", "/memory/unmark", "/memory/curve"):
|
|
370
412
|
trace_id = _str_field(body, "trace_id")
|
|
371
413
|
if not trace_id:
|
|
@@ -386,6 +428,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
386
428
|
else:
|
|
387
429
|
self._json(404, {"ok": False, "error": "not_found"})
|
|
388
430
|
return
|
|
431
|
+
except ManagementError as exc:
|
|
432
|
+
status = 409 if exc.code == "version_conflict" else (
|
|
433
|
+
404 if exc.code.endswith("_not_found") else 400
|
|
434
|
+
)
|
|
435
|
+
self._json(status, {"ok": False, "error": exc.code})
|
|
436
|
+
return
|
|
389
437
|
except BadRequest as exc:
|
|
390
438
|
self._json(400, {"ok": False, "error": "bad_request", "detail": str(exc)})
|
|
391
439
|
return
|
|
@@ -398,9 +446,37 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
398
446
|
self._json(500, {"ok": False, "error": type(exc).__name__,
|
|
399
447
|
"detail": str(exc)[:300]})
|
|
400
448
|
return
|
|
449
|
+
finally:
|
|
450
|
+
self.service.request_lock.release()
|
|
401
451
|
|
|
402
452
|
self._json(200, payload)
|
|
403
453
|
|
|
454
|
+
def _manage(self, route: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
455
|
+
manager = Management(self.service)
|
|
456
|
+
if route == "/manage/catalog":
|
|
457
|
+
return manager.catalog()
|
|
458
|
+
scope = _str_field(body, "scope_id")
|
|
459
|
+
if not scope:
|
|
460
|
+
raise BadRequest("missing_scope_id")
|
|
461
|
+
if route == "/manage/snapshot":
|
|
462
|
+
return manager.snapshot(scope, int(_num_field(body, "limit", 250, 1, 500)))
|
|
463
|
+
if route == "/manage/search":
|
|
464
|
+
return manager.search(
|
|
465
|
+
scope, _str_field(body, "query", limit=MAX_TEXT_CHARS),
|
|
466
|
+
int(_num_field(body, "limit", 30, 1, 50)),
|
|
467
|
+
)
|
|
468
|
+
trace = _str_field(body, "trace_id")
|
|
469
|
+
if route == "/manage/detail":
|
|
470
|
+
return manager.detail(scope, trace)
|
|
471
|
+
expected = _str_field(body, "expected_hash")
|
|
472
|
+
if route == "/manage/edit":
|
|
473
|
+
return manager.edit(
|
|
474
|
+
scope, trace, _str_field(body, "content", limit=MAX_TEXT_CHARS), expected
|
|
475
|
+
)
|
|
476
|
+
if route == "/manage/forget":
|
|
477
|
+
return manager.forget(scope, trace, expected)
|
|
478
|
+
raise ManagementError("route_not_found")
|
|
479
|
+
|
|
404
480
|
# ---------------------------------------------------------- 工具
|
|
405
481
|
def _read_json(self) -> dict[str, Any]:
|
|
406
482
|
try:
|
|
@@ -1,170 +1,170 @@
|
|
|
1
|
-
"""关系规格(RelationSpec)。
|
|
2
|
-
|
|
3
|
-
设计文档 7.1 的理由:哪些关系可自动接受、负值是否允许、谁能写,
|
|
4
|
-
不能由模型临时决定,也不应散落在代码里。每种关系用一份版本化规格声明,
|
|
5
|
-
内核只执行规格,不自行推断。
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
from dataclasses import dataclass, field
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
14
|
-
|
|
15
|
-
from .errors import SpecNotFound
|
|
16
|
-
|
|
17
|
-
RISK_TIERS = ("R0", "R1", "R2", "R3")
|
|
18
|
-
ADMISSION_MODES = ("shadow", "assisted", "auto")
|
|
19
|
-
EFFECT_KINDS = ("set", "delta", "invalidate")
|
|
20
|
-
MODALITIES = (
|
|
21
|
-
"asserted",
|
|
22
|
-
# 请求 / 祈使。「给我免三碗」不是在陈述事实,也不是假设——
|
|
23
|
-
# 说话人是真的在要求这件事。缺了这一格,它会被判成 hypothetical
|
|
24
|
-
# 然后 record_only,用户明确要求的事反而不记(实测踩到)。
|
|
25
|
-
"request",
|
|
26
|
-
"hypothetical",
|
|
27
|
-
"conditional",
|
|
28
|
-
"quoted",
|
|
29
|
-
"denied",
|
|
30
|
-
"planned",
|
|
31
|
-
"joking",
|
|
32
|
-
"ironic",
|
|
33
|
-
"in_world",
|
|
34
|
-
"unclear",
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@dataclass(frozen=True)
|
|
39
|
-
class RelationSpec:
|
|
40
|
-
relation: str
|
|
41
|
-
version: str
|
|
42
|
-
risk_tier: str
|
|
43
|
-
required_slots: tuple[str, ...]
|
|
44
|
-
allowed_modalities: tuple[str, ...]
|
|
45
|
-
writers: tuple[str, ...]
|
|
46
|
-
effect_kind: str
|
|
47
|
-
admission_mode: str = "shadow"
|
|
48
|
-
value_type: str = "integer" # integer / number / string / enum
|
|
49
|
-
# XM-12: unit 只用于展示,不参与 state_key,也不参与校验。
|
|
50
|
-
# 编码器给出与规格不符的单位时不会报错——这是有意的取舍,见 ISSUES。
|
|
51
|
-
# 真要校验,应判 disputed 而不是拒绝(拒绝会丢掉证据)。
|
|
52
|
-
unit: str | None = None
|
|
53
|
-
allow_negative: bool = False
|
|
54
|
-
enum_values: tuple[str, ...] = ()
|
|
55
|
-
confirmation: str = "none" # none / implicit / explicit / authority
|
|
56
|
-
validity: str = "permanent" # permanent / ttl / reconfirm
|
|
57
|
-
ttl_seconds: int | None = None
|
|
58
|
-
conflict_policy: str = "dispute" # dispute / reject / last_write_wins
|
|
59
|
-
conservation: str | None = None # None / "repayment":追踪累计偿付量
|
|
60
|
-
feeds: tuple[str, ...] = ()
|
|
61
|
-
derived_from: tuple[str, ...] = ()
|
|
62
|
-
note: str = ""
|
|
63
|
-
|
|
64
|
-
# ------------------------------------------------------------- 自检
|
|
65
|
-
def validate(self) -> None:
|
|
66
|
-
if self.risk_tier not in RISK_TIERS:
|
|
67
|
-
raise ValueError(f"{self.relation}: bad risk_tier {self.risk_tier!r}")
|
|
68
|
-
if self.admission_mode not in ADMISSION_MODES:
|
|
69
|
-
raise ValueError(f"{self.relation}: bad admission_mode {self.admission_mode!r}")
|
|
70
|
-
if self.effect_kind not in EFFECT_KINDS:
|
|
71
|
-
raise ValueError(f"{self.relation}: bad effect_kind {self.effect_kind!r}")
|
|
72
|
-
for m in self.allowed_modalities:
|
|
73
|
-
if m not in MODALITIES:
|
|
74
|
-
raise ValueError(f"{self.relation}: unknown modality {m!r}")
|
|
75
|
-
if not self.required_slots:
|
|
76
|
-
raise ValueError(f"{self.relation}: required_slots must not be empty")
|
|
77
|
-
# R3 永远不允许自动提交(设计文档 7.3 / 15.5)
|
|
78
|
-
if self.risk_tier == "R3" and self.admission_mode == "auto":
|
|
79
|
-
raise ValueError(
|
|
80
|
-
f"{self.relation}: R3 relation must not be 'auto' "
|
|
81
|
-
"(permissions/deletion go through the control channel only)"
|
|
82
|
-
)
|
|
83
|
-
if self.value_type == "enum" and not self.enum_values:
|
|
84
|
-
raise ValueError(f"{self.relation}: enum value_type requires enum_values")
|
|
85
|
-
if self.conservation not in (None, "repayment"):
|
|
86
|
-
raise ValueError(f"{self.relation}: unknown conservation {self.conservation!r}")
|
|
87
|
-
|
|
88
|
-
@property
|
|
89
|
-
def spec_version(self) -> str:
|
|
90
|
-
return f"{self.relation}@{self.version}"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
@dataclass
|
|
94
|
-
class SpecRegistry:
|
|
95
|
-
_by_relation: dict[str, RelationSpec] = field(default_factory=dict)
|
|
96
|
-
|
|
97
|
-
# ------------------------------------------------------------- 加载
|
|
98
|
-
@classmethod
|
|
99
|
-
def from_dir(cls, path: str | Path) -> "SpecRegistry":
|
|
100
|
-
reg = cls()
|
|
101
|
-
for f in sorted(Path(path).glob("*.json")):
|
|
102
|
-
reg.add(RelationSpec(**json.loads(f.read_text(encoding="utf-8"))))
|
|
103
|
-
return reg
|
|
104
|
-
|
|
105
|
-
@classmethod
|
|
106
|
-
def from_specs(cls, specs: list[RelationSpec]) -> "SpecRegistry":
|
|
107
|
-
reg = cls()
|
|
108
|
-
for s in specs:
|
|
109
|
-
reg.add(s)
|
|
110
|
-
return reg
|
|
111
|
-
|
|
112
|
-
def add(self, spec: RelationSpec) -> None:
|
|
113
|
-
spec.validate()
|
|
114
|
-
if spec.relation in self._by_relation:
|
|
115
|
-
raise ValueError(f"duplicate relation spec: {spec.relation}")
|
|
116
|
-
self._by_relation[spec.relation] = spec
|
|
117
|
-
|
|
118
|
-
# ------------------------------------------------------------- 查询
|
|
119
|
-
def get(self, relation: str) -> RelationSpec:
|
|
120
|
-
try:
|
|
121
|
-
return self._by_relation[relation]
|
|
122
|
-
except KeyError:
|
|
123
|
-
raise SpecNotFound(f"no spec registered for relation {relation!r}") from None
|
|
124
|
-
|
|
125
|
-
def all(self) -> list[RelationSpec]:
|
|
126
|
-
return list(self._by_relation.values())
|
|
127
|
-
|
|
128
|
-
# ------------------------------------------------------------- 依赖图
|
|
129
|
-
def dependency_edges(self) -> list[tuple[str, str]]:
|
|
130
|
-
"""返回 (input_relation, derived_relation) 边列表,用于环检测。"""
|
|
131
|
-
edges: list[tuple[str, str]] = []
|
|
132
|
-
for spec in self._by_relation.values():
|
|
133
|
-
for src in spec.derived_from:
|
|
134
|
-
edges.append((src, spec.relation))
|
|
135
|
-
return edges
|
|
136
|
-
|
|
137
|
-
def assert_acyclic(self) -> None:
|
|
138
|
-
"""依赖图必须为 DAG(设计文档 9.5)。"""
|
|
139
|
-
from .errors import DependencyCycle
|
|
140
|
-
|
|
141
|
-
graph: dict[str, set[str]] = {}
|
|
142
|
-
for src, dst in self.dependency_edges():
|
|
143
|
-
graph.setdefault(src, set()).add(dst)
|
|
144
|
-
|
|
145
|
-
WHITE, GREY, BLACK = 0, 1, 2
|
|
146
|
-
color: dict[str, int] = {}
|
|
147
|
-
|
|
148
|
-
def visit(node: str, stack: list[str]) -> None:
|
|
149
|
-
color[node] = GREY
|
|
150
|
-
stack.append(node)
|
|
151
|
-
for nxt in graph.get(node, ()):
|
|
152
|
-
c = color.get(nxt, WHITE)
|
|
153
|
-
if c == GREY:
|
|
154
|
-
cycle = " -> ".join(stack[stack.index(nxt):] + [nxt])
|
|
155
|
-
raise DependencyCycle(f"dependency cycle: {cycle}")
|
|
156
|
-
if c == WHITE:
|
|
157
|
-
visit(nxt, stack)
|
|
158
|
-
stack.pop()
|
|
159
|
-
color[node] = BLACK
|
|
160
|
-
|
|
161
|
-
for node in list(graph):
|
|
162
|
-
if color.get(node, WHITE) == WHITE:
|
|
163
|
-
visit(node, [])
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def slot_value(spec: RelationSpec, slots: dict[str, Any], name: str) -> Any:
|
|
167
|
-
"""取槽位;缺失时抛错,不做默认值填充(I5)。"""
|
|
168
|
-
if name not in slots:
|
|
169
|
-
raise KeyError(f"missing required slot {name!r} for relation {spec.relation!r}")
|
|
170
|
-
return slots[name]
|
|
1
|
+
"""关系规格(RelationSpec)。
|
|
2
|
+
|
|
3
|
+
设计文档 7.1 的理由:哪些关系可自动接受、负值是否允许、谁能写,
|
|
4
|
+
不能由模型临时决定,也不应散落在代码里。每种关系用一份版本化规格声明,
|
|
5
|
+
内核只执行规格,不自行推断。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import SpecNotFound
|
|
16
|
+
|
|
17
|
+
RISK_TIERS = ("R0", "R1", "R2", "R3")
|
|
18
|
+
ADMISSION_MODES = ("shadow", "assisted", "auto")
|
|
19
|
+
EFFECT_KINDS = ("set", "delta", "invalidate")
|
|
20
|
+
MODALITIES = (
|
|
21
|
+
"asserted",
|
|
22
|
+
# 请求 / 祈使。「给我免三碗」不是在陈述事实,也不是假设——
|
|
23
|
+
# 说话人是真的在要求这件事。缺了这一格,它会被判成 hypothetical
|
|
24
|
+
# 然后 record_only,用户明确要求的事反而不记(实测踩到)。
|
|
25
|
+
"request",
|
|
26
|
+
"hypothetical",
|
|
27
|
+
"conditional",
|
|
28
|
+
"quoted",
|
|
29
|
+
"denied",
|
|
30
|
+
"planned",
|
|
31
|
+
"joking",
|
|
32
|
+
"ironic",
|
|
33
|
+
"in_world",
|
|
34
|
+
"unclear",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RelationSpec:
|
|
40
|
+
relation: str
|
|
41
|
+
version: str
|
|
42
|
+
risk_tier: str
|
|
43
|
+
required_slots: tuple[str, ...]
|
|
44
|
+
allowed_modalities: tuple[str, ...]
|
|
45
|
+
writers: tuple[str, ...]
|
|
46
|
+
effect_kind: str
|
|
47
|
+
admission_mode: str = "shadow"
|
|
48
|
+
value_type: str = "integer" # integer / number / string / enum
|
|
49
|
+
# XM-12: unit 只用于展示,不参与 state_key,也不参与校验。
|
|
50
|
+
# 编码器给出与规格不符的单位时不会报错——这是有意的取舍,见 ISSUES。
|
|
51
|
+
# 真要校验,应判 disputed 而不是拒绝(拒绝会丢掉证据)。
|
|
52
|
+
unit: str | None = None
|
|
53
|
+
allow_negative: bool = False
|
|
54
|
+
enum_values: tuple[str, ...] = ()
|
|
55
|
+
confirmation: str = "none" # none / implicit / explicit / authority
|
|
56
|
+
validity: str = "permanent" # permanent / ttl / reconfirm
|
|
57
|
+
ttl_seconds: int | None = None
|
|
58
|
+
conflict_policy: str = "dispute" # dispute / reject / last_write_wins
|
|
59
|
+
conservation: str | None = None # None / "repayment":追踪累计偿付量
|
|
60
|
+
feeds: tuple[str, ...] = ()
|
|
61
|
+
derived_from: tuple[str, ...] = ()
|
|
62
|
+
note: str = ""
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------- 自检
|
|
65
|
+
def validate(self) -> None:
|
|
66
|
+
if self.risk_tier not in RISK_TIERS:
|
|
67
|
+
raise ValueError(f"{self.relation}: bad risk_tier {self.risk_tier!r}")
|
|
68
|
+
if self.admission_mode not in ADMISSION_MODES:
|
|
69
|
+
raise ValueError(f"{self.relation}: bad admission_mode {self.admission_mode!r}")
|
|
70
|
+
if self.effect_kind not in EFFECT_KINDS:
|
|
71
|
+
raise ValueError(f"{self.relation}: bad effect_kind {self.effect_kind!r}")
|
|
72
|
+
for m in self.allowed_modalities:
|
|
73
|
+
if m not in MODALITIES:
|
|
74
|
+
raise ValueError(f"{self.relation}: unknown modality {m!r}")
|
|
75
|
+
if not self.required_slots:
|
|
76
|
+
raise ValueError(f"{self.relation}: required_slots must not be empty")
|
|
77
|
+
# R3 永远不允许自动提交(设计文档 7.3 / 15.5)
|
|
78
|
+
if self.risk_tier == "R3" and self.admission_mode == "auto":
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"{self.relation}: R3 relation must not be 'auto' "
|
|
81
|
+
"(permissions/deletion go through the control channel only)"
|
|
82
|
+
)
|
|
83
|
+
if self.value_type == "enum" and not self.enum_values:
|
|
84
|
+
raise ValueError(f"{self.relation}: enum value_type requires enum_values")
|
|
85
|
+
if self.conservation not in (None, "repayment"):
|
|
86
|
+
raise ValueError(f"{self.relation}: unknown conservation {self.conservation!r}")
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def spec_version(self) -> str:
|
|
90
|
+
return f"{self.relation}@{self.version}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class SpecRegistry:
|
|
95
|
+
_by_relation: dict[str, RelationSpec] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------- 加载
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dir(cls, path: str | Path) -> "SpecRegistry":
|
|
100
|
+
reg = cls()
|
|
101
|
+
for f in sorted(Path(path).glob("*.json")):
|
|
102
|
+
reg.add(RelationSpec(**json.loads(f.read_text(encoding="utf-8"))))
|
|
103
|
+
return reg
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_specs(cls, specs: list[RelationSpec]) -> "SpecRegistry":
|
|
107
|
+
reg = cls()
|
|
108
|
+
for s in specs:
|
|
109
|
+
reg.add(s)
|
|
110
|
+
return reg
|
|
111
|
+
|
|
112
|
+
def add(self, spec: RelationSpec) -> None:
|
|
113
|
+
spec.validate()
|
|
114
|
+
if spec.relation in self._by_relation:
|
|
115
|
+
raise ValueError(f"duplicate relation spec: {spec.relation}")
|
|
116
|
+
self._by_relation[spec.relation] = spec
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------- 查询
|
|
119
|
+
def get(self, relation: str) -> RelationSpec:
|
|
120
|
+
try:
|
|
121
|
+
return self._by_relation[relation]
|
|
122
|
+
except KeyError:
|
|
123
|
+
raise SpecNotFound(f"no spec registered for relation {relation!r}") from None
|
|
124
|
+
|
|
125
|
+
def all(self) -> list[RelationSpec]:
|
|
126
|
+
return list(self._by_relation.values())
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------- 依赖图
|
|
129
|
+
def dependency_edges(self) -> list[tuple[str, str]]:
|
|
130
|
+
"""返回 (input_relation, derived_relation) 边列表,用于环检测。"""
|
|
131
|
+
edges: list[tuple[str, str]] = []
|
|
132
|
+
for spec in self._by_relation.values():
|
|
133
|
+
for src in spec.derived_from:
|
|
134
|
+
edges.append((src, spec.relation))
|
|
135
|
+
return edges
|
|
136
|
+
|
|
137
|
+
def assert_acyclic(self) -> None:
|
|
138
|
+
"""依赖图必须为 DAG(设计文档 9.5)。"""
|
|
139
|
+
from .errors import DependencyCycle
|
|
140
|
+
|
|
141
|
+
graph: dict[str, set[str]] = {}
|
|
142
|
+
for src, dst in self.dependency_edges():
|
|
143
|
+
graph.setdefault(src, set()).add(dst)
|
|
144
|
+
|
|
145
|
+
WHITE, GREY, BLACK = 0, 1, 2
|
|
146
|
+
color: dict[str, int] = {}
|
|
147
|
+
|
|
148
|
+
def visit(node: str, stack: list[str]) -> None:
|
|
149
|
+
color[node] = GREY
|
|
150
|
+
stack.append(node)
|
|
151
|
+
for nxt in graph.get(node, ()):
|
|
152
|
+
c = color.get(nxt, WHITE)
|
|
153
|
+
if c == GREY:
|
|
154
|
+
cycle = " -> ".join(stack[stack.index(nxt):] + [nxt])
|
|
155
|
+
raise DependencyCycle(f"dependency cycle: {cycle}")
|
|
156
|
+
if c == WHITE:
|
|
157
|
+
visit(nxt, stack)
|
|
158
|
+
stack.pop()
|
|
159
|
+
color[node] = BLACK
|
|
160
|
+
|
|
161
|
+
for node in list(graph):
|
|
162
|
+
if color.get(node, WHITE) == WHITE:
|
|
163
|
+
visit(node, [])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def slot_value(spec: RelationSpec, slots: dict[str, Any], name: str) -> Any:
|
|
167
|
+
"""取槽位;缺失时抛错,不做默认值填充(I5)。"""
|
|
168
|
+
if name not in slots:
|
|
169
|
+
raise KeyError(f"missing required slot {name!r} for relation {spec.relation!r}")
|
|
170
|
+
return slots[name]
|