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