deploy-bot commited on
Commit
35983fa
·
1 Parent(s): e60e2f8

deploy: backend for appQQQ/ai-chatbot

Browse files

FastAPI + LangGraph + Docling + BGE-M3 + ChromaDB.
Initial release from shuaiwang888/AI-Chatbot main branch.

.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Space build context .gitignore
2
+ # 不要 push 本地开发数据 / venv / 缓存
3
+
4
+ # Local data
5
+ data/
6
+ data/**
7
+ *.db
8
+ *.sqlite
9
+ *.sqlite3
10
+ *.parquet
11
+ *.arrow
12
+ *.bin
13
+ *.safetensors
14
+ *.gguf
15
+
16
+ # Python
17
+ __pycache__/
18
+ *.py[cod]
19
+ *.egg-info/
20
+ .venv/
21
+ venv/
22
+ .pytest_cache/
23
+ .mypy_cache/
24
+ .ruff_cache/
25
+
26
+ # Env & secrets (Space 走 Secrets, 不走 .env)
27
+ .env
28
+ .env.local
29
+ .env.*.local
30
+ *.pem
31
+ *.key
32
+
33
+ # Node (backend 不用, 但防误传)
34
+ node_modules/
35
+ dist/
36
+ *.log
37
+
38
+ # IDE
39
+ .vscode/
40
+ .idea/
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Tests (本项目走 GH Actions, Space 端不跑)
45
+ tests/
Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingFace Spaces Docker image
2
+ # python:3.12-slim 是 2026-06 推荐基础镜像
3
+ FROM python:3.12-slim
4
+
5
+ # HF Spaces 元数据
6
+ LABEL org.opencontainers.image.title="ai-chatbot"
7
+ LABEL org.opencontainers.image.description="Agentic multimodal RAG customer service"
8
+ LABEL org.opencontainers.image.source="https://github.com/yourname/ai-chatbot"
9
+ LABEL org.opencontainers.image.licenses="MIT"
10
+
11
+ # 环境
12
+ ENV PYTHONUNBUFFERED=1 \
13
+ PYTHONDONTWRITEBYTECODE=1 \
14
+ PIP_NO_CACHE_DIR=1 \
15
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
16
+ DATA_DIR=/data \
17
+ HF_HOME=/data/.cache/huggingface \
18
+ TRANSFORMERS_CACHE=/data/.cache/huggingface \
19
+ TOKENIZERS_PARALLELISM=false
20
+
21
+ # 系统依赖 (Poppler for PDF, GL libs for OpenCV/PaddleOCR)
22
+ RUN apt-get update && apt-get install -y --no-install-recommends \
23
+ build-essential \
24
+ gcc \
25
+ g++ \
26
+ libgl1 \
27
+ libglib2.0-0 \
28
+ poppler-utils \
29
+ curl \
30
+ && rm -rf /var/lib/apt/lists/*
31
+
32
+ # 工作目录
33
+ WORKDIR /app
34
+
35
+ # 先复制 requirements 利用 Docker 层缓存
36
+ COPY requirements.txt .
37
+
38
+ # 安装 Python 依赖
39
+ # 单独 install FlagEmbedding 比较慢, 但因为在 requirements.txt 里只装一次
40
+ RUN pip install -r requirements.txt
41
+
42
+ # 可选: 预热模型 (避免 Space 启动超时). 如果镜像太大, 可注释掉, 运行时再下载
43
+ # RUN python -c "from FlagEmbedding import BGEM3FlagModel, FlagReranker; \
44
+ # BGEM3FlagModel('BAAI/bge-m3', use_fp16=True); \
45
+ # FlagReranker('BAAI/bge-reranker-v2-m3')" \
46
+ # || echo "Model pre-warm skipped (will download on first startup)"
47
+
48
+ # 复制应用代码
49
+ COPY app ./app
50
+ COPY pyproject.toml ./pyproject.toml
51
+
52
+ # 数据目录 (HF Space 持久卷挂载点)
53
+ RUN mkdir -p /data/chroma /data/sqlite /data/uploads /data/.cache/huggingface
54
+
55
+ # 健康检查
56
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
57
+ CMD curl -f http://localhost:7860/api/v1/healthz || exit 1
58
+
59
+ # 暴露端口 (HF Spaces 约定 7860)
60
+ EXPOSE 7860
61
+
62
+ # 启动命令
63
+ # --workers 1: 避免 BGE-M3 / ChromaDB 在多 worker 下重复加载模型
64
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "*"]
README.md CHANGED
@@ -1,11 +1,33 @@
1
  ---
2
- title: Ai Chatbot
3
- emoji: 💻
4
- colorFrom: gray
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
- short_description: 基于多模态文档 (PDF / Word / 图片) 的私人智能客服,支持多轮对话、引用源、工具调用 (Agent 模式)
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Chatbot
3
+ emoji: "\U0001F916"
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ pinned: true
8
+ license: mit
9
+ short_description: Agentic multimodal RAG customer service (private)
10
  ---
11
 
12
+ # AI Chatbot Backend
13
+
14
+ Agentic multimodal RAG customer service backend.
15
+
16
+ - FastAPI + LangGraph (StateGraph)
17
+ - Docling (PDF/Word/Image) + BGE-M3 + ChromaDB
18
+ - MiniMax-M3 (default) / switchable to OpenAI / Anthropic / Qwen
19
+ - Persistent via HF Dataset repo (free tier disk is ephemeral)
20
+
21
+ ## Endpoints
22
+
23
+ - `GET /api/v1/healthz` — liveness
24
+ - `GET /api/v1/readyz` — readiness (Chroma + LLM reachable)
25
+ - `POST /api/v1/chat` — non-streaming chat
26
+ - `POST /api/v1/chat/stream` — SSE stream (astream_events v2)
27
+ - `POST /api/v1/documents/upload` — upload + ingest
28
+ - `GET /api/v1/documents` — list
29
+ - `DELETE /api/v1/documents/{id}` — remove
30
+ - `GET /api/v1/sessions` — list sessions
31
+ - `DELETE /api/v1/sessions/{id}` — clear session
32
+
33
+ See [PLAN.md](../PLAN.md) for full design.
app/__init__.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ai-chatbot backend application."""
2
+ __version__ = "0.1.0"
3
+
4
+ # Monkey patch transformers to bypass torch.load safety check on old PyTorch versions (macOS x86_64)
5
+ try:
6
+ import transformers.utils.import_utils
7
+ transformers.utils.import_utils.check_torch_load_is_safe = lambda *args, **kwargs: None
8
+ transformers.utils.import_utils.is_torch_mps_available = lambda *args, **kwargs: False
9
+ except ImportError:
10
+ pass
11
+
12
+ try:
13
+ import transformers.modeling_utils
14
+ transformers.modeling_utils.check_torch_load_is_safe = lambda *args, **kwargs: None
15
+ except ImportError:
16
+ pass
17
+
18
+ # Force CPU device for PyTorch MPS on Intel Macs to prevent NotImplementedError
19
+ try:
20
+ import torch
21
+ torch.backends.mps.is_available = lambda: False
22
+ torch.backends.mps.is_built = lambda: False
23
+ if hasattr(torch, "mps"):
24
+ torch.mps.is_available = lambda: False
25
+ except ImportError:
26
+ pass
27
+
28
+ # Force CPU device for accelerate to avoid MPS auto mapping
29
+ try:
30
+ import accelerate.utils
31
+ accelerate.utils.is_mps_available = lambda *args, **kwargs: False
32
+ except ImportError:
33
+ pass
34
+
35
+ try:
36
+ import accelerate.utils.imports
37
+ accelerate.utils.imports.is_mps_available = lambda *args, **kwargs: False
38
+ except ImportError:
39
+ pass
40
+
41
+ # Force CPU device for Docling to prevent early MPS detection during import
42
+ try:
43
+ import docling.utils.accelerator_utils
44
+ docling.utils.accelerator_utils.decide_device = lambda *args, **kwargs: "cpu"
45
+ except ImportError:
46
+ pass
app/agents/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """LangGraph agent (阶段 3 接入). 阶段 1 留空壳."""
app/agents/graph.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph StateGraph 装配 + 编译.
2
+
3
+ 设计: 图只负责"检索循环" (route → query_rewrite → retrieve → rerank → evaluate).
4
+ answer 流式生成由 chat 端点直接驱动 (answer_node_stream + asyncio.Queue),
5
+ 这样 SSE token 流不依赖 astream_events 的复杂性.
6
+
7
+ 流程图:
8
+
9
+ START
10
+
11
+
12
+ route ─── direct ───────────────────────────────► END
13
+
14
+
15
+ query_rewrite ──► retrieve ──► rerank ──► evaluate
16
+
17
+ ┌──── (needs_more, iter<max) ───┐
18
+ │ │
19
+ └──── (relevant/irrelevant/max) ┘
20
+
21
+
22
+ END
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+
28
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
29
+ from langgraph.graph import END, START, StateGraph
30
+
31
+ from app.agents.nodes import (
32
+ evaluate_node,
33
+ query_rewrite_node,
34
+ rerank_node,
35
+ retrieve_node,
36
+ route_node,
37
+ )
38
+ from app.agents.state import AgentState
39
+ from app.config import settings
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # ========== 边决策 ==========
45
+ def _after_route(state: AgentState) -> str:
46
+ decision = state.get("route_decision", "retrieve")
47
+ if decision == "direct":
48
+ return END
49
+ return "query_rewrite"
50
+
51
+
52
+ def _after_evaluate(state: AgentState) -> str:
53
+ """evaluate 后: needs_more + iter<max -> 回 retrieve; 否则 END."""
54
+ if state.get("needs_more_retrieval", False):
55
+ return "query_rewrite"
56
+ return END
57
+
58
+
59
+ # ========== 编译 ==========
60
+ def _build_graph() -> StateGraph:
61
+ g = StateGraph(AgentState)
62
+
63
+ g.add_node("route", route_node)
64
+ g.add_node("query_rewrite", query_rewrite_node)
65
+ g.add_node("retrieve", retrieve_node)
66
+ g.add_node("rerank", rerank_node)
67
+ g.add_node("evaluate", evaluate_node)
68
+
69
+ g.add_edge(START, "route")
70
+ g.add_conditional_edges(
71
+ "route", _after_route,
72
+ {END: END, "query_rewrite": "query_rewrite"},
73
+ )
74
+ g.add_edge("query_rewrite", "retrieve")
75
+ g.add_edge("retrieve", "rerank")
76
+ g.add_edge("rerank", "evaluate")
77
+ g.add_conditional_edges(
78
+ "evaluate", _after_evaluate,
79
+ {END: END, "query_rewrite": "query_rewrite"},
80
+ )
81
+
82
+ return g
83
+
84
+
85
+ # ========== 编译产物 (带 checkpointer) ==========
86
+ _compiled = None
87
+ _saver_cm = None
88
+ _compiled_loop = None
89
+
90
+
91
+ async def get_compiled_graph():
92
+ """懒加载 + 单例 (按 event loop 隔离)."""
93
+ global _compiled, _saver_cm, _compiled_loop
94
+ import asyncio
95
+ current_loop = asyncio.get_running_loop()
96
+
97
+ if _compiled is not None and _compiled_loop is current_loop:
98
+ return _compiled
99
+
100
+ if _compiled is not None:
101
+ await close_checkpointer()
102
+
103
+ g = _build_graph()
104
+ _saver_cm = AsyncSqliteSaver.from_conn_string(str(settings.langgraph_db_path))
105
+ saver = await _saver_cm.__aenter__()
106
+ _compiled = g.compile(checkpointer=saver)
107
+ _compiled_loop = current_loop
108
+ logger.info(
109
+ "LangGraph compiled with AsyncSqliteSaver at %s",
110
+ settings.langgraph_db_path,
111
+ )
112
+ return _compiled
113
+
114
+
115
+ async def close_checkpointer() -> None:
116
+ global _compiled, _saver_cm, _compiled_loop
117
+ _compiled = None
118
+ _compiled_loop = None
119
+ if _saver_cm is not None:
120
+ try:
121
+ await _saver_cm.__aexit__(None, None, None)
122
+ except Exception as e: # noqa: BLE001
123
+ logger.warning("AsyncSqliteSaver close failed: %s", e)
124
+ _saver_cm = None
app/agents/nodes.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph 节点实现.
2
+
3
+ 每个 node 接收 AgentState, 返回部分更新的 dict.
4
+ 节点间通过 state 自动传递, 不直接耦合.
5
+
6
+ 设计要点:
7
+ - 节点只做一件事, 容易测试
8
+ - LLM 调用统一走工厂, 走 LLM cache
9
+ - 异常不抛, 写入 state['error'], 让图走 fallback 边
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import re
16
+ import time
17
+ from typing import Any
18
+
19
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
20
+
21
+ from app.agents.prompts import (
22
+ ANSWER_PROMPT,
23
+ CRAG_EVAL_PROMPT,
24
+ MULTI_STEP_PROMPT,
25
+ QUERY_REWRITE_PROMPT,
26
+ ROUTE_PROMPT,
27
+ )
28
+ from app.agents.state import AgentState
29
+ from app.agents.tools import TOOL_SCHEMAS, execute_tool
30
+ from app.config import settings
31
+ from app.llm.base import LLMMessage
32
+ from app.llm.factory import get_llm
33
+ from app.services.embedding import get_embedder
34
+ from app.services.llm_cache import CachedAnswer, lookup as cache_lookup, store as cache_store
35
+ from app.services.reranker import get_reranker_service
36
+ from app.services.vector_store import RetrievalHit, hybrid_query
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ========== 工具: 取最近用户消息文本 ==========
42
+ def _last_user_query(state: AgentState) -> str:
43
+ for m in reversed(list(state.get("messages") or [])):
44
+ if hasattr(m, "type") and m.type == "human":
45
+ return m.content if isinstance(m.content, str) else str(m.content)
46
+ if isinstance(m, dict) and m.get("role") == "user":
47
+ return m.get("content", "")
48
+ return ""
49
+
50
+
51
+ def _safe_json(text: str) -> dict | None:
52
+ """尽量从 LLM 输出中抠 JSON. 失败返回 None."""
53
+ if not text:
54
+ return None
55
+ # 尝试直接 parse
56
+ try:
57
+ return json.loads(text)
58
+ except json.JSONDecodeError:
59
+ pass
60
+ # 抠 ```json ... ```
61
+ m = re.search(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", text, re.DOTALL)
62
+ if m:
63
+ try:
64
+ return json.loads(m.group(1))
65
+ except json.JSONDecodeError:
66
+ pass
67
+ # 抠第一个 { ... }
68
+ m = re.search(r"\{.*\}", text, re.DOTALL)
69
+ if m:
70
+ try:
71
+ return json.loads(m.group(0))
72
+ except json.JSONDecodeError:
73
+ pass
74
+ return None
75
+
76
+
77
+ # ========== Node 1: route ==========
78
+ async def route_node(state: AgentState) -> dict[str, Any]:
79
+ """判断 query 走向: direct / retrieve / multi_step."""
80
+ started = time.time()
81
+ query = _last_user_query(state)
82
+ if not query:
83
+ return {"route_decision": "retrieve"}
84
+
85
+ # 启发式快路 (避免每次都 LLM 调用)
86
+ ql = query.strip().lower()
87
+ if len(ql) <= 12 and any(g in ql for g in (
88
+ "你好", "您好", "hi", "hello", "hey", "你是谁", "what's up", "how are you",
89
+ "thanks", "thank you", "谢谢", "再见", "bye",
90
+ )):
91
+ return {"route_decision": "direct", "query_rewritten": query}
92
+
93
+ try:
94
+ llm = get_llm()
95
+ resp = await llm.chat(
96
+ messages=[
97
+ LLMMessage(role="system", content=ROUTE_PROMPT),
98
+ LLMMessage(role="user", content=query),
99
+ ],
100
+ temperature=0.0,
101
+ max_tokens=80,
102
+ )
103
+ data = _safe_json(resp.content or "")
104
+ decision = data.get("route", "retrieve") if data else "retrieve"
105
+ if decision not in ("direct", "retrieve", "multi_step"):
106
+ decision = "retrieve"
107
+ logger.debug("route: %s (%sms) reason=%s", decision, int((time.time() - started) * 1000),
108
+ (data or {}).get("reason", ""))
109
+ return {"route_decision": decision, "query_rewritten": query}
110
+ except Exception as e: # noqa: BLE001
111
+ logger.warning("route_node failed: %s, default to retrieve", e)
112
+ return {"route_decision": "retrieve", "query_rewritten": query}
113
+
114
+
115
+ # ========== Node 2: query_rewrite ==========
116
+ async def query_rewrite_node(state: AgentState) -> dict[str, Any]:
117
+ """对 query 改写, 提升召回. multi_step 时拆子问题."""
118
+ started = time.time()
119
+ query = state.get("query_rewritten") or _last_user_query(state)
120
+ if state.get("route_decision") == "direct":
121
+ return {"query_rewritten": query, "plan": []}
122
+
123
+ try:
124
+ llm = get_llm()
125
+ if state.get("route_decision") == "multi_step":
126
+ resp = await llm.chat(
127
+ messages=[
128
+ LLMMessage(role="system", content=MULTI_STEP_PROMPT),
129
+ LLMMessage(role="user", content=query),
130
+ ],
131
+ temperature=0.2,
132
+ max_tokens=200,
133
+ )
134
+ data = _safe_json(resp.content or "")
135
+ steps = (data or {}).get("steps", [query])
136
+ if not isinstance(steps, list) or not steps:
137
+ steps = [query]
138
+ return {"query_rewritten": steps[0], "plan": steps}
139
+
140
+ resp = await llm.chat(
141
+ messages=[
142
+ LLMMessage(role="system", content=QUERY_REWRITE_PROMPT.format(query=query)),
143
+ ],
144
+ temperature=0.3,
145
+ max_tokens=200,
146
+ )
147
+ data = _safe_json(resp.content or "")
148
+ rewrites = (data or {}).get("rewrites", [])
149
+ if not isinstance(rewrites, list) or not rewrites:
150
+ rewrites = [query]
151
+ # 拼接为最终检索串
152
+ merged = " | ".join([query] + list(rewrites[:2]))
153
+ logger.debug("query_rewrite: %d variants, %dms", len(rewrites), int((time.time() - started) * 1000))
154
+ return {"query_rewritten": merged, "plan": []}
155
+ except Exception as e: # noqa: BLE001
156
+ logger.warning("query_rewrite failed: %s", e)
157
+ return {"query_rewritten": query, "plan": []}
158
+
159
+
160
+ # ========== Node 3: retrieve ==========
161
+ async def retrieve_node(state: AgentState) -> dict[str, Any]:
162
+ """混合检索 top-K."""
163
+ started = time.time()
164
+ query = state.get("query_rewritten") or _last_user_query(state)
165
+ if not query:
166
+ return {"retrieved": [], "retrieved_doc_ids": []}
167
+
168
+ embedder = get_embedder()
169
+ out = await embedder.encode_query(query)
170
+ dense = out["dense"]
171
+ # dense shape: (1, 1024) or (1024,) depending on encode return
172
+ if dense.ndim == 2:
173
+ dense_vec = dense[0]
174
+ else:
175
+ dense_vec = dense
176
+ sparse = out.get("sparse", [{}])[0] if out.get("sparse") else None
177
+ colbert = out.get("colbert", [None])[0] if out.get("colbert") else None
178
+
179
+ # multi_step: 每步独立检索再合并
180
+ plan = state.get("plan") or []
181
+ all_hits: list[RetrievalHit] = []
182
+ seen: set[str] = set()
183
+ queries_to_run = plan if plan else [query]
184
+ for q in queries_to_run:
185
+ if q == query and all_hits:
186
+ continue # 主 query 已跑过
187
+ if q != query:
188
+ sub_out = await embedder.encode_query(q)
189
+ sub_dense = sub_out["dense"][0] if sub_out["dense"].ndim == 2 else sub_out["dense"]
190
+ sub_sparse = sub_out.get("sparse", [{}])[0] if sub_out.get("sparse") else None
191
+ sub_colbert = sub_out.get("colbert", [None])[0] if sub_out.get("colbert") else None
192
+ hits = hybrid_query(
193
+ query_emb=sub_dense,
194
+ query_sparse=sub_sparse,
195
+ query_colbert_emb=sub_colbert,
196
+ k=settings.rerank_top_n * 4,
197
+ )
198
+ else:
199
+ hits = hybrid_query(
200
+ query_emb=dense_vec,
201
+ query_sparse=sparse,
202
+ query_colbert_emb=colbert,
203
+ k=settings.rerank_top_n * 4,
204
+ )
205
+ for h in hits:
206
+ if h.chunk_id not in seen:
207
+ seen.add(h.chunk_id)
208
+ all_hits.append(h)
209
+
210
+ # 按 score 截前 N
211
+ all_hits.sort(key=lambda h: h.score, reverse=True)
212
+ all_hits = all_hits[: settings.rerank_top_n * 4]
213
+
214
+ doc_ids = list({h.doc_id for h in all_hits if h.doc_id})
215
+ logger.debug("retrieve: %d hits, %d docs, %dms",
216
+ len(all_hits), len(doc_ids), int((time.time() - started) * 1000))
217
+ return {"retrieved": all_hits, "retrieved_doc_ids": doc_ids}
218
+
219
+
220
+ # ========== Node 4: rerank ==========
221
+ async def rerank_node(state: AgentState) -> dict[str, Any]:
222
+ """BGE-reranker 精排 top-N + 产出引用."""
223
+ started = time.time()
224
+ hits = state.get("retrieved") or []
225
+ query = state.get("query_rewritten") or _last_user_query(state)
226
+ if not hits:
227
+ return {"reranked": [], "citations": [], "relevance_score": 0.0, "relevance_verdict": "irrelevant"}
228
+
229
+ reranker = get_reranker_service()
230
+ reranked = await reranker.rerank(query, hits, top_n=settings.rerank_top_n)
231
+
232
+ # 构造引用 (前 5 个, 按 rerank 分数)
233
+ citations: list[dict[str, Any]] = []
234
+ for i, h in enumerate(reranked):
235
+ doc = _doc_meta_brief(h.doc_id)
236
+ citations.append({
237
+ "doc_id": h.doc_id,
238
+ "filename": doc.get("filename", "未知"),
239
+ "page": h.page_no,
240
+ "heading": h.heading,
241
+ "snippet": (h.text or "")[:240],
242
+ "score": round(h.rerank_score, 4),
243
+ "rank": i + 1,
244
+ })
245
+
246
+ top_score = reranked[0].rerank_score if reranked else 0.0
247
+ if top_score >= settings.crag_relevance_threshold:
248
+ verdict = "relevant"
249
+ elif top_score < 0.3:
250
+ verdict = "irrelevant"
251
+ else:
252
+ verdict = "ambiguous"
253
+
254
+ logger.debug("rerank: top=%.3f verdict=%s %dms", top_score, verdict, int((time.time() - started) * 1000))
255
+ return {
256
+ "reranked": reranked,
257
+ "citations": citations,
258
+ "relevance_score": top_score,
259
+ "relevance_verdict": verdict,
260
+ }
261
+
262
+
263
+ def _doc_meta_brief(doc_id: str) -> dict[str, Any]:
264
+ try:
265
+ from app.models import db
266
+ d = db.doc_get(doc_id)
267
+ return d or {}
268
+ except Exception: # noqa: BLE001
269
+ return {}
270
+
271
+
272
+ # ========== Node 5: answer (流式 LLM 调用) ==========
273
+ async def answer_node_stream(
274
+ state: AgentState,
275
+ on_token: Any = None, # async callable(content: str) -> None
276
+ on_citation: Any = None,
277
+ on_thinking: Any = None,
278
+ ) -> dict[str, Any]:
279
+ """生成最终答案. 通过 on_token 回调逐 token 推送.
280
+
281
+ 流程:
282
+ 1. 拼装 context (从 reranked hits)
283
+ 2. 查 LLM 缓存
284
+ 3. 命中: 回放 tokens
285
+ 4. 未命中: 调 LLM 流式 + 缓存结果
286
+ """
287
+ query = state.get("query_rewritten") or _last_user_query(state)
288
+ reranked = state.get("reranked") or []
289
+ locale = state.get("locale", "zh")
290
+
291
+ # 拼 context
292
+ if reranked:
293
+ ctx_lines: list[str] = []
294
+ for i, h in enumerate(reranked, 1):
295
+ tag = f"[{i}]"
296
+ prefix_bits = []
297
+ if h.heading:
298
+ prefix_bits.append(f"章节: {h.heading}")
299
+ if h.page_no:
300
+ prefix_bits.append(f"页码: {h.page_no}")
301
+ if h.context_prefix:
302
+ prefix_bits.append(f"上下文: {h.context_prefix}")
303
+ meta = " | ".join(prefix_bits)
304
+ ctx_lines.append(f"{tag} {('('+meta+')') if meta else ''}\n{h.text}")
305
+ context = "\n\n".join(ctx_lines)
306
+ else:
307
+ if on_thinking:
308
+ await on_thinking("未在知识库中找到相关文档, 直接基于通用知识回答。")
309
+ context = "(无相关文档)"
310
+
311
+ prompt = ANSWER_PROMPT.format(context=context, query=query, LOCALE=locale)
312
+ system_msg = "你是私人智能客服, 回答需基于 context 引用, 用对应 locale 回答。"
313
+
314
+ # 缓存 key
315
+ top_doc_ids = [c["doc_id"] for c in state.get("citations", [])]
316
+ cached = cache_lookup(query, top_doc_ids, 0.7)
317
+
318
+ started = time.time()
319
+ if cached is not None:
320
+ # 回放
321
+ if on_thinking:
322
+ await on_thinking("(cache hit, 跳过 LLM)")
323
+ for tok in cached.tokens:
324
+ if on_token:
325
+ await on_token(tok)
326
+ return {
327
+ "final_answer": cached.content,
328
+ "messages": [AIMessage(content=cached.content)],
329
+ "elapsed_ms": int((time.time() - started) * 1000),
330
+ }
331
+
332
+ # 实际 LLM 流式
333
+ llm = get_llm()
334
+ collected: list[str] = []
335
+ full_text = ""
336
+ try:
337
+ async for chunk in llm.stream_chat(
338
+ messages=[
339
+ LLMMessage(role="system", content=system_msg),
340
+ LLMMessage(role="user", content=prompt),
341
+ ],
342
+ temperature=0.7,
343
+ max_tokens=1200,
344
+ ):
345
+ if chunk.content:
346
+ collected.append(chunk.content)
347
+ full_text += chunk.content
348
+ if on_token:
349
+ await on_token(chunk.content)
350
+ except Exception as e: # noqa: BLE001
351
+ logger.exception("answer_node_stream failed")
352
+ err_msg = f"抱歉, 生成答案时出错: {e}"
353
+ if on_token:
354
+ await on_token(err_msg)
355
+ return {
356
+ "final_answer": err_msg,
357
+ "messages": [AIMessage(content=err_msg)],
358
+ "error": str(e),
359
+ }
360
+
361
+ # 缓存结果
362
+ cache_store(query, top_doc_ids, 0.7, CachedAnswer(
363
+ content=full_text,
364
+ citations=state.get("citations", []),
365
+ tool_calls=[],
366
+ tokens=collected,
367
+ ))
368
+
369
+ # 推引用 (在 answer 末尾)
370
+ if on_citation and state.get("citations"):
371
+ for c in state["citations"]:
372
+ await on_citation(c)
373
+
374
+ return {
375
+ "final_answer": full_text,
376
+ "messages": [AIMessage(content=full_text)],
377
+ "elapsed_ms": int((time.time() - started) * 1000),
378
+ }
379
+
380
+
381
+ # ========== Node 6: evaluate (CRAG) ==========
382
+ async def evaluate_node(state: AgentState) -> dict[str, Any]:
383
+ """CRAG 自校正判定.
384
+
385
+ 阶段 1 (必走, 极快): 用 rerank top-1 score 做硬阈值判断
386
+ 阶段 2 (仅模糊区间): LLM judge 二次判定, 决定是否回 retrieve
387
+ """
388
+ iteration = state.get("iteration", 0) + 1
389
+ score = state.get("relevance_score", 0.0)
390
+ verdict = state.get("relevance_verdict", "ambiguous")
391
+ max_iter = state.get("max_iterations", settings.crag_max_iterations)
392
+
393
+ # 阶段 1: rerank 分数硬阈值
394
+ if verdict == "relevant":
395
+ return {
396
+ "iteration": iteration,
397
+ "needs_more_retrieval": False,
398
+ "crag_finished": True,
399
+ }
400
+ if verdict == "irrelevant":
401
+ # 直接告知用户, 不再循环
402
+ return {
403
+ "iteration": iteration,
404
+ "needs_more_retrieval": False,
405
+ "crag_finished": True,
406
+ }
407
+
408
+ # 阶段 2: 模糊区间, 调 LLM judge (用便宜的 judge model)
409
+ if iteration >= max_iter:
410
+ # 超过上限, 收口
411
+ return {
412
+ "iteration": iteration,
413
+ "needs_more_retrieval": False,
414
+ "crag_finished": True,
415
+ }
416
+
417
+ try:
418
+ llm = get_llm() # 用同 model (个人项目成本可接受)
419
+ query = state.get("query_rewritten") or _last_user_query(state)
420
+ reranked = state.get("reranked") or []
421
+ docs_summary = "\n".join(
422
+ f"[{i+1}] {h.heading or '无标题'}: {(h.text or '')[:120]}"
423
+ for i, h in enumerate(reranked[:5])
424
+ )
425
+ resp = await llm.chat(
426
+ messages=[
427
+ LLMMessage(role="system", content=CRAG_EVAL_PROMPT.format(
428
+ query=query, n=len(reranked[:5]), docs_summary=docs_summary,
429
+ )),
430
+ ],
431
+ temperature=0.0,
432
+ max_tokens=120,
433
+ )
434
+ data = _safe_json(resp.content or "")
435
+ v = (data or {}).get("verdict", "sufficient")
436
+ needs = v == "insufficient" and iteration < max_iter
437
+ return {
438
+ "iteration": iteration,
439
+ "needs_more_retrieval": needs,
440
+ "crag_finished": not needs,
441
+ }
442
+ except Exception as e: # noqa: BLE001
443
+ logger.warning("evaluate_node LLM judge failed: %s", e)
444
+ return {
445
+ "iteration": iteration,
446
+ "needs_more_retrieval": False,
447
+ "crag_finished": True,
448
+ }
449
+
450
+
451
+ # ========== Node 7: tool_executor ==========
452
+ async def tool_executor_node(state: AgentState) -> dict[str, Any]:
453
+ """执行 LLM 在 answer 阶段请求的工具调用."""
454
+ msgs = list(state.get("messages") or [])
455
+ last_ai = next((m for m in reversed(msgs)
456
+ if hasattr(m, "type") and m.type == "ai"), None)
457
+ tool_calls = getattr(last_ai, "tool_calls", None) or []
458
+
459
+ if not tool_calls:
460
+ return {"tool_results": []}
461
+
462
+ results: list[dict[str, Any]] = []
463
+ for tc in tool_calls:
464
+ name = tc.get("name", "")
465
+ args = tc.get("args", {})
466
+ if isinstance(args, str):
467
+ try:
468
+ args = json.loads(args)
469
+ except json.JSONDecodeError:
470
+ args = {}
471
+ try:
472
+ out = await execute_tool(name, args)
473
+ except Exception as e: # noqa: BLE001
474
+ out = f"工具执行异常: {e}"
475
+ results.append({"name": name, "args": args, "output": out})
476
+
477
+ return {
478
+ "tool_results": results,
479
+ "tool_calls": [{"name": r["name"], "args": r["args"]} for r in results],
480
+ }
app/agents/prompts.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """中英系统提示词. 集中管理, 便于 A/B 调优."""
2
+
3
+ # 路由判断提示
4
+ ROUTE_PROMPT = """你是一名 query 路由器. 根据用户问题, 决定走哪条路:
5
+
6
+ - "direct": 闲聊/通用知识/不需检索 (如"你好", "今天天气")
7
+ - "retrieve": 需要知识库检索 (绝大多数问题)
8
+ - "multi_step": 复杂多步, 需拆解为多个子问题
9
+
10
+ 仅输出一个 JSON, 不要解释: {"route": "direct"|"retrieve"|"multi_step", "reason": "<20字内>"}
11
+ """
12
+
13
+
14
+ # 查询改写提示 (用于提高检索召回)
15
+ QUERY_REWRITE_PROMPT = """你是查询改写助手. 给定用户的原始问题, 生成 1-3 个改写版本, 用于提升向量检索召回率.
16
+
17
+ 策略:
18
+ - 加入同义词 / 别名
19
+ - 改写为更具体的陈述句
20
+ - 如果是缩写, 展开全称
21
+
22
+ 仅输出 JSON: {{"rewrites": ["...", "..."]}}
23
+
24
+ 原始问题: {query}
25
+ """
26
+
27
+
28
+ # 答案生成主提示 (含引用)
29
+ ANSWER_PROMPT = """你是用户的私人智能客服, 基于以下检索到的文档片段回答问题.
30
+
31
+ 要求:
32
+ - 严格基于 <context> 标签内的内容回答, 不可编造
33
+ - 引用时用 [1] [2] 这样的角标, 末尾"参考:"列出对应来源
34
+ - 如果 <context> 不包含答案, 直接说"未在知识库中找到相关信息"
35
+ - 用 {LOCALE} 回答 (zh=中文, en=English)
36
+ - 简洁, 不超过 500 字, 除非问题本身要求长文
37
+
38
+ <context>
39
+ {context}
40
+ </context>
41
+
42
+ 用户问题: {query}
43
+ """
44
+
45
+
46
+ # CRAG 评估提示 (LLM judge, 仅在 rerank 分数 0.3-0.7 模糊区间触发)
47
+ CRAG_EVAL_PROMPT = """你是一名 RAG 质量评估员. 给定用户问题 + 检索到的文档摘要, 判断:
48
+
49
+ - "sufficient": 文档充分回答了问题
50
+ - "insufficient": 文档不够, 需要重新检索 (用其他关键词)
51
+ - "irrelevant": 文档与问题无关, 直接告诉用户找不到
52
+
53
+ 仅输出 JSON: {{"verdict": "sufficient"|"insufficient"|"irrelevant", "reason": "<30字内>"}}
54
+
55
+ 问题: {query}
56
+
57
+ 文档摘要 (top-{n}):
58
+ {docs_summary}
59
+ """
60
+
61
+
62
+ # 多步拆解
63
+ MULTI_STEP_PROMPT = """你是任务规划员. 把用户复杂问题拆解为 2-4 个可独立检索的子问题.
64
+
65
+ 仅输出 JSON: {{"steps": ["子问题1", "子问题2", ...]}}
66
+
67
+ 用户问题: {query}
68
+ """
app/agents/state.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AgentState - LangGraph 跨节点状态.
2
+
3
+ 使用 TypedDict 而不是 Pydantic, 因为 LangGraph 内置 add_messages reducer
4
+ 需要 Annotated[Sequence[BaseMessage], add_messages] 这样的类型签名.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated, Any, Literal, Sequence, TypedDict
9
+
10
+ from langchain_core.documents import Document
11
+ from langchain_core.messages import BaseMessage
12
+ from langgraph.graph.message import add_messages
13
+
14
+ from app.services.vector_store import RetrievalHit
15
+
16
+
17
+ RouteDecision = Literal["direct", "retrieve", "multi_step"]
18
+
19
+
20
+ class AgentState(TypedDict, total=False):
21
+ """LangGraph 跨节点状态. total=False 让所有字段可选, 各 node 自行填充."""
22
+
23
+ # ===== 基础 =====
24
+ messages: Annotated[Sequence[BaseMessage], add_messages]
25
+ session_id: str
26
+ user_id: str
27
+ locale: str # "zh" | "en"
28
+
29
+ # ===== 路由 =====
30
+ route_decision: RouteDecision
31
+ query_rewritten: str # 改写后的查询
32
+ plan: list[str] # 多步拆解的子任务
33
+
34
+ # ===== 检索 =====
35
+ retrieved: list[RetrievalHit]
36
+ reranked: list[RetrievalHit]
37
+ retrieved_doc_ids: list[str] # 命中 doc 列表 (用于 SSE retrieval 事件)
38
+
39
+ # ===== 引用 =====
40
+ citations: list[dict[str, Any]] # [{doc_id, page, snippet, score, source}]
41
+
42
+ # ===== 工具 =====
43
+ tool_calls: list[dict[str, Any]] # 工具调用历史
44
+ tool_results: list[dict[str, Any]] # 工具结果摘要
45
+
46
+ # ===== CRAG 自校正 =====
47
+ iteration: int
48
+ max_iterations: int
49
+ relevance_score: float # top-1 rerank score, 0-1
50
+ relevance_verdict: Literal["relevant", "ambiguous", "irrelevant"]
51
+ needs_more_retrieval: bool
52
+ crag_finished: bool
53
+
54
+ # ===== 元数据 =====
55
+ elapsed_ms: int
56
+ final_answer: str
57
+ error: str | None
58
+
59
+
60
+ # ========== State helpers ==========
61
+ def empty_state_for(session_id: str, user_id: str = "default", locale: str = "zh") -> AgentState:
62
+ from app.config import settings
63
+
64
+ return AgentState(
65
+ messages=[],
66
+ session_id=session_id,
67
+ user_id=user_id,
68
+ locale=locale,
69
+ route_decision="retrieve",
70
+ query_rewritten="",
71
+ plan=[],
72
+ retrieved=[],
73
+ reranked=[],
74
+ retrieved_doc_ids=[],
75
+ citations=[],
76
+ tool_calls=[],
77
+ tool_results=[],
78
+ iteration=0,
79
+ max_iterations=settings.crag_max_iterations,
80
+ relevance_score=0.0,
81
+ relevance_verdict="ambiguous",
82
+ needs_more_retrieval=False,
83
+ crag_finished=False,
84
+ elapsed_ms=0,
85
+ final_answer="",
86
+ error=None,
87
+ )
88
+
89
+
90
+ def messages_to_lc(messages: list[dict[str, Any]] | list[BaseMessage]) -> list[BaseMessage]:
91
+ """业务侧 dict 消息列表 (来自 SQLite) 转为 LangChain BaseMessage 列表."""
92
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
93
+
94
+ out: list[BaseMessage] = []
95
+ for m in messages:
96
+ if hasattr(m, "type"):
97
+ out.append(m) # 已经是 BaseMessage
98
+ continue
99
+ role = m.get("role", "user")
100
+ content = m.get("content", "")
101
+ if role == "system":
102
+ out.append(SystemMessage(content=content))
103
+ elif role == "user":
104
+ out.append(HumanMessage(content=content))
105
+ elif role == "assistant":
106
+ extra = {}
107
+ if m.get("tool_calls"):
108
+ extra["tool_calls"] = m["tool_calls"]
109
+ out.append(AIMessage(content=content, **extra))
110
+ elif role == "tool":
111
+ out.append(ToolMessage(
112
+ content=content,
113
+ tool_call_id=m.get("tool_call_id", ""),
114
+ ))
115
+ return out
app/agents/tools.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic AI 工具集 (供 LangGraph tool_executor 节点调用).
2
+
3
+ 工具列表:
4
+ - summarize_document: 总结指定文档
5
+ - compare_documents: 对比 2 个文档的某方面
6
+ - calculate: 安全数学计算 (用 AST 而不是 eval)
7
+ - list_documents: 列出已上传的文档
8
+ - get_current_time: 当前时间 (调试用)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import datetime as _dt
14
+ import logging
15
+ import operator
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+ from app.models import db
21
+ from app.services.embedding import get_embedder
22
+ from app.services.vector_store import hybrid_query
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ========== Tool schemas (OpenAI function calling 格式) ==========
28
+ TOOL_SCHEMAS: list[dict[str, Any]] = [
29
+ {
30
+ "type": "function",
31
+ "function": {
32
+ "name": "summarize_document",
33
+ "description": "根据 doc_id 总结指定文档的主要内容 (取前若干 chunks 拼接 + LLM 摘要).",
34
+ "parameters": {
35
+ "type": "object",
36
+ "properties": {
37
+ "doc_id": {
38
+ "type": "string",
39
+ "description": "要总结的文档 ID",
40
+ },
41
+ "max_chunks": {
42
+ "type": "integer",
43
+ "description": "最多取前多少个 chunk (默认 30)",
44
+ "default": 30,
45
+ },
46
+ },
47
+ "required": ["doc_id"],
48
+ },
49
+ },
50
+ },
51
+ {
52
+ "type": "function",
53
+ "function": {
54
+ "name": "compare_documents",
55
+ "description": "对比两个文档在某方面的异同. 返回结构化对比结果.",
56
+ "parameters": {
57
+ "type": "object",
58
+ "properties": {
59
+ "doc_id_a": {"type": "string"},
60
+ "doc_id_b": {"type": "string"},
61
+ "aspect": {
62
+ "type": "string",
63
+ "description": "对比的方面, 如 '技术选型', '营收', '风险' 等",
64
+ },
65
+ },
66
+ "required": ["doc_id_a", "doc_id_b", "aspect"],
67
+ },
68
+ },
69
+ },
70
+ {
71
+ "type": "function",
72
+ "function": {
73
+ "name": "calculate",
74
+ "description": "安全地计算数学表达式 (加减乘除/括号/比较), 用 AST 解析避免注入.",
75
+ "parameters": {
76
+ "type": "object",
77
+ "properties": {
78
+ "expression": {
79
+ "type": "string",
80
+ "description": "数学表达式, 如 '(2+3)*4'",
81
+ },
82
+ },
83
+ "required": ["expression"],
84
+ },
85
+ },
86
+ },
87
+ {
88
+ "type": "function",
89
+ "function": {
90
+ "name": "list_documents",
91
+ "description": "列出已上传的所有文档 (ID + 文件名 + 状态).",
92
+ "parameters": {
93
+ "type": "object",
94
+ "properties": {},
95
+ },
96
+ },
97
+ },
98
+ {
99
+ "type": "function",
100
+ "function": {
101
+ "name": "get_current_time",
102
+ "description": "获取服务器当前时间 (UTC / 本地时区).",
103
+ "parameters": {
104
+ "type": "object",
105
+ "properties": {
106
+ "tz": {
107
+ "type": "string",
108
+ "description": "时区, 如 'UTC', 'Asia/Shanghai'. 默认 UTC",
109
+ "default": "UTC",
110
+ },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ ]
116
+
117
+
118
+ # ========== 工具实现 ==========
119
+ async def tool_summarize_document(args: dict[str, Any]) -> str:
120
+ doc_id = args.get("doc_id")
121
+ if not doc_id:
122
+ return "❌ 缺少 doc_id"
123
+ doc = db.doc_get(doc_id)
124
+ if doc is None:
125
+ return f"❌ 文档 {doc_id} 不存在"
126
+
127
+ chunks = db.chunk_get_by_doc(doc_id, limit=int(args.get("max_chunks", 30)))
128
+ if not chunks:
129
+ return f"❌ 文档 {doc_id} 暂无内容"
130
+
131
+ text = "\n\n".join(c["text"] for c in chunks)
132
+ # 简单截断前 6000 字给 LLM 摘要
133
+ snippet = text[:6000]
134
+
135
+ from app.llm.factory import get_llm
136
+ llm = get_llm()
137
+ resp = await llm.chat(
138
+ messages=[
139
+ {"role": "system", "content": "你是文档摘要助手. 用中文输出简洁摘要, 列出主要章节和关键事实."},
140
+ {"role": "user", "content": f"请摘要以下内容 (来自 {doc.get('filename', doc_id)}):\n\n{snippet}"},
141
+ ],
142
+ temperature=0.3,
143
+ max_tokens=600,
144
+ )
145
+ return resp.content or "(空)"
146
+
147
+
148
+ async def tool_compare_documents(args: dict[str, Any]) -> str:
149
+ doc_a_id = args.get("doc_id_a")
150
+ doc_b_id = args.get("doc_id_b")
151
+ aspect = args.get("aspect", "整体")
152
+ if not (doc_a_id and doc_b_id):
153
+ return "��� 缺少 doc_id_a 或 doc_id_b"
154
+
155
+ chunks_a = db.chunk_get_by_doc(doc_a_id, limit=20)
156
+ chunks_b = db.chunk_get_by_doc(doc_b_id, limit=20)
157
+ if not chunks_a or not chunks_b:
158
+ return f"❌ 文档 {doc_a_id if not chunks_a else doc_b_id} 无内容"
159
+
160
+ text_a = "\n".join(c["text"] for c in chunks_a)[:3000]
161
+ text_b = "\n".join(c["text"] for c in chunks_b)[:3000]
162
+
163
+ from app.llm.factory import get_llm
164
+ llm = get_llm()
165
+ resp = await llm.chat(
166
+ messages=[
167
+ {"role": "system", "content": "你是文档对比助手. 输出 Markdown 表格, 列出差異与相同点."},
168
+ {"role": "user", "content": (
169
+ f"对比以下两份文档的「{aspect}」方面.\n\n"
170
+ f"## 文档 A\n{text_a}\n\n## 文档 B\n{text_b}\n\n"
171
+ f"输出: 1) 关键差异 (表格) 2) 相同点 3) 建议"
172
+ )},
173
+ ],
174
+ temperature=0.3,
175
+ max_tokens=800,
176
+ )
177
+ return resp.content or "(空)"
178
+
179
+
180
+ _BIN_OPS = {
181
+ ast.Add: operator.add, ast.Sub: operator.sub,
182
+ ast.Mult: operator.mul, ast.Div: operator.truediv,
183
+ ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
184
+ ast.Pow: operator.pow,
185
+ }
186
+ _CMP_OPS = {
187
+ ast.Eq: operator.eq, ast.NotEq: operator.ne,
188
+ ast.Lt: operator.lt, ast.LtE: operator.le,
189
+ ast.Gt: operator.gt, ast.GtE: operator.ge,
190
+ }
191
+ _UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
192
+
193
+
194
+ def _safe_eval(node: ast.AST) -> float | bool:
195
+ if isinstance(node, ast.Expression):
196
+ return _safe_eval(node.body)
197
+ if isinstance(node, ast.Constant):
198
+ if isinstance(node.value, (int, float)):
199
+ return node.value
200
+ raise ValueError(f"不支持的字面量: {node.value!r}")
201
+ if isinstance(node, ast.BinOp):
202
+ op = _BIN_OPS.get(type(node.op))
203
+ if op is None:
204
+ raise ValueError(f"不支持的二元运算: {type(node.op).__name__}")
205
+ return op(_safe_eval(node.left), _safe_eval(node.right))
206
+ if isinstance(node, ast.UnaryOp):
207
+ op = _UNARY_OPS.get(type(node.op))
208
+ if op is None:
209
+ raise ValueError(f"不支持的一元运算: {type(node.op).__name__}")
210
+ return op(_safe_eval(node.operand))
211
+ if isinstance(node, ast.Compare):
212
+ left = _safe_eval(node.left)
213
+ for cmp_op, right_node in zip(node.ops, node.comparators):
214
+ op = _CMP_OPS.get(type(cmp_op))
215
+ if op is None:
216
+ raise ValueError(f"不支持的比较: {type(cmp_op).__name__}")
217
+ left = op(left, _safe_eval(right_node))
218
+ if not left:
219
+ return False
220
+ return True
221
+ raise ValueError(f"不支持的语法: {ast.dump(node)}")
222
+
223
+
224
+ async def tool_calculate(args: dict[str, Any]) -> str:
225
+ expr = args.get("expression", "")
226
+ if not expr:
227
+ return "❌ 缺少 expression"
228
+ try:
229
+ tree = ast.parse(expr, mode="eval")
230
+ result = _safe_eval(tree)
231
+ return f"{expr} = {result}"
232
+ except Exception as e: # noqa: BLE001
233
+ return f"❌ 计算失败: {e}"
234
+
235
+
236
+ async def tool_list_documents(args: dict[str, Any]) -> str:
237
+ docs = db.doc_list(limit=200)
238
+ if not docs:
239
+ return "暂无已上传文档"
240
+ lines = [f"- {d['id'][:8]}… {d['filename']} (chunks={d.get('chunk_count', 0)}, status={d.get('status', '?')})" for d in docs]
241
+ return "已上传文档:\n" + "\n".join(lines)
242
+
243
+
244
+ async def tool_get_current_time(args: dict[str, Any]) -> str:
245
+ tz = args.get("tz", "UTC")
246
+ try:
247
+ from zoneinfo import ZoneInfo
248
+ now = _dt.datetime.now(ZoneInfo(tz))
249
+ except Exception: # noqa: BLE001
250
+ now = _dt.datetime.utcnow()
251
+ return now.strftime("%Y-%m-%d %H:%M:%S %Z")
252
+
253
+
254
+ # ========== 工具路由表 ==========
255
+ TOOL_FUNCS = {
256
+ "summarize_document": tool_summarize_document,
257
+ "compare_documents": tool_compare_documents,
258
+ "calculate": tool_calculate,
259
+ "list_documents": tool_list_documents,
260
+ "get_current_time": tool_get_current_time,
261
+ }
262
+
263
+
264
+ async def execute_tool(name: str, args: dict[str, Any]) -> str:
265
+ """统一工具执行入口."""
266
+ fn = TOOL_FUNCS.get(name)
267
+ if fn is None:
268
+ return f"❌ 未知工具: {name}"
269
+ try:
270
+ return await fn(args)
271
+ except Exception as e: # noqa: BLE001
272
+ logger.exception("Tool %s failed", name)
273
+ return f"❌ 工具执行失败: {e}"
274
+
275
+
276
+ __all__ = ["TOOL_SCHEMAS", "TOOL_FUNCS", "execute_tool"]
app/api/__init__.py ADDED
File without changes
app/api/chat.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """阶段 3 chat 端点: 接 LangGraph agent, 完整 AG-UI 9 类事件.
2
+
3
+ 事件流顺序 (典型):
4
+ thinking {content: "用户在询问财务数据..."}
5
+ agent_step {node: "route", status: "running"}
6
+ agent_step {node: "route", status: "done"}
7
+ retrieval {doc_ids: [...], scores: [...]}
8
+ citation {doc_id, page, snippet, score}
9
+ token {content: "..."} (多次, 流式)
10
+ agent_step {node: "evaluate", status: "done"}
11
+ done {usage, total_ms}
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import time
19
+ import uuid
20
+ from collections.abc import AsyncIterator
21
+
22
+ from fastapi import APIRouter, HTTPException
23
+ from fastapi.responses import StreamingResponse
24
+ from langchain_core.messages import HumanMessage
25
+
26
+ from app.agents.graph import get_compiled_graph
27
+ from app.agents.nodes import answer_node_stream
28
+ from app.agents.state import empty_state_for
29
+ from app.config import settings
30
+ from app.llm.base import LLMMessage
31
+ from app.llm.factory import get_llm
32
+ from app.models import db
33
+ from app.models.schemas import ChatRequest, ChatResponse
34
+ from app.streaming.events import (
35
+ sse_done,
36
+ sse_error,
37
+ sse_format,
38
+ EV_AGENT_STEP,
39
+ EV_CITATION,
40
+ EV_DONE,
41
+ EV_ERROR,
42
+ EV_PROGRESS,
43
+ EV_RETRIEVAL,
44
+ EV_THINKING,
45
+ EV_TOKEN,
46
+ EV_TOOL_CALL,
47
+ )
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ router = APIRouter(prefix="/chat", tags=["chat"])
52
+
53
+
54
+ # ========== 兜底: 阶段 1 简单直答 (route=direct / 无 agent) ==========
55
+ _FALLBACK_SYSTEM = (
56
+ "你是一个有帮助的中文 AI 助手。"
57
+ "回答应简洁、准确、礼貌。如不知道答案请直接说明。"
58
+ )
59
+
60
+
61
+ @router.post("", response_model=ChatResponse)
62
+ async def chat_simple(req: ChatRequest) -> ChatResponse:
63
+ """非流式 chat: 跑完整 agent 但收集完整结果再返."""
64
+ started = time.time()
65
+ try:
66
+ final_state, events_log = await _run_agent(req)
67
+ except Exception as e: # noqa: BLE001
68
+ logger.exception("chat failed")
69
+ raise HTTPException(status_code=502, detail=str(e)) from e
70
+
71
+ answer = final_state.get("final_answer") or "(无回答)"
72
+ return ChatResponse(
73
+ session_id=req.session_id,
74
+ message_id=uuid.uuid4().hex,
75
+ content=answer,
76
+ citations=final_state.get("citations", []),
77
+ agent_steps=events_log,
78
+ usage={}, # 阶段 3 暂不聚合
79
+ total_ms=int((time.time() - started) * 1000),
80
+ )
81
+
82
+
83
+ @router.post("/stream")
84
+ async def chat_stream(req: ChatRequest) -> StreamingResponse:
85
+ """SSE 流式 chat: 完整 AG-UI 事件."""
86
+ return StreamingResponse(
87
+ _agent_event_stream(req),
88
+ media_type="text/event-stream",
89
+ headers={
90
+ "Cache-Control": "no-cache, no-transform",
91
+ "X-Accel-Buffering": "no", # 关闭 nginx 缓冲
92
+ },
93
+ )
94
+
95
+
96
+ # ========== 核心: 跑 agent + 产生 SSE 事件 ==========
97
+ async def _agent_event_stream(req: ChatRequest) -> AsyncIterator[str]:
98
+ """驱动 agent + 把过程映射为 AG-UI 事件."""
99
+ queue: asyncio.Queue[tuple[str, dict] | None] = asyncio.Queue(maxsize=256)
100
+
101
+ async def emit(event: str, data: dict) -> None:
102
+ await queue.put((event, data))
103
+
104
+ async def runner() -> None:
105
+ try:
106
+ await _run_agent(req, emit=emit)
107
+ except Exception as e: # noqa: BLE001
108
+ logger.exception("agent runner failed")
109
+ await emit(EV_ERROR, {"code": "agent_failed", "message": str(e), "retryable": True})
110
+ finally:
111
+ await queue.put(None)
112
+
113
+ task = asyncio.create_task(runner())
114
+
115
+ # 启动心跳
116
+ started = time.time()
117
+ try:
118
+ while True:
119
+ try:
120
+ item = await asyncio.wait_for(queue.get(), timeout=15.0)
121
+ except asyncio.TimeoutError:
122
+ # 心跳
123
+ yield ": keepalive\n\n"
124
+ continue
125
+ if item is None:
126
+ break
127
+ event, data = item
128
+ yield sse_format(event, data)
129
+ finally:
130
+ if not task.done():
131
+ task.cancel()
132
+
133
+ # done
134
+ yield sse_done(total_ms=int((time.time() - started) * 1000))
135
+
136
+
137
+ async def _run_agent(
138
+ req: ChatRequest,
139
+ emit=None, # async callable | None
140
+ ) -> tuple[dict, list[dict]]:
141
+ """执行 agent. emit 可选, 用于 SSE 推送中间事件.
142
+
143
+ Returns:
144
+ (final_state_dict, events_log)
145
+ """
146
+ started = time.time()
147
+ events_log: list[dict] = []
148
+
149
+ async def _emit(event: str, data: dict) -> None:
150
+ if emit is not None:
151
+ await emit(event, data)
152
+ events_log.append({"event": event, "data": data, "ts": time.time()})
153
+
154
+ # 1. 加载历史消息
155
+ history_rows = db.message_list_by_session(req.session_id, limit=20)
156
+ from app.agents.state import messages_to_lc
157
+ history_msgs = messages_to_lc([
158
+ {"role": r["role"], "content": r["content"]}
159
+ for r in history_rows
160
+ ])
161
+
162
+ # 2. 添加本轮 user message
163
+ new_user_msg = HumanMessage(content=req.message)
164
+ history_msgs.append(new_user_msg)
165
+
166
+ # 3. 初始化 state
167
+ state = empty_state_for(req.session_id, locale=req.locale)
168
+ state["messages"] = history_msgs
169
+
170
+ # 4. 记录 user message 到 SQLite
171
+ db.session_upsert(req.session_id)
172
+ db.message_insert({
173
+ "id": uuid.uuid4().hex,
174
+ "session_id": req.session_id,
175
+ "role": "user",
176
+ "content": req.message,
177
+ })
178
+
179
+ # 5. 跑 LangGraph (retrieval loop)
180
+ try:
181
+ graph = await get_compiled_graph()
182
+ except Exception as e: # noqa: BLE001
183
+ await _emit(EV_ERROR, {"code": "graph_init_failed", "message": str(e)})
184
+ raise
185
+
186
+ config = {"configurable": {"thread_id": req.session_id}}
187
+ final_state: dict = dict(state)
188
+
189
+ # 直接用 graph.ainvoke, 拿最终 state
190
+ try:
191
+ result = await graph.ainvoke(state, config=config)
192
+ final_state.update(result)
193
+ except Exception as e: # noqa: BLE001
194
+ logger.exception("graph.ainvoke failed")
195
+ await _emit(EV_ERROR, {"code": "graph_failed", "message": str(e)})
196
+ raise
197
+
198
+ # 6. 推送检索事件
199
+ if final_state.get("retrieved_doc_ids"):
200
+ await _emit(EV_RETRIEVAL, {
201
+ "doc_ids": final_state["retrieved_doc_ids"],
202
+ "count": len(final_state.get("retrieved", [])),
203
+ "scores": [round(h.score, 4) for h in final_state.get("retrieved", [])[:10]],
204
+ })
205
+ if final_state.get("citations"):
206
+ await _emit(EV_PROGRESS, {"pct": 60, "label": "已生成引用, 正在回答..."})
207
+
208
+ # 7. 跑 answer 流式 (直接调 LLM, 不走 graph)
209
+ await _emit(EV_AGENT_STEP, {"node": "answer", "status": "running"})
210
+
211
+ async def _on_token(content: str) -> None:
212
+ await _emit(EV_TOKEN, {"content": content})
213
+
214
+ async def _on_citation(c: dict) -> None:
215
+ await _emit(EV_CITATION, c)
216
+
217
+ async def _on_thinking(content: str) -> None:
218
+ await _emit(EV_THINKING, {"content": content})
219
+
220
+ answer_update = await answer_node_stream(
221
+ final_state,
222
+ on_token=_on_token,
223
+ on_citation=_on_citation,
224
+ on_thinking=_on_thinking,
225
+ )
226
+ final_state.update(answer_update)
227
+
228
+ await _emit(EV_AGENT_STEP, {"node": "answer", "status": "done"})
229
+ await _emit(EV_PROGRESS, {"pct": 100, "label": "完成"})
230
+
231
+ # 8. 记录 assistant message 到 SQLite
232
+ elapsed = int((time.time() - started) * 1000)
233
+ db.message_insert({
234
+ "id": uuid.uuid4().hex,
235
+ "session_id": req.session_id,
236
+ "role": "assistant",
237
+ "content": final_state.get("final_answer", ""),
238
+ "citations": final_state.get("citations", []),
239
+ })
240
+
241
+ return final_state, events_log
app/api/documents.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """文档管理 API. 阶段 2: 完整 upload / list / delete / get / chunks."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
7
+
8
+ from app.core.errors import DocumentNotFoundError
9
+ from app.models import db
10
+ from app.models.schemas import DocumentChunk, IngestResult
11
+ from app.services.ingestion import (
12
+ delete_document,
13
+ get_document,
14
+ ingest_bytes,
15
+ list_documents,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+ router = APIRouter(prefix="/documents", tags=["documents"])
20
+
21
+
22
+ @router.get("")
23
+ async def list_docs(
24
+ limit: int = Query(default=100, ge=1, le=500),
25
+ offset: int = Query(default=0, ge=0),
26
+ ) -> dict:
27
+ docs = list_documents(limit=limit)
28
+ return {
29
+ "documents": docs[offset : offset + limit],
30
+ "total": len(docs),
31
+ }
32
+
33
+
34
+ @router.get("/{doc_id}")
35
+ async def get_doc(doc_id: str) -> dict:
36
+ doc = get_document(doc_id)
37
+ if doc is None:
38
+ raise DocumentNotFoundError(f"Document {doc_id} not found", code="doc_not_found")
39
+ return doc
40
+
41
+
42
+ @router.get("/{doc_id}/chunks")
43
+ async def list_chunks(
44
+ doc_id: str,
45
+ limit: int = Query(default=200, ge=1, le=1000),
46
+ offset: int = Query(default=0, ge=0),
47
+ ) -> dict:
48
+ """获取文档的 chunks 列表, 用于前端预览拆分结果.
49
+
50
+ 返回顺序: 按 chunk_index ASC. 包含 text / token_count / page_no / heading / context_prefix.
51
+ """
52
+ doc = get_document(doc_id)
53
+ if doc is None:
54
+ raise DocumentNotFoundError(f"Document {doc_id} not found", code="doc_not_found")
55
+ rows = db.chunk_get_by_doc(doc_id, limit=limit + offset)
56
+ # 截取 offset
57
+ rows = rows[offset : offset + limit]
58
+ return {
59
+ "doc_id": doc_id,
60
+ "chunks": [DocumentChunk(**r).model_dump() for r in rows],
61
+ "total": doc.get("chunk_count", len(rows)),
62
+ "returned": len(rows),
63
+ }
64
+
65
+
66
+ @router.post("/upload", response_model=IngestResult)
67
+ async def upload(file: UploadFile = File(...)) -> IngestResult:
68
+ """上传并摄入文档. 支持 PDF / DOCX / 图片.
69
+
70
+ 流程: 读字节 -> 摄入编排器 (内部 SHA256 查重 + 解析 + 分块 + 向量化 + 入库)
71
+ """
72
+ if file.filename is None:
73
+ raise HTTPException(status_code=400, detail="filename missing")
74
+
75
+ # 限制大小 (50MB; HF Space free 16GB RAM 下够用)
76
+ MAX_BYTES = 50 * 1024 * 1024
77
+ content = await file.read(MAX_BYTES + 1)
78
+ if len(content) > MAX_BYTES:
79
+ raise HTTPException(
80
+ status_code=413,
81
+ detail=f"File too large (> {MAX_BYTES // 1024 // 1024}MB). "
82
+ "Consider chunked upload via S3/R2 (TODO 阶段 5).",
83
+ )
84
+
85
+ try:
86
+ result = await ingest_bytes(content=content, filename=file.filename)
87
+ except Exception as e: # noqa: BLE001
88
+ # 已记录到 SQLite; 返回 502
89
+ logger.exception("Upload failed")
90
+ raise HTTPException(status_code=502, detail=f"Ingest failed: {e}") from e
91
+
92
+ return result
93
+
94
+
95
+ @router.delete("/{doc_id}")
96
+ async def delete_doc(doc_id: str) -> dict:
97
+ ok = await delete_document(doc_id)
98
+ if not ok:
99
+ raise DocumentNotFoundError(f"Document {doc_id} not found", code="doc_not_found")
100
+ return {"doc_id": doc_id, "deleted": True}
app/api/health.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """健康检查 + 探活."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ from fastapi import APIRouter, Request
7
+
8
+ from app.config import settings
9
+ from app.deps import get_llm, persist_status
10
+ from app.models.schemas import HealthStatus
11
+
12
+ router = APIRouter(tags=["health"])
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @router.get("/healthz", response_model=HealthStatus)
17
+ async def healthz() -> HealthStatus:
18
+ """Liveness 探针. 不检查外部依赖, 永远返回 ok (只要进程在)."""
19
+ return HealthStatus(
20
+ status="ok",
21
+ llm=True, # 进程在即代表 LLM 单例已建 (或尝试过)
22
+ persist=persist_status(),
23
+ chroma=False, # 阶段 2 接入
24
+ version=settings.app_version,
25
+ )
26
+
27
+
28
+ @router.get("/readyz", response_model=HealthStatus)
29
+ async def readyz(request: Request) -> HealthStatus:
30
+ """Readiness 探针. 检查 LLM 实际可达 + Chroma + 持久化模式."""
31
+ llm_ok = False
32
+ try:
33
+ llm = get_llm()
34
+ llm_ok = await llm.health_check()
35
+ except Exception as e: # noqa: BLE001
36
+ logger.warning("readyz: LLM health check error: %s", e)
37
+
38
+ chroma_ok = False
39
+ try:
40
+ from app.services.vector_store import get_chroma
41
+ _, coll, _ = get_chroma()
42
+ chroma_ok = coll.count() >= 0 # 任意 count 都不报错即说明连接通
43
+ except Exception as e: # noqa: BLE001
44
+ logger.warning("readyz: Chroma check error: %s", e)
45
+
46
+ persist = persist_status()
47
+ overall = "ok" if (llm_ok and chroma_ok) else "degraded"
48
+
49
+ return HealthStatus(
50
+ status=overall,
51
+ llm=llm_ok,
52
+ persist=persist,
53
+ chroma=chroma_ok,
54
+ version=settings.app_version,
55
+ )
app/api/sessions.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """会话管理 API (阶段 3 接入 LangGraph checkpoint + SQLite)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import uuid
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from langchain_core.messages import HumanMessage, AIMessage
10
+
11
+ from app.agents.graph import get_compiled_graph
12
+ from app.agents.state import messages_to_lc
13
+ from app.models import db
14
+
15
+ logger = logging.getLogger(__name__)
16
+ router = APIRouter(prefix="/sessions", tags=["sessions"])
17
+
18
+
19
+ @router.get("")
20
+ async def list_sessions(limit: int = 50) -> dict:
21
+ sessions = db.session_list(limit=limit)
22
+ return {"sessions": sessions, "total": len(sessions)}
23
+
24
+
25
+ @router.get("/{session_id}")
26
+ async def get_session(session_id: str) -> dict:
27
+ """返回会话 + 消息历史."""
28
+ session = db.session_get(session_id)
29
+ if session is None:
30
+ # 可能还没消息, 但 LangGraph checkpoint 里有. 试一下
31
+ pass
32
+ messages = db.message_list_by_session(session_id)
33
+ return {
34
+ "session": session or {"id": session_id, "title": None, "message_count": 0},
35
+ "messages": messages,
36
+ }
37
+
38
+
39
+ @router.delete("/{session_id}")
40
+ async def delete_session(session_id: str) -> dict:
41
+ """删除会话. SQLite + LangGraph checkpoint 一并清."""
42
+ db.session_delete(session_id)
43
+ # LangGraph checkpoint 删: 用其内部 API
44
+ try:
45
+ graph = await get_compiled_graph()
46
+ checkpointer = graph.checkpointer # AsyncSqliteSaver
47
+ if hasattr(checkpointer, "adelete_thread"):
48
+ await checkpointer.adelete_thread(session_id)
49
+ except Exception as e: # noqa: BLE001
50
+ logger.warning("LangGraph checkpoint delete failed for %s: %s", session_id, e)
51
+ return {"session_id": session_id, "deleted": True}
52
+
53
+
54
+ @router.post("")
55
+ async def create_session(title: str | None = None) -> dict:
56
+ """新建会话. 返回 session_id, 供后续 chat 使用."""
57
+ session_id = uuid.uuid4().hex
58
+ db.session_upsert(session_id, title=title)
59
+ return {"session_id": session_id, "title": title}
app/config.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """全量环境变量配置 (Pydantic Settings).
2
+
3
+ 修改默认值时, 注意区分:
4
+ - "工程常量" (代码内调用方期望) → 直接写死
5
+ - "环境变量" (部署时可调) → 字段, 通过 env 覆盖
6
+ """
7
+ from __future__ import annotations
8
+
9
+ # Monkey patch transformers to bypass torch.load safety check on old PyTorch versions (macOS x86_64)
10
+ try:
11
+ import transformers.utils.import_utils
12
+ transformers.utils.import_utils.check_torch_load_is_safe = lambda *args, **kwargs: None
13
+ except ImportError:
14
+ pass
15
+
16
+ try:
17
+ import transformers.modeling_utils
18
+ transformers.modeling_utils.check_torch_load_is_safe = lambda *args, **kwargs: None
19
+ except ImportError:
20
+ pass
21
+
22
+ # Force CPU device for PyTorch MPS on Intel Macs to prevent NotImplementedError
23
+ try:
24
+ import torch
25
+ torch.backends.mps.is_available = lambda: False
26
+ torch.backends.mps.is_built = lambda: False
27
+ if hasattr(torch, "mps"):
28
+ torch.mps.is_available = lambda: False
29
+ except ImportError:
30
+ pass
31
+
32
+ import json
33
+ from functools import lru_cache
34
+ from pathlib import Path
35
+ from typing import Any, Literal
36
+
37
+ from pydantic import Field, SecretStr, field_validator
38
+ from pydantic_settings import BaseSettings, SettingsConfigDict
39
+
40
+
41
+ class Settings(BaseSettings):
42
+ model_config = SettingsConfigDict(
43
+ env_file=".env",
44
+ env_file_encoding="utf-8",
45
+ extra="ignore",
46
+ case_sensitive=False,
47
+ )
48
+
49
+ # ========== App ==========
50
+ app_name: str = "ai-chatbot"
51
+ app_version: str = "0.1.0"
52
+ app_host: str = "0.0.0.0"
53
+ app_port: int = 7860
54
+ log_level: str = "INFO"
55
+
56
+ # ========== LLM Provider ==========
57
+ llm_provider: Literal["minimax", "openai", "anthropic", "qwen"] = "minimax"
58
+
59
+ # MiniMax-M3 (OpenAI 兼容)
60
+ minimax_api_key: SecretStr = Field(default=SecretStr(""))
61
+ minimax_base_url: str = "https://api.MiniMax.com/v1"
62
+ minimax_model: str = "MiniMax-M3"
63
+
64
+ # 其他 provider 备用 (切换 llm_provider 时生效)
65
+ openai_api_key: SecretStr = Field(default=SecretStr(""))
66
+ anthropic_api_key: SecretStr = Field(default=SecretStr(""))
67
+ qwen_api_key: SecretStr = Field(default=SecretStr(""))
68
+
69
+ # CRAG evaluate 阶段 2 用的 LLM judge (可选更小/更便宜)
70
+ llm_judge_model: str = "MiniMax-M3"
71
+
72
+ # ========== Embedding & Reranker ==========
73
+ embedding_model: str = "BAAI/bge-m3"
74
+ reranker_model: str = "BAAI/bge-reranker-v2-m3"
75
+ embedding_device: Literal["cpu", "cuda", "mps"] = "cpu"
76
+ use_fp16: bool = True
77
+
78
+ # ========== 向量库 / ChromaDB ==========
79
+ chroma_persist_dir: str = "./data/chroma"
80
+ chroma_collection: str = "docs"
81
+ enable_colbert: bool = True # 三路融合开关; false 则仅 dense+sparse
82
+
83
+ # ========== 持久化 (HF Dataset repo) ==========
84
+ hf_persist_repo: str = "" # 形如 "username/ai-chatbot-data". 留空禁用持久化
85
+ hf_token: SecretStr = Field(default=SecretStr(""))
86
+ persist_on_write: bool = True
87
+
88
+ # ========== 数据目录 ==========
89
+ data_dir: Path = Path("./data")
90
+ upload_dir: Path = Path("./data/uploads")
91
+
92
+ # ========== RAG / Agent ==========
93
+ chunk_size: int = 512
94
+ chunk_overlap: int = 64
95
+ semantic_chunking: bool = True
96
+ contextual_retrieval: bool = True
97
+ retrieval_k: int = 20
98
+ rerank_top_n: int = 5
99
+ crag_max_iterations: int = 2
100
+ crag_relevance_threshold: float = 0.7
101
+
102
+ # ========== LLM 缓存 ==========
103
+ llm_cache_enabled: bool = True
104
+ llm_cache_size: int = 200
105
+
106
+ # ========== 解析器 ==========
107
+ parser_primary: Literal["docling", "marker", "mineru", "vlm"] = "docling"
108
+ parser_fallback: Literal["docling", "marker", "mineru", "vlm"] = "marker"
109
+ parser_enable_ocr: bool = True
110
+ parser_table_structure: bool = True
111
+
112
+ # ========== 可观测性 ==========
113
+ langsmith_tracing: bool = False
114
+ langchain_api_key: SecretStr = Field(default=SecretStr(""))
115
+ langchain_project: str = "ai-chatbot"
116
+
117
+ # ========== CORS ==========
118
+ allowed_origins: list[str] = Field(
119
+ default_factory=lambda: [
120
+ "http://localhost:5173",
121
+ "http://localhost:3000",
122
+ ]
123
+ )
124
+
125
+ @field_validator("allowed_origins", mode="before")
126
+ @classmethod
127
+ def _parse_origins(cls, v: Any) -> list[str]:
128
+ """支持 JSON 数组字符串 或 python list."""
129
+ if isinstance(v, str):
130
+ v = v.strip()
131
+ if v.startswith("["):
132
+ return json.loads(v)
133
+ return [o.strip() for o in v.split(",") if o.strip()]
134
+ if isinstance(v, list):
135
+ return v
136
+ raise ValueError("allowed_origins must be a list or JSON string")
137
+
138
+ @field_validator("data_dir", "upload_dir", mode="after")
139
+ @classmethod
140
+ def _abs_path(cls, v: Path) -> Path:
141
+ return Path(v).expanduser().resolve()
142
+
143
+ # ========== 派生 ==========
144
+ @property
145
+ def sqlite_dir(self) -> Path:
146
+ p = self.data_dir / "sqlite"
147
+ p.mkdir(parents=True, exist_ok=True)
148
+ return p
149
+
150
+ @property
151
+ def chroma_dir(self) -> Path:
152
+ p = Path(self.chroma_persist_dir).expanduser().resolve()
153
+ p.mkdir(parents=True, exist_ok=True)
154
+ return p
155
+
156
+ @property
157
+ def hf_cache_dir(self) -> Path:
158
+ p = self.data_dir / ".cache" / "huggingface"
159
+ p.mkdir(parents=True, exist_ok=True)
160
+ return p
161
+
162
+ @property
163
+ def sqlite_db_path(self) -> Path:
164
+ return self.sqlite_dir / "app.db"
165
+
166
+ @property
167
+ def langgraph_db_path(self) -> Path:
168
+ return self.sqlite_dir / "langgraph.db"
169
+
170
+ @property
171
+ def upload_path(self) -> Path:
172
+ p = self.upload_dir
173
+ p.mkdir(parents=True, exist_ok=True)
174
+ return p
175
+
176
+ def is_persist_enabled(self) -> bool:
177
+ return bool(self.hf_persist_repo and self.hf_token.get_secret_value())
178
+
179
+
180
+ @lru_cache(maxsize=1)
181
+ def get_settings() -> Settings:
182
+ """单例 settings (避免重复读取环境变量)."""
183
+ return Settings()
184
+
185
+
186
+ # 全局访问点
187
+ settings = get_settings()
app/core/__init__.py ADDED
File without changes
app/core/errors.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """自定义异常 + FastAPI 异常处理."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.responses import JSONResponse
6
+
7
+
8
+ class AppError(Exception):
9
+ """应用层业务异常的基类."""
10
+
11
+ code: str = "app_error"
12
+ status_code: int = 500
13
+ retryable: bool = False
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ code: str | None = None,
20
+ status_code: int | None = None,
21
+ retryable: bool | None = None,
22
+ detail: dict | None = None,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.message = message
26
+ if code is not None:
27
+ self.code = code
28
+ if status_code is not None:
29
+ self.status_code = status_code
30
+ if retryable is not None:
31
+ self.retryable = retryable
32
+ self.detail = detail or {}
33
+
34
+
35
+ class DocumentNotFoundError(AppError):
36
+ code = "document_not_found"
37
+ status_code = 404
38
+
39
+
40
+ class IngestionFailedError(AppError):
41
+ code = "ingestion_failed"
42
+ status_code = 500
43
+ retryable = True
44
+
45
+
46
+ class LLMUnavailableError(AppError):
47
+ code = "llm_unavailable"
48
+ status_code = 503
49
+ retryable = True
50
+
51
+
52
+ class VectorStoreUnavailableError(AppError):
53
+ code = "vector_store_unavailable"
54
+ status_code = 503
55
+ retryable = True
56
+
57
+
58
+ def install_exception_handlers(app: FastAPI) -> None:
59
+ """挂载全局异常处理. 由 main.py 在创建 app 后调用."""
60
+
61
+ @app.exception_handler(AppError)
62
+ async def _app_error_handler(_: Request, exc: AppError) -> JSONResponse:
63
+ return JSONResponse(
64
+ status_code=exc.status_code,
65
+ content={
66
+ "code": exc.code,
67
+ "message": exc.message,
68
+ "retryable": exc.retryable,
69
+ "detail": exc.detail,
70
+ },
71
+ )
app/core/logging.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """结构化日志 (JSON 格式, 便于 LangSmith 解析)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import sys
6
+ from typing import Any
7
+
8
+ from app.config import settings
9
+
10
+
11
+ class JSONFormatter(logging.Formatter):
12
+ """极简 JSON formatter — 生产用. 开发可切回默认."""
13
+
14
+ def format(self, record: logging.LogRecord) -> str:
15
+ import json
16
+
17
+ payload: dict[str, Any] = {
18
+ "ts": self.formatTime(record, self.datefmt),
19
+ "level": record.levelname,
20
+ "logger": record.name,
21
+ "msg": record.getMessage(),
22
+ }
23
+ if record.exc_info:
24
+ payload["exc"] = self.formatException(record.exc_info)
25
+ # 透传 extra 字段
26
+ for k, v in record.__dict__.items():
27
+ if k in (
28
+ "name", "msg", "args", "levelname", "levelno", "pathname",
29
+ "filename", "module", "exc_info", "exc_text", "stack_info",
30
+ "lineno", "funcName", "created", "msecs", "relativeCreated",
31
+ "thread", "threadName", "processName", "process", "message",
32
+ "taskName",
33
+ ):
34
+ continue
35
+ try:
36
+ json.dumps(v)
37
+ payload[k] = v
38
+ except (TypeError, ValueError):
39
+ payload[k] = str(v)
40
+ return json.dumps(payload, ensure_ascii=False)
41
+
42
+
43
+ def setup_logging() -> None:
44
+ """初始化根 logger. 在 lifespan 启动时调用."""
45
+ root = logging.getLogger()
46
+ root.setLevel(settings.log_level.upper())
47
+
48
+ # 避免重复 handler
49
+ if root.handlers:
50
+ return
51
+
52
+ handler = logging.StreamHandler(sys.stdout)
53
+ if settings.log_level.upper() == "DEBUG":
54
+ handler.setFormatter(
55
+ logging.Formatter(
56
+ "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
57
+ datefmt="%H:%M:%S",
58
+ )
59
+ )
60
+ else:
61
+ handler.setFormatter(JSONFormatter())
62
+ root.addHandler(handler)
63
+
64
+ # 降噪第三方库
65
+ for noisy in ("httpx", "httpcore", "openai", "chromadb", "huggingface_hub"):
66
+ logging.getLogger(noisy).setLevel(logging.WARNING)
67
+
68
+
69
+ def get_logger(name: str) -> logging.Logger:
70
+ return logging.getLogger(name)
app/core/paths.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent disk path helpers.
2
+
3
+ HF Spaces 免费版 `/data` 是持久卷挂载点 (即使容器重启也保留).
4
+ 在本地 dev 时, 退回到仓库内 `./data/`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from app.config import settings
11
+
12
+
13
+ def data_dir() -> Path:
14
+ """根数据目录 (确保存在)."""
15
+ p = Path(settings.data_dir).expanduser().resolve()
16
+ p.mkdir(parents=True, exist_ok=True)
17
+ return p
18
+
19
+
20
+ def upload_dir() -> Path:
21
+ """用户上传的原始文件目录."""
22
+ p = data_dir() / "uploads"
23
+ p.mkdir(parents=True, exist_ok=True)
24
+ return p
25
+
26
+
27
+ def sqlite_dir() -> Path:
28
+ """SQLite 文件 (元数据 + LangGraph checkpoint) 目录."""
29
+ p = data_dir() / "sqlite"
30
+ p.mkdir(parents=True, exist_ok=True)
31
+ return p
32
+
33
+
34
+ def chroma_dir() -> Path:
35
+ """ChromaDB 持久化目录."""
36
+ p = data_dir() / "chroma"
37
+ p.mkdir(parents=True, exist_ok=True)
38
+ return p
39
+
40
+
41
+ def hf_cache_dir() -> Path:
42
+ """HuggingFace 模型缓存目录 (避免重复下载)."""
43
+ p = data_dir() / ".cache" / "huggingface"
44
+ p.mkdir(parents=True, exist_ok=True)
45
+ return p
app/deps.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """应用级单例 (LLM, embedder, vector store, etc.) 的获取与生命周期.
2
+
3
+ 被 main.py lifespan 和各 router 复用. 保持依赖注入简单, 不引入 DI 框架.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+
10
+ from app.config import settings
11
+ from app.llm.base import AbstractLLM
12
+
13
+ if TYPE_CHECKING:
14
+ from app.services.persist import persist_status
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def get_llm() -> AbstractLLM:
20
+ """从工厂取 LLM 单例. 失败时抛出 LLMUnavailableError."""
21
+ from app.llm.factory import get_llm as _factory_get_llm
22
+ return _factory_get_llm()
23
+
24
+
25
+ async def close_llm() -> None:
26
+ from app.llm.factory import close_llm as _factory_close
27
+ await _factory_close()
28
+
29
+
30
+ def persist_status() -> dict:
31
+ from app.services.persist import persist_status as _ps
32
+ return _ps()
app/llm/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """LLM provider 抽象. 由 factory.py 根据 settings.llm_provider 选择."""
2
+ from app.llm.base import AbstractLLM, LLMChunk, LLMMessage
3
+ from app.llm.factory import get_llm
4
+
5
+ __all__ = ["AbstractLLM", "LLMChunk", "LLMMessage", "get_llm"]
app/llm/base.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM provider 抽象基类 + 数据类型.
2
+
3
+ 设计原则:
4
+ - 与 LangChain / Pydantic AI 解耦, 业务代码只依赖本模块
5
+ - 流式 + 非流式接口都提供
6
+ - 支持 tool calling (OpenAI 兼容格式)
7
+ - 内置指数退避重试
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import abc
12
+ from collections.abc import AsyncIterator
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+
17
+ @dataclass
18
+ class LLMMessage:
19
+ """与 OpenAI ChatMessage 兼容的轻量消息结构."""
20
+
21
+ role: str # "system" | "user" | "assistant" | "tool"
22
+ content: str
23
+ name: str | None = None
24
+ tool_call_id: str | None = None
25
+ tool_calls: list[dict[str, Any]] | None = None # assistant 消息的 tool_calls
26
+
27
+
28
+ @dataclass
29
+ class ToolSpec:
30
+ """OpenAI 兼容的 function-calling 工具规格."""
31
+
32
+ name: str
33
+ description: str
34
+ parameters: dict[str, Any] # JSON Schema
35
+
36
+
37
+ @dataclass
38
+ class LLMChunk:
39
+ """流式响应的单个 chunk."""
40
+
41
+ content: str = ""
42
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
43
+ finish_reason: str | None = None
44
+ usage: dict[str, int] | None = None # 仅最后一个 chunk 携带
45
+
46
+
47
+ @dataclass
48
+ class LLMResponse:
49
+ """非流式响应的完整结果."""
50
+
51
+ content: str
52
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
53
+ finish_reason: str = "stop"
54
+ usage: dict[str, int] = field(default_factory=dict)
55
+ raw: Any = None # provider 原始响应 (调试用)
56
+
57
+
58
+ class AbstractLLM(abc.ABC):
59
+ """LLM provider 抽象基类."""
60
+
61
+ name: str = "abstract"
62
+
63
+ @abc.abstractmethod
64
+ async def chat(
65
+ self,
66
+ messages: list[LLMMessage],
67
+ *,
68
+ tools: list[ToolSpec] | None = None,
69
+ temperature: float = 0.7,
70
+ max_tokens: int | None = None,
71
+ **kwargs: Any,
72
+ ) -> LLMResponse:
73
+ """非流式聊天."""
74
+
75
+ @abc.abstractmethod
76
+ def stream_chat(
77
+ self,
78
+ messages: list[LLMMessage],
79
+ *,
80
+ tools: list[ToolSpec] | None = None,
81
+ temperature: float = 0.7,
82
+ max_tokens: int | None = None,
83
+ **kwargs: Any,
84
+ ) -> AsyncIterator[LLMChunk]:
85
+ """流式聊天. 必须返回 AsyncIterator (async generator)."""
86
+ # 抽象方法必须 yield 一次占位, 否则子类可能写错
87
+ # 但子类实现是 async def + yield, 所以这里只是签名
88
+ raise NotImplementedError
89
+ yield # type: ignore[unreachable] # pragma: no cover
90
+
91
+ @abc.abstractmethod
92
+ async def health_check(self) -> bool:
93
+ """探活: 极简调用 (如 1 token completion)."""
94
+
95
+ @abc.abstractmethod
96
+ async def aclose(self) -> None:
97
+ """关闭底层 client (httpx / AsyncOpenAI 等)."""
app/llm/factory.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM 工厂: 根据 settings.llm_provider 选择 provider.
2
+
3
+ 新增 provider 的步骤:
4
+ 1. 在 base.py 实现一个新类 (或复用 OpenAICompatibleLLM)
5
+ 2. 在本文件 _PROVIDERS 加一行
6
+ 3. 在 .env.example 暴露新 env
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from functools import lru_cache
12
+
13
+ from app.config import settings
14
+ from app.core.errors import LLMUnavailableError
15
+ from app.llm.base import AbstractLLM
16
+ from app.llm.minimax import OpenAICompatibleLLM
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _build_minimax() -> OpenAICompatibleLLM:
22
+ key = settings.minimax_api_key.get_secret_value()
23
+ if not key:
24
+ raise LLMUnavailableError(
25
+ "MINIMAX_API_KEY is empty. Set it in .env or HF Space secrets.",
26
+ code="llm_api_key_missing",
27
+ )
28
+ return OpenAICompatibleLLM(
29
+ api_key=key,
30
+ base_url=settings.minimax_base_url,
31
+ model=settings.minimax_model,
32
+ provider_name="minimax",
33
+ )
34
+
35
+
36
+ def _build_openai() -> OpenAICompatibleLLM:
37
+ key = settings.openai_api_key.get_secret_value()
38
+ if not key:
39
+ raise LLMUnavailableError(
40
+ "OPENAI_API_KEY is empty.",
41
+ code="llm_api_key_missing",
42
+ )
43
+ return OpenAICompatibleLLM(
44
+ api_key=key,
45
+ base_url="https://api.openai.com/v1",
46
+ # 允许用 env 覆盖 model (openai_model), 没设则用 gpt-4o-mini
47
+ model=getattr(settings, "openai_model", "gpt-4o-mini"),
48
+ provider_name="openai",
49
+ )
50
+
51
+
52
+ def _build_qwen() -> OpenAICompatibleLLM:
53
+ key = settings.qwen_api_key.get_secret_value()
54
+ if not key:
55
+ raise LLMUnavailableError(
56
+ "QWEN_API_KEY is empty.",
57
+ code="llm_api_key_missing",
58
+ )
59
+ return OpenAICompatibleLLM(
60
+ api_key=key,
61
+ # 阿里百炼 OpenAI 兼容端点
62
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
63
+ model=getattr(settings, "qwen_model", "qwen-max"),
64
+ provider_name="qwen",
65
+ )
66
+
67
+
68
+ def _build_anthropic() -> AbstractLLM:
69
+ """占位: Anthropic SDK 原生协议, 暂未实现, 留 hook."""
70
+ raise NotImplementedError(
71
+ "Anthropic provider not yet implemented. "
72
+ "Use openai-compatible via OpenRouter or implement anthropic.AsyncAnthropic wrapper."
73
+ )
74
+
75
+
76
+ _PROVIDERS = {
77
+ "minimax": _build_minimax,
78
+ "openai": _build_openai,
79
+ "qwen": _build_qwen,
80
+ "anthropic": _build_anthropic,
81
+ }
82
+
83
+
84
+ @lru_cache(maxsize=1)
85
+ def get_llm() -> AbstractLLM:
86
+ """单例 LLM. 在 lifespan 中调用 + 缓存."""
87
+ builder = _PROVIDERS.get(settings.llm_provider)
88
+ if builder is None:
89
+ raise LLMUnavailableError(
90
+ f"Unknown llm_provider: {settings.llm_provider}",
91
+ code="llm_provider_unknown",
92
+ )
93
+ logger.info("Initializing LLM provider: %s", settings.llm_provider)
94
+ return builder()
95
+
96
+
97
+ async def close_llm() -> None:
98
+ """关闭 LLM 客户端. lifespan shutdown 时调用."""
99
+ try:
100
+ llm = get_llm()
101
+ except Exception: # noqa: BLE001
102
+ return
103
+ await llm.aclose()
104
+ get_llm.cache_clear()
app/llm/minimax.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMax-M3 provider (OpenAI 兼容协议).
2
+
3
+ 通过 AsyncOpenAI(base_url=...) 调用 MiniMax 端点.
4
+ 所有 OpenAI 兼容的 provider (Qwen / DeepSeek / 自部署 vLLM 等) 都可以复用本类,
5
+ 只需换 base_url 和 model.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from typing import Any
12
+
13
+ from openai import APIError, APITimeoutError, AsyncOpenAI, RateLimitError
14
+ from tenacity import (
15
+ retry,
16
+ retry_if_exception_type,
17
+ stop_after_attempt,
18
+ wait_exponential,
19
+ )
20
+
21
+ from app.config import settings
22
+ from app.core.errors import LLMUnavailableError
23
+ from app.llm.base import (
24
+ AbstractLLM,
25
+ LLMChunk,
26
+ LLMMessage,
27
+ LLMResponse,
28
+ ToolSpec,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _to_openai_messages(messages: list[LLMMessage]) -> list[dict[str, Any]]:
35
+ """转换为本类消息 -> OpenAI ChatMessage dict."""
36
+ out: list[dict[str, Any]] = []
37
+ for m in messages:
38
+ d: dict[str, Any] = {"role": m.role, "content": m.content}
39
+ if m.name:
40
+ d["name"] = m.name
41
+ if m.tool_call_id:
42
+ d["tool_call_id"] = m.tool_call_id
43
+ if m.tool_calls:
44
+ d["tool_calls"] = m.tool_calls
45
+ out.append(d)
46
+ return out
47
+
48
+
49
+ def _to_openai_tools(tools: list[ToolSpec]) -> list[dict[str, Any]]:
50
+ """ToolSpec -> OpenAI tools format."""
51
+ return [
52
+ {
53
+ "type": "function",
54
+ "function": {
55
+ "name": t.name,
56
+ "description": t.description,
57
+ "parameters": t.parameters,
58
+ },
59
+ }
60
+ for t in tools
61
+ ]
62
+
63
+
64
+ class OpenAICompatibleLLM(AbstractLLM):
65
+ """OpenAI 兼容协议的通用 LLM (MiniMax / Qwen / DeepSeek / 自部署 vLLM).
66
+
67
+ 之所以不叫 MiniMaxLLM: 因为实现是通用的, 通过 (base_url, model) 切换 provider.
68
+ factory.py 用 settings 决定具体配置.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ api_key: str,
75
+ base_url: str,
76
+ model: str,
77
+ provider_name: str = "openai-compat",
78
+ timeout: float = 60.0,
79
+ ) -> None:
80
+ self.name = provider_name
81
+ self.model = model
82
+ # 关键: trust_env=False 强制不走系统代理 (urllib.getproxies() / macOS System Preferences)
83
+ # 否则 httpx 会尝试走 127.0.0.1:7897, 若该端口代理服务不可用, TLS 握手会挂在
84
+ # start_tls 阶段报 "Connection error"
85
+ import httpx as _httpx
86
+ _http_client = _httpx.AsyncClient(
87
+ trust_env=False,
88
+ timeout=timeout,
89
+ )
90
+ self.client = AsyncOpenAI(
91
+ api_key=api_key or "EMPTY",
92
+ base_url=base_url,
93
+ timeout=timeout,
94
+ max_retries=0, # tenacity 自管
95
+ http_client=_http_client,
96
+ )
97
+ logger.info(
98
+ "LLM client init: provider=%s base_url=%s model=%s (trust_env=False, no proxy)",
99
+ provider_name, base_url, model,
100
+ )
101
+
102
+ async def aclose(self) -> None:
103
+ await self.client.close()
104
+
105
+ @retry(
106
+ retry=retry_if_exception_type((APITimeoutError, RateLimitError, APIError)),
107
+ stop=stop_after_attempt(3),
108
+ wait=wait_exponential(multiplier=1, min=1, max=10),
109
+ reraise=True,
110
+ )
111
+ async def chat(
112
+ self,
113
+ messages: list[LLMMessage],
114
+ *,
115
+ tools: list[ToolSpec] | None = None,
116
+ temperature: float = 0.7,
117
+ max_tokens: int | None = None,
118
+ **kwargs: Any,
119
+ ) -> LLMResponse:
120
+ params: dict[str, Any] = {
121
+ "model": self.model,
122
+ "messages": _to_openai_messages(messages),
123
+ "temperature": temperature,
124
+ }
125
+ if max_tokens is not None:
126
+ params["max_tokens"] = max_tokens
127
+ if tools:
128
+ params["tools"] = _to_openai_tools(tools)
129
+ params.update(kwargs)
130
+
131
+ try:
132
+ resp = await self.client.chat.completions.create(**params)
133
+ except (APITimeoutError, RateLimitError, APIError) as e:
134
+ logger.warning("LLM chat retry: %s", e)
135
+ raise
136
+ except Exception as e:
137
+ raise LLMUnavailableError(
138
+ f"LLM call failed: {e}", retryable=False
139
+ ) from e
140
+
141
+ choice = resp.choices[0]
142
+ msg = choice.message
143
+ usage = (
144
+ {
145
+ "prompt_tokens": resp.usage.prompt_tokens,
146
+ "completion_tokens": resp.usage.completion_tokens,
147
+ "total_tokens": resp.usage.total_tokens,
148
+ }
149
+ if resp.usage
150
+ else {}
151
+ )
152
+ return LLMResponse(
153
+ content=msg.content or "",
154
+ tool_calls=[tc.model_dump() for tc in (msg.tool_calls or [])],
155
+ finish_reason=choice.finish_reason or "stop",
156
+ usage=usage,
157
+ raw=resp,
158
+ )
159
+
160
+ @retry(
161
+ retry=retry_if_exception_type((APITimeoutError, RateLimitError, APIError)),
162
+ stop=stop_after_attempt(3),
163
+ wait=wait_exponential(multiplier=1, min=1, max=10),
164
+ reraise=True,
165
+ )
166
+ async def stream_chat(
167
+ self,
168
+ messages: list[LLMMessage],
169
+ *,
170
+ tools: list[ToolSpec] | None = None,
171
+ temperature: float = 0.7,
172
+ max_tokens: int | None = None,
173
+ **kwargs: Any,
174
+ ) -> AsyncIterator[LLMChunk]:
175
+ params: dict[str, Any] = {
176
+ "model": self.model,
177
+ "messages": _to_openai_messages(messages),
178
+ "temperature": temperature,
179
+ "stream": True,
180
+ "stream_options": {"include_usage": True},
181
+ }
182
+ if max_tokens is not None:
183
+ params["max_tokens"] = max_tokens
184
+ if tools:
185
+ params["tools"] = _to_openai_tools(tools)
186
+ params.update(kwargs)
187
+
188
+ try:
189
+ stream = await self.client.chat.completions.create(**params)
190
+ async for ev in stream:
191
+ if not ev.choices:
192
+ # 最终 usage chunk (choices 为空, 只有 usage)
193
+ if getattr(ev, "usage", None):
194
+ yield LLMChunk(
195
+ content="",
196
+ finish_reason="stop",
197
+ usage={
198
+ "prompt_tokens": ev.usage.prompt_tokens,
199
+ "completion_tokens": ev.usage.completion_tokens,
200
+ "total_tokens": ev.usage.total_tokens,
201
+ },
202
+ )
203
+ continue
204
+ choice = ev.choices[0]
205
+ delta = choice.delta
206
+ tc_dumps: list[dict[str, Any]] = []
207
+ if delta.tool_calls:
208
+ for tc in delta.tool_calls:
209
+ tc_dumps.append(tc.model_dump(exclude_unset=True))
210
+ yield LLMChunk(
211
+ content=delta.content or "",
212
+ tool_calls=tc_dumps,
213
+ finish_reason=choice.finish_reason,
214
+ )
215
+ except (APITimeoutError, RateLimitError, APIError) as e:
216
+ logger.warning("LLM stream retry: %s", e)
217
+ raise
218
+ except Exception as e:
219
+ raise LLMUnavailableError(
220
+ f"LLM stream failed: {e}", retryable=False
221
+ ) from e
222
+
223
+ async def health_check(self) -> bool:
224
+ """极简探活: 1 token 补全."""
225
+ try:
226
+ resp = await self.client.chat.completions.create(
227
+ model=self.model,
228
+ messages=[{"role": "user", "content": "ping"}],
229
+ max_tokens=1,
230
+ temperature=0,
231
+ )
232
+ return bool(resp.choices)
233
+ except Exception as e: # noqa: BLE001
234
+ logger.warning("LLM health check failed: %s", e)
235
+ return False
236
+
237
+
238
+ # 向后兼容别名: 早期代码可能用 MiniMaxLLM
239
+ MiniMaxLLM = OpenAICompatibleLLM
app/main.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI 应用入口.
2
+
3
+ 启动顺序 (lifespan):
4
+ 1. setup_logging
5
+ 2. restore_from_hf (持久化恢复, 失败不阻塞)
6
+ 3. (阶段 2 接入) Chroma client, BGE-M3, reranker 预热
7
+ 4. (阶段 3 接入) LangGraph checkpointer 初始化
8
+
9
+ 关闭顺序:
10
+ 1. (阶段 3 接入) flush LangGraph checkpoint
11
+ 2. push_to_hf (持久化推送)
12
+ 3. close_llm (关闭 LLM httpx client)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from contextlib import asynccontextmanager
18
+
19
+ from fastapi import FastAPI
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+
22
+ from app import __version__
23
+ from app.agents.graph import close_checkpointer, get_compiled_graph
24
+ from app.api import chat, documents, health, sessions
25
+ from app.config import settings
26
+ from app.core.errors import install_exception_handlers
27
+ from app.core.logging import setup_logging
28
+ from app.deps import close_llm
29
+ from app.models import db as app_db
30
+ from app.services.embedding import warm_up as warm_embedder
31
+ from app.services.persist import push_to_hf, restore_from_hf
32
+ from app.services.reranker import warm_up as warm_reranker
33
+ from app.services.vector_store import get_chroma
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ @asynccontextmanager
39
+ async def lifespan(app: FastAPI):
40
+ # ===== Startup =====
41
+ setup_logging()
42
+ logger.info("=" * 60)
43
+ logger.info("Starting %s v%s", settings.app_name, __version__)
44
+ logger.info("LLM provider: %s | model: %s", settings.llm_provider, settings.minimax_model)
45
+ logger.info("Data dir: %s", settings.data_dir)
46
+ logger.info("Allowed origins: %s", settings.allowed_origins)
47
+
48
+ # 持久化恢复 (不阻塞)
49
+ await restore_from_hf()
50
+
51
+ # SQLite schema (元数据 + LangGraph checkpoint 共用 db)
52
+ app_db.init_db()
53
+
54
+ # Chroma 客户端 (建立 collection)
55
+ try:
56
+ get_chroma()
57
+ logger.info("ChromaDB ready")
58
+ except Exception as e: # noqa: BLE001
59
+ logger.warning("ChromaDB init failed (will retry on first request): %s", e)
60
+
61
+ # BGE-M3 & Reranker 预热.
62
+ # 必须在主线程 *同步* 执行, 不能 run_in_executor 异步跑 — 因为 FlagEmbedding
63
+ # 在 torch 2.2 + transformers 4.57 组合下, 子线程首次 .to(device) 会撞 meta tensor
64
+ # 错 "Cannot copy out of meta tensor; no data!"; 必须等它 meta→cpu 转移完再 await
65
+ import os
66
+ if settings.embedding_model and not os.environ.get("TESTING"):
67
+ try:
68
+ warm_embedder()
69
+ warm_reranker()
70
+ except Exception as e: # noqa: BLE001
71
+ logger.warning("Embedder/reranker warm-up failed: %s", e)
72
+
73
+ # LangGraph checkpointer 初始化 (不预热, 首次 chat 才编译)
74
+ try:
75
+ # 仅检查 import 路径, 不实际编译
76
+ from app.agents.graph import _build_graph # noqa: F401
77
+ logger.info("LangGraph importable")
78
+ except Exception as e: # noqa: BLE001
79
+ logger.warning("LangGraph import failed: %s", e)
80
+
81
+ logger.info("Backend ready")
82
+ yield
83
+
84
+ # ===== Shutdown =====
85
+ logger.info("Shutting down...")
86
+ try:
87
+ await push_to_hf()
88
+ except Exception as e: # noqa: BLE001
89
+ logger.warning("Final persist push failed: %s", e)
90
+ await close_checkpointer()
91
+ await close_llm()
92
+ logger.info("Bye")
93
+
94
+
95
+ app = FastAPI(
96
+ title="AI Chatbot",
97
+ version=__version__,
98
+ lifespan=lifespan,
99
+ docs_url="/api/docs",
100
+ redoc_url="/api/redoc",
101
+ openapi_url="/api/openapi.json",
102
+ )
103
+
104
+ # CORS (个人使用, 信任 GH Pages 域 + 本地)
105
+ app.add_middleware(
106
+ CORSMiddleware,
107
+ allow_origins=settings.allowed_origins,
108
+ allow_credentials=True,
109
+ allow_methods=["*"],
110
+ allow_headers=["*"],
111
+ expose_headers=["*"],
112
+ )
113
+
114
+ # 异常处理
115
+ install_exception_handlers(app)
116
+
117
+ # 路由
118
+ app.include_router(health.router, prefix="/api/v1")
119
+ app.include_router(chat.router, prefix="/api/v1")
120
+ app.include_router(documents.router, prefix="/api/v1")
121
+ app.include_router(sessions.router, prefix="/api/v1")
122
+
123
+
124
+ @app.get("/", include_in_schema=False)
125
+ async def root() -> dict:
126
+ return {
127
+ "name": settings.app_name,
128
+ "version": __version__,
129
+ "llm_provider": settings.llm_provider,
130
+ "docs": "/api/docs",
131
+ }
app/models/__init__.py ADDED
File without changes
app/models/db.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite 元数据 + LangGraph checkpoint 共享 db.
2
+
3
+ 用 sqlite3 stdlib 即可, 暂不引入 SQLAlchemy (减少依赖).
4
+ WAL 模式开启, 允许 LangGraph checkpoint 与文档读并发.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import Any, Iterator
16
+
17
+ from app.config import settings
18
+ from app.core.paths import sqlite_dir
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _local = threading.local()
23
+
24
+
25
+ def _connect() -> sqlite3.Connection:
26
+ """每线程一个连接 (sqlite3 本身不线程安全)."""
27
+ conn = sqlite3.connect(
28
+ str(settings.sqlite_db_path),
29
+ check_same_thread=False,
30
+ isolation_level=None, # autocommit, 手动 BEGIN
31
+ timeout=30.0,
32
+ )
33
+ conn.row_factory = sqlite3.Row
34
+ conn.execute("PRAGMA journal_mode=WAL")
35
+ conn.execute("PRAGMA synchronous=NORMAL")
36
+ conn.execute("PRAGMA foreign_keys=ON")
37
+ return conn
38
+
39
+
40
+ def get_conn() -> sqlite3.Connection:
41
+ """获取当前线程的连接."""
42
+ conn = getattr(_local, "conn", None)
43
+ if conn is None:
44
+ conn = _connect()
45
+ _local.conn = conn
46
+ return conn
47
+
48
+
49
+ @contextmanager
50
+ def transaction() -> Iterator[sqlite3.Connection]:
51
+ """显式事务 wrapper."""
52
+ conn = get_conn()
53
+ conn.execute("BEGIN")
54
+ try:
55
+ yield conn
56
+ except Exception:
57
+ conn.execute("ROLLBACK")
58
+ raise
59
+ else:
60
+ conn.execute("COMMIT")
61
+
62
+
63
+ SCHEMA = """
64
+ CREATE TABLE IF NOT EXISTS documents (
65
+ id TEXT PRIMARY KEY,
66
+ filename TEXT NOT NULL,
67
+ mime TEXT,
68
+ size INTEGER NOT NULL,
69
+ page_count INTEGER,
70
+ chunk_count INTEGER DEFAULT 0,
71
+ status TEXT NOT NULL DEFAULT 'uploading',
72
+ progress INTEGER DEFAULT 0, -- 0-100, 用于前端进度条
73
+ progress_label TEXT, -- "正在分块 (12/48)..." 等
74
+ error TEXT,
75
+ sha256 TEXT NOT NULL,
76
+ parser TEXT,
77
+ chunk_size INTEGER,
78
+ chunk_overlap INTEGER,
79
+ semantic_chunking INTEGER DEFAULT 0,
80
+ contextual_retrieval INTEGER DEFAULT 0,
81
+ meta_json TEXT,
82
+ created_at REAL NOT NULL,
83
+ updated_at REAL NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_documents_sha ON documents(sha256);
86
+ CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
87
+
88
+ CREATE TABLE IF NOT EXISTS chunks (
89
+ id TEXT PRIMARY KEY,
90
+ doc_id TEXT NOT NULL,
91
+ parent_id TEXT,
92
+ chunk_index INTEGER NOT NULL,
93
+ text TEXT NOT NULL,
94
+ token_count INTEGER,
95
+ page_no INTEGER,
96
+ heading TEXT,
97
+ context_prefix TEXT,
98
+ created_at REAL NOT NULL,
99
+ FOREIGN KEY (doc_id) REFERENCES documents(id) ON DELETE CASCADE
100
+ );
101
+ CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
102
+ CREATE INDEX IF NOT EXISTS idx_chunks_parent ON chunks(parent_id);
103
+
104
+ CREATE TABLE IF NOT EXISTS sessions (
105
+ id TEXT PRIMARY KEY,
106
+ user_id TEXT NOT NULL DEFAULT 'default',
107
+ title TEXT,
108
+ message_count INTEGER DEFAULT 0,
109
+ created_at REAL NOT NULL,
110
+ updated_at REAL NOT NULL
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS messages (
114
+ id TEXT PRIMARY KEY,
115
+ session_id TEXT NOT NULL,
116
+ role TEXT NOT NULL,
117
+ content TEXT NOT NULL,
118
+ citations_json TEXT,
119
+ tool_calls_json TEXT,
120
+ created_at REAL NOT NULL,
121
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
122
+ );
123
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
124
+ """
125
+
126
+
127
+ def init_db() -> None:
128
+ """启动时调用. 幂等."""
129
+ sqlite_dir()
130
+ conn = get_conn()
131
+ conn.executescript(SCHEMA)
132
+ # 在线迁移: 给旧 documents 表加新列 (progress / parser / chunk_size 等).
133
+ # 失败无害 (列已存在).
134
+ _MIGRATIONS = [
135
+ "ALTER TABLE documents ADD COLUMN progress INTEGER DEFAULT 0",
136
+ "ALTER TABLE documents ADD COLUMN progress_label TEXT",
137
+ "ALTER TABLE documents ADD COLUMN parser TEXT",
138
+ "ALTER TABLE documents ADD COLUMN chunk_size INTEGER",
139
+ "ALTER TABLE documents ADD COLUMN chunk_overlap INTEGER",
140
+ "ALTER TABLE documents ADD COLUMN semantic_chunking INTEGER DEFAULT 0",
141
+ "ALTER TABLE documents ADD COLUMN contextual_retrieval INTEGER DEFAULT 0",
142
+ ]
143
+ for sql in _MIGRATIONS:
144
+ try:
145
+ conn.execute(sql)
146
+ except Exception: # noqa: BLE001
147
+ pass # 列已存在, 跳过
148
+ logger.info("DB schema ensured at %s", settings.sqlite_db_path)
149
+
150
+
151
+ # ========== Document CRUD ==========
152
+ def doc_insert(doc: dict[str, Any]) -> None:
153
+ """doc 必须含: id, filename, size, sha256, created_at, updated_at."""
154
+ conn = get_conn()
155
+ conn.execute(
156
+ """
157
+ INSERT INTO documents (id, filename, mime, size, page_count, chunk_count,
158
+ status, progress, progress_label, error, sha256,
159
+ parser, chunk_size, chunk_overlap,
160
+ semantic_chunking, contextual_retrieval,
161
+ meta_json, created_at, updated_at)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
163
+ """,
164
+ (
165
+ doc["id"], doc["filename"], doc.get("mime"), doc["size"],
166
+ doc.get("page_count"), doc.get("chunk_count", 0),
167
+ doc.get("status", "uploading"),
168
+ doc.get("progress", 0), doc.get("progress_label"),
169
+ doc.get("error"),
170
+ doc["sha256"], doc.get("parser"),
171
+ doc.get("chunk_size"), doc.get("chunk_overlap"),
172
+ int(bool(doc.get("semantic_chunking", False))),
173
+ int(bool(doc.get("contextual_retrieval", False))),
174
+ json.dumps(doc.get("meta", {}), ensure_ascii=False),
175
+ doc["created_at"], doc.get("updated_at", doc["created_at"]),
176
+ ),
177
+ )
178
+
179
+
180
+ def doc_update_status(
181
+ doc_id: str,
182
+ status: str,
183
+ *,
184
+ error: str | None = None,
185
+ chunk_count: int | None = None,
186
+ page_count: int | None = None,
187
+ progress: int | None = None,
188
+ progress_label: str | None = None,
189
+ ) -> None:
190
+ conn = get_conn()
191
+ sets = ["status = ?", "updated_at = ?"]
192
+ params: list[Any] = [status, time.time()]
193
+ if error is not None:
194
+ sets.append("error = ?")
195
+ params.append(error)
196
+ if chunk_count is not None:
197
+ sets.append("chunk_count = ?")
198
+ params.append(chunk_count)
199
+ if page_count is not None:
200
+ sets.append("page_count = ?")
201
+ params.append(page_count)
202
+ if progress is not None:
203
+ sets.append("progress = ?")
204
+ params.append(max(0, min(100, int(progress))))
205
+ if progress_label is not None:
206
+ sets.append("progress_label = ?")
207
+ params.append(progress_label)
208
+ params.append(doc_id)
209
+ conn.execute(f"UPDATE documents SET {', '.join(sets)} WHERE id = ?", params)
210
+
211
+
212
+ def doc_get(doc_id: str) -> dict[str, Any] | None:
213
+ row = get_conn().execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone()
214
+ return _row_to_doc(row) if row else None
215
+
216
+
217
+ def doc_find_by_sha256(sha: str) -> dict[str, Any] | None:
218
+ row = get_conn().execute(
219
+ "SELECT * FROM documents WHERE sha256 = ? AND status = 'ready' ORDER BY created_at DESC LIMIT 1",
220
+ (sha,),
221
+ ).fetchone()
222
+ return _row_to_doc(row) if row else None
223
+
224
+
225
+ def doc_list(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
226
+ rows = get_conn().execute(
227
+ "SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
228
+ (limit, offset),
229
+ ).fetchall()
230
+ return [_row_to_doc(r) for r in rows]
231
+
232
+
233
+ def doc_delete(doc_id: str) -> bool:
234
+ cur = get_conn().execute("DELETE FROM documents WHERE id = ?", (doc_id,))
235
+ return cur.rowcount > 0
236
+
237
+
238
+ def _row_to_doc(row: sqlite3.Row) -> dict[str, Any]:
239
+ d = dict(row)
240
+ if d.get("meta_json"):
241
+ try:
242
+ d["meta"] = json.loads(d["meta_json"])
243
+ except json.JSONDecodeError:
244
+ d["meta"] = {}
245
+ d.pop("meta_json", None)
246
+ # 拆出 boolean 字段, 方便前端直接使用
247
+ d["semantic_chunking"] = bool(d.get("semantic_chunking"))
248
+ d["contextual_retrieval"] = bool(d.get("contextual_retrieval"))
249
+ return d
250
+
251
+
252
+ # ========== Chunk CRUD (用于上下文回查, 实际检索走 Chroma) ==========
253
+ def chunk_insert_bulk(chunks: list[dict[str, Any]]) -> None:
254
+ if not chunks:
255
+ return
256
+ conn = get_conn()
257
+ with transaction():
258
+ conn.executemany(
259
+ """
260
+ INSERT OR REPLACE INTO chunks
261
+ (id, doc_id, parent_id, chunk_index, text, token_count, page_no, heading, context_prefix, created_at)
262
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
263
+ """,
264
+ [
265
+ (
266
+ c["id"], c["doc_id"], c.get("parent_id"), c["chunk_index"],
267
+ c["text"], c.get("token_count"), c.get("page_no"),
268
+ c.get("heading"), c.get("context_prefix"), c.get("created_at", time.time()),
269
+ )
270
+ for c in chunks
271
+ ],
272
+ )
273
+
274
+
275
+ def chunk_get_by_doc(doc_id: str, limit: int = 200) -> list[dict[str, Any]]:
276
+ rows = get_conn().execute(
277
+ "SELECT * FROM chunks WHERE doc_id = ? ORDER BY chunk_index LIMIT ?",
278
+ (doc_id, limit),
279
+ ).fetchall()
280
+ return [dict(r) for r in rows]
281
+
282
+
283
+ def chunk_delete_by_doc(doc_id: str) -> int:
284
+ cur = get_conn().execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))
285
+ return cur.rowcount
286
+
287
+
288
+ # ========== Session / Message CRUD (阶段 3 实际使用) ==========
289
+ def session_upsert(session_id: str, user_id: str = "default", title: str | None = None) -> None:
290
+ now = time.time()
291
+ conn = get_conn()
292
+ conn.execute(
293
+ """
294
+ INSERT INTO sessions (id, user_id, title, message_count, created_at, updated_at)
295
+ VALUES (?, ?, ?, 0, ?, ?)
296
+ ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at
297
+ """,
298
+ (session_id, user_id, title, now, now),
299
+ )
300
+
301
+
302
+ def session_list(limit: int = 50) -> list[dict[str, Any]]:
303
+ rows = get_conn().execute(
304
+ "SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?", (limit,)
305
+ ).fetchall()
306
+ return [dict(r) for r in rows]
307
+
308
+
309
+ def session_get(session_id: str) -> dict[str, Any] | None:
310
+ row = get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
311
+ return dict(row) if row else None
312
+
313
+
314
+ def session_delete(session_id: str) -> bool:
315
+ cur = get_conn().execute("DELETE FROM sessions WHERE id = ?", (session_id,))
316
+ return cur.rowcount > 0
317
+
318
+
319
+ def message_insert(msg: dict[str, Any]) -> None:
320
+ now = msg.get("created_at", time.time())
321
+ conn = get_conn()
322
+ with transaction():
323
+ conn.execute(
324
+ """
325
+ INSERT INTO messages (id, session_id, role, content, citations_json, tool_calls_json, created_at)
326
+ VALUES (?, ?, ?, ?, ?, ?, ?)
327
+ """,
328
+ (
329
+ msg["id"], msg["session_id"], msg["role"], msg["content"],
330
+ json.dumps(msg.get("citations", []), ensure_ascii=False),
331
+ json.dumps(msg.get("tool_calls", []), ensure_ascii=False),
332
+ now,
333
+ ),
334
+ )
335
+ # 更新 session 计数
336
+ conn.execute(
337
+ "UPDATE sessions SET message_count = message_count + 1, updated_at = ? WHERE id = ?",
338
+ (now, msg["session_id"]),
339
+ )
340
+
341
+
342
+ def message_list_by_session(session_id: str, limit: int = 200) -> list[dict[str, Any]]:
343
+ rows = get_conn().execute(
344
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at LIMIT ?",
345
+ (session_id, limit),
346
+ ).fetchall()
347
+ out = []
348
+ for r in rows:
349
+ d = dict(r)
350
+ for k in ("citations_json", "tool_calls_json"):
351
+ v = d.pop(k, None)
352
+ if v:
353
+ try:
354
+ d[k.removesuffix("_json")] = json.loads(v)
355
+ except json.JSONDecodeError:
356
+ d[k.removesuffix("_json")] = []
357
+ out.append(d)
358
+ return out
app/models/schemas.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic IO schemas (API 请求/响应/事件)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ # ========== Chat ==========
10
+ class ChatRequest(BaseModel):
11
+ """非流式 / 流式聊天共用请求体."""
12
+
13
+ session_id: str = Field(default="default", description="会话 ID, 用于 LangGraph checkpoint 隔离")
14
+ message: str = Field(..., min_length=1, max_length=8000)
15
+ # 预留: 未来可加 doc_id 白名单 / 工具开关
16
+ doc_ids: list[str] | None = None
17
+ locale: Literal["zh", "en"] = "zh"
18
+
19
+
20
+ class ChatResponse(BaseModel):
21
+ """非流式响应."""
22
+
23
+ session_id: str
24
+ message_id: str
25
+ content: str
26
+ citations: list[dict[str, Any]] = Field(default_factory=list)
27
+ agent_steps: list[dict[str, Any]] = Field(default_factory=list)
28
+ usage: dict[str, int] = Field(default_factory=dict)
29
+ total_ms: int = 0
30
+
31
+
32
+ # ========== Documents ==========
33
+ class DocumentMeta(BaseModel):
34
+ id: str
35
+ filename: str
36
+ mime: str
37
+ size: int
38
+ page_count: int | None = None
39
+ chunk_count: int = 0
40
+ status: Literal["uploading", "parsing", "embedding", "ready", "failed"] = "uploading"
41
+ progress: int = 0
42
+ progress_label: str | None = None
43
+ error: str | None = None
44
+ created_at: float
45
+ sha256: str | None = None
46
+ parser: str | None = None
47
+ chunk_size: int | None = None
48
+ chunk_overlap: int | None = None
49
+ semantic_chunking: bool = False
50
+ contextual_retrieval: bool = False
51
+ meta: dict[str, Any] = Field(default_factory=dict)
52
+
53
+
54
+ class DocumentChunk(BaseModel):
55
+ id: str
56
+ doc_id: str
57
+ parent_id: str | None = None
58
+ chunk_index: int
59
+ text: str
60
+ token_count: int | None = None
61
+ page_no: int | None = None
62
+ heading: str | None = None
63
+ context_prefix: str | None = None
64
+ created_at: float
65
+
66
+
67
+ class IngestResult(BaseModel):
68
+ doc_id: str
69
+ chunk_count: int
70
+ status: Literal["ready", "duplicate"] = "ready"
71
+
72
+
73
+ # ========== Sessions ==========
74
+ class SessionMeta(BaseModel):
75
+ id: str
76
+ title: str | None = None
77
+ message_count: int = 0
78
+ created_at: float
79
+ updated_at: float
80
+
81
+
82
+ # ========== Health ==========
83
+ class HealthStatus(BaseModel):
84
+ status: Literal["ok", "degraded"]
85
+ llm: bool
86
+ persist: dict[str, Any]
87
+ chroma: bool = False
88
+ version: str
89
+
90
+
91
+ # ========== Streaming events (AG-UI 兼容) ==========
92
+ class StreamEvent(BaseModel):
93
+ """统一的 SSE 事件 envelope. 序列化为 `event: <type>\\ndata: <json>\\n\\n`."""
94
+
95
+ type: Literal[
96
+ "thinking", "agent_step", "retrieval", "tool_call",
97
+ "token", "citation", "progress", "done", "error",
98
+ ]
99
+ data: dict[str, Any] = Field(default_factory=dict)
100
+ ts: float = Field(default_factory=lambda: __import__("time").time())
app/services/__init__.py ADDED
File without changes
app/services/chunking.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """层次化分块器.
2
+
3
+ 策略:
4
+ 1. 优先按 markdown 标题拆分 (H1/H2/H3)
5
+ 2. 每个 section 内:
6
+ - 段间按 token 预算合并 (parent: 1500-2000t)
7
+ - 段内进一步切分 (child: 400-512t)
8
+ 3. 可选: 语义分块 (按 sentence 相似度二次切)
9
+ 4. 可选: Anthropic-style 上下文预置 (LLM 给每个 chunk 加 context prefix)
10
+
11
+ 输出:
12
+ - parents: 大块, 给 LLM 喂上下文用
13
+ - children: 小块, 给向量检索用
14
+ - 每个 child 带 parent_id, page_no, heading 字段
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import re
21
+ import time
22
+ import uuid
23
+ from dataclasses import dataclass, field
24
+
25
+ from app.config import settings
26
+ from app.llm.base import LLMMessage
27
+ from app.services.parsers.base_parser import ParsedDocument
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # 标题正则 (markdown)
33
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
34
+
35
+
36
+ @dataclass
37
+ class Chunk:
38
+ """分块结果 (child or parent)."""
39
+
40
+ id: str
41
+ doc_id: str
42
+ parent_id: str | None
43
+ chunk_index: int
44
+ text: str
45
+ token_count: int
46
+ page_no: int | None = None
47
+ heading: str | None = None
48
+ context_prefix: str | None = None
49
+ created_at: float = field(default_factory=time.time)
50
+
51
+
52
+ def _approx_token_count(s: str) -> int:
53
+ """粗估 token 数. 用 chars/2 当近似, 避免引 tiktoken."""
54
+ if not s:
55
+ return 0
56
+ # 中英文混合: CJK 算 1.5 token/char, 其它算 0.5
57
+ cjk = sum(1 for c in s if "一" <= c <= "鿿")
58
+ other = len(s) - cjk
59
+ return int(cjk * 1.5 + other * 0.5)
60
+
61
+
62
+ def _split_by_headings(markdown: str) -> list[tuple[str, str]]:
63
+ """按 markdown 标题拆分. 返回 [(heading, body), ...].
64
+
65
+ heading 为 None 表示标题前的引言段.
66
+ """
67
+ matches = list(_HEADING_RE.finditer(markdown))
68
+ if not matches:
69
+ return [(None, markdown)]
70
+
71
+ chunks: list[tuple[str, str]] = []
72
+ # 引言段
73
+ pre = markdown[: matches[0].start()].strip()
74
+ if pre:
75
+ chunks.append((None, pre))
76
+
77
+ for i, m in enumerate(matches):
78
+ heading = m.group(2).strip()
79
+ start = m.end()
80
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
81
+ body = markdown[start:end].strip()
82
+ if body:
83
+ chunks.append((heading, body))
84
+ return chunks
85
+
86
+
87
+ def _sliding_window(text: str, target: int, overlap: int) -> list[str]:
88
+ """按 token 估值的滑动窗口切分.
89
+
90
+ 简化版: 按段落切, 累积到 target token 就 flush, 与上一段重叠 overlap.
91
+ """
92
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
93
+ if not paragraphs:
94
+ return []
95
+
96
+ pieces: list[str] = []
97
+ buf: list[str] = []
98
+ buf_tokens = 0
99
+ for p in paragraphs:
100
+ pt = _approx_token_count(p)
101
+ # 单段超过 target, 硬切
102
+ if pt > target:
103
+ if buf:
104
+ pieces.append("\n\n".join(buf))
105
+ buf, buf_tokens = [], 0
106
+ # 字符级切
107
+ step = max(int(target * 2), 200) # target * 2 chars ≈ target tokens
108
+ for i in range(0, len(p), step):
109
+ pieces.append(p[i : i + step])
110
+ continue
111
+ if buf_tokens + pt > target and buf:
112
+ pieces.append("\n\n".join(buf))
113
+ # overlap: 保留最后一段
114
+ if overlap > 0 and buf:
115
+ last = buf[-1]
116
+ last_t = _approx_token_count(last)
117
+ if last_t <= overlap:
118
+ buf = [last]
119
+ buf_tokens = last_t
120
+ else:
121
+ buf, buf_tokens = [], 0
122
+ else:
123
+ buf, buf_tokens = [], 0
124
+ buf.append(p)
125
+ buf_tokens += pt
126
+ if buf:
127
+ pieces.append("\n\n".join(buf))
128
+ return pieces
129
+
130
+
131
+ def _split_by_paragraphs(text: str) -> list[str]:
132
+ return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
133
+
134
+
135
+ @dataclass
136
+ class ChunkingResult:
137
+ parents: list[Chunk]
138
+ children: list[Chunk]
139
+
140
+
141
+ def chunk_document(parsed: ParsedDocument, doc_id: str) -> ChunkingResult:
142
+ """对 ParsedDocument 做层次化分块.
143
+
144
+ 步骤:
145
+ 1. 按 markdown 标题分 section
146
+ 2. 每个 section 用滑动窗口切 parent (2000t, overlap 200)
147
+ 3. 每个 parent 内用滑动窗口切 child (512t, overlap 64)
148
+ 4. 按 page 编号 (按 parent 在 markdown 中的字符位置近似)
149
+ """
150
+ md = parsed.markdown
151
+ sections = _split_by_headings(md)
152
+ page_text = parsed.pages # 可能为空 (Marker)
153
+
154
+ parents: list[Chunk] = []
155
+ children: list[Chunk] = []
156
+ chunk_idx = 0
157
+
158
+ for heading, body in sections:
159
+ # 先切 parent
160
+ for ptext in _sliding_window(body, target=2000, overlap=200):
161
+ pid = uuid.uuid4().hex
162
+ ptoks = _approx_token_count(ptext)
163
+ # 估算 page_no
164
+ page_no = _estimate_page_no(ptext, parsed) if page_text else None
165
+ parent = Chunk(
166
+ id=pid,
167
+ doc_id=doc_id,
168
+ parent_id=None,
169
+ chunk_index=chunk_idx,
170
+ text=ptext,
171
+ token_count=ptoks,
172
+ page_no=page_no,
173
+ heading=heading,
174
+ )
175
+ parents.append(parent)
176
+ chunk_idx += 1
177
+
178
+ # 切 child
179
+ for ctext in _sliding_window(ptext, target=settings.chunk_size, overlap=settings.chunk_overlap):
180
+ cid = uuid.uuid4().hex
181
+ ctoks = _approx_token_count(ctext)
182
+ child = Chunk(
183
+ id=cid,
184
+ doc_id=doc_id,
185
+ parent_id=pid,
186
+ chunk_index=chunk_idx,
187
+ text=ctext,
188
+ token_count=ctoks,
189
+ page_no=page_no,
190
+ heading=heading,
191
+ )
192
+ children.append(child)
193
+ chunk_idx += 1
194
+
195
+ return ChunkingResult(parents=parents, children=children)
196
+
197
+
198
+ def _estimate_page_no(snippet: str, parsed: ParsedDocument) -> int | None:
199
+ """在 parsed.pages 里找包含 snippet 片段的页. 简单字符串包含."""
200
+ if not parsed.pages:
201
+ return None
202
+ # 取 snippet 前 50 字符作锚
203
+ anchor = snippet[:50].strip()
204
+ if not anchor:
205
+ return None
206
+ for p in parsed.pages:
207
+ if anchor in p.text:
208
+ return p.page_no
209
+ return None
210
+
211
+
212
+ # ========== 上下文预置 (Anthropic-style) ==========
213
+ _CONTEXT_PROMPT_TEMPLATE = (
214
+ "你是一名文档分块上下文标注助手。给定一段来自文档的 chunk (用 <chunk> 包裹) "
215
+ "以及文档标题, 请用 1-2 句中文描述该 chunk 在整篇文档中的上下文, 帮助后续检索时理解其含义。\n\n"
216
+ "要求:\n"
217
+ "- 简洁, 不超过 80 字\n"
218
+ "- 包含: 这是什么类型的内容 (定义 / 例子 / 数据 / 结论 / 步骤等), 在文档哪个章节\n"
219
+ "- 不要重复 chunk 原内容\n"
220
+ "- 仅输出描述本身, 不要加任何前缀\n\n"
221
+ "文档标题: {doc_title}\n"
222
+ "所属章节: {heading}\n"
223
+ "<chunk>\n{chunk}\n</chunk>\n\n"
224
+ "上下文描述:"
225
+ )
226
+
227
+
228
+ async def contextualize_chunks(
229
+ chunks: list[Chunk],
230
+ *,
231
+ doc_title: str = "未知文档",
232
+ max_concurrency: int = 4,
233
+ ) -> list[Chunk]:
234
+ """为每个 chunk 生成 context_prefix. Anthropic-style.
235
+
236
+ 优化:
237
+ - 并发限流 (max_concurrency), 不爆 LLM 限流
238
+ - 失败容错: 单条失败不中断, 留 prefix=None
239
+ """
240
+ if not settings.contextual_retrieval or not chunks:
241
+ return chunks
242
+
243
+ try:
244
+ from app.llm.factory import get_llm
245
+ except Exception as e: # noqa: BLE001
246
+ logger.warning("LLM not available for contextual retrieval: %s", e)
247
+ return chunks
248
+
249
+ sem = asyncio.Semaphore(max_concurrency)
250
+
251
+ async def _one(c: Chunk) -> None:
252
+ if c.context_prefix:
253
+ return
254
+ async with sem:
255
+ try:
256
+ llm = get_llm()
257
+ prompt = _CONTEXT_PROMPT_TEMPLATE.format(
258
+ doc_title=doc_title,
259
+ heading=c.heading or "无",
260
+ chunk=c.text[:1200], # 限长, 防 LLM token 爆
261
+ )
262
+ resp = await llm.chat(
263
+ messages=[
264
+ LLMMessage(role="system", content="你是上下文标注助手。"),
265
+ LLMMessage(role="user", content=prompt),
266
+ ],
267
+ temperature=0.2,
268
+ max_tokens=160,
269
+ )
270
+ prefix = (resp.content or "").strip().strip('"').strip("'")
271
+ if len(prefix) > 200:
272
+ prefix = prefix[:200]
273
+ c.context_prefix = prefix or None
274
+ except Exception as e: # noqa: BLE001
275
+ logger.warning("Contextual retrieval failed for chunk %s: %s", c.id[:8], e)
276
+
277
+ await asyncio.gather(*[_one(c) for c in chunks])
278
+ n_ok = sum(1 for c in chunks if c.context_prefix)
279
+ logger.info("Contextualized %d/%d chunks", n_ok, len(chunks))
280
+ return chunks
281
+
282
+
283
+ def chunk_to_embed_text(c: Chunk) -> str:
284
+ """向量化用的文本: context_prefix + heading + body."""
285
+ parts: list[str] = []
286
+ if c.context_prefix:
287
+ parts.append(f"[Context: {c.context_prefix}]")
288
+ if c.heading:
289
+ parts.append(f"[Section: {c.heading}]")
290
+ parts.append(c.text)
291
+ return "\n".join(parts)
app/services/embedding.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BGE-M3 嵌入服务.
2
+
3
+ BGE-M3 单模型产出三路表示 (FlagEmbedding 库):
4
+ - dense_vecs: 1024 维稠密向量 (语义)
5
+ - lexical_weights: SPLADE 风格稀疏权重 (精确词命中)
6
+ - colbert_vecs: 多向量 (每 token 一个 1024 维) (late interaction)
7
+
8
+ 设计:
9
+ - 全局单例 BGEM3FlagModel (首次调用懒加载)
10
+ - encode() 异步, 内部走线程池 (CPU 密集)
11
+ - encode_query() 给单 query 用, 自动加 instruction prefix
12
+ - 三路可独立开关 (ColBERT 占内存, 16GB RAM 下建议可降级)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import threading
19
+ from functools import lru_cache
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+
24
+ from app.config import settings
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # BGE-M3 query instruction (BAAI 官方推荐)
30
+ _QUERY_INSTRUCTION = (
31
+ "Represent this sentence for searching relevant passages: "
32
+ )
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def get_bge_model():
37
+ """懒加载 BGE-M3. CPU 上 fp16 自动降级为 fp32."""
38
+ from FlagEmbedding import BGEM3FlagModel
39
+
40
+ # CPU 上强制 fp32, 因为 FlagEmbedding 1.x 在 CPU + fp16 有数值问题
41
+ use_fp16 = settings.use_fp16 and settings.embedding_device in ("cuda", "mps")
42
+ logger.info(
43
+ "Loading BGE-M3: model=%s device=%s fp16=%s",
44
+ settings.embedding_model,
45
+ settings.embedding_device,
46
+ use_fp16,
47
+ )
48
+ model = BGEM3FlagModel(
49
+ settings.embedding_model,
50
+ use_fp16=use_fp16,
51
+ device=settings.embedding_device,
52
+ cache_dir=str(settings.hf_cache_dir),
53
+ )
54
+ # 立刻跑一次空 encode, 强制完成 meta→cpu 的 device 转移.
55
+ # 否则 FlagEmbedding.encode 内部的 self.model.to(device) 会撞到 meta tensor 报
56
+ # "Cannot copy out of meta tensor; no data!" (在 PyTorch 2.2 + transformers 4.57 组合下)
57
+ try:
58
+ with __import__("torch").no_grad():
59
+ model.encode(
60
+ ["warmup"],
61
+ return_dense=True,
62
+ return_sparse=settings.enable_colbert or True,
63
+ return_colbert_vecs=settings.enable_colbert,
64
+ batch_size=1,
65
+ max_length=8,
66
+ )
67
+ logger.info("BGE-M3 meta→cpu device transfer done")
68
+ except Exception as e: # noqa: BLE001
69
+ logger.warning("BGE-M3 warmup encode failed (will retry on first real call): %s", e)
70
+ return model
71
+
72
+
73
+ def warm_up() -> None:
74
+ """在 lifespan 启动时调用, 避免首次请求时的 30s 加载延迟."""
75
+ try:
76
+ get_bge_model()
77
+ logger.info("BGE-M3 warmed up")
78
+ except Exception as e: # noqa: BLE001
79
+ logger.warning("BGE-M3 warm-up failed: %s (will retry on first request)", e)
80
+
81
+
82
+ def _normalize_dense(vecs: np.ndarray) -> np.ndarray:
83
+ """L2 归一化, 余弦相似度 = 点积."""
84
+ norms = np.linalg.norm(vecs, axis=1, keepdims=True)
85
+ norms = np.maximum(norms, 1e-12)
86
+ return vecs / norms
87
+
88
+
89
+ class BGEM3Embedder:
90
+ """BGE-M3 嵌入器, 同时产出 dense / sparse / colbert."""
91
+
92
+ def __init__(self) -> None:
93
+ self._lock = threading.Lock()
94
+
95
+ async def encode(self, texts: list[str]) -> dict[str, Any]:
96
+ """编码多个文本.
97
+
98
+ Returns:
99
+ {
100
+ "dense": np.ndarray, # (N, 1024) 已 L2 归一化
101
+ "sparse": list[dict], # [{"token_id": weight, ...}, ...]
102
+ "colbert": list[np.ndarray], # [N x (T_i, 1024)]
103
+ }
104
+ """
105
+ if not texts:
106
+ return {"dense": np.zeros((0, 1024), dtype=np.float32), "sparse": [], "colbert": []}
107
+ loop = asyncio.get_running_loop()
108
+ return await loop.run_in_executor(None, self._encode_sync, texts)
109
+
110
+ def _encode_sync(self, texts: list[str]) -> dict[str, Any]:
111
+ model = get_bge_model()
112
+ with self._lock:
113
+ out = model.encode(
114
+ texts,
115
+ return_dense=True,
116
+ return_sparse=settings.enable_colbert or True, # sparse 一直开
117
+ return_colbert_vecs=settings.enable_colbert,
118
+ batch_size=12,
119
+ max_length=512,
120
+ )
121
+
122
+ dense = np.asarray(out["dense_vecs"], dtype=np.float32)
123
+ dense = _normalize_dense(dense)
124
+
125
+ # sparse: FlagEmbedding 返回 dict[token_str -> weight]; 标准化为 {token_id: weight}
126
+ sparse_list: list[dict[int, float]] = []
127
+ for sw in out.get("lexical_weights", []):
128
+ # sw: {token_str: weight} or {token_id(int): weight}
129
+ # FlagEmbedding 1.2+ 返回 token_id (int)
130
+ sparse_list.append({int(tid): float(w) for tid, w in sw.items()})
131
+
132
+ colbert_list: list[np.ndarray] = []
133
+ if settings.enable_colbert and "colbert_vecs" in out:
134
+ for v in out["colbert_vecs"]:
135
+ colbert_list.append(np.asarray(v, dtype=np.float32))
136
+
137
+ return {"dense": dense, "sparse": sparse_list, "colbert": colbert_list}
138
+
139
+ async def encode_query(self, query: str) -> dict[str, Any]:
140
+ """编码单条 query. 加 instruction prefix."""
141
+ return await self.encode([_QUERY_INSTRUCTION + query])
142
+
143
+ # ========== 便利方法 ==========
144
+ async def encode_dense_only(self, texts: list[str]) -> np.ndarray:
145
+ """只要 dense, 跳过 sparse/colbert (节省内存/时间)."""
146
+ if not texts:
147
+ return np.zeros((0, 1024), dtype=np.float32)
148
+ loop = asyncio.get_running_loop()
149
+ return await loop.run_in_executor(None, self._encode_dense_only_sync, texts)
150
+
151
+ def _encode_dense_only_sync(self, texts: list[str]) -> np.ndarray:
152
+ model = get_bge_model()
153
+ with self._lock:
154
+ out = model.encode(
155
+ [_QUERY_INSTRUCTION + t for t in texts],
156
+ return_dense=True,
157
+ return_sparse=False,
158
+ return_colbert_vecs=False,
159
+ batch_size=12,
160
+ max_length=512,
161
+ )
162
+ dense = np.asarray(out["dense_vecs"], dtype=np.float32)
163
+ return _normalize_dense(dense)
164
+
165
+
166
+ # 全局单例
167
+ _embedder: BGEM3Embedder | None = None
168
+
169
+
170
+ def get_embedder() -> BGEM3Embedder:
171
+ global _embedder
172
+ if _embedder is None:
173
+ _embedder = BGEM3Embedder()
174
+ return _embedder
app/services/ingestion.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """文档摄入编排器.
2
+
3
+ 完整流水线:
4
+ 1. 保存上传文件到 /data/uploads/{doc_id}/{filename}
5
+ 2. 计算 SHA256, 查 SQLite 是否有同 sha 的 ready 文档
6
+ - 有 -> 直接返回 {doc_id, status: "duplicate"}
7
+ 3. 解析 (智能路由: primary -> fallback)
8
+ 4. 分块 (层次化 + 可选语义 + 可选上下文预置)
9
+ 5. 向量化 (BGE-M3 三路)
10
+ 6. 入库:
11
+ - ChromaDB: dense + (colbert)
12
+ - 旁路: sparse (BM25-style)
13
+ - SQLite: document 行 + chunk 行
14
+ 7. 调度 persist.push 异步同步
15
+ 8. 返回 {doc_id, chunk_count, status: "ready"}
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import logging
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from app.config import settings
31
+ from app.core.errors import IngestionFailedError
32
+ from app.core.paths import upload_dir
33
+ from app.models import db
34
+ from app.models.schemas import IngestResult
35
+ from app.services.chunking import (
36
+ Chunk,
37
+ chunk_document,
38
+ chunk_to_embed_text,
39
+ contextualize_chunks,
40
+ )
41
+ from app.services.embedding import get_embedder
42
+ from app.services.parsers import parse_with_fallback
43
+ from app.services.parsers.base_parser import ParsedDocument
44
+ from app.services.persist import schedule_push
45
+ from app.services.vector_store import upsert_chunks
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # 单并发摄入队列 (避免大 PDF 同时跑爆 RAM)
51
+ _ingest_lock = asyncio.Lock()
52
+
53
+
54
+ @dataclass
55
+ class _IngestContext:
56
+ doc_id: str
57
+ file_path: Path
58
+ filename: str
59
+ mime: str
60
+ size: int
61
+ sha256: str
62
+ started: float
63
+
64
+
65
+ async def _save_upload(*, content: bytes, filename: str) -> tuple[Path, str, int, str]:
66
+ """保存上传字节到 /data/uploads/{doc_id}/{filename}, 返回 (path, mime, size, sha256)."""
67
+ doc_id = uuid.uuid4().hex
68
+ dest_dir = upload_dir() / doc_id
69
+ dest_dir.mkdir(parents=True, exist_ok=True)
70
+ # 防路径穿越
71
+ safe_name = Path(filename).name or "upload"
72
+ dest = dest_dir / safe_name
73
+ dest.write_bytes(content)
74
+
75
+ # MIME 简单嗅探
76
+ mime = _sniff_mime(safe_name, content[:2048])
77
+ sha = hashlib.sha256(content).hexdigest()
78
+ return dest, mime, len(content), sha
79
+
80
+
81
+ def _sniff_mime(filename: str, head: bytes) -> str:
82
+ ext = Path(filename).suffix.lower()
83
+ return {
84
+ ".pdf": "application/pdf",
85
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
86
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
87
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
88
+ ".png": "image/png",
89
+ ".jpg": "image/jpeg",
90
+ ".jpeg": "image/jpeg",
91
+ ".tiff": "image/tiff",
92
+ ".html": "text/html",
93
+ }.get(ext, "application/octet-stream")
94
+
95
+
96
+ async def ingest_bytes(
97
+ *,
98
+ content: bytes,
99
+ filename: str,
100
+ ) -> IngestResult:
101
+ """公开入口. 处理上传的原始字节."""
102
+ async with _ingest_lock:
103
+ return await _ingest_locked(content, filename)
104
+
105
+
106
+ async def _ingest_locked(content: bytes, filename: str) -> IngestResult:
107
+ started = time.time()
108
+ # 1. 保存
109
+ file_path, mime, size, sha = await _save_upload(content=content, filename=filename)
110
+ doc_id = file_path.parent.name # 用 upload 子目录名当 doc_id (一致)
111
+
112
+ logger.info("Ingest start: %s (%d bytes, sha=%s)", filename, size, sha[:12])
113
+
114
+ # 2. SHA256 幂等检查
115
+ existing = db.doc_find_by_sha256(sha)
116
+ if existing is not None:
117
+ logger.info("Duplicate by sha256: existing doc_id=%s", existing["id"])
118
+ # 删除刚写入的副本, 复用旧 doc
119
+ try:
120
+ file_path.unlink(missing_ok=True)
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+ return IngestResult(doc_id=existing["id"], chunk_count=existing.get("chunk_count", 0), status="duplicate")
124
+
125
+ # 3. 写入 SQLite (状态 uploading -> parsing)
126
+ db.doc_insert({
127
+ "id": doc_id,
128
+ "filename": Path(filename).name,
129
+ "mime": mime,
130
+ "size": size,
131
+ "sha256": sha,
132
+ "status": "parsing",
133
+ "progress": 0,
134
+ "progress_label": "开始解析...",
135
+ "parser": settings.parser_primary,
136
+ "chunk_size": settings.chunk_size,
137
+ "chunk_overlap": settings.chunk_overlap,
138
+ "semantic_chunking": settings.semantic_chunking,
139
+ "contextual_retrieval": settings.contextual_retrieval,
140
+ "created_at": started,
141
+ "updated_at": started,
142
+ "meta": {},
143
+ })
144
+
145
+ try:
146
+ # 4. 解析
147
+ db.doc_update_status(doc_id, "parsing", progress=10, progress_label="解析 PDF/Word 结构...")
148
+ parsed = await parse_with_fallback(file_path)
149
+ page_count = parsed.meta.get("page_count")
150
+ db.doc_update_status(doc_id, "parsing", page_count=page_count, progress=30, progress_label="解析完成, 准备分块")
151
+
152
+ # 5. 分块
153
+ chunking = chunk_document(parsed, doc_id=doc_id)
154
+ logger.info(
155
+ "Chunked: %d parents, %d children for %s",
156
+ len(chunking.parents), len(chunking.children), filename,
157
+ )
158
+ db.doc_update_status(
159
+ doc_id, "parsing",
160
+ progress=45,
161
+ progress_label=f"分块 {len(chunking.children)} 个 chunk",
162
+ )
163
+
164
+ # 6. 上下文预置 (可选, 走 LLM, 慢但提升召回)
165
+ if settings.contextual_retrieval and chunking.children:
166
+ db.doc_update_status(
167
+ doc_id, "embedding",
168
+ progress=50,
169
+ progress_label="上下文预标注 (走 LLM)...",
170
+ )
171
+ await contextualize_chunks(
172
+ chunking.children, doc_title=Path(filename).name, max_concurrency=4
173
+ )
174
+
175
+ # 7. 向量化
176
+ db.doc_update_status(doc_id, "embedding", progress=55, progress_label="向量化中...")
177
+ embedder = get_embedder()
178
+ embed_texts = [chunk_to_embed_text(c) for c in chunking.children]
179
+ if not embed_texts:
180
+ logger.warning("No chunks to embed for %s", filename)
181
+ db.doc_update_status(doc_id, "ready", chunk_count=0, progress=100, progress_label="完成 (空文档)")
182
+ return IngestResult(doc_id=doc_id, chunk_count=0, status="ready")
183
+
184
+ # batch 编码
185
+ BATCH = 32
186
+ all_dense: list[np.ndarray] = []
187
+ all_sparse: list[dict[int, float]] = []
188
+ all_colbert: list[np.ndarray] = []
189
+ n_total = len(embed_texts)
190
+ for i in range(0, n_total, BATCH):
191
+ batch = embed_texts[i : i + BATCH]
192
+ out = await embedder.encode(batch)
193
+ all_dense.append(out["dense"])
194
+ all_sparse.extend(out.get("sparse", []))
195
+ all_colbert.extend(out.get("colbert", []))
196
+ done = min(i + BATCH, n_total)
197
+ # 55% -> 95%
198
+ pct = 55 + int((done / n_total) * 40)
199
+ db.doc_update_status(
200
+ doc_id, "embedding",
201
+ progress=pct,
202
+ progress_label=f"向量化 {done}/{n_total}",
203
+ )
204
+ logger.info(
205
+ "Embedded %d/%d chunks", done, n_total
206
+ )
207
+
208
+ dense_arr = np.vstack(all_dense) if all_dense else np.zeros((0, 1024), dtype=np.float32)
209
+
210
+ # 8. 入库 ChromaDB + sparse 旁路
211
+ ids = [c.id for c in chunking.children]
212
+ documents = [c.text for c in chunking.children]
213
+ metadatas = [
214
+ {
215
+ "doc_id": c.doc_id,
216
+ "parent_id": c.parent_id or "",
217
+ "chunk_index": c.chunk_index,
218
+ "page_no": c.page_no or 0,
219
+ "heading": c.heading or "",
220
+ "context_prefix": c.context_prefix or "",
221
+ }
222
+ for c in chunking.children
223
+ ]
224
+ upsert_chunks(
225
+ ids=ids,
226
+ embeddings=dense_arr,
227
+ documents=documents,
228
+ metadatas=metadatas,
229
+ sparse_weights=all_sparse or None,
230
+ colbert_vecs=all_colbert or None,
231
+ )
232
+
233
+ # 9. 写入 SQLite chunks
234
+ db.chunk_insert_bulk([
235
+ {
236
+ "id": c.id,
237
+ "doc_id": c.doc_id,
238
+ "parent_id": c.parent_id,
239
+ "chunk_index": c.chunk_index,
240
+ "text": c.text,
241
+ "token_count": c.token_count,
242
+ "page_no": c.page_no,
243
+ "heading": c.heading,
244
+ "context_prefix": c.context_prefix,
245
+ "created_at": c.created_at,
246
+ }
247
+ for c in chunking.children
248
+ ])
249
+
250
+ # 10. 文档就绪
251
+ elapsed_ms = int((time.time() - started) * 1000)
252
+ db.doc_update_status(
253
+ doc_id, "ready",
254
+ chunk_count=len(chunking.children),
255
+ page_count=page_count,
256
+ progress=100,
257
+ progress_label="就绪",
258
+ )
259
+ logger.info(
260
+ "Ingest done: %s doc_id=%s chunks=%d %dms",
261
+ filename, doc_id, len(chunking.children), elapsed_ms,
262
+ )
263
+
264
+ # 11. 调度持久化推送
265
+ await schedule_push()
266
+
267
+ return IngestResult(
268
+ doc_id=doc_id,
269
+ chunk_count=len(chunking.children),
270
+ status="ready",
271
+ )
272
+
273
+ except Exception as e: # noqa: BLE001
274
+ logger.exception("Ingest failed for %s", filename)
275
+ try:
276
+ db.doc_update_status(doc_id, "failed", error=str(e)[:500])
277
+ except Exception: # noqa: BLE001
278
+ pass
279
+ raise IngestionFailedError(
280
+ f"Ingest failed: {e}", code="ingest_failed", detail={"doc_id": doc_id}
281
+ ) from e
282
+
283
+
284
+ async def delete_document(doc_id: str) -> bool:
285
+ """完整删除: ChromaDB + 旁路 sparse + SQLite + uploads/."""
286
+ # 先查, 拿文件路径
287
+ doc = db.doc_get(doc_id)
288
+ if doc is None:
289
+ return False
290
+
291
+ # 从 ChromaDB / sparse 旁路删
292
+ try:
293
+ from app.services.vector_store import delete_by_doc
294
+ delete_by_doc(doc_id)
295
+ except Exception as e: # noqa: BLE001
296
+ logger.warning("ChromaDB delete failed for %s: %s", doc_id, e)
297
+
298
+ # SQLite chunks + document
299
+ db.chunk_delete_by_doc(doc_id)
300
+ db.doc_delete(doc_id)
301
+
302
+ # 删上传原文件
303
+ if doc.get("sha256"):
304
+ # doc_id 即子目录名
305
+ up_dir = upload_dir() / doc_id
306
+ if up_dir.exists():
307
+ import shutil
308
+ shutil.rmtree(up_dir, ignore_errors=True)
309
+
310
+ await schedule_push()
311
+ return True
312
+
313
+
314
+ def list_documents(limit: int = 100) -> list[dict[str, Any]]:
315
+ return db.doc_list(limit=limit)
316
+
317
+
318
+ def get_document(doc_id: str) -> dict[str, Any] | None:
319
+ return db.doc_get(doc_id)
app/services/llm_cache.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM 响应缓存 (内存 LRU).
2
+
3
+ 为什么需要:
4
+ - 个人使用场景下, 同一问题反复问很常见
5
+ - 命中时直接跳过 LLM 调用 + 流式回放 token
6
+ - 节省 API 费用 + 缩短延迟
7
+
8
+ 设计:
9
+ - key = sha256( (query + top_doc_ids + temperature) ) -- 只缓存"标准问答", 不缓存工具调用
10
+ - value = 完整回答内容 + 引用 + 工具调用结果
11
+ - LRU 容量可配 (默认 200)
12
+ - 进程内, 重启清空 (避免引入 Redis)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import logging
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from cachetools import LRUCache
22
+
23
+ from app.config import settings
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class CachedAnswer:
30
+ content: str
31
+ citations: list[dict[str, Any]]
32
+ tool_calls: list[dict[str, Any]]
33
+ tokens: list[str] # 预切好的 token 序列, 流式回放
34
+
35
+
36
+ _cache: LRUCache | None = None
37
+ _hits = 0
38
+ _misses = 0
39
+
40
+
41
+ def _make_key(query: str, top_doc_ids: list[str], temperature: float) -> str:
42
+ """缓存 key. 包含 query + 命中文档 id + 温度, 避免不同上下文错命中."""
43
+ payload = f"{query.strip()}|{','.join(sorted(top_doc_ids))}|{temperature:.2f}"
44
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
45
+
46
+
47
+ def get_cache() -> LRUCache:
48
+ global _cache
49
+ if _cache is None:
50
+ size = settings.llm_cache_size if settings.llm_cache_enabled else 0
51
+ _cache = LRUCache(maxsize=size)
52
+ logger.info("LLM cache initialized: enabled=%s size=%d", settings.llm_cache_enabled, size)
53
+ return _cache
54
+
55
+
56
+ def lookup(query: str, top_doc_ids: list[str], temperature: float) -> CachedAnswer | None:
57
+ if not settings.llm_cache_enabled:
58
+ return None
59
+ key = _make_key(query, top_doc_ids, temperature)
60
+ hit = get_cache().get(key)
61
+ global _hits, _misses
62
+ if hit is not None:
63
+ _hits += 1
64
+ logger.debug("LLM cache HIT key=%s", key[:12])
65
+ else:
66
+ _misses += 1
67
+ return hit
68
+
69
+
70
+ def store(query: str, top_doc_ids: list[str], temperature: float, answer: CachedAnswer) -> None:
71
+ if not settings.llm_cache_enabled:
72
+ return
73
+ key = _make_key(query, top_doc_ids, temperature)
74
+ get_cache()[key] = answer
75
+ logger.debug("LLM cache STORE key=%s tokens=%d", key[:12], len(answer.tokens))
76
+
77
+
78
+ def stats() -> dict[str, Any]:
79
+ return {
80
+ "enabled": settings.llm_cache_enabled,
81
+ "size": len(get_cache()),
82
+ "max_size": get_cache().maxsize,
83
+ "hits": _hits,
84
+ "misses": _misses,
85
+ "hit_rate": (_hits / max(_hits + _misses, 1)),
86
+ }
87
+
88
+
89
+ def clear() -> None:
90
+ global _cache, _hits, _misses
91
+ _cache = None
92
+ _hits = 0
93
+ _misses = 0
94
+ logger.info("LLM cache cleared")
app/services/parsers/__init__.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser 工厂: 智能路由 + 降级链.
2
+
3
+ 智能路由逻辑:
4
+ 1. 查 settings.parser_primary (默认 docling)
5
+ 2. 主 parser 失败 -> 降级到 settings.parser_fallback
6
+ 3. 全部失败 -> 抛 IngestionFailedError
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ from app.config import settings
14
+ from app.core.errors import IngestionFailedError
15
+ from app.services.parsers.base_parser import BaseParser, ParsedDocument
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # 延迟注册: 实际 import 在 _build_parser 内
21
+ _REGISTRY: dict[str, type[BaseParser]] = {}
22
+
23
+
24
+ def _register_default() -> None:
25
+ """懒加载各 parser. 失败的 (未装) 仅记 warning, 不抛."""
26
+ if _REGISTRY:
27
+ return
28
+ try:
29
+ from app.services.parsers.docling_parser import DoclingParser
30
+ _REGISTRY["docling"] = DoclingParser
31
+ except ImportError as e:
32
+ logger.warning("docling not installed: %s", e)
33
+ try:
34
+ from app.services.parsers.marker_parser import MarkerParser
35
+ _REGISTRY["marker"] = MarkerParser
36
+ except ImportError as e:
37
+ logger.warning("marker-pdf not installed: %s", e)
38
+ # mineru / vlm 留 hook (按需装)
39
+
40
+
41
+ def _build_parser(name: str) -> BaseParser:
42
+ _register_default()
43
+ cls = _REGISTRY.get(name)
44
+ if cls is None:
45
+ raise IngestionFailedError(
46
+ f"Parser '{name}' is not installed. pip install docling / marker-pdf.",
47
+ code="parser_unavailable",
48
+ )
49
+ return cls()
50
+
51
+
52
+ def get_parser(name: str | None = None) -> BaseParser:
53
+ """获取单个 parser 实例 (按名字)."""
54
+ return _build_parser(name or settings.parser_primary)
55
+
56
+
57
+ def get_parser_chain() -> list[BaseParser]:
58
+ """按 settings 配置返回 [primary, fallback] 链."""
59
+ chain: list[BaseParser] = []
60
+ for name in (settings.parser_primary, settings.parser_fallback):
61
+ if name and name not in {p.name for p in chain}:
62
+ try:
63
+ chain.append(_build_parser(name))
64
+ except IngestionFailedError:
65
+ # 跳过未装的, 继续
66
+ continue
67
+ return chain
68
+
69
+
70
+ async def parse_with_fallback(file_path: Path) -> ParsedDocument:
71
+ """按链逐个尝试, 全部失败抛 IngestionFailedError."""
72
+ chain = get_parser_chain()
73
+ if not chain:
74
+ raise IngestionFailedError(
75
+ "No parser available. Install at least one of: docling, marker-pdf.",
76
+ code="no_parser_available",
77
+ )
78
+
79
+ # 选能处理该扩展名的 parser
80
+ candidates = [p for p in chain if p.can_handle(file_path)]
81
+ if not candidates:
82
+ raise IngestionFailedError(
83
+ f"No parser in chain supports {file_path.suffix}",
84
+ code="unsupported_format",
85
+ detail={"suffix": file_path.suffix, "chain": [p.name for p in chain]},
86
+ )
87
+
88
+ last_err: Exception | None = None
89
+ for parser in candidates:
90
+ try:
91
+ return await parser.parse(file_path)
92
+ except Exception as e: # noqa: BLE001
93
+ logger.warning("Parser %s failed for %s: %s", parser.name, file_path.name, e)
94
+ last_err = e
95
+
96
+ raise IngestionFailedError(
97
+ f"All parsers failed for {file_path.name}",
98
+ code="all_parsers_failed",
99
+ detail={"chain": [p.name for p in candidates]},
100
+ ) from last_err
101
+
102
+
103
+ __all__ = [
104
+ "BaseParser",
105
+ "ParsedDocument",
106
+ "PageContent",
107
+ "get_parser",
108
+ "get_parser_chain",
109
+ "parse_with_fallback",
110
+ ]
app/services/parsers/base_parser.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """文档解析抽象 + 数据结构.
2
+
3
+ 支持的输入: PDF, DOCX, PNG/JPG (含扫描件).
4
+
5
+ 设计:
6
+ - BaseParser 抽象 parse() -> ParsedDocument
7
+ - ParsedDocument 同时携带 markdown 全文 + 页面级结构 (含表格 / 图片)
8
+ - 各具体 parser (Docling / Marker / MinerU) 实现同一接口, 可热替换
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import abc
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass
18
+ class PageContent:
19
+ """单页内容. 至少要有 text, 可选带 tables / images."""
20
+
21
+ page_no: int
22
+ text: str
23
+ tables: list[dict] = field(default_factory=list) # {html, bbox, rows}
24
+ images: list[dict] = field(default_factory=list) # {bbox, caption, b64_thumb}
25
+ headings: list[str] = field(default_factory=list) # 当前页的标题
26
+
27
+
28
+ @dataclass
29
+ class ParsedDocument:
30
+ """解析后的统一文档结构.
31
+
32
+ 业务侧只用 markdown (全文) + pages (带页码引用) 两个核心字段.
33
+ """
34
+
35
+ markdown: str # 全文 markdown (LLM 友好)
36
+ pages: list[PageContent] = field(default_factory=list)
37
+ tables: list[dict] = field(default_factory=list) # 全部表格 (跨页表已合并)
38
+ images: list[dict] = field(default_factory=list)
39
+ meta: dict = field(default_factory=dict) # page_count, parser, elapsed_ms, ...
40
+
41
+
42
+ class BaseParser(abc.ABC):
43
+ """文档解析器抽象基类."""
44
+
45
+ name: str = "abstract"
46
+
47
+ @abc.abstractmethod
48
+ def supported_extensions(self) -> set[str]:
49
+ """形如 {'.pdf', '.docx'}."""
50
+
51
+ @abc.abstractmethod
52
+ async def parse(self, file_path: Path) -> ParsedDocument:
53
+ """同步 IO + 异步 wrapper. 重 CPU 解析可放线程池."""
54
+
55
+ def can_handle(self, file_path: Path) -> bool:
56
+ return file_path.suffix.lower() in self.supported_extensions()
app/services/parsers/docling_parser.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Docling parser - 主力.
2
+
3
+ 特性:
4
+ - 结构感知 (reading order / headings)
5
+ - 跨页表合并 (TableFormer)
6
+ - 内置 OCR (PaddleOCR 支持中英)
7
+ - DOCX / PPTX / 图片 / HTML 全支持
8
+
9
+ Docling API 在 2.x 期间变动较多, 本实现基于 2.30+ 兼容.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from app.config import settings
19
+ from app.services.parsers.base_parser import BaseParser, PageContent, ParsedDocument
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class DoclingParser(BaseParser):
25
+ name = "docling"
26
+
27
+ def supported_extensions(self) -> set[str]:
28
+ return {".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".tiff", ".html", ".xlsx"}
29
+
30
+ async def parse(self, file_path: Path) -> ParsedDocument:
31
+ loop = asyncio.get_running_loop()
32
+ return await loop.run_in_executor(None, self._parse_sync, file_path)
33
+
34
+ def _parse_sync(self, file_path: Path) -> ParsedDocument:
35
+ """实际解析. CPU 密集, 在线程池跑."""
36
+ # Force CPU device for Docling to prevent MPS NotImplementedError on Intel Mac
37
+ try:
38
+ import docling.utils.accelerator_utils
39
+ docling.utils.accelerator_utils.decide_device = lambda *args, **kwargs: "cpu"
40
+ except ImportError:
41
+ pass
42
+
43
+ # 延迟 import, 避免启动期未装 docling 时崩溃
44
+ from docling.document_converter import DocumentConverter, PdfFormatOption
45
+ from docling.datamodel.base_models import InputFormat
46
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
47
+
48
+ started = time.time()
49
+ logger.info("Docling parsing: %s", file_path.name)
50
+
51
+ from docling.datamodel.accelerator_options import AcceleratorOptions, AcceleratorDevice
52
+ opts = PdfPipelineOptions()
53
+ opts.do_ocr = settings.parser_enable_ocr
54
+ opts.do_table_structure = settings.parser_table_structure
55
+ opts.images_scale = 2.0
56
+ opts.artifacts_path = str(settings.data_dir / "docling_models")
57
+ opts.accelerator_options = AcceleratorOptions(device=AcceleratorDevice.CPU)
58
+
59
+ converter = DocumentConverter(
60
+ format_options={
61
+ InputFormat.PDF: PdfFormatOption(pipeline_options=opts),
62
+ }
63
+ )
64
+
65
+ try:
66
+ result = converter.convert(str(file_path))
67
+ except Exception as e: # noqa: BLE001
68
+ logger.exception("Docling parse failed: %s", e)
69
+ raise
70
+
71
+ doc = result.document
72
+
73
+ # 全文 markdown
74
+ markdown = doc.export_to_markdown()
75
+
76
+ # 页面级 (Docling 用 iterate_items / pages 属性, 视版本略有差异)
77
+ pages: list[PageContent] = []
78
+ try:
79
+ page_count = len(doc.pages) if hasattr(doc, "pages") else 0
80
+ for idx in range(page_count):
81
+ page = doc.pages[idx]
82
+ # 提取该页文本 (Docling 2.x 没有现成 API, 用 page-level export 近似)
83
+ page_md = ""
84
+ if hasattr(page, "export_to_markdown"):
85
+ try:
86
+ page_md = page.export_to_markdown()
87
+ except Exception: # noqa: BLE001
88
+ page_md = ""
89
+ pages.append(PageContent(
90
+ page_no=idx + 1,
91
+ text=page_md,
92
+ headings=[], # Docling 不直接给页级 headings, 留空
93
+ ))
94
+ except Exception as e: # noqa: BLE001
95
+ logger.warning("Docling page extraction partial: %s", e)
96
+
97
+ # 表格 (简化提取)
98
+ tables: list[dict] = []
99
+ try:
100
+ for t in (doc.tables or []):
101
+ tables.append({
102
+ "html": t.export_to_html() if hasattr(t, "export_to_html") else "",
103
+ "caption": getattr(t, "caption", None),
104
+ })
105
+ except Exception: # noqa: BLE001
106
+ pass
107
+
108
+ elapsed_ms = int((time.time() - started) * 1000)
109
+ logger.info("Docling done: %s pages, %s tables, %dms", len(pages), len(tables), elapsed_ms)
110
+
111
+ return ParsedDocument(
112
+ markdown=markdown,
113
+ pages=pages,
114
+ tables=tables,
115
+ images=[],
116
+ meta={
117
+ "parser": self.name,
118
+ "page_count": len(pages),
119
+ "elapsed_ms": elapsed_ms,
120
+ },
121
+ )
app/services/parsers/marker_parser.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Marker parser - 兜底 (Docling 失败时启用).
2
+
3
+ 特性:
4
+ - PDF -> Markdown 转换, 速度快
5
+ - 内置 surya-ocr, 支持 90+ 语言
6
+ - 不擅长复杂表格 / 跨页表 (不如 Docling), 但胜在稳定
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from app.services.parsers.base_parser import BaseParser, ParsedDocument
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class MarkerParser(BaseParser):
21
+ name = "marker"
22
+
23
+ def supported_extensions(self) -> set[str]:
24
+ # Marker 强项是 PDF; 其它类型建议直接 Docling
25
+ return {".pdf"}
26
+
27
+ async def parse(self, file_path: Path) -> ParsedDocument:
28
+ loop = asyncio.get_running_loop()
29
+ return await loop.run_in_executor(None, self._parse_sync, file_path)
30
+
31
+ def _parse_sync(self, file_path: Path) -> ParsedDocument:
32
+ # Marker 1.x 推荐用 marker's Python API 而非 CLI subprocess
33
+ from marker.converters.pdf import PdfConverter
34
+ from marker.models import create_model_dict
35
+ from marker.output import text_from_rendered
36
+
37
+ started = time.time()
38
+ logger.info("Marker parsing: %s", file_path.name)
39
+
40
+ converter = PdfConverter(artifact_dict=create_model_dict())
41
+ rendered = converter(str(file_path))
42
+ markdown, _, _ = text_from_rendered(rendered)
43
+
44
+ # Marker 不直接给 page-level 拆分, 走全文 markdown
45
+ elapsed_ms = int((time.time() - started) * 1000)
46
+ logger.info("Marker done: %dms", elapsed_ms)
47
+
48
+ # 尝试从 rendered 拿 metadata
49
+ meta = rendered.metadata if hasattr(rendered, "metadata") else {}
50
+ page_count = meta.get("page_stats", {}).get("total_pages") if isinstance(meta, dict) else None
51
+
52
+ return ParsedDocument(
53
+ markdown=markdown,
54
+ pages=[], # Marker 不分页
55
+ tables=[],
56
+ images=[],
57
+ meta={
58
+ "parser": self.name,
59
+ "page_count": page_count,
60
+ "elapsed_ms": elapsed_ms,
61
+ },
62
+ )
app/services/persist.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF Dataset repo 持久化同步.
2
+
3
+ 为什么需要: HF Spaces 免费版磁盘是临时的 (容器重启后 /data 内的非持久卷数据会丢).
4
+ 唯一免费的持久化方案是把 /data 同步到 HF Dataset repo (Git LFS).
5
+
6
+ 调用模式:
7
+ - lifespan startup: restore_from_hf()
8
+ - 每次写操作后: schedule_push() (异步, 不阻塞用户)
9
+ - lifespan shutdown: push_to_hf() (同步, 尽量)
10
+
11
+ 健壮性:
12
+ - 首次部署 (repo 不存在) → 捕获 RepositoryNotFoundError → 标记 "fresh_start"
13
+ - HF_TOKEN 缺失 → 跳过持久化, 降级为纯本地
14
+ - 网络错误 → 重试 3 次后放弃, 不阻塞业务
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import shutil
21
+ from pathlib import Path
22
+ from typing import Literal
23
+
24
+ from huggingface_hub import (
25
+ create_repo,
26
+ snapshot_download,
27
+ upload_folder,
28
+ )
29
+ from huggingface_hub.errors import RepositoryNotFoundError
30
+
31
+ from app.config import settings
32
+ from app.core.paths import data_dir, sqlite_dir, chroma_dir, upload_dir
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # 状态机: 持久化是否启用 / 启动模式
37
+ _state: dict[str, str | bool] = {
38
+ "mode": "disabled", # disabled | cold_restore | fresh_start
39
+ "last_push_at": 0.0,
40
+ "pending_push": False,
41
+ }
42
+
43
+
44
+ def persist_mode() -> Literal["disabled", "cold_restore", "fresh_start"]:
45
+ return _state["mode"] # type: ignore[return-value]
46
+
47
+
48
+ async def restore_from_hf() -> None:
49
+ """从 HF Dataset repo 拉取数据到本地.
50
+
51
+ 调用时机: FastAPI lifespan 启动.
52
+ """
53
+ if not settings.is_persist_enabled():
54
+ logger.info("Persistence disabled (HF_PERSIST_REPO or HF_TOKEN not set)")
55
+ _state["mode"] = "disabled"
56
+ return
57
+
58
+ repo_id = settings.hf_persist_repo
59
+ token = settings.hf_token.get_secret_value()
60
+ local_root = data_dir()
61
+ target_subdirs = ["chroma", "sqlite", "uploads"]
62
+
63
+ loop = asyncio.get_running_loop()
64
+ try:
65
+ await loop.run_in_executor(
66
+ None,
67
+ lambda: snapshot_download(
68
+ repo_id=repo_id,
69
+ repo_type="dataset",
70
+ local_dir=str(local_root),
71
+ token=token,
72
+ allow_patterns=[f"{d}/*" for d in target_subdirs] + target_subdirs,
73
+ ),
74
+ )
75
+ _state["mode"] = "cold_restore"
76
+ logger.info("Persisted data restored from %s", repo_id)
77
+ except RepositoryNotFoundError:
78
+ # 首次部署: repo 还没创建, 属正常情况
79
+ _state["mode"] = "fresh_start"
80
+ logger.info(
81
+ "Persist repo %s not found (first deploy?). "
82
+ "Will create on first push.",
83
+ repo_id,
84
+ )
85
+ except Exception as e: # noqa: BLE001
86
+ logger.error("Persist restore failed (will start fresh): %s", e)
87
+ _state["mode"] = "fresh_start"
88
+ # 不阻塞启动, 提示用户在 /readyz 看到降级状态
89
+
90
+
91
+ async def push_to_hf() -> None:
92
+ """同步推送本地数据到 HF Dataset repo. 阻塞."""
93
+ if not settings.is_persist_enabled():
94
+ return
95
+ if _state["mode"] == "fresh_start":
96
+ # 首次需要先 create_repo
97
+ await _ensure_repo_exists()
98
+
99
+ repo_id = settings.hf_persist_repo
100
+ token = settings.hf_token.get_secret_value()
101
+ local_root = data_dir()
102
+
103
+ loop = asyncio.get_running_loop()
104
+ try:
105
+ await loop.run_in_executor(
106
+ None,
107
+ lambda: upload_folder(
108
+ folder_path=str(local_root),
109
+ repo_id=repo_id,
110
+ repo_type="dataset",
111
+ token=token,
112
+ commit_message=f"sync {asyncio.get_running_loop().time():.0f}",
113
+ ignore_patterns=[".cache/*", "*.tmp", "*.lock"],
114
+ ),
115
+ )
116
+ _state["last_push_at"] = asyncio.get_running_loop().time()
117
+ _state["pending_push"] = False
118
+ logger.info("Persisted data pushed to %s", repo_id)
119
+ except Exception as e: # noqa: BLE001
120
+ logger.error("Persist push failed: %s", e)
121
+
122
+
123
+ async def schedule_push() -> None:
124
+ """异步推送, 不阻塞业务. 多次调用合并为一次 (简单去抖).
125
+
126
+ 适用: 摄入完成 / 删除文档后.
127
+ """
128
+ if not settings.is_persist_enabled():
129
+ return
130
+ if not settings.persist_on_write:
131
+ return
132
+ if _state["pending_push"]:
133
+ return # 已有 pending, 跳过
134
+
135
+ _state["pending_push"] = True
136
+
137
+ async def _delayed_push() -> None:
138
+ # 简单去抖: 延迟 30s, 把同一秒内的多次写合并
139
+ await asyncio.sleep(30)
140
+ if _state["pending_push"]:
141
+ await push_to_hf()
142
+
143
+ asyncio.create_task(_delayed_push())
144
+
145
+
146
+ async def _ensure_repo_exists() -> None:
147
+ """首次部署时自动创建 HF Dataset repo."""
148
+ repo_id = settings.hf_persist_repo
149
+ token = settings.hf_token.get_secret_value()
150
+ loop = asyncio.get_running_loop()
151
+ try:
152
+ await loop.run_in_executor(
153
+ None,
154
+ lambda: create_repo(
155
+ repo_id=repo_id,
156
+ repo_type="dataset",
157
+ token=token,
158
+ private=True,
159
+ exist_ok=True,
160
+ ),
161
+ )
162
+ logger.info("Created persist repo: %s", repo_id)
163
+ except Exception as e: # noqa: BLE001
164
+ logger.error("Failed to create persist repo: %s", e)
165
+
166
+
167
+ def persist_status() -> dict:
168
+ """供 /readyz 暴露持久化状态."""
169
+ return {
170
+ "enabled": settings.is_persist_enabled(),
171
+ "mode": _state["mode"],
172
+ "pending_push": _state["pending_push"],
173
+ "repo": settings.hf_persist_repo or None,
174
+ }
175
+
176
+
177
+ def _wipe_local_data() -> None:
178
+ """测试用: 清空本地 data 目录."""
179
+ for d in (sqlite_dir(), chroma_dir(), upload_dir()):
180
+ if d.exists():
181
+ shutil.rmtree(d, ignore_errors=True)
182
+
183
+
184
+ __all__ = [
185
+ "restore_from_hf",
186
+ "push_to_hf",
187
+ "schedule_push",
188
+ "persist_mode",
189
+ "persist_status",
190
+ ]
app/services/reranker.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BGE-reranker-v2-m3 精排服务.
2
+
3
+ 输入: query + list[RetrievalHit]
4
+ 输出: 同长度 list, 按相关性分数重排, 返回 top_n
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import threading
11
+ from functools import lru_cache
12
+
13
+ from app.config import settings
14
+ from app.services.vector_store import RetrievalHit
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @lru_cache(maxsize=1)
20
+ def get_reranker():
21
+ """懒加载 FlagReranker. CPU 上 fp16 强制 fp32."""
22
+ from FlagEmbedding import FlagReranker
23
+
24
+ use_fp16 = settings.use_fp16 and settings.embedding_device in ("cuda", "mps")
25
+ logger.info(
26
+ "Loading reranker: model=%s device=%s fp16=%s",
27
+ settings.reranker_model, settings.embedding_device, use_fp16,
28
+ )
29
+ model = FlagReranker(
30
+ settings.reranker_model,
31
+ use_fp16=use_fp16,
32
+ device=settings.embedding_device,
33
+ cache_dir=str(settings.hf_cache_dir),
34
+ )
35
+ # 预热一次 compute_score, 强制 meta→cpu device 转移 (避免后续 "Cannot copy out of meta tensor")
36
+ try:
37
+ with __import__("torch").no_grad():
38
+ model.compute_score([["warmup query", "warmup passage"]], normalize=True)
39
+ logger.info("Reranker meta→cpu device transfer done")
40
+ except Exception as e: # noqa: BLE001
41
+ logger.warning("Reranker warmup failed: %s", e)
42
+ return model
43
+
44
+
45
+ def warm_up() -> None:
46
+ try:
47
+ get_reranker()
48
+ logger.info("Reranker warmed up")
49
+ except Exception as e: # noqa: BLE001
50
+ logger.warning("Reranker warm-up failed: %s", e)
51
+
52
+
53
+ class RerankerService:
54
+ """封装 Reranker, 异步 + 限流 + 截断长文本."""
55
+
56
+ _sem = None # 全局信号量, 避免并发打爆 CPU
57
+
58
+ def __init__(self, max_concurrency: int = 2) -> None:
59
+ if RerankerService._sem is None:
60
+ RerankerService._sem = asyncio.Semaphore(max_concurrency)
61
+
62
+ async def rerank(
63
+ self,
64
+ query: str,
65
+ hits: list[RetrievalHit],
66
+ top_n: int | None = None,
67
+ max_length: int = 512,
68
+ ) -> list[RetrievalHit]:
69
+ """对 hits 按 query 相关性重排, 返回 top_n. 原顺序保留在 original_rank."""
70
+ if not hits:
71
+ return []
72
+ top_n = top_n or settings.rerank_top_n
73
+
74
+ # 截断过长的 text (BGE-reranker max 512 token)
75
+ pairs = [[query, (h.text or "")[: max_length * 2]] for h in hits]
76
+
77
+ loop = asyncio.get_running_loop()
78
+ scores = await loop.run_in_executor(None, self._rerank_sync, pairs)
79
+
80
+ # 记 original_rank + 新分数
81
+ for h, s in zip(hits, scores):
82
+ h.original_rank = hits.index(h)
83
+ h.rerank_score = float(s)
84
+
85
+ # 按 rerank_score 降序
86
+ reranked = sorted(hits, key=lambda h: h.rerank_score, reverse=True)
87
+ return reranked[:top_n]
88
+
89
+ def _rerank_sync(self, pairs: list[list[str]]) -> list[float]:
90
+ model = get_reranker()
91
+ # FlagReranker.compute_score 直接吃 list[list[str]]
92
+ scores = model.compute_score(pairs, normalize=True)
93
+ # 单条输入时返回 float, 统一为 list
94
+ if isinstance(scores, (int, float)):
95
+ scores = [float(scores)]
96
+ return [float(s) for s in scores]
97
+
98
+
99
+ # 进程内单例
100
+ _reranker: RerankerService | None = None
101
+
102
+
103
+ def get_reranker_service() -> RerankerService:
104
+ global _reranker
105
+ if _reranker is None:
106
+ _reranker = RerankerService()
107
+ return _reranker
app/services/vector_store.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """向量存储 + 混合检索.
2
+
3
+ ChromaDB v1.0 (Rust core) multi-vector 集合:
4
+ - 同一 collection 同时存 dense (1024d) + ColBERT (变长多向量)
5
+ - sparse 走旁路 BM25-style (用 BGE-M3 产出的 lexical weights 反序列化)
6
+
7
+ 混合检索 (三路 RRF):
8
+ - dense 走 ChromaDB HNSW
9
+ - sparse 走反序列化 + dot product
10
+ - colbert 走 late interaction max-sim
11
+ - 三路结果 RRF 融合
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ from app.config import settings
27
+ from app.core.paths import chroma_dir, sqlite_dir
28
+ from app.models import db
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # ========== 检索结果 ==========
34
+ @dataclass
35
+ class RetrievalHit:
36
+ chunk_id: str
37
+ doc_id: str
38
+ text: str
39
+ score: float
40
+ page_no: int | None
41
+ heading: str | None
42
+ context_prefix: str | None
43
+ meta: dict[str, Any]
44
+ # 用于 CRAG evaluate
45
+ sparse_score: float = 0.0
46
+ dense_score: float = 0.0
47
+ colbert_score: float = 0.0
48
+ # rerank 后填充
49
+ rerank_score: float = 0.0
50
+ original_rank: int = 0
51
+
52
+
53
+ # ========== Sparse 旁路索引 (SQLite) ==========
54
+ class SparseSidecar:
55
+ """存 sparse lexical weights, 用 sqlite 反查 + 打分.
56
+
57
+ 表 schema: chunk_sparse(chunk_id, weights_json)
58
+ """
59
+
60
+ def __init__(self, db_path: Path) -> None:
61
+ self.db_path = db_path
62
+ self._lock = threading.Lock()
63
+ self._ensure_table()
64
+
65
+ def _ensure_table(self) -> None:
66
+ with db.transaction() as _:
67
+ db.get_conn().execute(
68
+ """
69
+ CREATE TABLE IF NOT EXISTS chunk_sparse (
70
+ chunk_id TEXT PRIMARY KEY,
71
+ weights_json TEXT NOT NULL
72
+ )
73
+ """
74
+ )
75
+
76
+ def upsert_bulk(self, items: list[tuple[str, dict[int, float]]]) -> None:
77
+ if not items:
78
+ return
79
+ with self._lock, db.transaction():
80
+ db.get_conn().executemany(
81
+ "INSERT OR REPLACE INTO chunk_sparse (chunk_id, weights_json) VALUES (?, ?)",
82
+ [(cid, json.dumps({str(k): v for k, v in w.items()})) for cid, w in items],
83
+ )
84
+
85
+ def upsert_colbert(self, items: list[tuple[str, np.ndarray]]) -> None:
86
+ """ColBERT 多向量太占地方, 暂存为 .npy 文件, 路径记到 chunk_sparse 旁表."""
87
+ if not items:
88
+ return
89
+ from app.core.paths import data_dir
90
+
91
+ colbert_dir = data_dir() / "colbert"
92
+ colbert_dir.mkdir(parents=True, exist_ok=True)
93
+ with self._lock, db.transaction():
94
+ for cid, vec in items:
95
+ path = colbert_dir / f"{cid}.npy"
96
+ np.save(path, vec)
97
+ db.get_conn().execute(
98
+ "INSERT OR REPLACE INTO chunk_sparse (chunk_id, weights_json) VALUES (?, ?)",
99
+ (cid, json.dumps({"colbert_path": str(path.relative_to(data_dir()))})),
100
+ )
101
+
102
+ def get_sparse(self, chunk_id: str) -> dict[int, float] | None:
103
+ row = db.get_conn().execute(
104
+ "SELECT weights_json FROM chunk_sparse WHERE chunk_id = ? AND weights_json NOT LIKE '%colbert_path%'",
105
+ (chunk_id,),
106
+ ).fetchone()
107
+ if not row:
108
+ return None
109
+ try:
110
+ d = json.loads(row["weights_json"])
111
+ return {int(k): float(v) for k, v in d.items()}
112
+ except (json.JSONDecodeError, ValueError):
113
+ return None
114
+
115
+ def get_colbert_path(self, chunk_id: str) -> str | None:
116
+ row = db.get_conn().execute(
117
+ "SELECT weights_json FROM chunk_sparse WHERE chunk_id = ? AND weights_json LIKE '%colbert_path%'",
118
+ (chunk_id,),
119
+ ).fetchone()
120
+ if not row:
121
+ return None
122
+ try:
123
+ d = json.loads(row["weights_json"])
124
+ return d.get("colbert_path")
125
+ except json.JSONDecodeError:
126
+ return None
127
+
128
+ def score_sparse(
129
+ self, query_weights: dict[int, float], candidate_ids: list[str]
130
+ ) -> dict[str, float]:
131
+ """对 candidate 计算 sparse 分数 (q·d 内积). 0 表示完全无重叠."""
132
+ out: dict[str, float] = {}
133
+ if not query_weights:
134
+ return out
135
+ for cid in candidate_ids:
136
+ doc_w = self.get_sparse(cid)
137
+ if not doc_w:
138
+ out[cid] = 0.0
139
+ continue
140
+ # 公共 token 上的内积
141
+ s = 0.0
142
+ for tid, qw in query_weights.items():
143
+ dw = doc_w.get(tid)
144
+ if dw is not None:
145
+ s += qw * dw
146
+ out[cid] = s
147
+ return out
148
+
149
+ def delete_by_doc(self, doc_id: str) -> int:
150
+ # 通过 doc chunks 关联删除
151
+ rows = db.get_conn().execute(
152
+ "SELECT id FROM chunks WHERE doc_id = ?", (doc_id,)
153
+ ).fetchall()
154
+ if not rows:
155
+ return 0
156
+ ids = [r["id"] for r in rows]
157
+ cur = db.get_conn().execute(
158
+ f"DELETE FROM chunk_sparse WHERE chunk_id IN ({','.join('?' * len(ids))})",
159
+ ids,
160
+ )
161
+ return cur.rowcount
162
+
163
+
164
+ # ========== ChromaDB 客户端 ==========
165
+ _chroma_client = None
166
+ _chroma_collection = None
167
+ _sparse_sidecar: SparseSidecar | None = None
168
+
169
+
170
+ def get_chroma():
171
+ global _chroma_client, _chroma_collection, _sparse_sidecar
172
+ if _chroma_client is None:
173
+ import chromadb
174
+ from chromadb.config import Settings as ChromaSettings
175
+
176
+ chroma_dir()
177
+ _chroma_client = chromadb.PersistentClient(
178
+ path=str(settings.chroma_dir),
179
+ settings=ChromaSettings(anonymized_telemetry=False, allow_reset=False),
180
+ )
181
+ # ChromaDB v1.0+ 支持 multi-vector; 不指定 embedding_function (我们自己 embed)
182
+ _chroma_collection = _chroma_client.get_or_create_collection(
183
+ name=settings.chroma_collection,
184
+ metadata={"hnsw:space": "cosine"},
185
+ embedding_function=None,
186
+ )
187
+ # 旁路 sparse 索引
188
+ _sparse_sidecar = SparseSidecar(settings.sqlite_db_path)
189
+ logger.info(
190
+ "ChromaDB ready: dir=%s collection=%s",
191
+ settings.chroma_dir, settings.chroma_collection,
192
+ )
193
+ return _chroma_client, _chroma_collection, _sparse_sidecar
194
+
195
+
196
+ # ========== Upsert ==========
197
+ def upsert_chunks(
198
+ *,
199
+ ids: list[str],
200
+ embeddings: np.ndarray, # (N, 1024) dense
201
+ documents: list[str], # 文本
202
+ metadatas: list[dict[str, Any]],
203
+ sparse_weights: list[dict[int, float]] | None = None,
204
+ colbert_vecs: list[np.ndarray] | None = None,
205
+ ) -> None:
206
+ """写入 ChromaDB + 旁路 sparse/colbert."""
207
+ if not ids:
208
+ return
209
+ _, coll, sidecar = get_chroma()
210
+ coll.upsert(
211
+ ids=ids,
212
+ embeddings=embeddings.tolist(),
213
+ documents=documents,
214
+ metadatas=metadatas,
215
+ )
216
+ if sparse_weights:
217
+ sidecar.upsert_bulk(list(zip(ids, sparse_weights)))
218
+ if colbert_vecs and settings.enable_colbert:
219
+ sidecar.upsert_colbert(list(zip(ids, colbert_vecs)))
220
+
221
+
222
+ # ========== Query ==========
223
+ def query_dense(
224
+ query_emb: np.ndarray, k: int = 20, where: dict | None = None
225
+ ) -> list[tuple[str, float, dict]]:
226
+ _, coll, _ = get_chroma()
227
+ res = coll.query(
228
+ query_embeddings=[query_emb.tolist()],
229
+ n_results=k,
230
+ where=where,
231
+ include=["metadatas", "distances", "documents"],
232
+ )
233
+ if not res["ids"]:
234
+ return []
235
+ out: list[tuple[str, float, dict]] = []
236
+ for i, cid in enumerate(res["ids"][0]):
237
+ # cosine distance -> 转为 similarity
238
+ dist = res["distances"][0][i] if res["distances"] else 0.0
239
+ sim = 1.0 - dist
240
+ out.append((cid, sim, {
241
+ "text": res["documents"][0][i] if res["documents"] else "",
242
+ "meta": res["metadatas"][0][i] if res["metadatas"] else {},
243
+ }))
244
+ return out
245
+
246
+
247
+ def rrf_fuse(
248
+ *ranked_lists: list[tuple[str, float, dict]],
249
+ k: int = 60,
250
+ ) -> list[tuple[str, float, dict]]:
251
+ """Reciprocal Rank Fusion.
252
+
253
+ 每个 list 是 [(id, score, payload), ...], 排名越靠前 (index 0) 权重越高.
254
+ score = sum 1/(k + rank_i)
255
+ """
256
+ scores: dict[str, float] = {}
257
+ payloads: dict[str, dict] = {}
258
+ for ranked in ranked_lists:
259
+ for rank, (cid, _score, payload) in enumerate(ranked):
260
+ scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
261
+ if cid not in payloads:
262
+ payloads[cid] = payload
263
+ elif payload and payload.get("text"):
264
+ payloads[cid] = payload
265
+ out = sorted(scores.items(), key=lambda x: -x[1])
266
+ return [(cid, sc, payloads[cid]) for cid, sc in out]
267
+
268
+
269
+ def hybrid_query(
270
+ *,
271
+ query_emb: np.ndarray,
272
+ query_sparse: dict[int, float] | None = None,
273
+ query_colbert_emb: np.ndarray | None = None,
274
+ k: int = 20,
275
+ where: dict | None = None,
276
+ over_retrieve: int = 50,
277
+ ) -> list[RetrievalHit]:
278
+ """三路混合检索 + RRF.
279
+
280
+ Args:
281
+ query_emb: dense 向量 (1024d)
282
+ query_sparse: 稀疏权重
283
+ query_colbert_emb: (T, 1024) 多向量
284
+ k: 最终返回 top-k
285
+ over_retrieve: 每路多取一些再融合
286
+ """
287
+ started = time.time()
288
+
289
+ # 路 1: dense (ChromaDB HNSW)
290
+ dense = query_dense(query_emb, k=over_retrieve, where=where)
291
+
292
+ # 路 2: sparse (旁路 + 反查)
293
+ sparse: list[tuple[str, float, dict]] = []
294
+ if query_sparse:
295
+ _, _, sidecar = get_chroma()
296
+ cand_ids = [cid for cid, _, _ in dense] # dense top-N 作为候选, 避免全表
297
+ sparse_scores = sidecar.score_sparse(query_sparse, cand_ids)
298
+ # 按 sparse score 排序
299
+ sparse = sorted(
300
+ [
301
+ (cid, sparse_scores.get(cid, 0.0), {"text": "", "meta": {}})
302
+ for cid in cand_ids
303
+ ],
304
+ key=lambda x: -x[1],
305
+ )
306
+
307
+ # 路 3: colbert (late interaction)
308
+ colbert_ranked: list[tuple[str, float, dict]] = []
309
+ if settings.enable_colbert and query_colbert_emb is not None and len(query_colbert_emb) > 0:
310
+ from app.core.paths import data_dir
311
+ # 只对 dense top-N 计算 colbert
312
+ _, _, sidecar = get_chroma()
313
+ cand_ids = [cid for cid, _, _ in dense[:30]]
314
+ scored: list[tuple[str, float]] = []
315
+ for cid in cand_ids:
316
+ rel_path = sidecar.get_colbert_path(cid)
317
+ if not rel_path:
318
+ continue
319
+ full = data_dir() / rel_path
320
+ if not full.exists():
321
+ continue
322
+ try:
323
+ doc_vec = np.load(full)
324
+ except Exception: # noqa: BLE001
325
+ continue
326
+ # max-sim
327
+ sims = doc_vec @ query_colbert_emb.T # (T_doc, T_q)
328
+ if sims.size == 0:
329
+ continue
330
+ max_per_doc = sims.max(axis=0).mean() # mean of per-query-token max
331
+ scored.append((cid, float(max_per_doc)))
332
+ colbert_ranked = sorted(scored, key=lambda x: -x[1])
333
+ colbert_ranked = [(cid, s, {"text": "", "meta": {}}) for cid, s in colbert_ranked]
334
+
335
+ # RRF 融合
336
+ fused = rrf_fuse(dense, sparse, colbert_ranked, k=60)[:k]
337
+
338
+ # 构造 RetrievalHit
339
+ hits: list[RetrievalHit] = []
340
+ for cid, rrf_score, payload in fused:
341
+ meta = payload.get("meta", {})
342
+ hits.append(RetrievalHit(
343
+ chunk_id=cid,
344
+ doc_id=meta.get("doc_id", ""),
345
+ text=payload.get("text", ""),
346
+ score=rrf_score,
347
+ page_no=meta.get("page_no"),
348
+ heading=meta.get("heading"),
349
+ context_prefix=meta.get("context_prefix"),
350
+ meta=meta,
351
+ ))
352
+
353
+ logger.debug("hybrid_query returned %d hits in %dms", len(hits), int((time.time() - started) * 1000))
354
+ return hits
355
+
356
+
357
+ # ========== Delete ==========
358
+ def delete_by_doc(doc_id: str) -> int:
359
+ """从 ChromaDB + sparse 旁路一并删除."""
360
+ _, coll, sidecar = get_chroma()
361
+ # 先列 id (ChromaDB v1.0 用 where 过滤删除)
362
+ try:
363
+ coll.delete(where={"doc_id": doc_id})
364
+ except Exception as e: # noqa: BLE001
365
+ logger.warning("ChromaDB delete by where failed (%s), falling back to per-chunk", e)
366
+ # fallback: 列出来删
367
+ rows = db.get_conn().execute("SELECT id FROM chunks WHERE doc_id = ?", (doc_id,)).fetchall()
368
+ ids = [r["id"] for r in rows]
369
+ if ids:
370
+ coll.delete(ids=ids)
371
+ n = sidecar.delete_by_doc(doc_id)
372
+ return n
app/streaming/__init__.py ADDED
File without changes
app/streaming/events.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AG-UI 兼容事件类型常量 + helper.
2
+
3
+ AG-UI 协议: https://docs.ag-ui.com (CopilotKit 提出, 2025 起逐步成为 agent UI 事件标准)
4
+
5
+ 我们的 9 类事件:
6
+ - thinking: Agent 推理过程 / 决策说明
7
+ - agent_step: 节点生命周期 (running / done / error)
8
+ - retrieval: 命中文档 ID + 分数
9
+ - tool_call: 工具调用详情
10
+ - token: LLM delta
11
+ - citation: 引用片段
12
+ - progress: 整体进度 0-100
13
+ - done: 结束
14
+ - error: 错误
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any, Literal
20
+
21
+ # 事件类型常量 (防止拼写错)
22
+ EV_THINKING = "thinking"
23
+ EV_AGENT_STEP = "agent_step"
24
+ EV_RETRIEVAL = "retrieval"
25
+ EV_TOOL_CALL = "tool_call"
26
+ EV_TOKEN = "token"
27
+ EV_CITATION = "citation"
28
+ EV_PROGRESS = "progress"
29
+ EV_DONE = "done"
30
+ EV_ERROR = "error"
31
+
32
+ ALL_EVENTS: tuple[str, ...] = (
33
+ EV_THINKING, EV_AGENT_STEP, EV_RETRIEVAL, EV_TOOL_CALL,
34
+ EV_TOKEN, EV_CITATION, EV_PROGRESS, EV_DONE, EV_ERROR,
35
+ )
36
+
37
+
38
+ def sse_format(event: str, data: dict[str, Any]) -> str:
39
+ """格式化为标准 SSE 双行: `event: <type>\\ndata: <json>\\n\\n`."""
40
+ payload = json.dumps(data, ensure_ascii=False, default=str)
41
+ return f"event: {event}\ndata: {payload}\n\n"
42
+
43
+
44
+ def sse_heartbeat() -> str:
45
+ """保活注释, 避免代理超时. 不属于事件, 不会触发前端 listener."""
46
+ return ": keepalive\n\n"
47
+
48
+
49
+ def sse_done(usage: dict[str, int] | None = None, total_ms: int = 0) -> str:
50
+ return sse_format(EV_DONE, {"usage": usage or {}, "total_ms": total_ms})
51
+
52
+
53
+ def sse_error(
54
+ code: str,
55
+ message: str,
56
+ *,
57
+ retryable: bool = False,
58
+ detail: dict[str, Any] | None = None,
59
+ ) -> str:
60
+ return sse_format(
61
+ EV_ERROR,
62
+ {"code": code, "message": message, "retryable": retryable, "detail": detail or {}},
63
+ )
64
+
65
+
66
+ # 节点名常量 (在 LangGraph 里用到, 统一在此声明)
67
+ NODE_ROUTE = "route"
68
+ NODE_QUERY_REWRITE = "query_rewrite"
69
+ NODE_RETRIEVE = "retrieve"
70
+ NODE_RERANK = "rerank"
71
+ NODE_TOOL = "tool_executor"
72
+ NODE_ANSWER = "answer"
73
+ NODE_EVALUATE = "evaluate"
74
+
75
+ ALL_NODES: tuple[str, ...] = (
76
+ NODE_ROUTE, NODE_QUERY_REWRITE, NODE_RETRIEVE, NODE_RERANK,
77
+ NODE_TOOL, NODE_ANSWER, NODE_EVALUATE,
78
+ )
79
+
80
+
81
+ NodeName = Literal[
82
+ "route", "query_rewrite", "retrieve", "rerank",
83
+ "tool_executor", "answer", "evaluate",
84
+ ]
app/streaming/sse.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SSE generator 辅助. 提供 ping / retry / error 处理."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from collections.abc import AsyncIterator
8
+
9
+ from app.streaming.events import sse_format, sse_heartbeat
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def heartbeat_pinger(interval: float = 15.0) -> AsyncIterator[str]:
15
+ """无限流式发送 SSE 注释保活. 配合 `asyncio.Queue` 一起使用."""
16
+ try:
17
+ while True:
18
+ await asyncio.sleep(interval)
19
+ yield sse_heartbeat()
20
+ except asyncio.CancelledError:
21
+ return
22
+
23
+
24
+ async def event_stream(
25
+ queue: asyncio.Queue[tuple[str, dict] | None],
26
+ ) -> AsyncIterator[str]:
27
+ """从 asyncio.Queue 读取事件并序列化为 SSE. 收到 None 表示结束."""
28
+ while True:
29
+ item = await queue.get()
30
+ if item is None:
31
+ break
32
+ event, data = item
33
+ yield sse_format(event, data)
34
+
35
+
36
+ def safe_json(data: dict) -> str:
37
+ """防御性 JSON 序列化."""
38
+ return json.dumps(data, ensure_ascii=False, default=str)
pyproject.toml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ai-chatbot-backend"
7
+ version = "0.1.0"
8
+ description = "Agentic multimodal RAG customer service backend"
9
+ requires-python = ">=3.11"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "You" }]
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["."]
15
+ include = ["app*"]
16
+
17
+ [tool.pytest.ini_options]
18
+ asyncio_mode = "auto"
19
+ testpaths = ["tests"]
20
+ python_files = ["test_*.py"]
21
+ addopts = "-v --tb=short"
22
+
23
+ [tool.ruff]
24
+ line-length = 100
25
+ target-version = "py311"
26
+
27
+ [tool.ruff.lint]
28
+ select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
29
+ ignore = ["E501"]
30
+
31
+ [tool.mypy]
32
+ python_version = "3.11"
33
+ strict = true
34
+ ignore_missing_imports = true
requirements.txt ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ========== Web 框架 ==========
2
+ fastapi>=0.115,<1.0
3
+ uvicorn[standard]>=0.32,<1.0
4
+ python-multipart>=0.0.12 # 文件上传
5
+ sse-starlette>=2.1.3 # SSE 增强
6
+
7
+ # ========== 配置 ==========
8
+ pydantic>=2.9,<3.0
9
+ pydantic-settings>=2.5,<3.0
10
+ python-dotenv>=1.0
11
+
12
+ # ========== LLM 抽象 (OpenAI 兼容) ==========
13
+ openai>=1.60,<2.0 # MiniMax-M3 / OpenAI / 兼容 provider 共用
14
+ httpx>=0.27 # AsyncOpenAI 依赖
15
+
16
+ # ========== Agent / LangGraph ==========
17
+ langgraph>=0.3,<1.0
18
+ langchain-core>=0.3,<1.0
19
+ langgraph-checkpoint-sqlite>=2.0 # 实施前核对最新稳定版
20
+ pydantic-ai>=0.2,<1.0
21
+
22
+ # ========== 文档解析 ==========
23
+ docling>=2.30,<2.74 # 主力:结构感知 + 跨页表 + OCR
24
+ # marker-pdf>=1.10 # 兜底:文字密集型 PDF (Intel Mac 暂不支持)
25
+ # mineru>=0.22 # 可选:学术论文 / 复杂双栏 (按需安装)
26
+
27
+ # ========== Embedding / Reranker ==========
28
+ FlagEmbedding>=1.2 # BGE-M3 + bge-reranker-v2-m3
29
+ sentence-transformers>=3.0 # 备选 embedding 库
30
+ torch>=2.2 # FlagEmbedding 依赖
31
+
32
+ # ========== 向量库 ==========
33
+ chromadb>=0.5,<2.0 # 1.0+ Rust core
34
+ rank-bm25>=0.2.2 # BM25 备用 (当前用 BGE-M3 稀疏通路, 留作降级)
35
+
36
+ # ========== 持久化 ==========
37
+ huggingface_hub>=0.30 # snapshot_download / upload_folder
38
+
39
+ # ========== 分块 / NLP ==========
40
+ tiktoken>=0.7 # token 计数
41
+ nltk>=3.9 # 句子切分
42
+ tenacity>=9.0 # 重试
43
+ cachetools # LLM 缓存
44
+
45
+ # ========== 评估 / 可观测性 ==========
46
+ # ragas>=0.2 # RAG 质量离线评估
47
+ # deepeval>=1.0 # 质量门禁
48
+
49
+ # ========== 测试 ==========
50
+ pytest>=8.0
51
+ pytest-asyncio>=0.23
52
+ pytest-cov>=5.0
53
+ httpx>=0.27 # TestClient
54
+ respx>=0.21 # mock httpx