backend: serve the writing domain to agents as an MCP server
The paper is written by a model now. A browser is the wrong client for that:
the work is "generate the paper, then put it in", and doing it through a form
means a person retyping what a model already produced. So the same domain is
served over the Model Context Protocol, which Claude Code, Codex and the
DeepSeek Harness all speak.
It is a third front door, not a second implementation. Every tool is three
lines around an `app.crud` call and validates through `app.schemas`, exactly
as the REST routes do, so a rule fixed in the CRUD layer is fixed on both
surfaces and a paper written by an agent is indistinguishable from one written
by hand. What `app/mcp/` adds is only what a model needs and a browser does
not:
- 29 tools, prefixed `paper_` `paragraph_` `sentence_` `template_` `field_`,
because a model picks a tool out of a list by name rather than by reading 29
descriptions;
- results as compact `None`-free JSON, since a tool result is paid for in
context tokens and `PaperRead.model_dump()` carries four counts and two
timestamps into every list row;
- paragraphs addressed by **heading** as well as by position. Storage is
correct as it stands — a sentence remembers the position it sits at, which is
what makes a template switch non-destructive — but nobody writing
"1. Introduction" knows the template places it at `sort = 20`. The server
translates, and refuses with the real heading list when it cannot, so a model
that guessed wrong corrects itself in one retry;
- `paper_write` and `paper_write_text`: one intention, one call. The latter
finds its own sections from Markdown headings or from lines that name a
template heading, and reports every heading it could not place instead of
writing half a paper;
- `sentence_search` across papers, for consistency rather than retrieval — a
paper that says 洪水损失 should not be joined by one that says GUL;
- `paper_delete` refuses once, naming what would go with it. A cascading delete
has no undo in a tool call.
Two transports, one build. `stdio` is what a client spawns — so nothing in the
process may print to stdout, and diagnostics go to stderr. `streamable-http` is
what a client on another machine connects to, optionally behind a bearer token;
binding a non-loopback address disables the SDK's DNS-rebinding allow-list,
because a LAN client sends whatever Host it knows the server by.
Tools register with `structured_output=False` on purpose: inferred from a
`-> str` annotation the SDK publishes a `{"result": ...}` envelope and sends
the JSON twice, once as `structuredContent` and once as text, and clients that
read only one of the two then disagree about what came back.
`scripts/smoke_mcp.py` drives the whole loop through a real MCP client — the
child process and JSON-RPC over stdin/stdout a client actually uses — and runs
unchanged against a running HTTP server via `--url`. 47 checks pass on both
transports; the REST suite still passes its 45.
This commit is contained in:
@@ -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()]
|
||||
|
||||
@@ -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``.
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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 <token>`` 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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user