eva-agent MVP: wiki_sync и wiki_fetch (#1) #2

Merged
aep merged 15 commits from feat/1-mvp into main 2026-10-24 09:05:15 +03:00
4 changed files with 86 additions and 7 deletions
Showing only changes of commit 50878d5965 - Show all commits

View File

@ -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": "<id документа>", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`.
Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена).

View File

@ -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")}

View File

@ -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": "<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":

View File

@ -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="<p>к</p>")