Spaces:
Sleeping
Sleeping
deploy-bot commited on
Commit ·
35983fa
1
Parent(s): e60e2f8
deploy: backend for appQQQ/ai-chatbot
Browse filesFastAPI + LangGraph + Docling + BGE-M3 + ChromaDB.
Initial release from shuaiwang888/AI-Chatbot main branch.
- .gitignore +45 -0
- Dockerfile +64 -0
- README.md +29 -7
- app/__init__.py +46 -0
- app/agents/__init__.py +1 -0
- app/agents/graph.py +124 -0
- app/agents/nodes.py +480 -0
- app/agents/prompts.py +68 -0
- app/agents/state.py +115 -0
- app/agents/tools.py +276 -0
- app/api/__init__.py +0 -0
- app/api/chat.py +241 -0
- app/api/documents.py +100 -0
- app/api/health.py +55 -0
- app/api/sessions.py +59 -0
- app/config.py +187 -0
- app/core/__init__.py +0 -0
- app/core/errors.py +71 -0
- app/core/logging.py +70 -0
- app/core/paths.py +45 -0
- app/deps.py +32 -0
- app/llm/__init__.py +5 -0
- app/llm/base.py +97 -0
- app/llm/factory.py +104 -0
- app/llm/minimax.py +239 -0
- app/main.py +131 -0
- app/models/__init__.py +0 -0
- app/models/db.py +358 -0
- app/models/schemas.py +100 -0
- app/services/__init__.py +0 -0
- app/services/chunking.py +291 -0
- app/services/embedding.py +174 -0
- app/services/ingestion.py +319 -0
- app/services/llm_cache.py +94 -0
- app/services/parsers/__init__.py +110 -0
- app/services/parsers/base_parser.py +56 -0
- app/services/parsers/docling_parser.py +121 -0
- app/services/parsers/marker_parser.py +62 -0
- app/services/persist.py +190 -0
- app/services/reranker.py +107 -0
- app/services/vector_store.py +372 -0
- app/streaming/__init__.py +0 -0
- app/streaming/events.py +84 -0
- app/streaming/sse.py +38 -0
- pyproject.toml +34 -0
- requirements.txt +54 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
| 8 |
-
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|