diff --git a/backend/app/crud/paper.py b/backend/app/crud/paper.py index b617942..2c3c559 100644 --- a/backend/app/crud/paper.py +++ b/backend/app/crud/paper.py @@ -666,3 +666,43 @@ def template_paper_counts( with the count that explains why. """ return _paper_counts_by_template(db, template_ids) + + +# --- search ------------------------------------------------------------------ + + +def search_sentences( + db: Session, + *, + keyword: str, + paper_id: int | None = None, + limit: int = 50, +) -> list[tuple[PaperSentence, str]]: + """Find sentences whose text contains ``keyword``, newest paper first. + + Returns ``(sentence, paper_title)`` pairs, the title read alongside the row + so the caller does not pay a lazy load per sentence. + + The MCP server's ``sentence_search`` tool is the caller. What it serves is + consistency rather than retrieval: a paper that calls it 洪水损失 should + not be joined by one that calls it GUL, and reading whole documents to find + one phrase would not fit in a context window. + """ + pattern = like_pattern(keyword) + stmt = ( + select(PaperSentence, Paper.title) + .join(Paper, Paper.id == PaperSentence.paper_id) + .where(PaperSentence.content.like(pattern, escape=LIKE_ESCAPE)) + .order_by( + Paper.updated_at.desc(), + PaperSentence.paper_id, + PaperSentence.paper_template_filed_sort, + PaperSentence.sort, + PaperSentence.id, + ) + .limit(limit) + ) + if paper_id is not None: + stmt = stmt.where(PaperSentence.paper_id == paper_id) + + return [(sentence, title) for sentence, title in db.execute(stmt).all()] diff --git a/backend/app/mcp/__init__.py b/backend/app/mcp/__init__.py new file mode 100644 index 0000000..4b7b1a4 --- /dev/null +++ b/backend/app/mcp/__init__.py @@ -0,0 +1,25 @@ +"""The paper-doc MCP server: the same domain layer, spoken as MCP tools. + +Three front doors, one domain layer +----------------------------------- +The REST API (``app.api``), the MCP server (this package) and the seed script +(``scripts/seed.py``) all end up in :mod:`app.crud` and validate through +:mod:`app.schemas`. Nothing about a paper is decided here: a rule fixed in the +CRUD layer is fixed in the API, in the tools and in the next tool added. + +What lives here is only what a *model* needs and a browser does not: + +* compact JSON instead of HTTP envelopes, because tool results are paid for in + context tokens; +* paragraphs addressed by **heading** as well as by position, because a model + knows the paper says "1. Introduction" and does not know that the template + happens to place it at ``sort = 20``; +* a bulk write, because "generate the paper, then put it in" is one intention + and should not be thirty round trips. + +Transports +---------- +Both MCP transports are served from one build: ``stdio`` (what Claude Code, +Codex and the Harness spawn locally) and ``streamable-http`` (what a client on +another machine connects to). See ``scripts/mcp_server.py``. +""" diff --git a/backend/app/mcp/__main__.py b/backend/app/mcp/__main__.py new file mode 100644 index 0000000..beba5ca --- /dev/null +++ b/backend/app/mcp/__main__.py @@ -0,0 +1,15 @@ +"""``python -m app.mcp`` — the same entry point as ``scripts/mcp_server.py``. + +Having both means a client config can say either +``python -m app.mcp`` (from ``backend/``) or ``python scripts/mcp_server.py`` +(from anywhere), and neither needs to know about the other. +""" + +from __future__ import annotations + +import sys + +from app.mcp.server import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/mcp/selfcheck.py b/backend/app/mcp/selfcheck.py new file mode 100644 index 0000000..c2699d9 --- /dev/null +++ b/backend/app/mcp/selfcheck.py @@ -0,0 +1,64 @@ +"""Self-check: prove the server can start before a client has to find out. + +Run with ``python -m app.mcp --check`` (or ``scripts/mcp_server.py --check``). + +The failure this exists for is the quiet one. A stdio MCP client spawns the +server, the process starts, the handshake succeeds, and every tool call fails +with a database error the model reads as "the tool is broken". Connecting once +here, on demand, turns that into a message with a host and a port in it. + +It is deliberately independent of the transports: it builds the same server +object the transports serve, so the tool count it prints is the tool count a +client will see. +""" + +from __future__ import annotations + +import sys + +import anyio +from mcp.server.mcpserver import MCPServer +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.db.session import SessionLocal +from app.mcp.server import SERVER_VERSION +from app.models import Paper, PaperSentence, Template, TemplateFieldLibrary + + +def run_selfcheck(server: MCPServer) -> int: + """Report the database, the tool surface, and where the tools come from.""" + settings = get_settings() + print(f"paper-doc MCP {SERVER_VERSION} (name={server.name})") + print(f"database : {settings.safe_database_url}") + + try: + with SessionLocal() as db: + counts = { + "paper": db.scalar(select(func.count(Paper.id))) or 0, + "template": db.scalar(select(func.count(Template.id))) or 0, + "paper_sentence": db.scalar(select(func.count(PaperSentence.id))) or 0, + "template_field_library": db.scalar( + select(func.count(TemplateFieldLibrary.id)) + ) + or 0, + } + except Exception as error: # noqa: BLE001 - the message is the point + print(f"database : 连接失败 — {error}", file=sys.stderr) + print( + "检查 backend/.env 的 DB_* 配置,或用 proxy_endpoint skill 确认网络可达。", + file=sys.stderr, + ) + return 1 + + print("rows : " + ", ".join(f"{name}={count}" for name, count in counts.items())) + + tools = anyio.run(server.list_tools) + groups: dict[str, int] = {} + for tool in tools: + groups[tool.name.split("_", 1)[0]] = groups.get(tool.name.split("_", 1)[0], 0) + 1 + + print(f"tools : {len(tools)} " + ", ".join(f"{k}={v}" for k, v in sorted(groups.items()))) + for tool in tools: + print(f" - {tool.name}") + return 0 diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py new file mode 100644 index 0000000..b1b490d --- /dev/null +++ b/backend/app/mcp/server.py @@ -0,0 +1,247 @@ +"""Build and run the paper-doc MCP server. + +One build, three transports +--------------------------- +``stdio`` is what a client spawns: Claude Code, Codex and the DeepSeek Harness +all launch a command and speak JSON-RPC over its stdin/stdout. Nothing may be +printed to stdout by anything in this process — a stray ``print`` corrupts the +protocol stream, so diagnostics go to stderr (the SDK's own logging already +does, see ``configure_logging``). + +``streamable-http`` is what a client *on another machine* connects to: one +long-lived process serving ``POST /mcp``, optionally behind a bearer token. +``sse`` is the older HTTP shape, kept for clients that have not moved yet. + +Nothing is registered here +-------------------------- +The tools are registered by :func:`app.mcp.tools.register_all`, so this module +stays about transports and configuration. It is also the only module in the +package that knows a transport exists at all, which keeps the tool layer +testable by calling its functions directly. + +The instructions below are sent once, at ``initialize``. They are the model's +map of the tool surface, so they describe the *workflow* and leave the tool +descriptions to describe the tools. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from typing import Any + +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +from app.core.config import get_settings +from app.mcp.tools import register_all + +#: Version reported at ``initialize``. Bumped when the tool surface changes in +#: a way a client could notice, since it is the only version a client sees. +SERVER_VERSION = "1.0.0" + +INSTRUCTIONS = """\ +paper-doc 论文写作服务:管理论文(paper)、段落(paragraph)、句子(sentence)、 +模板(template)和字段库(field)。工具名前缀对应这五类。 + +典型写作流程: +1. paper_list / paper_get 找到论文;没有就 paper_create(建议带 template_id)。 +2. paper_outline 看段落骨架:每段有 position 和标题(如 “1. Introduction”)。 +3. 写入正文:整篇用 paper_write_text(Markdown 标题分段),单段用 paragraph_write, + 多段一起用 paper_write。段落可以用 heading 指定,服务端会解析成 position。 +4. 微调用 sentence_add / sentence_update / sentence_delete,不必重写整段。 +5. 验证用 paper_document(text 形式)或 paragraph_get。 + +关键规则: +- paper_sentence 是一行一句,写入时句子以数组给出最稳妥;给 text 时用 split 指定切句方式。 +- 段落由模板的 position 决定;换模板不丢正文,模板没定义的位置会显示为“未设定”。 +- 删除论文会连带句子与引用且不可恢复,paper_delete 需要 confirm=true。 +- 模板正被论文使用时不能删;字段正被模板使用时不能删——两者都会说明原因和解决办法。 +""" + + +def build_server() -> MCPServer: + """Assemble the server with every paper-doc tool registered.""" + server = MCPServer( + name="paper-doc", + title="paper-doc 论文写作", + version=SERVER_VERSION, + instructions=INSTRUCTIONS, + # The library's INFO stream includes one line per refused call, which + # is useful in a log and noise on a terminal shared with the protocol. + log_level="WARNING", + ) + register_all(server) + return server + + +# --- HTTP transports --------------------------------------------------------- + + +class BearerTokenMiddleware: + """Require ``Authorization: Bearer `` on every MCP request. + + A token, not an OAuth flow: this server is reached over a private network by + a client holding a shared secret, and the SDK's OAuth support would need a + full authorization server to say the same thing. When no token is + configured the check is skipped entirely — which is the right default for + ``stdio`` and for a loopback bind, and the reason + :func:`_serve_http` warns when a non-loopback host is used without one. + """ + + def __init__(self, app: Any, token: str) -> None: + self.app = app + self.token = token + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + if scope["type"] == "http": + headers = { + key.lower(): value for key, value in (scope.get("headers") or []) + } + presented = headers.get(b"authorization", b"").decode("latin-1") + if presented != f"Bearer {self.token}": + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"www-authenticate", b"Bearer"), + ], + } + ) + await send( + { + "type": "http.response.body", + "body": b'{"error":"unauthorized"}', + } + ) + return + await self.app(scope, receive, send) + + +def _transport_security(host: str) -> TransportSecuritySettings | None: + """DNS-rebinding protection, off for a non-loopback bind. + + The SDK turns it on by default for ``127.0.0.1``, where it costs nothing: + the client is local and sends the right ``Host``. A server bound to a LAN + address is reached by whatever name the client knows it by, so the + allow-list would reject every request with a confusing 421 — the token is + the gate there instead. + """ + if host in ("127.0.0.1", "localhost", "::1"): + return None + return TransportSecuritySettings(enable_dns_rebinding_protection=False) + + +def _serve_http( + server: MCPServer, + *, + transport: str, + host: str, + port: int, + path: str | None, + token: str | None, +) -> None: + """Build a Starlette app for an HTTP transport and serve it with uvicorn.""" + import uvicorn + + security = _transport_security(host) + + if transport == "sse": + app = server.sse_app(host=host, transport_security=security) + else: + app = server.streamable_http_app( + streamable_http_path=path or "/mcp", + host=host, + transport_security=security, + ) + + if token: + app = BearerTokenMiddleware(app, token) + elif host not in ("127.0.0.1", "localhost", "::1"): + print( + f"[paper-doc-mcp] 警告:绑定在 {host} 且没有设置 MCP_HTTP_TOKEN," + "网络内任何人都能改论文", + file=sys.stderr, + ) + + uvicorn.run(app, host=host, port=port, log_level="info") + + +# --- command line ------------------------------------------------------------ + + +def build_parser() -> argparse.ArgumentParser: + """The CLI, shared by the script entry point and the module entry point.""" + parser = argparse.ArgumentParser( + prog="paper-doc-mcp", + description="paper-doc 的 MCP 服务:论文、段落、句子、模板、字段的增删改查。", + ) + parser.add_argument( + "--transport", + default=os.getenv("MCP_TRANSPORT", "stdio"), + choices=["stdio", "http", "streamable-http", "sse"], + help="stdio(默认,给 Claude Code / Codex / Harness 本地拉起)或 http", + ) + parser.add_argument( + "--host", + default=os.getenv("MCP_HOST", "127.0.0.1"), + help="HTTP 监听地址,默认 127.0.0.1;对外提供服务时用 0.0.0.0", + ) + parser.add_argument( + "--port", + type=int, + default=int(os.getenv("MCP_PORT", "8931")), + help="HTTP 端口,默认 8931", + ) + parser.add_argument( + "--path", + default=os.getenv("MCP_PATH"), + help="streamable-http 的路径,默认 /mcp", + ) + parser.add_argument( + "--token", + default=os.getenv("MCP_HTTP_TOKEN"), + help="HTTP 传输的 Bearer 口令;对外暴露时务必设置", + ) + parser.add_argument( + "--check", + action="store_true", + help="只做自检:连数据库、列出工具名,然后退出(不启动服务)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Entry point of ``scripts/mcp_server.py`` and ``python -m app.mcp``.""" + args = build_parser().parse_args(argv) + server = build_server() + + if args.check: + from app.mcp.selfcheck import run_selfcheck + + return run_selfcheck(server) + + settings = get_settings() + print( + f"[paper-doc-mcp] transport={args.transport} db={settings.safe_database_url}", + file=sys.stderr, + ) + + if args.transport == "stdio": + server.run(transport="stdio") + else: + _serve_http( + server, + transport=args.transport, + host=args.host, + port=args.port, + path=args.path, + token=args.token, + ) + return 0 + + +__all__ = ["BearerTokenMiddleware", "INSTRUCTIONS", "SERVER_VERSION", "build_server", "main"] diff --git a/backend/app/mcp/specs.py b/backend/app/mcp/specs.py new file mode 100644 index 0000000..07bf6a4 --- /dev/null +++ b/backend/app/mcp/specs.py @@ -0,0 +1,120 @@ +"""The shapes a model may send when it writes. + +These are tool-argument models, not domain models: they exist so the JSON +Schema a client receives is precise enough that the model gets the shape right +the first time. Each one is converted into the project's own payload +(:class:`~app.schemas.paper.SentenceInput`, +:class:`~app.schemas.template.TemplateFieldInput`) before it reaches the CRUD +layer, so the validation rules stay in one place — a citation with a blank +quote is refused here for the same reason the REST API refuses it. + +The union types are deliberate. A model writing sentences naturally produces +either plain strings or objects, and refusing one of the two shapes would +produce a retry that costs a whole round trip: + + "sentences": ["First sentence.", "Second sentence."] + "sentences": [{"content": "Cited sentence.", "citations": [{"quote": "…"}]}] + "sentences": [{"text": "One long block to be cut.", "split": "sentence"}] +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field + +#: How a block of text becomes sentences when the caller sent ``text`` instead +#: of an explicit list. See :func:`app.mcp.support.split_text`. +SplitMode = Literal["line", "sentence", "paragraph"] + + +class CitationSpec(BaseModel): + """One citation of one sentence.""" + + quote: Annotated[ + str, + Field(description="引用内容,必填;空引用会被拒绝"), + ] + reference_id: Annotated[ + int | None, + Field(description="参考文献库的编号,暂无库时可省略"), + ] = None + + +class SentenceSpec(BaseModel): + """One sentence, optionally carrying citations.""" + + content: Annotated[str, Field(description="句子正文;空白会被折叠成一行")] + citations: Annotated[ + list[CitationSpec], + Field(description="这一段引用了哪些文献"), + ] = [] + + +class TextSpec(BaseModel): + """A block of text to be cut into sentences by the server.""" + + text: Annotated[str, Field(description="要写入的整段文本")] + split: Annotated[ + SplitMode, + Field(description="切句方式:line 一行一句(默认)/ sentence 按句号切 / paragraph 整段一句"), + ] = "line" + citations: Annotated[ + list[CitationSpec], + Field(description="整段共用的引用"), + ] = [] + + +#: One entry of a paragraph's sentence list — a string, a sentence object, or a +#: block to be cut. +SentenceItem = str | SentenceSpec | TextSpec + + +class ParagraphSpec(BaseModel): + """One paragraph to write, addressed by position or by heading.""" + + position: Annotated[ + int | None, + Field(description="段落位置(模板字段的 sort);与 heading 二选一"), + ] = None + heading: Annotated[ + str | None, + Field(description="段落标题,如 “1. Introduction”;会按模板解析成位置"), + ] = None + sentences: Annotated[ + list[SentenceItem] | None, + Field(description="句子列表;字符串或对象都可以"), + ] = None + text: Annotated[ + str | None, + Field(description="整段文本,等价于 sentences 里放一个 {text, split} 对象"), + ] = None + split: Annotated[ + SplitMode, + Field(description="text 的切句方式;默认 line 一行一句"), + ] = "line" + citations: Annotated[ + list[CitationSpec], + Field(description="整段共用的引用"), + ] = [] + + +class FieldSpec(BaseModel): + """One placement of a library field inside a template.""" + + field_id: Annotated[int, Field(description="字段库里的字段 id")] + sort: Annotated[ + int, + Field(description="渲染顺序,升序整数;唯一决定段落顺序的就是它"), + ] + + +__all__ = [ + "CitationSpec", + "FieldSpec", + "ParagraphSpec", + "SentenceItem", + "SentenceSpec", + "SplitMode", + "TextSpec", +] diff --git a/backend/app/mcp/support.py b/backend/app/mcp/support.py new file mode 100644 index 0000000..519033b --- /dev/null +++ b/backend/app/mcp/support.py @@ -0,0 +1,670 @@ +"""Shared plumbing for the MCP tools: sessions, JSON, lookups, and headings. + +Every tool in :mod:`app.mcp.tools` is three steps — open a session, call the +same :mod:`app.crud` helper the REST API calls, serialise the result into a +compact JSON string. This module holds the shared half of that. + +Two things here are worth knowing before reading a tool: + +**Why the results are hand-shaped rather than schema dumps.** A tool result is +paid for in context tokens, and ``PaperRead.model_dump()`` carries +``created_at``, ``updated_at`` and four count columns through every list row. +The ``*_row`` helpers below pick what a model actually reads, and +:func:`clean` drops every ``None`` on the way out. + +**Why a paragraph can be addressed by heading.** ``paper_sentence`` is stored +against a *position* (``paper_template_filed_sort``), which is correct for the +document and useless for a model: nobody writing "1. Introduction" knows that +the template happens to place it at ``sort = 20``. :func:`resolve_position` +accepts either, and matches a heading through the numbering, the level and a +unique substring so ``"Introduction"``, ``"1. Introduction"`` and +``"1 Introduction"`` all land in the same paragraph. + +Text helpers +------------ +:func:`split_text` is the one place this project ever cuts text, and it exists +only because a model arrives with a block of prose rather than with an array of +sentences. It is opt-in: the write tools default to ``line`` (one line, one +sentence — the project's own rule), and ``sentence`` refuses to cut at a +decimal point or after ``et al.``. The editor never uses it, so no stored text +is ever re-split. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from datetime import date, datetime +from decimal import Decimal +from typing import Any, NoReturn + +from mcp.server.mcpserver.exceptions import ToolError +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.crud import paper as paper_crud +from app.crud import template as template_crud +from app.db.session import SessionLocal +from app.models import ( + Paper, + PaperSentence, + Template, + TemplateFieldLibrary, +) +from app.schemas.paper import ( + CitationInput, + PaperDocumentRead, + ParagraphRead, + SentenceInput, + fold_whitespace, +) + +#: Upper bound on a page a tool may ask for. The REST API caps at 200 as well; +#: a tool result is a context window, so it is not raised here. +MAX_PAGE_SIZE = 200 + + +# --- sessions and errors ----------------------------------------------------- + + +@contextmanager +def session() -> Iterator[Session]: + """One database session per tool call, always closed. + + A tool is a whole request: there is no FastAPI dependency to hang a session + on, and leaving one open across calls would keep a TiDB connection pinned + for as long as the client stays alive. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def fail(message: str) -> NoReturn: + """Refuse a call with a message the model can act on. + + :class:`~mcp.server.mcpserver.exceptions.ToolError` becomes + ``isError: true`` with this text as the content, which the model reads and + retries against — the opposite of an unhandled exception, whose message + never reaches it. + """ + raise ToolError(message) + + +# --- JSON shaping ------------------------------------------------------------ + + +def _jsonable(value: Any) -> Any: + """Render the two types SQLAlchemy hands back that JSON does not have.""" + if isinstance(value, (datetime, date)): + # Seconds are enough for a list row, and microseconds are six tokens. + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, Decimal): + return float(value) + raise TypeError(f"不能序列化的类型:{type(value).__name__}") + + +def clean(value: Any) -> Any: + """Recursively drop ``None`` from objects, so a result carries only what is set. + + Lists and the empty string survive: an empty ``sentences`` list means "this + paragraph is empty", which is a fact, while ``author: null`` means "no + author", which the absence of the key says for free. + """ + if isinstance(value, dict): + return { + key: clean(item) for key, item in value.items() if item is not None + } + if isinstance(value, list): + return [clean(item) for item in value] + return value + + +def dumps(value: Any) -> str: + """Serialise a tool result: compact, unescaped, ``None``-free.""" + return json.dumps( + clean(value), + ensure_ascii=False, + default=_jsonable, + separators=(",", ":"), + ) + + +def page_args(page: int, page_size: int) -> tuple[int, int]: + """Clamp a page request to something a context window can hold.""" + return max(1, int(page)), min(max(1, int(page_size)), MAX_PAGE_SIZE) + + +def page_payload(*, items: list[Any], total: int, page: int, page_size: int) -> dict: + """The list envelope every list tool returns, mirroring ``PageResult``.""" + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size if page_size else 0, + } + + +# --- lookups that explain themselves ----------------------------------------- + + +def paper_or_fail(db: Session, paper_id: int) -> Paper: + """Return one paper, or refuse with the id that was not found.""" + paper = paper_crud.get(db, paper_id) + if paper is None: + fail(f"论文 {paper_id} 不存在") + return paper + + +def template_or_fail(db: Session, template_id: int) -> Template: + """Return one template, or refuse with the id that was not found.""" + template = template_crud.get(db, template_id) + if template is None: + fail(f"模板 {template_id} 不存在") + return template + + +def field_or_fail(db: Session, field_id: int) -> TemplateFieldLibrary: + """Return one library field, or refuse with the id that was not found.""" + from app.crud import template_field_library as field_crud + + field = field_crud.get(db, field_id) + if field is None: + fail(f"字段 {field_id} 不存在") + return field + + +def sentence_or_fail(paper: Paper, sentence_id: int) -> PaperSentence: + """Find a sentence *within* one paper. + + Scoped to the paper on purpose, exactly as the REST route is: an id from + another paper must not be editable through this one. + """ + for sentence in paper.sentences: + if sentence.id == sentence_id: + return sentence + fail(f"句子 {sentence_id} 不属于论文 {paper.id}") + raise AssertionError # unreachable; keeps type checkers honest + + +def id_list(value: int | Sequence[int] | None) -> list[int]: + """Accept ``3`` or ``[3, 4]`` as the same request. + + A model told to "delete these two" should not have to discover a separate + batch tool, and a batch of one is the common case anyway. + """ + if value is None: + return [] + if isinstance(value, bool): + fail("ids 必须是整数或整数数组") + if isinstance(value, int): + return [value] + return [int(item) for item in value] + + +# --- rows -------------------------------------------------------------------- + + +def _short_moment(value: datetime | None) -> str | None: + return value.strftime("%Y-%m-%d %H:%M") if value is not None else None + + +def paper_row(paper: Paper) -> dict: + """One paper as a list row or a summary. + + The counts come from :func:`app.crud.paper.read` rather than from a second + count here, so a tool result and a page of the UI can never disagree about + how much has been written. + """ + read = paper_crud.read(paper) + return { + "id": read.id, + "title": read.title, + "template_id": read.template_id, + "template": read.template_name, + "author": read.author, + "status": read.status, + "keywords": read.keywords, + "target_journal": read.target_journal, + # 已写段落 / 模板定义段落 — the progress read-out the paper list shows. + "paragraphs": read.paragraph_count, + "template_paragraphs": read.template_paragraph_count, + "sentences": read.sentence_count, + "updated_at": _short_moment(read.updated_at), + } + + +def citation_row(citation: Any) -> dict: + """One citation, as the model reads it.""" + return {"reference_id": citation.reference_id, "quote": citation.quote} + + +def sentence_row(sentence: Any, *, separator: str | None = None) -> dict: + """One sentence with its citations. + + ``separator_before`` is passed through when the caller assembled a + paragraph, because it is derived from the neighbouring sentence and cannot + be recomputed from this row alone. + """ + row = { + "id": sentence.id, + "position": sentence.paper_template_filed_sort, + "sort": sentence.sort, + "content": sentence.content, + "citations": [citation_row(item) for item in sentence.citations], + } + if separator is not None: + row["separator_before"] = separator + return row + + +def paragraph_row(paragraph: ParagraphRead, *, preview: int = 80) -> dict: + """One paragraph as an outline entry: where it is and how much is in it.""" + text = paragraph_text(paragraph) + return { + "position": paragraph.paper_template_filed_sort, + "heading": paragraph.name, + "level": paragraph.level, + "matched": paragraph.matched, + "sentences": len(paragraph.sentences), + "chars": len(text), + "preview": text[:preview] if preview else None, + } + + +def paragraph_text(paragraph: ParagraphRead, *, citations: bool = False) -> str: + """Print one paragraph exactly as the document renders it. + + ``separator_before`` is added, not invented: the server decided what + belongs at each seam, so a tool result and the paper view agree on where + the spaces go. Nothing here adds spacing of its own. + """ + parts: list[str] = [] + for sentence in paragraph.sentences: + parts.append(sentence.separator_before + sentence.content) + if citations and sentence.citations: + markers = ",".join(str(index) for index in range(1, len(sentence.citations) + 1)) + parts.append(f"[{markers}]") + return "".join(parts) + + +def document_text( + document: PaperDocumentRead, + *, + include_empty: bool = False, + citations: bool = True, +) -> str: + """Render a whole paper as the text a reader would see. + + Headings come from the template, paragraphs from the content, and + positions the template does not define are printed under 未设定 — the same + three rules the paper view renders by, applied here so a single tool call + can hand a model the finished manuscript. + """ + lines: list[str] = [] + for paragraph in document.paragraphs: + body = paragraph_text(paragraph, citations=citations) + if not body and not include_empty: + continue + lines.append(paragraph.name if paragraph.matched and paragraph.name else "未设定") + lines.append(body) + lines.append("") + + if citations and document.citations: + lines.append("参考文献") + for citation in document.citations: + reference = ( + f"[{citation.reference_id}]" if citation.reference_id else "" + ) + lines.append(f"[{citation.index}]{reference} {citation.quote}") + lines.append("") + + text = "\n".join(lines).rstrip("\n") + if document.warnings: + text += "\n\n(提示:" + " ".join(document.warnings) + ")" + return text + + +# --- headings ---------------------------------------------------------------- + +#: A leading outline number: ``1``, ``1.``, ``2.3.1``, ``3、``, ``4)``. +_NUMBER_PREFIX = re.compile(r"^\s*\d+(?:\.\d+)*\s*[.、))]?\s*") + + +def _heading_key(text: str) -> str: + """A comparison key for a heading: no markup, no number, no space, no case. + + Removing the numbering is what lets ``"Introduction"`` find + ``"1. Introduction"``. Removing the space is what lets a line the model + wrote as ``"1.Introduction"`` still find it. + """ + stripped = text.strip().lstrip("#").strip() + stripped = _NUMBER_PREFIX.sub("", stripped) + return re.sub(r"[\s\u3000]+", "", stripped).casefold() + + +def heading_index(paper: Paper) -> list[dict]: + """The paper's outline: every position its template defines. + + Ties are collapsed the way :func:`app.crud.paper.build_document` collapses + them — the first placement at a position renders the heading — so this list + and the document it describes cannot disagree about what a position says. + """ + if paper.template is None: + return [] + + seen: dict[int, dict] = {} + for placement in paper.template.items: + if placement.sort in seen: + continue + seen[placement.sort] = { + "position": placement.sort, + "heading": placement.field.name, + "level": placement.field.level, + "field_id": placement.field_id, + } + return [seen[position] for position in sorted(seen)] + + +def resolve_position( + paper: Paper, + *, + position: int | None = None, + heading: str | None = None, +) -> int: + """Turn "where the model wants to write" into a paragraph position. + + A position is taken as given — including one the template does not define, + which is exactly the case the renderer already handles as 未设定. A heading + is matched against the paper's template and refused with the list of what + the template actually says, because a model that guessed a heading will + guess again correctly once it can see the real ones. + """ + if position is not None: + return int(position) + if heading is None or not heading.strip(): + fail("必须给出 position 或 heading,两者至少要有一个") + + outline = heading_index(paper) + if not outline: + fail( + f"论文 {paper.id} 还没有模板,无法按标题定位;" + "请改用 position,或先用 paper_update 给论文设置 template_id" + ) + + query = _heading_key(heading) + exact = [item for item in outline if _heading_key(item["heading"]) == query] + if len(exact) == 1: + return int(exact[0]["position"]) + if len(exact) > 1: + fail(_ambiguous(heading, exact)) + + partial = [item for item in outline if query and query in _heading_key(item["heading"])] + if len(partial) == 1: + return int(partial[0]["position"]) + if len(partial) > 1: + fail(_ambiguous(heading, partial)) + + available = "、".join(f"{item['heading']}({item['position']})" for item in outline) + fail(f"模板里没有标题“{heading}”。可用标题:{available}") + + +def _ambiguous(heading: str, matches: list[dict]) -> str: + listed = "、".join(f"{item['heading']}({item['position']})" for item in matches) + return f"标题“{heading}”匹配到多个段落({listed}),请写完整标题或直接用 position" + + +# --- text -------------------------------------------------------------------- + +#: Where a sentence may end in CJK text. ``;`` is deliberately absent: a +#: semicolon joins two halves of one argument often enough that cutting there +#: would produce fragments rather than sentences. +_CJK_TERMINATORS = "。!?…" + +#: Trailing marks that belong to the sentence they follow. +_TRAILING = "”’」』))】》…" + +#: Latin abbreviations whose full stop is not a sentence end. +_ABBREVIATIONS = frozenset( + { + "al.", + "approx.", + "ca.", + "cf.", + "dr.", + "e.g.", + "etc.", + "fig.", + "figs.", + "i.e.", + "mr.", + "mrs.", + "no.", + "prof.", + "vs.", + } +) + +SPLIT_MODES = ("line", "sentence", "paragraph") + + +def split_text(text: str, mode: str = "line") -> list[str]: + """Cut a block of prose into sentences — the only text cutting in the project. + + Three modes, in increasing order of how much the tool is trusted: + + ``line`` + Each non-blank line is one sentence. The default, and the project's own + rule ("one line in the editor is one sentence"): nothing is guessed, so + nothing can be guessed wrong. + ``paragraph`` + The whole block folds to a single sentence. For a writer who puts a + paragraph on one line and means it as one unit. + ``sentence`` + Best-effort splitting after ``。!?`` and after a Latin ``.`` ``!`` + ``?`` that is followed by a space and something that starts a sentence. + It does not cut at a decimal point or after ``et al.``, but it is still + a guess — which is why it is never the default and never applied to + text that is already stored. + + Blank lines are dropped in every mode: the tool is writing, and an empty + sentence is only meaningful when a person puts one there on purpose. + """ + if mode not in SPLIT_MODES: + fail(f"split 只能是 {'/'.join(SPLIT_MODES)},收到的是“{mode}”") + + if mode == "paragraph": + folded = fold_whitespace(text) + return [folded] if folded else [] + + if mode == "line": + return [line.strip() for line in text.splitlines() if line.strip()] + + return _split_sentences(fold_whitespace(text)) + + +def _split_sentences(text: str) -> list[str]: + """The ``sentence`` mode of :func:`split_text`, on already-folded text.""" + if not text: + return [] + + out: list[str] = [] + start = 0 + index = 0 + + while index < len(text): + char = text[index] + + if char in _CJK_TERMINATORS: + end = index + 1 + while end < len(text) and text[end] in _TRAILING: + end += 1 + out.append(text[start:end]) + start = end + index = end + continue + + if char in ".!?" and _is_latin_break(text, index): + out.append(text[start : index + 1]) + start = index + 1 + + index += 1 + + tail = text[start:] + if tail: + out.append(tail) + + return [item.strip() for item in out if item.strip()] + + +def _is_latin_break(text: str, index: int) -> bool: + """Whether the ``.``/``!``/``?`` at ``index`` ends a sentence.""" + # A boundary needs a space (or the end) after it, or "3.14" and "www.x.com" + # would each become two sentences. + after = index + 1 + if after < len(text) and not text[after].isspace(): + return False + + follower = after + while follower < len(text) and text[follower].isspace(): + follower += 1 + if follower >= len(text): + return True + + # The next sentence starts with a capital, a digit, a quote or CJK; a + # lowercase continuation means this was an abbreviation inside a sentence. + nxt = text[follower] + if not (nxt.isupper() or nxt.isdigit() or nxt in "\"'“(([" or ord(nxt) > 0x2E80): + return False + + token_start = index + while token_start > 0 and not text[token_start - 1].isspace(): + token_start -= 1 + token = text[token_start : index + 1] + + if token.casefold() in _ABBREVIATIONS: + return False + # A lone letter is an initial — "J. Smith" is one name, not two sentences. + if len(token) == 2 and token[0].isupper(): + return False + + return True + + +def sentence_inputs(items: Sequence[Any], *, default_split: str = "line") -> list[SentenceInput]: + """Normalise what a model sent into the project's own sentence payload. + + One item may arrive in any of the shapes a model naturally produces: + + * ``"A sentence."`` — the common case; + * ``{"content": "...", "citations": [{"quote": "...", "reference_id": 3}]}``; + * ``{"text": "one\\ntwo", "split": "line"}`` — a block to be cut. + + Every path ends in :class:`~app.schemas.paper.SentenceInput`, so the + whitespace folding and the "a citation must quote something" rule are the + ones the REST API enforces, not a second copy of them. + """ + out: list[SentenceInput] = [] + + for item in items: + if isinstance(item, str): + out.append(SentenceInput(content=item)) + continue + + # A tool signature declares the union as pydantic models, so the same + # path has to accept both an already-validated model and the raw dict + # that a lower-level caller passes. + if isinstance(item, BaseModel): + item = item.model_dump() + + if not isinstance(item, dict): + fail(f"句子只能是字符串或对象,收到的是 {type(item).__name__}") + + citations = [ + CitationInput( + quote=str(citation.get("quote", "")), + reference_id=citation.get("reference_id"), + ) + for citation in (item.get("citations") or []) + ] + + if item.get("content") is not None: + out.append(SentenceInput(content=str(item["content"]), citations=citations)) + continue + + if item.get("text") is not None: + mode = item.get("split") or default_split + for piece in split_text(str(item["text"]), mode): + out.append(SentenceInput(content=piece, citations=citations)) + continue + + fail("句子对象需要有 content 或 text 字段") + + return out + + +def paragraph_inputs(spec: Any) -> list[SentenceInput]: + """Collect one paragraph's sentences from every shape a model may send. + + A caller may hand over an explicit ``sentences`` list, a single ``text`` + block to be cut, or neither. "Neither" is refused rather than treated as an + empty paragraph: clearing a paragraph is a different intention with its own + tool, and silently emptying one because an argument was misspelled is the + one mistake this layer must not make. + """ + items: list[Any] = list(spec.sentences or []) + if not items and spec.text is not None and spec.text.strip(): + items = [{"text": spec.text, "split": spec.split}] + + if not items: + fail( + f"段落(position={spec.position} heading={spec.heading})既没有 sentences " + "也没有 text。若确实要清空这段,请用 paragraph_delete" + ) + + extra = [ + CitationInput(quote=item.quote, reference_id=item.reference_id) + for item in (spec.citations or []) + ] + inputs = sentence_inputs(items) + if extra: + # Paragraph-level citations apply to every sentence that does not carry + # its own — the shape a model produces when a whole paragraph rests on + # one source. + for item in inputs: + if not item.citations: + item.citations = list(extra) + return inputs + + +__all__ = [ + "MAX_PAGE_SIZE", + "SPLIT_MODES", + "clean", + "citation_row", + "document_text", + "dumps", + "fail", + "field_or_fail", + "heading_index", + "id_list", + "page_args", + "page_payload", + "paper_or_fail", + "paper_row", + "paragraph_inputs", + "paragraph_row", + "paragraph_text", + "resolve_position", + "sentence_inputs", + "sentence_or_fail", + "sentence_row", + "session", + "split_text", + "template_or_fail", +] diff --git a/backend/app/mcp/tools/__init__.py b/backend/app/mcp/tools/__init__.py new file mode 100644 index 0000000..70e7433 --- /dev/null +++ b/backend/app/mcp/tools/__init__.py @@ -0,0 +1,33 @@ +"""Tool registry: the five groups, registered in one call. + +The groups mirror the domain rather than the HTTP routes — papers and +paragraphs and sentences are the same tables the REST API serves, but a model +reaches for "append a sentence" and "write a whole paper" as different jobs, +and the split between :mod:`~app.mcp.tools.papers` and +:mod:`~app.mcp.tools.paragraphs` follows those jobs. + +Prefixes are load-bearing: every tool name starts with its group +(``paper_``, ``paragraph_``, ``sentence_``, ``template_``, ``field_``), because +that prefix is what a model uses to pick a tool out of a list of twenty-nine +without reading all twenty-nine descriptions. +""" + +from __future__ import annotations + +from mcp.server.mcpserver import MCPServer + +from app.mcp.tools import fields, papers, paragraphs, sentences, templates + +#: Registration order is also the order a client lists the tools in, which is +#: the order a paper is actually written in: find it, shape it, write it, then +#: the things it is written against. +MODULES = (papers, paragraphs, sentences, templates, fields) + + +def register_all(server: MCPServer) -> None: + """Register every paper-doc tool on ``server``.""" + for module in MODULES: + module.register(server) + + +__all__ = ["MODULES", "register_all"] diff --git a/backend/app/mcp/tools/fields.py b/backend/app/mcp/tools/fields.py new file mode 100644 index 0000000..be75ab1 --- /dev/null +++ b/backend/app/mcp/tools/fields.py @@ -0,0 +1,185 @@ +"""Field-library tools (字段库): the reusable headings templates are built from. + +The library is the catalogue, and ``template_field`` is a placement in one +template — one word apart, opposite meanings. A field exists once and may be +placed by any number of templates, which is why editing a field restyles every +outline that uses it and why deleting one is refused while any template still +places it. + +The numbering a reader sees ("1. Introduction", "2.1 Study area") is part of +``name`` and is written by the caller. Nothing here derives or rewrites it: +``level`` is a rendering hint, not a parent pointer, which is what lets one +level-2 heading sit under two different level-1 headings in two templates. +""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.server.mcpserver import MCPServer +from pydantic import Field +from sqlalchemy.orm import Session + +from app.crud import template_field_library as field_crud +from app.mcp import support as s +from app.mcp.tools.registry import mcp_tool +from app.models import TemplateFieldLibrary +from app.schemas.template_field_library import ( + TemplateFieldLibraryCreate, + TemplateFieldLibraryUpdate, +) + + +def register(server: MCPServer) -> None: + """Register every field-library tool on ``server``.""" + + @mcp_tool(server, title="列出字段库") + def field_list( + keyword: Annotated[str | None, Field(description="按字段名称模糊搜索")] = None, + level: Annotated[ + int | None, Field(description="按层级过滤:1 是一级标题,2 是二级") + ] = None, + page: Annotated[int, Field(description="页码,从 1 开始")] = 1, + page_size: Annotated[int, Field(description="每页条数,最大 200")] = 50, + ) -> str: + """列出字段库(模板能用的所有标题)。建模板前先查这里的 id。""" + page, page_size = s.page_args(page, page_size) + with s.session() as db: + rows, total = field_crud.list_fields( + db, keyword=keyword, level=level, page=page, page_size=page_size + ) + return s.dumps( + s.page_payload( + items=[_field_row(row) for row in rows], + total=total, + page=page, + page_size=page_size, + ) + ) + + @mcp_tool(server, title="取一个字段") + def field_get( + field_id: Annotated[int, Field(description="字段 id")], + ) -> str: + """取字段库里一个字段,并给出它被几个模板使用。""" + with s.session() as db: + field = s.field_or_fail(db, field_id) + payload = _field_row(field) + payload["used_by_templates"] = field_crud.usage_counts(db, [field.id]).get( + field.id, 0 + ) + return s.dumps(payload) + + @mcp_tool(server, title="新建字段") + def field_create( + name: Annotated[ + str, + Field(description="字段名,编号要自己写进去,例如 “3. Results” 或 “3.1 Study area”"), + ], + level: Annotated[ + int, Field(description="层级 1-9,只决定缩进,1 是一级标题") + ] = 1, + font_size: Annotated[ + float, Field(description="字号(磅),中文五号是 10.5") + ] = 12.0, + font_color: Annotated[ + str, Field(description="颜色,#RRGGBB 或 rgb(r,g,b)") + ] = "#000000", + ) -> str: + """往字段库加一个标题字段,可带字号颜色。""" + payload = TemplateFieldLibraryCreate( + name=name, level=level, font_size=font_size, font_color=font_color + ) + with s.session() as db: + return s.dumps(_field_row(field_crud.create(db, payload))) + + @mcp_tool(server, title="修改字段") + def field_update( + field_id: Annotated[int, Field(description="字段 id")], + name: Annotated[str | None, Field(description="新名称;不传则不改")] = None, + level: Annotated[int | None, Field(description="新层级")] = None, + font_size: Annotated[float | None, Field(description="新字号")] = None, + font_color: Annotated[str | None, Field(description="新颜色")] = None, + ) -> str: + """改字段的名称或排版。所有引用它的模板会立刻跟着变。""" + # Only the keys actually given are sent: the update schema treats an + # explicitly-passed ``None`` as "set this column to null", which no + # column here allows, so a parameter left out must not reach it. + values: dict[str, object] = {} + if name is not None: + values["name"] = name + if level is not None: + values["level"] = level + if font_size is not None: + values["font_size"] = font_size + if font_color is not None: + values["font_color"] = font_color + if not values: + s.fail("没有给出任何要修改的内容") + + payload = TemplateFieldLibraryUpdate(**values) + + with s.session() as db: + field = s.field_or_fail(db, field_id) + updated = field_crud.update(db, field, payload) + result = _field_row(updated) + result["used_by_templates"] = field_crud.usage_counts(db, [field_id]).get( + field_id, 0 + ) + return s.dumps(result) + + @mcp_tool(server, title="删除字段") + def field_delete( + field_ids: Annotated[int | list[int], Field(description="字段 id,或 id 数组")], + ) -> str: + """删除字段(仍被模板使用的会被拒绝,需先从模板里移除)。""" + ids = s.id_list(field_ids) + if not ids: + s.fail("必须给出至少一个字段 id") + + with s.session() as db: + fields = field_crud.get_many(db, ids) + _assert_unused(db, fields) + for field in fields: + field_crud.delete(db, field) + found = {field.id for field in fields} + return s.dumps( + { + "deleted": len(fields), + "ids": [field_id for field_id in ids if field_id in found], + "missing": [field_id for field_id in ids if field_id not in found], + } + ) + + +# --- helpers ----------------------------------------------------------------- + + +def _field_row(field: TemplateFieldLibrary) -> dict: + """One library entry as a model reads it.""" + return { + "id": field.id, + "name": field.name, + "level": field.level, + "font_size": float(field.font_size), + "font_color": field.font_color, + } + + +def _assert_unused(db: Session, fields: list[TemplateFieldLibrary]) -> None: + """Refuse the delete while any template still places the field. + + Dropping a field that is placed somewhere would silently remove a heading + from that template's outline — data loss wearing the costume of cleanup. + Every offender is named at once, with its template count. + """ + counts = field_crud.usage_counts(db, [field.id for field in fields]) + if not counts: + return + + names = {field.id: field.name for field in fields} + blockers = "、".join( + f"“{names.get(field_id, field_id)}”({count} 个模板)" + for field_id, count in sorted(counts.items()) + ) + s.fail(f"以下字段正被模板使用,请先用 template_update 从模板里移除:{blockers}") diff --git a/backend/app/mcp/tools/papers.py b/backend/app/mcp/tools/papers.py new file mode 100644 index 0000000..b61edad --- /dev/null +++ b/backend/app/mcp/tools/papers.py @@ -0,0 +1,506 @@ +"""Paper tools (论文): the library, the document, and the write path. + +The write path is the reason this package exists. A model asked to "write the +paper into the system" arrives with a generated document, not with paragraph +positions, so the tools accept either and the server does the translating: + +* :func:`paper_write` takes one request per paragraph, each addressed by + ``heading`` ("1. Introduction") or by ``position``; +* :func:`paper_write_text` takes the whole generated document as one string and + finds the sections itself, so a twenty-paragraph paper is one tool call. + +Both end in :func:`app.crud.paper.replace_paragraph` — the same function the +paragraph editor calls — which is why a paper written through MCP and a paper +written by hand are indistinguishable afterwards. +""" + +from __future__ import annotations + +import json +from typing import Annotated, Any, Literal + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from pydantic import Field + +from app.crud import paper as paper_crud +from app.mcp import support as s +from app.mcp.specs import ParagraphSpec +from app.mcp.tools.registry import mcp_tool +from app.models import Paper +from app.schemas.paper import ( + PaperCreate, + PaperUpdate, + ParagraphUpdate, + SentenceCreate, +) + + +def register(server: MCPServer) -> None: + """Register every paper tool on ``server``.""" + + @mcp_tool(server, title="列出论文") + def paper_list( + keyword: Annotated[ + str | None, Field(description="按标题、作者或关键词模糊搜索") + ] = None, + status: Annotated[ + Literal["draft", "writing", "done"] | None, + Field(description="按状态过滤"), + ] = None, + template_id: Annotated[ + int | None, Field(description="按所用模板过滤") + ] = None, + page: Annotated[int, Field(description="页码,从 1 开始")] = 1, + page_size: Annotated[int, Field(description="每页条数,最大 200")] = 20, + ) -> str: + """列出/搜索论文(论文列表,不含正文)。返回 id、标题、模板、进度。""" + page, page_size = s.page_args(page, page_size) + with s.session() as db: + items, total = paper_crud.list_papers( + db, + keyword=keyword, + status=status, + template_id=template_id, + page=page, + page_size=page_size, + ) + return s.dumps( + s.page_payload( + items=[ + { + "id": item.id, + "title": item.title, + "template_id": item.template_id, + "template": item.template_name, + "author": item.author, + "status": item.status, + "keywords": item.keywords, + "paragraphs": item.paragraph_count, + "template_paragraphs": item.template_paragraph_count, + "sentences": item.sentence_count, + } + for item in items + ], + total=total, + page=page, + page_size=page_size, + ) + ) + + @mcp_tool(server, title="取一篇论文") + def paper_get( + paper_id: Annotated[int, Field(description="论文 id")], + ) -> str: + """取一篇论文的元信息与写作进度,不含正文。""" + with s.session() as db: + return s.dumps(s.paper_row(s.paper_or_fail(db, paper_id))) + + @mcp_tool(server, title="新建论文") + def paper_create( + title: Annotated[str, Field(description="论文标题,不能为空")], + template_id: Annotated[ + int | None, Field(description="所用模板 id;给出后论文立刻拥有整篇结构") + ] = None, + author: Annotated[str | None, Field(description="作者")] = None, + status: Annotated[ + Literal["draft", "writing", "done"] | None, + Field(description="状态,默认 draft"), + ] = None, + keywords: Annotated[ + str | None, Field(description="关键词,逗号分隔;会去重并存成规范形式") + ] = None, + target_journal: Annotated[ + str | None, Field(description="投稿目标期刊") + ] = None, + ) -> str: + """新建一篇论文。只写元信息,正文为空;结构来自模板,随时可换。""" + payload = PaperCreate( + title=title, + template_id=template_id, + author=author, + status=status or "draft", + keywords=keywords, + target_journal=target_journal, + ) + with s.session() as db: + _assert_template_exists(db, payload.template_id) + return s.dumps(s.paper_row(paper_crud.create(db, payload))) + + @mcp_tool(server, title="修改论文") + def paper_update( + paper_id: Annotated[int, Field(description="论文 id")], + title: Annotated[str | None, Field(description="新标题;不传则不变")] = None, + template_id: Annotated[ + int | None, + Field(description="切换到另一个模板;句子留在原位置,标题随之改变"), + ] = None, + clear_template: Annotated[ + bool, Field(description="true 表示把模板置空(正文全部落到“未设定”)") + ] = False, + author: Annotated[ + str | None, Field(description="作者;空字符串表示清空") + ] = None, + status: Annotated[ + Literal["draft", "writing", "done"] | None, Field(description="状态") + ] = None, + keywords: Annotated[ + str | None, Field(description="关键词;空字符串表示清空") + ] = None, + target_journal: Annotated[ + str | None, Field(description="目标期刊;空字符串表示清空") + ] = None, + ) -> str: + """修改论文元信息,或切换模板(换模板会重塑全文结构,正文不丢)。""" + values: dict[str, Any] = {} + if title is not None: + values["title"] = title + if clear_template: + values["template_id"] = None + elif template_id is not None: + values["template_id"] = template_id + if author is not None: + values["author"] = author + if status is not None: + values["status"] = status + if keywords is not None: + values["keywords"] = keywords + if target_journal is not None: + values["target_journal"] = target_journal + + if not values: + s.fail("没有给出任何要修改的字段") + + payload = PaperUpdate(**values) + # The schemas normalise (trimmed title, canonical keywords, blank -> + # None); only the keys actually sent are applied, so a field left out + # is not mistaken for an explicit null. + applied = payload.model_dump(exclude_unset=True) + + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + if "template_id" in applied: + _assert_template_exists(db, applied["template_id"]) + + template_changed = ( + "template_id" in applied + and applied["template_id"] != paper.template_id + ) + updated = paper_crud.update( + db, paper, values=applied, template_changed=template_changed + ) + return s.dumps({**s.paper_row(updated), "template_changed": template_changed}) + + @mcp_tool(server, title="删除论文") + def paper_delete( + paper_ids: Annotated[int | list[int], Field(description="论文 id,或 id 数组")], + confirm: Annotated[ + bool, + Field(description="必须显式传 true;删除会连同句子和引用一起消失,不可恢复"), + ] = False, + ) -> str: + """删除论文(连句子和引用一起)。必须 confirm=true 才会执行。""" + ids = s.id_list(paper_ids) + if not ids: + s.fail("必须给出至少一个论文 id") + if not confirm: + s.fail( + f"删除论文 {ids} 会连同其全部句子和引用一起消失,且无法恢复。" + "确认无误后请带 confirm=true 重新调用" + ) + with s.session() as db: + return s.dumps({"deleted": paper_crud.delete_many(db, ids), "ids": ids}) + + @mcp_tool(server, title="论文骨架") + def paper_outline( + paper_id: Annotated[int, Field(description="论文 id")], + ) -> str: + """取论文的段落骨架:每段的 position、标题、已写句子数。写正文前先看这个。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + document = paper_crud.build_document(db, paper) + written = { + paragraph.paper_template_filed_sort: len(paragraph.sentences) + for paragraph in document.paragraphs + } + return s.dumps( + { + "paper": s.paper_row(paper), + "paragraphs": [ + {**item, "sentences": written.get(item["position"], 0)} + for item in s.heading_index(paper) + ], + "warnings": document.warnings, + } + ) + + @mcp_tool(server, title="读论文全文") + def paper_document( + paper_id: Annotated[int, Field(description="论文 id")], + format: Annotated[ + Literal["text", "json", "both"], + Field(description="text=可直接阅读的全文(默认),json=带 id 的结构,both=两者都要"), + ] = "text", + include_empty: Annotated[ + bool, Field(description="是否输出还没有内容的段落") + ] = False, + citations: Annotated[ + bool, Field(description="是否带上引用标记与参考文献列表") + ] = True, + ) -> str: + """读整篇论文:按模板顺序输出全文(含引用编号和参考文献)。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + document = paper_crud.build_document(db, paper) + + payload: dict[str, Any] = {"paper": s.paper_row(paper)} + if format in ("text", "both"): + payload["text"] = s.document_text( + document, include_empty=include_empty, citations=citations + ) + if format in ("json", "both"): + payload["paragraphs"] = [ + { + **s.paragraph_row(paragraph, preview=0), + "sentences": [ + s.sentence_row(row, separator=row.separator_before) + for row in paragraph.sentences + ], + } + for paragraph in document.paragraphs + if include_empty or paragraph.sentences + ] + payload["citations"] = [ + { + "index": item.index, + "reference_id": item.reference_id, + "quote": item.quote, + "sentence_id": item.sentence_id, + } + for item in document.citations + ] + if document.warnings: + payload["warnings"] = document.warnings + return s.dumps(payload) + + @mcp_tool(server, title="批量写段落") + def paper_write( + paper_id: Annotated[int, Field(description="论文 id")], + paragraphs: Annotated[ + list[ParagraphSpec], + Field( + description=( + "要写的段落数组。每段用 heading(如 “1. Introduction”)或 position 指定," + "正文放 sentences(字符串数组或带 citations 的对象数组)," + "或放 text + split 让服务端切句" + ) + ), + ], + mode: Annotated[ + Literal["replace", "append"], + Field(description="replace=整段覆盖(默认),append=追加到该段末尾"), + ] = "replace", + ) -> str: + """把生成好的段落写入论文:按标题或位置定位,一次可写多段。AI 写完直接调用这个。""" + if not paragraphs: + s.fail("paragraphs 不能为空") + + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + written: list[dict[str, Any]] = [] + + for spec in paragraphs: + position = s.resolve_position( + paper, position=spec.position, heading=spec.heading + ) + inputs = s.paragraph_inputs(spec) + heading = _heading_at(paper, position) + + if mode == "append": + for item in inputs: + paper_crud.append_sentence( + db, + paper, + SentenceCreate( + paper_template_filed_sort=position, + content=item.content, + citations=item.citations, + sort=item.sort, + ), + ) + else: + paper_crud.replace_paragraph( + db, + paper, + position, + ParagraphUpdate(sentences=inputs), + ) + + written.append( + { + "position": position, + "heading": heading, + "mode": mode, + "sentences": len(inputs), + "chars": sum(len(item.content) for item in inputs), + } + ) + + return s.dumps( + { + "paper_id": paper.id, + "written": written, + "total_sentences": sum(item["sentences"] for item in written), + } + ) + + @mcp_tool(server, title="整篇写入") + def paper_write_text( + paper_id: Annotated[int, Field(description="论文 id")], + text: Annotated[ + str, + Field( + description=( + "整篇论文的文本。用 Markdown 标题(# 0 Abstract / ## 1. Introduction)" + "或与模板同名的标题行分段,标题下的内容写进对应段落" + ) + ), + ], + split: Annotated[ + Literal["line", "sentence", "paragraph"], + Field(description="段落内怎么切句:line 一行一句(默认)/ sentence 按句号 / paragraph 整段一句"), + ] = "line", + mode: Annotated[ + Literal["replace", "append"], + Field(description="replace=整段覆盖(默认),append=追加到该段末尾"), + ] = "replace", + strict: Annotated[ + bool, + Field(description="true=有标题匹配不上就整篇不写;false=能写的先写,其余在 unmatched 里返回"), + ] = False, + ) -> str: + """把一整篇生成好的论文按标题切段后写入论文。适合“AI 写完直接灌进去”。""" + sections = _split_sections(text) + if not sections: + s.fail("没有从 text 里找到任何与模板同名的标题,请改用 paper_write 按 position 写") + + paragraphs: list[ParagraphSpec] = [] + unmatched: list[str] = [] + + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + for heading, body in sections: + try: + s.resolve_position(paper, heading=heading) + except ToolError as error: + # ToolError carries the candidate list the model needs to + # correct the heading, so it is reported rather than raised + # when the caller asked for a lenient run. + unmatched.append(f"{heading}:{error}") + if strict: + s.fail(f"标题“{heading}”无法定位到模板段落;{error}") + continue + paragraphs.append( + ParagraphSpec(heading=heading, text=body, split=split) + ) + + if not paragraphs: + s.fail("没有任何标题能定位到模板段落:" + ";".join(unmatched)) + + payload = json.loads(paper_write(paper_id=paper_id, paragraphs=paragraphs, mode=mode)) + if unmatched: + payload["unmatched"] = unmatched + return s.dumps(payload) + + +# --- helpers ----------------------------------------------------------------- + + +def _assert_template_exists(db: Any, template_id: int | None) -> None: + """Refuse a paper pointing at a template that is not there. + + Checked in Python rather than by a foreign key, exactly as the REST route + does: TiDB parses but does not enforce ``FOREIGN KEY``, so an unchecked + write would leave a paper with no structure and no way to notice. + """ + if template_id is None: + return + s.template_or_fail(db, template_id) + + +def _heading_at(paper: Paper, position: int) -> str | None: + """What the paper's template calls this position, if it names it at all.""" + for item in s.heading_index(paper): + if item["position"] == position: + return str(item["heading"]) + return None + + +#: A Markdown ATX heading, the most likely shape of generated output. +_MARKDOWN_HEADING = "#" + + +def _split_sections(text: str) -> list[tuple[str, str]]: + """Cut a generated document into ``(heading, body)`` pairs. + + Two heading shapes are recognised, in this order: + + 1. a Markdown heading line — ``# 0 Abstract`` / ``## 1. Introduction``; + 2. a plain line that is *exactly* one of the template's headings after + normalisation, which is what a model produces when it copies the outline + out of :func:`paper_outline` and writes prose under it. + + A body line is never mistaken for a heading: rule 2 needs a short line with + no sentence-ending punctuation, and it still has to match a heading the + caller's paper actually defines. Anything before the first heading is + dropped — there is no paragraph to put it in — which is why the tool + refuses outright when no heading matched at all. + """ + sections: list[tuple[str, str]] = [] + heading: str | None = None + body: list[str] = [] + + def close() -> None: + if heading is not None: + sections.append((heading, "\n".join(body).strip())) + + for line in text.splitlines(): + stripped = line.strip() + candidate = stripped.lstrip(_MARKDOWN_HEADING).strip() if stripped.startswith(_MARKDOWN_HEADING) else None + + if candidate is not None: + if not candidate: + continue + close() + heading, body = candidate, [] + continue + + if _looks_like_plain_heading(stripped): + close() + heading, body = stripped, [] + continue + + if heading is not None: + body.append(line) + + close() + # A heading with an empty body is a section the caller left blank; keeping + # it would overwrite a written paragraph with nothing. + return [(name, block) for name, block in sections if block] + + +def _looks_like_plain_heading(line: str) -> bool: + """Whether a plain line could be a heading rather than prose. + + Deliberately conservative. The caller's paper decides the final answer in + :func:`app.mcp.support.resolve_position`; this only avoids handing it every + sentence in the document. + """ + if not line or len(line) > 60: + return False + if line[-1] in "。!?.!?,,、;;::": + return False + # A heading has no more than a handful of words before the first full stop; + # a prose line at this length that ends without punctuation is rare enough + # that the template match is what settles it. + return True diff --git a/backend/app/mcp/tools/paragraphs.py b/backend/app/mcp/tools/paragraphs.py new file mode 100644 index 0000000..2524252 --- /dev/null +++ b/backend/app/mcp/tools/paragraphs.py @@ -0,0 +1,277 @@ +"""Paragraph tools (段落): one position of one paper, read and written whole. + +A paragraph is not a row — it is a *position*. It exists because the template +defines it or because a sentence occupies it, which is why there is no +``paragraph_create``: writing into an empty position creates the paragraph, and +there is nothing else a create could mean. + +Every tool here takes ``position`` or ``heading`` and lets +:func:`app.mcp.support.resolve_position` decide. Writing goes through +:func:`app.crud.paper.replace_paragraph`, the same call the editor's save button +makes, so "replace" always means "this position now holds exactly these +sentences" — never a merge that quietly keeps a line the model meant to drop. +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from mcp.server.mcpserver import MCPServer +from pydantic import Field +from sqlalchemy.orm import Session + +from app.crud import paper as paper_crud +from app.mcp import support as s +from app.mcp.specs import CitationSpec, ParagraphSpec, SentenceItem +from app.mcp.tools.registry import mcp_tool +from app.models import Paper, PaperSentence +from app.schemas.paper import ( + ParagraphUpdate, + SentenceCreate, + SentenceInput, +) + + +def register(server: MCPServer) -> None: + """Register every paragraph tool on ``server``.""" + + @mcp_tool(server, title="列出段落") + def paragraph_list( + paper_id: Annotated[int, Field(description="论文 id")], + include_empty: Annotated[ + bool, Field(description="是否包含模板里还没有内容的段落(默认包含)") + ] = True, + preview: Annotated[ + int, Field(description="每段返回多少字的开头预览,0 表示不返回") + ] = 80, + ) -> str: + """列出论文的段落骨架:position、标题、句子数、开头预览。改正文前先看这个。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + document = paper_crud.build_document(db, paper) + items = [ + s.paragraph_row(paragraph, preview=preview) + for paragraph in document.paragraphs + if include_empty or paragraph.sentences + ] + return s.dumps( + { + "paper_id": paper.id, + "paper_title": paper.title, + "total": len(items), + "items": items, + "warnings": document.warnings, + } + ) + + @mcp_tool(server, title="读一个段落") + def paragraph_get( + paper_id: Annotated[int, Field(description="论文 id")], + position: Annotated[ + int | None, Field(description="段落位置;与 heading 二选一") + ] = None, + heading: Annotated[ + str | None, Field(description="段落标题,如 “2. Methods”") + ] = None, + ) -> str: + """读一个段落:拼好的正文、逐句内容与 id、引用、段间分隔符。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + field_sort = s.resolve_position(paper, position=position, heading=heading) + paragraph = paper_crud.get_paragraph(db, paper, field_sort) + if paragraph is None: + s.fail( + f"论文 {paper_id} 没有第 {field_sort} 段:模板没有这个位置,也没有内容" + ) + return s.dumps( + { + "paper_id": paper.id, + "paper_title": paper.title, + "position": paragraph.paper_template_filed_sort, + "heading": paragraph.name, + "matched": paragraph.matched, + "text": s.paragraph_text(paragraph), + "sentences": [ + s.sentence_row(row, separator=row.separator_before) + for row in paragraph.sentences + ], + } + ) + + @mcp_tool(server, title="写一个段落") + def paragraph_write( + paper_id: Annotated[int, Field(description="论文 id")], + position: Annotated[ + int | None, Field(description="段落位置;与 heading 二选一") + ] = None, + heading: Annotated[ + str | None, Field(description="段落标题,如 “3. Results”") + ] = None, + sentences: Annotated[ + list[SentenceItem] | None, + Field(description="句子数组:字符串、{content, citations} 或 {text, split}"), + ] = None, + text: Annotated[ + str | None, Field(description="整段文本;与 sentences 二选一") + ] = None, + split: Annotated[ + Literal["line", "sentence", "paragraph"], + Field(description="text 的切句方式;默认 line 一行一句"), + ] = "line", + citations: Annotated[ + list[CitationSpec] | None, + Field(description="整段共用的引用,会补到没有自带引用的句子上"), + ] = None, + mode: Annotated[ + Literal["replace", "append"], + Field(description="replace=整段覆盖(默认),append=追加到该段末尾"), + ] = "replace", + ) -> str: + """写一个段落:按标题或位置定位,整段覆盖或追加。""" + spec = ParagraphSpec( + position=position, + heading=heading, + sentences=sentences, + text=text, + split=split, + citations=citations or [], + ) + + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + field_sort = s.resolve_position(paper, position=position, heading=heading) + inputs = s.paragraph_inputs(spec) + return s.dumps( + _write(db, paper, field_sort, inputs, mode, heading_label=heading) + ) + + @mcp_tool(server, title="清空段落") + def paragraph_delete( + paper_id: Annotated[int, Field(description="论文 id")], + position: Annotated[ + int | None, Field(description="段落位置;与 heading 二选一") + ] = None, + heading: Annotated[str | None, Field(description="段落标题")] = None, + ) -> str: + """清空一个段落的全部句子。段落本身还在,仍会以空段落渲染。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + field_sort = s.resolve_position(paper, position=position, heading=heading) + existing = _sentences_at(db, paper, field_sort) + paper_crud.replace_paragraph( + db, paper, field_sort, ParagraphUpdate(sentences=[]) + ) + return s.dumps( + { + "paper_id": paper.id, + "position": field_sort, + "heading": _heading_at(paper, field_sort), + "deleted_sentences": len(existing), + } + ) + + @mcp_tool(server, title="移动段落") + def paragraph_move( + paper_id: Annotated[int, Field(description="论文 id")], + position: Annotated[ + int | None, Field(description="要搬走的段落位置;与 heading 二选一") + ] = None, + heading: Annotated[str | None, Field(description="要搬走的段落标题")] = None, + target_position: Annotated[ + int | None, Field(description="搬到哪个位置;与 target_heading 二选一") + ] = None, + target_heading: Annotated[ + str | None, Field(description="搬到哪个标题下") + ] = None, + ) -> str: + """把一个段落的全部句子搬到另一个位置,接在目标段落已有内容之后。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + source = s.resolve_position(paper, position=position, heading=heading) + target = s.resolve_position( + paper, position=target_position, heading=target_heading + ) + if source == target: + s.fail(f"源位置和目标位置都是 {source},没有可搬的内容") + + rows = _sentences_at(db, paper, source) + if not rows: + s.fail(f"第 {source} 段没有内容可以搬") + + inputs = [ + SentenceInput(content=row.content, sort=row.sort, citations=list(row.citations)) + for row in rows + ] + paper_crud.replace_paragraph( + db, paper, source, ParagraphUpdate(sentences=inputs, target_sort=target) + ) + return s.dumps( + { + "paper_id": paper.id, + "from": {"position": source, "heading": _heading_at(paper, source)}, + "to": {"position": target, "heading": _heading_at(paper, target)}, + "moved_sentences": len(inputs), + } + ) + + +# --- helpers ----------------------------------------------------------------- + + +def _sentences_at(db: Session, paper: Paper, field_sort: int) -> list[PaperSentence]: + """The stored sentences of one paragraph, in render order.""" + return sorted( + (row for row in paper.sentences if row.paper_template_filed_sort == field_sort), + key=lambda row: (row.sort, row.id), + ) + + +def _heading_at(paper: Paper, field_sort: int) -> str | None: + """What the paper's template calls this position, if it names it at all.""" + for item in s.heading_index(paper): + if item["position"] == field_sort: + return str(item["heading"]) + return None + + +def _write( + db: Session, + paper: Paper, + field_sort: int, + inputs: list[SentenceInput], + mode: str, + *, + heading_label: str | None, +) -> dict: + """Apply one paragraph write and describe what happened. + + ``append`` goes through :func:`app.crud.paper.append_sentence` one sentence + at a time — the same call ``POST /papers/{id}/sentences`` makes — rather + than read-modify-write, so two sentences appended in one request cannot + overwrite each other's ``sort``. + """ + if mode == "append": + for item in inputs: + paper_crud.append_sentence( + db, + paper, + SentenceCreate( + paper_template_filed_sort=field_sort, + content=item.content, + citations=item.citations, + sort=item.sort, + ), + ) + else: + paper_crud.replace_paragraph( + db, paper, field_sort, ParagraphUpdate(sentences=inputs) + ) + + return { + "paper_id": paper.id, + "position": field_sort, + "heading": _heading_at(paper, field_sort) or heading_label, + "mode": mode, + "sentences": len(inputs), + "chars": sum(len(item.content) for item in inputs), + } diff --git a/backend/app/mcp/tools/registry.py b/backend/app/mcp/tools/registry.py new file mode 100644 index 0000000..44cf6fe --- /dev/null +++ b/backend/app/mcp/tools/registry.py @@ -0,0 +1,56 @@ +"""Tool registration helpers. + +One shape, one place +-------------------- +Every tool in this project returns a JSON string, and that is a decision +rather than a default: :func:`mcp_tool` registers with +``structured_output=False`` so a result travels as a text block and nothing +else. The alternative — letting the SDK infer a structured output schema from +the ``-> str`` annotation — publishes a ``{"result": "..."}`` envelope and +sends the same JSON twice, once as ``structuredContent`` and once as text. +Half the clients read only one of the two, and the other half reads both and +pays for the duplication twice. + +So a tool is registered by:: + + @mcp_tool(server, title="列出论文") + def paper_list(...) -> str: + \"\"\"Short description — this text is what the model reads.\"\"\" + +The docstring becomes the tool description verbatim, which makes it the most +expensive string in the project: it is sent with every request. Keep it to the +one or two lines a model needs to choose the tool, and put the reasoning in +the module docstring instead, where it costs nothing per request. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TypeVar + +from mcp.server.mcpserver import MCPServer + +F = TypeVar("F", bound=Callable[..., Any]) + + +def mcp_tool( + server: MCPServer, + *, + name: str | None = None, + title: str | None = None, +) -> Callable[[F], F]: + """Register one tool as unstructured text output.""" + + def decorator(fn: F) -> F: + server.add_tool( + fn, + name=name or fn.__name__, + title=title, + structured_output=False, + ) + return fn + + return decorator + + +__all__ = ["mcp_tool"] diff --git a/backend/app/mcp/tools/sentences.py b/backend/app/mcp/tools/sentences.py new file mode 100644 index 0000000..16ebffb --- /dev/null +++ b/backend/app/mcp/tools/sentences.py @@ -0,0 +1,227 @@ +"""Sentence tools (语句): the smallest unit the paper model stores. + +One row is one sentence, and nothing about the writing path ever re-splits it — +the writer decides where a sentence ends (see ``docs/OVERVIEW.md``). That makes +these the tools to reach for when a paragraph is *almost* right: rewriting the +whole paragraph to fix one line would relabel every sentence id in it, and an +id is what a citation points at. + +:func:`sentence_search` reads across papers instead of within one, because the +job it serves is consistency: a model that has already written "flood damage" +in one paper should not start writing "GUL" in the next. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from mcp.server.mcpserver import MCPServer +from pydantic import Field + +from app.crud import paper as paper_crud +from app.mcp import support as s +from app.mcp.specs import CitationSpec +from app.mcp.tools.registry import mcp_tool +from app.models import Paper +from app.schemas.paper import CitationInput, SentenceCreate, SentenceUpdate + + +def register(server: MCPServer) -> None: + """Register every sentence tool on ``server``.""" + + @mcp_tool(server, title="列出句子") + def sentence_list( + paper_id: Annotated[int, Field(description="论文 id")], + position: Annotated[ + int | None, Field(description="只看某一段;不传则返回全文句子") + ] = None, + heading: Annotated[ + str | None, Field(description="只看某个标题下的句子") + ] = None, + include_empty: Annotated[ + bool, Field(description="是否包含空句子(只挂引用的行)") + ] = False, + ) -> str: + """列出论文里的句子,带句子 id、所属段落和引用。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + target = ( + s.resolve_position(paper, position=position, heading=heading) + if (position is not None or heading is not None) + else None + ) + + rows = [ + row + for row in paper.sentences + if (target is None or row.paper_template_filed_sort == target) + and (include_empty or row.content) + ] + rows.sort(key=lambda row: (row.paper_template_filed_sort, row.sort, row.id)) + + return s.dumps( + { + "paper_id": paper.id, + "paper_title": paper.title, + "total": len(rows), + "items": [ + { + **s.sentence_row(row), + "heading": _heading_at(paper, row.paper_template_filed_sort), + } + for row in rows + ], + } + ) + + @mcp_tool(server, title="加一句") + def sentence_add( + paper_id: Annotated[int, Field(description="论文 id")], + content: Annotated[str, Field(description="句子正文;会折叠成一行")], + position: Annotated[ + int | None, Field(description="加到哪一段;与 heading 二选一") + ] = None, + heading: Annotated[str | None, Field(description="加到哪个标题下")] = None, + citations: Annotated[ + list[CitationSpec] | None, Field(description="这句引用了哪些文献") + ] = None, + sort: Annotated[ + int | None, Field(description="段内位置;不传则排在段尾") + ] = None, + ) -> str: + """在某一段末尾追加一句话,可带引用。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + field_sort = s.resolve_position(paper, position=position, heading=heading) + sentence = paper_crud.append_sentence( + db, + paper, + SentenceCreate( + paper_template_filed_sort=field_sort, + content=content, + citations=_citations(citations or []), + sort=sort, + ), + ) + return s.dumps( + { + "paper_id": paper.id, + "position": field_sort, + "heading": _heading_at(paper, field_sort), + "sentence": s.sentence_row(sentence), + } + ) + + @mcp_tool(server, title="改一句") + def sentence_update( + paper_id: Annotated[int, Field(description="论文 id")], + sentence_id: Annotated[int, Field(description="句子 id(见 sentence_list)")], + content: Annotated[str | None, Field(description="新正文;不传则不改")] = None, + position: Annotated[ + int | None, Field(description="把这句话移到另一段;不传则不动") + ] = None, + sort: Annotated[ + int | None, Field(description="段内排序值;不传则不动") + ] = None, + citations: Annotated[ + list[CitationSpec] | None, + Field(description="引用整组替换;传 [] 表示清空引用"), + ] = None, + ) -> str: + """修改一句话的正文、所属段落、排序或引用(引用是整体替换)。""" + if content is None and position is None and sort is None and citations is None: + s.fail("没有给出任何要修改的内容") + + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + sentence = s.sentence_or_fail(paper, sentence_id) + updated = paper_crud.update_sentence( + db, + paper, + sentence, + SentenceUpdate( + content=content, + paper_template_filed_sort=position, + sort=sort, + citations=None if citations is None else _citations(citations), + ), + ) + return s.dumps( + { + "paper_id": paper.id, + "sentence": s.sentence_row(updated), + } + ) + + @mcp_tool(server, title="删一句") + def sentence_delete( + paper_id: Annotated[int, Field(description="论文 id")], + sentence_id: Annotated[int, Field(description="句子 id")], + ) -> str: + """删除一句话,连同它挂着的引用。""" + with s.session() as db: + paper = s.paper_or_fail(db, paper_id) + sentence = s.sentence_or_fail(paper, sentence_id) + snapshot = s.sentence_row(sentence) + paper_crud.delete_sentence(db, paper, sentence) + return s.dumps( + { + "paper_id": paper.id, + "deleted": snapshot, + } + ) + + @mcp_tool(server, title="搜索句子") + def sentence_search( + keyword: Annotated[str, Field(description="要搜索的字词")], + paper_id: Annotated[ + int | None, Field(description="只在这篇论文里搜;不传则全库搜") + ] = None, + limit: Annotated[int, Field(description="最多返回多少条")] = 30, + ) -> str: + """跨论文搜索已经写过的句子,用来保持术语和说法一致。""" + if not keyword.strip(): + s.fail("keyword 不能为空") + + with s.session() as db: + rows = paper_crud.search_sentences( + db, + keyword=keyword, + paper_id=paper_id, + limit=max(1, min(int(limit), 200)), + ) + return s.dumps( + { + "keyword": keyword, + "total": len(rows), + "items": [ + { + "paper_id": sentence.paper_id, + "paper_title": title, + "sentence_id": sentence.id, + "position": sentence.paper_template_filed_sort, + "content": sentence.content, + } + for sentence, title in rows + ], + } + ) + + +# --- helpers ----------------------------------------------------------------- + + +def _citations(items: list[Any]) -> list[CitationInput]: + """Convert the tool-level citation spec into the project's own payload.""" + return [ + CitationInput(quote=item.quote, reference_id=item.reference_id) + for item in items + ] + + +def _heading_at(paper: Paper, field_sort: int) -> str | None: + """What the paper's template calls this position, if it names it at all.""" + for item in s.heading_index(paper): + if item["position"] == field_sort: + return str(item["heading"]) + return None diff --git a/backend/app/mcp/tools/templates.py b/backend/app/mcp/tools/templates.py new file mode 100644 index 0000000..6ab265d --- /dev/null +++ b/backend/app/mcp/tools/templates.py @@ -0,0 +1,253 @@ +"""Template tools (模板): the outline a paper is written against. + +A template is not configuration, it is *structure*: a paper renders its +headings by reading its template live, so editing a template reshapes every +paper written against it and deleting one would empty them. That is why +:func:`template_delete` refuses while any paper points at the template, with the +paper count in the message — the same refusal, and the same words, the REST API +gives. + +The two ways to send a selection exist because two callers exist. A model that +knows the field ids and the order it wants them in sends ``field_ids`` and lets +the server number them 10, 20, 30 — spaced so a field can later be slipped +between two others without renumbering the rest. A caller reproducing an exact +outline sends ``fields`` with the sorts it means. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from mcp.server.mcpserver import MCPServer +from pydantic import Field +from sqlalchemy.orm import Session + +from app.crud import paper as paper_crud +from app.crud import template as template_crud +from app.mcp import support as s +from app.mcp.specs import FieldSpec +from app.mcp.tools.registry import mcp_tool +from app.models import Template +from app.schemas.template import TemplateCreate, TemplateFieldInput + +#: Spacing between generated sorts. Ties are legal in a template and gaps are +#: expected — the editor numbers a fresh selection 1..N only on request — so +#: numbering 10, 20, 30 leaves room to insert without touching the rest. +SORT_STEP = 10 + + +def register(server: MCPServer) -> None: + """Register every template tool on ``server``.""" + + @mcp_tool(server, title="列出模板") + def template_list( + keyword: Annotated[ + str | None, Field(description="按模板名称或摘要模糊搜索") + ] = None, + page: Annotated[int, Field(description="页码,从 1 开始")] = 1, + page_size: Annotated[int, Field(description="每页条数,最大 200")] = 20, + ) -> str: + """列出论文模板(含字段数量,不含大纲)。""" + page, page_size = s.page_args(page, page_size) + with s.session() as db: + items, total = template_crud.list_templates( + db, keyword=keyword, page=page, page_size=page_size + ) + return s.dumps( + s.page_payload( + items=[ + { + "id": item.id, + "name": item.name, + "abstract": item.abstract, + "fields": item.field_count, + "updated_at": item.updated_at, + } + for item in items + ], + total=total, + page=page, + page_size=page_size, + ) + ) + + @mcp_tool(server, title="取模板大纲") + def template_get( + template_id: Annotated[int, Field(description="模板 id")], + ) -> str: + """取一个模板的完整大纲:每个字段的位置、标题、层级与字号。""" + with s.session() as db: + template = s.template_or_fail(db, template_id) + payload = _template_row(template) + payload["used_by_papers"] = paper_crud.template_paper_counts( + db, [template.id] + ).get(template.id, 0) + return s.dumps(payload) + + @mcp_tool(server, title="新建模板") + def template_create( + name: Annotated[str, Field(description="模板名称,必须唯一")], + abstract: Annotated[str | None, Field(description="模板说明")] = None, + field_ids: Annotated[ + list[int] | None, + Field(description="字段库 id 数组,按顺序排成大纲;sort 会自动编号 10/20/30…"), + ] = None, + fields: Annotated[ + list[FieldSpec] | None, + Field(description="要精确指定 sort 时用这个,格式 [{field_id, sort}];给了它就忽略 field_ids"), + ] = None, + ) -> str: + """新建模板:名称 + 说明 + 有序字段选择。用 field_list 先查字段 id。""" + selection = _selection(field_ids, fields) + payload = TemplateCreate(name=name, abstract=abstract, fields=selection) + + with s.session() as db: + _assert_name_free(db, payload.name) + _assert_fields_exist(db, payload.fields) + template = template_crud.create( + db, + name=payload.name, + abstract=payload.abstract, + fields=payload.fields, + ) + return s.dumps(_template_row(template)) + + @mcp_tool(server, title="修改模板") + def template_update( + template_id: Annotated[int, Field(description="模板 id")], + name: Annotated[str | None, Field(description="新名称;不传则不改")] = None, + abstract: Annotated[str | None, Field(description="新说明")] = None, + clear_abstract: Annotated[ + bool, Field(description="true 表示把说明清空") + ] = False, + field_ids: Annotated[ + list[int] | None, + Field(description="整组替换字段选择,按顺序编号;不传则不动大纲"), + ] = None, + fields: Annotated[ + list[FieldSpec] | None, + Field(description="整组替换并精确指定 sort,格式 [{field_id, sort}]"), + ] = None, + ) -> str: + """修改模板名称、说明或整组字段。改字段会立刻影响所有用它的论文。""" + if name is None and abstract is None and not clear_abstract and field_ids is None and fields is None: + s.fail("没有给出任何要修改的内容") + + selection = ( + None if (field_ids is None and fields is None) else _selection(field_ids, fields) + ) + + with s.session() as db: + template = s.template_or_fail(db, template_id) + if name is not None: + _assert_name_free(db, name, exclude_id=template_id) + if selection is not None: + _assert_fields_exist(db, selection) + + updated = template_crud.update( + db, + template, + name=name, + abstract=abstract, + abstract_provided=clear_abstract or abstract is not None, + fields=selection, + ) + return s.dumps(_template_row(updated)) + + @mcp_tool(server, title="删除模板") + def template_delete( + template_ids: Annotated[int | list[int], Field(description="模板 id,或 id 数组")], + ) -> str: + """删除模板(正被论文使用的模板会被拒绝,需先给论文换模板)。""" + ids = s.id_list(template_ids) + if not ids: + s.fail("必须给出至少一个模板 id") + + with s.session() as db: + _assert_not_used_by_papers(db, ids) + return s.dumps({"deleted": template_crud.delete_many(db, ids), "ids": ids}) + + +# --- helpers ----------------------------------------------------------------- + + +def _template_row(template: Template) -> dict: + """One template flattened for a model: the outline is the point.""" + return { + "id": template.id, + "name": template.name, + "abstract": template.abstract, + "fields": [ + { + "position": item.sort, + "field_id": item.field_id, + "name": item.field.name, + "level": item.field.level, + "font_size": float(item.field.font_size), + "font_color": item.field.font_color, + } + for item in template.items + ], + } + + +def _selection( + field_ids: list[int] | None, + fields: list[FieldSpec] | None, +) -> list[TemplateFieldInput]: + """Turn either way of describing a selection into placement rows. + + The order of ``field_ids`` is the outline order; the numbering is generated + rather than asked for, because a model that has just looked at the field + library knows what it wants in what order and not which integers to store. + """ + if fields: + return [ + TemplateFieldInput(field_id=item.field_id, sort=item.sort) for item in fields + ] + if field_ids: + return [ + TemplateFieldInput(field_id=field_id, sort=(index + 1) * SORT_STEP) + for index, field_id in enumerate(field_ids) + ] + return [] + + +def _assert_name_free(db: Session, name: str, *, exclude_id: int | None = None) -> None: + if template_crud.name_taken(db, name, exclude_id=exclude_id): + s.fail(f"模板名称“{name}”已存在") + + +def _assert_fields_exist(db: Session, fields: list[Any]) -> None: + """Reject a selection that points at fields the library does not have. + + Checked in Python rather than by a foreign key, exactly as the REST route + does: TiDB parses but does not enforce ``FOREIGN KEY``. + """ + missing = template_crud.missing_field_ids(db, [item.field_id for item in fields]) + if missing: + joined = "、".join(str(field_id) for field_id in missing) + s.fail(f"字段不存在:{joined}(先用 field_list 查可用的字段 id)") + + +def _assert_not_used_by_papers(db: Session, template_ids: list[int]) -> None: + """Refuse to delete a template a paper is written against. + + Reported for every offending template at once, so a batch delete does not + become trial and error. The fix is a template switch on the paper, which + :func:`paper_update` does in one call. + """ + counts = paper_crud.template_paper_counts(db, template_ids) + if not counts: + return + + blockers = [] + for template_id, count in sorted(counts.items()): + template = db.get(Template, template_id) + name = template.name if template is not None else template_id + blockers.append(f"“{name}”({count} 篇论文)") + + s.fail( + "以下模板正被论文使用,请先用 paper_update 给论文切换模板:" + + "、".join(blockers) + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 24ce6fc..176c4e7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,7 @@ alembic>=1.14 pymysql>=1.1 cryptography>=43.0 pydantic-settings>=2.6 +# MCP server (app/mcp/) — the same domain layer, exposed as MCP tools for +# Claude Code, Codex and the DeepSeek Harness. Only the MCP entry points need +# it; the REST API does not import it. +mcp>=2.2,<3 diff --git a/backend/scripts/mcp_server.py b/backend/scripts/mcp_server.py new file mode 100644 index 0000000..5344e2e --- /dev/null +++ b/backend/scripts/mcp_server.py @@ -0,0 +1,40 @@ +"""MCP server entry point for paper-doc. + +Usage (from anywhere — the script anchors ``sys.path`` itself):: + + backend/.venv/bin/python backend/scripts/mcp_server.py # stdio + backend/.venv/bin/python backend/scripts/mcp_server.py --check # 自检 + backend/.venv/bin/python backend/scripts/mcp_server.py \\ + --transport http --host 0.0.0.0 --port 8931 --token secret + +``stdio`` is what an MCP client spawns: Claude Code, Codex and the DeepSeek +Harness all run a command and speak JSON-RPC over its stdin/stdout. Two rules +follow from that, and both are easy to break by accident: + +* **Nothing may print to stdout** except the protocol. Diagnostics go to + stderr; a ``print`` added here for debugging would corrupt the stream and + present as "the server disconnected". +* **The command must work from any working directory**, because a client's + ``cwd`` is its own, not this project's. That is why the path anchor below + exists rather than a relative ``import app``. + +The CLI itself lives in :func:`app.mcp.server.main` so this file stays a +launcher: the same arguments are reachable as ``python -m app.mcp`` from +``backend/``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Running this as a plain script puts scripts/ on sys.path, not backend/, so +# `import app` would fail. Anchor to the backend directory instead. +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from app.mcp.server import main # noqa: E402 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/scripts/smoke_mcp.py b/backend/scripts/smoke_mcp.py new file mode 100644 index 0000000..a8f2d9a --- /dev/null +++ b/backend/scripts/smoke_mcp.py @@ -0,0 +1,514 @@ +"""End-to-end smoke test for the MCP server, spoken as a real MCP client. + +Run it from ``backend/``:: + + .venv/bin/python scripts/smoke_mcp.py + +It spawns ``scripts/mcp_server.py`` exactly the way Claude Code, Codex and the +Harness do — a child process, JSON-RPC over its stdin/stdout — and then walks +the whole writing loop through tool calls: create a paper on a template, read +the outline, write paragraphs by *heading*, write a whole generated document in +one call, edit one sentence without rewriting its paragraph, search across +papers, switch the template, and delete everything it created. + +Speaking the real protocol is the point. Calling the tool functions directly +would test the domain layer, which ``scripts/smoke_papers.py`` already covers; +what is untested until a client connects is the part that only exists on the +wire — argument schemas, tool discovery, error results, and the stdio stream +staying clean enough to carry the protocol. + +Exits non-zero on the first failed expectation and cleans up after itself. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import anyio + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from mcp import types # noqa: E402 +from mcp.client.session import ClientSession # noqa: E402 +from mcp.client.stdio import StdioServerParameters, stdio_client # noqa: E402 +from sqlalchemy import or_, select # noqa: E402 + +from app.db.session import SessionLocal # noqa: E402 +from app.models import Paper, Template # noqa: E402 + +SERVER_SCRIPT = BACKEND_DIR / "scripts" / "mcp_server.py" + +_checks = 0 +_created_papers: list[int] = [] +_created_templates: list[int] = [] + + +def check(label: str, condition: bool, detail: str = "") -> None: + """Assert one expectation and report it.""" + global _checks + _checks += 1 + if not condition: + raise AssertionError(f"{label}: {detail}") + print(f" ok {label}") + + +def payload(result: types.CallToolResult) -> Any: + """The JSON a tool returned, refusing an error result. + + A tool that failed returns ``isError`` with the message as its content, not + an exception — so a smoke test that only looked at the content would read a + refusal as data. That is the failure mode this helper exists to prevent. + """ + text = "".join( + block.text for block in result.content if isinstance(block, types.TextContent) + ) + if result.is_error: + raise AssertionError(f"tool returned an error: {text}") + return json.loads(text) if text else None + + +async def call(session: ClientSession, tool: str, **arguments: Any) -> Any: + """Call one tool and return its JSON payload. + + The parameter is ``tool``, not ``name``: several tools here take an + argument called ``name`` (``template_create``, ``field_create``) and a + helper whose own parameter shadowed it would fail with a TypeError that + looks nothing like the tool call it came from. + """ + return payload(await session.call_tool(tool, arguments)) + + +async def expect_error(session: ClientSession, tool: str, **arguments: Any) -> str: + """Call one tool expecting a refusal, and return the message.""" + result = await session.call_tool(tool, arguments) + if not result.is_error: + raise AssertionError(f"{tool} should have refused, but returned {result.content}") + return "".join( + block.text for block in result.content if isinstance(block, types.TextContent) + ) + + +def pick_template() -> tuple[int, list[dict[str, Any]]]: + """The template with the most fields, and its outline as the tools see it.""" + with SessionLocal() as db: + template = db.scalars( + select(Template).order_by(Template.id.asc()) + ).all() + best = max(template, key=lambda item: len(item.items)) + return best.id, [ + {"position": item.sort, "name": item.field.name} for item in best.items + ] + + +@asynccontextmanager +async def connect(url: str | None, token: str | None) -> AsyncIterator[ClientSession]: + """Open a session over stdio (default) or against a running HTTP server. + + Both paths yield the same :class:`ClientSession`, so every check below runs + unchanged on either transport — which is the point of testing the protocol + rather than the functions: the tools must behave identically whether a + client spawned them or connected to them. + """ + if url: + # headers live on the HTTP client, not on the transport call. + import httpx2 + from mcp.client.streamable_http import streamable_http_client + + headers = {"Authorization": f"Bearer {token}"} if token else {} + async with httpx2.AsyncClient(headers=headers) as http_client: + async with streamable_http_client(url, http_client=http_client) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + yield session + return + + parameters = StdioServerParameters( + command=sys.executable, + args=[str(SERVER_SCRIPT)], + cwd=str(BACKEND_DIR), + ) + async with stdio_client(parameters) as (read, write): + async with ClientSession(read, write) as session: + yield session + + +async def run(url: str | None = None, token: str | None = None) -> int: + template_id, outline = pick_template() + check("a template with fields exists", len(outline) >= 3, f"got {len(outline)}") + first = outline[0]["name"] + second = outline[1]["name"] + + async with connect(url, token) as session: + handshake = await session.initialize() + check( + "server announces itself", + handshake.server_info.name == "paper-doc", + str(handshake.server_info), + ) + + print("\n1. tool discovery") + listed = await session.list_tools() + names = {tool.name for tool in listed.tools} + check("29 tools are published", len(names) == 29, f"got {len(names)}") + check( + "every group is reachable", + {"paper_write", "paragraph_write", "sentence_add", "template_get", "field_list"} + <= names, + ) + check( + "no tool returns a structured envelope", + all(tool.output_schema is None for tool in listed.tools), + ) + write_tool = next(tool for tool in listed.tools if tool.name == "paper_write") + check( + "paper_write describes its paragraph array", + "paragraphs" in write_tool.input_schema["properties"], + ) + + print("\n2. create a paper on that template") + paper = await call( + session, + "paper_create", + title="SMOKE MCP 论文", + template_id=template_id, + author="smoke", + keywords="测试, smoke, 测试", + ) + _created_papers.append(paper["id"]) + check("created", paper["title"] == "SMOKE MCP 论文") + check("keywords are deduplicated", paper["keywords"] == "测试, smoke", paper["keywords"]) + check( + "the whole structure exists with no content", + paper["paragraphs"] == 0 and paper["template_paragraphs"] == len(outline), + json.dumps(paper, ensure_ascii=False), + ) + + print("\n3. read the outline before writing") + skeleton = await call(session, "paper_outline", paper_id=paper["id"]) + check( + "the outline carries every position", + len(skeleton["paragraphs"]) == len(outline), + json.dumps(skeleton, ensure_ascii=False), + ) + check("nothing has been written", all(item["sentences"] == 0 for item in skeleton["paragraphs"])) + + print("\n4. write paragraphs by heading") + written = await call( + session, + "paper_write", + paper_id=paper["id"], + paragraphs=[ + { + "heading": first, + "sentences": ["第一句。", "第二句。"], + }, + { + "heading": second.lstrip("0123456789. "), + "text": "这一整段只有一行,按句号切成两句。第二句在这里。", + "split": "sentence", + }, + ], + ) + check("two paragraphs written", len(written["written"]) == 2, json.dumps(written, ensure_ascii=False)) + check("four sentences total", written["total_sentences"] == 4, str(written["total_sentences"])) + check( + "both headings resolved to template positions", + all(item["heading"] for item in written["written"]), + json.dumps(written, ensure_ascii=False), + ) + + print("\n5. a heading the template does not have is refused, with candidates") + message = await expect_error( + session, + "paragraph_write", + paper_id=paper["id"], + heading="不存在的标题", + sentences=["x"], + ) + check("the refusal lists the real headings", first in message, message) + + print("\n6. read one paragraph back") + paragraph = await call( + session, "paragraph_get", paper_id=paper["id"], heading=first + ) + check("two sentences stored", len(paragraph["sentences"]) == 2, json.dumps(paragraph, ensure_ascii=False)) + check( + "CJK seams take no separator", + all(row["separator_before"] == "" for row in paragraph["sentences"]), + json.dumps(paragraph["sentences"], ensure_ascii=False), + ) + + print("\n7. append a sentence with a citation") + added = await call( + session, + "sentence_add", + paper_id=paper["id"], + heading=first, + content="A cited sentence.", + citations=[{"quote": "引用的原话", "reference_id": 7}], + ) + check("appended", added["sentence"]["content"] == "A cited sentence.") + check("the citation is stored", added["sentence"]["citations"][0]["quote"] == "引用的原话") + + after = await call(session, "paragraph_get", paper_id=paper["id"], heading=first) + check( + "the seam before it takes a space", + after["sentences"][-1]["separator_before"] == " ", + json.dumps(after["sentences"][-1], ensure_ascii=False), + ) + + print("\n8. edit one sentence without touching its paragraph") + edited = await call( + session, + "sentence_update", + paper_id=paper["id"], + sentence_id=after["sentences"][0]["id"], + content="改过的第一句。", + ) + check("edited", edited["sentence"]["content"] == "改过的第一句。") + still = await call(session, "paragraph_get", paper_id=paper["id"], heading=first) + check("the other sentences kept their ids", still["sentences"][1]["id"] == after["sentences"][1]["id"]) + + print("\n9. delete one sentence") + before = await call(session, "sentence_list", paper_id=paper["id"]) + await call( + session, + "sentence_delete", + paper_id=paper["id"], + sentence_id=still["sentences"][-1]["id"], + ) + remaining = await call(session, "sentence_list", paper_id=paper["id"]) + check( + "exactly one sentence fewer", + remaining["total"] == before["total"] - 1, + f"{before['total']} -> {remaining['total']}", + ) + + print("\n10. write a whole generated document in one call") + generated = "\n".join( + [f"# {item['name']}" + "\n" + f"{item['name']}的正文写在标题下面。这句话是第二句。" + for item in outline] + ) + whole = await call( + session, "paper_write_text", paper_id=paper["id"], text=generated, split="sentence" + ) + check( + "every heading in the document was written", + len(whole["written"]) == len(outline), + json.dumps(whole, ensure_ascii=False), + ) + check("nothing was left unmatched", "unmatched" not in whole, json.dumps(whole, ensure_ascii=False)) + + print("\n11. read the paper as a document") + document = await call(session, "paper_document", paper_id=paper["id"]) + check("the text carries every heading", all(item["name"] in document["text"] for item in outline)) + check("and every paragraph", document["text"].count("正文写在标题下面") == len(outline)) + structured = await call( + session, "paper_document", paper_id=paper["id"], format="json" + ) + check( + "the json form carries sentence ids", + all( + row.get("id") is not None + for paragraph in structured["paragraphs"] + for row in paragraph["sentences"] + ), + ) + + print("\n12. search across papers") + found = await call(session, "sentence_search", keyword="正文写在标题下面", paper_id=paper["id"]) + check("the search finds what was written", found["total"] >= 1, str(found["total"])) + + print("\n13. move a paragraph, then clear the one it moved onto") + home = outline[0] + target = outline[1] + relocation = await call( + session, + "paragraph_move", + paper_id=paper["id"], + heading=home["name"], + target_heading=target["name"], + ) + check( + "the move reports both ends", + relocation["from"]["position"] == home["position"] + and relocation["to"]["position"] == target["position"], + json.dumps(relocation, ensure_ascii=False), + ) + emptied = await call(session, "paragraph_get", paper_id=paper["id"], position=home["position"]) + check("the source paragraph is empty now", emptied["sentences"] == [], json.dumps(emptied, ensure_ascii=False)) + merged = await call(session, "paragraph_get", paper_id=paper["id"], position=target["position"]) + check( + "the target holds both paragraphs' sentences", + len(merged["sentences"]) == 4, + json.dumps(merged["sentences"], ensure_ascii=False), + ) + + cleared = await call( + session, "paragraph_delete", paper_id=paper["id"], position=target["position"] + ) + check("clearing reports how much it removed", cleared["deleted_sentences"] == 4, json.dumps(cleared, ensure_ascii=False)) + after_clear = await call(session, "paragraph_get", paper_id=paper["id"], position=target["position"]) + check("and the paragraph is empty", after_clear["sentences"] == [], json.dumps(after_clear, ensure_ascii=False)) + + print("\n14. the field library round-trips") + field = await call(session, "field_create", name="SMOKE MCP 字段", level=2, font_size=9.5) + check("created with its typography", field["level"] == 2 and field["font_size"] == 9.5, json.dumps(field, ensure_ascii=False)) + renamed = await call(session, "field_update", field_id=field["id"], name="SMOKE MCP 字段(改)") + check("renamed", renamed["name"] == "SMOKE MCP 字段(改)", json.dumps(renamed, ensure_ascii=False)) + dropped = await call(session, "field_delete", field_ids=field["id"]) + check("deleted while no template places it", dropped["deleted"] == 1, json.dumps(dropped, ensure_ascii=False)) + check("and it is reported as found, not missing", dropped["missing"] == [], json.dumps(dropped, ensure_ascii=False)) + + print("\n15. switch the template and keep the content") + spare = await call( + session, + "template_create", + name="SMOKE MCP 模板", + abstract="临时模板", + field_ids=[_field_id(outline[0]["name"])], + ) + _created_templates.append(spare["id"]) + check("a template can be built from field ids", spare["fields"][0]["position"] == 10, json.dumps(spare, ensure_ascii=False)) + + rebuilt = await call( + session, + "template_update", + template_id=spare["id"], + name="SMOKE MCP 模板", + field_ids=[_field_id(outline[0]["name"]), _field_id(outline[1]["name"])], + ) + check( + "the outline is replaced wholesale and renumbered", + [item["position"] for item in rebuilt["fields"]] == [10, 20], + json.dumps(rebuilt, ensure_ascii=False), + ) + + sentences_before = (await call(session, "sentence_list", paper_id=paper["id"]))["total"] + switched = await call( + session, "paper_update", paper_id=paper["id"], template_id=spare["id"] + ) + check("the switch is reported", switched["template_changed"] is True) + switched_document = await call( + session, "paper_document", paper_id=paper["id"], format="json" + ) + check( + "content survives the switch, under 未设定", + sum(len(row["sentences"]) for row in switched_document["paragraphs"]) + == sentences_before, + json.dumps(switched_document, ensure_ascii=False)[:400], + ) + check( + "positions the new template does not define are marked unmatched", + any(row["matched"] is False for row in switched_document["paragraphs"]), + json.dumps(switched_document, ensure_ascii=False)[:400], + ) + + print("\n16. a template in use cannot be deleted") + message = await expect_error(session, "template_delete", template_ids=spare["id"]) + check("the refusal names the blocker", "SMOKE MCP 模板" in message, message) + + print("\n17. deleting a paper needs an explicit confirmation") + message = await expect_error(session, "paper_delete", paper_ids=paper["id"]) + check("the refusal explains itself", "confirm=true" in message, message) + + print("\n18. delete the paper and the template") + deleted = await call(session, "paper_delete", paper_ids=paper["id"], confirm=True) + check("deleted", deleted["deleted"] == 1, json.dumps(deleted, ensure_ascii=False)) + _created_papers.remove(paper["id"]) + gone = await call(session, "template_delete", template_ids=spare["id"]) + check("the template is free to go now", gone["deleted"] == 1, json.dumps(gone, ensure_ascii=False)) + _created_templates.remove(spare["id"]) + + print(f"\nall {_checks} checks passed") + return 0 + + +def _field_id(name: str) -> int: + """The library field behind a heading name, for the template-create step.""" + from app.models import TemplateFieldLibrary + + with SessionLocal() as db: + row = db.scalars( + select(TemplateFieldLibrary).where(TemplateFieldLibrary.name == name) + ).first() + if row is None: + raise AssertionError(f"field {name!r} not in the library") + return row.id + + +def cleanup() -> None: + """Remove anything this run created, even after a failed expectation.""" + if _created_papers: + with SessionLocal() as db: + for paper in db.scalars(select(Paper).where(Paper.id.in_(_created_papers))).all(): + db.delete(paper) + db.commit() + print(f"cleaned up papers {_created_papers}") + if _created_templates: + with SessionLocal() as db: + for template in db.scalars( + select(Template).where(Template.id.in_(_created_templates)) + ).all(): + db.delete(template) + db.commit() + print(f"cleaned up templates {_created_templates}") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """``--url`` switches the suite from spawning a server to dialling one.""" + parser = argparse.ArgumentParser(description="paper-doc MCP 冒烟测试") + parser.add_argument( + "--url", + default=os.getenv("MCP_SMOKE_URL"), + help="连到已在运行的 streamable-http 服务,例如 http://127.0.0.1:8931/mcp;" + "不传则自己拉起 stdio 子进程", + ) + parser.add_argument( + "--token", + default=os.getenv("MCP_HTTP_TOKEN"), + help="HTTP 传输的 Bearer 口令", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + # The smoke test writes real rows, so it removes its own first: a previous + # run that died before cleanup would otherwise leave a duplicate title. + with SessionLocal() as db: + stale = db.scalars( + select(Paper).where(or_(Paper.title == "SMOKE MCP 论文")) + ).all() + for paper in stale: + db.delete(paper) + stale_templates = db.scalars( + select(Template).where(Template.name == "SMOKE MCP 模板") + ).all() + for template in stale_templates: + db.delete(template) + db.commit() + + try: + return anyio.run(run, args.url, args.token) + except AssertionError as error: + print(f"\nFAILED: {error}", file=sys.stderr) + return 1 + finally: + cleanup() + + +if __name__ == "__main__": + sys.exit(main())