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