1341 lines
67 KiB
Python
1341 lines
67 KiB
Python
"""Owner-scoped public-document library and offline review API.
|
||
|
||
This router is intentionally separate from the deep-research report router.
|
||
Research sessions own transient agent work; public documents need immutable
|
||
originals, repair versions and review snapshots that remain available after a
|
||
session or sandbox has gone away.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import uuid
|
||
import zipfile
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path, PurePath
|
||
from typing import Any, Literal
|
||
from xml.etree import ElementTree
|
||
|
||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||
from fastapi.responses import FileResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.authz import require_permission
|
||
from app.gateway.deps import get_current_user
|
||
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||
from deerflow.config.runtime_paths import runtime_home
|
||
from deerflow.persistence.gongwen_documents import GongwenDocumentStore
|
||
|
||
# This import must remain at router-module load time. The Gateway imports
|
||
# routers before its lifespan calls Base.metadata.create_all(), so it ensures
|
||
# newly deployed databases create these two tables on first boot.
|
||
from deerflow.persistence.gongwen_documents.model import ( # noqa: F401
|
||
GongwenDocumentRow,
|
||
GongwenDocumentVersionRow,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/gongwen-documents", tags=["gongwen-documents"])
|
||
|
||
_DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||
_MARKDOWN_MIME = "text/markdown; charset=utf-8"
|
||
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
||
_ENGINE_TIMEOUT_SECONDS = 120
|
||
_W_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
||
_SAFE_FILE_NAME_RE = re.compile(r"[^A-Za-z0-9._()\-\u4e00-\u9fff]+")
|
||
_CUSTOM_TEMPLATE_KEY_RE = re.compile(r"custom_[A-Za-z0-9]{8,64}")
|
||
_THREAD_ARTIFACT_PATH_RE = re.compile(r"^mnt/user-data/(?:uploads|outputs)/[^/]+(?:/[^/]+)*$")
|
||
|
||
|
||
# The engine remains the source of truth for rules. These records are only
|
||
# presentation metadata: labels, grouping and a stable template key selected
|
||
# by the UI. The complete supported type list is validated against the
|
||
# restricted engine entrypoint rather than accepting arbitrary CLI values.
|
||
BUILTIN_TEMPLATES: tuple[dict[str, str], ...] = (
|
||
{"key": "notice", "name": "通知", "category": "机关常用", "description": "部署工作、传达事项和发布安排"},
|
||
{"key": "request", "name": "请示", "category": "机关常用", "description": "向上级机关请求指示、批准事项"},
|
||
{"key": "report", "name": "报告", "category": "机关常用", "description": "汇报工作、反映情况和提出建议"},
|
||
{"key": "letter", "name": "函", "category": "机关常用", "description": "机关之间商洽工作、询问和答复问题"},
|
||
{"key": "minutes", "name": "会议纪要", "category": "机关常用", "description": "记载会议主要情况和议定事项"},
|
||
{"key": "decision", "name": "决定", "category": "机关常用", "description": "对重要事项作出决策和部署"},
|
||
{"key": "resolution", "name": "决议", "category": "机关常用", "description": "经会议讨论通过的重要事项"},
|
||
{"key": "reply", "name": "批复", "category": "机关常用", "description": "答复下级机关请示事项"},
|
||
{"key": "opinion", "name": "意见", "category": "机关常用", "description": "提出见解和处理办法"},
|
||
{"key": "communique", "name": "公报", "category": "机关常用", "description": "公开发布重要决定或统计信息"},
|
||
{"key": "announcement", "name": "公告", "category": "机关常用", "description": "向国内外宣布重要事项"},
|
||
{"key": "bulletin", "name": "通报", "category": "机关常用", "description": "表彰、批评或传达重要精神"},
|
||
{"key": "instruction", "name": "指示", "category": "机关常用", "description": "对重要工作提出原则和要求"},
|
||
{"key": "command", "name": "命令(令)", "category": "机关常用", "description": "公布行政法规、任免和嘉奖事项"},
|
||
{"key": "bill", "name": "议案", "category": "机关常用", "description": "向法定会议提请审议事项和方案"},
|
||
{"key": "regulation", "name": "规定", "category": "机关常用", "description": "对特定范围内工作作出规范和约束"},
|
||
{"key": "notice_public", "name": "公示", "category": "机关常用", "description": "在一定范围内公开应当知悉的事项"},
|
||
{"key": "meeting", "name": "会议通知", "category": "事务文书", "description": "会议时间、地点、议程和参会要求"},
|
||
{"key": "work_plan", "name": "工作计划", "category": "事务文书", "description": "目标、任务、措施和进度安排"},
|
||
{"key": "summary", "name": "工作总结", "category": "事务文书", "description": "工作回顾、成效、问题和下一步计划"},
|
||
{"key": "speech", "name": "讲话稿", "category": "事务文书", "description": "会议讲话、动员部署和总结发言"},
|
||
{"key": "host_speech", "name": "主持词", "category": "事务文书", "description": "会议主持流程、串联提示和现场引导"},
|
||
{"key": "news", "name": "新闻稿", "category": "事务文书", "description": "发布活动、会议和工作动态信息"},
|
||
{"key": "technical_proposal", "name": "方案", "category": "事务文书", "description": "项目背景、目标、路径和保障措施"},
|
||
{"key": "table_sign", "name": "签报", "category": "事务文书", "description": "事项请示、审批和流转办理"},
|
||
)
|
||
_TEMPLATE_BY_KEY = {item["key"]: item for item in BUILTIN_TEMPLATES}
|
||
|
||
# The upstream project uses one GB/T 9704 typesetting baseline, but its sample
|
||
# documents are deliberately *not* one shared “工作目标 / 工作要求” shell. These
|
||
# profiles mirror that distinction for conversational planning: each document
|
||
# type has its own writing skeleton and the facts that must be collected before
|
||
# asking a model to draft. The DOCX formatter remains the installed skill.
|
||
_TEMPLATE_PROFILES: dict[str, dict[str, list[str]]] = {
|
||
"notice": {"outline": ["工作目标", "工作要求", "其他事项"], "questions": ["通知面向哪些单位或人员?", "需要完成什么事项、在什么时间前完成?", "有哪些具体要求、材料或联络方式?"]},
|
||
"request": {"outline": ["基本情况", "请示事项", "建议方案"], "questions": ["请向哪个上级机关请示?", "请示的背景和现状是什么?", "希望上级明确或批准什么,以及建议方案是什么?"]},
|
||
"report": {"outline": ["工作开展情况", "主要成效", "存在问题", "下一步工作计划"], "questions": ["向谁报告、报告的时间范围是什么?", "已经开展了哪些工作和取得哪些可核实成效?", "目前有什么问题,下一步准备怎么做?"]},
|
||
"letter": {"outline": ["来函事项", "函复意见", "办理说明"], "questions": ["受文单位是谁?", "需要商洽、询问或答复的具体事项是什么?", "本单位的明确意见、协作方式或时限是什么?"]},
|
||
"minutes": {"outline": ["会议基本信息", "议定事项", "责任与时限"], "questions": ["会议时间、地点、主持人、参会人员和记录人分别是谁?", "会议围绕哪些议题形成了什么意见?", "每项决定由谁负责、何时完成?"]},
|
||
"decision": {"outline": ["决定依据", "决定事项", "执行与施行"], "questions": ["作出决定的依据和目的是什么?", "需要明确哪些决定事项?", "由谁执行、何时生效、是否有配套要求?"]},
|
||
"resolution": {"outline": ["会议通过情况", "决议事项", "贯彻要求"], "questions": ["由什么会议在何时通过?", "会议形成了哪些决议?", "需要向哪些对象提出怎样的贯彻要求?"]},
|
||
"reply": {"outline": ["来文情况", "批复意见", "执行要求"], "questions": ["下级单位的来文名称、文号和请示事项是什么?", "需要作出哪些明确批复意见?", "是否有执行范围、时限或后续报告要求?"]},
|
||
"opinion": {"outline": ["重要意义", "总体要求", "主要措施", "组织保障"], "questions": ["意见拟解决什么问题、面向哪些单位?", "总体目标和基本原则是什么?", "有哪些可执行措施和组织保障?"]},
|
||
"communique": {"outline": ["会议概况", "会议指出", "会议强调", "会议要求"], "questions": ["会议名称、时间、地点和主要议题是什么?", "会议有哪些重要判断或结论?", "会议强调和要求落实的事项是什么?"]},
|
||
"announcement": {"outline": ["发布事由", "公告内容", "执行要求"], "questions": ["发布通告的事由和适用范围是什么?", "需要公告哪些具体事项?", "执行时间、地点、对象及注意事项是什么?"]},
|
||
"bulletin": {"outline": ["基本情况", "原因分析", "处理意见", "工作要求"], "questions": ["通报的事实和时间线是什么?", "原因或经验教训是什么?", "处理意见及后续工作要求是什么?"]},
|
||
"instruction": {"outline": ["形势背景", "总体要求", "组织领导"], "questions": ["当前面临什么形势或任务?", "对目标、任务和原则有哪些明确要求?", "组织领导和落实责任如何安排?"]},
|
||
"command": {"outline": ["命令事项", "发布依据", "施行安排"], "questions": ["命令(令)的编号和发布主体是什么?", "发布依据、主要事项和适用对象是什么?", "从何时起施行,签发人是谁?"]},
|
||
"bill": {"outline": ["制定背景", "主要内容", "需说明问题"], "questions": ["向哪个审议机关提请审议?", "议案或草案的制定背景是什么?", "主要内容、争议或需说明的问题是什么?"]},
|
||
"regulation": {"outline": ["总则", "主要制度", "附则"], "questions": ["制度的制定依据、目的和适用范围是什么?", "需要规定哪些职责、流程、权利义务或监督机制?", "解释主体和施行日期如何确定?"]},
|
||
"notice_public": {"outline": ["公告依据", "公告事项", "办理说明"], "questions": ["公告的依据和对象范围是什么?", "需要公开哪些具体事项?", "办理时间、地点、材料和咨询方式是什么?"]},
|
||
"meeting": {"outline": ["会议基本信息", "会议议程", "参会要求"], "questions": ["会议的时间、地点、主题和主办单位是什么?", "议程和参会人员范围是什么?", "报名、材料准备和纪律要求是什么?"]},
|
||
"work_plan": {"outline": ["指导思想", "工作目标", "主要任务", "实施步骤", "保障措施"], "questions": ["实施方案的政策依据、背景和总体目标是什么?", "有哪些主要任务、责任单位和完成标准?", "实施分几个阶段,各阶段时间和保障措施是什么?"]},
|
||
"summary": {"outline": ["基本情况", "主要做法和成效", "问题不足", "下一步打算"], "questions": ["总结的时间范围和工作背景是什么?", "主要做法、数据和成效有哪些?", "存在什么问题,下一步有什么计划?"]},
|
||
"speech": {"outline": ["开场与背景", "形势认识", "工作部署", "落实要求"], "questions": ["讲话的场合、对象和主题是什么?", "需要传达哪些背景、判断或重点信息?", "准备部署哪些任务并提出哪些落实要求?"]},
|
||
"host_speech": {"outline": ["开场", "议程串联", "现场提示", "总结致谢"], "questions": ["活动名称、时间、地点和主持人是谁?", "完整议程、嘉宾和发言顺序是什么?", "有哪些需要主持人特别提示的环节?"]},
|
||
"news": {"outline": ["导语", "活动经过", "主要成果", "后续安排"], "questions": ["新闻事件发生在何时何地、由谁组织?", "活动过程和关键事实是什么?", "有哪些成果、数据、引语或后续安排可公开?"]},
|
||
"technical_proposal": {"outline": ["项目背景", "建设目标", "总体方案", "实施计划", "保障措施"], "questions": ["项目背景、现状问题和适用范围是什么?", "建设目标和验收标准是什么?", "技术/业务方案、实施计划、资源和风险保障如何安排?"]},
|
||
"table_sign": {"outline": ["单位名称", "人员姓名", "职务或席位"], "questions": ["桌签使用场景是什么?", "需要展示的单位、姓名、职务或席位分别是什么?"]},
|
||
}
|
||
|
||
_TEMPLATE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("request", ("请示", "报请", "审批")), ("report", ("报告", "汇报")),
|
||
("minutes", ("会议纪要", "纪要")), ("meeting", ("会议通知", "开会", "会议")),
|
||
("work_plan", ("方案", "计划", "实施")), ("regulation", ("制度", "办法", "规定")),
|
||
("summary", ("总结", "复盘")), ("letter", ("函", "商洽", "复函")),
|
||
("reply", ("批复",)), ("opinion", ("意见",)), ("bulletin", ("通报",)),
|
||
("announcement", ("通告",)), ("notice_public", ("公告", "公示")),
|
||
("news", ("新闻稿", "新闻")), ("speech", ("讲话", "发言稿")),
|
||
("host_speech", ("主持词",)), ("notice", ("通知",)),
|
||
)
|
||
|
||
|
||
def _template_profile(template_key: str) -> dict[str, list[str]]:
|
||
profile = _TEMPLATE_PROFILES.get(template_key)
|
||
if profile is not None:
|
||
return {"outline": list(profile["outline"]), "questions": list(profile["questions"])}
|
||
return {
|
||
"outline": ["基本情况", "主要内容", "办理要求"],
|
||
"questions": ["文稿面向谁、用于什么场景?", "需要写入哪些准确事实和要求?", "落款单位、日期或时限如何确定?"],
|
||
}
|
||
|
||
|
||
def _recommend_template(intent: str) -> tuple[str, str]:
|
||
compact = re.sub(r"\s+", "", intent)
|
||
for key, terms in _TEMPLATE_KEYWORDS:
|
||
if any(term in compact for term in terms):
|
||
return key, f"根据“{next(term for term in terms if term in compact)}”识别为{_TEMPLATE_BY_KEY[key]['name']}场景。"
|
||
return "work_plan", "未识别到明确文种,先按“工作方案”规划结构;你可随时切换模板。"
|
||
|
||
|
||
class DocumentElementsRequest(BaseModel):
|
||
"""User-confirmed official-issue elements, injected after validation.
|
||
|
||
Everything stays optional and empty means absent: the workbench never
|
||
invents issue elements, it only injects what the user actually filled in.
|
||
"""
|
||
|
||
org_name: str = Field(default="", max_length=120)
|
||
doc_number: str = Field(default="", max_length=96)
|
||
signer: str = Field(default="", max_length=60)
|
||
cc: str = Field(default="", max_length=300)
|
||
printer: str = Field(default="", max_length=120)
|
||
print_date: str = Field(default="", max_length=40)
|
||
page_number: bool = False
|
||
|
||
|
||
class GenerateDocumentRequest(BaseModel):
|
||
title: str = Field(..., min_length=1, max_length=160)
|
||
document_type: str = Field(default="notice", max_length=48)
|
||
content: str = Field(..., min_length=1, max_length=200_000)
|
||
template_key: str | None = Field(default=None, max_length=96)
|
||
research_session_id: str | None = Field(default=None, max_length=64)
|
||
elements: DocumentElementsRequest | None = None
|
||
|
||
|
||
class ImportThreadArtifactRequest(BaseModel):
|
||
"""A DOCX already uploaded to, or generated by, an owned agent thread."""
|
||
|
||
thread_id: str = Field(..., min_length=1, max_length=128)
|
||
artifact_path: str = Field(..., min_length=1, max_length=1_024)
|
||
source: Literal["upload", "generated", "repaired"] = "generated"
|
||
parent_artifact_path: str | None = Field(default=None, max_length=1_024)
|
||
title: str | None = Field(default=None, max_length=160)
|
||
document_type: str = Field(default="auto", max_length=48)
|
||
template_key: str | None = Field(default=None, max_length=96)
|
||
|
||
|
||
class RepairRequest(BaseModel):
|
||
# Reserved for selected format-rule repair in the next iteration. The
|
||
# first workflow repairs all deterministic format violations only.
|
||
selected_rule_ids: list[str] = Field(default_factory=list, max_length=100)
|
||
|
||
|
||
class DraftPlanRequest(BaseModel):
|
||
intent: str = Field(..., min_length=1, max_length=8_000)
|
||
template_key: str | None = Field(default=None, max_length=96)
|
||
|
||
|
||
class DraftAnswer(BaseModel):
|
||
question: str = Field(..., min_length=1, max_length=800)
|
||
answer: str = Field(..., min_length=1, max_length=8_000)
|
||
|
||
|
||
class DraftWriteRequest(BaseModel):
|
||
intent: str = Field(..., min_length=1, max_length=8_000)
|
||
template_key: str = Field(..., max_length=96)
|
||
answers: list[DraftAnswer] = Field(default_factory=list, max_length=16)
|
||
title_hint: str | None = Field(default=None, max_length=160)
|
||
model_name: str | None = Field(default=None, max_length=160)
|
||
|
||
|
||
class CreateTemplateRequest(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=128)
|
||
description: str = Field(default="", max_length=1_000)
|
||
category: str = Field(default="我的模板", min_length=1, max_length=64)
|
||
outline: list[str] = Field(..., min_length=1, max_length=20)
|
||
questions: list[str] = Field(default_factory=list, max_length=20)
|
||
engine_type: str = Field(default="notice", max_length=48)
|
||
|
||
|
||
def _store(request: Request) -> GongwenDocumentStore:
|
||
store = getattr(request.app.state, "gongwen_document_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="公文文库暂不可用")
|
||
return store
|
||
|
||
|
||
async def _user_id(request: Request) -> str | None:
|
||
return await get_current_user(request)
|
||
|
||
|
||
def _documents_root() -> Path:
|
||
root = (runtime_home() / "gongwen-documents").resolve()
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
return root
|
||
|
||
|
||
def _user_bucket(user_id: str | None) -> str:
|
||
# Do not expose a user id in the storage tree or in an accidental log line.
|
||
return hashlib.sha256((user_id or "anonymous").encode("utf-8")).hexdigest()[:24]
|
||
|
||
|
||
def _document_dir(user_id: str | None, document_id: str) -> Path:
|
||
if not re.fullmatch(r"gw_[A-Za-z0-9_-]{8,64}", document_id):
|
||
raise HTTPException(status_code=404, detail="文档不存在")
|
||
directory = (_documents_root() / _user_bucket(user_id) / document_id).resolve()
|
||
if _documents_root() not in directory.parents:
|
||
raise HTTPException(status_code=400, detail="非法文档路径")
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
return directory
|
||
|
||
|
||
def _safe_filename(raw: str | None, fallback: str) -> str:
|
||
name = PurePath(raw or "").name.strip()
|
||
name = _SAFE_FILE_NAME_RE.sub("-", name).strip(".-")
|
||
return (name or fallback)[:180]
|
||
|
||
|
||
def _safe_title(raw: str) -> str:
|
||
compact = re.sub(r"\s+", " ", raw).strip()
|
||
return compact[:160] or "未命名公文"
|
||
|
||
|
||
def _custom_templates_root() -> Path:
|
||
root = (_documents_root() / ".custom-templates").resolve()
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
return root
|
||
|
||
|
||
def _custom_template_dir(user_id: str | None, template_key: str) -> Path:
|
||
if not _CUSTOM_TEMPLATE_KEY_RE.fullmatch(template_key):
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
root = _custom_templates_root()
|
||
directory = (root / _user_bucket(user_id) / template_key).resolve()
|
||
if root not in directory.parents:
|
||
raise HTTPException(status_code=400, detail="非法模板路径")
|
||
return directory
|
||
|
||
|
||
def _clean_template_lines(values: list[Any], *, limit: int) -> list[str]:
|
||
result: list[str] = []
|
||
for value in values:
|
||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||
if text and text not in result:
|
||
result.append(text[:160])
|
||
if len(result) >= limit:
|
||
break
|
||
return result
|
||
|
||
|
||
def _questions_for_outline(outline: list[str]) -> list[str]:
|
||
return [f"请补充“{section}”相关的准确事实、要求或说明。" for section in outline]
|
||
|
||
|
||
def _custom_template_response(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||
key = str(raw.get("key") or "")
|
||
name = _safe_title(str(raw.get("name") or ""))
|
||
outline = _clean_template_lines(
|
||
raw.get("outline") if isinstance(raw.get("outline"), list) else [], limit=20
|
||
)
|
||
if not _CUSTOM_TEMPLATE_KEY_RE.fullmatch(key) or not name or not outline:
|
||
return None
|
||
questions = _clean_template_lines(
|
||
raw.get("questions") if isinstance(raw.get("questions"), list) else [], limit=20
|
||
) or _questions_for_outline(outline)
|
||
engine_type = str(raw.get("engine_type") or "notice")
|
||
if engine_type not in _TEMPLATE_BY_KEY:
|
||
engine_type = "notice"
|
||
return {
|
||
"key": key,
|
||
"name": name,
|
||
"category": _safe_title(str(raw.get("category") or "我的模板"))[:64],
|
||
"description": str(raw.get("description") or "基于自定义结构创建的公文模板。").strip()[:1_000],
|
||
"outline": outline,
|
||
"questions": questions,
|
||
"scope": "personal",
|
||
"active": True,
|
||
"engineType": engine_type,
|
||
"sourceFileName": raw.get("source_file_name"),
|
||
"createdAt": raw.get("created_at"),
|
||
}
|
||
|
||
|
||
def _list_custom_templates_sync(user_id: str | None) -> list[dict[str, Any]]:
|
||
root = (_custom_templates_root() / _user_bucket(user_id)).resolve()
|
||
if not root.is_dir():
|
||
return []
|
||
result: list[dict[str, Any]] = []
|
||
for metadata_path in root.glob("custom_*/template.json"):
|
||
try:
|
||
raw = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
continue
|
||
if isinstance(raw, dict):
|
||
template = _custom_template_response(raw)
|
||
if template is not None:
|
||
result.append(template)
|
||
return sorted(result, key=lambda item: str(item.get("createdAt") or ""), reverse=True)
|
||
|
||
|
||
def _get_custom_template_sync(user_id: str | None, template_key: str) -> dict[str, Any] | None:
|
||
directory = _custom_template_dir(user_id, template_key)
|
||
metadata_path = directory / "template.json"
|
||
try:
|
||
raw = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return None
|
||
return _custom_template_response(raw) if isinstance(raw, dict) else None
|
||
|
||
|
||
def _save_custom_template_sync(
|
||
user_id: str | None,
|
||
template: dict[str, Any],
|
||
*,
|
||
source_payload: bytes | None = None,
|
||
source_file_name: str | None = None,
|
||
) -> dict[str, Any]:
|
||
directory = _custom_template_dir(user_id, str(template["key"]))
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
if source_payload is not None:
|
||
source_path = directory / "template.docx"
|
||
temp_source_path = directory / f".{source_path.name}.{uuid.uuid4().hex}.tmp"
|
||
temp_source_path.write_bytes(source_payload)
|
||
temp_source_path.replace(source_path)
|
||
template["source_file_name"] = source_file_name or "custom-template.docx"
|
||
metadata_path = directory / "template.json"
|
||
temp_metadata_path = directory / f".{metadata_path.name}.{uuid.uuid4().hex}.tmp"
|
||
temp_metadata_path.write_text(
|
||
json.dumps(template, ensure_ascii=False, separators=(",", ":")), encoding="utf-8"
|
||
)
|
||
temp_metadata_path.replace(metadata_path)
|
||
response = _custom_template_response(template)
|
||
if response is None:
|
||
raise HTTPException(status_code=422, detail="模板结构无效")
|
||
return response
|
||
|
||
|
||
async def _custom_templates_for_user(user_id: str | None) -> list[dict[str, Any]]:
|
||
return await asyncio.to_thread(_list_custom_templates_sync, user_id)
|
||
|
||
|
||
async def _resolve_template(request: Request, template_key: str) -> dict[str, Any]:
|
||
if template_key in _TEMPLATE_BY_KEY:
|
||
return {
|
||
**_TEMPLATE_BY_KEY[template_key],
|
||
**_template_profile(template_key),
|
||
"scope": "builtin",
|
||
"active": True,
|
||
"engineType": template_key,
|
||
}
|
||
user_id = await _user_id(request)
|
||
template = await asyncio.to_thread(_get_custom_template_sync, user_id, template_key)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
return template
|
||
|
||
|
||
def _outline_from_template_docx(payload: bytes) -> list[str]:
|
||
lines = [re.sub(r"\s+", " ", line).strip() for line in _text_from_docx_payload(payload).splitlines()]
|
||
lines = [line for line in lines if line]
|
||
numbered: list[str] = []
|
||
for line in lines:
|
||
match = re.match(r"^(?:[一二三四五六七八九十]+[、.]|\d+[、.])\s*(.+)$", line)
|
||
if match:
|
||
numbered.append(match.group(1).strip())
|
||
candidates = numbered or [line for line in lines[1:] if len(line) <= 48]
|
||
return _clean_template_lines(candidates, limit=12) or ["正文内容"]
|
||
|
||
|
||
def _relative_file_path(user_id: str | None, document_id: str, storage_key: str) -> Path:
|
||
base = _document_dir(user_id, document_id)
|
||
key = PurePath(storage_key)
|
||
if key.is_absolute() or ".." in key.parts:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
target = (base / key).resolve()
|
||
if base != target.parent and base not in target.parents:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
return target
|
||
|
||
|
||
def _normalize_thread_artifact_path(raw_path: str) -> str:
|
||
"""Accept only a DOCX path inside an owned thread's uploads or outputs."""
|
||
normalized = raw_path.strip().lstrip("/").replace("\\", "/")
|
||
if normalized and "/" not in normalized:
|
||
normalized = f"mnt/user-data/outputs/{normalized}"
|
||
if any(part in {"", ".", ".."} for part in normalized.split("/")):
|
||
raise HTTPException(status_code=422, detail="对话文件路径无效")
|
||
if not _THREAD_ARTIFACT_PATH_RE.fullmatch(normalized):
|
||
raise HTTPException(status_code=422, detail="只能归档对话的上传文件或输出文件")
|
||
if not normalized.lower().endswith(".docx"):
|
||
raise HTTPException(status_code=422, detail="当前仅支持归档 DOCX 文件")
|
||
return normalized
|
||
|
||
|
||
def _artifact_import_ref_path(
|
||
user_id: str | None,
|
||
document_id: str,
|
||
*,
|
||
thread_id: str,
|
||
artifact_path: str,
|
||
) -> Path:
|
||
source_key = hashlib.sha256(f"{thread_id}\0{artifact_path}".encode()).hexdigest()[:24]
|
||
return _document_dir(user_id, document_id) / ".artifact-imports" / f"{source_key}.json"
|
||
|
||
|
||
def _find_imported_artifact_sync(
|
||
user_id: str | None,
|
||
*,
|
||
thread_id: str,
|
||
artifact_path: str,
|
||
) -> str | None:
|
||
root = (_documents_root() / _user_bucket(user_id)).resolve()
|
||
if not root.is_dir():
|
||
return None
|
||
for ref_path in root.glob("gw_*/.artifact-imports/*.json"):
|
||
try:
|
||
ref = json.loads(ref_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
continue
|
||
if (
|
||
isinstance(ref, dict)
|
||
and ref.get("thread_id") == thread_id
|
||
and ref.get("artifact_path") == artifact_path
|
||
):
|
||
document_id = str(ref.get("document_id") or "")
|
||
if re.fullmatch(r"gw_[A-Za-z0-9_-]{8,64}", document_id):
|
||
return document_id
|
||
return None
|
||
|
||
|
||
def _save_artifact_import_ref_sync(
|
||
user_id: str | None,
|
||
document_id: str,
|
||
*,
|
||
thread_id: str,
|
||
artifact_path: str,
|
||
) -> None:
|
||
ref_path = _artifact_import_ref_path(
|
||
user_id,
|
||
document_id,
|
||
thread_id=thread_id,
|
||
artifact_path=artifact_path,
|
||
)
|
||
ref_path.parent.mkdir(parents=True, exist_ok=True)
|
||
temp_path = ref_path.with_name(f".{ref_path.name}.{uuid.uuid4().hex}.tmp")
|
||
temp_path.write_text(
|
||
json.dumps(
|
||
{
|
||
"document_id": document_id,
|
||
"thread_id": thread_id,
|
||
"artifact_path": artifact_path,
|
||
},
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
temp_path.replace(ref_path)
|
||
|
||
|
||
def _file_meta(path: Path) -> tuple[int, str]:
|
||
digest = hashlib.sha256()
|
||
size = 0
|
||
with path.open("rb") as handle:
|
||
while chunk := handle.read(1024 * 1024):
|
||
digest.update(chunk)
|
||
size += len(chunk)
|
||
return size, digest.hexdigest()
|
||
|
||
|
||
def _engine_script() -> Path:
|
||
configured = os.environ.get("GONGWEN_SKILL_ROOT", "").strip()
|
||
candidates: list[Path] = []
|
||
if configured:
|
||
candidate = Path(configured)
|
||
if not candidate.is_absolute():
|
||
raise HTTPException(status_code=503, detail="GONGWEN_SKILL_ROOT 必须是绝对路径")
|
||
candidates.append(candidate)
|
||
backend_root = Path(__file__).resolve().parents[3]
|
||
candidates.extend((
|
||
backend_root / "skills" / "custom" / "gongwen-assistant",
|
||
backend_root / "skills" / "public" / "gongwen-assistant",
|
||
))
|
||
for root in candidates:
|
||
script = (root / "scripts" / "run_gongwen.py").resolve()
|
||
if script.is_file() and root.resolve() in script.parents:
|
||
return script
|
||
raise HTTPException(status_code=503, detail="未安装公文引擎,请先安装 gongwen-assistant 技能包")
|
||
|
||
|
||
def _decode_engine_json(stdout: str) -> Any:
|
||
text = stdout.strip()
|
||
if not text:
|
||
return {}
|
||
try:
|
||
return json.loads(text)
|
||
except json.JSONDecodeError:
|
||
# The upstream CLI normally emits one JSON payload. Keep a defensive
|
||
# fallback for benign logging before the payload without accepting an
|
||
# arbitrary non-JSON command result as a review report.
|
||
starts = [index for index in (text.find("{"), text.find("[")) if index >= 0]
|
||
if not starts:
|
||
return {"raw_output": text}
|
||
candidate = text[min(starts):]
|
||
try:
|
||
return json.loads(candidate)
|
||
except json.JSONDecodeError:
|
||
return {"raw_output": text}
|
||
|
||
|
||
def _run_engine_sync(arguments: list[str], *, state_dir: Path) -> Any:
|
||
script = _engine_script()
|
||
environment = os.environ.copy()
|
||
environment["GONGWEN_DATA_DIR"] = str(state_dir)
|
||
try:
|
||
completed = subprocess.run(
|
||
[sys.executable, str(script), *arguments],
|
||
cwd=str(script.parent.parent),
|
||
env=environment,
|
||
check=False,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=_ENGINE_TIMEOUT_SECONDS,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
raise HTTPException(status_code=504, detail="公文引擎处理超时,请缩小文档后重试") from exc
|
||
payload = _decode_engine_json(completed.stdout)
|
||
if completed.returncode != 0:
|
||
error = (completed.stderr or "").strip() or str(payload.get("raw_output") or "公文引擎处理失败")
|
||
raise HTTPException(status_code=422, detail=error[:1000])
|
||
return payload
|
||
|
||
|
||
async def _run_engine(arguments: list[str], *, state_dir: Path) -> Any:
|
||
return await asyncio.to_thread(_run_engine_sync, arguments, state_dir=state_dir)
|
||
|
||
|
||
async def _reuse_verified_review(result: Any, path: Path, document_type: str, state_dir: Path) -> Any:
|
||
"""Reuse this invocation's output review; support older custom engines."""
|
||
verification = result.get("optimize", result) if isinstance(result, dict) else {}
|
||
if isinstance(verification, dict) and verification.get("verify_executed") is True:
|
||
if verification.get("verified") is not True:
|
||
raise HTTPException(status_code=422, detail="公文引擎未完成复验,请检查引擎错误")
|
||
review = verification.get("review")
|
||
if isinstance(review, dict) and isinstance(review.get("issues"), list):
|
||
return review
|
||
return await _run_engine(["check", str(path), "-t", document_type, "--json"], state_dir=state_dir)
|
||
|
||
|
||
_ELEMENT_TEXT_FIELDS = ("org_name", "doc_number", "signer", "cc", "printer", "print_date")
|
||
|
||
|
||
def _element_injection_steps(
|
||
elements: DocumentElementsRequest | None, target: Path
|
||
) -> list[list[str]]:
|
||
"""Engine argument lists that inject user-confirmed issue elements in place.
|
||
|
||
Order is fixed (header → footer → page number) and mirrors the skill's
|
||
documented workflow: body validation happens first, injection last, and no
|
||
check/optimize runs afterwards because the checker misreads the injected
|
||
red-head/imprint zones as body formatting violations. All-empty input
|
||
yields no steps, keeping element-less generation byte-for-byte unchanged.
|
||
"""
|
||
|
||
if elements is None:
|
||
return []
|
||
values = {
|
||
name: re.sub(r"\s+", " ", str(getattr(elements, name) or "")).strip()
|
||
for name in _ELEMENT_TEXT_FIELDS
|
||
}
|
||
steps: list[list[str]] = []
|
||
if any(values[name] for name in ("org_name", "doc_number", "signer")):
|
||
if not values["org_name"]:
|
||
# The engine rejects a header without --org-name; failing here
|
||
# keeps the message actionable instead of surfacing engine stderr.
|
||
raise HTTPException(
|
||
status_code=422, detail="填写了发文字号或签发人时,必须同时填写发文机关标志(版头)"
|
||
)
|
||
header = ["header", str(target), "-o", str(target), "--org-name", values["org_name"]]
|
||
if values["doc_number"]:
|
||
header += ["--doc-number", values["doc_number"]]
|
||
if values["signer"]:
|
||
header += ["--signer", values["signer"]]
|
||
steps.append(header)
|
||
if any(values[name] for name in ("cc", "printer", "print_date")):
|
||
footer = ["footer", str(target), "-o", str(target)]
|
||
if values["cc"]:
|
||
footer += ["--cc", values["cc"]]
|
||
if values["printer"]:
|
||
footer += ["--printer", values["printer"]]
|
||
if values["print_date"]:
|
||
footer += ["--print-date", values["print_date"]]
|
||
steps.append(footer)
|
||
if elements.page_number:
|
||
steps.append(["pagenum", str(target), "-o", str(target)])
|
||
return steps
|
||
|
||
|
||
def _normalize_issue(raw: Any) -> dict[str, Any]:
|
||
item = raw if isinstance(raw, dict) else {"message": str(raw)}
|
||
severity = str(item.get("severity") or item.get("level") or "P2").upper()
|
||
if severity not in {"P0", "P1", "P2"}:
|
||
severity = "P2"
|
||
location = item.get("location") or item.get("position") or item.get("paragraph") or ""
|
||
paragraph_index = item.get("paragraph_index")
|
||
if not isinstance(paragraph_index, int):
|
||
match = re.search(r"(?:第\s*)?(\d+)\s*(?:段|段落)", str(location))
|
||
paragraph_index = max(0, int(match.group(1)) - 1) if match else None
|
||
return {
|
||
"ruleId": str(item.get("rule_id") or item.get("ruleId") or item.get("id") or ""),
|
||
"severity": severity,
|
||
"category": str(item.get("check_type") or item.get("category") or item.get("type") or "格式"),
|
||
"name": str(item.get("name") or item.get("title") or "格式问题"),
|
||
"location": str(location),
|
||
"paragraphIndex": paragraph_index,
|
||
"message": str(item.get("message") or item.get("reason") or item.get("detail") or "需要人工核对"),
|
||
"expected": str(item.get("expected") or ""),
|
||
"actual": str(item.get("actual") or item.get("current") or ""),
|
||
"standardRef": str(item.get("standard_ref") or item.get("standardRef") or ""),
|
||
"autoFixable": bool(item.get("auto_fixable", item.get("fixable", True))),
|
||
}
|
||
|
||
|
||
def _review_snapshot(raw: Any, *, document_type: str) -> tuple[dict[str, Any], dict[str, int]]:
|
||
if isinstance(raw, list):
|
||
items = raw
|
||
envelope: dict[str, Any] = {}
|
||
elif isinstance(raw, dict):
|
||
maybe_items = raw.get("issues") or raw.get("items") or raw.get("results") or []
|
||
items = maybe_items if isinstance(maybe_items, list) else []
|
||
envelope = raw
|
||
else:
|
||
items, envelope = [], {"raw_output": str(raw)}
|
||
issues = [_normalize_issue(item) for item in items]
|
||
counts = {"P0": 0, "P1": 0, "P2": 0}
|
||
for issue in issues:
|
||
counts[issue["severity"]] += 1
|
||
return {
|
||
"engine": "gongwen-assistant",
|
||
"documentType": document_type,
|
||
"issues": issues,
|
||
"summary": envelope.get("summary") if isinstance(envelope.get("summary"), dict) else None,
|
||
"raw": envelope,
|
||
}, counts
|
||
|
||
|
||
def _review_status(counts: dict[str, int]) -> str:
|
||
return "review_required" if sum(counts.values()) else "verified"
|
||
|
||
|
||
def _content_to_text(content: Any) -> str:
|
||
if isinstance(content, str):
|
||
return content.strip()
|
||
if isinstance(content, list):
|
||
parts: list[str] = []
|
||
for item in content:
|
||
if isinstance(item, str):
|
||
parts.append(item)
|
||
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
||
parts.append(item["text"])
|
||
else:
|
||
text = getattr(item, "text", None)
|
||
if isinstance(text, str):
|
||
parts.append(text)
|
||
return "".join(parts).strip()
|
||
return str(content or "").strip()
|
||
|
||
|
||
def _fallback_draft_markdown(*, title: str, profile: dict[str, list[str]], answers: list[DraftAnswer]) -> str:
|
||
facts = {answer.question: answer.answer.strip() for answer in answers if answer.answer.strip()}
|
||
lines = [f"# {title}", "", "以下内容依据已确认信息起草;未提供的事实以“[待补充]”保留。", ""]
|
||
for index, section in enumerate(profile["outline"], 1):
|
||
matching = next((value for question, value in facts.items() if section[:2] in question or section in question), "")
|
||
value = matching or (list(facts.values())[index - 1] if len(facts) >= index else "[待补充]")
|
||
numeral = "一二三四五六七八九十"[min(index - 1, 9)]
|
||
lines.extend((f"## {numeral}、{section}", value, ""))
|
||
return "\n".join(lines).strip()
|
||
|
||
|
||
async def _write_draft_markdown(
|
||
body: DraftWriteRequest,
|
||
request: Request,
|
||
*,
|
||
template: dict[str, Any] | None = None,
|
||
) -> tuple[str, str, bool]:
|
||
"""Draft prose with the deployment's configured model, with a safe fallback.
|
||
|
||
This purposefully does not ask the model to invent missing facts. The
|
||
answer set is the conversation's confirmed material; unknown official
|
||
elements stay as explicit placeholders for the user to complete.
|
||
"""
|
||
template = template or _TEMPLATE_BY_KEY[body.template_key]
|
||
profile = {
|
||
"outline": list(template.get("outline") or _template_profile(body.template_key)["outline"]),
|
||
"questions": list(template.get("questions") or _template_profile(body.template_key)["questions"]),
|
||
}
|
||
title = _safe_title(body.title_hint or f"关于{body.intent[:48]}的{template['name']}")
|
||
fact_lines = "\n".join(f"- {item.question}:{item.answer}" for item in body.answers)
|
||
system = (
|
||
"你是严谨的中文公文起草助手。只可依据用户已确认的事实写作,"
|
||
"不得编造单位、文号、日期、数据、法律依据或会议结论。缺失事实保留“[待补充]”。"
|
||
"严格使用给定的文种结构,输出纯 Markdown:第一行仅一个一级标题,后续章节用二级标题;"
|
||
"层次序号依次用“一、”“(一)”“1.”“(1)”。"
|
||
"用语庄重规范:用“其他”不用“其它”;“截至”后接时间点;搭配“决定/部署/指示”用“作出”;"
|
||
"不用口语、网络用语、空话套话和主观模糊词。"
|
||
"单位名称首次出现用全称且全篇一致;按文种使用规范结束语(如通知用“特此通知”,请示用“妥否,请批示”)。"
|
||
"不要使用代码块、解释、参考文献或客套说明。"
|
||
)
|
||
prompt = (
|
||
f"用户意图:{body.intent}\n\n"
|
||
f"文种:{template['name']}\n"
|
||
f"建议标题:{title}\n"
|
||
f"必须保留的章节顺序:{'、'.join(profile['outline'])}\n\n"
|
||
f"已确认信息:\n{fact_lines or '- [尚未补充事实]'}\n\n"
|
||
"请起草一份可直接交付前复核的公文内容。"
|
||
)
|
||
try:
|
||
from langchain_core.messages import HumanMessage, SystemMessage
|
||
|
||
from deerflow.models import create_chat_model
|
||
|
||
model = create_chat_model(
|
||
name=(body.model_name or "").strip() or None,
|
||
thinking_enabled=False,
|
||
force_disable_thinking=True,
|
||
app_config=getattr(request.app.state, "config", None),
|
||
)
|
||
response = await model.ainvoke([SystemMessage(content=system), HumanMessage(content=prompt)])
|
||
markdown = _content_to_text(getattr(response, "content", ""))
|
||
markdown = re.sub(r"^```(?:markdown|md)?\s*|\s*```$", "", markdown, flags=re.IGNORECASE).strip()
|
||
if markdown and len(markdown) >= 24:
|
||
if not markdown.startswith("#"):
|
||
markdown = f"# {title}\n\n{markdown}"
|
||
return title, markdown[:200_000], False
|
||
except Exception:
|
||
# Model configuration/availability must not discard a user's confirmed
|
||
# facts. Return a clearly reviewable skeleton that can still become a
|
||
# compliant DOCX without a network call or a new dependency.
|
||
pass
|
||
return title, _fallback_draft_markdown(title=title, profile=profile, answers=body.answers), True
|
||
|
||
|
||
def _text_from_docx_payload(payload: bytes) -> str:
|
||
try:
|
||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||
xml = archive.read("word/document.xml")
|
||
root = ElementTree.fromstring(xml)
|
||
except (KeyError, OSError, zipfile.BadZipFile, ElementTree.ParseError):
|
||
return ""
|
||
return "\n".join("".join(node.text or "" for node in paragraph.iter(f"{_W_NS}t")) for paragraph in root.iter(f"{_W_NS}p"))
|
||
|
||
|
||
def _detect_document_type(file_name: str, payload: bytes) -> str:
|
||
sample = f"{file_name}\n{_text_from_docx_payload(payload)[:8_000]}"
|
||
selected, _ = _recommend_template(sample)
|
||
return selected
|
||
|
||
|
||
def _extract_docx_paragraphs(path: Path) -> list[dict[str, Any]]:
|
||
"""A dependency-free reading preview used for issue jump/highlight.
|
||
|
||
It intentionally does not claim pixel-perfect Word rendering; the actual
|
||
DOCX remains downloadable. The review view gives users a stable text
|
||
location for every checker finding without LibreOffice or a browser plugin.
|
||
"""
|
||
try:
|
||
with zipfile.ZipFile(path) as archive:
|
||
xml = archive.read("word/document.xml")
|
||
root = ElementTree.fromstring(xml)
|
||
except (KeyError, OSError, zipfile.BadZipFile, ElementTree.ParseError):
|
||
return []
|
||
result: list[dict[str, Any]] = []
|
||
for paragraph in root.iter(f"{_W_NS}p"):
|
||
text = "".join(node.text or "" for node in paragraph.iter(f"{_W_NS}t")).strip()
|
||
if text:
|
||
result.append({"index": len(result), "text": text})
|
||
return result
|
||
|
||
|
||
async def _append_file_version(
|
||
store: GongwenDocumentStore,
|
||
*,
|
||
document_id: str,
|
||
user_id: str | None,
|
||
path: Path,
|
||
kind: str,
|
||
file_name: str,
|
||
review: dict[str, Any] | None,
|
||
document_patch: dict[str, Any],
|
||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||
size, digest = await asyncio.to_thread(_file_meta, path)
|
||
directory = _document_dir(user_id, document_id)
|
||
key = path.resolve().relative_to(directory).as_posix()
|
||
result = await store.append_version(
|
||
document_id,
|
||
user_id,
|
||
{
|
||
"id": f"gwv_{uuid.uuid4().hex}",
|
||
"kind": kind,
|
||
"file_name": file_name,
|
||
"storage_key": key,
|
||
"content_type": _DOCX_MIME if path.suffix.lower() == ".docx" else _MARKDOWN_MIME,
|
||
"size_bytes": size,
|
||
"sha256": digest,
|
||
"review_snapshot": review,
|
||
},
|
||
document_patch,
|
||
) or (None, None)
|
||
if result[0] is None:
|
||
raise HTTPException(status_code=404, detail="文档不存在")
|
||
return result # type: ignore[return-value]
|
||
|
||
|
||
def _document_response(document: dict[str, Any], versions: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||
data = {
|
||
"id": document["id"], "title": document.get("title") or "", "documentType": document.get("document_type") or "notice",
|
||
"source": document.get("source") or "upload", "status": document.get("status") or "checking",
|
||
"templateKey": document.get("template_key"), "researchSessionId": document.get("research_session_id"),
|
||
"originalVersionId": document.get("original_version_id"), "latestVersionId": document.get("latest_version_id"),
|
||
"issueCounts": document.get("issue_counts") or {"P0": 0, "P1": 0, "P2": 0}, "error": document.get("error"),
|
||
"createdAt": document.get("created_at"), "updatedAt": document.get("updated_at"),
|
||
}
|
||
if versions is not None:
|
||
data["versions"] = [_version_response(item) for item in versions]
|
||
return data
|
||
|
||
|
||
def _version_response(version: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": version["id"], "documentId": version.get("document_id"), "versionNo": version.get("version_no"),
|
||
"kind": version.get("kind"), "fileName": version.get("file_name"), "contentType": version.get("content_type"),
|
||
"sizeBytes": version.get("size_bytes"), "sha256": version.get("sha256"), "review": version.get("review_snapshot"),
|
||
"createdAt": version.get("created_at"),
|
||
}
|
||
|
||
|
||
@router.get("/templates")
|
||
async def list_templates(request: Request) -> dict[str, Any]:
|
||
user_id = await _user_id(request)
|
||
builtins = [
|
||
{**item, **_template_profile(item["key"]), "scope": "builtin", "active": True, "engineType": item["key"]}
|
||
for item in BUILTIN_TEMPLATES
|
||
]
|
||
return {"items": [*builtins, *(await _custom_templates_for_user(user_id))]}
|
||
|
||
|
||
@router.post("/templates", status_code=201)
|
||
async def create_template(body: CreateTemplateRequest, request: Request) -> dict[str, Any]:
|
||
engine_type = body.engine_type.strip() or "notice"
|
||
if engine_type not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="请选择受支持的基础文种")
|
||
outline = _clean_template_lines(body.outline, limit=20)
|
||
if not outline:
|
||
raise HTTPException(status_code=422, detail="请至少填写一个正文结构")
|
||
user_id = await _user_id(request)
|
||
now = datetime.now(UTC).isoformat()
|
||
template = await asyncio.to_thread(
|
||
_save_custom_template_sync,
|
||
user_id,
|
||
{
|
||
"key": f"custom_{uuid.uuid4().hex}",
|
||
"name": body.name,
|
||
"description": body.description,
|
||
"category": body.category,
|
||
"outline": outline,
|
||
"questions": _clean_template_lines(body.questions, limit=20),
|
||
"engine_type": engine_type,
|
||
"created_at": now,
|
||
},
|
||
)
|
||
return template
|
||
|
||
|
||
@router.post("/templates/upload", status_code=201)
|
||
async def create_template_from_upload(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
name: str | None = Form(None),
|
||
description: str = Form(""),
|
||
category: str = Form("我的模板"),
|
||
engine_type: str = Form("notice"),
|
||
) -> dict[str, Any]:
|
||
file_name = _safe_filename(file.filename, "custom-template.docx")
|
||
if not file_name.lower().endswith(".docx"):
|
||
raise HTTPException(status_code=422, detail="仅支持 DOCX 模板文件")
|
||
payload = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||
await file.close()
|
||
if len(payload) > _MAX_UPLOAD_BYTES:
|
||
raise HTTPException(status_code=413, detail="模板文件不能超过 25 MB")
|
||
if not payload.startswith(b"PK"):
|
||
raise HTTPException(status_code=422, detail="上传文件不是有效的 DOCX 模板")
|
||
if engine_type not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="请选择受支持的基础文种")
|
||
outline = _outline_from_template_docx(payload)
|
||
user_id = await _user_id(request)
|
||
template = await asyncio.to_thread(
|
||
_save_custom_template_sync,
|
||
user_id,
|
||
{
|
||
"key": f"custom_{uuid.uuid4().hex}",
|
||
"name": _safe_title(name or Path(file_name).stem),
|
||
"description": description or "从上传的 Word 模板中提取结构创建。",
|
||
"category": category or "我的模板",
|
||
"outline": outline,
|
||
"questions": _questions_for_outline(outline),
|
||
"engine_type": engine_type,
|
||
"created_at": datetime.now(UTC).isoformat(),
|
||
},
|
||
source_payload=payload,
|
||
source_file_name=file_name,
|
||
)
|
||
return template
|
||
|
||
|
||
@router.post("/drafts/plan")
|
||
async def plan_draft(body: DraftPlanRequest, request: Request) -> dict[str, Any]:
|
||
if body.template_key:
|
||
template = await _resolve_template(request, body.template_key)
|
||
reason = f"已按你选择的“{template['name']}”规划起草信息。"
|
||
else:
|
||
template_key, reason = _recommend_template(body.intent)
|
||
template = await _resolve_template(request, template_key)
|
||
return {"template": template, "reason": reason, "intent": body.intent.strip()}
|
||
|
||
if body.template_key:
|
||
if body.template_key not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="模板不存在")
|
||
template_key = body.template_key
|
||
reason = f"已按你选择的{_TEMPLATE_BY_KEY[template_key]['name']}规划起草信息。"
|
||
else:
|
||
template_key, reason = _recommend_template(body.intent)
|
||
template = _TEMPLATE_BY_KEY[template_key]
|
||
return {
|
||
"template": {**template, **_template_profile(template_key), "scope": "builtin", "active": True},
|
||
"reason": reason,
|
||
"intent": body.intent.strip(),
|
||
}
|
||
|
||
|
||
@router.post("/drafts/write")
|
||
async def write_draft(body: DraftWriteRequest, request: Request) -> dict[str, Any]:
|
||
template = await _resolve_template(request, body.template_key)
|
||
title, markdown, used_fallback = await _write_draft_markdown(body, request, template=template)
|
||
return {
|
||
"title": title,
|
||
"markdown": markdown,
|
||
"templateKey": body.template_key,
|
||
"usedFallback": used_fallback,
|
||
}
|
||
|
||
if body.template_key not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="模板不存在")
|
||
title, markdown, used_fallback = await _write_draft_markdown(body, request)
|
||
return {
|
||
"title": title,
|
||
"markdown": markdown,
|
||
"templateKey": body.template_key,
|
||
"usedFallback": used_fallback,
|
||
}
|
||
|
||
|
||
@router.get("/templates/{template_key}/download")
|
||
async def download_template(template_key: str, request: Request) -> FileResponse:
|
||
"""Build a standard blank DOCX once, then serve the local cached copy.
|
||
|
||
This is intentionally a download rather than a sandbox artifact; a
|
||
template is a reusable library resource, not a chat-generated file.
|
||
"""
|
||
user_id = await _user_id(request) # Keep template downloads inside the authenticated workbench.
|
||
if template_key not in _TEMPLATE_BY_KEY:
|
||
template = await asyncio.to_thread(_get_custom_template_sync, user_id, template_key)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
directory = _custom_template_dir(user_id, template_key)
|
||
source = directory / "template.docx"
|
||
if source.is_file():
|
||
return FileResponse(source, media_type=_DOCX_MIME, filename=f"{template['name']}—个人模板.docx")
|
||
output = directory / "template-sample.docx"
|
||
if not output.is_file():
|
||
markdown = directory / "template-sample.md"
|
||
skeleton = "\n\n".join([f"# {template['name']}", *[f"## {section}\n[待补充]" for section in template["outline"]]])
|
||
await asyncio.to_thread(markdown.write_text, skeleton, "utf-8")
|
||
await _run_engine(
|
||
["draft", str(markdown), "-o", str(output), "-t", str(template["engineType"]), "--verify", "--json"],
|
||
state_dir=directory / ".engine",
|
||
)
|
||
return FileResponse(output, media_type=_DOCX_MIME, filename=f"{template['name']}—个人模板.docx")
|
||
cache_dir = (_documents_root() / ".template-samples").resolve()
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
output = cache_dir / f"{template_key}.docx"
|
||
if not output.is_file():
|
||
await _run_engine(["template", template_key, "-o", str(output)], state_dir=cache_dir / ".engine")
|
||
template = _TEMPLATE_BY_KEY[template_key]
|
||
return FileResponse(output, media_type=_DOCX_MIME, filename=f"{template['name']}—标准空白模板.docx")
|
||
|
||
|
||
@router.get("/documents")
|
||
async def list_documents(request: Request, source: str | None = None, status: str | None = None) -> dict[str, Any]:
|
||
user_id = await _user_id(request)
|
||
rows = await _store(request).list_documents(user_id, source=source, status=status)
|
||
return {"items": [_document_response(row) for row in rows]}
|
||
|
||
|
||
@router.post("/documents/import-thread-artifact", status_code=201)
|
||
@require_permission("threads", "read", owner_check=True, require_existing=True)
|
||
async def import_thread_artifact(body: ImportThreadArtifactRequest, request: Request) -> dict[str, Any]:
|
||
"""Copy an owned agent-thread DOCX into the durable public-document library.
|
||
|
||
The browser submits only the virtual path; ownership and host-path
|
||
resolution happen here. The small source-reference sidecar makes this
|
||
idempotent when a thread is revisited or the browser reconnects.
|
||
"""
|
||
artifact_path = _normalize_thread_artifact_path(body.artifact_path)
|
||
user_id = await _user_id(request)
|
||
store = _store(request)
|
||
existing_id = await asyncio.to_thread(
|
||
_find_imported_artifact_sync,
|
||
user_id,
|
||
thread_id=body.thread_id,
|
||
artifact_path=artifact_path,
|
||
)
|
||
if existing_id:
|
||
existing = await store.get_document(existing_id, user_id)
|
||
if existing is not None:
|
||
return _document_response(existing, await store.list_versions(existing_id, user_id))
|
||
|
||
source_path = await aresolve_thread_virtual_path(body.thread_id, artifact_path)
|
||
if not source_path.is_file():
|
||
raise HTTPException(status_code=404, detail="对话文件不存在或尚未生成完成")
|
||
source_size = await asyncio.to_thread(lambda: source_path.stat().st_size)
|
||
if source_size > _MAX_UPLOAD_BYTES:
|
||
raise HTTPException(status_code=413, detail="归档文件不能超过 25 MB")
|
||
payload = await asyncio.to_thread(source_path.read_bytes)
|
||
if not payload.startswith(b"PK"):
|
||
raise HTTPException(status_code=422, detail="对话文件不是有效的 DOCX 文档")
|
||
|
||
parent_document: dict[str, Any] | None = None
|
||
if body.source == "repaired":
|
||
if not body.parent_artifact_path:
|
||
raise HTTPException(status_code=422, detail="修复版必须关联对话中的原始 Word 文件")
|
||
parent_artifact_path = _normalize_thread_artifact_path(body.parent_artifact_path)
|
||
parent_id = await asyncio.to_thread(
|
||
_find_imported_artifact_sync,
|
||
user_id,
|
||
thread_id=body.thread_id,
|
||
artifact_path=parent_artifact_path,
|
||
)
|
||
parent_document = await store.get_document(parent_id, user_id) if parent_id else None
|
||
if parent_document is None or not parent_document.get("original_version_id"):
|
||
raise HTTPException(status_code=409, detail="请先归档原始 Word 文件,再归档修复版")
|
||
|
||
selected_template = await _resolve_template(request, body.template_key) if body.template_key else None
|
||
document_type = body.document_type
|
||
if document_type in {"", "auto"}:
|
||
document_type = _detect_document_type(source_path.name, payload)
|
||
if selected_template is not None:
|
||
document_type = str(selected_template["engineType"])
|
||
if parent_document is not None:
|
||
document_type = str(parent_document.get("document_type") or "notice")
|
||
if document_type not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="不支持的公文文种")
|
||
|
||
if parent_document is None:
|
||
document_id = f"gw_{uuid.uuid4().hex}"
|
||
title = _safe_title(body.title or source_path.stem)
|
||
document = await store.create_document({
|
||
"id": document_id,
|
||
"user_id": user_id,
|
||
"title": title,
|
||
"document_type": document_type,
|
||
"source": body.source,
|
||
"status": "checking",
|
||
"template_key": body.template_key or document_type,
|
||
})
|
||
else:
|
||
document_id = str(parent_document["id"])
|
||
title = _safe_title(str(parent_document.get("title") or source_path.stem))
|
||
document = parent_document
|
||
directory = _document_dir(user_id, document_id)
|
||
stored_path = directory / (
|
||
"original.docx" if body.source == "upload"
|
||
else f"repaired-{uuid.uuid4().hex[:10]}.docx" if body.source == "repaired"
|
||
else "generated.docx"
|
||
)
|
||
await asyncio.to_thread(stored_path.write_bytes, payload)
|
||
version_kind = "original" if body.source == "upload" else "repaired" if body.source == "repaired" else "generated"
|
||
try:
|
||
raw_review = await _run_engine(
|
||
["check", str(stored_path), "-t", document_type, "--json"],
|
||
state_dir=directory / ".engine",
|
||
)
|
||
review, counts = _review_snapshot(raw_review, document_type=document_type)
|
||
document, _ = await _append_file_version(
|
||
store,
|
||
document_id=document_id,
|
||
user_id=user_id,
|
||
path=stored_path,
|
||
kind=version_kind,
|
||
file_name=_safe_filename(source_path.name, f"{title}.docx"),
|
||
review=review,
|
||
document_patch={
|
||
"status": "repaired" if body.source == "repaired" else _review_status(counts),
|
||
"issue_counts": counts,
|
||
"error": None,
|
||
},
|
||
)
|
||
except HTTPException as exc:
|
||
document, _ = await _append_file_version(
|
||
store,
|
||
document_id=document_id,
|
||
user_id=user_id,
|
||
path=stored_path,
|
||
kind=version_kind,
|
||
file_name=_safe_filename(source_path.name, f"{title}.docx"),
|
||
review=None,
|
||
document_patch={
|
||
"status": "failed",
|
||
"issue_counts": {"P0": 0, "P1": 0, "P2": 0},
|
||
"error": str(exc.detail),
|
||
},
|
||
)
|
||
await asyncio.to_thread(
|
||
_save_artifact_import_ref_sync,
|
||
user_id,
|
||
document_id,
|
||
thread_id=body.thread_id,
|
||
artifact_path=artifact_path,
|
||
)
|
||
return _document_response(document, await store.list_versions(document_id, user_id))
|
||
|
||
|
||
@router.post("/documents/upload", status_code=201)
|
||
async def upload_document(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
document_type: str = Form("auto"),
|
||
template_key: str | None = Form(None),
|
||
) -> dict[str, Any]:
|
||
file_name = _safe_filename(file.filename, "uploaded-document.docx")
|
||
if not file_name.lower().endswith(".docx"):
|
||
raise HTTPException(status_code=422, detail="当前仅支持可编辑的 DOCX 文档")
|
||
payload = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||
await file.close()
|
||
if len(payload) > _MAX_UPLOAD_BYTES:
|
||
raise HTTPException(status_code=413, detail="文件不能超过 25 MB")
|
||
if not payload.startswith(b"PK"):
|
||
raise HTTPException(status_code=422, detail="上传文件不是有效的 DOCX 文档")
|
||
user_id = await _user_id(request)
|
||
selected_template = await _resolve_template(request, template_key) if template_key else None
|
||
if document_type in {"", "auto"}:
|
||
document_type = _detect_document_type(file_name, payload)
|
||
if selected_template is not None:
|
||
document_type = str(selected_template["engineType"])
|
||
if document_type not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="不支持的公文文种")
|
||
document_id = f"gw_{uuid.uuid4().hex}"
|
||
title = _safe_title(Path(file_name).stem)
|
||
store = _store(request)
|
||
document = await store.create_document({
|
||
"id": document_id, "user_id": user_id, "title": title, "document_type": document_type,
|
||
"source": "upload", "status": "checking", "template_key": template_key or document_type,
|
||
})
|
||
directory = _document_dir(user_id, document_id)
|
||
original_path = directory / "original.docx"
|
||
await asyncio.to_thread(original_path.write_bytes, payload)
|
||
try:
|
||
raw_review = await _run_engine(["check", str(original_path), "-t", document_type, "--json"], state_dir=directory / ".engine")
|
||
review, counts = _review_snapshot(raw_review, document_type=document_type)
|
||
document, _ = await _append_file_version(
|
||
store, document_id=document_id, user_id=user_id, path=original_path, kind="original", file_name=file_name,
|
||
review=review, document_patch={"status": _review_status(counts), "issue_counts": counts, "error": None},
|
||
)
|
||
except HTTPException as exc:
|
||
document, _ = await _append_file_version(
|
||
store, document_id=document_id, user_id=user_id, path=original_path, kind="original", file_name=file_name,
|
||
review=None, document_patch={"status": "failed", "issue_counts": {"P0": 0, "P1": 0, "P2": 0}, "error": str(exc.detail)},
|
||
)
|
||
versions = await store.list_versions(document_id, user_id)
|
||
return _document_response(document, versions)
|
||
|
||
|
||
@router.post("/documents/generate", status_code=201)
|
||
async def generate_document(body: GenerateDocumentRequest, request: Request) -> dict[str, Any]:
|
||
selected_template_key = body.template_key or body.document_type
|
||
template = await _resolve_template(request, selected_template_key)
|
||
document_type = str(template["engineType"])
|
||
if document_type not in _TEMPLATE_BY_KEY:
|
||
raise HTTPException(status_code=422, detail="不支持的公文文种")
|
||
user_id = await _user_id(request)
|
||
document_id = f"gw_{uuid.uuid4().hex}"
|
||
title = _safe_title(body.title)
|
||
store = _store(request)
|
||
document = await store.create_document({
|
||
"id": document_id, "user_id": user_id, "title": title, "document_type": document_type,
|
||
"source": "generated", "status": "checking", "template_key": selected_template_key,
|
||
"research_session_id": body.research_session_id,
|
||
})
|
||
directory = _document_dir(user_id, document_id)
|
||
markdown_path = directory / "source.md"
|
||
await asyncio.to_thread(markdown_path.write_text, body.content, "utf-8")
|
||
await _append_file_version(
|
||
store, document_id=document_id, user_id=user_id, path=markdown_path, kind="markdown", file_name=f"{title}.md",
|
||
review=None, document_patch={"status": "checking"},
|
||
)
|
||
output_path = directory / "generated.docx"
|
||
try:
|
||
generation_result = await _run_engine(
|
||
["draft", str(markdown_path), "-o", str(output_path), "-t", document_type, "--verify", "--json"],
|
||
state_dir=directory / ".engine",
|
||
)
|
||
raw_review = await _reuse_verified_review(generation_result, output_path, document_type, directory / ".engine")
|
||
review, counts = _review_snapshot(raw_review, document_type=document_type)
|
||
# Official-issue elements are injected only after body validation, and
|
||
# the pre-injection review stays the review of record — the checker
|
||
# cannot see the red-head/imprint zones and would report false P0s.
|
||
for step in _element_injection_steps(body.elements, output_path):
|
||
await _run_engine(step, state_dir=directory / ".engine")
|
||
document, _ = await _append_file_version(
|
||
store, document_id=document_id, user_id=user_id, path=output_path, kind="generated", file_name=f"{title}.docx",
|
||
review=review, document_patch={"status": _review_status(counts), "issue_counts": counts, "error": None},
|
||
)
|
||
except HTTPException as exc:
|
||
document = await store.update_document(document_id, user_id, {"status": "failed", "error": str(exc.detail)}) or document
|
||
versions = await store.list_versions(document_id, user_id)
|
||
return _document_response(document, versions)
|
||
|
||
|
||
@router.get("/documents/{document_id}")
|
||
async def get_document(document_id: str, request: Request) -> dict[str, Any]:
|
||
user_id = await _user_id(request)
|
||
store = _store(request)
|
||
document = await store.get_document(document_id, user_id)
|
||
if document is None:
|
||
raise HTTPException(status_code=404, detail="文档不存在")
|
||
return _document_response(document, await store.list_versions(document_id, user_id))
|
||
|
||
|
||
@router.post("/documents/{document_id}/repair")
|
||
async def repair_document(document_id: str, body: RepairRequest, request: Request) -> dict[str, Any]:
|
||
# Selected-rule repair is deliberately not passed until the upstream engine
|
||
# exposes a stable restricted option. Never silently pretend a subset was
|
||
# repaired: deterministic format repair is all-or-nothing in this release.
|
||
if body.selected_rule_ids:
|
||
raise HTTPException(status_code=422, detail="首期仅支持一键修复全部可自动修复的格式问题")
|
||
user_id = await _user_id(request)
|
||
store = _store(request)
|
||
document = await store.get_document(document_id, user_id)
|
||
if document is None:
|
||
raise HTTPException(status_code=404, detail="文档不存在")
|
||
original_id = document.get("original_version_id")
|
||
if not original_id:
|
||
raise HTTPException(status_code=409, detail="仅上传校审的文档支持格式修复")
|
||
original = await store.get_version(document_id, str(original_id), user_id)
|
||
if original is None:
|
||
raise HTTPException(status_code=404, detail="原件不存在")
|
||
original_path = _relative_file_path(user_id, document_id, str(original["storage_key"]))
|
||
if not original_path.is_file():
|
||
raise HTTPException(status_code=404, detail="原件文件不存在")
|
||
directory = _document_dir(user_id, document_id)
|
||
repaired_path = directory / f"repaired-{uuid.uuid4().hex[:10]}.docx"
|
||
document_type = str(document.get("document_type") or "notice")
|
||
repair_result = await _run_engine(
|
||
["optimize", str(original_path), "-o", str(repaired_path), "-t", document_type, "--apply", "--verify", "--json"],
|
||
state_dir=directory / ".engine",
|
||
)
|
||
raw_review = await _reuse_verified_review(repair_result, repaired_path, document_type, directory / ".engine")
|
||
review, counts = _review_snapshot(raw_review, document_type=document_type)
|
||
stem = Path(str(original.get("file_name") or document.get("title") or "公文")).stem
|
||
document, _ = await _append_file_version(
|
||
store, document_id=document_id, user_id=user_id, path=repaired_path, kind="repaired", file_name=f"{stem}-修复版.docx",
|
||
review=review, document_patch={"status": "repaired", "issue_counts": counts, "error": None},
|
||
)
|
||
return _document_response(document, await store.list_versions(document_id, user_id))
|
||
|
||
|
||
@router.get("/documents/{document_id}/versions/{version_id}/preview")
|
||
async def preview_version(document_id: str, version_id: str, request: Request) -> dict[str, Any]:
|
||
user_id = await _user_id(request)
|
||
version = await _store(request).get_version(document_id, version_id, user_id)
|
||
if version is None:
|
||
raise HTTPException(status_code=404, detail="文档版本不存在")
|
||
path = _relative_file_path(user_id, document_id, str(version["storage_key"]))
|
||
if path.suffix.lower() != ".docx":
|
||
return {"paragraphs": [], "review": version.get("review_snapshot")}
|
||
return {"paragraphs": await asyncio.to_thread(_extract_docx_paragraphs, path), "review": version.get("review_snapshot")}
|
||
|
||
|
||
@router.get("/documents/{document_id}/versions/{version_id}/download")
|
||
async def download_version(document_id: str, version_id: str, request: Request) -> FileResponse:
|
||
user_id = await _user_id(request)
|
||
version = await _store(request).get_version(document_id, version_id, user_id)
|
||
if version is None:
|
||
raise HTTPException(status_code=404, detail="文档版本不存在")
|
||
path = _relative_file_path(user_id, document_id, str(version["storage_key"]))
|
||
if not path.is_file():
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
return FileResponse(path, media_type=str(version.get("content_type") or "application/octet-stream"), filename=str(version.get("file_name") or path.name))
|