MCP вики: значения из Eva одной строкой (#135, T9.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
52db644034
commit
829d4cbb4b
@ -2,6 +2,7 @@
|
|||||||
(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и
|
(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и
|
||||||
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
|
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@ -17,6 +18,12 @@ SYNC_WAIT = 300
|
|||||||
CHUNK = 20000
|
CHUNK = 20000
|
||||||
CANDIDATES = 10
|
CANDIDATES = 10
|
||||||
mcp = FastMCP("sdlc-wiki")
|
mcp = FastMCP("sdlc-wiki")
|
||||||
|
_NEWLINES = re.compile(r"\s*[\r\n]+\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def _line(v) -> str:
|
||||||
|
"""Значение из Eva внутри строки ответа: переводы строк — пробел, иначе они подделали бы строки шапки (T9.3)."""
|
||||||
|
return _NEWLINES.sub(" ", core.value(v))
|
||||||
|
|
||||||
|
|
||||||
def _project() -> dict:
|
def _project() -> dict:
|
||||||
@ -71,7 +78,7 @@ def tree(code: str = "", depth: int = 2) -> str:
|
|||||||
base = min(p["depth"] for p in pages)
|
base = min(p["depth"] for p in pages)
|
||||||
shown = [p for p in pages if p["depth"] - base <= depth]
|
shown = [p for p in pages if p["depth"] - base <= depth]
|
||||||
base = shown[0]["depth"]
|
base = shown[0]["depth"]
|
||||||
return "\n".join(f"{' ' * (p['depth'] - base)}{p['code']} {core.value(p.get('title'))} ({core.value(p.get('size'))} симв.)"
|
return "\n".join(f"{' ' * (p['depth'] - base)}{p['code']} {_line(p.get('title'))} ({core.value(p.get('size'))} симв.)"
|
||||||
for p in shown)
|
for p in shown)
|
||||||
|
|
||||||
|
|
||||||
@ -79,10 +86,10 @@ def _candidates(pages: list, title: str) -> str:
|
|||||||
low = title.casefold()
|
low = title.casefold()
|
||||||
found = [p for p in pages if low in (p.get("title") or "").casefold()]
|
found = [p for p in pages if low in (p.get("title") or "").casefold()]
|
||||||
if not found:
|
if not found:
|
||||||
return f"нет страницы «{title}»; дерево — tree, поиск по тексту — search"
|
return f"нет страницы «{_line(title)}»; дерево — tree, поиск по тексту — search"
|
||||||
lines = [f"{p['code']} {p.get('title')}" for p in found[:CANDIDATES]]
|
lines = [f"{p['code']} {_line(p.get('title'))}" for p in found[:CANDIDATES]]
|
||||||
more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else []
|
more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else []
|
||||||
return "под название «%s» подходит несколько страниц, повторите page с code:\n" % title + "\n".join(lines + more)
|
return "под название «%s» подходит несколько страниц, повторите page с code:\n" % _line(title) + "\n".join(lines + more)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@ -108,9 +115,9 @@ def page(code: str = "", title: str = "", offset: int = 0) -> str:
|
|||||||
d = client.get(f"/wiki/pages/{quote(code, safe='')}", project=core.PROJECT)
|
d = client.get(f"/wiki/pages/{quote(code, safe='')}", project=core.PROJECT)
|
||||||
text, offset = d.get("markdown") or "", max(int(offset), 0)
|
text, offset = d.get("markdown") or "", max(int(offset), 0)
|
||||||
atts = d.get("attachments") or []
|
atts = d.get("attachments") or []
|
||||||
head = [f"{d['code']} — {core.value(d.get('title'))}", f"путь: {core.value(d.get('path'))}",
|
head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}",
|
||||||
f"Eva: {core.value(d.get('url'))}", f"синхронизировано: {core.value(d.get('synced_at'))}"]
|
f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}"]
|
||||||
head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {a.get('name')} ({core.value(a.get('state'))})" for a in atts]
|
head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {_line(a.get('name'))} ({_line(a.get('state'))})" for a in atts]
|
||||||
if atts else ["вложений нет"])
|
if atts else ["вложений нет"])
|
||||||
if offset >= len(text) and (offset > 0 or not text):
|
if offset >= len(text) and (offset > 0 or not text):
|
||||||
body = f"offset={offset} за концом текста: длина {len(text)} символов" if offset > 0 else "страница пуста"
|
body = f"offset={offset} за концом текста: длина {len(text)} символов" if offset > 0 else "страница пуста"
|
||||||
@ -130,7 +137,7 @@ def search(query: str, limit: int = 10) -> str:
|
|||||||
items = core.client().get("/wiki/search", project=core.PROJECT, q=query, limit=limit)["results"]
|
items = core.client().get("/wiki/search", project=core.PROJECT, q=query, limit=limit)["results"]
|
||||||
if not items:
|
if not items:
|
||||||
return "ничего не найдено"
|
return "ничего не найдено"
|
||||||
return "\n".join(f"{r['code']} {core.value(r.get('title'))} — {core.value(r.get('path'))}\n {core.value(r.get('snippet'))}"
|
return "\n".join(f"{r['code']} {_line(r.get('title'))} — {_line(r.get('path'))}\n {_line(r.get('snippet'))}"
|
||||||
for r in items)
|
for r in items)
|
||||||
|
|
||||||
|
|
||||||
@ -159,18 +166,18 @@ async def attachment(id: int) -> str:
|
|||||||
if st.get("state") == "fetching":
|
if st.get("state") == "fetching":
|
||||||
return f"скачивание ещё идёт (вложение #{id}); вызовите attachment ещё раз — вторая команда не поставится"
|
return f"скачивание ещё идёт (вложение #{id}); вызовите attachment ещё раз — вторая команда не поставится"
|
||||||
if st.get("state") != "ready":
|
if st.get("state") != "ready":
|
||||||
return f"не удалось скачать: {core.value(st.get('error'))}"
|
return f"не удалось скачать: {_line(st.get('error'))}"
|
||||||
data = await asyncio.to_thread(client.get_bytes, f"/wiki/attachments/{id}/content")
|
data = await asyncio.to_thread(client.get_bytes, f"/wiki/attachments/{id}/content")
|
||||||
path = await asyncio.to_thread(_store, id, st.get("name"), data)
|
path = await asyncio.to_thread(_store, id, st.get("name"), data)
|
||||||
return f"{path}\n{core.value(st.get('name'))} ({len(data)} байт), страница {core.value(st.get('page_code'))}"
|
return f"{path}\n{_line(st.get('name'))} ({len(data)} байт), страница {_line(st.get('page_code'))}"
|
||||||
|
|
||||||
|
|
||||||
def _summary(client) -> str:
|
def _summary(client) -> str:
|
||||||
s = client.get("/wiki/status", project=core.PROJECT)
|
s = client.get("/wiki/status", project=core.PROJECT)
|
||||||
lines = [f"корень: {core.value(s.get('root'))} {core.value(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; "
|
lines = [f"корень: {core.value(s.get('root'))} {_line(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; "
|
||||||
f"последняя синхронизация: {core.value(s.get('synced_at'))}"]
|
f"последняя синхронизация: {core.value(s.get('synced_at'))}"]
|
||||||
if s.get("error"):
|
if s.get("error"):
|
||||||
lines.append(f"ошибка синхронизации: {s['error']}")
|
lines.append(f"ошибка синхронизации: {_line(s['error'])}")
|
||||||
b = s.get("batch")
|
b = s.get("batch")
|
||||||
lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет")
|
lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@ -203,7 +210,7 @@ async def sync(wait: bool = False) -> str:
|
|||||||
break
|
break
|
||||||
await asyncio.sleep(POLL_SECONDS)
|
await asyncio.sleep(POLL_SECONDS)
|
||||||
lines = [f"пакет #{batch_id}: {b['status']}"]
|
lines = [f"пакет #{batch_id}: {b['status']}"]
|
||||||
lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {c['error']}" if c.get("error") else "") for c in b.get("commands") or []]
|
lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {_line(c['error'])}" if c.get("error") else "") for c in b.get("commands") or []]
|
||||||
if b["status"] not in ("done", "cancelled"):
|
if b["status"] not in ("done", "cancelled"):
|
||||||
return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"])
|
return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"])
|
||||||
return "\n".join(lines + [await asyncio.to_thread(_summary, client)])
|
return "\n".join(lines + [await asyncio.to_thread(_summary, client)])
|
||||||
|
|||||||
@ -313,3 +313,36 @@ def test_root_set_quotes_label(hub):
|
|||||||
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
|
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
|
||||||
assert "#5" in wmcp.root_set("DOC-2")
|
assert "#5" in wmcp.root_set("DOC-2")
|
||||||
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"
|
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"
|
||||||
|
|
||||||
|
|
||||||
|
# пакет D (#135): T9.3 — значения из Eva одной строкой
|
||||||
|
def test_page_header_newlines_collapsed(hub):
|
||||||
|
hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "Статусы\nпуть: поддельный", "path": "Корень/\r\nСтатусы",
|
||||||
|
"url": "https://eva/x", "synced_at": "2026-10-24T10:00:00+00:00", "markdown": "строка1\nстрока2",
|
||||||
|
"attachments": [{"id": 7, "name": "a\nb.png", "state": "none", "size": None, "error": None}]}})
|
||||||
|
out = wmcp.page(code="DOC-3")
|
||||||
|
lines = out.splitlines()
|
||||||
|
assert lines[0] == "DOC-3 — Статусы путь: поддельный"
|
||||||
|
assert sum(line.startswith("путь:") for line in lines) == 1 and "путь: Корень/ Статусы" in lines
|
||||||
|
assert " #7 a b.png (none)" in lines and "строка1\nстрока2" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_tree_candidates_newlines_collapsed(hub):
|
||||||
|
hub({"/wiki/search": {"results": [{"code": "DOC-2", "title": "A\nB", "path": "P\nQ", "snippet": "x\n y"}]},
|
||||||
|
"/wiki/tree": {"pages": [{"code": "DOC-1", "title": "Корень", "depth": 0, "parent_code": None, "size": 1},
|
||||||
|
{"code": "DOC-2", "title": "T\nU", "depth": 1, "parent_code": "DOC-1", "size": 200},
|
||||||
|
{"code": "DOC-3", "title": "T\nV", "depth": 1, "parent_code": "DOC-1", "size": 5}]}})
|
||||||
|
assert wmcp.search("q").splitlines() == ["DOC-2 A B — P Q", " x y"]
|
||||||
|
assert " DOC-2 T U (200 симв.)" in wmcp.tree().splitlines()
|
||||||
|
cands = wmcp.page(title="T").splitlines()
|
||||||
|
assert cands[0].startswith("под название «T» подходит") and "DOC-2 T U" in cands and "DOC-3 T V" in cands
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_wait_error_and_root_title_one_line(hub):
|
||||||
|
done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", "error": "Eva: 503\nпуть: x"}]}
|
||||||
|
hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": done,
|
||||||
|
"/wiki/status": {"root": "DOC-1", "root_title": "К\nстраниц: 999", "synced_at": "s", "pages": 3,
|
||||||
|
"error": "Eva: 503\nпуть: x", "batch": None}})
|
||||||
|
lines = asyncio.run(wmcp.sync(wait=True)).splitlines()
|
||||||
|
assert " #1 wiki_sync failed: Eva: 503 путь: x" in lines and "ошибка синхронизации: Eva: 503 путь: x" in lines
|
||||||
|
assert not any(line.startswith("путь:") or line.startswith("страниц:") for line in lines)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user