From 50878d5965294f7822f1abd096c9e48407c4bde4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:08:10 +0400 Subject: [PATCH] =?UTF-8?q?wiki=5Fsync:=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20parent/tree=5Fparent=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D1=83=20id=20->=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=20(T4.1,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/eva-agent-mvp/design.md | 7 ++++++ src/agent.py | 23 ++++++++++++++--- tests/fake_eva.py | 32 +++++++++++++++++++++--- tests/test_wiki_sync.py | 31 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 5bb26d3..5be37aa 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -177,3 +177,10 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ. 30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде. Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e. +31. **Решение 26 уточнено (T4.1): ключ дерева — код страницы, но ссылку `parent`/`tree_parent` в код разрешает `build_children`.** + - Ссылка Eva приходит то объектом `{id, code, ...}`, то строкой-id, то объектом без `code` (так говорит `ref_id` эталонного экспорта). + - `build_children` строит карту `str(id) → code` по документам проекта и корню. Ссылка с `code` даёт код сразу; иначе берётся `id` (из dict или сама строка) и ищется в карте. Неразрешённая ссылка (родитель в другом проекте) пропускается. + - Без проекта (фильтр `tree_parent.code`) разрешение не нужно: родитель известен по запросу. + - `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`. + + Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена). diff --git a/src/agent.py b/src/agent.py index 2ad72f4..f465700 100644 --- a/src/agent.py +++ b/src/agent.py @@ -672,11 +672,21 @@ TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "ordern def _ref_code(ref) -> Optional[str]: - """Код из ссылки Eva {"id", "code"}; страница без родителя — None.""" + """Код из ссылки Eva с полем code; страница без родителя — None.""" code = ref.get("code") if isinstance(ref, dict) else None return code if isinstance(code, str) and code else None +def _ref_id(ref) -> Optional[str]: + """Ссылочные поля Eva приходят то объектом {id, code, ...}, то строкой-id (как в eva_wiki_export.ref_id).""" + if not ref: + return None + if isinstance(ref, dict): + v = ref.get("id") + return str(v) if v not in (None, "") else None + return str(ref) + + def _orderno(value) -> Optional[int]: if isinstance(value, bool): return None @@ -691,8 +701,15 @@ def build_children(eva: "EvaClient", root: dict) -> Dict[str, List[dict]]: children = {} # type: Dict[str, List[dict]] project = _ref_code(root.get("project")) if project: - for d in eva.list_docs([["project.code", "==", project]], TREE_FIELDS): - for pc in {_ref_code(d.get("parent")), _ref_code(d.get("tree_parent"))} - {None}: + docs = eva.list_docs([["project.code", "==", project]], TREE_FIELDS) + id_to_code = {str(d["id"]): d["code"] for d in docs + [root] if d.get("id") is not None and d.get("code")} + + def parent_code(ref) -> Optional[str]: + # dict с code — код как есть; иначе (dict только с id, голая строка-id) — через карту id → код + return _ref_code(ref) or id_to_code.get(_ref_id(ref) or "") + + for d in docs: + for pc in {parent_code(d.get("parent")), parent_code(d.get("tree_parent"))} - {None}: children.setdefault(pc, []).append(d) else: queue, seen = [root], {root.get("code")} diff --git a/tests/fake_eva.py b/tests/fake_eva.py index 28afa24..484eda1 100644 --- a/tests/fake_eva.py +++ b/tests/fake_eva.py @@ -6,7 +6,9 @@ from urllib.parse import parse_qs, urlsplit def _field(doc, path): - """Значение поля по пути `project.code`: ссылка хранится как {"id", "code"}.""" + """Значение поля по пути `project.code`: ссылка project хранится как {"id", "code"}, parent/tree_parent — кодом.""" + if path in ("parent.code", "tree_parent.code"): + return doc.get(path.split(".")[0]) cur = doc for part in path.split("."): if not isinstance(cur, dict): @@ -20,6 +22,9 @@ class FakeEva: def __init__(self): self.docs = [] + # форма ссылок parent/tree_parent в ответах: "full" — {"id": "", "code"} (по умолчанию, как в Eva), + # "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка) + self.ref_form = "full" self.files = {} # путь → (байты, с Content-Length?) self.requests = [] # {"method", "path", "body", "auth"} self.failures = [] # [status, retry_after] — по одному на ответ @@ -49,11 +54,30 @@ class FakeEva: self.server.server_close() def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""): + # parent/tree_parent хранятся кодами родителей; в ответе они превращаются в ссылку по self.ref_form ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731 - self.docs.append({"id": id, "code": code, "name": name, "parent": ref(parent), - "tree_parent": ref(tree_parent), "project": ref(project), "orderno": orderno, + self.docs.append({"id": id, "code": code, "name": name, "parent": parent, + "tree_parent": tree_parent, "project": ref(project), "orderno": orderno, "text": text}) + def _ref(self, code): + if code is None: + return None + target = next((d for d in self.docs if d["code"] == code), None) + rid = str(target["id"]) if target else code + form = self.ref_form + if form == "id_only": + return {"id": rid} + if form == "string": + return rid + if form == "code_id": + return {"id": code, "code": code} + return {"id": rid, "code": code} + + def _out(self, d, fields): + d = dict(d, parent=self._ref(d["parent"]), tree_parent=self._ref(d["tree_parent"])) + return {k: d.get(k) for k in fields} if fields else d + def add_file(self, path, data, length=True): self.files[path] = (data, length) @@ -118,7 +142,7 @@ class FakeEva: def rpc(self, method, kw): found = [d for d in self.docs if self._match(d, kw.get("filter") or [])] fields = kw.get("fields") - pick = (lambda d: {k: d.get(k) for k in fields}) if fields else dict # noqa: E731 + pick = lambda d: self._out(d, fields) # noqa: E731 if method == "CmfDocument.get": return pick(found[0]) if found else None if method == "CmfDocument.list": diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 3a0a086..5ac9beb 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -101,6 +101,37 @@ class WikiSyncTest(SyncBase): self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-A", "DOC-B", "DOC-C"]) self.assertEqual([p["code"] for p in self.sent()], codes) + def fill_forms_tree(self): + e = self.eva + e.add_doc(101, "DOC-1", "Корень", project="P") + e.add_doc(103, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1) # только parent + e.add_doc(104, "DOC-4", "Внук", tree_parent="DOC-3", project="P") # только tree_parent + e.add_doc(110, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2) + e.add_doc(111, "DOC-B", "Б", tree_parent="DOC-A", project="P") # цикл A <-> B + e.add_doc(112, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3) # два родителя + + def test_all_ref_forms(self): + """Ссылки parent/tree_parent бывают {id, code}, {id} и голой строкой-id; id документа не равен коду.""" + for form in ("full", "id_only", "string", "code_id"): + with self.subTest(form=form): + self.setUp() + self.eva.ref_form = form + self.fill_forms_tree() + out = self.sync() + self.assertEqual(out["status"], "done", out) + codes = out["result"]["codes"] + self.assertEqual(codes[:4], ["DOC-1", "DOC-3", "DOC-C", "DOC-4"]) + self.assertEqual(len(codes), len(set(codes))) + self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-4", "DOC-A", "DOC-B", "DOC-C"]) + parents = {p["code"]: p["parent_code"] for p in self.sent()} + self.assertEqual(parents["DOC-4"], "DOC-3") + self.assertEqual(parents["DOC-A"], "DOC-1") + self.assertEqual(parents["DOC-B"], "DOC-A") + self.assertEqual(parents["DOC-C"], "DOC-3") # первый родитель по обходу в глубину + self.assertEqual(self.sent()[0]["eva_id"], "101") + self.eva.stop() + self.hub.stop() + def test_root_without_project(self): e = self.eva e.add_doc(1, "DOC-1", "Корень", text="

к

")