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:
jerry
2026-09-03 21:13:37 +08:00
co-authored by Claude Fable 5
parent aab24d2f85
commit 5db3ead73e
51 changed files with 6904 additions and 22 deletions
+33
View File
@@ -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
+12
View File
@@ -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 轮问答
+52
View File
@@ -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
View File
@@ -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:
+823
View File
@@ -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
View File
@@ -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 错误信封
# ---------------------------------------------------------------------------
+6
View File
@@ -0,0 +1,6 @@
"""services/docgen/ —— 文档生成 BFF 服务层(P1,dev1 归属)。
纯生成/导出,不含存储检索(存储检索在 vstore 独立服务,经 client.py 访问)。
全部模块:settings / client / parsing / llm / intent / md2docx / md2pdf /
pipeline / editor / converter(converter 为 dev2 桩签名适配层,见报告)。
"""
+219
View File
@@ -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)}}
+45
View File
@@ -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()
+90
View File
@@ -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")
+94
View File
@@ -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
+197
View File
@@ -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)
+155
View File
@@ -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
+195
View File
@@ -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
+78
View File
@@ -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
+309
View File
@@ -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)}
+58
View File
@@ -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"
+219
View File
@@ -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())
+163
View File
@@ -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"]
+59
View File
@@ -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)}`;
}
+8 -1
View File
@@ -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>
+502
View File
@@ -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>
+302 -5
View File
@@ -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) */
+85
View File
@@ -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>
+27 -4
View File
@@ -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;
+71 -7
View File
@@ -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"
+63
View File
@@ -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;
+74
View File
@@ -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;
+230
View File
@@ -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);
+227
View File
@@ -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
View File
@@ -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; }
+6
View File
@@ -0,0 +1,6 @@
"""vstore —— 文档加密主库 + 嵌入检索独立服务(FastAPI,端口 8094)。
- 单一主库:上传解析 → 提取 → 嵌入 → Fernet 加密落盘 → faiss 增量索引
- 明文纪律:原文只在内存中流转,磁盘上只存 .enc 密文
- 检索三档降级:向量 → meta 关键词 → 最新 N 条
"""
+149
View File
@@ -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"]}
+123
View File
@@ -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)
+87
View File
@@ -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
+91
View File
@@ -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]
+291
View File
@@ -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)
+32
View File
@@ -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"
+266
View File
@@ -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)
+86
View File
@@ -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
+42
View File
@@ -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
+110
View File
@@ -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
+82
View File
@@ -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
+63
View File
@@ -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") # 不抛异常
+86
View File
@@ -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([]) == []
+141
View File
@@ -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([]) == []
+119
View File
@@ -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
+47
View File
@@ -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
+124
View File
@@ -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
View File
@@ -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]