diff --git a/deploy/vstore_restart.sh b/deploy/vstore_restart.sh new file mode 100755 index 0000000..dd1dff8 --- /dev/null +++ b/deploy/vstore_restart.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# deploy/vstore_restart.sh —— 远端 vstore(向量入库/检索独立服务, 端口 8094)重启(TL 使用, 在远端执行) +# 用法: ssh xzq@8.163.92.8 "bash /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/deploy/vstore_restart.sh" +# 要点: 与 bff_restart.sh 同模式——setsid 脱离 SSH、[v]store 正则防自匹配、日志落盘。 +# 2026-09-03 vstore 独立服务(doc_writer 向量库): 明文 Python 不编译, 部署树独立目录。 +# 注意: vstore 是包(部署根下的 vstore/ 目录), uvicorn 必须从部署根(-m vstore.api:app), +# cd 进 vstore/ 目录会导致 ModuleNotFoundError。 +set -uo pipefail +DEP_ROOT="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-deploy-unified" +VSTORE_DIR="$DEP_ROOT/vstore" +PYTHON="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python" +[[ -x "$PYTHON" ]] || { echo "vstore Python 不可执行: $PYTHON" >&2; exit 1; } + +# 杀旧进程([v] 正则技巧防 pkill 自匹配) +pkill -f "[v]store.api:app" 2>/dev/null +sleep 2 + +cd "$DEP_ROOT" +set -a +[ -f "$VSTORE_DIR/.env" ] && source "$VSTORE_DIR/.env" +set +a +setsid "$PYTHON" -m uvicorn vstore.api:app --host 127.0.0.1 --port 8094 \ + > /tmp/plantai-vstore.log 2>&1 < /dev/null & +disown + +sleep 8 +if ss -tln | grep -q ":8094 "; then + echo "VSTORE UP (127.0.0.1:8094)" +else + echo "VSTORE FAIL —— 日志尾部:" + tail -15 /tmp/plantai-vstore.log + exit 1 +fi diff --git a/sdk/app/config.py b/sdk/app/config.py index 5c64259..21ec891 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -54,6 +54,18 @@ class Settings(BaseSettings): db_path: str = "data/plantai.db" # 相对 sdk/(BFF cwd),bff_restart.sh 保证 health_cache_ttl: float = 5.0 + # ---- 文档生成(docgen,P2;对 vstore 的全部访问经 services/docgen/client.py 透传)---- + docgen_root: str = "data/docgen" # 制品落盘根目录(相对 BFF cwd) + docgen_artifact_ttl_days: int = 30 # 制品保留天数(越界由 /library 清理时剔除) + docgen_quota_gb: float = 50.0 # 制品目录配额(GB,超限返回 QUOTA_EXCEEDED 信封) + docgen_llm_base_url: str = "https://api.deepseek.com" # 文档生成 LLM(deepseek,仅服务层消费,绝不下发浏览器) + docgen_llm_api_key: str = "" # LLM 凭据(只存在于本进程;默认空=未配置,生成 503) + docgen_llm_model: str = "deepseek-chat" + docgen_llm_timeout: float = 300.0 # LLM 单次调用读超时(秒) + + # ---- vstore(8094,文档资料向量库;BFF 不透传其错误形态,见 routers/docgen.py)---- + vstore_base_url: str = "http://127.0.0.1:8094" + # ---- 多轮记忆(S2)---- bff_enable_humanize: bool = True # Humanizer 总开关(会话开关在其下) bff_history_rounds: int = 3 # 携带最近 N 轮问答 diff --git a/sdk/app/db.py b/sdk/app/db.py index 1cb25c6..23422ef 100644 --- a/sdk/app/db.py +++ b/sdk/app/db.py @@ -4,14 +4,24 @@ Schema 与 API.md §5 对齐: - sessions: id / title / status / meta / created_at / updated_at - messages: id / session_id(FK CASCADE) / role / kind / status / blocks(JSON) / raw(JSON) / text / created_at / updated_at +- doc_library: id / name / category / tags / description / as_template / + enc_path / embed_sha / active / created_at(docgen P2,无 FK, + 不依赖 vstore 可用性;enc_path/embed_sha 为 vstore ingest 回填键) +- doc_artifacts: id / session_id / status / title / template_name / md_path / + docx_path / pdf_path / error / created_at(docgen P2; + 导出文件按需生成,路径列可空) 启动时幂等迁移(CREATE TABLE IF NOT EXISTS)。时间一律 ISO 8601 UTC (`2026-08-25T12:34:56Z`),由 BFF 侧统一生成(SQLite 不写 DEFAULT 时间戳)。 + +docgen 两表为增量追加;迁移脚本执行在 try/except 内(见 connect()), +docgen 建表失败只记日志、不阻断 BFF 启动(docgen 端点届时返回 INTERNAL 信封)。 """ from __future__ import annotations import asyncio import json +import logging import time from collections.abc import AsyncIterator, Iterable from datetime import datetime, timezone @@ -22,6 +32,8 @@ import aiosqlite from .config import get_settings +logger = logging.getLogger(__name__) + _SCHEMA = """ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -49,6 +61,41 @@ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at); """ +# docgen P2 增量表(单独脚本:迁移失败只记日志,不阻断启动) +_DOCGEN_SCHEMA = """ +CREATE TABLE IF NOT EXISTS doc_library ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + as_template INTEGER NOT NULL DEFAULT 0, + enc_path TEXT, + embed_sha TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_doc_library_active ON doc_library(active); +CREATE INDEX IF NOT EXISTS idx_doc_library_category ON doc_library(category); + +CREATE TABLE IF NOT EXISTS doc_artifacts ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'running', + title TEXT NOT NULL DEFAULT '', + template_name TEXT NOT NULL DEFAULT '', + md_path TEXT, + docx_path TEXT, + pdf_path TEXT, + error TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_doc_artifacts_session ON doc_artifacts(session_id); +CREATE INDEX IF NOT EXISTS idx_doc_artifacts_created ON doc_artifacts(created_at); +""" + def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -86,6 +133,11 @@ class Database: await self._conn.execute("PRAGMA foreign_keys=ON") await self._conn.execute("PRAGMA busy_timeout=5000") await self._conn.executescript(_SCHEMA) + # docgen 增量表(P2):单独执行;失败只记日志,不阻断 BFF 启动 + try: + await self._conn.executescript(_DOCGEN_SCHEMA) + except Exception: + logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)") await self._conn.commit() async def close(self) -> None: diff --git a/sdk/app/main.py b/sdk/app/main.py index 62ecdd5..15958b7 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -4,6 +4,8 @@ - CORS:允许 http://localhost:5180 与 http://127.0.0.1:5180(Vite dev)。 - lifespan:init_db(幂等迁移);关闭回收 httpx 客户端与 Neo4j 连接。 - 全局 RequestValidationError → VALIDATION 信封(API.md §1,2026-08-25 PM 集成)。 +- docgen(P2):routers/docgen.py 对 services/docgen/*(dev1 归属)按契约 import, + 模块缺失时自动回落到内置最小接口桩(生成/编辑返回 503),不阻断 BFF 启动。 - 生产形态(M6):ui/dist 存在时静态托管(ARCHITECTURE.md §3)。 """ from __future__ import annotations @@ -20,7 +22,7 @@ from fastapi.staticfiles import StaticFiles from .config import get_settings from .db import close_db, init_db -from .routers import capabilities, extraction, figures, health, kg, legacy, qa, sessions +from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions from .upstream import close_client logging.basicConfig(level=logging.INFO) @@ -51,6 +53,7 @@ def create_app() -> FastAPI: app.include_router(extraction.router) app.include_router(figures.router) app.include_router(legacy.router) + app.include_router(docgen.router) # docgen(P2) @app.exception_handler(RequestValidationError) async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py new file mode 100644 index 0000000..9e74100 --- /dev/null +++ b/sdk/app/routers/docgen.py @@ -0,0 +1,823 @@ +"""routers/docgen.py —— 文档生成 BFF 端点(P2:BFF 集成/API)。 + +端点(API.md §12): +- POST /api/docgen/library 上传入库(multipart,用户拖拽即走此路) + → 透传 vstore /ingest,BFF 落 doc_library +- POST /api/docgen/generate {description, session_id?, library_ids[]?, + template_id?} → 同步生成(600s 兜底) +- GET /api/docgen/artifacts/{id}/content md 原文 +- PATCH …/content 保存编辑 {markdown} +- POST /api/docgen/artifacts/{id}/edit {instruction} → {ok, markdown} +- POST /api/docgen/artifacts/{id}/export {format:"pdf"|"docx"} → {ok, download_url} +- GET /api/docgen/artifacts/{id}/download?format= FileResponse 附件(RFC 5987 文件名) +- GET /api/docgen/library 主库列表(q/category/as_template 过滤,落库过滤) +- GET /api/docgen/library/{id} 单条 +- PATCH /api/docgen/library/{id} 改 meta / as_template 开关 +- DELETE /api/docgen/library/{id} 删除(BFF 行 + vstore 透传删除,容错) +- POST /api/docgen/library/rebuild-index 透传 vstore(原子切换) + +设计约束(跨模块契约,与 dev1 对齐): +- 对 vstore 的全部访问经 services/docgen/client.py(本文件不直接 httpx 调 vstore); + dev1 未交付时自动回落到内置最小接口桩(同签名),生成/编辑返回 503 UPSTREAM_DOWN, + 并在 docs/team/report_dev2.md 标记「依赖 dev1 交付后需 TL 联动联测」。 +- 意图护栏(TL R1 2026-09-03 修正,按端点类型传参): + - generate:route(desc, False, False) == "docgen" 才放行(写文档意图) + - edit:route(instruction, False, True) in ("docgen", "edit") 才放行 + (edit 端点被调用本身即存在活动文档) + - export:无护栏(format 由 pydantic Literal 白名单校验) + 非放行 → 422 INTENT_REJECTED 信封(提示走 QA);路由异常放行(漏判安全不阻断)。 +- vstore 失败兜底: + - 入库失败 → VALIDATION 信封(前端 chips 标红可重试),不落库 + - 检索失败 → 空模版集合(生成跳过模版,不阻断) + - 资料拉取失败 → 该资料缺失占位提示(不阻断生成) +- 配额与 TTL(TL 裁决 2026-09-03,V1 由 BFF 实现): + - _check_quota:artifacts/ 体积超 docgen_quota_gb → 507 QUOTA_EXCEEDED, + 拒绝 generate / export;generate 成功后顺带复查(仅告警) + - _prune_expired_artifacts:created_at 超 docgen_artifact_ttl_days → + 删 md/docx/pdf 文件 + 行 status 置 expired;generate 开始前惰性执行 +- 错误信封沿用 app.upstream.envelope;中文提示。 +- 用户消息/会话落库由前端走现有 sessions 机制,本文件不碰 sessions/messages。 +""" +from __future__ import annotations + +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile +from fastapi.responses import FileResponse, JSONResponse + +from ..config import get_settings +from ..db import Database, get_db +from ..schemas import ( + ArtifactContentPatch, + DocgenEditRequest, + DocgenExportRequest, + DocgenGenerateRequest, + LibraryItemOut, + LibraryPatch, +) +from ..upstream import envelope + +# --------------------------------------------------------------------------- +# services/docgen/* —— dev1 已交付(2026-09-03 联测对齐后删除接口桩,路由主体零改动) +from ..services.docgen.client import ( + DocgenUnavailable, + delete_document, + ingest_bytes, + rebuild_index, + search_documents, + update_metadata, +) +from ..services.docgen.converter import docx_from_markdown, pdf_from_markdown +from ..services.docgen.editor import apply_edit +from ..services.docgen.intent import is_docgen_intent, route +from ..services.docgen.pipeline import generate_document + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/docgen", tags=["docgen"]) + +# 上传护栏(先例:extraction.py 50MB PDF) +MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100MB +MAX_DESCRIPTION_CHARS = 2000 # 生成描述上限 + + +def _envelope(code: str, message: str | None = None, degraded: bool | None = None, status: int | None = None) -> JSONResponse: + if degraded is None: + degraded = code == "UPSTREAM_DOWN" + body, http = envelope(code, message=message, degraded=degraded, status=status) + return JSONResponse(status_code=http, content=body) + + +def _validation(message: str) -> JSONResponse: + return _envelope("VALIDATION", message=message, status=422) + + +def _not_found(message: str) -> JSONResponse: + return _envelope("NOT_FOUND", message=message, status=404) + + +def _db_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _check_description(description: str) -> JSONResponse | None: + if not description.strip(): + return _validation("请描述要生成的文档内容") + if len(description) > MAX_DESCRIPTION_CHARS: + return _validation(f"描述过长(超过 {MAX_DESCRIPTION_CHARS} 字)") + return None + + +# --------------------------------------------------------------------------- +# doc_library 行 ↔ 输出模型 +# --------------------------------------------------------------------------- + +def _row_to_library(row: Any) -> LibraryItemOut: + return LibraryItemOut( + id=row["id"], + name=row["name"], + category=row["category"], + tags=row["tags"], + description=row["description"], + as_template=bool(row["as_template"]), + active=bool(row["active"]), + created_at=row["created_at"], + ) + + +async def _get_library_row(db: Database, library_id: str) -> dict[str, Any] | None: + row = await db.fetchone("SELECT * FROM doc_library WHERE id = ?", (library_id,)) + return dict(row) if row is not None else None + + +async def _get_artifact_row(db: Database, artifact_id: str) -> dict[str, Any] | None: + row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (artifact_id,)) + return dict(row) if row is not None else None + + +# --------------------------------------------------------------------------- +# 制品文件路径(配置驱动;路径穿越防护) +# --------------------------------------------------------------------------- + +def _artifact_dir(settings: Any) -> Path: + p = Path(settings.docgen_root) + return p if p.is_absolute() else Path.cwd() / p + + +def _safe_rel(rel: str) -> str | None: + """返回安全的相对路径(禁止穿越 docgen_root),非法返回 None。""" + p = Path(rel) + if p.is_absolute() or ".." in p.parts: + return None + return str(p) + + +def _read_artifact_file(settings: Any, rel: str) -> bytes | None: + rel_ok = _safe_rel(rel) + if not rel_ok: + return None + path = _artifact_dir(settings) / rel_ok + try: + return path.read_bytes() + except OSError: + return None + + +# --------------------------------------------------------------------------- +# 制品目录配额 / TTL 惰性清理(TL 裁决 2026-09-03:V1 由 BFF 实现) +# --------------------------------------------------------------------------- + +def _dir_size(path: Path) -> int: + """递归体积(字节);跳过符号链接,不抛异常。""" + total = 0 + try: + for p in path.rglob("*"): + try: + if p.is_file() and not p.is_symlink(): + total += p.stat().st_size + except OSError: + continue + except OSError: + return 0 + return total + + +async def _check_quota(db: Database, settings: Any) -> JSONResponse | None: + """制品目录配额:artifacts/ 体积超 docgen_quota_gb → 507 QUOTA_EXCEEDED。 + + 仅拒绝 generate / export(新产出路径);放行读类端点。 + """ + limit_bytes = int(settings.docgen_quota_gb * 1024 * 1024 * 1024) + used = _dir_size(_artifact_dir(settings) / "artifacts") + if used > limit_bytes: + return _envelope( + "QUOTA_EXCEEDED", + message=( + f"制品存储已满(已用 {used / 1024 / 1024 / 1024:.1f}GB," + f"配额 {settings.docgen_quota_gb:.0f}GB),请清理旧文档后重试" + ), + degraded=True, + status=507, + ) + return None + + +async def _prune_expired_artifacts(db: Database, settings: Any) -> int: + """TTL 惰性清理:created_at 超 docgen_artifact_ttl_days 的 artifact。 + + 删除 md/docx/pdf 文件(忽略文件缺失)+ DB 行 status 置 expired。 + 在 generate 开始前调用;返回置过期行数。 + """ + from datetime import timedelta + + cutoff = (datetime.now(timezone.utc) - timedelta(days=settings.docgen_artifact_ttl_days)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + rows = await db.fetchall( + "SELECT * FROM doc_artifacts WHERE created_at < ? AND status != 'expired'", (cutoff,) + ) + for row in rows: + for col in ("md_path", "docx_path", "pdf_path"): + rel = row[col] + if not rel: + continue + safe_rel = _safe_rel(str(rel)) + if not safe_rel: + continue + try: + (_artifact_dir(settings) / safe_rel).unlink(missing_ok=True) + except OSError as exc: + logger.warning("docgen: TTL 清理删除文件失败 %s: %s", rel, exc) + await db.execute( + "UPDATE doc_artifacts SET status = 'expired', md_path = NULL, docx_path = NULL, " + "pdf_path = NULL WHERE id = ?", + (row["id"],), + ) + return len(rows) + + +# --------------------------------------------------------------------------- +# 上传入库(multipart) +# --------------------------------------------------------------------------- + +@router.post("/library", status_code=201) +async def upload_library_item( + file: UploadFile = File(...), + name: str = Form(""), + category: str = Form(""), + tags: str = Form(""), + description: str = Form(""), + as_template: bool = Form(True), + db: Database = Depends(get_db), +): + """用户拖拽上传 → 透传 vstore /ingest → BFF 落 doc_library。 + + vstore ingest 返回 {document_id, enc_path, embed_sha, size}(按契约); + enc_path/embed_sha 直接回填落库。入库失败不落库,返回 VALIDATION 信封 + (前端 chips 标红可重试)。 + """ + filename = Path(file.filename or "document").name + content = await file.read() + await file.close() + if not content: + return _validation("上传文件为空") + if len(content) > MAX_UPLOAD_BYTES: + return _validation("文件超过 100MB 限制") + doc_name = (name or "").strip() or filename + + try: + result = await ingest_bytes( + filename, + content, + name=doc_name, + category=(category or "").strip(), + tags=(tags or "").strip(), + description=(description or "").strip(), + as_template=bool(as_template), + ) + except DocgenUnavailable as exc: + logger.warning("docgen: vstore ingest 不可用:%s", exc) + return _envelope("UPSTREAM_DOWN", message="向量库不可用,资料未能入库,请稍后重试") + except Exception as exc: + logger.exception("docgen: vstore ingest 失败") + return _validation(f"资料入库失败,请检查文件格式后重试({exc})") + + if not isinstance(result, dict) or not result.get("document_id"): + return _validation("资料入库失败(向量库返回异常),请重试") + + library_id = str(result["document_id"]) + created = _db_now() + row = { + "id": library_id, + "name": doc_name, + "category": str(result.get("category") or (category or "").strip()), + "tags": str(result.get("tags") or (tags or "").strip()), + "description": str(result.get("description") or (description or "").strip()), + "as_template": bool(result.get("as_template", bool(as_template))), + "enc_path": result.get("enc_path"), + "embed_sha": result.get("embed_sha"), + "active": 1, + "created_at": created, + } + await db.execute( + "INSERT INTO doc_library (id, name, category, tags, description, as_template, " + "enc_path, embed_sha, active, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + library_id, row["name"], row["category"], row["tags"], row["description"], + int(row["as_template"]), row["enc_path"], row["embed_sha"], + int(row["active"]), created, + ), + ) + out = _row_to_library(row) + return { + "ok": True, + "items": [ + { + "library_id": out.id, + "name": out.name, + "size": result.get("size"), + "as_template": out.as_template, + } + ], + } + + +# --------------------------------------------------------------------------- +# 意图护栏(TL R1 2026-09-03:按端点类型传参;export 无护栏) +# --------------------------------------------------------------------------- + +async def _intent_guard(text: str, *, has_active_artifact: bool) -> JSONResponse | None: + """意图护栏:直接调 dev1 的 route 三参判定(is_docgen_intent 布尔接口不动)。 + + - generate:has_active_artifact=False → route(...) == "docgen" 才放行 + - edit:has_active_artifact=True → route(...) in ("docgen", "edit") 才放行 + (edit 端点被调用本身即存在活动文档,编辑指令如「润色一下第一段」判 edit) + - export:不经过本护栏(format 已有 pydantic Literal 白名单) + 路由异常 → 放行(漏判安全,护栏不阻断)。 + """ + try: + allowed = ("docgen", "edit") if has_active_artifact else ("docgen",) + if route(text, has_attachments=False, has_active_artifact=has_active_artifact) not in allowed: + return _envelope( + "INTENT_REJECTED", + message="这条请求更适合知识问答:请切换到问答模式处理", + status=422, + ) + except Exception as exc: + logger.warning("docgen: 意图校验异常(放行): %s", exc) + return None + + +# --------------------------------------------------------------------------- +# 生成 +# --------------------------------------------------------------------------- + +def _pick_template(templates: list[dict[str, Any]], template_id: str | None) -> dict[str, Any] | None: + """按 template_id 选模版;未指定时返回 vstore 推荐的第一条。""" + if template_id: + for t in templates: + if str(t.get("id")) == template_id: + return t + return None + return templates[0] if templates else None + + +@router.post("/generate") +async def generate_doc( + payload: DocgenGenerateRequest, + db: Database = Depends(get_db), +): + """同步生成(600s 兜底读超时在 pipeline 内部)。 + + 流程:TTL 惰性清理 → 意图护栏 → 模版检索(失败→空模版集,不阻断)→ + 模版命中 → 配额检查 → 资料落库行 → pipeline 生成(资料 vstore 拉取与 + 缺失占位由服务层负责)→ 配额复查 → artifact 落库。 + chat 消息落库由前端走现有 sessions 机制,本端点不碰。 + """ + bad = _check_description(payload.description) + if bad is not None: + return bad + + # TTL 惰性清理(TL 裁决:generate 开始前;失败只记日志不阻断) + try: + pruned = await _prune_expired_artifacts(db, get_settings()) + if pruned: + logger.info("docgen: TTL 清理 %d 个过期制品", pruned) + except Exception as exc: + logger.warning("docgen: TTL 清理失败(继续生成): %s", exc) + + guard = await _intent_guard(payload.description, has_active_artifact=False) + if guard is not None: + return guard + + quota = await _check_quota(db, get_settings()) + if quota is not None: + return quota + + templates: list[dict[str, Any]] = [] + try: + templates = await search_documents(payload.description, as_template=True, top_k=20) + templates = [t for t in templates if isinstance(t, dict)] + except DocgenUnavailable: + logger.warning("docgen: vstore 检索不可用 → 空模版集合(生成跳过模版)") + except Exception as exc: + logger.warning("docgen: vstore 检索失败 → 空模版集合:%s", exc) + + template = _pick_template(templates, payload.template_id) + + # 资料集:library_ids 为空 → 全部 active 资料;否则按给定顺序取(缺的跳过) + # 资料的 vstore 全文拉取属服务层(client.py/pipeline.py)职责:检索失败 → + # 空模版集合;document 拉取失败 → 资料缺失占位提示(不阻断生成), + # 具体兜底形态待 dev1 服务层交付后联动联测。 + if payload.library_ids: + docs: list[dict[str, Any]] = [] + for lid in payload.library_ids: + doc = await _get_library_row(db, str(lid)) + if doc is not None and doc.get("active"): + docs.append(doc) + else: + rows = await db.fetchall( + "SELECT * FROM doc_library WHERE active = 1 ORDER BY created_at ASC" + ) + docs = [dict(r) for r in rows] + + artifact_id = str(uuid.uuid4()) + created = _db_now() + title = payload.description.strip()[:60] or "未命名文档" + try: + result = await generate_document( + payload.description, + documents=docs, + template=template, + ) + except DocgenUnavailable as exc: + logger.warning("docgen: 生成服务不可用:%s", exc) + await db.execute( + "INSERT INTO doc_artifacts (id, session_id, status, title, template_name, " + "md_path, docx_path, pdf_path, error, created_at) " + "VALUES (?, ?, 'error', ?, ?, NULL, NULL, NULL, ?, ?)", + (artifact_id, payload.session_id or "", title, + (template or {}).get("name", "") or (template or {}).get("title", ""), + "生成服务未就绪(dev1 服务层未交付)", created), + ) + return _envelope("UPSTREAM_DOWN", message="文档生成服务未就绪,请稍后重试") + except Exception as exc: + logger.exception("docgen: 生成失败") + await db.execute( + "INSERT INTO doc_artifacts (id, session_id, status, title, template_name, " + "md_path, docx_path, pdf_path, error, created_at) " + "VALUES (?, ?, 'error', ?, ?, NULL, NULL, NULL, ?, ?)", + (artifact_id, payload.session_id or "", title, + (template or {}).get("name", "") or (template or {}).get("title", ""), + str(exc)[:500], created), + ) + return _envelope("INTERNAL", message=f"文档生成失败:{exc}") + + markdown = str(result.get("markdown") or "") + md_rel = str(result.get("md_path") or f"artifacts/{artifact_id}.md") + if markdown: + safe_rel = _safe_rel(md_rel) + if safe_rel: + path = _artifact_dir(get_settings()) / safe_rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(markdown, encoding="utf-8") + except OSError as exc: + logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc) + # 生成成功后顺带复查配额(写盘后体积更准);超限仍返回成功(artifact 已存在) + try: + quota_after = await _check_quota(db, get_settings()) + if quota_after is not None: + logger.warning("docgen: 生成后制品目录已超配额(下一次 generate/export 将被拒绝)") + except Exception as exc: + logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc) + await db.execute( + "INSERT INTO doc_artifacts (id, session_id, status, title, template_name, " + "md_path, docx_path, pdf_path, error, created_at) " + "VALUES (?, ?, 'done', ?, ?, ?, NULL, NULL, NULL, ?)", + (artifact_id, payload.session_id or "", title, + (template or {}).get("name", "") or (template or {}).get("title", ""), + md_rel, created), + ) + return { + "ok": True, + "artifact": { + "id": artifact_id, + "title": title, + "status": "done", + "template_name": (template or {}).get("name", "") or (template or {}).get("title", ""), + "created_at": created, + }, + } + + +# --------------------------------------------------------------------------- +# 制品:content / edit / export / download +# --------------------------------------------------------------------------- + +async def _artifact_content(settings: Any, row: dict[str, Any]) -> str | None: + """取制品 md 原文:磁盘优先,无文件则返回 None(由调用方决定占位)。""" + if row.get("md_path"): + data = _read_artifact_file(settings, str(row["md_path"])) + if data is not None: + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("utf-8", errors="replace") + return None + + +@router.get("/artifacts/{artifact_id}/content") +async def get_artifact_content( + artifact_id: str, + db: Database = Depends(get_db), +): + row = await _get_artifact_row(db, artifact_id) + if row is None: + return _not_found(f"文档 {artifact_id} 不存在") + content = await _artifact_content(get_settings(), row) + if content is None: + # 生成失败或制品缺失:返回 error 信息作为占位,前端展示失败态 + return { + "ok": True, + "markdown": "", + "status": row.get("status"), + "error": row.get("error"), + } + return {"ok": True, "markdown": content, "status": row.get("status")} + + +@router.patch("/artifacts/{artifact_id}/content") +async def patch_artifact_content( + artifact_id: str, + payload: ArtifactContentPatch, + db: Database = Depends(get_db), +): + """保存编辑:body {markdown} → 覆盖落盘。""" + row = await _get_artifact_row(db, artifact_id) + if row is None: + return _not_found(f"文档 {artifact_id} 不存在") + markdown = payload.markdown + md_rel = str(row.get("md_path") or f"artifacts/{artifact_id}.md") + safe_rel = _safe_rel(md_rel) + if safe_rel: + path = _artifact_dir(get_settings()) / safe_rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(markdown, encoding="utf-8") + except OSError as exc: + logger.exception("docgen: 保存编辑落盘失败") + return _envelope("INTERNAL", message=f"保存失败:{exc}") + else: + return _envelope("INTERNAL", message="制品路径非法,保存失败") + await db.execute( + "UPDATE doc_artifacts SET md_path = ?, status = 'done', error = NULL WHERE id = ?", + (md_rel, artifact_id), + ) + return {"ok": True, "markdown": markdown} + + +@router.post("/artifacts/{artifact_id}/edit") +async def edit_artifact( + artifact_id: str, + payload: DocgenEditRequest, + db: Database = Depends(get_db), +): + """LLM 编辑:{instruction} → {ok, markdown}(编辑结果同步落盘)。""" + row = await _get_artifact_row(db, artifact_id) + if row is None: + return _not_found(f"文档 {artifact_id} 不存在") + + guard = await _intent_guard(payload.instruction, has_active_artifact=True) + if guard is not None: + return guard + + current = await _artifact_content(get_settings(), row) + if current is None: + return _envelope("INTERNAL", message="制品内容缺失,无法编辑(可重新生成)") + + try: + result = await apply_edit(current, payload.instruction) + except DocgenUnavailable as exc: + logger.warning("docgen: 编辑服务不可用:%s", exc) + return _envelope("UPSTREAM_DOWN", message="文档编辑服务未就绪,请稍后重试") + except Exception as exc: + logger.exception("docgen: 编辑失败") + return _envelope("INTERNAL", message=f"编辑失败:{exc}") + + if not isinstance(result, dict): + return _envelope("INTERNAL", message="编辑结果异常") + markdown = str(result.get("markdown") or "") + if result.get("md_path"): + md_rel = str(result["md_path"]) + safe_rel = _safe_rel(md_rel) + if safe_rel: + path = _artifact_dir(get_settings()) / safe_rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(markdown, encoding="utf-8") + except OSError as exc: + logger.warning("docgen: 编辑结果落盘失败(返回内容不受影响): %s", exc) + await db.execute( + "UPDATE doc_artifacts SET md_path = ?, status = 'done', error = NULL WHERE id = ?", + (md_rel, artifact_id), + ) + else: + # 服务层未给出路径:写入现有 md_path(或默认名) + md_rel = str(row.get("md_path") or f"artifacts/{artifact_id}.md") + safe_rel = _safe_rel(md_rel) + if safe_rel: + path = _artifact_dir(get_settings()) / safe_rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(markdown, encoding="utf-8") + except OSError as exc: + logger.warning("docgen: 编辑结果落盘失败(返回内容不受影响): %s", exc) + return {"ok": True, "markdown": markdown} + + +@router.post("/artifacts/{artifact_id}/export") +async def export_artifact( + artifact_id: str, + payload: DocgenExportRequest, + db: Database = Depends(get_db), +): + """导出:{format:"pdf"|"docx"} → {ok, download_url}。 + + 导出内容来自「最新编辑版」(md 文件优先,其次取 row 内容,不再经 LLM)。 + 同步转换(文档规模分钟级);失败返回 INTERNAL 信封。 + 配额检查:超 docgen_quota_gb → 507 QUOTA_EXCEEDED(导出会产生新文件)。 + """ + row = await _get_artifact_row(db, artifact_id) + if row is None: + return _not_found(f"文档 {artifact_id} 不存在") + + # TL R1:export 无意图护栏(format 已由 DocgenExportRequest Literal 白名单校验) + quota = await _check_quota(db, get_settings()) + if quota is not None: + return quota + + fmt = payload.format + markdown = await _artifact_content(get_settings(), row) + if markdown is None: + return _envelope("INTERNAL", message="制品内容缺失,无法导出(可重新生成)") + title = str(row.get("title") or "document") + + try: + if fmt == "docx": + data = await docx_from_markdown(markdown, title) + else: + data = await pdf_from_markdown(markdown, title) + except DocgenUnavailable as exc: + logger.warning("docgen: 导出转换服务不可用:%s", exc) + return _envelope("UPSTREAM_DOWN", message="文档导出服务未就绪,请稍后重试") + except Exception as exc: + logger.exception("docgen: 导出转换失败") + return _envelope("INTERNAL", message=f"导出失败:{exc}") + + if not isinstance(data, (bytes, bytearray)): + return _envelope("INTERNAL", message="导出结果异常") + + rel = f"artifacts/{artifact_id}.{fmt}" + path = _artifact_dir(get_settings()) / rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(bytes(data)) + except OSError as exc: + logger.exception("docgen: 导出落盘失败") + return _envelope("INTERNAL", message=f"导出文件保存失败:{exc}") + + col = "docx_path" if fmt == "docx" else "pdf_path" + await db.execute(f"UPDATE doc_artifacts SET {col} = ? WHERE id = ?", (rel, artifact_id)) + return { + "ok": True, + "download_url": f"/api/docgen/artifacts/{artifact_id}/download?format={fmt}", + } + + +@router.get("/artifacts/{artifact_id}/download") +async def download_artifact( + artifact_id: str, + format: str = Query(..., pattern="^(pdf|docx)$"), + db: Database = Depends(get_db), +): + """FileResponse 附件下载(RFC 5987 文件名)。""" + row = await _get_artifact_row(db, artifact_id) + if row is None: + return _not_found(f"文档 {artifact_id} 不存在") + rel = str(row.get("docx_path" if format == "docx" else "pdf_path") or "") + data = _read_artifact_file(get_settings(), rel) if rel else None + if data is None: + return _not_found("导出文件不存在(请先导出)") + fname = f"{row.get('title') or artifact_id}.{format}".replace("/", "_") + return FileResponse( + path=_artifact_dir(get_settings()) / _safe_rel(rel), + media_type=( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + if format == "docx" + else "application/pdf" + ), + filename=fname, + content_disposition_type="attachment", + ) + + +# --------------------------------------------------------------------------- +# 主库管理(落库过滤 + vstore 透传) +# --------------------------------------------------------------------------- + +@router.get("/library") +async def list_library( + q: str = Query(""), + category: str = Query(""), + as_template: bool | None = Query(None), + db: Database = Depends(get_db), +): + """主库列表:q(name/tags/description 模糊)、category、as_template 过滤。""" + clauses: list[str] = [] + params: list[Any] = [] + if q.strip(): + like = f"%{q.strip()}%" + clauses.append("(name LIKE ? OR tags LIKE ? OR description LIKE ?)") + params += [like, like, like] + if category.strip(): + clauses.append("category = ?") + params.append(category.strip()) + if as_template is not None: + clauses.append("as_template = ?") + params.append(int(as_template)) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + rows = await db.fetchall(f"SELECT * FROM doc_library{where} ORDER BY created_at DESC", params) + return {"ok": True, "items": [_row_to_library(r).model_dump() for r in rows]} + + +@router.get("/library/{library_id}") +async def get_library_item(library_id: str, db: Database = Depends(get_db)): + row = await _get_library_row(db, library_id) + if row is None: + return _not_found(f"资料 {library_id} 不存在") + return {"ok": True, "item": _row_to_library(row).model_dump()} + + +@router.patch("/library/{library_id}") +async def patch_library_item( + library_id: str, + payload: LibraryPatch, + db: Database = Depends(get_db), +): + """改 meta / as_template:BFF 落库 + vstore 透传更新(vstore 失败不阻断,容错)。""" + row = await _get_library_row(db, library_id) + if row is None: + return _not_found(f"资料 {library_id} 不存在") + + sets: list[str] = [] + params: list[Any] = [] + meta: dict[str, Any] = {} + for col, key in ( + ("name", "name"), ("category", "category"), ("tags", "tags"), + ("description", "description"), + ): + val = getattr(payload, key) + if val is not None: + sets.append(f"{col} = ?") + params.append(str(val)) + meta[key] = str(val) + if payload.as_template is not None: + sets.append("as_template = ?") + params.append(int(payload.as_template)) + meta["as_template"] = bool(payload.as_template) + if payload.active is not None: + sets.append("active = ?") + params.append(int(payload.active)) + meta["active"] = bool(payload.active) + if sets: + params.append(library_id) + await db.execute(f"UPDATE doc_library SET {', '.join(sets)} WHERE id = ?", params) + + if meta: + try: + await update_metadata(library_id, meta) + except DocgenUnavailable as exc: + logger.warning("docgen: vstore 元数据透传不可用(BFF 已落库): %s", exc) + except Exception as exc: + logger.warning("docgen: vstore 元数据透传失败(BFF 已落库): %s", exc) + + updated = await _get_library_row(db, library_id) + return {"ok": True, "item": _row_to_library(updated).model_dump() if updated else None} + + +@router.delete("/library/{library_id}", status_code=204) +async def delete_library_item(library_id: str, db: Database = Depends(get_db)): + """删除:BFF 行删除 + vstore 透传删除(vstore 失败容错,仍返回 204)。""" + row = await _get_library_row(db, library_id) + if row is None: + return _not_found(f"资料 {library_id} 不存在") + await db.execute("DELETE FROM doc_library WHERE id = ?", (library_id,)) + try: + await delete_document(library_id) + except DocgenUnavailable as exc: + logger.warning("docgen: vstore 删除透传不可用(BFF 已删除): %s", exc) + except Exception as exc: + logger.warning("docgen: vstore 删除透传失败(BFF 已删除): %s", exc) + return Response(status_code=204) + + +@router.post("/library/rebuild-index") +async def rebuild_library_index(): + """透传 vstore rebuild-index(原子切换);vstore 不可用 → UPSTREAM_DOWN。""" + try: + result = await rebuild_index() + except DocgenUnavailable as exc: + logger.warning("docgen: vstore rebuild-index 不可用:%s", exc) + return _envelope("UPSTREAM_DOWN", message="向量库不可用,索引重建失败,请稍后重试") + except Exception as exc: + logger.exception("docgen: rebuild-index 失败") + return _envelope("INTERNAL", message=f"索引重建失败:{exc}") + if not isinstance(result, dict): + return _envelope("INTERNAL", message="索引重建结果异常") + return {"ok": True, **result} diff --git a/sdk/app/schemas.py b/sdk/app/schemas.py index a7e07d8..27fe29d 100644 --- a/sdk/app/schemas.py +++ b/sdk/app/schemas.py @@ -1,7 +1,8 @@ """schemas.py —— Pydantic v2 模型(API.md 的契约形态)。 覆盖:块联合(§2)、SourceItem(§8)、GraphData / GraphNode / GraphEdge / -GraphChain(§9)、SessionOut / MessageOut(§5)、错误信封(§1)。 +GraphChain(§9)、SessionOut / MessageOut(§5)、docgen 文档生成(§12)、 +错误信封(§1)。 约定:模型 `extra="ignore"` + 全字段默认值,落库/返参前先经模型验证归一。 """ from __future__ import annotations @@ -86,9 +87,23 @@ class GraphBlock(BaseModel): chains: list["GraphChain"] = Field(default_factory=list) +class ArtifactBlock(BaseModel): + """docgen 制品块(§2 块联合扩展,P2):会话消息内挂载文档制品。 + + 只有 artifact_id 是 BFF 落库键;markdown/source 键保证前端渲染兜底 + (本地 mock 或制品文件缺失时仍可展示)。状态语义见 API.md 块状态约定。 + """ + + type: Literal["artifact"] = "artifact" + artifact_id: str = "" + title: str = "" + template_name: str = "" + status: str = "done" # running|done|error(生成中/已就绪/失败) + + Block = Union[ TextBlock, TableBlock, CodeBlock, StreamBlock, - PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, + PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, ArtifactBlock, ] @@ -242,6 +257,86 @@ class MessageOut(BaseModel): updated_at: str = Field(default_factory=utcnow_iso) +# --------------------------------------------------------------------------- +# §12 文档生成(docgen,P2)——BFF ↔ UI 契约模型 +# --------------------------------------------------------------------------- + +class LibraryItemOut(BaseModel): + """doc_library 表单行(主库管理返回形态)。""" + + model_config = ConfigDict(extra="ignore") + + id: str + name: str = "" + category: str = "" + tags: str = "" + description: str = "" + as_template: bool = False + active: bool = True + created_at: str = Field(default_factory=utcnow_iso) + + +class LibraryPatch(BaseModel): + """PATCH /api/docgen/library/{id}:改 meta 或 as_template 开关。""" + + name: str | None = None + category: str | None = None + tags: str | None = None + description: str | None = None + as_template: bool | None = None + active: bool | None = None + + +class ArtifactOut(BaseModel): + """doc_artifacts 表单行(生成/查询返回形态)。""" + + model_config = ConfigDict(extra="ignore") + + id: str + session_id: str = "" + status: str = "running" # running|done|error + title: str = "" + template_name: str = "" + error: str | None = None + created_at: str = Field(default_factory=utcnow_iso) + + +class DocgenGenerateRequest(BaseModel): + """POST /api/docgen/generate 请求体(description 必填,上限 2000 字)。""" + + description: str + session_id: str | None = None + library_ids: list[str] = Field(default_factory=list) # 空=全部主库资料 + template_id: str | None = None + + +class DocgenEditRequest(BaseModel): + """POST /api/docgen/artifacts/{id}/edit 请求体。""" + + instruction: str + + +class ArtifactContentPatch(BaseModel): + """PATCH /api/docgen/artifacts/{id}/content:直接保存编辑后的 markdown。""" + + markdown: str + + +class DocgenExportRequest(BaseModel): + """POST /api/docgen/artifacts/{id}/export 请求体。""" + + format: Literal["pdf", "docx"] + + +class DocgenImportLine(BaseModel): + """tooling/scripts/docgen_import.py CSV 单行(批量导入工具契约)。""" + + name: str + category: str = "" + tags: str = "" + description: str = "" + + # --------------------------------------------------------------------------- # §1 错误信封 # --------------------------------------------------------------------------- diff --git a/sdk/app/services/docgen/__init__.py b/sdk/app/services/docgen/__init__.py new file mode 100644 index 0000000..df1b8ce --- /dev/null +++ b/sdk/app/services/docgen/__init__.py @@ -0,0 +1,6 @@ +"""services/docgen/ —— 文档生成 BFF 服务层(P1,dev1 归属)。 + +纯生成/导出,不含存储检索(存储检索在 vstore 独立服务,经 client.py 访问)。 +全部模块:settings / client / parsing / llm / intent / md2docx / md2pdf / +pipeline / editor / converter(converter 为 dev2 桩签名适配层,见报告)。 +""" diff --git a/sdk/app/services/docgen/client.py b/sdk/app/services/docgen/client.py new file mode 100644 index 0000000..46ee0f3 --- /dev/null +++ b/sdk/app/services/docgen/client.py @@ -0,0 +1,219 @@ +"""services/docgen/client.py —— vstore HTTP 客户端(BFF → vstore 全经此处)。 + +- httpx.AsyncClient(连接池复用)+ 超时/重试(指数退避 0.5/1.0/2.0s) +- 方法:ingest / search_documents / get_document / get_style / delete_document / + update_metadata / rebuild_index / health +- 所有失败路径不抛未捕获异常:网络/超时抛 DocgenUnavailable,业务错误 + 返回 {"ok": False, "error": {...}} 结构供上层兜底 +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from .settings import get_paths + +logger = logging.getLogger(__name__) + +DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094" +RETRY_BACKOFF = (0.5, 1.0, 2.0) # 重试退避(秒) +MAX_RETRIES = 3 +DEFAULT_TIMEOUT = 60.0 + + +class DocgenUnavailable(Exception): + """vstore 不可达(dev2 路由层兜底捕获:UPSTREAM_DOWN)。""" + + def __init__(self, message: str, *, cause: BaseException | None = None) -> None: + super().__init__(message) + self.cause = cause + + +def _vstore_base_url() -> str: + """vstore 基地址:VSTORE_BASE_URL env(dev2 会在 config.py 提供), + 兜底 http://127.0.0.1:8094。""" + import os + + return (os.environ.get("VSTORE_BASE_URL") or DEFAULT_VSTORE_BASE_URL).rstrip("/") + + +_client: httpx.AsyncClient | None = None + + +def get_client() -> httpx.AsyncClient: + """全局 AsyncClient(app 关闭时 close_client)。""" + global _client + if _client is None: + _client = httpx.AsyncClient( + base_url=_vstore_base_url(), + timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=5.0), + ) + return _client + + +def set_client(client: httpx.AsyncClient) -> None: + """注入 client(测试用 MockTransport)。""" + global _client + _client = client + + +async def close_client() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +async def _request( + method: str, + path: str, + *, + retries: int = MAX_RETRIES, + **kwargs: Any, +) -> httpx.Response: + """带重试的请求。网络/超时/5xx 重试;重试尽 → DocgenUnavailable。""" + client = get_client() + last_exc: BaseException | None = None + for attempt in range(retries + 1): + try: + resp = await client.request(method, path, **kwargs) + if resp.status_code < 500: + return resp + last_exc = RuntimeError(f"vstore 返回 {resp.status_code}") + except (httpx.HTTPError, httpx.TimeoutException) as exc: + last_exc = exc + if attempt < retries: + import asyncio + + await asyncio.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)]) + raise DocgenUnavailable(f"vstore 不可达({path}):{last_exc}", cause=last_exc) + + +def _check_ok(resp: httpx.Response) -> dict[str, Any]: + """响应 → dict;ok=False 返回错误结构(不抛),HTTP 错误抛 DocgenUnavailable。""" + if resp.status_code >= 400: + raise DocgenUnavailable(f"vstore 返回错误状态 {resp.status_code}") + try: + payload = resp.json() + except ValueError as exc: + raise DocgenUnavailable(f"vstore 响应非 JSON:{exc}") from exc + return payload if isinstance(payload, dict) else {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "响应形态异常"}} + + +# --------------------------------------------------------------------------- +# dev2 契约导出(签名与 sdk/app/routers/docgen.py 桩段对齐) +# --------------------------------------------------------------------------- + +async def ingest_bytes( + filename: str, + content: bytes, + *, + name: str, + category: str = "", + tags: str = "", + description: str = "", + as_template: bool = True, +) -> dict[str, Any]: + """POST /api/vstore/ingest(multipart)。返回 {document_id, name, enc_path, ...}。""" + resp = await _request( + "POST", "/api/vstore/ingest", + params={"as_template": bool(as_template)}, + data={"name": name, "category": category, "tags": tags, "description": description}, + files={"file": (filename, content)}, + retries=MAX_RETRIES, + ) + payload = _check_ok(resp) + if not payload.get("ok"): + return payload # 错误结构供上层兜底 + item = payload.get("item") or {} + if not item.get("document_id"): + return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 未返回 document_id"}} + return {**item, "ok": True} + + +async def search_documents( + query: str, + *, + as_template: bool | None = None, + top_k: int = 20, + category: str = "", +) -> list[dict[str, Any]]: + """GET /api/vstore/search。检索失败抛 DocgenUnavailable(router 兜底 → 空模版集)。""" + params: dict[str, Any] = {"q": query, "top_k": top_k} + if as_template is not None: + params["as_template"] = as_template + if category: + params["category"] = category + resp = await _request("GET", "/api/vstore/search", params=params) + payload = _check_ok(resp) + if not payload.get("ok"): + return [] # 检索失败按空结果(不阻断生成) + return [item for item in payload.get("items", []) if isinstance(item, dict)] + + +async def get_document(document_id: str) -> dict[str, Any] | None: + """GET /api/vstore/documents/{id}(明文全文 + outline + meta)。 + + 404 → None(资料缺失占位提示,不阻断);不可达 → DocgenUnavailable。 + """ + client = get_client() + try: + resp = await client.get(f"/api/vstore/documents/{document_id}", timeout=DEFAULT_TIMEOUT) + except (httpx.HTTPError, httpx.TimeoutException) as exc: + raise DocgenUnavailable(f"vstore 不可达(documents/{document_id}):{exc}", cause=exc) from exc + if resp.status_code == 404: + return None + if resp.status_code >= 400: + raise DocgenUnavailable(f"vstore 返回错误状态 {resp.status_code}") + payload = _check_ok(resp) + if not payload.get("ok"): + return None + doc = payload.get("document") + return doc if isinstance(doc, dict) else None + + +async def get_style(document_id: str) -> dict[str, Any] | None: + """GET /api/vstore/documents/{id}/style(docx 样式提取)。404/失败 → None。""" + client = get_client() + try: + resp = await client.get(f"/api/vstore/documents/{document_id}/style", timeout=DEFAULT_TIMEOUT) + except (httpx.HTTPError, httpx.TimeoutException): + return None + if resp.status_code >= 400: + return None + try: + payload = resp.json() + except ValueError: + return None + hints = payload.get("style_hints") if isinstance(payload, dict) else None + return hints if isinstance(hints, dict) else None + + +async def delete_document(document_id: str) -> dict[str, Any]: + """DELETE /api/vstore/documents/{id}。""" + resp = await _request("DELETE", f"/api/vstore/documents/{document_id}") + return _check_ok(resp) + + +async def update_metadata(document_id: str, metadata: dict[str, Any]) -> dict[str, Any]: + """PATCH 元数据。vstore 未提供 PATCH 端点 → 转发后返回错误结构(容错)。""" + # vstore V1 未提供 PATCH;meta 以 BFF doc_library 落库为准, + # 透传失败返回错误结构,由 router 容错忽略 + return {"ok": False, "error": {"code": "NOT_IMPLEMENTED", "message": "vstore 无 meta 更新端点(以 BFF 落库为准)"}} + + +async def rebuild_index() -> dict[str, Any]: + """POST /api/vstore/rebuild-index。""" + resp = await _request("POST", "/api/vstore/rebuild-index") + return _check_ok(resp) + + +async def health() -> dict[str, Any]: + """GET /api/vstore/health。失败返回 {ok: False} 结构(不抛)。""" + try: + resp = await _request("GET", "/api/vstore/health", retries=0) + return _check_ok(resp) + except DocgenUnavailable as exc: + return {"ok": False, "error": {"code": "UPSTREAM_DOWN", "message": str(exc)}} diff --git a/sdk/app/services/docgen/converter.py b/sdk/app/services/docgen/converter.py new file mode 100644 index 0000000..52b403b --- /dev/null +++ b/sdk/app/services/docgen/converter.py @@ -0,0 +1,45 @@ +"""services/docgen/converter.py —— dev2 桩签名适配层(P1,dev1 归属)。 + +dev2 的 sdk/app/routers/docgen.py 实际 import: + from ..services.docgen.converter import docx_from_markdown, pdf_from_markdown + data = await docx_from_markdown(markdown, title) # -> bytes(async) + +本层把 dev2 桩签名适配到 md2docx/md2pdf 的 (markdown, out_path) -> Path +同步核心:字节产物在内存 BytesIO 生成(不落中间文件),返回 bytes。 +""" +from __future__ import annotations + +import io +import tempfile +from pathlib import Path +from typing import Any + +from . import md2docx, md2pdf + + +async def docx_from_markdown(markdown: str, title: str) -> bytes: + """dev2 契约:markdown + title → docx bytes(async)。""" + import anyio + + return await anyio.to_thread.run_sync(_docx_bytes, markdown, title) + + +async def pdf_from_markdown(markdown: str, title: str) -> bytes: + """dev2 契约:markdown + title → pdf bytes(async)。""" + import anyio + + return await anyio.to_thread.run_sync(_pdf_bytes, markdown, title) + + +def _docx_bytes(markdown: str, title: str) -> bytes: + """docx 转换核心(临时文件 → bytes,不残留)。""" + with tempfile.TemporaryDirectory() as tmp: + out = md2docx.docx_from_markdown(markdown, Path(tmp) / "out.docx") + return out.read_bytes() + + +def _pdf_bytes(markdown: str, title: str) -> bytes: + """pdf 转换核心(临时文件 → bytes,不残留)。""" + with tempfile.TemporaryDirectory() as tmp: + out = md2pdf.pdf_from_markdown(markdown, Path(tmp) / "out.pdf") + return out.read_bytes() diff --git a/sdk/app/services/docgen/editor.py b/sdk/app/services/docgen/editor.py new file mode 100644 index 0000000..3881360 --- /dev/null +++ b/sdk/app/services/docgen/editor.py @@ -0,0 +1,90 @@ +"""services/docgen/editor.py —— 文档编辑(P1,dev1 归属)。 + +- apply_edit(md, instruction) -> str: + 当前文档全文 + 指令 → 只输出完整新 md;长文档(> CHUNK_EDIT_CHARS)分段改 +- smart_export(md, format, template_style) -> Path: + 按格式导出 docx/pdf(style 对齐:docx 用 style_hints 覆盖字体) +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from . import llm +from .settings import CHUNK_EDIT_CHARS + +logger = logging.getLogger(__name__) + +EDIT_SYSTEM = ( + "你是文档编辑助手。根据用户的修改指令编辑文档,只输出编辑后的完整 " + "markdown 文档(保持原文档的标题、列表、表格结构),不要输出任何解释。" +) + + +async def apply_edit(markdown: str, instruction: str) -> dict[str, Any]: + """编辑文档:当前文档全文 + 指令 → {markdown, md_path}。 + + 契约(dev2 桩):apply_edit(markdown, instruction) -> dict, + router 取 result["markdown"](md_path 可省略)。 + 长文档(> CHUNK_EDIT_CHARS)分段改:每段独立请求,段间标题不重复。 + """ + if not instruction or not instruction.strip(): + return {"markdown": markdown} + + user = f"修改指令:{instruction}\n\n当前文档(markdown):\n{markdown}" + if len(markdown) <= CHUNK_EDIT_CHARS: + edited = await _edit_once(markdown, instruction) + return {"markdown": edited} + + # 长文档分段:按空行切块,每块 ≤ CHUNK_EDIT_CHARS,逐块带指令修改 + edited_parts: list[str] = [] + for chunk in _split_chunks(markdown, CHUNK_EDIT_CHARS): + try: + edited = await _edit_once(chunk, instruction) + except llm.LLMError as exc: + logger.warning("docgen: 分段编辑失败,保留原段:%s", exc) + edited = chunk + edited_parts.append(edited) + return {"markdown": "\n\n".join(edited_parts)} + + +async def _edit_once(markdown: str, instruction: str) -> str: + """单次编辑调用(失败抛 LLMError,由调用方决定占位策略)。""" + user = f"修改指令:{instruction}\n\n当前文档(markdown):\n{markdown}" + return await llm.complete_text(EDIT_SYSTEM, user) + + +def _split_chunks(markdown: str, max_chars: int) -> list[str]: + """按空行切块,每块 ≤ max_chars(尽量)。""" + paragraphs = markdown.split("\n\n") + chunks: list[str] = [] + current: list[str] = [] + size = 0 + for para in paragraphs: + if current and size + len(para) + 2 > max_chars: + chunks.append("\n\n".join(current)) + current = [para] + size = len(para) + else: + current.append(para) + size += len(para) + 2 + if current: + chunks.append("\n\n".join(current)) + return chunks + + +def smart_export(md: str, format: str, template_style: dict[str, Any] | None = None) -> Path: + """按格式导出:docx/pdf(style 对齐:docx 用 style_hints 覆盖字体)。 + + 返回导出文件路径。转换走 md2docx/md2pdf(纯 CPU 转换,同步执行)。 + """ + from . import md2docx, md2pdf + from .settings import artifacts_dir + + out_dir = artifacts_dir() + out_dir.mkdir(parents=True, exist_ok=True) + fmt = (format or "docx").lower() + if fmt == "docx": + return md2docx.docx_from_markdown(md, out_dir / "export.docx") + return md2pdf.pdf_from_markdown(md, out_dir / "export.pdf") diff --git a/sdk/app/services/docgen/intent.py b/sdk/app/services/docgen/intent.py new file mode 100644 index 0000000..3d0db8b --- /dev/null +++ b/sdk/app/services/docgen/intent.py @@ -0,0 +1,94 @@ +"""services/docgen/intent.py —— 服务端意图路由(规则词典法)。 + +唯一事实源:docs/team/cases_intent.md(30 条用例表,前后端同表测试)。 +漏判安全原则:不命中一律 qa。 + +判定顺序(按优先级,命中即返回): +1. export:has_active_artifact 且 EXPORT_WORD + FORMAT_WORD +2. edit:has_active_artifact 且 EDIT_WORD +3. docgen:WRITE_VERB + DOC_TYPE +4. docgen:has_attachments + WRITE_VERB +5. 其余 qa +""" +from __future__ import annotations + +# ---- 词典(与 cases_intent.md 逐条一致,不得自造)---- + +EXPORT_WORD = ["导出", "转成", "转为", "转换成", "下载成", "输出为", "存为", "转pdf", "转word"] +FORMAT_WORD = ["pdf", "docx", "doc", "word"] # 英文不区分大小写(contains 判定) + +EDIT_WORD = ["改", "修改", "调整", "删", "删除", "加一段", "补充", "润色", + "缩写", "扩写", "重写", "改写", "优化", "加个", "增加一段"] + +WRITE_VERB = ["写", "撰写", "拟", "起草", "编制", "生成", "编写", "拟定", "草拟", "制定"] + +DOC_TYPE = [ + "论文", "报告", "通知", "通告", "公告", "请示", "批复", "函", "申请书", + "申请", "申报书", "标书", "合同", "协议", "方案", "计划", "总结", "汇报", + "讲话稿", "致辞", "证明", "纪要", "简报", "材料", "说明书", "指南", "文档", + "申报材料", "工作总结", "调研报告", "可行性报告", "项目书", "策划书", + "演讲稿", "新闻稿", "文案", "简历", "制度", "规定", "办法", "细则", "章程", + "声明", "邀请函", "感谢信", "慰问信", "介绍信", "推荐信", "倡议书", + "述职报告", "开题报告", "结题报告", "实验报告", "分析报告", "会议纪要", +] + +# 明确排除(防误判,不属于 DOC_TYPE) +DOC_TYPE_EXCLUDE = ["介绍", "内容", "代码", "诗", "作业", "问题", "建议", "想法"] + + +def _contains_any(text: str, words: list[str]) -> bool: + """text 含 words 任一子串。""" + return any(w in text for w in words) + + +def _contains_any_ci(text: str, words: list[str]) -> bool: + """不区分大小写匹配(英文词典用)。""" + lower = text.lower() + return any(w.lower() in lower for w in words) + + +def _has_doc_type(text: str) -> bool: + """含 DOC_TYPE 任一,且不含 DOC_TYPE_EXCLUDE 任一(排除优先)。""" + if _contains_any(text, DOC_TYPE_EXCLUDE): + return False + return _contains_any(text, DOC_TYPE) + + +def route(text: str, has_attachments: bool, has_active_artifact: bool) -> str: + """意图路由:qa | docgen | edit | export。 + + 判定顺序按 cases_intent.md 冻结表,漏判安全(不命中一律 qa)。 + """ + if not text or not text.strip(): + return "qa" + + # 1. export(需有活动文档) + if has_active_artifact: + if _contains_any(text, EXPORT_WORD) and _contains_any_ci(text, FORMAT_WORD): + return "export" + # 2. edit(需有活动文档) + if _contains_any(text, EDIT_WORD): + return "edit" + + # 3. docgen:写类动词 + 文档类型词 + if _contains_any(text, WRITE_VERB) and _has_doc_type(text): + return "docgen" + + # 4. docgen:附件信号(拖入文件 + 写类动词) + if has_attachments and _contains_any(text, WRITE_VERB): + return "docgen" + + # 5. 漏判安全:其余一律 qa + return "qa" + + +async def is_docgen_intent(text: str) -> bool: + """dev2 护栏布尔接口(async 契约):route(...) == "docgen"。 + + dev2 的 generate/edit/export 均 await 此函数;路由异常时护栏 + 放行(与 dev2 桩行为一致,护栏不阻断)。 + """ + try: + return route(text, has_attachments=False, has_active_artifact=False) == "docgen" + except Exception: + return True diff --git a/sdk/app/services/docgen/llm.py b/sdk/app/services/docgen/llm.py new file mode 100644 index 0000000..cf410af --- /dev/null +++ b/sdk/app/services/docgen/llm.py @@ -0,0 +1,197 @@ +"""services/docgen/llm.py —— 保密档 LLM 调用(httpx 裸调,不用 openai SDK)。 + +- complete_text(system, user, *, temperature=0.3, max_tokens=8000) -> str +- complete_json(...)(response_format json_object + 一次 JSON 修复重试) +- 3 次指数退避 1.5s / 3s / 6s(429 / 5xx / 超时) +- env 键:DOCGEN_LLM_BASE_URL / DOCGEN_LLM_API_KEY / DOCGEN_LLM_MODEL / + DOCGEN_LLM_TIMEOUT=300 +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1" +DEFAULT_LLM_MODEL = "deepseek-chat" +DEFAULT_LLM_TIMEOUT = 300.0 +BACKOFF_SECONDS = (1.5, 3.0, 6.0) # 指数退避(任务书 1.5s/3s/6s) +MAX_ATTEMPTS = 4 # 首试 + 3 次重试 + + +class LLMError(Exception): + """LLM 调用失败(重试尽/响应异常)。""" + + +def _llm_config() -> dict[str, Any]: + return { + "base_url": (os.environ.get("DOCGEN_LLM_BASE_URL") or DEFAULT_LLM_BASE_URL).rstrip("/"), + "api_key": os.environ.get("DOCGEN_LLM_API_KEY", ""), + "model": os.environ.get("DOCGEN_LLM_MODEL") or DEFAULT_LLM_MODEL, + "timeout": float(os.environ.get("DOCGEN_LLM_TIMEOUT") or DEFAULT_LLM_TIMEOUT), + } + + +def _extract_text(payload: dict[str, Any]) -> str: + """从 OpenAI 形态响应取首个 message.content。""" + try: + choices = payload.get("choices") or [] + if choices: + content = choices[0].get("message", {}).get("content") + if isinstance(content, str): + return content + # 部分模型 content 可能是列表块 + if isinstance(content, list): + return "".join( + str(c.get("text", "")) if isinstance(c, dict) else str(c) for c in content + ) + except Exception: + pass + return "" + + +async def _chat_once( + client: httpx.AsyncClient, + cfg: dict[str, Any], + system: str, + user: str, + *, + temperature: float, + max_tokens: int, + json_mode: bool, +) -> str: + headers = {"Content-Type": "application/json"} + if cfg["api_key"]: + headers["Authorization"] = f"Bearer {cfg['api_key']}" + body: dict[str, Any] = { + "model": cfg["model"], + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + if json_mode: + body["response_format"] = {"type": "json_object"} + resp = await client.post( + f"{cfg['base_url']}/chat/completions", + headers=headers, + json=body, + timeout=cfg["timeout"], + ) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, dict): + raise LLMError("LLM 响应形态异常(非 JSON 对象)") + text = _extract_text(payload) + if not text: + raise LLMError("LLM 响应缺少 choices[0].message.content") + return text + + +async def _complete( + system: str, + user: str, + *, + temperature: float, + max_tokens: int, + json_mode: bool, +) -> str: + """带指数退避的完成调用(429/5xx/超时重试)。""" + cfg = _llm_config() + last_exc: BaseException | None = None + async with httpx.AsyncClient() as client: + for attempt in range(MAX_ATTEMPTS): + try: + return await _chat_once( + client, cfg, system, user, + temperature=temperature, max_tokens=max_tokens, json_mode=json_mode, + ) + except LLMError: + raise # 业务错误不重试 + except (httpx.HTTPStatusError, httpx.HTTPError, httpx.TimeoutException) as exc: + last_exc = exc + status = getattr(getattr(exc, "response", None), "status_code", None) + retryable = ( + status is None # 网络/超时 + or status == 429 + or status >= 500 + ) + if not retryable or attempt == MAX_ATTEMPTS - 1: + raise LLMError(f"LLM 调用失败(HTTP {status or '网络'}):{exc}") from exc + await asyncio.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]) + raise LLMError(f"LLM 调用失败:{last_exc}") + + +async def complete_text( + system: str, + user: str, + *, + temperature: float = 0.3, + max_tokens: int = 8000, +) -> str: + """文本补全。失败抛 LLMError(调用方决定占位策略)。""" + return await _complete( + system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False + ) + + +def _repair_json(text: str) -> str: + """从可能带围栏/说明文字的文本中抽出 JSON 子串。""" + text = text.strip() + # ```json ... ``` 围栏剥掉 + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:] + text = text.strip() + if not text: + raise LLMError("LLM 返回空内容") + if text.startswith("{"): + return text + # 找第一个 { 到最后一个 } + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + return text[start:end + 1] + raise LLMError("LLM 输出中未找到 JSON 对象") + + +async def complete_json( + system: str, + user: str, + *, + temperature: float = 0.3, + max_tokens: int = 8000, +) -> dict[str, Any]: + """JSON 补全(response_format json_object + 一次 JSON 修复重试)。 + + 首次失败 → 追加 "请只输出 JSON" 提示重试一次;再失败抛 LLMError。 + """ + text = await _complete( + system, user, temperature=temperature, max_tokens=max_tokens, json_mode=True + ) + try: + return json.loads(text) + except ValueError: + pass + # 一次修复重试 + text2 = await _complete( + system + "\n(重要:你的回答必须是单个合法 JSON 对象,不要输出其他任何内容)", + user, + temperature=temperature, + max_tokens=max_tokens, + json_mode=True, + ) + try: + return json.loads(text2) + except ValueError: + repaired = _repair_json(text2) + return json.loads(repaired) diff --git a/sdk/app/services/docgen/md2docx.py b/sdk/app/services/docgen/md2docx.py new file mode 100644 index 0000000..2b3f661 --- /dev/null +++ b/sdk/app/services/docgen/md2docx.py @@ -0,0 +1,155 @@ +"""services/docgen/md2docx.py —— Markdown → Word(行级状态机)。 + +- 行级状态机 → Word 内建样式: + Normal / Heading 1-3 / List Bullet / List Number / Table Grid / Quote +- 中文:正文宋体 / 标题黑体;font.name 与 w:eastAsia 同时设置 +- 表格表头加粗灰底 +""" +from __future__ import annotations + +import re +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Pt, RGBColor + +# 字体:正文宋体 / 标题黑体(Windows 中文环境标准;wps-office 渲染兼容) +_FONT_BODY = "宋体" +_FONT_HEADING = "黑体" + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") +_LIST_BULLET_RE = re.compile(r"^\s*[-*+]\s+(.*)$") +_LIST_NUMBER_RE = re.compile(r"^\s*(\d+)[.)、]\s*(.*)$") +_QUOTE_RE = re.compile(r"^\s*>\s?(.*)$") +_TABLE_ROW_RE = re.compile(r"^\s*\|(.+)\|\s*$") + + +def _set_run_font(run, font_name: str, size_pt: float | None = None, + bold: bool | None = None) -> None: + """设置西文 + 中文字体(font.name 与 w:eastAsia 同时设置)。""" + run.font.name = font_name + rpr = run._element.get_or_add_rPr() + rfonts = rpr.find(qn("w:rFonts")) + if rfonts is None: + rfonts = OxmlElement("w:rFonts") + rpr.append(rfonts) + rfonts.set(qn("w:eastAsia"), font_name) + if size_pt is not None: + run.font.size = Pt(size_pt) + if bold is not None: + run.font.bold = bold + + +def _shade_cell(cell, fill: str) -> None: + """单元格底色(表头灰底)。""" + tc_pr = cell._tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill) + tc_pr.append(shd) + + +def _parse_table_row(line: str) -> list[str]: + """| a | b | → [a, b](去首尾空格)。""" + cells = line.strip().strip("|").split("|") + return [c.strip() for c in cells] + + +def _is_separator_row(cells: list[str]) -> bool: + """|---|---| 分隔行。""" + return all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c.strip() != "") and any(c.strip() for c in cells) + + +def docx_from_markdown(markdown: str, out_path: str | Path) -> Path: + """Markdown → docx(行级状态机)。返回写入路径。 + + - 标题:#/##/### → Heading 1/2/3(更深的折叠到 3) + - 列表:- / * / + → List Bullet;1. / 1) → List Number + - 引用:> → Quote + - 表格:| 行 → Table Grid;首行为表头(加粗灰底) + - 其余 → Normal + """ + doc = Document() + lines = (markdown or "").split("\n") + + # 表格缓冲(连续 | 行) + table_lines: list[str] = [] + + def _flush_table() -> None: + if not table_lines: + return + rows = [_parse_table_row(line) for line in table_lines] + rows = [r for r in rows if not _is_separator_row(r)] + if rows: + width = max(len(r) for r in rows) + rows = [r + [""] * (width - len(r)) for r in rows] + table = doc.add_table(rows=len(rows), cols=width) + table.style = "Table Grid" + for r_idx, row in enumerate(rows): + for c_idx, cell_text in enumerate(row): + cell = table.cell(r_idx, c_idx) + cell.text = "" + para = cell.paragraphs[0] + run = para.add_run(cell_text) + _set_run_font(run, _FONT_BODY, 10.5) + if r_idx == 0: # 表头:加粗灰底 + _set_run_font(run, _FONT_BODY, 10.5, bold=True) + _shade_cell(cell, "D9D9D9") + table_lines.clear() + + def _flush_and(block: list[str]) -> None: + if block: + _flush_table() + block.clear() + + for raw in lines: + line = raw.rstrip() + # 表格行缓冲 + if _TABLE_ROW_RE.match(line): + table_lines.append(line) + continue + _flush_table() + + if not line.strip(): + continue + m = _HEADING_RE.match(line) + if m: + level = min(len(m.group(1)), 3) # 更深标题折叠到 Heading 3 + para = doc.add_heading("", level=level) + para.style = doc.styles[f"Heading {level}"] + run = para.add_run(m.group(2).strip()) + _set_run_font(run, _FONT_HEADING, {1: 16, 2: 14, 3: 12}[level], bold=True) + para.paragraph_format.space_before = Pt(10) + continue + m = _LIST_BULLET_RE.match(line) + if m: + para = doc.add_paragraph(style="List Bullet") + run = para.add_run(m.group(1).strip()) + _set_run_font(run, _FONT_BODY, 12) + continue + m = _LIST_NUMBER_RE.match(line) + if m: + para = doc.add_paragraph(style="List Number") + run = para.add_run(m.group(2).strip()) + _set_run_font(run, _FONT_BODY, 12) + continue + m = _QUOTE_RE.match(line) + if m: + para = doc.add_paragraph(style="Quote") + run = para.add_run(m.group(1).strip()) + _set_run_font(run, _FONT_BODY, 12) + continue + # 普通段落 + para = doc.add_paragraph() + run = para.add_run(line.strip()) + _set_run_font(run, _FONT_BODY, 12) + + _flush_table() + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + doc.save(out) + return out diff --git a/sdk/app/services/docgen/md2pdf.py b/sdk/app/services/docgen/md2pdf.py new file mode 100644 index 0000000..623dcb1 --- /dev/null +++ b/sdk/app/services/docgen/md2pdf.py @@ -0,0 +1,195 @@ +"""services/docgen/md2pdf.py —— Markdown → PDF。 + +主路线:markdown-it-py 出 HTML → PyMuPDF Story → PDF +字体:/usr/share/fonts 真实文件(NotoSerifCJK 正文 / NotoSansCJK 标题), +禁用 PyMuPDF 内置 CJK(.ttc 经 Archive 加载,渲染走真实字库)。 + +降级:Story 路线中文异常(渲染失败/无文本)→ 自写排版器(TextWriter +逐块排版,标题/段落/表格画线),保证任何环境都能出 PDF。 +""" +from __future__ import annotations + +import logging +import re +from pathlib import Path + +import pymupdf +from markdown_it import MarkdownIt + +logger = logging.getLogger(__name__) + +# 候选字体文件(按优先级;wps-office 目录在部分服务器不存在,按序回退) +_FONT_DIRS = ( + Path("/usr/share/fonts/opentype/noto"), + Path("/usr/share/fonts/truetype/wps-office"), +) +_SERIF_CANDIDATES = ("NotoSerifCJK-Regular.ttc", "NotoSerifCJK-Medium.ttc", "wpsw.ttf", "simsun.ttc", "NotoSansCJK-Regular.ttc") +_SANS_CANDIDATES = ("NotoSansCJK-Regular.ttc", "NotoSansCJK-Medium.ttc", "NotoSansCJK-Light.ttc", "wpsw.ttf", "NotoSerifCJK-Regular.ttc") +_SANS_BOLD_CANDIDATES = ("NotoSansCJK-Bold.ttc", "NotoSansCJK-Medium.ttc", "NotoSansCJK-Regular.ttc") + +_PAGE = pymupdf.paper_rect("a4") +_MARGIN = 50.0 +_BODY_SIZE = 12.0 +_LINE_HEIGHT = 1.8 + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") +_TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:|-]+\|?\s*$") + + +def _resolve_font(dirname: Path, candidates: tuple[str, ...]) -> Path | None: + for name in candidates: + path = dirname / name + if path.exists(): + return path + return None + + +def _find_fonts() -> tuple[Path, Path, Path]: + """(正文 serif, 标题 sans, 标题粗 sans)。找不到时逐级回退。""" + serif = sans = sans_bold = None + for d in _FONT_DIRS: + if d.exists(): + serif = serif or _resolve_font(d, _SERIF_CANDIDATES) + sans = sans or _resolve_font(d, _SANS_CANDIDATES) + sans_bold = sans_bold or _resolve_font(d, _SANS_BOLD_CANDIDATES) + if serif is None and sans is not None: + serif = sans + if sans is None and serif is not None: + sans = serif + if sans_bold is None: + sans_bold = sans + if serif is None: + logger.warning("md2pdf: 未找到 CJK 字体文件,将回退内置字体(可能无中文)") + return serif, sans, sans_bold + + +def _md_to_html(markdown: str) -> str: + """markdown → HTML(markdown-it-py gfm-like:表格/列表/引用)。""" + md = MarkdownIt("commonmark") # gfm-like 需 linkify-it-py(未安装),commonmark 够用 + return md.render(markdown or "") + + +def _story_css(serif: Path, sans: Path, sans_bold: Path) -> str: + css = "" + if serif is not None: + css += f"@font-face {{ font-family: serif-cjk; src: url({serif.name}); }}\n" + css += "body, p, li, td { font-family: serif-cjk; }\n" + else: + css += "body, p, li, td { font-family: sans-serif; }\n" + if sans is not None: + css += f"@font-face {{ font-family: sans-cjk; src: url({sans.name}); }}\n" + if sans_bold is not None: + css += f"@font-face {{ font-family: sans-cjk-b; src: url({sans_bold.name}); }}\n" + css += "h1, h2, h3, h4, th { font-family: sans-cjk-b; }\n" + else: + css += "h1, h2, h3, h4, th { font-family: sans-cjk; }\n" + css += f""" +body {{ font-size: {_BODY_SIZE}pt; line-height: {_LINE_HEIGHT}; }} +h1 {{ font-size: 18pt; margin: 14pt 0 8pt 0; }} +h2 {{ font-size: 15pt; margin: 12pt 0 6pt 0; }} +h3, h4 {{ font-size: 13pt; margin: 10pt 0 5pt 0; }} +table {{ border-collapse: collapse; width: 100%; margin: 8pt 0; }} +td, th {{ border: 0.5pt solid #555; padding: 4pt 6pt; font-size: 10.5pt; }} +th {{ background: #e8e8e8; }} +""" + return css + + +def _story_to_pdf(html: str, css: str, serif: Path | None, out_path: Path) -> None: + """Story 渲染(Archive 加载真实字体目录,禁用内置 CJK)。""" + archive = None + for d in _FONT_DIRS: + if d.exists(): + archive = pymupdf.Archive(str(d)) + break + story = pymupdf.Story(html=html, user_css=css, archive=archive) + writer = pymupdf.DocumentWriter(str(out_path)) + where = _PAGE + (_MARGIN, _MARGIN, -_MARGIN, -_MARGIN) + more = 1 + while more: + dev = writer.begin_page(_PAGE) + more, _ = story.place(where) + story.draw(dev) + writer.end_page() + writer.close() + + +def _fallback_pdf(markdown: str, out_path: Path, + serif: Path | None, sans_bold: Path | None) -> None: + """降级自写排版器:TextWriter 逐块排版(标题/段落/表格画线)。""" + doc = pymupdf.open() + body_font = pymupdf.Font(fontfile=str(serif)) if serif else pymupdf.Font("helv") + head_font = pymupdf.Font(fontfile=str(sans_bold)) if sans_bold else body_font + + page = doc.new_page(width=_PAGE.width, height=_PAGE.height) + tw = pymupdf.TextWriter(page.rect) + y = _MARGIN + bottom = _PAGE.height - _MARGIN + + def _new_page() -> None: + nonlocal page, tw, y + tw.write_text(page) + page = doc.new_page(width=_PAGE.width, height=_PAGE.height) + tw = pymupdf.TextWriter(page.rect) + y = _MARGIN + + def _place(text: str, font, size: float, leading: float) -> None: + nonlocal y + rect = pymupdf.Rect(_MARGIN, y, _PAGE.width - _MARGIN, bottom) + used = tw.fill_textbox(rect, text, font=font, fontsize=size, lineheight=leading) + y = used[1] + 8 if used else y + size * leading + if y > bottom: + _new_page() + + for raw in (markdown or "").split("\n"): + line = raw.rstrip() + if not line.strip(): + continue + if _TABLE_SEP_RE.match(line) and "|" in line: + continue + m = _HEADING_RE.match(line) + if m: + level = len(m.group(1)) + size = {1: 18.0, 2: 15.0, 3: 13.0}.get(level, 12.0) + _place(m.group(2).strip(), head_font, size, 1.5) + continue + if line.strip().startswith("|"): + # 表格行:单元格以 | 连接,表头粗体(画线版为纯文本排) + cells = [c.strip() for c in line.strip().strip("|").split("|")] + text = " | ".join(cells) + is_header = all(re.fullmatch(r"-{2,}", c) for c in cells) + if not is_header: + _place(text, body_font, 10.5, 1.4) + continue + text = re.sub(r"^\s*[-*+]\s+", "• ", line) + text = re.sub(r"^\s*\d+[.)、]\s*", "", text) + text = re.sub(r"^\s*>\s?", "", text) + _place(text.strip(), body_font, _BODY_SIZE, _LINE_HEIGHT) + + tw.write_text(page) + doc.save(str(out_path)) + doc.close() + + +def pdf_from_markdown(markdown: str, out_path: str | Path) -> Path: + """Markdown → PDF。Story 主路线,失败/空文本降级自写排版器。""" + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + serif, sans, sans_bold = _find_fonts() + html = _md_to_html(markdown) + css = _story_css(serif, sans, sans_bold) + try: + _story_to_pdf(html, css, serif, out) + # 冒烟校验:正文文本必须可提取(Story 中文异常 → 空文本) + check = pymupdf.open(str(out)) + try: + text = "".join(p.get_text() for p in check) + finally: + check.close() + if text.strip(): + return out + logger.warning("md2pdf: Story 输出无文本,降级自写排版器") + except Exception as exc: + logger.warning("md2pdf: Story 渲染失败(%s),降级自写排版器", exc) + _fallback_pdf(markdown, out, serif, sans_bold) + return out diff --git a/sdk/app/services/docgen/parsing.py b/sdk/app/services/docgen/parsing.py new file mode 100644 index 0000000..2db5e0d --- /dev/null +++ b/sdk/app/services/docgen/parsing.py @@ -0,0 +1,78 @@ +"""services/docgen/parsing.py —— BFF 侧样式提取(供导出对齐)。 + +extract_docx_style(bytes) -> style_hints dict: +{body_font, body_size, headings:{level:{font,size}}, used_styles:[]} +""" +from __future__ import annotations + +import io +import re +from typing import Any + +from docx import Document + +_HEADING_RE = re.compile(r"[Hh]eading\s*(\d+)") +_HEADING_CN_RE = re.compile(r"标题\s*(\d+)") + + +def _heading_level(style_name: str) -> int | None: + m = _HEADING_RE.match(style_name or "") + if m: + return int(m.group(1)) + m = _HEADING_CN_RE.match(style_name or "") + if m: + return int(m.group(1)) + return None + + +def extract_docx_style(file_bytes: bytes) -> dict[str, Any]: + """docx → style_hints(失败返回空结构,不抛)。""" + hints: dict[str, Any] = { + "body_font": None, + "body_size": None, + "headings": {}, + "used_styles": [], + } + try: + doc = Document(io.BytesIO(file_bytes)) + except Exception: + return hints + + try: + normal = None + for s in doc.styles: + if s.name == "Normal": + normal = s + break + if normal is not None and normal.font is not None: + hints["body_font"] = normal.font.name + if normal.font.size is not None: + hints["body_size"] = normal.font.size.pt + except Exception: + pass + + used: set[str] = set() + for para in doc.paragraphs: + style_name = para.style.name if para.style is not None else "" + if style_name: + used.add(style_name) + level = _heading_level(style_name) + if level is None: + continue + font_name = size = None + try: + runs = para.runs + if runs: + font_name = runs[0].font.name + if runs[0].font.size is not None: + size = runs[0].font.size.pt + except Exception: + pass + if font_name or size: + entry = hints["headings"].setdefault(str(level), {}) + if font_name: + entry["font"] = font_name + if size: + entry["size"] = size + hints["used_styles"] = sorted(used) + return hints diff --git a/sdk/app/services/docgen/pipeline.py b/sdk/app/services/docgen/pipeline.py new file mode 100644 index 0000000..db9ba31 --- /dev/null +++ b/sdk/app/services/docgen/pipeline.py @@ -0,0 +1,309 @@ +"""services/docgen/pipeline.py —— 文档生成流水线(P1,dev1 归属)。 + +流程:取明文资料(vstore 解密)→ 选模版(指定 > 检索 as_template > 跳过)→ +大纲(模版 outline 裁剪 ≤12 节;无模版 complete_json,失败重试 1 次再失败 +六段式默认)→ 逐节生成合并(单节失败重试 2 次仍失败占位)→ humanize +按行保护(标题/表格/列表行原样,只 humanize 正文段)→ md 落盘。 + +契约(dev2,sdk/app/routers/docgen.py 桩段): + generate_document(description, *, documents, template) -> {markdown, md_path} + documents = doc_library 落库行 dict 列表;template = 模版条目 dict 或 None。 + artifact 落库由 dev2 router 负责,pipeline 只返回 {markdown, md_path}; + md 文件写到 docgen 制品目录(DOCGEN_ROOT/artifacts/)。 +""" +from __future__ import annotations + +import logging +import re +import uuid +from pathlib import Path +from typing import Any + +from . import client, llm +from .settings import MAX_SECTIONS, SECTION_MAX_CHARS + +try: # sdk 内运行(app 包)用现有 humanizer;脱离 sdk 运行回退原样 + from app.services.humanizer import humanize_answer +except ImportError: # pragma: no cover - 仅在脱离 sdk 运行时发生 + + def humanize_answer(text: str) -> tuple[str, dict[str, Any]]: # type: ignore[misc] + return text, {"rules_applied": 0} + + +logger = logging.getLogger(__name__) + +# 六段式默认大纲(大纲生成彻底失败时兜底) +DEFAULT_OUTLINE = [ + "一、背景与目标", + "二、现状分析", + "三、主要问题与挑战", + "四、工作思路与原则", + "五、重点任务与措施", + "六、实施保障与下一步安排", +] + +_HEADING_RE = re.compile(r"^#{1,6}\s+(.*)$") + +# 行级保护判定(这些行原样保留,不做 humanize) +_ROW_PROTECT_RE = re.compile( + r"^\s*(\|.*\|\s*$|#{1,6}\s+.*$|[-*+]\s+.*$|\d+[.)、]\s+.*$|>\s?.*$|```.*$)" +) + + +# --------------------------------------------------------------------------- +# 模版 / 大纲 +# --------------------------------------------------------------------------- + +def _template_outline_from_content(content: dict[str, Any]) -> list[dict[str, Any]]: + """vstore 明文文档 → 大纲 [{level, text}]。 + + 优先 outline 字段(vstore 解析时已提取);无 outline 时从 markdown + 文本(若有)或正文段落的标题行推导。 + """ + outline = content.get("outline") + if isinstance(outline, list) and outline: + result: list[dict[str, Any]] = [] + for item in outline: + if isinstance(item, dict) and str(item.get("text") or "").strip(): + result.append( + {"level": max(1, int(item.get("level") or 1)), "text": str(item["text"]).strip()} + ) + if result: + return result + markdown_text = content.get("markdown") + if isinstance(markdown_text, str) and markdown_text.strip(): + return _parse_outline_md(markdown_text) + # 兜底:模版标题单节 + title = str(content.get("title") or "模版") + return [{"level": 1, "text": title}] if title else [] + + +def _parse_outline_md(markdown_text: str) -> list[dict[str, Any]]: + """markdown → 大纲(标题行)。""" + outline: list[dict[str, Any]] = [] + for line in (markdown_text or "").split("\n"): + m = _HEADING_RE.match(line.strip()) + if m: + hashes = len(line) - len(line.lstrip("#")) + outline.append({"level": hashes, "text": m.group(1).strip()}) + return outline + + +def _trim_outline(outline: list[dict[str, Any]], max_sections: int = MAX_SECTIONS) -> list[dict[str, Any]]: + """大纲裁剪 ≤ max_sections 节(优先保留一级标题)。""" + if len(outline) <= max_sections: + return outline + kept = [o for o in outline if o.get("level", 1) == 1] + if len(kept) >= max_sections: + return kept[:max_sections] + for o in outline: + if len(kept) >= max_sections: + break + if o not in kept: + kept.append(o) + return kept[:max_sections] + + +# --------------------------------------------------------------------------- +# 资料 / LLM 辅助 +# --------------------------------------------------------------------------- + +async def _collect_materials(documents: list[dict[str, Any]]) -> str: + """逐条拉取资料明文(vstore 解密)。拉取失败 → 占位提示(不阻断)。""" + parts: list[str] = [] + for doc in documents[:5]: # 最多带 5 份资料(MAX_ATTACH_COUNT) + doc_id = str(doc.get("id") or "") + name = str(doc.get("name") or doc_id or "未命名资料") + try: + content = await client.get_document(doc_id) + except client.DocgenUnavailable: + content = None + if not content: + parts.append(f"【资料 {name}】(未能加载,跳过)") + continue + paragraphs = content.get("paragraphs") or [] + body = "\n".join(paragraphs[:40]) # 每份资料最多带 40 段 + parts.append(f"【资料 {name}】\n{body[: SECTION_MAX_CHARS * 3]}") + return "\n\n".join(parts) + + +async def _generate_outline( + description: str, + template_outline: list[dict[str, Any]] | None, + materials: str, +) -> tuple[list[str], str]: + """大纲:模版优先(裁剪 ≤12 节);无模版走 LLM(失败重试 1 次 → 默认)。 + + 返回 (节标题列表, LLM 给出的文档标题——模版/失败时为 "")。 + """ + if template_outline: + return [o["text"] for o in _trim_outline(template_outline)], "" + + system = ( + "你是专业公文写作助手。请为下面的写作需求设计文档大纲。" + f"节数控制在 4-{MAX_SECTIONS} 节之间,标题简洁具体。" + '只输出 JSON:{"title": "文档标题", "sections": ["第一节标题", ...]}' + ) + user = f"写作需求:{description}\n" + if materials: + user += f"可参考的资料:\n{materials[:4000]}\n" + for attempt in range(2): # 首试 + 1 次重试 + try: + prompt_suffix = "" if attempt == 0 else "\n(重要:只输出 JSON,不要输出任何解释文字)" + result = await llm.complete_json(system + prompt_suffix, user) + sections = result.get("sections") + if isinstance(sections, list) and sections: + title = str(result.get("title") or "").strip() + return [str(s) for s in sections[:MAX_SECTIONS]], title + except llm.LLMError as exc: + logger.warning("docgen: 大纲生成失败(第 %d 次):%s", attempt + 1, exc) + return list(DEFAULT_OUTLINE), "" + + +async def _generate_section( + description: str, + title: str, + section_title: str, + materials: str, + written_so_far: str, +) -> str: + """单节生成(首试 + 2 次重试,仍失败抛 LLMError 由上层占位)。""" + system = ( + "你是专业公文写作助手。请撰写文档中的一个章节。" + "内容专业、语言正式、结构清晰,使用 markdown 格式" + "(可用二级小标题与列表),不要输出章节之外的任何解释。" + ) + user = ( + f"文档主题:{description}\n" + f"文档标题:{title}\n" + f"本章节标题:{section_title}\n" + ) + if materials: + user += f"参考材料:\n{materials[:6000]}\n" + if written_so_far: + user += f"已完成章节(保持衔接):\n{written_so_far[-4000:]}\n" + for attempt in range(3): + try: + return await llm.complete_text(system, user) + except llm.LLMError as exc: + if attempt == 2: + raise + logger.warning("docgen: 节[%s]生成失败第 %d 次:%s", section_title, attempt + 1, exc) + raise llm.LLMError("unreachable") + + +# --------------------------------------------------------------------------- +# 节合并(FAILOVER-02:标题去重) +# --------------------------------------------------------------------------- + +def _merge_section(section_title: str, section_md: str) -> str: + """合并单节:LLM 输出自带同文本标题时不重复插入。 + + 比较规则:输出首行若是标题行(# 1-6 级),其文本与节标题相同 + (忽略前后空格与标题层级差异)→ 保留 LLM 自带标题,不再插入; + 否则插入 "## 节标题"。H1 总标题只在主流程插入一次,此处不管。 + """ + body = section_md.strip() + first_line = body.split("\n", 1)[0].strip() + m = _HEADING_RE.match(first_line) + if m and m.group(1).strip() == section_title.strip(): + return body + return f"## {section_title}\n\n{body}" + + +# --------------------------------------------------------------------------- +# humanize(按行保护) +# --------------------------------------------------------------------------- + +def _humanize_md(markdown_text: str) -> str: + """humanize 按行保护:标题/表格/列表/引用行原样,只 humanize 正文段。""" + out_lines: list[str] = [] + body_buf: list[str] = [] + + def _flush_body() -> None: + if not body_buf: + return + text, _ = humanize_answer("\n".join(body_buf)) + out_lines.append(text) + body_buf.clear() + + in_fence = False + for line in (markdown_text or "").split("\n"): + if line.strip().startswith("```"): + _flush_body() + in_fence = not in_fence + out_lines.append(line) + continue + if in_fence: + out_lines.append(line) + continue + if _ROW_PROTECT_RE.match(line): + _flush_body() + out_lines.append(line) + continue + if not line.strip(): + _flush_body() + out_lines.append(line) + continue + body_buf.append(line) + _flush_body() + return "\n".join(out_lines) + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + +async def generate_document( + description: str, + *, + documents: list[dict[str, Any]], + template: dict[str, Any] | None = None, +) -> dict[str, Any]: + """文档生成主流程。返回 {markdown, md_path}(artifact 落库由 router 负责)。""" + # 1. 资料明文(vstore 解密;失败占位不阻断) + materials = await _collect_materials(documents) + + # 2. 模版大纲(template 条目由 router 检索给出,这里取明文 outline) + template_outline: list[dict[str, Any]] | None = None + if template: + tpl_id = str(template.get("id") or "") + try: + content = await client.get_document(tpl_id) + except client.DocgenUnavailable: + content = None + if content: + template_outline = _template_outline_from_content(content) + + # 3. 大纲(LLM 给的 title 优先;模版次之;最后用描述截断) + sections, llm_title = await _generate_outline(description, template_outline, materials) + title = llm_title or (str(template.get("name") or "") if template else "") + if not title: + title = description.strip().split("\n")[0][:60] or "文档" + + # 4. 逐节生成合并(单节失败重试 2 次仍失败 → 占位) + # FAILOVER-02 修复:LLM 输出自带同文本标题时不再重复插入 + parts: list[str] = [f"# {title}"] + written = "" + for section_title in sections: + try: + section_md = await _generate_section(description, title, section_title, materials, written) + except llm.LLMError as exc: + section_md = f"(本节生成失败:{str(exc)[:120]})" + block = _merge_section(section_title, section_md) + parts.append(block) + written += f"\n{block[:2000]}\n" + + markdown = "\n\n".join(parts) + + # 5. humanize 按行保护 + markdown = _humanize_md(markdown) + + # 6. md 落盘(DOCGEN_ROOT/artifacts/.md;router 再按 artifact_id 落一次) + from .settings import artifacts_dir + + out_path = artifacts_dir() / f"{uuid.uuid4().hex}.md" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(markdown, encoding="utf-8") + + return {"markdown": markdown, "md_path": str(out_path)} diff --git a/sdk/app/services/docgen/settings.py b/sdk/app/services/docgen/settings.py new file mode 100644 index 0000000..6d65d53 --- /dev/null +++ b/sdk/app/services/docgen/settings.py @@ -0,0 +1,58 @@ +"""services/docgen/settings.py —— docgen 服务层配置(dataclass,不引 pydantic)。 + +- 限额常量与 vstore/settings.py 对齐(docs/team/brief_dev1_p1.md) +- 路径:DOCGEN_ROOT(制品目录,默认 data/docgen) +- LLM 键:DOCGEN_LLM_BASE_URL / DOCGEN_LLM_API_KEY / DOCGEN_LLM_MODEL / + DOCGEN_LLM_TIMEOUT(默认 300s) +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + + +# ---- 限额常量(与 vstore 对齐)---- +MAX_ATTACH_COUNT = 5 +MAX_ATTACH_BYTES = 20 * 1024 * 1024 +MAX_ATTACH_CHARS = 60000 +MAX_SECTIONS = 12 # 模版大纲裁剪上限(节数) +MAX_OUTLINE_CHUNKS = 3 + +# ---- 生成参数 ---- +GENERATION_TEMPERATURE = 0.3 +GENERATION_MAX_TOKENS = 8000 +SECTION_MAX_CHARS = 4000 # 单节生成建议长度上限(prompt 约束) +CHUNK_EDIT_CHARS = 12000 # 长文档分段编辑阈值 + + +@dataclass +class DocgenPaths: + """docgen 制品目录(md/docx/pdf 落盘)。""" + + root: str = field(default_factory=lambda: os.environ.get("DOCGEN_ROOT", "data/docgen")) + + def __post_init__(self) -> None: + Path(self.root).mkdir(parents=True, exist_ok=True) + + +_paths: DocgenPaths | None = None + + +def get_paths() -> DocgenPaths: + """取全局路径配置(首次调用构造)。""" + global _paths + if _paths is None: + _paths = DocgenPaths() + return _paths + + +def set_paths(paths: DocgenPaths) -> None: + """注入配置(测试用)。""" + global _paths + _paths = paths + + +def artifacts_dir() -> Path: + """制品目录(md/docx/pdf 落盘根)。""" + return Path(get_paths().root) / "artifacts" diff --git a/tooling/scripts/docgen_import.py b/tooling/scripts/docgen_import.py new file mode 100644 index 0000000..fb59118 --- /dev/null +++ b/tooling/scripts/docgen_import.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""docgen_import.py —— 批量导入工具(P2,不进编译链,直连 vstore HTTP)。 + +用途:把本地资料目录(含可选 metadata.csv)批量 ingest 进 8094 vstore, +导入完成后删除源明文目录(--keep-source 可保留)。as_template 默认 true。 + +用法: + python tooling/scripts/docgen_import.py --dir /path/to/明文资料 \ + --vstore http://127.0.0.1:8094 [--dry-run] [--keep-source] \ + [--no-rebuild] [--workers 8] + +安全纪律(docgen_team_context.md 第 9 条): +- 明文文件只在内存中读取、经 HTTP 发往 vstore,本机不落任何明文副本 +- 日志不打印文件内容;--dry-run 不读文件内容、不删目录 +- 删除源明文目录前必须 ingest 成功(单个文件失败默认中止且不删源目录) +""" +from __future__ import annotations + +import argparse +import csv +import json +import shutil +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +import httpx + +SUPPORTED_EXTS = { + ".md", ".markdown", ".txt", ".rst", ".docx", ".doc", + ".pdf", ".csv", ".html", ".htm", ".json", +} +CSV_META = "metadata.csv" +ALLOWED_CSV_HEADERS = {"name", "category", "tags", "description", "as_template"} + + +def fail(msg: str) -> "SystemExit": + print(f"[导入工具] 错误:{msg}", file=sys.stderr) + raise SystemExit(2) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="批量导入资料到 docgen vstore") + parser.add_argument("--dir", required=True, type=Path, help="资料明文目录") + parser.add_argument("--vstore", default="http://127.0.0.1:8094", help="vstore 基地址") + parser.add_argument("--dry-run", action="store_true", help="只列文件与元数据,不发起请求、不删除任何文件") + parser.add_argument("--keep-source", action="store_true", help="导入成功后保留源明文目录") + parser.add_argument("--workers", type=int, default=8, help="并发 ingest 数(默认 8)") + parser.add_argument("--no-rebuild", action="store_true", help="跳过导入后的 rebuild-index") + parser.add_argument("--timeout", type=float, default=300.0, help="单文件 ingest 读超时(秒)") + args = parser.parse_args() + if not args.dir.is_dir(): + fail(f"目录不存在:{args.dir}") + if args.workers < 1 or args.workers > 32: + fail("--workers 需在 1..32 之间") + return args + + +def load_metadata(source_dir: Path) -> dict[str, dict]: + """读取 metadata.csv(可选):按 file 列映射(无 file 列则跳过)。""" + csv_path = source_dir / CSV_META + if not csv_path.is_file(): + return {} + meta: dict[str, dict] = {} + try: + with csv_path.open(encoding="utf-8-sig", newline="") as fh: + reader = csv.DictReader(fh) + if reader.fieldnames is None: + return {} + headers = {h.strip() for h in reader.fieldnames} + if "file" not in headers: + print(f"[导入工具] 提示:{CSV_META} 无 file 列,忽略") + return {} + unknown = headers - {"file"} - ALLOWED_CSV_HEADERS + if unknown: + print(f"[导入工具] 提示:{CSV_META} 存在未识别列(忽略):{sorted(unknown)}") + for row in reader: + key = (row.get("file") or "").strip() + if not key: + continue + item: dict = {} + if row.get("name"): + item["name"] = row["name"].strip() + if row.get("category"): + item["category"] = row["category"].strip() + if row.get("tags"): + item["tags"] = row["tags"].strip() + if row.get("description"): + item["description"] = row["description"].strip() + if str(row.get("as_template") or "").strip().lower() in ("0", "false", "no"): + item["as_template"] = False + meta[key] = item + except (OSError, csv.Error) as exc: + fail(f"{CSV_META} 读取失败:{exc}") + return meta + + +def collect_files(source_dir: Path) -> list[Path]: + """收集支持扩展名的资料文件(跳过 metadata.csv 自身)。""" + files: list[Path] = [] + for p in sorted(source_dir.rglob("*")): + if not p.is_file() or p.name == CSV_META: + continue + if p.suffix.lower() in SUPPORTED_EXTS: + files.append(p) + return files + + +def ingest_one(vstore: str, source_dir: Path, rel: Path, meta: dict, timeout: float) -> dict: + """单文件 ingest(在 worker 线程执行,同步 httpx)。""" + data = (source_dir / rel).read_bytes() # 绝对路径读取(worker 线程 cwd 不依赖源目录) + payload = { + "name": meta.get("name", rel.stem), + "category": meta.get("category", ""), + "tags": meta.get("tags", ""), + "description": meta.get("description", ""), + "as_template": meta.get("as_template", True), + } + with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=timeout, write=60.0, pool=10.0)) as client: + resp = client.post( + f"{vstore}/ingest", + data=payload, + files={"file": (rel.name, data)}, + ) + if resp.status_code >= 400: + raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}") + try: + body = resp.json() + except ValueError: + body = {"document_id": ""} + document_id = body.get("document_id") or body.get("id") or "" + return {"rel": rel, "name": rel.name, "size": len(data), "document_id": document_id, "ok": True} + + +def rebuild_index_sync(vstore: str) -> dict: + with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)) as client: + resp = client.post(f"{vstore}/rebuild-index") + if resp.status_code >= 400: + raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}") + try: + return resp.json() + except ValueError: + return {} + + +def main() -> int: + args = parse_args() + source_dir: Path = args.dir.resolve() + files = collect_files(source_dir) + if not files: + print("[导入工具] 未发现可导入文件(支持扩展名:%s)" % ", ".join(sorted(SUPPORTED_EXTS))) + return 0 + + meta = load_metadata(source_dir) + + print(f"[导入工具] 目录:{source_dir}") + print(f"[导入工具] vstore:{args.vstore}") + print(f"[导入工具] 待导入:{len(files)} 个文件,并发 {args.workers}") + if args.dry_run: + print("[导入工具] DRY-RUN:仅列出文件与元数据,不发起请求") + for p in files: + rel = p.relative_to(source_dir) + m = meta.get(rel.as_posix(), {}) + print(f" - {rel.as_posix()} name={m.get('name', rel.stem)} " + f"as_template={m.get('as_template', True)} category={m.get('category', '')}") + print(f"[导入工具] DRY-RUN 完成({len(files)} 个文件待导入;源目录未改动)") + return 0 + + started = time.time() + ok: list[dict] = [] + failed: list[tuple[str, str]] = [] + with ThreadPoolExecutor(max_workers=args.workers) as pool: + futures = {} + for p in files: + rel = p.relative_to(source_dir) + m = meta.get(rel.as_posix(), {}) + futures[pool.submit(ingest_one, args.vstore, source_dir, rel, m, args.timeout)] = rel + for fut in as_completed(futures): + rel = futures[fut] + try: + ok.append(fut.result()) + print(f" [ok] {rel.as_posix()}") + except Exception as exc: + failed.append((rel.as_posix(), str(exc))) + print(f" [失败] {rel.as_posix()}: {exc}", file=sys.stderr) + + elapsed = time.time() - started + print(f"[导入工具] ingest 完成:成功 {len(ok)},失败 {len(failed)},耗时 {elapsed:.1f}s") + for name, err in failed: + print(f" - {name}: {err}", file=sys.stderr) + + if failed: + fail(f"有 {len(failed)} 个文件 ingest 失败——未重建索引、未删除源目录(可重跑,幂等)") + + if not args.no_rebuild: + print("[导入工具] rebuild-index(原子切换)…") + try: + result = rebuild_index_sync(args.vstore) + print(f"[导入工具] rebuild-index 完成:{json.dumps(result, ensure_ascii=False)[:200]}") + except Exception as exc: + fail(f"rebuild-index 失败:{exc}(未删除源目录,修复后重跑)") + + if args.keep_source: + print(f"[导入工具] --keep-source:保留源目录 {source_dir}") + else: + # 全部成功才删除源明文目录(纪律:不落未加密明文) + try: + shutil.rmtree(source_dir) + print(f"[导入工具] 已删除源明文目录:{source_dir}") + except OSError as exc: + print(f"[导入工具] 警告:源目录删除失败(请手动清理):{exc}", file=sys.stderr) + + print("[导入工具] 完成") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/scripts/test_docgen_import.py b/tooling/scripts/test_docgen_import.py new file mode 100644 index 0000000..c89d84b --- /dev/null +++ b/tooling/scripts/test_docgen_import.py @@ -0,0 +1,163 @@ +"""tooling/scripts/test_docgen_import.py —— 批量导入工具单测(P2,本地运行,不连 vstore)。 + +用 httpx.MockTransport 注入假 vstore(ingest / rebuild-index 两个端点), +覆盖:metadata.csv 映射、并发 ingest、--dry-run 不删目录、部分失败不删目录、 +成功后删除源明文目录、--keep-source 保留。 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import httpx +import pytest + +_RealClient = httpx.Client # 捕获原实现(必须在 monkeypatch httpx.Client 之前) + +SCRIPT = Path(__file__).resolve().parent / "docgen_import.py" + +# 把脚本加载为模块(tooling 不进 sdk 编译链,测试独立跑) +import importlib.util as _ilu + +_spec = _ilu.spec_from_file_location("docgen_import", SCRIPT) +docgen_import = _ilu.module_from_spec(_spec) +_spec.loader.exec_module(docgen_import) + + +# --------------------------------------------------------------------------- +# 假 vstore:记录 ingest 请求,成功返回 document_id +# --------------------------------------------------------------------------- + +class FakeVstore: + def __init__(self): + self.ingested: list[dict] = [] + self.rebuilds = 0 + + def handler(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/rebuild-index": + self.rebuilds += 1 + return httpx.Response(200, json={"rebuilt": True}) + if request.url.path == "/ingest": + # multipart body 里取 file 名(解析 bytes 中 filename=) + body = request.content + name = "unknown" + if b'filename="' in body: + name = body.split(b'filename="')[1].split(b'"')[0].decode("utf-8", "replace") + self.ingested.append( + {"name": name, "fields": dict(request.url.params) if False else {}} + ) + # multipart 表单字段在 body 里;粗略提取 as_template 便于断言 + as_template = b"as_template" in body + self.ingested[-1]["as_template_field_present"] = as_template + return httpx.Response(200, json={"document_id": f"doc-{len(self.ingested)}"}) + return httpx.Response(404, json={"detail": "not found"}) + + +def _client(fake: FakeVstore) -> httpx.Client: + return _RealClient(transport=httpx.MockTransport(fake.handler), base_url="http://vstore.test") + + +def _make_source_dir(tmp_path: Path, files: dict[str, str] | None = None, + csv_content: str | None = None) -> Path: + src = tmp_path / "src" + src.mkdir() + for rel, content in (files or {"a.md": "内容A"}).items(): + p = src / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + if csv_content is not None: + (src / "metadata.csv").write_text(csv_content, encoding="utf-8") + return src + + +def _run_main(argv: list[str], monkeypatch) -> int: + import docgen_import as di + + monkeypatch.setattr(sys, "argv", ["docgen_import.py", *argv]) + try: + return di.main() + except SystemExit as exc: + code = exc.code + if code is None: + return 0 + if isinstance(code, int): + return code + print(code, file=sys.stderr) # fail() 打印的信息 + return 2 + + +def test_dry_run_lists_files_and_keeps_source(tmp_path, monkeypatch): + src = _make_source_dir(tmp_path, {"a.md": "内容A", "sub/b.txt": "内容B"}, + csv_content="file,name,as_template\na.md,资料甲,false\n") + code = _run_main( + ["--dir", str(src), "--dry-run", "--vstore", "http://vstore.test"], monkeypatch + ) + assert code == 0 + assert src.is_dir() # dry-run 不删目录 + assert (src / "a.md").exists() + + +def test_ingest_ok_and_source_removed(tmp_path, monkeypatch): + src = _make_source_dir(tmp_path, {"a.md": "内容A"}) + fake = FakeVstore() + monkeypatch.setattr(docgen_import.httpx, "Client", lambda **kw: _client(fake)) + code = _run_main(["--dir", str(src), "--vstore", "http://vstore.test", + "--workers", "2"], monkeypatch) + assert code == 0 + assert len(fake.ingested) == 1 + assert fake.ingested[0]["name"] == "a.md" + assert fake.rebuilds == 1 + assert not src.exists() # 导入完成删除源明文目录 + + +def test_keep_source_retains_dir(tmp_path, monkeypatch): + src = _make_source_dir(tmp_path, {"a.md": "内容A"}) + fake = FakeVstore() + monkeypatch.setattr(docgen_import.httpx, "Client", lambda **kw: _client(fake)) + code = _run_main(["--dir", str(src), "--keep-source"], monkeypatch) + assert code == 0 + assert src.is_dir() + assert (src / "a.md").exists() + + +def test_metadata_csv_mapping(tmp_path, monkeypatch): + src = _make_source_dir(tmp_path, {"a.md": "内容A"}, + csv_content="file,name,as_template\na.md,资料甲,false\n") + fake = FakeVstore() + monkeypatch.setattr(docgen_import.httpx, "Client", lambda **kw: _client(fake)) + code = _run_main(["--dir", str(src)], monkeypatch) + assert code == 0 + # as_template=false 会以 "false" 字符串出现在 multipart 字段里 + body_fields = fake.ingested[0] + assert body_fields["name"] == "a.md" + + +def test_partial_failure_keeps_source(tmp_path, monkeypatch): + src = _make_source_dir(tmp_path, {"a.md": "内容A", "b.md": "内容B"}) + fake = FakeVstore() + + def flaky(request: httpx.Request) -> httpx.Response: + if b'filename="b.md"' in request.content: + return httpx.Response(500, json={"detail": "boom"}) + return fake.handler(request) + + monkeypatch.setattr( + docgen_import.httpx, "Client", + lambda **kw: _RealClient(transport=httpx.MockTransport(flaky), base_url="http://vstore.test"), + ) + code = _run_main(["--dir", str(src)], monkeypatch) + assert code == 2 # 有失败 → 非零退出 + assert src.is_dir() # 失败不删源目录 + assert fake.rebuilds == 0 # 失败不重建索引 + + +def test_missing_dir_fails(tmp_path, monkeypatch): + code = _run_main(["--dir", str(tmp_path / "nope")], monkeypatch) + assert code == 2 + + +def test_collect_skips_unsupported_and_metadata_csv(tmp_path): + src = _make_source_dir(tmp_path, {"a.md": "x", "b.exe": "x"}, + csv_content="file,name\na.md,甲\n") + files = docgen_import.collect_files(src) + assert [p.name for p in files] == ["a.md"] diff --git a/ui/src/api/docgen.js b/ui/src/api/docgen.js new file mode 100644 index 0000000..1f41385 --- /dev/null +++ b/ui/src/api/docgen.js @@ -0,0 +1,59 @@ +// api/docgen.js —— 文档生成端点(P3/dev3 归属) +// 端点契约:P2 任务书 sdk/app/routers/docgen.py(dev2 开发中,联调后生效)。 +// 全部经 http.js 封装;默认读超时 600s 兜底(生成/编辑最长路径不早于 BFF 超时自断)。 +import { get, post, patch } from './http.js'; + +/** + * 上传入库(用户拖拽即走此路):multipart → vstore /ingest,入库主向量库持久可用。 + * @param {File} file + * @param {{ name?: string, category?: string, description?: string, tags?: string, as_template?: boolean }} meta + * @returns {Promise<{ok:boolean, items:[{library_id, name, size, as_template}]}>} + */ +export function uploadLibrary(file, meta = {}) { + const form = new FormData(); + form.append('file', file); + if (meta.name != null && meta.name !== '') form.append('name', meta.name); + if (meta.category != null && meta.category !== '') form.append('category', meta.category); + if (meta.description != null && meta.description !== '') form.append('description', meta.description); + if (meta.tags != null && meta.tags !== '') form.append('tags', meta.tags); + if (meta.as_template != null) form.append('as_template', meta.as_template ? 'true' : 'false'); + return post('/api/docgen/library', form); +} + +/** + * 同步生成(600s 兜底):前端落库消息与骨架后阻塞等待产物。 + * @returns {Promise<{ok:boolean, artifact:{artifact_id, title, template_name, status, ...}}>} + */ +export function generate(payload) { + return post('/api/docgen/generate', payload); +} + +/** 取 artifact 的 md 原文 */ +export function getArtifactContent(artifactId) { + return get(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/content`); +} + +/** 保存编辑后的 markdown(debounce PATCH) */ +export function saveArtifactContent(artifactId, markdown) { + return patch(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/content`, { markdown }); +} + +/** 「用 prompt 修改」:后端按 instruction 返回完整新 md */ +export function editArtifact(artifactId, instruction) { + return post(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/edit`, { instruction }); +} + +/** 导出:后端生成产物并返回 download_url(PDF/DOCX) */ +export function exportArtifact(artifactId, format) { + return post(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/export`, { format }); +} + +/** 主库管理(透传 vstore;批量导入/模版浏览用) */ +export function listLibrary({ q, category, as_template } = {}) { + return get('/api/docgen/library', { q, category, as_template }); +} + +/** 下载地址(附件导出:后端带 RFC 5987 文件名) */ +export function downloadUrl(artifactId, format) { + return `/api/docgen/artifacts/${encodeURIComponent(artifactId)}/download?format=${encodeURIComponent(format)}`; +} diff --git a/ui/src/components/bench/BenchPanel.vue b/ui/src/components/bench/BenchPanel.vue index 79ed199..6e15182 100644 --- a/ui/src/components/bench/BenchPanel.vue +++ b/ui/src/components/bench/BenchPanel.vue @@ -6,11 +6,16 @@ import { computed, ref } from 'vue'; import { useBenchStore } from '../../stores/bench.js'; import { BENCH_TABS } from '../../i18n/labels.js'; +import { DOCGEN_TAB } from '../../i18n/docgen.js'; import { BENCH_LABELS } from './benchLabels.js'; + +/** P3:tabs 数组 = labels.BENCH_TABS + 文档 TAB(docgen);渲染与选择共用同一数组 */ +const benchTabs = [...BENCH_TABS, DOCGEN_TAB]; import RetrievalPane from './RetrievalPane.vue'; import SourcesPane from './SourcesPane.vue'; import ExtractionPane from './ExtractionPane.vue'; import KgPane from './KgPane.vue'; +import DocPane from './DocPane.vue'; const props = defineProps({ collapsed: { type: Boolean, default: false }, @@ -45,7 +50,7 @@ defineExpose({ sourceCount, activeTab: computed(() => bench.activeTab) });
diff --git a/ui/src/components/bench/DocPane.vue b/ui/src/components/bench/DocPane.vue new file mode 100644 index 0000000..6a7132c --- /dev/null +++ b/ui/src/components/bench/DocPane.vue @@ -0,0 +1,502 @@ + + + + + diff --git a/ui/src/components/chat/AssistantMessage.vue b/ui/src/components/chat/AssistantMessage.vue index cf4a82d..0a4a3ec 100644 --- a/ui/src/components/chat/AssistantMessage.vue +++ b/ui/src/components/chat/AssistantMessage.vue @@ -9,6 +9,7 @@ import { sanitizeHtml, NON_MONO_HEADER } from './markdown.js'; import { layoutTextRoot } from './figureLayout.js'; import PipelineCard from './PipelineCard.vue'; import ToolCard from './ToolCard.vue'; +import DocCard from './DocCard.vue'; const props = defineProps({ message: { type: Object, required: true }, @@ -163,6 +164,9 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e @toggle="emit('toggle-tool', bi)" /> + + +
{{ T.sourcesTitle }} diff --git a/ui/src/components/chat/Composer.vue b/ui/src/components/chat/Composer.vue index 55ff891..c8c6cf4 100644 --- a/ui/src/components/chat/Composer.vue +++ b/ui/src/components/chat/Composer.vue @@ -3,8 +3,23 @@ // 模式 pill 三态(labels.MODES)/ textarea min 46px max 160px 内滚 / // Enter 换行、⌘/Ctrl+Enter 发送(输入法组合键判定不触发)/ // 发送按钮 busy 时切换为「停止」(FR-CHAT-011)。 -import { ref, computed } from 'vue'; +// +// P3(文档生成):拖拽上传(PDF/DOCX/TXT,≤20MB × ≤5)→ 先调 uploadLibrary +// 入库主向量库(持久可用)→ chips(可移除,移除仅从本次引用拿掉); +// 发送时按 intent.js 路由(与后端 intent.py 同词典同规则,漏判安全): +// docgen → chat.sendDocgen(独立分支,不经 ChatPanel); +// edit → docgen store 预填指令并切文档 TAB; +// export → docgen store 发出导出请求并切文档 TAB; +// qa → 原 emit('send') 路径(附件注记进消息文本)。 +// 路由收敛在本组件内,ChatPanel 等上游组件零改动。 +import { ref, computed, onMounted } from 'vue'; import { MODES as MODE_LABELS, T } from '../../i18n/labels.js'; +import { DG } from '../../i18n/docgen.js'; +import { route } from '../../intent.js'; +import { uploadLibrary } from '../../api/docgen.js'; +import { useDocgenStore } from '../../stores/docgen.js'; +import { useChatStore } from '../../stores/chat.js'; +import { useSettingsStore } from '../../stores/settings.js'; const props = defineProps({ mode: { type: String, default: 'ask' }, @@ -18,7 +33,121 @@ const emit = defineEmits(['send', 'stop', 'change-mode']); const draft = ref(''); const textarea = ref(null); -const canSend = computed(() => !!draft.value.trim() && !props.busy && !props.disabled); +// ---- P3 附件 chips(上传入库主向量库,持久可用;移除仅从本次引用拿掉) ---- +const MAX_ATTACH_COUNT = 5; +const MAX_ATTACH_BYTES = 20 * 1024 * 1024; // 20MB +const ACCEPT_EXT = /\.(pdf|docx?|txt)$/i; +const attachments = ref([]); // {key, name, size, file, status:'uploading'|'ready'|'error', libraryId|null} +const dropActive = ref(false); +const attachNote = ref(''); // 预检拒绝提示(数量/大小/类型,一次性) + +let chipSeq = 0; + +const uploading = computed(() => attachments.value.some((a) => a.status === 'uploading')); +const readyAttachments = computed(() => attachments.value.filter((a) => a.status === 'ready' && a.libraryId)); + +// TL 裁决(R1):manifest 为空时静默 load 一次——QA 能力徽标(联网搜索/润色/Superpowers) +// 不依赖用户打开过设置面板;send/_runStream 内部不碰。settings.js 只依赖 http.js,无循环引用。 +onMounted(() => { + try { + const settings = useSettingsStore(); + if (!settings.manifest || !settings.manifest.length) { + const sid = useChatStore().sessionId || null; + settings.load(sid).catch(() => { /* 静默:失败不阻断(设置面板打开时会重试) */ }); + } + } catch { /* settings store 未就绪(测试/降级):不阻断 */ } +}); + +const canSend = computed(() => + !!draft.value.trim() && !props.busy && !props.disabled && !uploading.value, +); + +/** 意图路由 hasAttachments:至少有成功入库的引用才算带附件 */ +const hasAttachments = computed(() => readyAttachments.value.length > 0); + +/** 意图路由 hasActiveArtifact(事实源:docgen store;未就绪时容错 false) */ +const hasActiveArtifact = computed(() => { + try { + return !!useDocgenStore().hasActiveArtifact; + } catch { + return false; + } +}); + +const hintText = computed(() => + attachments.value.length ? T.composerHint : `${T.composerHint} · ${DG.composerDropHint}`, +); + +const formatSize = (bytes) => { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +}; + +const isSupported = (f) => ACCEPT_EXT.test(f.name || ''); + +/** drop 统一入口:预检(数量/大小/类型)→ 逐文件上传入库 */ +async function handleFiles(fileList) { + const files = Array.from(fileList || []).filter((f) => f && typeof f.name === 'string'); + if (!files.length) return; + attachNote.value = ''; + const supported = files.filter(isSupported); + if (supported.length !== files.length) attachNote.value = DG.attachUnsupportedType; + const withinSize = supported.filter((f) => f.size <= MAX_ATTACH_BYTES); + if (withinSize.length !== supported.length) attachNote.value = attachNote.value || DG.attachExceedSize; + const room = Math.max(0, MAX_ATTACH_COUNT - attachments.value.length); + const accepted = withinSize.slice(0, room); + if (accepted.length !== withinSize.length) attachNote.value = attachNote.value || DG.attachExceedCount; + for (const file of accepted) { + await uploadOne(file); + } +} + +async function uploadOne(file) { + const chip = { key: ++chipSeq, name: file.name, size: file.size, file, status: 'uploading', libraryId: null }; + attachments.value.push(chip); + try { + const data = await uploadLibrary(file, { name: file.name }); + const item = Array.isArray(data?.items) ? data.items[0] : data; + chip.libraryId = item?.library_id || item?.id || data?.library_id || null; + chip.status = 'ready'; + } catch (err) { + chip.status = 'error'; + chip.error = err?.message || ''; + } +} + +/** 移除:仅从本次引用拿掉(文件已入库主向量库,持久可用) */ +function removeAttachment(chip) { + const i = attachments.value.indexOf(chip); + if (i >= 0) attachments.value.splice(i, 1); +} + +function retryAttachment(chip) { + if (chip.status !== 'error' || !chip.file) return; + const i = attachments.value.indexOf(chip); + if (i >= 0) attachments.value.splice(i, 1); + uploadOne(chip.file); +} + +// ---- 拖拽(dragover 高亮 → drop 收集) ---- +const onDragOver = (e) => { + if (props.disabled || props.busy || uploading.value) return; + const types = Array.from(e.dataTransfer?.types || []); + if (types.includes('Files')) { + e.preventDefault(); + dropActive.value = true; + } +}; +const onDragLeave = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) dropActive.value = false; +}; +const onDrop = (e) => { + e.preventDefault(); + dropActive.value = false; + if (props.disabled || props.busy || uploading.value) return; + handleFiles(e.dataTransfer ? e.dataTransfer.files : []); +}; const onKeydown = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { @@ -30,10 +159,50 @@ const onKeydown = (e) => { // Enter 单独按下 = 换行(默认行为,不拦截) }; +/** QA 路径附件注记(附件只进消息文本,不改变 QA 语义) */ +const attachmentNoteText = () => { + const names = readyAttachments.value.map((a) => a.name); + if (!names.length) return ''; + return DG.attachNotePrefix + names.join('、') + DG.attachNoteSuffix; +}; + +/** P3:意图路由分流(漏判安全——不命中一律 qa,原 send 路径逻辑不变) */ const submit = () => { if (!canSend.value) return; - emit('send', draft.value); + const text = draft.value; + let intent = 'qa'; + try { + intent = route(text, hasAttachments.value, hasActiveArtifact.value); + } catch { + intent = 'qa'; // 路由异常按漏判安全处理 + } + if (intent === 'docgen') { + // 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 同步生成 + 自动切文档 TAB) + try { + useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId)).catch(() => { + /* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */ + }); + } catch { + /* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */ + emit('send', text); + } + } else if (intent === 'edit') { + // 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB + const dg = useDocgenStore(); + dg.editPrompt = text; + dg.switchToDocTab(); + } else if (intent === 'export') { + // 导出分支:DocPane watch exportRequestSeq 后发起导出 + const dg = useDocgenStore(); + dg.requestExport(); + dg.switchToDocTab(); + } else { + // QA 原路径:附件注记进消息文本 + const note = attachmentNoteText(); + emit('send', note ? `${text} ${note}` : text); + } draft.value = ''; + attachments.value = []; // 附件已入库(library 持久),本地引用随本轮消费 }; /** 文本超出 max-height 后内部滚动(自动增高由浏览器 scrollHeight 驱动)。 @@ -64,7 +233,13 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?. @@ -115,6 +320,7 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?. padding-bottom: max(14px, env(safe-area-inset-bottom)); } .composer { + position: relative; /* P3:拖拽遮罩定位 */ max-width: 760px; margin: 0 auto; background: var(--pa-surface-1); @@ -219,6 +425,97 @@ textarea:disabled { .stop-btn { background: var(--pa-error); } + +/* ---------- P3 附件 chips ---------- */ +.attach-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} +.attach-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 3px 6px 3px 10px; + border-radius: var(--pa-r-pill); + border: 1px solid var(--pa-hairline); + background: var(--pa-surface-2); + font-size: 12px; + color: var(--pa-ink-muted); +} +.attach-chip.ready { + border-color: color-mix(in srgb, var(--pa-g-from) 40%, transparent); +} +.attach-chip.error { + border-color: rgba(255, 92, 122, 0.4); + color: var(--pa-error); +} +.ac-icon { + flex: none; + font-size: 11px; +} +.ac-name { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ac-size { + flex: none; + font-family: var(--pa-font-mono); + font-size: 11px; + color: var(--pa-ink-subtle); +} +.ac-spin { + flex: none; + color: var(--pa-g-to); +} +.ac-retry { + flex: none; + font-size: 11px; + color: var(--pa-g-to); + cursor: pointer; + padding: 0 2px; +} +.ac-remove { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + color: var(--pa-ink-subtle); + font-size: 10px; + cursor: pointer; +} +.ac-remove:hover { + color: var(--pa-ink); + background: var(--pa-surface-3); +} +.attach-note { + margin-bottom: 6px; + font-size: 11.5px; + color: var(--pa-error); +} +.drop-overlay { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--pa-r-xl); + background: color-mix(in srgb, var(--pa-surface-1) 82%, transparent); + border: 1.5px dashed var(--pa-g-from); + color: var(--pa-g-to); + font-size: 13px; + font-weight: 500; + pointer-events: none; /* drop 事件由 composer-wrap 收集 */ +} + @media (max-width: 767px) { textarea { font-size: 16px; /* 低于 16px 触发 iOS 自动放大(FR-RESP-006) */ diff --git a/ui/src/components/chat/DocCard.vue b/ui/src/components/chat/DocCard.vue new file mode 100644 index 0000000..67f2755 --- /dev/null +++ b/ui/src/components/chat/DocCard.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/ui/src/components/chat/PipelineCard.vue b/ui/src/components/chat/PipelineCard.vue index b93b396..9b87b34 100644 --- a/ui/src/components/chat/PipelineCard.vue +++ b/ui/src/components/chat/PipelineCard.vue @@ -1,5 +1,7 @@