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