Files

1341 lines
67 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))