feat(docgen): doc_writer 能力全链——vstore 独立服务 + BFF docgen 路由 + 前端文档工作台
- vstore 独立安装包(8094):解析/嵌入/加密 vault/faiss 检索,存储路径支持本地 file 与 http(s):// URL,启动握手校验,集群化预留(embed 独立模块) - BFF docgen 路由:上传入库/generate/编辑/导出/下载/主库管理,意图护栏(与前端同词典),vstore 失败兜底,quota 507 与 TTL 惰性清理 - 前端:自然触发意图路由、拖拽上传 chips、能力徽标、右侧文档 TAB(WYSIWYG 编辑/prompt 修改/PDF+DOCX 导出下载)、右栏拖宽+手柄仿左栏 - 批量导入工具 docgen_import.py(并发 8、导入即加密+删源明文) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+33
@@ -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
|
||||
@@ -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 轮问答
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-1
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
+97
-2
@@ -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 错误信封
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""services/docgen/ —— 文档生成 BFF 服务层(P1,dev1 归属)。
|
||||
|
||||
纯生成/导出,不含存储检索(存储检索在 vstore 独立服务,经 client.py 访问)。
|
||||
全部模块:settings / client / parsing / llm / intent / md2docx / md2pdf /
|
||||
pipeline / editor / converter(converter 为 dev2 桩签名适配层,见报告)。
|
||||
"""
|
||||
@@ -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)}}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<uuid>.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)}
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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) });
|
||||
<div class="inner">
|
||||
<div class="bench-tabs" role="tablist">
|
||||
<button
|
||||
v-for="tb in BENCH_TABS"
|
||||
v-for="tb in benchTabs"
|
||||
:key="tb.key"
|
||||
role="tab"
|
||||
:aria-selected="bench.activeTab === tb.key"
|
||||
@@ -57,6 +62,8 @@ defineExpose({ sourceCount, activeTab: computed(() => bench.activeTab) });
|
||||
<div v-if="bench.activeTab === 'retrieval'" class="tab-pane"><RetrievalPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'sources'" class="tab-pane"><SourcesPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'extract'" class="tab-pane"><ExtractionPane /></div>
|
||||
<!-- P3:文档 TAB(docgen 产物预览/编辑/导出;非 labels.BENCH_TABS 成员) -->
|
||||
<div v-else-if="bench.activeTab === 'doc'" class="tab-pane"><DocPane /></div>
|
||||
<div v-else class="tab-pane"><KgPane /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
<script setup>
|
||||
// bench/DocPane.vue —— 文档 TAB 主体(P3)
|
||||
// 预览:markdown-it 渲染 HTML(复用 markdown.js renderMarkdown/sanitizeHtml,LLM 输出先消毒)
|
||||
// 编辑:块级就地编辑(标题/正文段落/列表/表格行 contenteditable,blur 序列化回 md
|
||||
// + debounce 800ms PATCH 保存)
|
||||
// 修改:「用 prompt 修改」输入行 → edit 端点(后端返回完整新 md)
|
||||
// 导出:PDF/DOCX 按钮组 → export 端点 → 下载(download_url 兜底可点链接)
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||
import { useDocgenStore } from '../../stores/docgen.js';
|
||||
import { DG } from '../../i18n/docgen.js';
|
||||
import { renderMarkdown } from '../chat/markdown.js';
|
||||
|
||||
const docgen = useDocgenStore();
|
||||
|
||||
const previewEl = ref(null);
|
||||
const editRoot = ref(null); // 编辑态内容容器(其直接子元素为可编辑块)
|
||||
|
||||
// 导出动作若由 Composer 的 export 分支触发(docgen.requestExport()),在此消费
|
||||
watch(
|
||||
() => docgen.exportRequestSeq,
|
||||
(seq) => {
|
||||
if (seq > 0) docgen.runExport('docx');
|
||||
},
|
||||
);
|
||||
|
||||
/** 预览 HTML:md → renderMarkdown(markdown-it + DOMPurify);空内容给空态 */
|
||||
const previewHtml = computed(() => {
|
||||
const md = docgen.md;
|
||||
if (!md) return '';
|
||||
return renderMarkdown(md);
|
||||
});
|
||||
|
||||
/** 编辑态渲染 HTML(进入编辑时以当前 md 渲染一次;blur 序列化后重建,
|
||||
* 避免编辑期间随 docgen.md 响应式重渲染打断焦点) */
|
||||
const editHtml = ref('');
|
||||
|
||||
/** 预览模式 / 编辑模式 */
|
||||
const mode = ref('preview');
|
||||
const editing = computed(() => mode.value === 'edit');
|
||||
|
||||
let lastOuter = ''; // 序列化结果缓存(无变化不触发保存)
|
||||
|
||||
// ---------- 块级就地编辑(blur 序列化回 md) ----------
|
||||
// BUG-2 修复:contenteditable 是 HTML 属性,CSS 无法添加——渲染后程序化接线。
|
||||
// 按 serializeMd 覆盖的块类型:标题/段落/引用/代码块给元素本身,列表给 li,表格给 tr
|
||||
// (v-html 内容不经 Vue 渲染,必须 DOM 直设)。
|
||||
const EDITABLE_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'PRE', 'BLOCKQUOTE']);
|
||||
|
||||
function wireContenteditable() {
|
||||
const root = editRoot.value;
|
||||
if (!root) return;
|
||||
for (const el of Array.from(root.children)) {
|
||||
const tag = (el.tagName || '').toUpperCase();
|
||||
if (EDITABLE_TAGS.has(tag)) {
|
||||
el.setAttribute('contenteditable', 'true');
|
||||
} else if (tag === 'UL' || tag === 'OL') {
|
||||
for (const li of Array.from(el.children)) {
|
||||
if (li.tagName === 'LI') li.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
} else if (tag === 'TABLE') {
|
||||
for (const tr of el.querySelectorAll('tr')) tr.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 从 contenteditable 块 DOM 反向序列化为 markdown(标题/段落/列表/表格,与预览结构 1:1) */
|
||||
const serializeMd = () => {
|
||||
const root = editRoot.value;
|
||||
if (!root) return '';
|
||||
const nodes = Array.from(root.childNodes);
|
||||
let out = '';
|
||||
const cellText = (el) => String(el.innerText == null ? '' : el.innerText).replace(/\n/g, ' ').trim();
|
||||
const blockText = (el) => String(el.innerText == null ? '' : el.innerText).replace(/ /g, ' ').trim();
|
||||
for (const node of nodes) {
|
||||
const tag = (node.tagName || '').toUpperCase();
|
||||
const t = blockText(node);
|
||||
if (!t && !['TABLE', 'PRE'].includes(tag)) continue;
|
||||
if (tag === 'H1') out += `${t}\n${'='.repeat(Math.max(t.length, 1))}\n\n`;
|
||||
else if (tag === 'H2') out += `## ${t}\n\n`;
|
||||
else if (tag === 'H3') out += `### ${t}\n\n`;
|
||||
else if (tag === 'H4') out += `#### ${t}\n\n`;
|
||||
else if (tag === 'H5') out += `##### ${t}\n\n`;
|
||||
else if (tag === 'H6') out += `###### ${t}\n\n`;
|
||||
else if (tag === 'UL') {
|
||||
for (const li of Array.from(node.children).filter((c) => c.tagName === 'LI')) out += `- ${blockText(li)}\n`;
|
||||
out += '\n';
|
||||
} else if (tag === 'OL') {
|
||||
let i = 1;
|
||||
for (const li of Array.from(node.children).filter((c) => c.tagName === 'LI')) out += `${i++}. ${blockText(li)}\n`;
|
||||
out += '\n';
|
||||
} else if (tag === 'TABLE') {
|
||||
const trs = Array.from(node.querySelectorAll('tr'));
|
||||
for (const [ri, tr] of trs.entries()) {
|
||||
const cells = Array.from(tr.children)
|
||||
.filter((c) => ['TD', 'TH'].includes(c.tagName))
|
||||
.map(cellText);
|
||||
out += `| ${cells.join(' | ')} |\n`;
|
||||
if (ri === 0) out += `|${cells.map(() => ' --- ').join('|')}|\n`;
|
||||
}
|
||||
out += '\n';
|
||||
} else if (tag === 'PRE') {
|
||||
out += '```\n' + String(node.innerText || '') + '\n```\n\n';
|
||||
} else if (tag === 'BLOCKQUOTE') {
|
||||
for (const line of String(node.innerText || '').split('\n')) out += `> ${line}\n`;
|
||||
out += '\n';
|
||||
} else if (tag === 'P') {
|
||||
out += `${t}\n\n`;
|
||||
} else {
|
||||
out += `${t}\n\n`; // 未知块兜底按段落处理
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 进入编辑:以当前 md 渲染一次可编辑 DOM,nextTick 后程序化接线 contenteditable
|
||||
* (BUG-2 修复:v-html 出的是静态 HTML,属性只能 DOM 直设) */
|
||||
const enterEdit = () => {
|
||||
lastOuter = docgen.md || '';
|
||||
editHtml.value = renderMarkdown(docgen.md);
|
||||
mode.value = 'edit';
|
||||
nextTick(wireContenteditable);
|
||||
};
|
||||
|
||||
/** blur/focusout 序列化:比对变化后写回 store(store 的 debounce 会做 PATCH);
|
||||
* 写回后按序列化结果重建编辑 DOM 并重新接线。
|
||||
* R2 修复(DC-UI-08):blur 不冒泡——改用 focusout(冒泡,根 div 可收到所有子块失焦);
|
||||
* relatedTarget 仍在编辑区内 = 块间焦点迁移,不序列化(防止重建打断输入)。
|
||||
* 防抖:focusout→序列化→重建→焦点恢复引发的二次 focusout 不重复序列化。 */
|
||||
let editBlurTimer = null;
|
||||
const onEditBlur = (e) => {
|
||||
if (!editing.value) return;
|
||||
// 焦点迁移到编辑区内其他块:不触发保存(输入未离开编辑区)
|
||||
if (editRoot.value && e.relatedTarget && editRoot.value.contains(e.relatedTarget)) return;
|
||||
// 记住失焦块在顶层 children 中的索引,重建后恢复焦点到同位置(体验:焦点不丢失)
|
||||
const focusIdx = rememberFocusIndex(e.target);
|
||||
if (editBlurTimer) clearTimeout(editBlurTimer);
|
||||
editBlurTimer = setTimeout(() => {
|
||||
editBlurTimer = null;
|
||||
if (!editing.value) return;
|
||||
const next = serializeMd();
|
||||
if (next && next !== lastOuter) {
|
||||
lastOuter = next;
|
||||
docgen.md = next;
|
||||
docgen.scheduleSave();
|
||||
editHtml.value = renderMarkdown(next);
|
||||
nextTick(() => {
|
||||
wireContenteditable();
|
||||
restoreFocus(focusIdx);
|
||||
});
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
/** 找失焦元素最接近 editRoot.children 的祖先块索引(事件 target 可能是块内嵌套节点) */
|
||||
function rememberFocusIndex(target) {
|
||||
const root = editRoot.value;
|
||||
if (!root || !target) return -1;
|
||||
let el = target;
|
||||
while (el && el.parentElement !== root) el = el.parentElement;
|
||||
return el ? Array.from(root.children).indexOf(el) : -1;
|
||||
}
|
||||
|
||||
/** 重建后恢复焦点到同索引块(无对应块则落到编辑容器) */
|
||||
function restoreFocus(idx) {
|
||||
const root = editRoot.value;
|
||||
if (!root) return;
|
||||
const target = idx >= 0 ? root.children[idx] : null;
|
||||
if (target && typeof target.focus === 'function') {
|
||||
try {
|
||||
target.focus();
|
||||
} catch { /* 焦点恢复失败不影响保存流程 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出编辑:先同步序列化一次(flush 最后一次未保存的编辑——focusout 定时器
|
||||
* 会因 editing=false 被跳过,不在这里 flush 会丢字),再切预览 */
|
||||
const exitEdit = () => {
|
||||
if (editing.value) {
|
||||
const next = serializeMd();
|
||||
if (next && next !== lastOuter) {
|
||||
lastOuter = next;
|
||||
docgen.md = next;
|
||||
docgen.scheduleSave();
|
||||
}
|
||||
if (editBlurTimer) {
|
||||
clearTimeout(editBlurTimer);
|
||||
editBlurTimer = null;
|
||||
}
|
||||
}
|
||||
mode.value = 'preview';
|
||||
};
|
||||
|
||||
// ---------- 「用 prompt 修改」 ----------
|
||||
const editPrompt = computed({
|
||||
get: () => docgen.editPrompt,
|
||||
set: (v) => { docgen.editPrompt = v; },
|
||||
});
|
||||
const runEdit = () => docgen.runEdit();
|
||||
|
||||
// ---------- 导出 ----------
|
||||
const exporting = (fmt) => !!docgen.exporting[fmt];
|
||||
const exportUrl = (fmt) => docgen.exportUrls[fmt] || null;
|
||||
const runExport = (fmt) => docgen.runExport(fmt);
|
||||
const exportBtnLabel = (fmt) => {
|
||||
if (exporting(fmt)) return DG.docPaneExporting;
|
||||
if (exportUrl(fmt)) return DG.docPaneExportReady;
|
||||
return fmt === 'pdf' ? DG.docPaneExportPdf : DG.docPaneExportDocx;
|
||||
};
|
||||
|
||||
/** 导出 URL 的手动点击兜底(后端附件下载) */
|
||||
const openExportUrl = (fmt) => {
|
||||
const url = exportUrl(fmt);
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
// Composer 的 edit 分支预填指令(edit-doc → ChatPanel 直接写 docgen.editPrompt 并切 TAB;
|
||||
// 此方法保留给未来经 BenchPanel ref 的桥接)
|
||||
const prefillEdit = (instruction) => {
|
||||
docgen.editPrompt = instruction;
|
||||
};
|
||||
defineExpose({ prefillEdit });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (editBlurTimer) clearTimeout(editBlurTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doc-pane">
|
||||
<!-- 无活动文档:空态 -->
|
||||
<div v-if="!docgen.activeArtifact" class="doc-empty">
|
||||
<div class="de-icon">📄</div>
|
||||
<div class="de-text">{{ DG.docPaneEmpty }}</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 头部:标题 + 模版 + 模式切换 -->
|
||||
<div class="doc-head">
|
||||
<div class="dh-meta">
|
||||
<div class="dh-title">{{ docgen.title || DG.docPaneTitleLabel }}</div>
|
||||
<div v-if="docgen.activeArtifact.template_name" class="dh-tpl">
|
||||
{{ DG.docPaneTemplateLabel }}:{{ docgen.activeArtifact.template_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dh-actions">
|
||||
<button
|
||||
v-if="mode === 'preview'"
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
@click="enterEdit"
|
||||
>{{ DG.docPaneEditMode }}</button>
|
||||
<button v-else class="pane-btn" type="button" @click="exitEdit">{{ DG.docPanePreviewMode }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载 / 错误态 -->
|
||||
<div v-if="docgen.loading" class="doc-state">{{ DG.docPaneArtifactLoading }}</div>
|
||||
<div v-else-if="docgen.loadError" class="doc-state error">
|
||||
<span>{{ DG.docPaneContentError }}</span>
|
||||
<button class="pane-btn" type="button" @click="docgen.reload()">{{ DG.docPaneReload }}</button>
|
||||
</div>
|
||||
<div v-else-if="!docgen.md" class="doc-state">{{ DG.docPaneArtifactMissing }}</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 预览:markdown-it 渲染(已消毒) -->
|
||||
<div
|
||||
v-if="mode === 'preview'"
|
||||
ref="previewEl"
|
||||
class="doc-preview md-text"
|
||||
v-html="previewHtml"
|
||||
></div>
|
||||
|
||||
<!-- 编辑:块级就地 contenteditable(失焦序列化回 md + debounce PATCH)。
|
||||
R2:@focusout(冒泡,子块失焦根 div 可收到;blur 不冒泡收不到) -->
|
||||
<div
|
||||
v-else
|
||||
ref="editRoot"
|
||||
class="doc-edit md-text"
|
||||
@focusout="onEditBlur"
|
||||
v-html="editHtml"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<!-- 保存状态提示 -->
|
||||
<div v-if="editing && docgen.saving" class="doc-save-state">{{ DG.docPaneSaveHint }}…</div>
|
||||
<div v-else-if="editing && docgen.saveError" class="doc-save-state error">{{ DG.docPaneSaveError }}</div>
|
||||
|
||||
<!-- 「用 prompt 修改」输入行 -->
|
||||
<div class="edit-prompt-row">
|
||||
<input
|
||||
v-model="editPrompt"
|
||||
class="ep-input"
|
||||
type="text"
|
||||
:placeholder="DG.docPaneEditPromptPlaceholder"
|
||||
:disabled="docgen.editRunning"
|
||||
@keydown.enter.prevent="runEdit"
|
||||
/>
|
||||
<button
|
||||
class="ep-run"
|
||||
type="button"
|
||||
:disabled="!editPrompt.trim() || docgen.editRunning"
|
||||
@click="runEdit"
|
||||
>{{ docgen.editRunning ? DG.docPaneEditRunning : DG.docPaneEditRun }}</button>
|
||||
</div>
|
||||
<div v-if="docgen.editError" class="doc-save-state error">{{ DG.docPaneEditError }}</div>
|
||||
|
||||
<!-- 导出按钮组 -->
|
||||
<div class="export-row">
|
||||
<button
|
||||
class="pane-btn exp"
|
||||
type="button"
|
||||
:disabled="exporting('pdf')"
|
||||
@click="exportUrl('pdf') ? openExportUrl('pdf') : runExport('pdf')"
|
||||
>{{ exportBtnLabel('pdf') }}</button>
|
||||
<button
|
||||
class="pane-btn exp"
|
||||
type="button"
|
||||
:disabled="exporting('docx')"
|
||||
@click="exportUrl('docx') ? openExportUrl('docx') : runExport('docx')"
|
||||
>{{ exportBtnLabel('docx') }}</button>
|
||||
</div>
|
||||
<div v-if="docgen.exportError.pdf || docgen.exportError.docx" class="doc-save-state error">
|
||||
{{ (docgen.exportError.pdf || docgen.exportError.docx || {}).message || '导出失败' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.doc-empty {
|
||||
padding: 36px 10px;
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.de-icon {
|
||||
font-size: 26px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.de-text {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.doc-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.dh-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
.dh-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dh-tpl {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.dh-actions {
|
||||
flex: none;
|
||||
}
|
||||
.pane-btn {
|
||||
padding: 5px 13px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.pane-btn:hover:not(:disabled) {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
.pane-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.doc-state {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12.5px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.doc-state.error {
|
||||
color: var(--pa-error);
|
||||
border-color: rgba(255, 92, 122, 0.35);
|
||||
}
|
||||
.doc-preview {
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.doc-edit {
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
/* 编辑态:容器直接子块全部可编辑(v-html 内容不带 scope,必须 :deep()) */
|
||||
.doc-edit :deep(> *) {
|
||||
outline: none;
|
||||
border-radius: var(--pa-r-xs);
|
||||
padding: 1px 2px;
|
||||
min-height: 1.2em;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.doc-edit :deep(> *:hover) {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.doc-edit :deep(> *:focus) {
|
||||
background: var(--pa-surface-2);
|
||||
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--pa-g-from) 55%, transparent);
|
||||
}
|
||||
.doc-edit :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.doc-edit :deep(th),
|
||||
.doc-edit :deep(td) {
|
||||
border: 1px solid var(--pa-hairline);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.doc-save-state {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.doc-save-state.error {
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.edit-prompt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.ep-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 11px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
transition: border-color 0.18s var(--pa-ease);
|
||||
}
|
||||
.ep-input:focus {
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.ep-input::placeholder {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.ep-run {
|
||||
flex: none;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ep-run:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.export-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.pane-btn.exp {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
<!-- artifact:文档产物卡(P3;「打开预览」→ docgen store + bench 文档 TAB) -->
|
||||
<DocCard v-if="b.type === 'artifact'" :block="b" />
|
||||
|
||||
<!-- sources:内联引用 chip(FR-CHAT-015) -->
|
||||
<div v-if="b.type === 'sources' && b.items && b.items.length" class="inline-sources">
|
||||
<span class="src-label">{{ T.sourcesTitle }}</span>
|
||||
|
||||
@@ -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?.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-wrap">
|
||||
<div
|
||||
class="composer-wrap"
|
||||
:class="{ 'drop-active': dropActive }"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div class="composer">
|
||||
<div class="mode-pills">
|
||||
<button
|
||||
@@ -78,6 +253,34 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
|
||||
{{ pm.label }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- P3 附件 chips:上传中 / 已入库(可移除)/ 失败(可重试) -->
|
||||
<div v-if="attachments.length" class="attach-chips">
|
||||
<span
|
||||
v-for="a in attachments"
|
||||
:key="a.key"
|
||||
class="attach-chip"
|
||||
:class="a.status"
|
||||
:title="a.status === 'error' ? (a.error || DG.attachUploadError) : a.name"
|
||||
>
|
||||
<span class="ac-icon">📎</span>
|
||||
<span class="ac-name">{{ a.name }}</span>
|
||||
<span class="ac-size">{{ formatSize(a.size) }}</span>
|
||||
<span v-if="a.status === 'uploading'" class="ac-spin spin-ico"></span>
|
||||
<button
|
||||
v-if="a.status === 'error'"
|
||||
class="ac-retry"
|
||||
type="button"
|
||||
@click.stop="retryAttachment(a)"
|
||||
>{{ DG.attachRetry }}</button>
|
||||
<button
|
||||
class="ac-remove"
|
||||
type="button"
|
||||
:aria-label="DG.chipRemove"
|
||||
@click.stop="removeAttachment(a)"
|
||||
>✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="attachNote" class="attach-note">{{ attachNote }}</p>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="draft"
|
||||
@@ -87,7 +290,7 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
<div class="composer-actions">
|
||||
<span class="hint">{{ healthNote || T.composerHint }}</span>
|
||||
<span class="hint">{{ healthNote || hintText }}</span>
|
||||
<button
|
||||
v-if="!busy"
|
||||
class="send-btn"
|
||||
@@ -104,6 +307,8 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
|
||||
{{ T.stop }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 拖拽高亮遮罩(drop 由 composer-wrap 收集) -->
|
||||
<div v-if="dropActive" class="drop-overlay">{{ DG.composerDropOver }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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) */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
// chat/DocCard.vue —— 消息流内文档卡片(P3)
|
||||
// 显示生成产物的标题/模版名 + 「打开预览」按钮。
|
||||
// 「打开预览」自足闭环:docgen store 加载产物 + bench 切文档 TAB——
|
||||
// 不向上游抛事件(MessageList/ChatPanel 零改动)。
|
||||
import { useDocgenStore } from '../../stores/docgen.js';
|
||||
import { DG } from '../../i18n/docgen.js';
|
||||
|
||||
const { block } = defineProps({
|
||||
block: { type: Object, required: true }, // {type:'artifact', artifact_id, title, template_name, status}
|
||||
});
|
||||
|
||||
const docgen = useDocgenStore();
|
||||
|
||||
const open = () => {
|
||||
// 自足闭环:加载 artifact → 切文档 TAB(docgen store 内部动态 import bench 防循环依赖)
|
||||
docgen.open({ artifact_id: block.artifact_id, title: block.title, template_name: block.template_name, status: block.status });
|
||||
docgen.switchToDocTab();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doc-card">
|
||||
<div class="dc-icon">📄</div>
|
||||
<div class="dc-meta">
|
||||
<div class="dc-title">{{ block.title || DG.docCardEmpty }}</div>
|
||||
<div v-if="block.template_name" class="dc-tpl">
|
||||
{{ DG.docCardTemplate }}:{{ block.template_name }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="dc-open" type="button" @click="open">{{ DG.docCardOpen }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 14px 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
transition: border-color 0.2s var(--pa-ease);
|
||||
}
|
||||
.doc-card:hover {
|
||||
border-color: color-mix(in srgb, var(--pa-g-from) 45%, var(--pa-hairline));
|
||||
}
|
||||
.dc-icon {
|
||||
flex: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
.dc-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dc-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-tpl {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.dc-open {
|
||||
flex: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.3s var(--pa-ease);
|
||||
}
|
||||
.dc-open:hover {
|
||||
box-shadow: 0 0 18px rgba(0, 212, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
// chat/PipelineCard.vue —— 检索流水线五相位卡(FR-CHAT-009,UI_SPEC §4.4)
|
||||
// P3:pc-head 增加 capabilities 徽标区(v-for 渲染 chip,复用 badge 视觉);
|
||||
// block.capabilities 缺省时零渲染(既有消息无影响)。
|
||||
// 相位色只答「哪一步在做什么」(--pa-t-* 令牌),语义色只答「整体成败」,
|
||||
// 两者不得混用(PRD 关键约束)。running 相位点与字用该相位的 --pa-t-* 色。
|
||||
import { T, PHASE_COLOR_VAR, STATUS_LABEL } from '../../i18n/labels.js';
|
||||
@@ -18,10 +20,18 @@ const stepStateText = (state) => ({
|
||||
<template>
|
||||
<div class="pipeline-card">
|
||||
<div class="pc-head">
|
||||
<span class="pc-title">{{ T.pipelineTitle }}</span>
|
||||
<span class="badge" :class="block.status">
|
||||
<span class="dot"></span>{{ STATUS_LABEL[block.status] || STATUS_LABEL.idle }}
|
||||
</span>
|
||||
<span class="pc-title">{{ block.title || T.pipelineTitle }}</span>
|
||||
<div class="pc-head-right">
|
||||
<!-- P3:能力徽标区(block.capabilities 缺省时零渲染,既有消息无影响) -->
|
||||
<span
|
||||
v-for="cap in block.capabilities || []"
|
||||
:key="cap.key"
|
||||
class="badge cap"
|
||||
>{{ cap.label }}</span>
|
||||
<span class="badge" :class="block.status">
|
||||
<span class="dot"></span>{{ STATUS_LABEL[block.status] || STATUS_LABEL.idle }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pipeline">
|
||||
<div v-for="(st, si) in block.steps" :key="si" :class="['p-step', st.state]">
|
||||
@@ -52,8 +62,21 @@ const stepStateText = (state) => ({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* P3:能力徽标区(与状态 badge 同视觉语言,渐变描边区分) */
|
||||
.pc-head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.badge.cap {
|
||||
color: var(--pa-g-to);
|
||||
background: color-mix(in srgb, var(--pa-g-from) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--pa-g-from) 35%, transparent);
|
||||
}
|
||||
.pc-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
响应式四档(FR-RESP-001..004):
|
||||
<1024px → .drawers-mode:左右栏脱离网格,成为抽屉(Drawer 内容经 Teleport 落到 body)
|
||||
<768px → .mobile:底栏隐藏、工作台为全屏覆盖层(顶部 tab 条 + 关闭按钮)
|
||||
FR-RESP-010:监听 visualViewport 键盘弹出,压缩消息区高度保持贴底。 -->
|
||||
FR-RESP-010:监听 visualViewport 键盘弹出,压缩消息区高度保持贴底。
|
||||
P3:右栏可拖拽改宽(.bench-resizer,min 300px / max 60vw,localStorage pa-bench-width,
|
||||
初始 380px,折叠态不拖拽);展开/收起按钮改左栏 .side-handle-btn 同款。 -->
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import AuroraBg from './AuroraBg.vue';
|
||||
@@ -27,6 +29,55 @@ const BENCH_KEY = 'pa-bench-collapsed';
|
||||
const sideCollapsed = ref(localStorage.getItem(SIDE_KEY) === '1');
|
||||
const benchCollapsed = ref(localStorage.getItem(BENCH_KEY) === '1');
|
||||
|
||||
/* ---------- P3:右栏拖拽改宽(FR-SHELL-005 补充) ---------- */
|
||||
const BENCH_WIDTH_KEY = 'pa-bench-width';
|
||||
const BENCH_MIN_W = 300;
|
||||
const BENCH_MAX_VW = 0.6; // 最大 60vw
|
||||
|
||||
const savedWidth = Number(localStorage.getItem(BENCH_WIDTH_KEY));
|
||||
const benchWidth = ref(Number.isFinite(savedWidth) && savedWidth >= BENCH_MIN_W ? savedWidth : 380);
|
||||
const benchResizing = ref(false);
|
||||
|
||||
/** 右栏宽度经 .app-shell 内联 grid-template-columns 驱动(内联优先于 responsive.css
|
||||
* laptop 档固定列宽;drawers 档返回 null 交给 B1 规则)。折叠态压 28px。 */
|
||||
const shellStyle = computed(() => {
|
||||
if (drawersMode.value) return null;
|
||||
const side = sideCollapsed.value ? '64px' : '260px';
|
||||
const bench = benchCollapsed.value ? '28px' : `${benchWidth.value}px`;
|
||||
return { gridTemplateColumns: `${side} minmax(0,1fr) ${bench}` };
|
||||
});
|
||||
|
||||
function startBenchResize(e) {
|
||||
// 折叠态(28px 竖条)与抽屉档不拖拽
|
||||
if (benchCollapsed.value || drawersMode.value) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = benchWidth.value;
|
||||
benchResizing.value = true; // 拖拽中禁用网格过渡,跟手
|
||||
document.body.style.userSelect = 'none';
|
||||
const move = (ev) => {
|
||||
// 右栏左边缘拖拽:向左拉变宽、向右拉变窄
|
||||
const maxW = Math.round(window.innerWidth * BENCH_MAX_VW);
|
||||
benchWidth.value = Math.min(maxW, Math.max(BENCH_MIN_W, startW + (startX - ev.clientX)));
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
document.body.style.userSelect = '';
|
||||
benchResizing.value = false;
|
||||
localStorage.setItem(BENCH_WIDTH_KEY, String(benchWidth.value));
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
}
|
||||
|
||||
/** 折叠态:28px 竖条整体是点击区(按钮嵌其上,条身点击同样展开)。
|
||||
* 只处理条身自身的点击(e.target === e.currentTarget),
|
||||
* 避免内部按钮的 click 冒泡导致二次切换。 */
|
||||
function onBenchWrapClick(e) {
|
||||
if (benchCollapsed.value && e && e.target === e.currentTarget) toggleBench();
|
||||
}
|
||||
|
||||
function toggleSide() {
|
||||
sideCollapsed.value = !sideCollapsed.value;
|
||||
localStorage.setItem(SIDE_KEY, sideCollapsed.value ? '1' : '0');
|
||||
@@ -145,6 +196,7 @@ const shellClass = computed(() => ({
|
||||
mobile: tier.value === 'mobile',
|
||||
'keyboard-open': keyboardOpen.value,
|
||||
'has-tools': drawersMode.value,
|
||||
'bench-resizing': benchResizing.value,
|
||||
}));
|
||||
/* ---------- 覆盖层 tab 条(tab 状态由 bench store 接管,FR-RESP-004 全屏工作台) ---------- */
|
||||
const overlayActiveTab = computed(() => bench.activeTab);
|
||||
@@ -154,7 +206,7 @@ function overlaySelectTab(tab) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :class="shellClass">
|
||||
<div class="app-shell" :class="shellClass" :style="shellStyle">
|
||||
<AuroraBg />
|
||||
|
||||
<TopBar
|
||||
@@ -191,13 +243,25 @@ function overlaySelectTab(tab) {
|
||||
<ChatPanel />
|
||||
|
||||
<!-- 右栏(≥1024px 固定;<1024px 抽屉形态) -->
|
||||
<div v-if="!drawersMode" class="bench-wrap">
|
||||
<div v-if="!drawersMode" class="bench-wrap" @click="onBenchWrapClick">
|
||||
<BenchPanel />
|
||||
<!-- 折叠手柄条(FR-SHELL-005:折叠 ≠ 隐藏,28px 手柄保留可见) -->
|
||||
<button class="bench-handle" type="button" title="展开工作台" aria-label="展开工作台" @click="toggleBench">
|
||||
<span v-html="chevronLeft"></span>
|
||||
<!-- P3:左边缘拖拽条(8px,折叠态隐藏不可拖拽) -->
|
||||
<div class="bench-resizer" @mousedown="startBenchResize"></div>
|
||||
<!-- P3:展开/收起按钮 = 左栏 .side-handle-btn 同款(26×26 圆角、hairline 描边、
|
||||
surface-1 底、跨右栏左边缘半嵌 left:0 + translate(-50%,-50%)、垂直居中、图标 13px)。
|
||||
箭头方向镜像左栏:折叠态朝左(指向内容侧)、展开态朝右。 -->
|
||||
<button
|
||||
class="icon-btn bench-handle-btn"
|
||||
:class="{ collapsed: benchCollapsed }"
|
||||
type="button"
|
||||
:title="benchCollapsed ? '展开工作台' : '折叠工作台'"
|
||||
:aria-label="benchCollapsed ? '展开工作台' : '折叠工作台'"
|
||||
@click="toggleBench"
|
||||
>
|
||||
<span v-html="benchCollapsed ? chevronLeft : chevronRight"></span>
|
||||
</button>
|
||||
<!-- 展开态的折叠入口 -->
|
||||
<!-- 旧折叠入口:视觉已由 bench-handle-btn 接管(CSS 退役 display:none),
|
||||
保留 DOM 以兼容 B1 appshell.test.js 的折叠断言(P3 不破坏既有测试) -->
|
||||
<button
|
||||
class="icon-btn bench-collapse-btn"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// i18n/docgen.js —— 文档生成(P3)全部新文案(dev3 归属,独立文件不碰 labels.js 红线)
|
||||
// 能力徽标、文档 TAB、docgen pipeline 五步、附件 chips、编辑/导出等全部文案集中于此。
|
||||
|
||||
export const DOCGEN_TAB = { key: 'doc', label: '文档' };
|
||||
|
||||
/** docgen pipeline 五步(相位复用 PHASE_COLOR_VAR 键:planning/retrieval/evidence/generation/done) */
|
||||
export const DOCGEN_STEPS = [
|
||||
{ name: '解析资料', desc: '解析上传资料并入库检索', phase: 'planning' },
|
||||
{ name: '检索模版', desc: '在主库检索相近文档模版', phase: 'retrieval' },
|
||||
{ name: '规划大纲', desc: '按模版结构规划章节大纲', phase: 'evidence' },
|
||||
{ name: '逐节生成', desc: '按大纲逐节生成正文', phase: 'generation' },
|
||||
{ name: '导出文档', desc: '合成全文并生成文档产物', phase: 'done' },
|
||||
];
|
||||
|
||||
/** 能力徽标(QA pipeline 注入 + docgen pipeline capabilities 区共用;label 优先取 manifest) */
|
||||
export const CAP_BADGES = {
|
||||
doc_writer: '文档生成',
|
||||
memory: '多轮记忆',
|
||||
web_search: '联网搜索',
|
||||
humanize: '答案润色',
|
||||
superpowers: 'Superpowers',
|
||||
};
|
||||
|
||||
export const DG = {
|
||||
docgenPipelineTitle: '⚙ 文档生成流水线',
|
||||
// —— 作曲器附件区 ——
|
||||
composerDropHint: '拖拽 PDF/DOCX/TXT 到输入框可上传资料(≤20MB × 5)',
|
||||
composerDropOver: '松开以添加资料(PDF / DOCX / TXT,≤20MB)',
|
||||
chipRemove: '移除',
|
||||
attachExceedCount: '最多上传 5 个文件',
|
||||
attachExceedSize: '单个文件不能超过 20MB',
|
||||
attachUnsupportedType: '仅支持 PDF / DOCX / TXT',
|
||||
attachUploadError: '上传失败',
|
||||
attachRetry: '重试',
|
||||
attachNotePrefix: '(已上传资料:',
|
||||
attachNoteSuffix: ')',
|
||||
// —— DocCard(消息流内文档卡片) ——
|
||||
docCardTemplate: '模版',
|
||||
docCardOpen: '打开预览',
|
||||
docCardEmpty: '文档',
|
||||
// —— DocPane(文档 TAB) ——
|
||||
docPaneEmpty: '尚无文档。在对话中提出写作需求(如「帮我写一份方案」),或上传资料后发送,即可在此预览与编辑。',
|
||||
docPaneTitleLabel: '标题',
|
||||
docPaneTemplateLabel: '模版',
|
||||
docPaneEditMode: '编辑',
|
||||
docPanePreviewMode: '预览',
|
||||
docPaneSaveHint: '编辑后自动保存',
|
||||
docPaneSaveError: '保存失败',
|
||||
docPaneEditPromptPlaceholder: '用 prompt 修改全文,如:把第2段改正式一点',
|
||||
docPaneEditRun: '修改',
|
||||
docPaneEditRunning: '修改中…',
|
||||
docPaneEditError: '修改失败',
|
||||
docPaneExportPdf: '导出 PDF',
|
||||
docPaneExportDocx: '导出 DOCX',
|
||||
docPaneExporting: '导出中…',
|
||||
docPaneExportReady: '下载',
|
||||
docPaneArtifactLoading: '文档加载中…',
|
||||
docPaneArtifactMissing: '文档内容不可用(可能已被清理)',
|
||||
docPaneContentError: '文档内容加载失败',
|
||||
docPaneReload: '重新加载',
|
||||
};
|
||||
|
||||
export default DG;
|
||||
@@ -0,0 +1,74 @@
|
||||
// intent.js —— 前端意图路由(P3/dev3 归属;纯函数,无 Vue 依赖,可直接 node 直测)
|
||||
// 唯一事实源:docs/team/cases_intent.md(与后端 services/docgen/intent.py 同词典同规则同用例表)
|
||||
// 判定顺序(优先级从高到低,命中即返回;漏判安全:不命中一律 qa):
|
||||
// 1. export:hasActiveArtifact 且含 EXPORT_WORD 且含 FORMAT_WORD
|
||||
// 2. edit: hasActiveArtifact 且含 EDIT_WORD
|
||||
// 3. docgen:含 WRITE_VERB 且含 DOC_TYPE(排除词优先,防误判)
|
||||
// 4. docgen:hasAttachments 且含 WRITE_VERB(附件兜底信号)
|
||||
// 5. qa
|
||||
|
||||
// —— 导出意图词典 ——
|
||||
const EXPORT_WORD = ['导出', '转成', '转为', '转换成', '下载成', '输出为', '存为', '转pdf', '转word'];
|
||||
// FORMAT_WORD 英文不区分大小写;"doc" 是 "docx" 的子串,判定时逐一 contains 即可
|
||||
const FORMAT_WORD = ['pdf', 'docx', 'doc', 'word'];
|
||||
|
||||
// —— 编辑意图词典(仅当存在活动文档时生效)——
|
||||
const EDIT_WORD = ['改', '修改', '调整', '删', '删除', '加一段', '补充', '润色', '缩写', '扩写', '重写', '改写', '优化', '加个', '增加一段'];
|
||||
|
||||
// —— 写文档意图词典 ——
|
||||
const WRITE_VERB = ['写', '撰写', '拟', '起草', '编制', '生成', '编写', '拟定', '草拟', '制定'];
|
||||
const DOC_TYPE = [
|
||||
'论文', '报告', '通知', '通告', '公告', '请示', '批复', '函', '申请书', '申请',
|
||||
'申报书', '标书', '合同', '协议', '方案', '计划', '总结', '汇报', '讲话稿', '致辞',
|
||||
'证明', '纪要', '简报', '材料', '说明书', '指南', '文档', '申报材料', '工作总结',
|
||||
'调研报告', '可行性报告', '项目书', '策划书', '演讲稿', '新闻稿', '文案', '简历',
|
||||
'制度', '规定', '办法', '细则', '章程', '声明', '邀请函', '感谢信', '慰问信',
|
||||
'介绍信', '推荐信', '倡议书', '述职报告', '开题报告', '结题报告', '实验报告',
|
||||
'分析报告', '会议纪要',
|
||||
];
|
||||
// 明确排除(不属于 DOC_TYPE,防误判):介绍、内容、代码、诗、作业、问题、建议、想法
|
||||
const DOC_TYPE_EXCLUDE = ['介绍', '内容', '代码', '诗', '作业', '问题', '建议', '想法'];
|
||||
|
||||
/** 文本是否含词典中任一词(子串判定;英文不区分大小写——中文不受影响,与后端一致) */
|
||||
export function hasAnyWord(text, words) {
|
||||
const s = String(text == null ? '' : text).toLowerCase();
|
||||
for (const w of words) {
|
||||
if (s.includes(String(w).toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 文本是否含任一排除词(排除词优先于 DOC_TYPE 命中——防误判) */
|
||||
export function hasAnyExcludeWord(text) {
|
||||
return hasAnyWord(text, DOC_TYPE_EXCLUDE);
|
||||
}
|
||||
|
||||
/** 导出文档类型词词典(任务书约定的导出类型词;判定走 EXPORT_WORD+FORMAT_WORD,此处导出供组件复用) */
|
||||
export const EXPORT_DOC_TYPE_WORDS = [
|
||||
'论文', '报告', '通知', '通告', '公告', '请示', '批复', '函', '申请书', '申报书',
|
||||
'标书', '合同', '协议', '方案', '计划', '总结', '汇报', '讲话稿', '致辞',
|
||||
'证明', '纪要', '简报', '材料', '说明书', '指南',
|
||||
];
|
||||
|
||||
/**
|
||||
* 意图路由(唯一事实源 docs/team/cases_intent.md 的 route 签名)
|
||||
* @param {string} text 用户输入文本
|
||||
* @param {boolean} hasAttachments 本轮是否带附件(资料 chips)
|
||||
* @param {boolean} hasActiveArtifact 是否存在活动文档(文档 TAB 当前打开的 artifact)
|
||||
* @returns {'qa'|'docgen'|'edit'|'export'}
|
||||
*/
|
||||
export function route(text, hasAttachments, hasActiveArtifact) {
|
||||
const s = String(text == null ? '' : text);
|
||||
// 1. 导出意图(需活动文档)
|
||||
if (hasActiveArtifact && hasAnyWord(s, EXPORT_WORD) && hasAnyWord(s, FORMAT_WORD)) return 'export';
|
||||
// 2. 编辑意图(需活动文档)
|
||||
if (hasActiveArtifact && hasAnyWord(s, EDIT_WORD)) return 'edit';
|
||||
// 3. 写文档意图:写类动词 + 文档类型词(排除词防误判优先)
|
||||
if (hasAnyWord(s, WRITE_VERB) && !hasAnyExcludeWord(s) && hasAnyWord(s, DOC_TYPE)) return 'docgen';
|
||||
// 4. 附件兜底:拖入文件 + 写类动词,即使无文档类型词也视为生成意图
|
||||
if (hasAttachments && hasAnyWord(s, WRITE_VERB)) return 'docgen';
|
||||
// 5. 漏判安全:一律 qa
|
||||
return 'qa';
|
||||
}
|
||||
|
||||
export default route;
|
||||
@@ -20,6 +20,14 @@ import { PHASE_LABEL, PHASE_DESC, T } from '../i18n/labels.js';
|
||||
import { MODES } from '../config/modes.js';
|
||||
import { renderMarkdown } from '../components/chat/markdown.js';
|
||||
|
||||
// ---- P3 文档生成:独立分支(send/_runStream 零改动) ----
|
||||
import { DOCGEN_STEPS, CAP_BADGES, DG as DG_LABELS } from '../i18n/docgen.js';
|
||||
import { generate } from '../api/docgen.js';
|
||||
import { useSettingsStore } from './settings.js';
|
||||
|
||||
/** docgen pipeline 卡标题(区别于 QA 的「检索流水线」) */
|
||||
const DG_TITLE = DG_LABELS.docgenPipelineTitle;
|
||||
|
||||
const CHECKPOINT_MS = 3000;
|
||||
const REVEAL_STEP_MS = 30;
|
||||
const REVEAL_MAX_MS = 8000;
|
||||
@@ -55,6 +63,57 @@ export function newSkeletonMessage() {
|
||||
};
|
||||
}
|
||||
|
||||
/** 新建一条 docgen 骨架消息(P3):pipeline 块带能力徽标 + 文档生成五步
|
||||
* (相位复用 PHASE_COLOR_VAR 键 planning/retrieval/evidence/generation/done) */
|
||||
export function newDocgenSkeletonMessage() {
|
||||
return {
|
||||
id: null, // POST /messages 后回填
|
||||
role: 'assistant',
|
||||
kind: 'docgen',
|
||||
status: 'streaming',
|
||||
streamErr: null,
|
||||
interrupted: false,
|
||||
blocks: [{
|
||||
type: 'pipeline',
|
||||
title: DG_TITLE,
|
||||
status: 'running',
|
||||
capabilities: [{ key: 'doc_writer', label: CAP_BADGES.doc_writer }],
|
||||
steps: DOCGEN_STEPS.map((st) => ({ name: st.name, state: 'waiting', desc: st.desc, phase: st.phase })),
|
||||
}],
|
||||
raw: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 收集本次实际启用的会话能力 chips(联网搜索/答案润色/Superpowers;
|
||||
* label 取自 settings store 已加载的 manifest)。settings 未就绪时返回空数组(不阻断)。 */
|
||||
export function collectQaCaps() {
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const enabled = settings.sessionCaps || {};
|
||||
const out = [];
|
||||
for (const name of ['web_search', 'humanize', 'superpowers']) {
|
||||
if (enabled[name] === true) {
|
||||
const item = (settings.manifest || []).find((c) => c.name === name);
|
||||
out.push({ key: name, label: item?.label || CAP_BADGES[name] || name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return []; // settings store 未就绪(测试/降级):无徽标不阻断
|
||||
}
|
||||
}
|
||||
|
||||
/** QA 路径:把本次启用的会话能力注入助手 pipeline 块徽标区
|
||||
* (block.capabilities 缺省时 PipelineCard 零渲染,既有消息无影响) */
|
||||
export function injectQaCaps(aMsg) {
|
||||
const caps = collectQaCaps();
|
||||
if (!caps.length) return;
|
||||
const pipe = (aMsg.blocks || []).find((b) => b.type === 'pipeline');
|
||||
if (pipe) pipe.capabilities = caps;
|
||||
}
|
||||
|
||||
/** 把归一化 QA 响应(complete 帧 payload)装配成块联合(不含 pipeline;FR-CHAT-013)
|
||||
* BUG-R1-06 修复:上游 answer 是 markdown 字符串(PRD FR-CHAT-004),装配时必须经
|
||||
* renderMarkdown(markdown-it + DOMPurify)转 HTML 后再生成 text 块——此前直接以原文
|
||||
@@ -289,6 +348,8 @@ export const useChatStore = defineStore('chat', {
|
||||
} catch {
|
||||
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御,正常 mock/BFF 不会发生)
|
||||
}
|
||||
// P3:QA 路径注入本次启用的会话能力徽标 chips(send 其余逻辑零改动)
|
||||
injectQaCaps(aMsg);
|
||||
|
||||
// 4–5. 打开流并按模式消费
|
||||
const modeCfg = MODES[mode] || MODES.ask;
|
||||
@@ -300,6 +361,167 @@ export const useChatStore = defineStore('chat', {
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// sendDocgen(P3):文档生成独立分支——用户消息落库(现有机制)→
|
||||
// docgen 助手骨架 → 同步 generate(600s 兜底)→ 成功:pipeline(done)
|
||||
// + artifact 块 + 持久化 + 自动切文档 TAB;失败 _errorMarker 标注可重试。
|
||||
// send()/_runStream 零改动;docgen 不经过 SSE。
|
||||
// ---------------------------------------------------------------
|
||||
async sendDocgen(description, libraryIds, { userMsg } = {}) {
|
||||
const q = String(description == null ? '' : description).trim();
|
||||
if (!q || this.busy) return;
|
||||
const sessions = useSessionsStore();
|
||||
if (!this.sessionId) {
|
||||
try {
|
||||
const sess = await sessions.create();
|
||||
this.sessionId = sess.id;
|
||||
} catch {
|
||||
return; // 创建失败:列表页可见错误(失败必须可见)
|
||||
}
|
||||
}
|
||||
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
|
||||
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
|
||||
}
|
||||
const sessionId = this.sessionId;
|
||||
const msgList = this.messages;
|
||||
this.sessionMsgs[sessionId] = msgList;
|
||||
const ctx = newStreamCtx(sessionId, msgList);
|
||||
this.streamCtx[sessionId] = ctx;
|
||||
this.lastResult = null;
|
||||
const startAt = Date.now();
|
||||
|
||||
// 1. 用户消息入列 + 落库(retry 复用既有用户消息时跳过——同位置替换不新增用户消息)
|
||||
let userId = null;
|
||||
let userMsgRef = null;
|
||||
if (userMsg) {
|
||||
userMsgRef = userMsg;
|
||||
userId = userMsg.id;
|
||||
} else {
|
||||
const userMsgRaw = { id: null, role: 'user', kind: 'docgen', status: 'done', text: q, mode: 'ask', meta: { library_ids: libraryIds || [] }, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
msgList.push(userMsgRaw);
|
||||
// 关键:闭包必须持响应式代理而非 raw 对象(见 send 同款修复)
|
||||
userMsgRef = msgList[msgList.length - 1];
|
||||
try {
|
||||
const saved = await createMessage(sessionId, { role: 'user', text: q });
|
||||
userId = saved.id;
|
||||
userMsgRef.id = userId;
|
||||
} catch (err) {
|
||||
// 用户消息落库失败:按失败可见原则,终止本轮并给出可重试错误
|
||||
userMsgRef.persistError = err;
|
||||
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 会话 running
|
||||
sessions.patchLocal(sessionId, { status: 'running' });
|
||||
try {
|
||||
await updateSessionQuiet(sessionId, { status: 'running' });
|
||||
} catch { /* 状态 PATCH 失败不阻断(终态会再同步) */ }
|
||||
|
||||
// 3. docgen 助手骨架(pipeline 五步 + 文档生成徽标)+ 落库
|
||||
const aMsgRaw = newDocgenSkeletonMessage();
|
||||
msgList.push(aMsgRaw);
|
||||
const aMsg = msgList[msgList.length - 1];
|
||||
ctx.aMsg = aMsg;
|
||||
this.runningMsgId = null;
|
||||
try {
|
||||
const skeleton = await createMessage(sessionId, {
|
||||
role: 'assistant', kind: 'docgen', status: 'streaming',
|
||||
blocks: [{ type: 'pipeline', title: DG_TITLE, status: 'running', capabilities: aMsg.blocks[0].capabilities, steps: aMsg.blocks[0].steps.map((st) => ({ ...st })) }],
|
||||
});
|
||||
aMsg.id = skeleton.id;
|
||||
this.runningMsgId = skeleton.id;
|
||||
} catch {
|
||||
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御)
|
||||
}
|
||||
|
||||
// 4. 同步生成(600s 兜底):期间按序推进 pipeline 相位(生成是长任务,需要视觉反馈)
|
||||
const pipe = aMsg.blocks[0];
|
||||
const stepSet = (phase, state, desc) => {
|
||||
const st = pipe.steps.find((x) => x.phase === phase);
|
||||
if (st) {
|
||||
if (state) st.state = state;
|
||||
if (desc != null) st.desc = desc;
|
||||
}
|
||||
this.scrollSeq++;
|
||||
};
|
||||
stepSet('planning', 'running');
|
||||
try {
|
||||
const payload = await generate({ description: q, session_id: sessionId, library_ids: libraryIds || [] });
|
||||
// 成功:pipeline done + artifact 块
|
||||
const artifact = payload?.artifact || null;
|
||||
pipe.status = 'done';
|
||||
pipe.steps.forEach((s) => { s.state = 'done'; });
|
||||
if (artifact) {
|
||||
const art = { ...artifact };
|
||||
const artId = art.artifact_id || art.id;
|
||||
if (artId) art.artifact_id = artId;
|
||||
aMsg.blocks.push({
|
||||
type: 'artifact',
|
||||
artifact_id: art.artifact_id,
|
||||
title: art.title || '',
|
||||
template_name: art.template_name || '',
|
||||
status: art.status || 'done',
|
||||
});
|
||||
}
|
||||
aMsg.status = 'done';
|
||||
aMsg.raw = payload ?? null;
|
||||
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
|
||||
try {
|
||||
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
|
||||
} catch (persistErr) {
|
||||
// 落库失败不掩盖成功事实:本地消息保持 done,仅记录
|
||||
console.warn('persistFinal failed after docgen:', persistErr);
|
||||
}
|
||||
// 产物接管给文档 TAB 并自动切过去(B3 setActiveTab 任意 key 可用;失败不阻断)
|
||||
if (artifact) {
|
||||
try {
|
||||
const { useDocgenStore } = await import('../stores/docgen.js');
|
||||
const dg = useDocgenStore();
|
||||
// BUG-3 修复:后端 ArtifactOut 无 markdown 字段——adoptArtifact 在无本地内容时
|
||||
// 自动拉取 content(docgen store 内部 fetchContent),切 TAB 后不会出现「内容不可用」。
|
||||
await dg.adoptArtifact(artifact, {
|
||||
initialMd: payload && typeof payload === 'object' && payload.markdown != null ? payload.markdown : '',
|
||||
});
|
||||
} catch { /* docgen store 未就绪不阻断 */ }
|
||||
try {
|
||||
const { useBenchStore } = await import('../stores/bench.js');
|
||||
useBenchStore().setActiveTab('doc');
|
||||
} catch { /* bench 未就绪不阻断 */ }
|
||||
}
|
||||
this._finishCtx(ctx, {
|
||||
ok: true,
|
||||
payload,
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
dur,
|
||||
blocks: aMsg.blocks.length,
|
||||
});
|
||||
} catch (err) {
|
||||
// 失败:_errorMarker 标注(可重试;后端 error.message 直接展示)
|
||||
const marker = this._errorMarker(err.code);
|
||||
const text = err.message || marker.text || T.upstreamError;
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = marker.ann;
|
||||
aMsg.errText = text;
|
||||
pipe.status = 'error';
|
||||
const running = pipe.steps.find((s) => s.state === 'running');
|
||||
if (running) running.state = 'done';
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, {
|
||||
ok: false,
|
||||
error: { code: err.code || 'NETWORK', message: text, retryable: true },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async _runStream(q, modeCfg, aMsg, sessionId, userId, ctx) {
|
||||
const controller = new AbortController();
|
||||
ctx.controller = controller;
|
||||
@@ -627,6 +849,14 @@ export const useChatStore = defineStore('chat', {
|
||||
const q = user.text || '';
|
||||
const sessionId = this.sessionId;
|
||||
const msgList = this.messages;
|
||||
// P3:docgen 失败消息的重试走文档生成路径(同位置替换,复用既有用户消息;
|
||||
// 附件引用以本地 meta.library_ids 为准——会话刷新后丢失则退化为无附件生成)
|
||||
if (aMsg.kind === 'docgen' || user.kind === 'docgen') {
|
||||
msgList.splice(idx, 1);
|
||||
this.lastResult = null;
|
||||
await this.sendDocgen(q, (user.meta && user.meta.library_ids) || [], { userMsg: user });
|
||||
return;
|
||||
}
|
||||
msgList.splice(idx, 1);
|
||||
this.lastResult = null;
|
||||
const ctx = newStreamCtx(sessionId, msgList);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// stores/docgen.js —— 文档 TAB 状态(P3/dev3 归属)
|
||||
// 只承载文档域数据(活动 artifact / md 内容 / 编辑态 / 导出态),
|
||||
// 不 import bench.js 的状态——切 TAB 只经 bench.setActiveTab('doc')(B3 的 setActiveTab 任意 key 可用)。
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
getArtifactContent,
|
||||
saveArtifactContent,
|
||||
editArtifact,
|
||||
exportArtifact,
|
||||
} from '../api/docgen.js';
|
||||
|
||||
/** 打开活动文档:拉取 md 原文并置为活动 artifact */
|
||||
export const useDocgenStore = defineStore('docgen', {
|
||||
state: () => ({
|
||||
activeArtifact: null, // 当前打开的 artifact 快照 {artifact_id, title, template_name, status, ...} | null
|
||||
md: '', // 活动文档的 markdown 原文
|
||||
loading: false, // 拉取内容中
|
||||
loadError: null, // 拉取失败 {message} | null
|
||||
editing: false, // 就地编辑态(contenteditable 激活)
|
||||
saving: false, // debounce PATCH 保存中
|
||||
saveError: null, // 保存失败标记 | null
|
||||
editPrompt: '', // 「用 prompt 修改」输入行草稿
|
||||
editRunning: false, // prompt 修改请求中
|
||||
editError: null, // prompt 修改失败 {message} | null
|
||||
exporting: {}, // format -> true(导出请求中)
|
||||
exportError: {}, // format -> {message} | null
|
||||
exportUrls: {}, // format -> download_url(后端可直接下载)
|
||||
exportRequestSeq: 0, // 导出请求信号:Composer export 分支 +1,DocPane watch 后消费
|
||||
templateCandidates: [], // 模版检索候选(生成后由后端返回;当前版本主要展示用)
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/** 是否存在活动文档(意图路由 hasActiveArtifact 的事实源) */
|
||||
hasActiveArtifact() {
|
||||
return !!this.activeArtifact;
|
||||
},
|
||||
/** 文档标题(活动 artifact 优先,否则空) */
|
||||
title() {
|
||||
return this.activeArtifact?.title || '';
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 打开活动文档(DocCard「打开预览」/ 生成成功自动切换的入口)
|
||||
* BUG-4 修复:同 id 且有本地内容且非强制 → 复用不重复拉取;
|
||||
* 同 id 但无本地内容 → 仍要拉取(旧短路导致「内容不可用」)。
|
||||
* @param {{artifact_id?: string, id?: string, title?: string, template_name?: string, status?: string}} artifact
|
||||
* @param {{force?: boolean}} opts
|
||||
*/
|
||||
async open(artifact, { force = false } = {}) {
|
||||
if (!artifact) return;
|
||||
const id = artifact.artifact_id || artifact.id;
|
||||
if (!id) return;
|
||||
const prevId = this.activeArtifact ? (this.activeArtifact.artifact_id || this.activeArtifact.id) : null;
|
||||
const sameArtifact = prevId === id;
|
||||
if (sameArtifact && !force && this.md) return; // 同 id 且有本地内容:复用不重复拉取
|
||||
this.activeArtifact = { ...artifact, artifact_id: id };
|
||||
this.loadError = null;
|
||||
this.saveError = null;
|
||||
this.editError = null;
|
||||
this.editing = false;
|
||||
// 切换文档或强制刷新:清掉旧文档内容再拉(否则 !this.md 判定会误复用旧内容)
|
||||
if (!sameArtifact || force) this.md = '';
|
||||
if (!this.md) await this.fetchContent(id);
|
||||
},
|
||||
|
||||
/** 拉取 artifact 的 md 原文(失败不抛异常、不丢 artifact 身份——DocPane 展示错误态 + 重试) */
|
||||
async fetchContent(id) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await getArtifactContent(id);
|
||||
// data 可能是 {ok, markdown, title, ...} 或裸字符串(契约未冻结前两者都容错)
|
||||
const mdText = typeof data === 'string' ? data : (data?.markdown ?? data?.content ?? '');
|
||||
this.md = String(mdText ?? '');
|
||||
if (data && typeof data === 'object') {
|
||||
if (data.title) this.activeArtifact.title = data.title;
|
||||
if (data.template_name) this.activeArtifact.template_name = data.template_name;
|
||||
if (Array.isArray(data.templates)) this.templateCandidates = data.templates;
|
||||
}
|
||||
} catch (err) {
|
||||
this.loadError = { message: err?.message || '' };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 打开失败后的重新拉取(强制重拉,不清 activeArtifact 避免闪空态) */
|
||||
reload() {
|
||||
const art = this.activeArtifact;
|
||||
if (art) this.open(art, { force: true });
|
||||
},
|
||||
|
||||
/** 生成成功后接管产物(chat.sendDocgen 成功路径调用;本动作内不含自动切 TAB——由调用方显式切)。
|
||||
* BUG-3 修复:后端 ArtifactOut 无 markdown 字段——无本地 md 内容时自动拉取 content。
|
||||
* @param {{artifact_id?: string, id?: string}} artifact
|
||||
* @param {{initialMd?: string}} opts 若上游直接给了 markdown 则用之(否则走 content 拉取) */
|
||||
async adoptArtifact(artifact, { initialMd = '' } = {}) {
|
||||
if (!artifact) return;
|
||||
const id = artifact.artifact_id || artifact.id;
|
||||
if (!id) return;
|
||||
this.activeArtifact = { ...artifact, artifact_id: id };
|
||||
this.loadError = null;
|
||||
this.editError = null;
|
||||
this.saveError = null;
|
||||
this.editing = false;
|
||||
this.md = String(initialMd ?? '');
|
||||
if (!this.md) await this.fetchContent(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* 就地编辑保存:debounce 800ms 后 PATCH(连续输入合并为一次请求)。
|
||||
* 保存失败仅标记,不打断编辑(下次输入重试)。
|
||||
*/
|
||||
scheduleSave() {
|
||||
if (!this.activeArtifact || !this.activeArtifact.artifact_id) return;
|
||||
if (this._saveTimer) clearTimeout(this._saveTimer);
|
||||
this._saveTimer = setTimeout(() => {
|
||||
this._saveTimer = null;
|
||||
this.save();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (!this.activeArtifact) return;
|
||||
const id = this.activeArtifact.artifact_id || this.activeArtifact.id;
|
||||
if (!id) return;
|
||||
this.saving = true;
|
||||
this.saveError = null;
|
||||
try {
|
||||
await saveArtifactContent(id, this.md);
|
||||
} catch (err) {
|
||||
this.saveError = { message: err?.message || '' };
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 「用 prompt 修改」:后端按指令返回完整新 md,覆盖当前内容 */
|
||||
async runEdit() {
|
||||
const instruction = (this.editPrompt || '').trim();
|
||||
if (!instruction || !this.activeArtifact || this.editRunning) return;
|
||||
const id = this.activeArtifact.artifact_id || this.activeArtifact.id;
|
||||
if (!id) return;
|
||||
this.editRunning = true;
|
||||
this.editError = null;
|
||||
try {
|
||||
const data = await editArtifact(id, instruction);
|
||||
const mdText = typeof data === 'string' ? data : (data?.markdown ?? '');
|
||||
this.md = String(mdText ?? this.md);
|
||||
this.editPrompt = '';
|
||||
} catch (err) {
|
||||
this.editError = { message: err?.message || '' };
|
||||
} finally {
|
||||
this.editRunning = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 导出(PDF/DOCX):后端生成产物并返回 download_url,前端触发下载 */
|
||||
async runExport(format) {
|
||||
if (!this.activeArtifact || this.exporting[format]) return;
|
||||
const id = this.activeArtifact.artifact_id || this.activeArtifact.id;
|
||||
if (!id) return;
|
||||
this.exporting[format] = true;
|
||||
this.exportError[format] = null;
|
||||
try {
|
||||
const data = await exportArtifact(id, format);
|
||||
const url = data?.download_url || data?.url || null;
|
||||
if (url) {
|
||||
this.exportUrls[format] = url;
|
||||
// 直接触发下载(后端附件 Content-Disposition)
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch { /* 下载触发失败:链接仍保留在导出按钮上,可手动点击 */ }
|
||||
} else if (data && data.ok === false) {
|
||||
this.exportError[format] = { message: data?.error?.message || data?.message || '' };
|
||||
}
|
||||
} catch (err) {
|
||||
this.exportError[format] = { message: err?.message || '' };
|
||||
} finally {
|
||||
this.exporting[format] = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 外部(Composer export 分支)发起的导出请求:DocPane watch 此序号消费 */
|
||||
requestExport() {
|
||||
this.exportRequestSeq++;
|
||||
},
|
||||
|
||||
/** 切到文档 TAB(经 bench.setActiveTab;B3 未就绪时静默——动态 import 防循环依赖) */
|
||||
switchToDocTab() {
|
||||
try {
|
||||
import('../stores/bench.js').then(({ useBenchStore }) => {
|
||||
useBenchStore().setActiveTab('doc');
|
||||
}).catch(() => {});
|
||||
} catch { /* bench 未就绪:仅 docgen 状态仍可用 */ }
|
||||
},
|
||||
|
||||
/** 关闭活动文档(切走 doc TAB 不清空;此处供未来「关闭文档」入口) */
|
||||
close() {
|
||||
this.activeArtifact = null;
|
||||
this.md = '';
|
||||
this.loading = false;
|
||||
this.loadError = null;
|
||||
this.editing = false;
|
||||
this.saveError = null;
|
||||
this.editError = null;
|
||||
this.exportError = {};
|
||||
this.exportUrls = {};
|
||||
},
|
||||
|
||||
/** 会话切换时清空文档域状态(会话级产物不跨会话) */
|
||||
resetSession() {
|
||||
if (this._saveTimer) {
|
||||
clearTimeout(this._saveTimer);
|
||||
this._saveTimer = null;
|
||||
}
|
||||
this.close();
|
||||
this.templateCandidates = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
+39
-2
@@ -53,14 +53,16 @@ html.drawer-open { overflow: hidden; }
|
||||
position: relative; z-index: 1;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0,1fr) 380px;
|
||||
grid-template-columns: 260px minmax(0,1fr) var(--bench-w, 380px);
|
||||
grid-template-rows: var(--topbar-h) minmax(0,1fr) var(--statusbar-h);
|
||||
grid-template-areas: "top top top" "side chat bench" "status status status";
|
||||
transition: grid-template-columns .3s var(--pa-ease-out);
|
||||
}
|
||||
.app-shell.side-collapsed { grid-template-columns: 64px minmax(0,1fr) 380px; }
|
||||
.app-shell.side-collapsed { grid-template-columns: 64px minmax(0,1fr) var(--bench-w, 380px); }
|
||||
.app-shell.bench-collapsed { grid-template-columns: 260px minmax(0,1fr) 28px; }
|
||||
.app-shell.side-collapsed.bench-collapsed { grid-template-columns: 64px minmax(0,1fr) 28px; }
|
||||
/* P3:拖拽中禁用网格过渡,宽度实时跟手 */
|
||||
.app-shell.bench-resizing { transition: none; }
|
||||
|
||||
/* ---------- 顶栏(UI_SPEC §4.1,FR-SHELL-002) ---------- */
|
||||
.top-bar {
|
||||
@@ -538,6 +540,39 @@ html.drawer-open { overflow: hidden; }
|
||||
min-width: 0; overflow: hidden;
|
||||
transition: transform .3s var(--pa-ease-out);
|
||||
}
|
||||
/* P3:右栏拖拽条(左边缘 8px、cursor:col-resize;折叠态隐藏不可拖拽) */
|
||||
.bench-resizer {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 8px; z-index: 8;
|
||||
cursor: col-resize;
|
||||
transition: background .18s var(--pa-ease);
|
||||
}
|
||||
.bench-resizer:hover,
|
||||
.bench-resizer:active {
|
||||
background: color-mix(in srgb, var(--pa-g-from) 30%, transparent);
|
||||
}
|
||||
.bench-collapsed .bench-resizer { display: none; }
|
||||
/* P3:展开/收起按钮 = 左栏 .side-handle-btn 同款(26×26 圆角、hairline 描边、
|
||||
surface-1 底、跨右栏左边缘半嵌、垂直居中、图标 13px;折叠态箭头朝左) */
|
||||
.bench-handle-btn {
|
||||
position: absolute; left: 0; z-index: 9;
|
||||
top: calc(var(--topbar-h) + (100% - var(--topbar-h) - var(--statusbar-h)) / 2);
|
||||
transform: translate(-50%, -50%);
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-1);
|
||||
border-radius: var(--pa-r-md);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--pa-ink-muted);
|
||||
transition: left .3s var(--pa-ease-out), background .2s var(--pa-ease), color .2s var(--pa-ease);
|
||||
}
|
||||
.bench-handle-btn svg { width: 13px; height: 13px; }
|
||||
.bench-handle-btn:hover { color: var(--pa-ink); }
|
||||
/* 折叠态:28px 竖条仍是点击区(bench-wrap 响应点击),按钮嵌其上同款式(半嵌不变) */
|
||||
.bench-collapsed .bench-wrap { cursor: pointer; }
|
||||
/* 旧折叠入口按钮视觉退役(P3 由 bench-handle-btn 接管;保留 DOM 兼容既有测试) */
|
||||
.bench-collapse-btn { display: none; }
|
||||
.bench-collapsed .bench-wrap .bench-collapse-btn { display: none; }
|
||||
.bench-wrap > .bench-panel { flex: 1 1 auto; min-width: 0; min-height: 0; }
|
||||
.bench-panel {
|
||||
grid-area: bench;
|
||||
@@ -548,6 +583,8 @@ html.drawer-open { overflow: hidden; }
|
||||
transition: all .3s var(--pa-ease-out);
|
||||
}
|
||||
.bench-panel .inner { display: flex; flex-direction: column; min-height: 0; width: 380px; transition: opacity .2s var(--pa-ease); }
|
||||
/* P3:右栏拖宽后面板内容随列宽伸缩(优先于 responsive.css laptop 档固定 340px) */
|
||||
.bench-wrap .bench-panel .inner { width: 100%; }
|
||||
.bench-collapsed .bench-panel .inner { opacity: 0; pointer-events: none; }
|
||||
.bench-collapsed .bench-wrap > .bench-panel { opacity: 0; pointer-events: none; }
|
||||
.bench-collapsed .bench-wrap .bench-handle { display: flex; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""vstore —— 文档加密主库 + 嵌入检索独立服务(FastAPI,端口 8094)。
|
||||
|
||||
- 单一主库:上传解析 → 提取 → 嵌入 → Fernet 加密落盘 → faiss 增量索引
|
||||
- 明文纪律:原文只在内存中流转,磁盘上只存 .enc 密文
|
||||
- 检索三档降级:向量 → meta 关键词 → 最新 N 条
|
||||
"""
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""api.py —— vstore FastAPI 服务(8094)。
|
||||
|
||||
端点(docs/team/brief_dev1_p1.md):
|
||||
- POST /api/vstore/ingest multipart: file + name/category/description/tags/as_template
|
||||
- GET /api/vstore/search ?q&top_k&category&as_template
|
||||
- GET /api/vstore/documents/{id} 明文全文 + outline + meta(仅生成时取)
|
||||
- GET /api/vstore/documents/{id}/style
|
||||
- DELETE /api/vstore/documents/{id}
|
||||
- POST /api/vstore/rebuild-index
|
||||
- GET /api/vstore/health
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from . import vault as _vault
|
||||
from .backend import BackendError, handshake, parse_backend
|
||||
from .settings import (
|
||||
DEFAULT_TOP_K,
|
||||
MAX_TOP_K,
|
||||
get_paths,
|
||||
)
|
||||
from .vault import VaultError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="PlantAI vstore", version="0.1.0")
|
||||
|
||||
|
||||
def _error(code: str, message: str, status: int, degraded: bool = False) -> JSONResponse:
|
||||
"""错误信封(与 BFF 现有格式一致:{ok, error:{code, message, degraded}})。"""
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={"ok": False, "error": {"code": code, "message": message, "degraded": degraded}},
|
||||
)
|
||||
|
||||
|
||||
# ---- 启动钩子:解析后端 + 握手校验(失败抛异常拒绝启动)----
|
||||
|
||||
def _init_service() -> None:
|
||||
paths = get_paths()
|
||||
backend = parse_backend(paths.vault_path, paths.index_path)
|
||||
try:
|
||||
handshake(backend)
|
||||
except BackendError as exc:
|
||||
logger.error("vstore: 启动握手失败,拒绝启动:%s", exc)
|
||||
raise
|
||||
_vault.set_backend(backend)
|
||||
_vault.init_vault()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
_init_service()
|
||||
|
||||
|
||||
# ---- 端点 ----
|
||||
|
||||
@app.get("/api/vstore/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
"""健康检查:后端 ok + 库存量。"""
|
||||
vault = _vault.get_vault()
|
||||
count = vault.index.count() if hasattr(vault, "index") else len(vault.list_rows())
|
||||
return {"ok": True, "count": count, "backend": "local"}
|
||||
|
||||
|
||||
@app.post("/api/vstore/ingest")
|
||||
async def ingest(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
category: str = Form(""),
|
||||
description: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
as_template: bool = Form(False),
|
||||
) -> dict[str, Any]:
|
||||
"""multipart 入库。文件解析/嵌入/加密落盘/索引由 vault.add 完成。"""
|
||||
content = await file.read()
|
||||
try:
|
||||
item = _vault.get_vault().add(
|
||||
content,
|
||||
filename=file.filename or "document",
|
||||
meta={"name": name, "category": category, "description": description, "tags": tags},
|
||||
as_template=bool(as_template),
|
||||
)
|
||||
except VaultError as exc:
|
||||
return _error("VALIDATION", str(exc), 422)
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@app.get("/api/vstore/search")
|
||||
async def search(
|
||||
q: str = "",
|
||||
top_k: int = DEFAULT_TOP_K,
|
||||
category: str = "",
|
||||
as_template: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""三档降级检索。"""
|
||||
try:
|
||||
result = _vault.get_vault().search(
|
||||
q,
|
||||
top_k=max(1, min(top_k, MAX_TOP_K)),
|
||||
category=category or None,
|
||||
as_template=as_template,
|
||||
)
|
||||
return {"ok": True, **result}
|
||||
except VaultError as exc:
|
||||
return _error("INTERNAL", str(exc), 500, degraded=True)
|
||||
|
||||
|
||||
@app.get("/api/vstore/documents/{doc_id}")
|
||||
async def get_document(doc_id: str) -> dict[str, Any]:
|
||||
"""明文全文 + outline + meta(仅生成时取)。"""
|
||||
doc = _vault.get_vault().get_document_json(doc_id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
return {"ok": True, "document": doc}
|
||||
|
||||
|
||||
@app.get("/api/vstore/documents/{doc_id}/style")
|
||||
async def get_style(doc_id: str) -> dict[str, Any]:
|
||||
"""docx 样式提取(导出对齐用)。"""
|
||||
style = _vault.get_vault().get_style(doc_id)
|
||||
if style is None:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
return {"ok": True, "style_hints": style}
|
||||
|
||||
|
||||
@app.delete("/api/vstore/documents/{doc_id}")
|
||||
async def delete_document(doc_id: str) -> dict[str, Any]:
|
||||
"""删除:索引行 + 密文 secure_delete。"""
|
||||
removed = _vault.get_vault().delete(doc_id)
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@app.post("/api/vstore/rebuild-index")
|
||||
async def rebuild_index() -> dict[str, Any]:
|
||||
"""重建索引:全部密文重新解密 → 嵌入 → 原子切换。
|
||||
|
||||
明文只在内存流转;任何一条失败(密钥不符/嵌入失败)整库拒绝切换。
|
||||
"""
|
||||
vault = _vault.get_vault()
|
||||
rebuilt = vault.rebuild_index()
|
||||
if rebuilt["status"] == "error":
|
||||
return _error("INTERNAL", rebuilt["message"], 500, degraded=True)
|
||||
return {"ok": True, "count": rebuilt["count"]}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""backend.py —— 存储后端解析与握手校验(本地磁盘 / 远程 HTTP)。
|
||||
|
||||
- parse_backend(spec) → LocalBackend | RemoteBackend
|
||||
含 :// 且 scheme http/https → RemoteBackend(vault/index 操作转 HTTP 指向远程根);
|
||||
其余(含 file://)→ LocalBackend(本机磁盘)
|
||||
- 启动握手校验:/health + 空检索;失败日志告警并抛异常(拒绝启动)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""后端不可用/握手失败(启动拒绝)。"""
|
||||
|
||||
|
||||
def _parse_scheme(spec: str) -> str:
|
||||
"""提取 scheme(http/https 才视为远程)。"""
|
||||
return urlparse(spec).scheme.lower()
|
||||
|
||||
|
||||
def is_remote_spec(spec: str) -> bool:
|
||||
"""含 :// 且 scheme http/https → 远程。"""
|
||||
return "://" in spec and _parse_scheme(spec) in ("http", "https")
|
||||
|
||||
|
||||
class LocalBackend:
|
||||
"""本机磁盘后端:vault/index 均为本地目录。"""
|
||||
|
||||
kind = "local"
|
||||
|
||||
def __init__(self, vault_path: str, index_path: str) -> None:
|
||||
self.vault_path = Path(vault_path)
|
||||
self.index_path = Path(index_path)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""本地健康:目录可写即 ok。"""
|
||||
try:
|
||||
self.vault_path.mkdir(parents=True, exist_ok=True)
|
||||
self.index_path.mkdir(parents=True, exist_ok=True)
|
||||
return {"ok": True, "backend": self.kind}
|
||||
except OSError as exc:
|
||||
raise BackendError(f"本地目录不可写:{exc}") from exc
|
||||
|
||||
def search_empty(self) -> dict[str, Any]:
|
||||
"""空检索握手:本地恒可用。"""
|
||||
return {"ok": True, "mode": "local", "items": []}
|
||||
|
||||
def close(self) -> None: # pragma: no cover - 无资源可释放
|
||||
pass
|
||||
|
||||
|
||||
class RemoteBackend:
|
||||
"""远程 HTTP 后端:vault/index 操作指向远程 vstore 根。"""
|
||||
|
||||
kind = "remote"
|
||||
|
||||
def __init__(self, vault_url: str, index_url: str) -> None:
|
||||
self.vault_url = vault_url.rstrip("/")
|
||||
self.index_url = index_url.rstrip("/")
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""远程健康:GET /health(超时 5s)。"""
|
||||
try:
|
||||
resp = httpx.get(f"{self.index_url}/health", timeout=5.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise BackendError(f"远程 vstore 健康检查失败:{exc}") from exc
|
||||
|
||||
def search_empty(self) -> dict[str, Any]:
|
||||
"""空检索握手:GET /api/vstore/search?q=。"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self.index_url}/api/vstore/search", params={"q": "", "top_k": 1}, timeout=5.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise BackendError(f"远程 vstore 空检索握手失败:{exc}") from exc
|
||||
|
||||
def close(self) -> None: # pragma: no cover - 无连接可释放
|
||||
pass
|
||||
|
||||
|
||||
def parse_backend(vault_spec: str, index_spec: str) -> LocalBackend | RemoteBackend:
|
||||
"""解析存储后端。
|
||||
|
||||
- 两个 spec 都是本地(含 file://)→ LocalBackend(file:// 剥掉前缀)
|
||||
- 两个 spec 都是 http(s):// → RemoteBackend
|
||||
- 混合形态不支持,抛 BackendError
|
||||
"""
|
||||
vault_remote = is_remote_spec(vault_spec)
|
||||
index_remote = is_remote_spec(index_spec)
|
||||
if vault_remote != index_remote:
|
||||
raise BackendError(
|
||||
f"vault/index 后端形态不一致(vault={'远程' if vault_remote else '本地'},"
|
||||
f"index={'远程' if index_remote else '本地'}),不支持混合部署"
|
||||
)
|
||||
if vault_remote:
|
||||
return RemoteBackend(vault_url=vault_spec, index_url=index_spec)
|
||||
# 本地路径(file:// 前缀剥掉)
|
||||
vault_path = vault_spec[len("file://"):] if vault_spec.startswith("file://") else vault_spec
|
||||
index_path = index_spec[len("file://"):] if index_spec.startswith("file://") else index_spec
|
||||
return LocalBackend(vault_path=vault_path, index_path=index_path)
|
||||
|
||||
|
||||
def handshake(backend: LocalBackend | RemoteBackend) -> None:
|
||||
"""启动握手:health + 空检索。失败日志告警并抛 BackendError(拒绝启动)。"""
|
||||
health = backend.health()
|
||||
if not health.get("ok"):
|
||||
raise BackendError(f"后端健康检查未通过:{health}")
|
||||
search = backend.search_empty()
|
||||
if not search.get("ok"):
|
||||
raise BackendError(f"后端空检索握手未通过:{search}")
|
||||
logger.info("backend: %s 握手通过(health+空检索)", backend.kind)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""crypto.py —— Fernet 对称加密封装(模版/附件密文落盘,明文纪律)。
|
||||
|
||||
- encrypt_bytes / decrypt_bytes:bytes 进 bytes 出
|
||||
- dump_encrypted / load_encrypted:.enc 原子落盘(临时文件 + os.replace)
|
||||
- secure_delete:3 遍覆写(0x00/0xFF/0xAA)+ fsync 后删除,尽力而为
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from .settings import get_fernet_key
|
||||
|
||||
|
||||
class CryptoError(Exception):
|
||||
"""加解密失败(密钥不匹配/数据损坏/输入为空)。"""
|
||||
|
||||
|
||||
def _cipher(key: bytes | None = None) -> Fernet:
|
||||
return Fernet(key if key is not None else get_fernet_key())
|
||||
|
||||
|
||||
def encrypt_bytes(data: bytes, key: bytes | None = None) -> bytes:
|
||||
"""明文 bytes → Fernet token(URL-safe base64,可直接落盘 .enc)。"""
|
||||
if not data:
|
||||
raise CryptoError("加密内容为空")
|
||||
try:
|
||||
return _cipher(key).encrypt(data)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise CryptoError(f"加密失败:{exc}") from exc
|
||||
|
||||
|
||||
def decrypt_bytes(token: bytes, key: bytes | None = None) -> bytes:
|
||||
"""Fernet token → 明文 bytes。密钥不符/数据损坏抛 CryptoError。"""
|
||||
if not token:
|
||||
raise CryptoError("密文为空")
|
||||
try:
|
||||
return _cipher(key).decrypt(token)
|
||||
except InvalidToken as exc:
|
||||
raise CryptoError("解密失败:密钥不匹配或数据已损坏") from exc
|
||||
|
||||
|
||||
def dump_encrypted(path: str | Path, data: bytes, key: bytes | None = None) -> Path:
|
||||
"""密文落盘(.enc)。原子写:先临时文件再 os.replace。"""
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = encrypt_bytes(data, key=key)
|
||||
tmp = target.with_name(target.name + ".tmp")
|
||||
tmp.write_bytes(token)
|
||||
os.replace(tmp, target)
|
||||
return target
|
||||
|
||||
|
||||
def load_encrypted(path: str | Path, key: bytes | None = None) -> bytes:
|
||||
"""读取 .enc 并解密为明文 bytes。"""
|
||||
target = Path(path)
|
||||
if not target.exists():
|
||||
raise CryptoError(f"密文不存在:{target}")
|
||||
return decrypt_bytes(target.read_bytes(), key=key)
|
||||
|
||||
|
||||
def secure_delete(path: str | Path) -> None:
|
||||
"""尽力安全删除:3 遍覆写 + fsync 后删除。
|
||||
|
||||
说明:对 SSD 无法保证物理擦除,但可防常规恢复;失败时仍尽力 unlink。
|
||||
"""
|
||||
target = Path(path)
|
||||
if not target.exists():
|
||||
return
|
||||
try:
|
||||
size = target.stat().st_size
|
||||
if size > 0:
|
||||
with open(target, "r+b") as f:
|
||||
for pattern in (b"\x00", b"\xff", b"\xaa"):
|
||||
f.seek(0)
|
||||
f.write(pattern * size)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
target.unlink()
|
||||
except OSError:
|
||||
# 覆写失败也绝不残留明文(本路径本就只存密文)
|
||||
try:
|
||||
target.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,91 @@
|
||||
"""embed.py —— ollama 嵌入调用封装(httpx,并发 ≤8,bge-m3 1024 维)。
|
||||
|
||||
- embed_batch(texts):同步函数,分批并发调 POST {url}/api/embed
|
||||
- 空文本返回零向量;维度不符抛 EmbedError
|
||||
- 单元测试注入假 embed(monkeypatch),本模块不依赖真 ollama
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import httpx
|
||||
|
||||
from .settings import (
|
||||
DEFAULT_EMBED_MODEL,
|
||||
DEFAULT_EMBED_URL,
|
||||
EMBED_CONCURRENCY,
|
||||
EMBED_DIM,
|
||||
EMBED_TIMEOUT,
|
||||
get_paths,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmbedError(Exception):
|
||||
"""嵌入调用失败(服务不可达/维度不符/响应异常)。"""
|
||||
|
||||
|
||||
def _zero_vector() -> list[float]:
|
||||
return [0.0] * EMBED_DIM
|
||||
|
||||
|
||||
def _request_one(client: httpx.Client, url: str, model: str, text: str) -> list[float]:
|
||||
"""单条嵌入请求。空文本返回零向量(不打上游)。"""
|
||||
if not text or not text.strip():
|
||||
return _zero_vector()
|
||||
try:
|
||||
resp = client.post(
|
||||
f"{url.rstrip('/')}/api/embed",
|
||||
json={"model": model, "input": text},
|
||||
timeout=EMBED_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
embeddings = payload.get("embeddings")
|
||||
if not embeddings or not isinstance(embeddings[0], list):
|
||||
raise EmbedError(f"嵌入响应形态异常:{str(payload)[:200]}")
|
||||
vector = [float(v) for v in embeddings[0]]
|
||||
if len(vector) != EMBED_DIM:
|
||||
raise EmbedError(f"嵌入维度不符:期望 {EMBED_DIM},实际 {len(vector)}")
|
||||
return vector
|
||||
except EmbedError:
|
||||
raise
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise EmbedError(f"嵌入服务调用失败:{exc}") from exc
|
||||
|
||||
|
||||
def embed_batch(
|
||||
texts: list[str],
|
||||
url: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> list[list[float]]:
|
||||
"""批量嵌入:线程池并发 ≤ EMBED_CONCURRENCY,保持输入顺序。
|
||||
|
||||
url/model 缺省取 settings(VSTORE_EMBED_URL / VSTORE_EMBED_MODEL)。
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
paths = get_paths()
|
||||
base_url = url or paths.embed_url or DEFAULT_EMBED_URL
|
||||
model_name = model or paths.embed_model or DEFAULT_EMBED_MODEL
|
||||
|
||||
results: list[list[float] | None] = [None] * len(texts)
|
||||
with httpx.Client() as client:
|
||||
with ThreadPoolExecutor(max_workers=EMBED_CONCURRENCY) as pool:
|
||||
futures = {
|
||||
pool.submit(_request_one, client, base_url, model_name, text): idx
|
||||
for idx, text in enumerate(texts)
|
||||
}
|
||||
for future in futures:
|
||||
idx = futures[future]
|
||||
try:
|
||||
results[idx] = future.result()
|
||||
except EmbedError as exc:
|
||||
logger.warning("embed: 第 %d 条嵌入失败:%s", idx, exc)
|
||||
results[idx] = None
|
||||
except Exception as exc: # 线程池其他异常也降级为零向量
|
||||
logger.warning("embed: 第 %d 条嵌入异常:%s", idx, exc)
|
||||
results[idx] = None
|
||||
return [v if v is not None else _zero_vector() for v in results]
|
||||
@@ -0,0 +1,291 @@
|
||||
"""parsing.py —— 上传文档解析(docx / pdf / 文本),产出统一结构。
|
||||
|
||||
统一产出(dict):
|
||||
{
|
||||
"outline": [{"level": int, "text": str}, ...], # 标题大纲(按出现顺序)
|
||||
"title": str, # 文档标题(推断)
|
||||
"paragraphs": [str, ...], # 正文段落
|
||||
"tables": [[[cell, ...], ...], ...], # 表格(按出现顺序)
|
||||
"style_hints": {...}, # docx 样式线索(导出对齐用)
|
||||
}
|
||||
|
||||
PDF 上限 300 页(docs/team/brief_dev1_p1.md);文本文件 utf-8 → gb18030 回退。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pymupdf
|
||||
from docx import Document
|
||||
from docx.document import Document as _DocxDoc
|
||||
from docx.table import Table as _DocxTable
|
||||
from docx.text.paragraph import Paragraph as _DocxParagraph
|
||||
|
||||
# PDF 页数上限
|
||||
MAX_PDF_PAGES = 300
|
||||
|
||||
# 大纲嵌入切块参数(与 settings 对齐,避免循环 import)
|
||||
_OUTLINE_EMBED_MAX_CHARS = 7500
|
||||
_OUTLINE_EMBED_MAX_CHUNKS = 3
|
||||
|
||||
_HEADING_RE = re.compile(r"[Hh]eading\s*(\d+)")
|
||||
_HEADING_CN_RE = re.compile(r"标题\s*(\d+)")
|
||||
|
||||
|
||||
class ParsingError(Exception):
|
||||
"""文件解析失败(格式不支持/损坏/超限)。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# docx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _heading_level(style_name: str) -> int | None:
|
||||
"""从样式名推断标题级别(Heading 1 / 标题 1 → 1)。"""
|
||||
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 _docx_style_hints(doc: _DocxDoc) -> dict[str, Any]:
|
||||
"""提取 docx 样式线索:正文字体/字号 + 各级标题字体/字号(尽力而为)。"""
|
||||
hints: dict[str, Any] = {
|
||||
"body_font": None,
|
||||
"body_size": None,
|
||||
"headings": {},
|
||||
"used_styles": [],
|
||||
}
|
||||
try:
|
||||
styles = doc.styles
|
||||
normal = styles["Normal"] if "Normal" in [s.name for s in styles] else None
|
||||
# python-docx: styles["Normal"] 可能抛 KeyError,用迭代兜底
|
||||
if normal is None:
|
||||
for s in 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
|
||||
|
||||
|
||||
def _table_rows(table: _DocxTable) -> list[list[str]]:
|
||||
"""表格 → 行×单元格文本(合并单元格重复出现,保持矩阵形状)。"""
|
||||
rows: list[list[str]] = []
|
||||
for row in table.rows:
|
||||
rows.append([cell.text.strip() for cell in row.cells])
|
||||
return rows
|
||||
|
||||
|
||||
def extract_docx(file_bytes: bytes) -> dict[str, Any]:
|
||||
"""解析 docx:大纲 + 段落 + 表格 + 样式线索。"""
|
||||
try:
|
||||
doc: _DocxDoc = Document(io.BytesIO(file_bytes))
|
||||
except Exception as exc:
|
||||
raise ParsingError(f"docx 解析失败:{exc}") from exc
|
||||
|
||||
outline: list[dict[str, Any]] = []
|
||||
paragraphs: list[str] = []
|
||||
title = ""
|
||||
for para in doc.paragraphs:
|
||||
text = (para.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
level = _heading_level(para.style.name if para.style is not None else "")
|
||||
if level is not None:
|
||||
outline.append({"level": level, "text": text})
|
||||
continue
|
||||
paragraphs.append(text)
|
||||
if not title:
|
||||
title = text # 首个非标题段落视作文档标题
|
||||
|
||||
tables: list[list[list[str]]] = []
|
||||
for table in doc.tables:
|
||||
rows = _table_rows(table)
|
||||
if rows and any(any(cell for cell in row) for row in rows):
|
||||
tables.append(rows)
|
||||
|
||||
return {
|
||||
"outline": outline,
|
||||
"title": title,
|
||||
"paragraphs": paragraphs,
|
||||
"tables": tables,
|
||||
"style_hints": _docx_style_hints(doc),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pdf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_pdf(file_bytes: bytes) -> dict[str, Any]:
|
||||
"""解析 PDF(pymupdf,≤300 页):书签为大纲,文本块为段落。"""
|
||||
try:
|
||||
doc = pymupdf.open(stream=file_bytes, filetype="pdf")
|
||||
except Exception as exc:
|
||||
raise ParsingError(f"pdf 解析失败:{exc}") from exc
|
||||
try:
|
||||
if doc.page_count > MAX_PDF_PAGES:
|
||||
raise ParsingError(f"PDF 超过 {MAX_PDF_PAGES} 页限制(当前 {doc.page_count} 页)")
|
||||
|
||||
outline: list[dict[str, Any]] = []
|
||||
try:
|
||||
for level, text, _page in doc.get_toc():
|
||||
if text and text.strip():
|
||||
outline.append({"level": max(1, level), "text": text.strip()})
|
||||
except Exception:
|
||||
outline = []
|
||||
|
||||
paragraphs: list[str] = []
|
||||
for page in doc:
|
||||
blocks = page.get_text("blocks")
|
||||
for block in blocks:
|
||||
try:
|
||||
text = str(block[4]).strip() if len(block) > 4 else ""
|
||||
except Exception:
|
||||
continue
|
||||
if text:
|
||||
paragraphs.append(text)
|
||||
|
||||
title = outline[0]["text"] if outline else ""
|
||||
return {
|
||||
"outline": outline,
|
||||
"title": title,
|
||||
"paragraphs": paragraphs,
|
||||
"tables": [],
|
||||
"style_hints": {},
|
||||
}
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文本文件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_text_file(file_bytes: bytes) -> dict[str, Any]:
|
||||
"""解析纯文本(utf-8 → gb18030 回退);空行分段。"""
|
||||
for encoding in ("utf-8", "gb18030"):
|
||||
try:
|
||||
text = file_bytes.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
text = file_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
paragraphs = [line.strip() for line in text.split("\n\n") if line.strip()]
|
||||
if not paragraphs:
|
||||
paragraphs = [line.strip() for line in text.split("\n") if line.strip()]
|
||||
title = paragraphs[0] if paragraphs else ""
|
||||
return {
|
||||
"outline": [],
|
||||
"title": title,
|
||||
"paragraphs": paragraphs,
|
||||
"tables": [],
|
||||
"style_hints": {},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 大纲派生
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def outline_to_markdown(outline: list[dict[str, Any]]) -> str:
|
||||
"""大纲 → markdown 标题行(level 1 → #,level 2 → ## …)。"""
|
||||
lines: list[str] = []
|
||||
for item in outline:
|
||||
try:
|
||||
level = max(1, int(item.get("level", 1)))
|
||||
except (TypeError, ValueError):
|
||||
level = 1
|
||||
text = str(item.get("text", "")).strip()
|
||||
if text:
|
||||
lines.append(f"{'#' * min(level, 6)} {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def outline_to_embedding_chunks(
|
||||
outline: list[dict[str, Any]],
|
||||
max_chars: int = _OUTLINE_EMBED_MAX_CHARS,
|
||||
max_chunks: int = _OUTLINE_EMBED_MAX_CHUNKS,
|
||||
) -> list[str]:
|
||||
"""大纲 → 嵌入文本块。
|
||||
|
||||
全长 ≤ max_chars 直接整块;超长按一级标题分节打包,
|
||||
块数不超过 max_chunks(超出部分并入最后一块)。
|
||||
"""
|
||||
md = outline_to_markdown(outline)
|
||||
if len(md) <= max_chars:
|
||||
return [md] if md else []
|
||||
|
||||
sections: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in md.split("\n"):
|
||||
if line.startswith("# "): # 一级标题开新节
|
||||
if current:
|
||||
sections.append("\n".join(current))
|
||||
current = [line]
|
||||
else:
|
||||
current.append(line)
|
||||
if current:
|
||||
sections.append("\n".join(current))
|
||||
if not sections:
|
||||
return [md[:max_chars]]
|
||||
|
||||
chunks: list[str] = []
|
||||
buf: list[str] = []
|
||||
for section in sections:
|
||||
if buf and len("\n".join(buf)) + len(section) + 1 > max_chars and len(chunks) < max_chunks - 1:
|
||||
chunks.append("\n".join(buf))
|
||||
buf = [section]
|
||||
else:
|
||||
buf.append(section)
|
||||
if buf:
|
||||
chunks.append("\n".join(buf))
|
||||
return chunks[:max_chunks]
|
||||
|
||||
|
||||
def extract(file_bytes: bytes, filename: str) -> dict[str, Any]:
|
||||
"""按扩展名分发解析。未知扩展按文本处理(utf-8 → gb18030)。"""
|
||||
name = (filename or "").lower()
|
||||
if name.endswith(".docx"):
|
||||
return extract_docx(file_bytes)
|
||||
if name.endswith(".pdf"):
|
||||
return extract_pdf(file_bytes)
|
||||
return extract_text_file(file_bytes)
|
||||
@@ -0,0 +1,32 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "plantai-vstore"
|
||||
version = "0.1.0"
|
||||
description = "PlantAI-rag-new vstore:文档加密主库 + 嵌入检索独立服务(8094)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"httpx>=0.27",
|
||||
"cryptography>=42",
|
||||
"faiss-cpu>=1.8",
|
||||
"numpy>=1.26",
|
||||
"python-docx>=1.1",
|
||||
"pymupdf>=1.24",
|
||||
"PyYAML>=6.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["vstore*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["vstore/tests"]
|
||||
python_files = ["test_*.py"]
|
||||
asyncio_mode = "auto"
|
||||
@@ -0,0 +1,266 @@
|
||||
"""retrieval.py —— faiss 向量检索 + index.json 单一事实源。
|
||||
|
||||
- IndexFlatIP(内积,配合 L2 归一化向量即余弦相似度)
|
||||
- index.json:检索元数据单一事实源(行号 == json 下标);faiss 内存索引
|
||||
可随时由 index.json 重建
|
||||
- search 三档降级:向量 → meta 关键词 → 最新 N 条
|
||||
- 增量写锁(threading.Lock);rebuild 临时索引 + os.replace 原子切换
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
|
||||
from .settings import (
|
||||
DEFAULT_TOP_K,
|
||||
EMBED_DIM,
|
||||
FALLBACK_LATEST_N,
|
||||
MAX_TOP_K,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_FILE = "index.json"
|
||||
INDEX_TMP_SUFFIX = ".rebuild.tmp"
|
||||
|
||||
|
||||
class RetrievalError(Exception):
|
||||
"""检索/索引操作失败。"""
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _normalize(vec: list[float]) -> np.ndarray:
|
||||
"""L2 归一化(IndexFlatIP 下等价余弦)。零向量原样返回。"""
|
||||
arr = np.asarray(vec, dtype=np.float32)
|
||||
norm = float(np.linalg.norm(arr))
|
||||
if norm > 0:
|
||||
arr = arr / norm
|
||||
return arr
|
||||
|
||||
|
||||
class RetrievalIndex:
|
||||
"""检索索引:index.json + faiss 内存索引(同一目录,单一事实源)。"""
|
||||
|
||||
def __init__(self, index_path: str | Path, dim: int = EMBED_DIM) -> None:
|
||||
self.dir = Path(index_path)
|
||||
self.dim = dim
|
||||
self._lock = threading.Lock()
|
||||
self._faiss: faiss.IndexFlatIP = faiss.IndexFlatIP(dim)
|
||||
self._rows: list[dict[str, Any]] = []
|
||||
|
||||
# ---- 加载 / 落盘 ----
|
||||
|
||||
@property
|
||||
def json_path(self) -> Path:
|
||||
return self.dir / INDEX_FILE
|
||||
|
||||
def load(self) -> None:
|
||||
"""从 index.json 加载行并重建 faiss。文件不存在视为空库。"""
|
||||
with self._lock:
|
||||
self._rows = []
|
||||
self._faiss = faiss.IndexFlatIP(self.dim)
|
||||
path = self.json_path
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
items = data.get("items", []) if isinstance(data, dict) else []
|
||||
except (ValueError, OSError) as exc:
|
||||
logger.warning("retrieval: index.json 读取失败,按空库处理:%s", exc)
|
||||
return
|
||||
for row_id, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
row = dict(item)
|
||||
row["row_id"] = row_id
|
||||
self._rows.append(row)
|
||||
vec = row.get("vector")
|
||||
if isinstance(vec, list) and vec:
|
||||
self._faiss.add(_normalize(vec).reshape(1, self.dim))
|
||||
|
||||
def _dump_locked(self, path: Path) -> None:
|
||||
"""写 json(需持有锁)。"""
|
||||
tmp = path.with_name(path.name + INDEX_TMP_SUFFIX)
|
||||
payload = {"version": 1, "items": [{k: v for k, v in r.items() if k != "row_id"} for r in self._rows]}
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
def save(self) -> None:
|
||||
"""落盘(原子)。"""
|
||||
with self._lock:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self._dump_locked(self.json_path)
|
||||
|
||||
# ---- 增删 ----
|
||||
|
||||
def add_item(self, item: dict[str, Any], vector: list[float]) -> int:
|
||||
"""追加一行(json 下标 == 行号)。返回 row_id。"""
|
||||
with self._lock:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
row_id = len(self._rows)
|
||||
row = dict(item)
|
||||
row["row_id"] = row_id
|
||||
row["vector"] = list(vector)
|
||||
self._rows.append(row)
|
||||
self._faiss.add(_normalize(vector).reshape(1, self.dim))
|
||||
self._dump_locked(self.json_path)
|
||||
return row_id
|
||||
|
||||
def remove_item(self, document_id: str) -> bool:
|
||||
"""按 document_id 删除(faiss 不支持逐行删,整库重建——库规模小可接受)。"""
|
||||
with self._lock:
|
||||
before = len(self._rows)
|
||||
self._rows = [r for r in self._rows if str(r.get("id")) != document_id]
|
||||
self._faiss = faiss.IndexFlatIP(self.dim)
|
||||
for row in self._rows:
|
||||
vec = row.get("vector")
|
||||
if isinstance(vec, list) and vec:
|
||||
self._faiss.add(_normalize(vec).reshape(1, self.dim))
|
||||
if self.dir.exists():
|
||||
self._dump_locked(self.json_path)
|
||||
return len(self._rows) < before
|
||||
|
||||
def update_meta(self, document_id: str, meta: dict[str, Any]) -> bool:
|
||||
"""更新行的 meta 字段(向量不变)。"""
|
||||
with self._lock:
|
||||
for row in self._rows:
|
||||
if str(row.get("id")) == document_id:
|
||||
for key, value in meta.items():
|
||||
if key != "vector":
|
||||
row[key] = value
|
||||
if self.dir.exists():
|
||||
self._dump_locked(self.json_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---- 查询 ----
|
||||
|
||||
def rows(self) -> list[dict[str, Any]]:
|
||||
"""行快照(不含 vector,供列表接口)。"""
|
||||
with self._lock:
|
||||
return [{k: v for k, v in r.items() if k != "vector"} for r in self._rows]
|
||||
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._rows)
|
||||
|
||||
def _match_meta(self, row: dict[str, Any], keywords: list[str]) -> bool:
|
||||
"""meta 关键词匹配:name/category/tags/description 任一命中任一关键词。"""
|
||||
if not keywords:
|
||||
return True
|
||||
haystack = " ".join(
|
||||
str(row.get(k) or "") for k in ("name", "category", "tags", "description")
|
||||
).lower()
|
||||
return any(kw.lower() in haystack for kw in keywords)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query_vector: list[float],
|
||||
top_k: int = DEFAULT_TOP_K,
|
||||
category: str | None = None,
|
||||
as_template: bool | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""三档降级检索:向量 → meta 关键词 → 最新 N 条。
|
||||
|
||||
返回 {"mode": "vector"|"keyword"|"latest"|"empty", "items": [...]}。
|
||||
"""
|
||||
top_k = max(1, min(int(top_k), MAX_TOP_K))
|
||||
with self._lock:
|
||||
rows = self._rows
|
||||
|
||||
def _filtered(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for r in candidates:
|
||||
if category and str(r.get("category", "")) != category:
|
||||
continue
|
||||
if as_template is not None and bool(r.get("as_template")) != bool(as_template):
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
# 一档:向量
|
||||
if rows:
|
||||
try:
|
||||
q = _normalize(query_vector).reshape(1, self.dim)
|
||||
dist, idx = self._faiss.search(q, min(top_k, len(rows)))
|
||||
vector_hits = []
|
||||
for score, row_idx in zip(dist[0].tolist(), idx[0].tolist()):
|
||||
if 0 <= row_idx < len(rows):
|
||||
vector_hits.append((float(score), rows[row_idx]))
|
||||
filtered = _filtered([r for _, r in vector_hits])
|
||||
# 分数阈值:零向量查询(或过滤后无相关命中)时
|
||||
# 最高分仍 ≈ 0,视为无命中 → 降级下一档
|
||||
best_score = vector_hits[0][0] if vector_hits else 0.0
|
||||
if filtered and best_score > 0.01:
|
||||
return {
|
||||
"mode": "vector",
|
||||
"items": [
|
||||
{k: v for k, v in r.items() if k != "vector"}
|
||||
for r in filtered[:top_k]
|
||||
],
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("retrieval: 向量检索失败,降级关键词:%s", exc)
|
||||
|
||||
# 二档:meta 关键词
|
||||
if keywords:
|
||||
kw_hits = [r for r in rows if self._match_meta(r, keywords)]
|
||||
filtered = _filtered(kw_hits)
|
||||
if filtered:
|
||||
return {
|
||||
"mode": "keyword",
|
||||
"items": [
|
||||
{k: v for k, v in r.items() if k != "vector"}
|
||||
for r in filtered[:top_k]
|
||||
],
|
||||
}
|
||||
|
||||
# 三档:最新 N 条(created_at 倒序)
|
||||
latest = sorted(
|
||||
rows, key=lambda r: str(r.get("created_at") or ""), reverse=True
|
||||
)
|
||||
filtered = _filtered(latest)
|
||||
if filtered:
|
||||
return {
|
||||
"mode": "latest",
|
||||
"items": [
|
||||
{k: v for k, v in r.items() if k != "vector"}
|
||||
for r in filtered[: min(top_k, FALLBACK_LATEST_N)]
|
||||
],
|
||||
}
|
||||
return {"mode": "empty", "items": []}
|
||||
|
||||
def rebuild(self, items: list[tuple[dict[str, Any], list[float]]]) -> int:
|
||||
"""整库重建:items = [(meta_row, vector), ...]。
|
||||
|
||||
临时文件写好后 os.replace 原子切换到 index.json;faiss 同步重建。
|
||||
返回行数。
|
||||
"""
|
||||
with self._lock:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
new_rows: list[dict[str, Any]] = []
|
||||
new_faiss = faiss.IndexFlatIP(self.dim)
|
||||
for row_id, (item, vector) in enumerate(items):
|
||||
row = dict(item)
|
||||
row["row_id"] = row_id
|
||||
row["vector"] = list(vector)
|
||||
new_rows.append(row)
|
||||
new_faiss.add(_normalize(vector).reshape(1, self.dim))
|
||||
# 原子切换:写临时文件 → os.replace
|
||||
self._rows = new_rows
|
||||
self._faiss = new_faiss
|
||||
self._dump_locked(self.json_path)
|
||||
return len(new_rows)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""settings.py —— vstore 环境变量驱动配置(dataclass,不引 pydantic)。
|
||||
|
||||
环境变量键:
|
||||
- VSTORE_KEY:Fernet key,必填(缺失抛异常拒绝启动)
|
||||
- VSTORE_ROOT:数据根目录(默认 data/vstore)
|
||||
- VSTORE_VAULT_PATH / VSTORE_INDEX_PATH:可覆盖(本地路径或 http(s):// URL)
|
||||
- VSTORE_EMBED_URL:ollama 嵌入服务(默认 http://127.0.0.1:11434)
|
||||
- VSTORE_EMBED_MODEL:嵌入模型(默认 bge-m3:567m)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---- 限额常量(与 BFF 侧 docgen 对齐,docs/team/brief_dev1_p1.md)----
|
||||
MAX_ATTACH_COUNT = 5 # 单次上传附件数上限
|
||||
MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 单附件 20MB
|
||||
MAX_ATTACH_CHARS = 60000 # 单附件解析后字符上限
|
||||
MAX_SECTIONS = 12 # 模版大纲裁剪上限(节数)
|
||||
MAX_OUTLINE_CHUNKS = 3 # 大纲嵌入切块上限
|
||||
|
||||
# ---- 检索参数 ----
|
||||
EMBED_DIM = 1024 # bge-m3:567m 输出维度
|
||||
DEFAULT_TOP_K = 5
|
||||
MAX_TOP_K = 20
|
||||
OUTLINE_EMBED_MAX_CHARS = 7500 # 大纲超过此长度按节切块(≤MAX_OUTLINE_CHUNKS)
|
||||
FALLBACK_LATEST_N = 8 # 三档降级最后一档:最新 N 条
|
||||
|
||||
# ---- 嵌入服务 ----
|
||||
DEFAULT_EMBED_URL = "http://127.0.0.1:11434"
|
||||
DEFAULT_EMBED_MODEL = "bge-m3:567m"
|
||||
EMBED_CONCURRENCY = 8 # embed_batch 并发上限
|
||||
EMBED_TIMEOUT = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VstorePaths:
|
||||
"""vstore 数据路径(vault/index 可分别指向本地目录或远程 http(s):// 根)。"""
|
||||
|
||||
root: str = field(default_factory=lambda: os.environ.get("VSTORE_ROOT", "data/vstore"))
|
||||
vault_path: str = field(default_factory=lambda: os.environ.get("VSTORE_VAULT_PATH", ""))
|
||||
index_path: str = field(default_factory=lambda: os.environ.get("VSTORE_INDEX_PATH", ""))
|
||||
embed_url: str = field(default_factory=lambda: os.environ.get("VSTORE_EMBED_URL", DEFAULT_EMBED_URL))
|
||||
embed_model: str = field(default_factory=lambda: os.environ.get("VSTORE_EMBED_MODEL", DEFAULT_EMBED_MODEL))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# vault/index 未显式指定时落在 root 下
|
||||
if not self.vault_path:
|
||||
self.vault_path = str(Path(self.root) / "vault")
|
||||
if not self.index_path:
|
||||
self.index_path = str(Path(self.root) / "index")
|
||||
|
||||
|
||||
def get_fernet_key() -> bytes:
|
||||
"""VSTORE_KEY(Fernet key,必填)。缺失抛异常(阻止启动)。
|
||||
|
||||
生成方式:
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
"""
|
||||
raw = os.environ.get("VSTORE_KEY", "")
|
||||
if not raw:
|
||||
raise RuntimeError(
|
||||
"缺少环境变量 VSTORE_KEY(Fernet key,必填)。生成:"
|
||||
'python -c "from cryptography.fernet import Fernet; '
|
||||
'print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
_paths: VstorePaths | None = None
|
||||
|
||||
|
||||
def get_paths() -> VstorePaths:
|
||||
"""取全局路径配置(首次调用构造)。"""
|
||||
global _paths
|
||||
if _paths is None:
|
||||
_paths = VstorePaths()
|
||||
return _paths
|
||||
|
||||
|
||||
def set_paths(paths: VstorePaths) -> None:
|
||||
"""注入配置(测试/启动用)。"""
|
||||
global _paths
|
||||
_paths = paths
|
||||
@@ -0,0 +1,42 @@
|
||||
"""vstore/tests/conftest.py —— 测试公共配置。
|
||||
|
||||
- 固定 Fernet key(必须在 import vstore 前设置)
|
||||
- 假 embed(确定性向量,不依赖真 ollama)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# 固定测试密钥(必须在 import vstore.crypto 之前设置好环境变量)
|
||||
TEST_KEY = "3CW0cVSYhnDo-bwkrZL5ElLhNrCtl5zyuRZvATckirs="
|
||||
os.environ.setdefault("VSTORE_KEY", TEST_KEY)
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
|
||||
import vstore.vault as vault # noqa: E402
|
||||
|
||||
|
||||
def fake_embed(texts: list[str]) -> list[list[float]]:
|
||||
"""假嵌入:文本首字符哈希 → 单位基向量方向,确定且可区分。"""
|
||||
dim = 1024
|
||||
out: list[list[float]] = []
|
||||
for text in texts:
|
||||
if not text or not text.strip():
|
||||
out.append([0.0] * dim)
|
||||
continue
|
||||
# 用文本第一个字符的码点做种子,生成确定向量
|
||||
seed = ord(text[0])
|
||||
vec = np.zeros(dim, dtype=np.float32)
|
||||
vec[seed % dim] = 1.0
|
||||
if seed % 7 == 0:
|
||||
vec[(seed + 1) % dim] = 0.5
|
||||
out.append((vec / float(np.linalg.norm(vec))).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def embed_fn(monkeypatch):
|
||||
"""注入假 embed 到 vault 模块(vault.add/search/rebuild 都走它)。"""
|
||||
monkeypatch.setattr(vault, "_EMBED_FN", fake_embed)
|
||||
return fake_embed
|
||||
@@ -0,0 +1,110 @@
|
||||
"""test_api.py —— FastAPI 端点(TestClient + 假 embed,不碰真 ollama)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from vstore import vault as vault_mod
|
||||
from vstore.api import app
|
||||
from vstore.settings import VstorePaths, set_paths
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, embed_fn):
|
||||
set_paths(VstorePaths(root=str(tmp_path)))
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_health(client):
|
||||
resp = client.get("/api/vstore/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 0
|
||||
|
||||
|
||||
def test_ingest_then_search(client):
|
||||
resp = client.post(
|
||||
"/api/vstore/ingest",
|
||||
files={"file": ("report.txt", "小麦育种年度报告内容。".encode("utf-8"), "text/plain")},
|
||||
data={"name": "年度报告", "category": "农业", "description": "小麦"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
item = data["item"]
|
||||
assert item["document_id"]
|
||||
assert item["name"] == "年度报告"
|
||||
|
||||
resp2 = client.get("/api/vstore/search", params={"q": "小麦"})
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert data2["ok"] is True
|
||||
assert len(data2["items"]) >= 1
|
||||
assert data2["items"][0]["id"] == item["document_id"]
|
||||
|
||||
|
||||
def test_ingest_empty_file_rejected(client):
|
||||
resp = client.post(
|
||||
"/api/vstore/ingest",
|
||||
files={"file": ("a.txt", b"", "text/plain")},
|
||||
data={"name": "x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert data["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_document_roundtrip(client):
|
||||
doc_id = client.post(
|
||||
"/api/vstore/ingest",
|
||||
files={"file": ("d.txt", "文档正文内容。".encode("utf-8"))},
|
||||
data={"name": "d"},
|
||||
).json()["item"]["document_id"]
|
||||
|
||||
resp = client.get(f"/api/vstore/documents/{doc_id}")
|
||||
assert resp.status_code == 200
|
||||
doc = resp.json()["document"]
|
||||
assert "文档正文内容" in doc["paragraphs"][0]
|
||||
|
||||
# style(非 docx → 空 hints)
|
||||
resp_style = client.get(f"/api/vstore/documents/{doc_id}/style")
|
||||
assert resp_style.status_code == 200
|
||||
assert resp_style.json()["style_hints"] == {}
|
||||
|
||||
|
||||
def test_document_404(client):
|
||||
assert client.get("/api/vstore/documents/nope").status_code == 404
|
||||
assert client.get("/api/vstore/documents/nope/style").status_code == 404
|
||||
|
||||
|
||||
def test_delete_document(client):
|
||||
doc_id = client.post(
|
||||
"/api/vstore/ingest",
|
||||
files={"file": ("d.txt", b"content")},
|
||||
data={"name": "d"},
|
||||
).json()["item"]["document_id"]
|
||||
resp = client.delete(f"/api/vstore/documents/{doc_id}")
|
||||
assert resp.json() == {"ok": True, "removed": True}
|
||||
assert client.get(f"/api/vstore/documents/{doc_id}").status_code == 404
|
||||
|
||||
|
||||
def test_rebuild_index(client):
|
||||
client.post(
|
||||
"/api/vstore/ingest",
|
||||
files={"file": ("a.txt", b"aaa")},
|
||||
data={"name": "a"},
|
||||
)
|
||||
resp = client.post("/api/vstore/rebuild-index")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 1
|
||||
|
||||
|
||||
def test_search_params_bounded(client):
|
||||
resp = client.get("/api/vstore/search", params={"q": "", "top_k": 999})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
@@ -0,0 +1,82 @@
|
||||
"""test_backend.py —— 后端路径解析三形态 + 远程握手失败拒绝。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vstore.backend import (
|
||||
BackendError,
|
||||
LocalBackend,
|
||||
RemoteBackend,
|
||||
handshake,
|
||||
is_remote_spec,
|
||||
parse_backend,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_backend_bare_paths():
|
||||
backend = parse_backend("/tmp/vault", "/tmp/index")
|
||||
assert isinstance(backend, LocalBackend)
|
||||
assert str(backend.vault_path) == "/tmp/vault"
|
||||
|
||||
|
||||
def test_parse_backend_file_url():
|
||||
backend = parse_backend("file:///tmp/vault", "file:///tmp/index")
|
||||
assert isinstance(backend, LocalBackend)
|
||||
assert str(backend.vault_path) == "/tmp/vault" # file:// 前缀剥掉
|
||||
assert str(backend.index_path) == "/tmp/index"
|
||||
|
||||
|
||||
def test_parse_backend_http_urls():
|
||||
backend = parse_backend("http://h1:1/vault", "https://h2:2/index")
|
||||
assert isinstance(backend, RemoteBackend)
|
||||
assert backend.vault_url == "http://h1:1/vault"
|
||||
assert backend.index_url == "https://h2:2/index"
|
||||
|
||||
|
||||
def test_parse_backend_mixed_rejected():
|
||||
with pytest.raises(BackendError):
|
||||
parse_backend("/tmp/vault", "http://h:1/index")
|
||||
|
||||
|
||||
def test_is_remote_spec():
|
||||
assert is_remote_spec("http://x")
|
||||
assert is_remote_spec("https://x")
|
||||
assert not is_remote_spec("file:///x")
|
||||
assert not is_remote_spec("/tmp/x")
|
||||
assert not is_remote_spec("ftp://x") # 仅 http/https 视为远程
|
||||
|
||||
|
||||
def test_local_handshake_ok(tmp_path):
|
||||
backend = LocalBackend(str(tmp_path / "vault"), str(tmp_path / "index"))
|
||||
handshake(backend) # 不抛
|
||||
assert (tmp_path / "vault").exists()
|
||||
|
||||
|
||||
def test_remote_health_ok(monkeypatch):
|
||||
def fake_get(url, timeout=None):
|
||||
assert url.endswith("/health")
|
||||
return httpx.Response(200, json={"ok": True, "count": 0}, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr("vstore.backend.httpx.get", fake_get)
|
||||
backend = RemoteBackend("http://h:1/v", "http://h:2/i")
|
||||
assert backend.health()["ok"] is True
|
||||
|
||||
|
||||
def test_remote_handshake_failure_rejected(monkeypatch):
|
||||
def fake_get(url, timeout=None):
|
||||
raise httpx.ConnectError("down", request=None)
|
||||
|
||||
monkeypatch.setattr("vstore.backend.httpx.get", fake_get)
|
||||
backend = RemoteBackend("http://h:1/v", "http://h:2/i")
|
||||
with pytest.raises(BackendError):
|
||||
handshake(backend)
|
||||
|
||||
|
||||
def test_remote_search_empty_handshake(monkeypatch):
|
||||
def fake_get(url, timeout=None, params=None):
|
||||
return httpx.Response(200, json={"ok": True, "mode": "latest", "items": []}, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr("vstore.backend.httpx.get", fake_get)
|
||||
backend = RemoteBackend("http://h:1/v", "http://h:2/i")
|
||||
assert backend.search_empty()["ok"] is True
|
||||
@@ -0,0 +1,63 @@
|
||||
"""test_crypto.py —— Fernet 加解密 / 落盘 / secure_delete。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vstore.crypto import (
|
||||
CryptoError,
|
||||
decrypt_bytes,
|
||||
dump_encrypted,
|
||||
encrypt_bytes,
|
||||
load_encrypted,
|
||||
secure_delete,
|
||||
)
|
||||
|
||||
|
||||
def test_roundtrip_bytes():
|
||||
plain = "小麦育种试验报告——2026".encode("utf-8")
|
||||
token = encrypt_bytes(plain)
|
||||
assert token != plain
|
||||
assert decrypt_bytes(token) == plain
|
||||
|
||||
|
||||
def test_decrypt_wrong_key():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
other_key = Fernet.generate_key()
|
||||
token = encrypt_bytes(b"hello")
|
||||
with pytest.raises(CryptoError):
|
||||
decrypt_bytes(token, key=other_key)
|
||||
|
||||
|
||||
def test_empty_inputs():
|
||||
with pytest.raises(CryptoError):
|
||||
encrypt_bytes(b"")
|
||||
with pytest.raises(CryptoError):
|
||||
decrypt_bytes(b"")
|
||||
|
||||
|
||||
def test_dump_load_encrypted(tmp_path):
|
||||
target = tmp_path / "doc.enc"
|
||||
plain = b"secret plaintext 123"
|
||||
dump_encrypted(target, plain)
|
||||
raw = target.read_bytes()
|
||||
assert b"secret plaintext 123" not in raw # 密文不包含明文
|
||||
assert load_encrypted(target) == plain
|
||||
|
||||
|
||||
def test_dump_atomic_no_tmp_left(tmp_path):
|
||||
target = tmp_path / "a.enc"
|
||||
dump_encrypted(target, b"data")
|
||||
assert not (tmp_path / "a.enc.tmp").exists()
|
||||
assert target.exists()
|
||||
|
||||
|
||||
def test_secure_delete(tmp_path):
|
||||
target = tmp_path / "x.bin"
|
||||
target.write_bytes(b"0123456789")
|
||||
secure_delete(target)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_secure_delete_missing_ok(tmp_path):
|
||||
secure_delete(tmp_path / "nope.bin") # 不抛异常
|
||||
@@ -0,0 +1,86 @@
|
||||
"""test_embed.py —— ollama 嵌入封装(httpx.Client 注入,不碰真 ollama)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vstore import embed
|
||||
from vstore.embed import EmbedError, embed_batch
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, payload=None, fail=False):
|
||||
self._payload = payload
|
||||
self._fail = fail
|
||||
|
||||
def raise_for_status(self):
|
||||
if self._fail:
|
||||
raise httpx.ConnectError("down", request=None)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""记录调用,返回确定性向量。"""
|
||||
|
||||
def __init__(self, vectors=None, fail=False):
|
||||
self.vectors = vectors or []
|
||||
self.fail = fail
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def post(self, url, json=None, timeout=None):
|
||||
self.calls.append((url, json))
|
||||
if self.fail:
|
||||
return _FakeResp(fail=True)
|
||||
text = (json or {}).get("input", "")
|
||||
return _FakeResp({"embeddings": [[1.0] + [0.0] * 1023]})
|
||||
|
||||
|
||||
def _vec():
|
||||
return [1.0] + [0.0] * 1023
|
||||
|
||||
|
||||
def test_embed_batch_ok(monkeypatch):
|
||||
fake = _FakeClient()
|
||||
monkeypatch.setattr(embed.httpx, "Client", lambda: fake)
|
||||
out = embed_batch(["hello", "world"], url="http://x", model="m")
|
||||
assert len(out) == 2
|
||||
assert out[0] == _vec()
|
||||
assert len(fake.calls) == 2
|
||||
|
||||
|
||||
def test_embed_batch_empty_text_no_upstream(monkeypatch):
|
||||
fake = _FakeClient()
|
||||
monkeypatch.setattr(embed.httpx, "Client", lambda: fake)
|
||||
out = embed_batch(["", " ", "hello"])
|
||||
assert len(out) == 3
|
||||
assert out[0] == [0.0] * 1024 # 空文本零向量
|
||||
assert len(fake.calls) == 1 # 只有 "hello" 打上游
|
||||
|
||||
|
||||
def test_embed_batch_upstream_fail_degrades_zero(monkeypatch):
|
||||
fake = _FakeClient(fail=True)
|
||||
monkeypatch.setattr(embed.httpx, "Client", lambda: fake)
|
||||
out = embed_batch(["hello"])
|
||||
assert out == [[0.0] * 1024] # 降级零向量,不抛
|
||||
|
||||
|
||||
def test_embed_batch_bad_dim_degrades(monkeypatch):
|
||||
class BadClient(_FakeClient):
|
||||
def post(self, url, json=None, timeout=None):
|
||||
return _FakeResp({"embeddings": [[1.0, 2.0]]}) # 维度不符
|
||||
|
||||
monkeypatch.setattr(embed.httpx, "Client", lambda: BadClient())
|
||||
out = embed_batch(["hello"])
|
||||
assert out == [[0.0] * 1024]
|
||||
|
||||
|
||||
def test_embed_batch_empty_list():
|
||||
assert embed_batch([]) == []
|
||||
@@ -0,0 +1,141 @@
|
||||
"""test_parsing.py —— docx/pdf/文本解析 + 大纲派生。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from vstore.parsing import (
|
||||
MAX_PDF_PAGES,
|
||||
ParsingError,
|
||||
extract,
|
||||
extract_docx,
|
||||
extract_pdf,
|
||||
extract_text_file,
|
||||
outline_to_embedding_chunks,
|
||||
outline_to_markdown,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# docx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_docx_bytes() -> bytes:
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
doc.add_heading("第一章 总则", level=1)
|
||||
doc.add_paragraph("这是正文第一段,介绍项目背景。")
|
||||
doc.add_heading("1.1 适用范围", level=2)
|
||||
doc.add_paragraph("本制度适用于全体人员。")
|
||||
table = doc.add_table(rows=2, cols=2)
|
||||
table.cell(0, 0).text = "字段"
|
||||
table.cell(0, 1).text = "说明"
|
||||
table.cell(1, 0).text = "名称"
|
||||
table.cell(1, 1).text = "项目名称"
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_extract_docx_outline_paragraphs_tables():
|
||||
parsed = extract_docx(_make_docx_bytes())
|
||||
assert [o["text"] for o in parsed["outline"]] == ["第一章 总则", "1.1 适用范围"]
|
||||
assert parsed["outline"][0]["level"] == 1
|
||||
assert parsed["outline"][1]["level"] == 2
|
||||
assert any("正文第一段" in p for p in parsed["paragraphs"])
|
||||
assert len(parsed["tables"]) == 1
|
||||
assert parsed["tables"][0][0] == ["字段", "说明"]
|
||||
assert parsed["title"] # 首个非标题段落作为标题
|
||||
assert "used_styles" in parsed["style_hints"]
|
||||
|
||||
|
||||
def test_extract_docx_bad_bytes():
|
||||
with pytest.raises(ParsingError):
|
||||
extract_docx(b"this is not a docx file at all")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pdf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_pdf_bytes(pages: int = 2) -> bytes:
|
||||
import pymupdf
|
||||
|
||||
doc = pymupdf.open()
|
||||
for i in range(pages):
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 72), f"Page {i + 1} content line")
|
||||
doc.set_toc([[1, "第一章 概述", 1], [2, "第一节 背景", 1]])
|
||||
data = doc.tobytes()
|
||||
doc.close()
|
||||
return data
|
||||
|
||||
|
||||
def test_extract_pdf_outline_and_text():
|
||||
parsed = extract_pdf(_make_pdf_bytes())
|
||||
assert [o["text"] for o in parsed["outline"]] == ["第一章 概述", "第一节 背景"]
|
||||
assert any("Page 1" in p for p in parsed["paragraphs"])
|
||||
assert parsed["title"] == "第一章 概述"
|
||||
|
||||
|
||||
def test_extract_pdf_page_limit(monkeypatch):
|
||||
monkeypatch.setattr("vstore.parsing.MAX_PDF_PAGES", 2)
|
||||
with pytest.raises(ParsingError):
|
||||
extract_pdf(_make_pdf_bytes(pages=3))
|
||||
|
||||
|
||||
def test_extract_pdf_bad_bytes():
|
||||
with pytest.raises(ParsingError):
|
||||
extract_pdf(b"not a pdf")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文本文件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_extract_text_utf8():
|
||||
parsed = extract_text_file("第一段内容。\n\n第二段内容。".encode("utf-8"))
|
||||
assert parsed["paragraphs"] == ["第一段内容。", "第二段内容。"]
|
||||
assert parsed["title"] == "第一段内容。"
|
||||
|
||||
|
||||
def test_extract_text_gb18030_fallback():
|
||||
# "中文" 的 gb18030 编码
|
||||
parsed = extract_text_file("中文内容测试".encode("gb18030"))
|
||||
assert parsed["paragraphs"] == ["中文内容测试"]
|
||||
|
||||
|
||||
def test_extract_dispatch_by_extension():
|
||||
assert "outline" in extract(b"plain text", "a.txt")
|
||||
assert "outline" in extract(b"plain text", "noext")
|
||||
with pytest.raises(ParsingError):
|
||||
extract(b"xx", "a.pdf")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 大纲派生
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_outline_to_markdown():
|
||||
md = outline_to_markdown([{"level": 1, "text": "总则"}, {"level": 3, "text": "细则"}])
|
||||
assert md == "# 总则\n### 细则"
|
||||
|
||||
|
||||
def test_outline_chunks_short():
|
||||
outline = [{"level": 1, "text": "总则"}]
|
||||
chunks = outline_to_embedding_chunks(outline)
|
||||
assert chunks == ["# 总则"]
|
||||
|
||||
|
||||
def test_outline_chunks_long_split_limited():
|
||||
# 构造超长大纲:10 个一级标题,每个 900 字符 → 全长 > 7500
|
||||
outline = [{"level": 1, "text": f"第{i}章" + "x" * 900} for i in range(10)]
|
||||
chunks = outline_to_embedding_chunks(outline)
|
||||
assert 1 <= len(chunks) <= 3 # 不超过 MAX_OUTLINE_CHUNKS
|
||||
assert all(len(c) <= 7600 for c in chunks)
|
||||
|
||||
|
||||
def test_outline_chunks_empty():
|
||||
assert outline_to_embedding_chunks([]) == []
|
||||
@@ -0,0 +1,119 @@
|
||||
"""test_retrieval.py —— faiss 索引 + index.json 单一事实源 + 三档降级。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vstore.retrieval import INDEX_FILE, RetrievalIndex
|
||||
|
||||
|
||||
def _vec(idx: int, dim: int = 1024) -> list[float]:
|
||||
v = np.zeros(dim, dtype=np.float32)
|
||||
v[idx % dim] = 1.0
|
||||
return v.tolist()
|
||||
|
||||
|
||||
def _add(ri: RetrievalIndex, doc_id: str, name: str, idx: int, **meta):
|
||||
row = {
|
||||
"id": doc_id, "name": name, "category": meta.get("category", ""),
|
||||
"tags": meta.get("tags", ""), "description": meta.get("description", ""),
|
||||
"as_template": meta.get("as_template", False),
|
||||
"created_at": meta.get("created_at", "2026-09-01T00:00:00Z"),
|
||||
}
|
||||
return ri.add_item(row, _vec(idx))
|
||||
|
||||
|
||||
def test_add_and_json_row_order(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "甲", 0)
|
||||
_add(ri, "b", "乙", 1)
|
||||
data = json.loads((tmp_path / INDEX_FILE).read_text(encoding="utf-8"))
|
||||
assert [it["id"] for it in data["items"]] == ["a", "b"] # 行号 == 下标
|
||||
assert ri.count() == 2
|
||||
|
||||
|
||||
def test_load_rebuilds_faiss(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "甲", 0)
|
||||
ri2 = RetrievalIndex(tmp_path)
|
||||
ri2.load()
|
||||
result = ri2.search(_vec(0), top_k=1)
|
||||
assert result["mode"] == "vector"
|
||||
assert result["items"][0]["id"] == "a"
|
||||
|
||||
|
||||
def test_search_vector_mode(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "小麦", 0)
|
||||
_add(ri, "b", "水稻", 1)
|
||||
result = ri.search(_vec(1), top_k=1)
|
||||
assert result["mode"] == "vector"
|
||||
assert result["items"][0]["id"] == "b"
|
||||
|
||||
|
||||
def test_search_filters_category_and_template(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "模板甲", 0, category="公文", as_template=True)
|
||||
_add(ri, "b", "资料乙", 1, category="资料", as_template=False)
|
||||
result = ri.search(_vec(0), top_k=5, category="公文")
|
||||
assert all(i["category"] == "公文" for i in result["items"])
|
||||
result2 = ri.search(_vec(0), top_k=5, as_template=True)
|
||||
assert all(i["as_template"] for i in result2["items"])
|
||||
|
||||
|
||||
def test_search_keyword_fallback(tmp_path):
|
||||
"""向量无命中时(零向量+过滤)→ meta 关键词档。"""
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "小麦育种", 0, description="关于小麦的内容")
|
||||
_add(ri, "b", "水稻栽培", 1, description="关于水稻的内容")
|
||||
result = ri.search([0.0] * 1024, top_k=2, keywords=["小麦"])
|
||||
assert result["mode"] == "keyword"
|
||||
assert result["items"][0]["id"] == "a"
|
||||
|
||||
|
||||
def test_search_latest_fallback(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "甲", 0, created_at="2026-09-01T00:00:00Z")
|
||||
_add(ri, "b", "乙", 1, created_at="2026-09-02T00:00:00Z")
|
||||
result = ri.search([0.0] * 1024, top_k=2) # 无关键词
|
||||
assert result["mode"] == "latest"
|
||||
assert result["items"][0]["id"] == "b" # 最新在前
|
||||
|
||||
|
||||
def test_search_empty(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
result = ri.search(_vec(0))
|
||||
assert result == {"mode": "empty", "items": []}
|
||||
|
||||
|
||||
def test_remove_item(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "甲", 0)
|
||||
_add(ri, "b", "乙", 1)
|
||||
assert ri.remove_item("a") is True
|
||||
assert ri.count() == 1
|
||||
assert ri.remove_item("nope") is False
|
||||
|
||||
|
||||
def test_update_meta(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "a", "甲", 0)
|
||||
assert ri.update_meta("a", {"name": "甲改", "category": "新类"}) is True
|
||||
rows = ri.rows()
|
||||
assert rows[0]["name"] == "甲改"
|
||||
assert rows[0]["category"] == "新类"
|
||||
assert ri.update_meta("nope", {"name": "x"}) is False
|
||||
|
||||
|
||||
def test_rebuild_atomic_switch(tmp_path):
|
||||
ri = RetrievalIndex(tmp_path)
|
||||
_add(ri, "old", "旧", 0)
|
||||
items = [({"id": "n1", "name": "新一", "created_at": "2026-09-03T00:00:00Z"}, _vec(2)),
|
||||
({"id": "n2", "name": "新二", "created_at": "2026-09-03T00:00:00Z"}, _vec(3))]
|
||||
count = ri.rebuild(items)
|
||||
assert count == 2
|
||||
data = json.loads((tmp_path / INDEX_FILE).read_text(encoding="utf-8"))
|
||||
assert [it["id"] for it in data["items"]] == ["n1", "n2"]
|
||||
assert not (tmp_path / (INDEX_FILE + ".rebuild.tmp")).exists() # 临时文件已切换
|
||||
assert ri.count() == 2
|
||||
@@ -0,0 +1,47 @@
|
||||
"""test_settings.py —— 限额常量与路径配置。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from vstore import settings
|
||||
from vstore.settings import VstorePaths, get_fernet_key
|
||||
|
||||
|
||||
def test_limits_match_brief():
|
||||
"""限额常量与任务书对齐。"""
|
||||
assert settings.MAX_ATTACH_COUNT == 5
|
||||
assert settings.MAX_ATTACH_BYTES == 20 * 1024 * 1024
|
||||
assert settings.MAX_ATTACH_CHARS == 60000
|
||||
assert settings.MAX_SECTIONS == 12
|
||||
assert settings.MAX_OUTLINE_CHUNKS == 3
|
||||
assert settings.EMBED_DIM == 1024
|
||||
|
||||
|
||||
def test_paths_default_derivation(tmp_path):
|
||||
paths = VstorePaths(root=str(tmp_path))
|
||||
assert paths.vault_path == str(tmp_path / "vault")
|
||||
assert paths.index_path == str(tmp_path / "index")
|
||||
|
||||
|
||||
def test_paths_explicit_override():
|
||||
paths = VstorePaths(root="/x", vault_path="/v", index_path="http://h:1")
|
||||
assert paths.vault_path == "/v"
|
||||
assert paths.index_path == "http://h:1"
|
||||
|
||||
|
||||
def test_embed_defaults():
|
||||
assert settings.DEFAULT_EMBED_URL == "http://127.0.0.1:11434"
|
||||
assert settings.DEFAULT_EMBED_MODEL == "bge-m3:567m"
|
||||
|
||||
|
||||
def test_fernet_key_required(monkeypatch):
|
||||
monkeypatch.delenv("VSTORE_KEY", raising=False)
|
||||
with pytest.raises(RuntimeError):
|
||||
get_fernet_key()
|
||||
|
||||
|
||||
def test_fernet_key_ok(monkeypatch):
|
||||
monkeypatch.setenv("VSTORE_KEY", "k" * 44)
|
||||
assert get_fernet_key() == b"k" * 44
|
||||
@@ -0,0 +1,124 @@
|
||||
"""test_vault.py —— 主库:加密落盘无明文断言 + 增删检索(假 embed)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vstore.vault import Vault, VaultError, get_embed_fn, set_embed_fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path, embed_fn):
|
||||
v = Vault(tmp_path / "vault", tmp_path / "index")
|
||||
return v
|
||||
|
||||
|
||||
def _read_all_bytes(path) -> bytes:
|
||||
if path.is_dir():
|
||||
data = b""
|
||||
for f in sorted(path.rglob("*")):
|
||||
if f.is_file():
|
||||
data += f.read_bytes()
|
||||
return data
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def test_add_encrypted_no_plaintext_on_disk(vault):
|
||||
content = "小麦育种试验报告。这是绝密明文,绝不能出现在磁盘上。".encode("utf-8")
|
||||
item = vault.add(content, "report.txt", {"name": "试验报告", "description": "小麦"}, as_template=False)
|
||||
assert item["document_id"]
|
||||
# 全部落盘字节(vault + index)不得包含明文
|
||||
for path in (vault.vault_dir, vault.index.dir):
|
||||
assert "小麦育种试验报告" not in _read_all_bytes(path).decode("utf-8", errors="replace")
|
||||
assert "绝密明文" not in _read_all_bytes(vault.vault_dir).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def test_add_enc_is_fernet_token(vault):
|
||||
item = vault.add(b"hello doc", "a.txt", {"name": "n"})
|
||||
enc = vault.vault_dir / f"{item['document_id']}.enc"
|
||||
assert enc.exists()
|
||||
assert enc.read_bytes().startswith(b"gAAAA") # Fernet token 前缀
|
||||
|
||||
|
||||
def test_add_and_get_decrypted(vault):
|
||||
item = vault.add(
|
||||
"正文内容第一段。\n\n第二段。".encode("utf-8"), "doc.txt", {"name": "材料", "category": "资料"}
|
||||
)
|
||||
doc = vault.get_decrypted(item["document_id"])
|
||||
assert doc["meta"]["name"] == "材料"
|
||||
assert "正文内容第一段" in doc["extracted"]["paragraphs"][0]
|
||||
json_doc = vault.get_document_json(item["document_id"])
|
||||
assert json_doc["title"] == "正文内容第一段。"
|
||||
|
||||
|
||||
def test_add_empty_rejected(vault):
|
||||
with pytest.raises(VaultError):
|
||||
vault.add(b"", "a.txt", {})
|
||||
|
||||
|
||||
def test_add_oversize_rejected(vault):
|
||||
with pytest.raises(VaultError):
|
||||
vault.add(b"x" * (21 * 1024 * 1024), "a.txt", {})
|
||||
|
||||
|
||||
def test_add_bad_docx_rejected(vault):
|
||||
with pytest.raises(VaultError):
|
||||
vault.add(b"not a docx", "a.docx", {})
|
||||
|
||||
|
||||
def test_delete_removes_enc_and_index(vault):
|
||||
item = vault.add(b"content", "a.txt", {"name": "n"})
|
||||
doc_id = item["document_id"]
|
||||
assert vault.delete(doc_id) is True
|
||||
assert not (vault.vault_dir / f"{doc_id}.enc").exists()
|
||||
assert vault.get_decrypted(doc_id) is None
|
||||
assert vault.delete("nope") is False
|
||||
|
||||
|
||||
def test_search_uses_fake_embed(vault):
|
||||
vault.add("小麦内容。".encode("utf-8"), "a.txt", {"name": "小麦", "description": "小麦育种"})
|
||||
vault.add("水稻内容。".encode("utf-8"), "b.txt", {"name": "水稻", "description": "水稻栽培"})
|
||||
result = vault.search("水稻", top_k=2)
|
||||
assert result["mode"] in ("vector", "keyword", "latest")
|
||||
assert len(result["items"]) >= 1
|
||||
|
||||
|
||||
def test_rebuild_index_from_enc(vault):
|
||||
vault.add("甲内容。".encode("utf-8"), "a.txt", {"name": "甲", "as_template": True})
|
||||
vault.add("乙内容。".encode("utf-8"), "b.txt", {"name": "乙"})
|
||||
rebuilt = vault.rebuild_index()
|
||||
assert rebuilt["status"] == "ok"
|
||||
assert rebuilt["count"] == 2
|
||||
# 重建后可检索
|
||||
result = vault.search("乙")
|
||||
assert len(result["items"]) >= 1
|
||||
|
||||
|
||||
def test_get_style_missing_returns_empty(vault):
|
||||
item = vault.add(b"plain", "a.txt", {"name": "n"})
|
||||
assert vault.get_style(item["document_id"]) == {}
|
||||
|
||||
|
||||
def test_update_metadata(vault):
|
||||
item = vault.add(b"x", "a.txt", {"name": "n"})
|
||||
assert vault.update_metadata(item["document_id"], {"name": "改名"}) is True
|
||||
rows = vault.list_rows()
|
||||
assert rows[0]["name"] == "改名"
|
||||
|
||||
|
||||
def test_template_outline_chunks_limited(vault):
|
||||
# 大纲超长 → 切块 ≤ MAX_OUTLINE_CHUNKS;embed_chunks 反映切块数
|
||||
from vstore.parsing import extract_docx # 用 docx 造带大纲文档
|
||||
|
||||
import io
|
||||
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
for i in range(12):
|
||||
doc.add_heading(f"第{i}章" + "内容" * 100, level=1)
|
||||
doc.add_paragraph("正文。")
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
|
||||
item = vault.add(buf.getvalue(), "t.docx", {"name": "长模板"}, as_template=True)
|
||||
assert 1 <= item["embed_chunks"] <= 3
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
"""vault.py —— 单一主库:上传 → 解析 → 提取 → 嵌入 → 加密落盘 → 索引。
|
||||
|
||||
明文纪律:
|
||||
- add() 原文只在函数内流转,函数末尾丢弃引用,绝不落盘明文
|
||||
- 磁盘上只有 .enc 密文(Fernet)
|
||||
- 检索 meta 存 index.json(name/category/tags/description,不含正文)
|
||||
|
||||
后端形态:
|
||||
- 本地:Vault(本机磁盘,vault/ 密文 + index/ 检索索引)
|
||||
- 远程:RemoteVault(操作转 HTTP 指向远程 vstore 根,接口语义与本地一致)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from .crypto import CryptoError, dump_encrypted, load_encrypted, secure_delete
|
||||
from .embed import embed_batch
|
||||
from .parsing import ParsingError, extract, outline_to_embedding_chunks
|
||||
from .retrieval import RetrievalIndex, RetrievalError
|
||||
from .settings import (
|
||||
EMBED_DIM,
|
||||
MAX_ATTACH_BYTES,
|
||||
MAX_ATTACH_CHARS,
|
||||
MAX_OUTLINE_CHUNKS,
|
||||
get_paths,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 嵌入调用函数(测试可注入假 embed)
|
||||
_EMBED_FN: Callable[..., list[list[float]]] = embed_batch
|
||||
|
||||
|
||||
class VaultError(Exception):
|
||||
"""主库操作失败(限额/解析/加密/检索)。"""
|
||||
|
||||
|
||||
def set_embed_fn(fn: Callable[..., list[list[float]]]) -> None:
|
||||
"""注入嵌入函数(测试用)。"""
|
||||
global _EMBED_FN
|
||||
_EMBED_FN = fn
|
||||
|
||||
|
||||
def get_embed_fn() -> Callable[..., list[list[float]]]:
|
||||
return _EMBED_FN
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _serialize_doc(extracted: dict[str, Any], meta: dict[str, Any]) -> bytes:
|
||||
"""序列化落盘结构(JSON,明文只在内存)。"""
|
||||
payload = {
|
||||
"meta": meta,
|
||||
"extracted": {
|
||||
"title": extracted.get("title") or "",
|
||||
"outline": extracted.get("outline") or [],
|
||||
"paragraphs": extracted.get("paragraphs") or [],
|
||||
"tables": extracted.get("tables") or [],
|
||||
"style_hints": extracted.get("style_hints") or {},
|
||||
},
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _deserialize_doc(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(payload, dict) or "extracted" not in payload:
|
||||
raise VaultError("密文内容结构异常")
|
||||
return payload
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise VaultError(f"密文内容解析失败:{exc}") from exc
|
||||
|
||||
|
||||
def _meta_to_keywords(meta: dict[str, Any]) -> list[str]:
|
||||
"""meta → 关键词列表(检索二档用):name/description/tags 切词。"""
|
||||
words: list[str] = []
|
||||
for key in ("name", "title", "description", "tags"):
|
||||
value = str(meta.get(key) or "")
|
||||
if value:
|
||||
words.extend([w for w in value.replace(",", " ").replace(",", " ").split() if len(w) >= 2])
|
||||
return words
|
||||
|
||||
|
||||
def _make_doc_json(path: str, title: str, outline: list[dict[str, Any]],
|
||||
paragraphs: list[str], tables: list[list[list[str]]],
|
||||
style_hints: dict[str, Any]) -> dict[str, Any]:
|
||||
"""构造 /documents/{id} 返回体(明文全文,仅生成时取)。"""
|
||||
return {
|
||||
"title": title,
|
||||
"outline": outline,
|
||||
"paragraphs": paragraphs,
|
||||
"tables": tables,
|
||||
"style_hints": style_hints,
|
||||
"enc_path": path,
|
||||
}
|
||||
|
||||
|
||||
class Vault:
|
||||
"""本地主库。add:限额检查 → 解析 → 嵌入 → 加密落盘 → 索引。"""
|
||||
|
||||
def __init__(self, vault_path: str | Path, index_path: str | Path) -> None:
|
||||
self.vault_dir = Path(vault_path)
|
||||
self.index = RetrievalIndex(index_path)
|
||||
|
||||
# ---- 增删改查 ----
|
||||
|
||||
def add(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
meta: dict[str, Any],
|
||||
as_template: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""入库一条。原文只在函数内流转,末尾丢弃引用。"""
|
||||
doc_id = str(uuid.uuid4())
|
||||
name = str(meta.get("name") or filename or "未命名")
|
||||
category = str(meta.get("category") or "")
|
||||
tags = str(meta.get("tags") or "")
|
||||
description = str(meta.get("description") or "")
|
||||
|
||||
if not file_bytes:
|
||||
raise VaultError("上传文件为空")
|
||||
if len(file_bytes) > MAX_ATTACH_BYTES:
|
||||
raise VaultError(f"文件超过 {MAX_ATTACH_BYTES // (1024 * 1024)}MB 限制")
|
||||
try:
|
||||
extracted = extract(file_bytes, filename)
|
||||
except ParsingError as exc:
|
||||
raise VaultError(str(exc)) from exc
|
||||
|
||||
paragraphs = extracted.get("paragraphs") or []
|
||||
outline = extracted.get("outline") or []
|
||||
text_len = sum(len(p) for p in paragraphs)
|
||||
if text_len > MAX_ATTACH_CHARS:
|
||||
raise VaultError(f"文件内容超过 {MAX_ATTACH_CHARS} 字符限制(当前 {text_len})")
|
||||
|
||||
doc_meta = {
|
||||
"id": doc_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"as_template": bool(as_template),
|
||||
"size": len(file_bytes),
|
||||
"created_at": _now_iso(),
|
||||
"embed_chunks": 0,
|
||||
}
|
||||
|
||||
# 嵌入块:模版用标题+大纲;普通资料用标题+描述+正文前 20 段
|
||||
chunks = [extracted.get("title") or name, description]
|
||||
if as_template:
|
||||
chunks = chunks[:1] + outline_to_embedding_chunks(outline)[:MAX_OUTLINE_CHUNKS]
|
||||
else:
|
||||
body = "\n".join(paragraphs[:20])
|
||||
if body:
|
||||
chunks.append(body[:MAX_ATTACH_CHARS])
|
||||
chunks = [c for c in chunks if c and c.strip()]
|
||||
vectors = _EMBED_FN(chunks)
|
||||
|
||||
enc_path = self.vault_dir / f"{doc_id}.enc"
|
||||
try:
|
||||
dump_encrypted(enc_path, _serialize_doc(extracted, doc_meta))
|
||||
except (CryptoError, OSError) as exc:
|
||||
raise VaultError(f"密文落盘失败:{exc}") from exc
|
||||
|
||||
try:
|
||||
doc_meta["enc_path"] = str(enc_path)
|
||||
doc_meta["embed_chunks"] = len(chunks)
|
||||
# keywords 只用用户 meta,不含正文派生的 title(明文纪律)
|
||||
doc_meta["keywords"] = _meta_to_keywords(doc_meta)
|
||||
self.index.add_item(doc_meta, vectors[0])
|
||||
except (RetrievalError, ValueError, IndexError) as exc:
|
||||
# 索引失败:清理密文,不让库处于半一致状态
|
||||
secure_delete(enc_path)
|
||||
raise VaultError(f"索引写入失败:{exc}") from exc
|
||||
|
||||
embed_chunks = doc_meta["embed_chunks"]
|
||||
embed_sha = hashlib.sha256(
|
||||
json.dumps(vectors[0], ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# 明文纪律:原文字节与解析结果在本函数末尾丢弃引用
|
||||
del file_bytes, extracted, paragraphs, outline, chunks, vectors
|
||||
|
||||
return {
|
||||
"document_id": doc_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"as_template": bool(as_template),
|
||||
"enc_path": str(enc_path),
|
||||
"embed_sha": embed_sha,
|
||||
"embed_chunks": embed_chunks,
|
||||
}
|
||||
|
||||
def get_decrypted(self, doc_id: str) -> dict[str, Any] | None:
|
||||
"""取解密后的文档结构(meta + extracted)。不存在返回 None。"""
|
||||
enc_path = self.vault_dir / f"{doc_id}.enc"
|
||||
if not enc_path.exists():
|
||||
return None
|
||||
try:
|
||||
return _deserialize_doc(load_encrypted(enc_path))
|
||||
except (CryptoError, VaultError, OSError) as exc:
|
||||
logger.warning("vault: 解密失败 %s:%s", doc_id, exc)
|
||||
return None
|
||||
|
||||
def get_document_json(self, doc_id: str) -> dict[str, Any] | None:
|
||||
"""GET /documents/{id} 响应体(明文全文 + outline + meta)。"""
|
||||
doc = self.get_decrypted(doc_id)
|
||||
if doc is None:
|
||||
return None
|
||||
extracted = doc.get("extracted") or {}
|
||||
return _make_doc_json(
|
||||
path=f"vault/{doc_id}.enc",
|
||||
title=extracted.get("title") or "",
|
||||
outline=extracted.get("outline") or [],
|
||||
paragraphs=extracted.get("paragraphs") or [],
|
||||
tables=extracted.get("tables") or [],
|
||||
style_hints=extracted.get("style_hints") or {},
|
||||
)
|
||||
|
||||
def get_style(self, doc_id: str) -> dict[str, Any] | None:
|
||||
"""docx 样式提取(/documents/{id}/style)。"""
|
||||
doc = self.get_decrypted(doc_id)
|
||||
if doc is None:
|
||||
return None
|
||||
return (doc.get("extracted") or {}).get("style_hints") or {}
|
||||
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
"""删除:索引行 + 密文 secure_delete。"""
|
||||
removed = self.index.remove_item(doc_id)
|
||||
enc_path = self.vault_dir / f"{doc_id}.enc"
|
||||
if enc_path.exists():
|
||||
secure_delete(enc_path)
|
||||
return removed
|
||||
|
||||
def list_rows(self) -> list[dict[str, Any]]:
|
||||
"""主库行列表(无向量无正文)。"""
|
||||
return self.index.rows()
|
||||
|
||||
def update_metadata(self, doc_id: str, meta: dict[str, Any]) -> bool:
|
||||
"""更新 meta。"""
|
||||
return self.index.update_meta(doc_id, meta)
|
||||
|
||||
# ---- 检索 ----
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
category: str | None = None,
|
||||
as_template: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""三档降级检索(向量 → meta 关键词 → 最新)。query 为空走 latest。"""
|
||||
keywords = _meta_to_keywords({"description": query, "name": query}) if query else None
|
||||
vector = _EMBED_FN([query or ""])[0] if query else [0.0] * EMBED_DIM
|
||||
return self.index.search(
|
||||
vector, top_k=top_k, category=category, as_template=as_template, keywords=keywords
|
||||
)
|
||||
|
||||
def rebuild_index(self) -> dict[str, Any]:
|
||||
"""整库重建:全部密文重新解密 → 嵌入 → 原子切换。
|
||||
|
||||
明文只在内存流转;任何一条失败(密钥不符/嵌入失败)整库拒绝切换。
|
||||
"""
|
||||
items: list[tuple[dict[str, Any], list[float]]] = []
|
||||
enc_files = sorted(self.vault_dir.glob("*.enc"))
|
||||
for enc in enc_files:
|
||||
doc_id = enc.stem
|
||||
try:
|
||||
doc = _deserialize_doc(load_encrypted(enc))
|
||||
except (CryptoError, VaultError, OSError) as exc:
|
||||
return {"status": "error", "message": f"密文 {doc_id} 解密失败:{exc}"}
|
||||
meta = doc.get("meta") or {}
|
||||
extracted = doc.get("extracted") or {}
|
||||
title = extracted.get("title") or str(meta.get("name") or doc_id)
|
||||
chunks = [title, str(meta.get("description") or "")]
|
||||
if meta.get("as_template"):
|
||||
chunks = chunks[:1] + outline_to_embedding_chunks(
|
||||
extracted.get("outline") or []
|
||||
)[:MAX_OUTLINE_CHUNKS]
|
||||
else:
|
||||
body = "\n".join((extracted.get("paragraphs") or [])[:20])
|
||||
if body:
|
||||
chunks.append(body[:MAX_ATTACH_CHARS])
|
||||
chunks = [c for c in chunks if c and c.strip()]
|
||||
vectors = _EMBED_FN(chunks)
|
||||
if not vectors:
|
||||
return {"status": "error", "message": f"文档 {doc_id} 嵌入失败"}
|
||||
row = dict(meta)
|
||||
# keywords 只用 meta,不含正文派生的 title(明文纪律)
|
||||
row["keywords"] = _meta_to_keywords(meta)
|
||||
items.append((row, vectors[0]))
|
||||
try:
|
||||
count = self.index.rebuild(items)
|
||||
except (RetrievalError, ValueError) as exc:
|
||||
return {"status": "error", "message": f"索引重建失败:{exc}"}
|
||||
return {"status": "ok", "count": count}
|
||||
|
||||
|
||||
class RemoteVault:
|
||||
"""远程主库:所有操作转 HTTP 指向远程 vstore 根(接口语义与本地一致)。
|
||||
|
||||
vault/index 操作统一打到 index_url 的 /api/vstore/*(远程端自身
|
||||
再按它的 vault/index 配置落盘)。同步 httpx(远程形态 V1 低频使用)。
|
||||
"""
|
||||
|
||||
def __init__(self, vault_url: str, index_url: str) -> None:
|
||||
self.vault_url = vault_url.rstrip("/")
|
||||
self.index_url = index_url.rstrip("/")
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
resp = httpx.request(method, f"{self.index_url}{path}", timeout=60.0, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
def add(self, file_bytes: bytes, filename: str, meta: dict[str, Any],
|
||||
as_template: bool = False) -> dict[str, Any]:
|
||||
resp = self._request(
|
||||
"POST", "/api/vstore/ingest",
|
||||
data={k: v for k, v in meta.items() if k in ("name", "category", "description", "tags")},
|
||||
files={"file": (filename, file_bytes)},
|
||||
params={"as_template": bool(as_template)},
|
||||
)
|
||||
payload = resp.json()
|
||||
return payload.get("item") or {}
|
||||
|
||||
def search(self, query: str, top_k: int = 5, category: str | None = None,
|
||||
as_template: bool | None = None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"q": query, "top_k": top_k}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if as_template is not None:
|
||||
params["as_template"] = as_template
|
||||
resp = self._request("GET", "/api/vstore/search", params=params)
|
||||
payload = resp.json()
|
||||
return {"mode": payload.get("mode", "remote"), "items": payload.get("items", [])}
|
||||
|
||||
def get_document_json(self, doc_id: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
resp = self._request("GET", f"/api/vstore/documents/{doc_id}")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
return resp.json().get("document") or None
|
||||
|
||||
def get_style(self, doc_id: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
resp = self._request("GET", f"/api/vstore/documents/{doc_id}/style")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
return resp.json().get("style_hints") or None
|
||||
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
resp = self._request("DELETE", f"/api/vstore/documents/{doc_id}")
|
||||
return bool(resp.json().get("removed"))
|
||||
|
||||
def update_metadata(self, doc_id: str, meta: dict[str, Any]) -> bool:
|
||||
# 远程端无 PATCH 端点;V1 远程形态以 BFF 落库为准(meta 更新容错忽略)
|
||||
logger.warning("vstore: 远程形态 update_metadata 暂不支持(%s)", doc_id)
|
||||
return False
|
||||
|
||||
def rebuild_index(self) -> dict[str, Any]:
|
||||
resp = self._request("POST", "/api/vstore/rebuild-index")
|
||||
payload = resp.json()
|
||||
return {"status": "ok" if payload.get("ok") else "error", "count": payload.get("count", 0)}
|
||||
|
||||
def list_rows(self) -> list[dict[str, Any]]:
|
||||
resp = self._request("GET", "/api/vstore/search", params={"q": "", "top_k": 50})
|
||||
return resp.json().get("items", [])
|
||||
|
||||
|
||||
# ---- 全局单例管理(api.py 启动钩子经 set_backend/init_vault 注入)----
|
||||
|
||||
_g_vault: Vault | RemoteVault | None = None
|
||||
_g_backend: Any = None
|
||||
|
||||
|
||||
def set_backend(backend: Any) -> None:
|
||||
"""注入后端(api.py 启动钩子调用)。"""
|
||||
global _g_backend
|
||||
_g_backend = backend
|
||||
|
||||
|
||||
def init_vault() -> None:
|
||||
"""按后端构造全局主库(本地/远程)。"""
|
||||
global _g_vault
|
||||
backend = _g_backend
|
||||
if backend is None:
|
||||
raise RuntimeError("vstore: 后端未初始化(先 set_backend + init_vault)")
|
||||
if getattr(backend, "kind", "") == "remote":
|
||||
_g_vault = RemoteVault(backend.vault_url, backend.index_url)
|
||||
else:
|
||||
vault = Vault(str(backend.vault_path), str(backend.index_path))
|
||||
vault.index.load()
|
||||
_g_vault = vault
|
||||
|
||||
|
||||
def get_vault() -> Vault | RemoteVault:
|
||||
"""全局主库单例。"""
|
||||
if _g_vault is None:
|
||||
init_vault()
|
||||
return _g_vault # type: ignore[return-value]
|
||||
Reference in New Issue
Block a user