feat: QYLAW 法律法规知识库
- 语义检索(FAISS + embedding)+ 精确查找(法规名+条号) - RAG 问答(SSE 流式,支持 thinking 折叠显示) - 法规浏览(原文阅读) - 历史记录(检索+对话持久化到 SQLite) - 设置页(系统提示词/模板/LLM 参数可配置) - 检索质量评估脚本 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# 环境
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 数据(不提交)
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.faiss
|
||||||
|
law-kb-data/
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
tailwind-input.css
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# QYLAW 法律法规知识库 Dockerfile
|
||||||
|
# 基于 python:3.11-slim,纯 CPU(FAISS 不需 GPU)
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
LABEL maintainer="qy123"
|
||||||
|
LABEL description="QYLAW 法律法规知识库 — FAISS 语义检索 + RAG 问答"
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖(faiss-cpu 需要 libgomp)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 先复制 requirements 利用 Docker 缓存
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
|
||||||
|
# 环境变量默认值(容器内通过 --network host 访问 localhost)
|
||||||
|
ENV LAW_PACK_DIR=/data/law-pack-2026-07-01-markdown \
|
||||||
|
LAW_KB_DATA_DIR=/data/law-kb-data \
|
||||||
|
EMBEDDING_URL=http://localhost:8003/v1 \
|
||||||
|
LLM_URL=http://localhost:7000/v1 \
|
||||||
|
APP_PORT=8090
|
||||||
|
|
||||||
|
# 暴露端口(--network host 模式下仅作记录)
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8090/health')" || exit 1
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# QYLAW 法律法规知识库
|
||||||
|
|
||||||
|
基于 17291 篇现行有效法律法规的语义检索与 RAG 问答系统。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
114 (192.168.110.114)
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ law-kb (FastAPI :8090, Docker) │
|
||||||
|
│ --network host │
|
||||||
|
│ │
|
||||||
|
│ 静态页(/) + API(/api/*) │
|
||||||
|
│ FAISS + SQLite + 法规原文 │
|
||||||
|
│ │
|
||||||
|
│ → localhost:8003 embedding │
|
||||||
|
│ → localhost:7000 qwen35 │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
↑
|
||||||
|
本机浏览器 http://192.168.110.114:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **语义检索**:自然语言查询,返回相关法规条文,支持按类别/省份过滤
|
||||||
|
- **RAG 问答**:提问 → 检索相关条文 → LLM 生成带引用的答案(流式)
|
||||||
|
- **原文浏览**:按类别/省份浏览法规列表,查看完整原文
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 传输法规数据到 114
|
||||||
|
rsync -avz law-pack-2026-07-01-markdown/ nvidia@192.168.110.114:/data/law-pack-2026-07-01-markdown/
|
||||||
|
|
||||||
|
# 2. 构建索引
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v /data/law-pack-2026-07-01-markdown:/data/law-pack-2026-07-01-markdown:ro \
|
||||||
|
-v /data/law-kb-data:/data/law-kb-data \
|
||||||
|
law-kb:latest \
|
||||||
|
python scripts/build_index.py --mode full
|
||||||
|
|
||||||
|
# 3. 启动服务
|
||||||
|
docker run -d --name law-kb --network host --restart unless-stopped \
|
||||||
|
-v /data/law-pack-2026-07-01-markdown:/data/law-pack-2026-07-01-markdown:ro \
|
||||||
|
-v /data/law-kb-data:/data/law-kb-data \
|
||||||
|
law-kb:latest
|
||||||
|
|
||||||
|
# 4. 访问
|
||||||
|
open http://192.168.110.114:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
law-kb/
|
||||||
|
├── app/ # FastAPI 应用
|
||||||
|
│ ├── main.py # 入口
|
||||||
|
│ ├── config.py # 配置
|
||||||
|
│ ├── models.py # Pydantic 模型
|
||||||
|
│ ├── routers/ # API 路由
|
||||||
|
│ ├── services/ # 服务层
|
||||||
|
│ └── static/ # 前端静态文件
|
||||||
|
├── scripts/ # 索引构建脚本
|
||||||
|
├── tests/ # 测试
|
||||||
|
├── Dockerfile
|
||||||
|
├── requirements.txt
|
||||||
|
└── run.md # 运行手册
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 `run.md` 和 `pmdocs/`。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""QYLAW 法律法规知识库应用包"""
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""配置模块 — 路径、端口、外部服务地址"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ===== 路径配置 =====
|
||||||
|
# 法规原文目录(容器内挂载点,只读)
|
||||||
|
LAW_PACK_DIR = Path(os.getenv("LAW_PACK_DIR", "/data/law-pack-2026-07-01-markdown"))
|
||||||
|
|
||||||
|
# 索引数据目录(容器内挂载点,读写)
|
||||||
|
LAW_KB_DATA_DIR = Path(os.getenv("LAW_KB_DATA_DIR", "/data/law-kb-data"))
|
||||||
|
|
||||||
|
# FAISS 索引文件路径
|
||||||
|
FAISS_INDEX_PATH = LAW_KB_DATA_DIR / "faiss" / "index.faiss"
|
||||||
|
|
||||||
|
# SQLite metadata 数据库路径
|
||||||
|
SQLITE_PATH = LAW_KB_DATA_DIR / "metadata.db"
|
||||||
|
|
||||||
|
# 构建日志目录
|
||||||
|
LOGS_DIR = LAW_KB_DATA_DIR / "logs"
|
||||||
|
|
||||||
|
# 地方性法规区域映射文件
|
||||||
|
REGION_MAPPING_PATH = LAW_PACK_DIR / "地方性法规区域映射.json"
|
||||||
|
|
||||||
|
# 法规类别与目录名映射
|
||||||
|
CATEGORY_DIRS = {
|
||||||
|
"法律": "法律",
|
||||||
|
"行政法规": "行政法规",
|
||||||
|
"监察法规": "监察法规",
|
||||||
|
"司法解释": "司法解释",
|
||||||
|
"地方性法规": "地方性法规",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 外部服务地址 =====
|
||||||
|
# 容器内通过 localhost 访问(--network host 模式)
|
||||||
|
EMBEDDING_URL = os.getenv("EMBEDDING_URL", "http://localhost:8003/v1")
|
||||||
|
LLM_URL = os.getenv("LLM_URL", "http://localhost:7000/v1")
|
||||||
|
|
||||||
|
# embedding 模型名(与 114 上 vLLM served-model-name 一致)
|
||||||
|
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "qwen3-embedding-0.6b")
|
||||||
|
|
||||||
|
# LLM 模型名
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3.5-35b")
|
||||||
|
|
||||||
|
# ===== 索引参数 =====
|
||||||
|
# embedding 批量大小
|
||||||
|
EMBEDDING_BATCH_SIZE = int(os.getenv("EMBEDDING_BATCH_SIZE", "32"))
|
||||||
|
|
||||||
|
# FAISS HNSW 参数
|
||||||
|
HNSW_M = 32
|
||||||
|
HNSW_EF_CONSTRUCTION = 200
|
||||||
|
HNSW_EF_SEARCH = 64
|
||||||
|
|
||||||
|
# 检索默认 Top-K
|
||||||
|
DEFAULT_TOP_K = 20
|
||||||
|
|
||||||
|
# RAG 检索 Top-K
|
||||||
|
RAG_TOP_K = 10
|
||||||
|
|
||||||
|
# RAG 相似度阈值(低于此值不调用 LLM)
|
||||||
|
RAG_SIMILARITY_THRESHOLD = 0.3
|
||||||
|
|
||||||
|
# ===== API 参数 =====
|
||||||
|
# 查询最大长度
|
||||||
|
MAX_QUERY_LENGTH = 1000
|
||||||
|
|
||||||
|
# 分页默认值
|
||||||
|
DEFAULT_PAGE_SIZE = 20
|
||||||
|
MAX_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
# 浏览页分页
|
||||||
|
BROWSE_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
# ===== 服务端口 =====
|
||||||
|
APP_PORT = int(os.getenv("APP_PORT", "8090"))
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
"""FastAPI 应用入口 — 挂载静态页 + API 路由"""
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import APP_PORT, LAW_KB_DATA_DIR
|
||||||
|
from app.routers import search, rag, law, stats, prompts, history
|
||||||
|
from app.services.retriever import Retriever
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期:启动时加载索引,关闭时释放"""
|
||||||
|
# 启动:加载 FAISS 索引和 SQLite
|
||||||
|
retriever = Retriever.get_instance()
|
||||||
|
retriever.load()
|
||||||
|
yield
|
||||||
|
# 关闭:无需特殊处理
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="QYLAW 法律法规知识库",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_trace_id(request: Request, call_next):
|
||||||
|
"""为每个请求添加 trace_id"""
|
||||||
|
trace_id = request.headers.get("X-Trace-Id") or str(uuid.uuid4())[:8]
|
||||||
|
request.state.trace_id = trace_id
|
||||||
|
start = time.time()
|
||||||
|
response = await call_next(request)
|
||||||
|
elapsed = (time.time() - start) * 1000
|
||||||
|
response.headers["X-Trace-Id"] = trace_id
|
||||||
|
response.headers["X-Response-Time-ms"] = f"{elapsed:.0f}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# 注册 API 路由
|
||||||
|
app.include_router(search.router, prefix="/api", tags=["检索"])
|
||||||
|
app.include_router(rag.router, prefix="/api", tags=["RAG 问答"])
|
||||||
|
app.include_router(law.router, prefix="/api", tags=["法规浏览"])
|
||||||
|
app.include_router(stats.router, prefix="/api", tags=["统计"])
|
||||||
|
app.include_router(prompts.router, prefix="/api", tags=["提示词配置"])
|
||||||
|
app.include_router(history.router, prefix="/api", tags=["历史记录"])
|
||||||
|
|
||||||
|
|
||||||
|
# 静态文件
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_index() -> HTMLResponse:
|
||||||
|
"""返回 index.html(带 no-cache 头,防止浏览器缓存旧版)"""
|
||||||
|
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
|
return HTMLResponse(content=html, headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
"""主页"""
|
||||||
|
return _serve_index()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""健康检查"""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# 前端路由 catch-all:必须放在所有 API 路由和 /health 之后
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def spa_fallback(path: str):
|
||||||
|
"""SPA 路由兜底:/search, /rag, /browse, /history, /settings 返回 index.html"""
|
||||||
|
# 排除 API 和静态文件路径(已被前面的路由和 mount 处理)
|
||||||
|
if path.startswith("api/") or path.startswith("static/"):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return _serve_index()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
"""Pydantic 请求/响应模型"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 统一响应壳 =====
|
||||||
|
class ApiResponse(BaseModel):
|
||||||
|
"""统一 API 响应壳"""
|
||||||
|
code: int = 0
|
||||||
|
message: str = "ok"
|
||||||
|
data: Optional[dict] = None
|
||||||
|
trace_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 检索 =====
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
"""检索请求"""
|
||||||
|
query: str = Field(..., min_length=1, max_length=1000, description="查询文本")
|
||||||
|
category: Optional[str] = Field(None, description="法规类别过滤")
|
||||||
|
province: Optional[str] = Field(None, description="省份过滤(仅地方性法规)")
|
||||||
|
city: Optional[str] = Field(None, description="市级过滤(仅地方性法规)")
|
||||||
|
top_k: int = Field(20, ge=1, le=100, description="返回数量")
|
||||||
|
page: int = Field(1, ge=1, description="页码")
|
||||||
|
page_size: int = Field(20, ge=1, le=100, description="每页数量")
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultItem(BaseModel):
|
||||||
|
"""单条检索结果"""
|
||||||
|
law_id: int
|
||||||
|
law_name: str
|
||||||
|
category: str
|
||||||
|
chapter: Optional[str] = None
|
||||||
|
clause_no: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
score: float
|
||||||
|
file_path: str
|
||||||
|
province: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
publish_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponseData(BaseModel):
|
||||||
|
"""检索响应数据"""
|
||||||
|
results: List[SearchResultItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
# ===== RAG 问答 =====
|
||||||
|
class RagRequest(BaseModel):
|
||||||
|
"""RAG 问答请求"""
|
||||||
|
question: str = Field(..., min_length=1, max_length=1000, description="用户问题")
|
||||||
|
template: str = Field("simple", description="prompt 模板: simple/professional/compare")
|
||||||
|
category: Optional[str] = Field(None, description="法规类别过滤")
|
||||||
|
province: Optional[str] = Field(None, description="省份过滤")
|
||||||
|
system_prompt: Optional[str] = Field(None, description="自定义系统提示词,覆盖默认")
|
||||||
|
template_text: Optional[str] = Field(None, description="自定义模板正文,覆盖预设模板。需含 {context} 和 {question} 占位符")
|
||||||
|
|
||||||
|
|
||||||
|
class CitationItem(BaseModel):
|
||||||
|
"""RAG 引用条文"""
|
||||||
|
law_id: int
|
||||||
|
law_name: str
|
||||||
|
category: str
|
||||||
|
chapter: Optional[str] = None
|
||||||
|
clause_no: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
score: float
|
||||||
|
file_path: str
|
||||||
|
province: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 法规浏览 =====
|
||||||
|
class LawListItem(BaseModel):
|
||||||
|
"""法规列表项"""
|
||||||
|
law_id: int
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
publish_date: Optional[str] = None
|
||||||
|
province: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
clause_count: int
|
||||||
|
file_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class LawDetail(BaseModel):
|
||||||
|
"""法规详情"""
|
||||||
|
law_id: int
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
publish_date: Optional[str] = None
|
||||||
|
province: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
file_path: str
|
||||||
|
clauses: List[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 统计 =====
|
||||||
|
class StatsResponse(BaseModel):
|
||||||
|
"""索引统计"""
|
||||||
|
total_laws: int
|
||||||
|
total_clauses: int
|
||||||
|
category_stats: dict
|
||||||
|
index_built_at: Optional[str] = None
|
||||||
|
faiss_dim: Optional[int] = None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API 路由模块"""
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""历史记录 API — /api/history"""
|
||||||
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
|
|
||||||
|
from app.services import history as hist_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/search")
|
||||||
|
async def list_search_history(
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
):
|
||||||
|
"""获取检索历史列表(分页,最新在前)"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
data = hist_service.get_search_history(page, page_size)
|
||||||
|
return {"code": 0, "message": "ok", "data": data, "trace_id": trace_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/chat")
|
||||||
|
async def list_chat_history(
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
):
|
||||||
|
"""获取对话历史列表(分页,最新在前)"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
data = hist_service.get_chat_history(page, page_size)
|
||||||
|
return {"code": 0, "message": "ok", "data": data, "trace_id": trace_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/chat/{chat_id}")
|
||||||
|
async def get_chat_detail(chat_id: int, request: Request):
|
||||||
|
"""获取单条对话历史详情"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
detail = hist_service.get_chat_detail(chat_id)
|
||||||
|
if not detail:
|
||||||
|
raise HTTPException(status_code=404, detail="对话记录不存在")
|
||||||
|
return {"code": 0, "message": "ok", "data": detail, "trace_id": trace_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/search/{history_id}")
|
||||||
|
async def delete_search_history(history_id: int, request: Request):
|
||||||
|
"""删除单条检索历史"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
if hist_service.delete_search_history(history_id):
|
||||||
|
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||||
|
raise HTTPException(status_code=500, detail="删除失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/chat/{history_id}")
|
||||||
|
async def delete_chat_history(history_id: int, request: Request):
|
||||||
|
"""删除单条对话历史"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
if hist_service.delete_chat_history(history_id):
|
||||||
|
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||||
|
raise HTTPException(status_code=500, detail="删除失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/search")
|
||||||
|
async def clear_search_history(request: Request):
|
||||||
|
"""清空全部检索历史"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
if hist_service.clear_search_history():
|
||||||
|
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||||
|
raise HTTPException(status_code=500, detail="清空失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/chat")
|
||||||
|
async def clear_chat_history(request: Request):
|
||||||
|
"""清空全部对话历史"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
if hist_service.clear_chat_history():
|
||||||
|
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||||
|
raise HTTPException(status_code=500, detail="清空失败")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""法规浏览 API — /api/laws, /api/law/{law_id}"""
|
||||||
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.law_reader import LawReader
|
||||||
|
from app.config import BROWSE_PAGE_SIZE
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/laws")
|
||||||
|
async def list_laws(
|
||||||
|
request: Request,
|
||||||
|
category: Optional[str] = Query(None, description="法规类别过滤"),
|
||||||
|
province: Optional[str] = Query(None, description="省份过滤"),
|
||||||
|
keyword: Optional[str] = Query(None, description="法规名关键词"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200, description="每页数量"),
|
||||||
|
):
|
||||||
|
"""法规列表(分页 + 过滤)"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
reader = LawReader.get_instance()
|
||||||
|
result = reader.list_laws(
|
||||||
|
category=category,
|
||||||
|
province=province,
|
||||||
|
keyword=keyword,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": result,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/law/{law_id}")
|
||||||
|
async def get_law(law_id: int, request: Request):
|
||||||
|
"""法规详情(含原文 + 条文列表)"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
reader = LawReader.get_instance()
|
||||||
|
detail = reader.get_law(law_id)
|
||||||
|
if detail is None:
|
||||||
|
raise HTTPException(status_code=404, detail="法规不存在")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": detail,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""提示词配置 API — /api/prompts"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.prompt_config import (
|
||||||
|
get_all_configs,
|
||||||
|
set_config,
|
||||||
|
get_llm_params,
|
||||||
|
DEFAULTS,
|
||||||
|
TEMPLATE_KEY_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PromptUpdateRequest(BaseModel):
|
||||||
|
"""提示词更新请求"""
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
template_simple: Optional[str] = None
|
||||||
|
template_professional: Optional[str] = None
|
||||||
|
template_compare: Optional[str] = None
|
||||||
|
# LLM 参数
|
||||||
|
llm_thinking_enabled: Optional[str] = None # "true" / "false"
|
||||||
|
llm_max_tokens: Optional[str] = None # 字符串,后端转 int
|
||||||
|
llm_temperature: Optional[str] = None # 字符串,后端转 float
|
||||||
|
llm_thinking_budget: Optional[str] = None # 字符串,后端转 int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompts")
|
||||||
|
async def get_prompts(request: Request):
|
||||||
|
"""获取所有提示词配置(含默认值)"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
configs = get_all_configs()
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"configs": configs,
|
||||||
|
"defaults": DEFAULTS,
|
||||||
|
"template_keys": list(TEMPLATE_KEY_MAP.keys()),
|
||||||
|
"llm_params": get_llm_params(),
|
||||||
|
},
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/prompts")
|
||||||
|
async def update_prompts(req: PromptUpdateRequest, request: Request):
|
||||||
|
"""更新提示词配置(空字符串表示恢复默认)"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
updated = []
|
||||||
|
# 系统提示词
|
||||||
|
if req.system_prompt is not None:
|
||||||
|
if set_config("system_prompt", req.system_prompt):
|
||||||
|
updated.append("system_prompt")
|
||||||
|
# 模板正文
|
||||||
|
for tpl_key in ["template_simple", "template_professional", "template_compare"]:
|
||||||
|
val = getattr(req, tpl_key, None)
|
||||||
|
if val is not None:
|
||||||
|
if set_config(tpl_key, val):
|
||||||
|
updated.append(tpl_key)
|
||||||
|
# LLM 参数
|
||||||
|
for llm_key in ["llm_thinking_enabled", "llm_max_tokens", "llm_temperature", "llm_thinking_budget"]:
|
||||||
|
val = getattr(req, llm_key, None)
|
||||||
|
if val is not None:
|
||||||
|
if set_config(llm_key, val):
|
||||||
|
updated.append(llm_key)
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {"updated": updated},
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prompts/reset")
|
||||||
|
async def reset_prompts(request: Request):
|
||||||
|
"""重置所有提示词配置为默认值"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
for key in DEFAULTS.keys():
|
||||||
|
set_config(key, "")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {"reset": list(DEFAULTS.keys())},
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""RAG 问答 API — /api/rag(SSE 流式)"""
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.retriever import Retriever
|
||||||
|
from app.services.embedding import embed_text
|
||||||
|
from app.services.llm import stream_chat
|
||||||
|
from app.services.prompt_config import get_template_text, get_system_prompt
|
||||||
|
from app.services.history import add_chat_history, update_chat_history
|
||||||
|
from app.config import RAG_TOP_K, RAG_SIMILARITY_THRESHOLD, MAX_QUERY_LENGTH
|
||||||
|
from app.models import RagRequest
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_context(results: list) -> str:
|
||||||
|
"""将检索结果拼装为 context 文本"""
|
||||||
|
lines = []
|
||||||
|
for i, r in enumerate(results, 1):
|
||||||
|
meta = f"《{r['law_name']}》"
|
||||||
|
if r.get("clause_no"):
|
||||||
|
meta += f" {r['clause_no']}"
|
||||||
|
if r.get("chapter"):
|
||||||
|
meta += f"({r['chapter']})"
|
||||||
|
if r.get("province"):
|
||||||
|
meta += f" [{r['province']}]"
|
||||||
|
lines.append(f"{i}. {meta}\n{r['content']}")
|
||||||
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rag")
|
||||||
|
async def rag(req: RagRequest, request: Request):
|
||||||
|
"""RAG 问答(SSE 流式)
|
||||||
|
|
||||||
|
流程:向量化问题 → 检索 Top-10 → 检查相似度阈值 → 拼 prompt → 流式生成
|
||||||
|
"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
|
||||||
|
if len(req.question) > MAX_QUERY_LENGTH:
|
||||||
|
raise HTTPException(status_code=400, detail="问题过长")
|
||||||
|
|
||||||
|
# 向量化问题
|
||||||
|
try:
|
||||||
|
query_vec = await embed_text(req.question)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"embedding 服务不可用: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检索
|
||||||
|
retriever = Retriever.get_instance()
|
||||||
|
if not retriever.is_ready():
|
||||||
|
raise HTTPException(status_code=503, detail="索引未加载")
|
||||||
|
|
||||||
|
results = retriever.search(
|
||||||
|
query_vec=query_vec,
|
||||||
|
top_k=RAG_TOP_K,
|
||||||
|
category=req.category,
|
||||||
|
province=req.province,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 相似度阈值检查
|
||||||
|
if not results or results[0]["score"] < RAG_SIMILARITY_THRESHOLD:
|
||||||
|
async def no_result_stream():
|
||||||
|
yield f"data: {json.dumps({'type': 'no_result', 'message': '未找到相关法规,无法回答'}, ensure_ascii=False)}\n\n"
|
||||||
|
return StreamingResponse(no_result_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
# 拼 prompt(优先级:请求自定义 > 数据库配置 > 默认)
|
||||||
|
if req.template_text and "{context}" in req.template_text and "{question}" in req.template_text:
|
||||||
|
template = req.template_text
|
||||||
|
else:
|
||||||
|
template = get_template_text(req.template) # 从数据库读取
|
||||||
|
context = _build_context(results)
|
||||||
|
prompt = template.format(context=context, question=req.question)
|
||||||
|
|
||||||
|
# 系统提示词(优先级:请求自定义 > 数据库配置 > 默认)
|
||||||
|
system_prompt = req.system_prompt if req.system_prompt else get_system_prompt()
|
||||||
|
|
||||||
|
# LLM 参数(从数据库读取)
|
||||||
|
from app.services.prompt_config import get_llm_params
|
||||||
|
llm_params = get_llm_params()
|
||||||
|
|
||||||
|
# 引用条文(先发送)
|
||||||
|
citations = [
|
||||||
|
{
|
||||||
|
"law_id": r["law_id"],
|
||||||
|
"law_name": r["law_name"],
|
||||||
|
"category": r["category"],
|
||||||
|
"chapter": r.get("chapter"),
|
||||||
|
"clause_no": r.get("clause_no"),
|
||||||
|
"content": r["content"],
|
||||||
|
"score": r["score"],
|
||||||
|
"file_path": r["file_path"],
|
||||||
|
"province": r.get("province"),
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
# 先创建对话历史记录(status=streaming)
|
||||||
|
chat_id = add_chat_history(
|
||||||
|
question=req.question,
|
||||||
|
template=req.template,
|
||||||
|
category=req.category,
|
||||||
|
province=req.province,
|
||||||
|
thinking_enabled=llm_params["thinking_enabled"],
|
||||||
|
status="streaming",
|
||||||
|
)
|
||||||
|
# 发送 chat_id 给前端(用于关联历史记录)
|
||||||
|
yield f"data: {json.dumps({'type': 'chat_id', 'chat_id': chat_id}, ensure_ascii=False)}\n\n"
|
||||||
|
# 发送引用条文
|
||||||
|
yield f"data: {json.dumps({'type': 'citations', 'clauses': citations}, ensure_ascii=False)}\n\n"
|
||||||
|
# 发送 LLM 参数(前端用于显示 thinking 状态等)
|
||||||
|
yield f"data: {json.dumps({'type': 'params', 'thinking_enabled': llm_params['thinking_enabled']}, ensure_ascii=False)}\n\n"
|
||||||
|
# 流式生成答案(区分 thinking 和 answer)
|
||||||
|
thinking_acc = ""
|
||||||
|
answer_acc = ""
|
||||||
|
try:
|
||||||
|
async for chunk_type, chunk_content in stream_chat(
|
||||||
|
prompt,
|
||||||
|
system=system_prompt,
|
||||||
|
temperature=llm_params["temperature"],
|
||||||
|
max_tokens=llm_params["max_tokens"],
|
||||||
|
thinking_enabled=llm_params["thinking_enabled"],
|
||||||
|
thinking_budget=llm_params["thinking_budget"],
|
||||||
|
):
|
||||||
|
if chunk_type == "thinking":
|
||||||
|
thinking_acc += chunk_content
|
||||||
|
elif chunk_type == "answer":
|
||||||
|
answer_acc += chunk_content
|
||||||
|
yield f"data: {json.dumps({'type': chunk_type, 'content': chunk_content}, ensure_ascii=False)}\n\n"
|
||||||
|
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
||||||
|
# 流式完成,更新历史记录
|
||||||
|
update_chat_history(
|
||||||
|
chat_id=chat_id,
|
||||||
|
thinking=thinking_acc,
|
||||||
|
answer=answer_acc,
|
||||||
|
citations=json.dumps(citations, ensure_ascii=False),
|
||||||
|
status="done",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
# 记录错误状态
|
||||||
|
update_chat_history(
|
||||||
|
chat_id=chat_id,
|
||||||
|
thinking=thinking_acc,
|
||||||
|
answer=answer_acc,
|
||||||
|
citations=json.dumps(citations, ensure_ascii=False),
|
||||||
|
status="error",
|
||||||
|
)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""检索 API — /api/search"""
|
||||||
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.retriever import Retriever
|
||||||
|
from app.services.embedding import embed_text
|
||||||
|
from app.services.history import add_search_history
|
||||||
|
from app.config import MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search(
|
||||||
|
request: Request,
|
||||||
|
query: str = Query(..., min_length=1, max_length=MAX_QUERY_LENGTH, description="查询文本"),
|
||||||
|
mode: str = Query("semantic", description="检索模式:semantic(语义) / keyword(关键词精确)"),
|
||||||
|
category: Optional[str] = Query(None, description="法规类别过滤"),
|
||||||
|
province: Optional[str] = Query(None, description="省份过滤"),
|
||||||
|
city: Optional[str] = Query(None, description="市级过滤"),
|
||||||
|
top_k: int = Query(20, ge=1, le=100, description="返回数量"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
):
|
||||||
|
"""检索法规条文(语义检索或关键词精确搜索)
|
||||||
|
|
||||||
|
- mode=semantic: 向量化查询 → FAISS 检索 → metadata 过滤(默认)
|
||||||
|
- mode=keyword: 按法规名+条号精确匹配,支持"郑州市劳动用工条例 第三十二条"
|
||||||
|
"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
|
||||||
|
retriever = Retriever.get_instance()
|
||||||
|
if not retriever.is_ready():
|
||||||
|
raise HTTPException(status_code=503, detail="索引未加载,请等待或检查索引文件")
|
||||||
|
|
||||||
|
if mode == "keyword":
|
||||||
|
# 关键词精确搜索
|
||||||
|
results = retriever.keyword_search(
|
||||||
|
query=query,
|
||||||
|
top_k=top_k * 3 if (category or province or city) else top_k,
|
||||||
|
category=category,
|
||||||
|
province=province,
|
||||||
|
city=city,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 语义检索
|
||||||
|
try:
|
||||||
|
query_vec = await embed_text(query)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"embedding 服务不可用: {str(e)}",
|
||||||
|
)
|
||||||
|
fetch_k = top_k * 3 if (category or province or city) else top_k
|
||||||
|
results = retriever.search(
|
||||||
|
query_vec=query_vec,
|
||||||
|
top_k=fetch_k,
|
||||||
|
category=category,
|
||||||
|
province=province,
|
||||||
|
city=city,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
total = len(results)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
page_results = results[start:end]
|
||||||
|
|
||||||
|
# 记录检索历史
|
||||||
|
add_search_history(
|
||||||
|
query=query,
|
||||||
|
category=category,
|
||||||
|
province=province,
|
||||||
|
city=city,
|
||||||
|
result_count=total,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"results": page_results,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
},
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""统计 API — /api/stats"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from app.services.retriever import Retriever
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def stats(request: Request):
|
||||||
|
"""返回索引统计信息"""
|
||||||
|
trace_id = getattr(request.state, "trace_id", "")
|
||||||
|
retriever = Retriever.get_instance()
|
||||||
|
data = retriever.get_stats()
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": data,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""服务层模块"""
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""embedding 服务客户端 — 调用 114:8003 Qwen3-Embedding 服务"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import EMBEDDING_URL, EMBEDDING_MODEL, EMBEDDING_BATCH_SIZE
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> httpx.AsyncClient:
|
||||||
|
"""复用 httpx 客户端"""
|
||||||
|
global _client
|
||||||
|
if _client is None or _client.is_closed:
|
||||||
|
_client = httpx.AsyncClient(timeout=60)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_batch(texts: List[str], batch_size: int = EMBEDDING_BATCH_SIZE) -> List[List[float]]:
|
||||||
|
"""批量向量化
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: 文本列表
|
||||||
|
batch_size: 每批大小,默认 32
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
向量列表,顺序与输入一致
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: embedding 服务不可用或返回错误
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
client = _get_client()
|
||||||
|
all_vecs: List[List[float]] = []
|
||||||
|
|
||||||
|
for i in range(0, len(texts), batch_size):
|
||||||
|
batch = texts[i : i + batch_size]
|
||||||
|
payload = {
|
||||||
|
"model": EMBEDDING_MODEL,
|
||||||
|
"input": batch,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 重试 3 次,指数退避
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{EMBEDDING_URL}/embeddings",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
# vLLM embeddings 响应格式: {data: [{embedding: [...]}], ...}
|
||||||
|
vecs = [item["embedding"] for item in data["data"]]
|
||||||
|
all_vecs.extend(vecs)
|
||||||
|
last_err = None
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
wait = 2 ** attempt
|
||||||
|
logger.warning(f"embedding 批次 {i//batch_size} 第 {attempt+1} 次失败: {e}, {wait}s 后重试")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
if last_err is not None:
|
||||||
|
raise RuntimeError(f"embedding 服务调用失败(重试 3 次): {last_err}")
|
||||||
|
|
||||||
|
return all_vecs
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_text(text: str) -> List[float]:
|
||||||
|
"""单条文本向量化"""
|
||||||
|
vecs = await embed_batch([text])
|
||||||
|
return vecs[0]
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""历史记录服务 — 检索历史和对话历史持久化到 SQLite
|
||||||
|
|
||||||
|
表结构:
|
||||||
|
- search_history: 检索历史(查询词、过滤条件、结果数)
|
||||||
|
- chat_history: 对话历史(问题、模板、thinking、答案、引用条文)
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import SQLITE_PATH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn() -> sqlite3.Connection:
|
||||||
|
"""获取 SQLite 连接(自动建表)"""
|
||||||
|
SQLITE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(SQLITE_PATH), check_same_thread=False)
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
province TEXT,
|
||||||
|
city TEXT,
|
||||||
|
result_count INTEGER DEFAULT 0,
|
||||||
|
top_k INTEGER DEFAULT 20,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
template TEXT DEFAULT 'simple',
|
||||||
|
category TEXT,
|
||||||
|
province TEXT,
|
||||||
|
thinking TEXT,
|
||||||
|
answer TEXT,
|
||||||
|
citations TEXT,
|
||||||
|
thinking_enabled INTEGER DEFAULT 1,
|
||||||
|
status TEXT DEFAULT 'done',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_created
|
||||||
|
ON search_history(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_history_created
|
||||||
|
ON chat_history(created_at DESC);
|
||||||
|
""")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def add_search_history(
|
||||||
|
query: str,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
province: Optional[str] = None,
|
||||||
|
city: Optional[str] = None,
|
||||||
|
result_count: int = 0,
|
||||||
|
top_k: int = 20,
|
||||||
|
) -> int:
|
||||||
|
"""记录检索历史
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
记录 ID,失败返回 0
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO search_history
|
||||||
|
(query, category, province, city, result_count, top_k, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(query[:200], category, province, city, result_count, top_k, ts),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
rid = cursor.lastrowid
|
||||||
|
conn.close()
|
||||||
|
return rid
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"记录检索历史失败: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_chat_history(
|
||||||
|
question: str,
|
||||||
|
template: str = "simple",
|
||||||
|
category: Optional[str] = None,
|
||||||
|
province: Optional[str] = None,
|
||||||
|
thinking: str = "",
|
||||||
|
answer: str = "",
|
||||||
|
citations: str = "[]",
|
||||||
|
thinking_enabled: bool = True,
|
||||||
|
status: str = "done",
|
||||||
|
) -> int:
|
||||||
|
"""记录对话历史
|
||||||
|
|
||||||
|
Args:
|
||||||
|
citations: JSON 字符串,引用条文列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
记录 ID,失败返回 0
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO chat_history
|
||||||
|
(question, template, category, province, thinking, answer,
|
||||||
|
citations, thinking_enabled, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(question[:1000], template, category, province,
|
||||||
|
thinking, answer, citations, 1 if thinking_enabled else 0,
|
||||||
|
status, ts),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
rid = cursor.lastrowid
|
||||||
|
conn.close()
|
||||||
|
return rid
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"记录对话历史失败: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_chat_history(
|
||||||
|
chat_id: int,
|
||||||
|
thinking: str = "",
|
||||||
|
answer: str = "",
|
||||||
|
citations: str = "[]",
|
||||||
|
status: str = "done",
|
||||||
|
) -> bool:
|
||||||
|
"""更新对话历史(流式生成完成后更新完整内容)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 成功
|
||||||
|
"""
|
||||||
|
if not chat_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE chat_history
|
||||||
|
SET thinking = ?, answer = ?, citations = ?, status = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(thinking, answer, citations, status, chat_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新对话历史失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_search_history(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict:
|
||||||
|
"""获取检索历史列表(分页,最新在前)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"items": [...], "total": int, "page": int, "page_size": int}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM search_history").fetchone()[0]
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT id, query, category, province, city, result_count, top_k, created_at
|
||||||
|
FROM search_history
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
(page_size, offset),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": r[0], "query": r[1], "category": r[2], "province": r[3],
|
||||||
|
"city": r[4], "result_count": r[5], "top_k": r[6], "created_at": r[7],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取检索历史失败: {e}")
|
||||||
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_history(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict:
|
||||||
|
"""获取对话历史列表(分页,最新在前)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"items": [...], "total": int, "page": int, "page_size": int}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM chat_history").fetchone()[0]
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT id, question, template, category, province,
|
||||||
|
thinking, answer, citations, thinking_enabled, status, created_at
|
||||||
|
FROM chat_history
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
(page_size, offset),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
"id": r[0], "question": r[1], "template": r[2], "category": r[3],
|
||||||
|
"province": r[4], "thinking": r[5], "answer": r[6],
|
||||||
|
"citations": json.loads(r[7]) if r[7] else [],
|
||||||
|
"thinking_enabled": bool(r[8]), "status": r[9], "created_at": r[10],
|
||||||
|
})
|
||||||
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取对话历史失败: {e}")
|
||||||
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_detail(chat_id: int) -> Optional[dict]:
|
||||||
|
"""获取单条对话历史详情"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"""SELECT id, question, template, category, province,
|
||||||
|
thinking, answer, citations, thinking_enabled, status, created_at
|
||||||
|
FROM chat_history WHERE id = ?""",
|
||||||
|
(chat_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row[0], "question": row[1], "template": row[2], "category": row[3],
|
||||||
|
"province": row[4], "thinking": row[5], "answer": row[6],
|
||||||
|
"citations": json.loads(row[7]) if row[7] else [],
|
||||||
|
"thinking_enabled": bool(row[8]), "status": row[9], "created_at": row[10],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取对话详情失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_search_history(history_id: int) -> bool:
|
||||||
|
"""删除单条检索历史"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute("DELETE FROM search_history WHERE id = ?", (history_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除检索历史失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_chat_history(history_id: int) -> bool:
|
||||||
|
"""删除单条对话历史"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute("DELETE FROM chat_history WHERE id = ?", (history_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除对话历史失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_search_history() -> bool:
|
||||||
|
"""清空全部检索历史"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute("DELETE FROM search_history")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"清空检索历史失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_chat_history() -> bool:
|
||||||
|
"""清空全部对话历史"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute("DELETE FROM chat_history")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"清空对话历史失败: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""法规原文读取服务 — 从 law-pack 目录读取 markdown,从 SQLite 取列表"""
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import SQLITE_PATH, LAW_PACK_DIR
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LawReader:
|
||||||
|
"""法规原文读取单例"""
|
||||||
|
|
||||||
|
_instance: Optional["LawReader"] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "LawReader":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._conn: Optional[sqlite3.Connection] = None
|
||||||
|
|
||||||
|
def _ensure_conn(self):
|
||||||
|
if self._conn is None and SQLITE_PATH.exists():
|
||||||
|
self._conn = sqlite3.connect(str(SQLITE_PATH), check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
def list_laws(
|
||||||
|
self,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
province: Optional[str] = None,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""法规列表(分页 + 过滤)"""
|
||||||
|
self._ensure_conn()
|
||||||
|
if self._conn is None:
|
||||||
|
return {"results": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
where = []
|
||||||
|
params: List[Any] = []
|
||||||
|
if category:
|
||||||
|
where.append("category = ?")
|
||||||
|
params.append(category)
|
||||||
|
if province:
|
||||||
|
where.append("province = ?")
|
||||||
|
params.append(province)
|
||||||
|
if keyword:
|
||||||
|
where.append("name LIKE ?")
|
||||||
|
params.append(f"%{keyword}%")
|
||||||
|
|
||||||
|
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
|
|
||||||
|
# 总数
|
||||||
|
total = self._conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM laws {where_clause}", params
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = self._conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, name, category, publish_date, province, city, clause_count, file_path
|
||||||
|
FROM laws {where_clause}
|
||||||
|
ORDER BY category, name
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
params + [page_size, offset],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
"law_id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"category": row["category"],
|
||||||
|
"publish_date": row["publish_date"],
|
||||||
|
"province": row["province"],
|
||||||
|
"city": row["city"],
|
||||||
|
"clause_count": row["clause_count"],
|
||||||
|
"file_path": row["file_path"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": results,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_law(self, law_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""法规详情(含原文 + 条文列表)"""
|
||||||
|
self._ensure_conn()
|
||||||
|
if self._conn is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM laws WHERE id = ?", (law_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 读取原文(路径校验防穿越)
|
||||||
|
file_path = Path(row["file_path"])
|
||||||
|
try:
|
||||||
|
# 确保在 law-pack 目录内
|
||||||
|
abs_path = file_path.resolve()
|
||||||
|
pack_root = LAW_PACK_DIR.resolve()
|
||||||
|
if not str(abs_path).startswith(str(pack_root)):
|
||||||
|
logger.error(f"路径穿越尝试: {file_path}")
|
||||||
|
return None
|
||||||
|
content = abs_path.read_text(encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取原文失败 {file_path}: {e}")
|
||||||
|
content = f"[读取失败: {e}]"
|
||||||
|
|
||||||
|
# 条文列表
|
||||||
|
clauses = self._conn.execute(
|
||||||
|
"SELECT id, chapter, clause_no, content FROM clauses WHERE law_id = ? ORDER BY id",
|
||||||
|
(law_id,),
|
||||||
|
).fetchall()
|
||||||
|
clause_list = [
|
||||||
|
{
|
||||||
|
"clause_id": c["id"],
|
||||||
|
"chapter": c["chapter"],
|
||||||
|
"clause_no": c["clause_no"],
|
||||||
|
"content": c["content"],
|
||||||
|
}
|
||||||
|
for c in clauses
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"law_id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"category": row["category"],
|
||||||
|
"publish_date": row["publish_date"],
|
||||||
|
"province": row["province"],
|
||||||
|
"city": row["city"],
|
||||||
|
"content": content,
|
||||||
|
"file_path": row["file_path"],
|
||||||
|
"clauses": clause_list,
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""LLM 客户端 — 调用 114:7000 Qwen3.5-35B 服务(流式)"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import LLM_URL, LLM_MODEL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> httpx.AsyncClient:
|
||||||
|
global _client
|
||||||
|
if _client is None or _client.is_closed:
|
||||||
|
_client = httpx.AsyncClient(timeout=120)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SYSTEM_PROMPT = "你是法律助手,根据提供的法规条文回答问题,必须引用法规名和条号。"
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
prompt: str,
|
||||||
|
system: str | None = None,
|
||||||
|
temperature: float = 0.3,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
thinking_enabled: bool = True,
|
||||||
|
thinking_budget: int = 2048,
|
||||||
|
) -> AsyncGenerator[tuple[str, str], None]:
|
||||||
|
"""流式对话,区分 thinking 和 answer
|
||||||
|
|
||||||
|
模型输出格式:thinking 内容以 "Here's a thinking process:" 开头,
|
||||||
|
以 </thinking> 结尾,之后是正式回答。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 用户 prompt(已含 context)
|
||||||
|
system: system prompt
|
||||||
|
temperature: 温度
|
||||||
|
max_tokens: 最大生成 token(含 thinking)
|
||||||
|
thinking_enabled: 是否启用 thinking
|
||||||
|
thinking_budget: thinking 预算(thinking 最大 token 数)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
(type, content) 元组,type 为 "thinking" 或 "answer"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: LLM 服务不可用
|
||||||
|
"""
|
||||||
|
client = _get_client()
|
||||||
|
sys_content = system if system else DEFAULT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
# thinking 关闭时,通过 system prompt 补充指示
|
||||||
|
if not thinking_enabled:
|
||||||
|
sys_content += "\n\n注意:不要输出思考过程,直接给出答案。"
|
||||||
|
|
||||||
|
# payload:使用 chat_template_kwargs.enable_thinking 控制 thinking 开关
|
||||||
|
# 这是 Qwen3.5 vLLM 的原生参数,比 system prompt 更可靠
|
||||||
|
payload = {
|
||||||
|
"model": LLM_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": sys_content},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": True,
|
||||||
|
"chat_template_kwargs": {"enable_thinking": thinking_enabled},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 状态机:跟踪当前是否在 thinking 区域
|
||||||
|
in_thinking = thinking_enabled # thinking 开启时,输出通常以 thinking 开头
|
||||||
|
# 小缓冲:仅用于检测跨块的 thinking 结束标记
|
||||||
|
tail_buffer = ""
|
||||||
|
# Qwen3.5 的 thinking 结束标记: </think>
|
||||||
|
THINKING_END_TAG = "</think>"
|
||||||
|
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(2): # 重试 1 次
|
||||||
|
try:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{LLM_URL}/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
timeout=120,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line or not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
data_str = line[6:]
|
||||||
|
if data_str.strip() == "[DONE]":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
delta = chunk["choices"][0].get("delta", {})
|
||||||
|
content = delta.get("content", "")
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not thinking_enabled:
|
||||||
|
# thinking 关闭,全部作为 answer
|
||||||
|
yield ("answer", content)
|
||||||
|
else:
|
||||||
|
# thinking 开启,需要解析 THINKING_END_TAG 标记
|
||||||
|
if in_thinking:
|
||||||
|
if THINKING_END_TAG in content:
|
||||||
|
# 分割:前面是 thinking,后面是 answer
|
||||||
|
parts = content.split(THINKING_END_TAG, 1)
|
||||||
|
thinking_part = parts[0]
|
||||||
|
answer_part = parts[1] if len(parts) > 1 else ""
|
||||||
|
if thinking_part:
|
||||||
|
yield ("thinking", thinking_part)
|
||||||
|
if answer_part:
|
||||||
|
yield ("answer", answer_part.lstrip())
|
||||||
|
in_thinking = False
|
||||||
|
tail_buffer = ""
|
||||||
|
else:
|
||||||
|
# 检查是否是 THINKING_END_TAG 的前缀(跨块情况)
|
||||||
|
tail_buffer = (tail_buffer + content)[-12:]
|
||||||
|
# 检查 tail_buffer 是否是 THINKING_END_TAG 的前缀
|
||||||
|
is_prefix = any(
|
||||||
|
tail_buffer.endswith(THINKING_END_TAG[:k])
|
||||||
|
for k in range(1, len(THINKING_END_TAG))
|
||||||
|
)
|
||||||
|
if is_prefix:
|
||||||
|
continue
|
||||||
|
yield ("thinking", content)
|
||||||
|
else:
|
||||||
|
yield ("answer", content)
|
||||||
|
except (json.JSONDecodeError, KeyError, IndexError):
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
logger.warning(f"LLM 流式调用第 {attempt+1} 次失败: {e}")
|
||||||
|
|
||||||
|
raise RuntimeError(f"LLM 服务调用失败: {last_err}")
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""提示词配置服务 — 从 SQLite 读写系统提示词和模板正文
|
||||||
|
|
||||||
|
配置项(config_key):
|
||||||
|
- system_prompt: 系统提示词(覆盖默认)
|
||||||
|
- template_simple: 通俗解释模板正文
|
||||||
|
- template_professional: 专业分析模板正文
|
||||||
|
- template_compare: 对比条文模板正文
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import SQLITE_PATH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 默认值(与 rag.py 中 PROMPT_TEMPLATES 保持一致)
|
||||||
|
DEFAULTS = {
|
||||||
|
"system_prompt": "你是法律助手,根据提供的法规条文回答问题,必须引用法规名和条号。",
|
||||||
|
"template_simple": (
|
||||||
|
"你是一位耐心的法律科普助手。请根据以下法规条文,用通俗易懂的语言回答用户问题。\n"
|
||||||
|
"要求:\n"
|
||||||
|
"1. 避免法律术语,用日常语言解释\n"
|
||||||
|
'2. 必须引用依据的法规名和条号,格式如"根据《XX法》第X条"\n'
|
||||||
|
'3. 如果条文不足以回答,明确说明"根据现有法规无法完全回答"\n'
|
||||||
|
"4. 不要编造法规\n\n"
|
||||||
|
"【相关法规条文】\n{context}\n\n【用户问题】\n{question}"
|
||||||
|
),
|
||||||
|
"template_professional": (
|
||||||
|
"你是一位专业的法律分析助手。请根据以下法规条文,对用户问题进行专业分析。\n"
|
||||||
|
"要求:\n"
|
||||||
|
"1. 使用规范法律术语\n"
|
||||||
|
'2. 必须引用依据的法规名和条号,格式如"依据《XX法》第X条"\n'
|
||||||
|
"3. 分析条文的适用条件、法律后果\n"
|
||||||
|
"4. 如有多条相关,对比分析\n"
|
||||||
|
"5. 指出条文的适用边界和可能的争议点\n"
|
||||||
|
"6. 不要编造法规\n\n"
|
||||||
|
"【相关法规条文】\n{context}\n\n【用户问题】\n{question}"
|
||||||
|
),
|
||||||
|
"template_compare": (
|
||||||
|
"你是一位法规研究助手。请根据以下法规条文,对比分析不同法规对同一问题的规定。\n"
|
||||||
|
"要求:\n"
|
||||||
|
"1. 列出每条相关法规的具体规定\n"
|
||||||
|
"2. 对比规定的异同\n"
|
||||||
|
"3. 标注法规名、条号、发布日期\n"
|
||||||
|
"4. 指出适用范围差异(如全国性 vs 地方性)\n"
|
||||||
|
"5. 不要编造法规\n\n"
|
||||||
|
"【相关法规条文】\n{context}\n\n【用户问题】\n{question}"
|
||||||
|
),
|
||||||
|
# LLM 参数默认值
|
||||||
|
"llm_thinking_enabled": "true", # thinking 开关
|
||||||
|
"llm_max_tokens": "4096", # 最大生成 token 数
|
||||||
|
"llm_temperature": "0.3", # 温度
|
||||||
|
"llm_thinking_budget": "2048", # thinking 预算(thinking 最大 token 数)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 模板 key 与 rag.py PROMPT_TEMPLATES 的映射
|
||||||
|
TEMPLATE_KEY_MAP = {
|
||||||
|
"simple": "template_simple",
|
||||||
|
"professional": "template_professional",
|
||||||
|
"compare": "template_compare",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn() -> sqlite3.Connection:
|
||||||
|
"""获取 SQLite 连接(自动建表)"""
|
||||||
|
SQLITE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(SQLITE_PATH), check_same_thread=False)
|
||||||
|
# 自动建表(若数据库不存在或表不存在)
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
config_key TEXT NOT NULL UNIQUE,
|
||||||
|
config_value TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_configs() -> Dict[str, str]:
|
||||||
|
"""读取所有提示词配置,未配置的用默认值填充
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{config_key: config_value} 所有配置项(含默认值)
|
||||||
|
"""
|
||||||
|
configs = dict(DEFAULTS) # 先用默认值
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT config_key, config_value FROM prompt_configs"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
key, val = row[0], row[1]
|
||||||
|
if val: # 空值视为未配置,用默认
|
||||||
|
configs[key] = val
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取提示词配置失败,使用默认值: {e}")
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(key: str) -> str:
|
||||||
|
"""读取单个配置,未配置返回默认值"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT config_value FROM prompt_configs WHERE config_key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row and row[0]:
|
||||||
|
return row[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取配置 {key} 失败: {e}")
|
||||||
|
return DEFAULTS.get(key, "")
|
||||||
|
|
||||||
|
|
||||||
|
def set_config(key: str, value: str) -> bool:
|
||||||
|
"""写入单个配置(空值删除记录,回退到默认)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
if value: # 有值则 upsert
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO prompt_configs (config_key, config_value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(config_key) DO UPDATE SET
|
||||||
|
config_value = excluded.config_value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(key, value, ts),
|
||||||
|
)
|
||||||
|
else: # 空值则删除(回退默认)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM prompt_configs WHERE config_key = ?", (key,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"写入配置 {key} 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_text(template: str) -> str:
|
||||||
|
"""获取指定模板的正文
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: 模板名(simple/professional/compare)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模板正文(含 {context} 和 {question} 占位符)
|
||||||
|
"""
|
||||||
|
key = TEMPLATE_KEY_MAP.get(template, "template_simple")
|
||||||
|
return get_config(key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_params() -> dict:
|
||||||
|
"""获取 LLM 参数配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"thinking_enabled": bool, "max_tokens": int, "temperature": float, "thinking_budget": int}
|
||||||
|
"""
|
||||||
|
configs = get_all_configs()
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"thinking_enabled": configs.get("llm_thinking_enabled", "true").lower() == "true",
|
||||||
|
"max_tokens": int(configs.get("llm_max_tokens", "4096")),
|
||||||
|
"temperature": float(configs.get("llm_temperature", "0.3")),
|
||||||
|
"thinking_budget": int(configs.get("llm_thinking_budget", "2048")),
|
||||||
|
}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {
|
||||||
|
"thinking_enabled": True,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"thinking_budget": 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt() -> str:
|
||||||
|
"""获取系统提示词"""
|
||||||
|
return get_config("system_prompt")
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""FAISS 检索器 — 加载索引 + SQLite metadata,实现语义检索 + 过滤"""
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
FAISS_INDEX_PATH,
|
||||||
|
SQLITE_PATH,
|
||||||
|
HNSW_EF_SEARCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Retriever:
|
||||||
|
"""检索器单例"""
|
||||||
|
|
||||||
|
_instance: Optional["Retriever"] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "Retriever":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._faiss = None
|
||||||
|
self._conn: Optional[sqlite3.Connection] = None
|
||||||
|
self._dim: Optional[int] = None
|
||||||
|
self._ready = False
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return self._ready
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
"""加载 FAISS 索引和 SQLite"""
|
||||||
|
if not FAISS_INDEX_PATH.exists():
|
||||||
|
logger.warning(f"FAISS 索引不存在: {FAISS_INDEX_PATH}")
|
||||||
|
return
|
||||||
|
if not SQLITE_PATH.exists():
|
||||||
|
logger.warning(f"SQLite 不存在: {SQLITE_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
import faiss
|
||||||
|
|
||||||
|
logger.info(f"加载 FAISS 索引: {FAISS_INDEX_PATH}")
|
||||||
|
self._faiss = faiss.read_index(str(FAISS_INDEX_PATH))
|
||||||
|
self._dim = self._faiss.d
|
||||||
|
|
||||||
|
# 设置 HNSW 搜索参数
|
||||||
|
if hasattr(self._faiss, "hnsw"):
|
||||||
|
self._faiss.hnsw.efSearch = HNSW_EF_SEARCH
|
||||||
|
|
||||||
|
logger.info(f"加载 SQLite: {SQLITE_PATH}")
|
||||||
|
self._conn = sqlite3.connect(str(SQLITE_PATH), check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
self._ready = True
|
||||||
|
count = self._faiss.ntotal
|
||||||
|
logger.info(f"索引加载完成: {count} 向量, dim={self._dim}")
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query_vec: List[float],
|
||||||
|
top_k: int = 20,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
province: Optional[str] = None,
|
||||||
|
city: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""语义检索 + metadata 过滤
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_vec: 查询向量
|
||||||
|
top_k: 返回数量
|
||||||
|
category: 法规类别过滤
|
||||||
|
province: 省份过滤
|
||||||
|
city: 市级过滤
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
结果列表,每项含 law_id/law_name/category/chapter/clause_no/content/score/file_path/province
|
||||||
|
"""
|
||||||
|
if not self._ready:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# FAISS 检索(取 top_k * 3 用于过滤后仍有足够结果)
|
||||||
|
vec = np.array([query_vec], dtype=np.float32)
|
||||||
|
fetch_k = min(top_k * 3 if (category or province or city) else top_k, self._faiss.ntotal)
|
||||||
|
scores, indices = self._faiss.search(vec, fetch_k)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for score, faiss_idx in zip(scores[0], indices[0]):
|
||||||
|
if faiss_idx < 0:
|
||||||
|
continue
|
||||||
|
# 从 SQLite 取 metadata
|
||||||
|
row = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.law_id, c.chapter, c.clause_no, c.content, c.faiss_idx,
|
||||||
|
l.name, l.category, l.publish_date, l.file_path, l.province, l.city
|
||||||
|
FROM clauses c JOIN laws l ON c.law_id = l.id
|
||||||
|
WHERE c.faiss_idx = ?
|
||||||
|
""",
|
||||||
|
(int(faiss_idx),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# metadata 过滤
|
||||||
|
if category and row["category"] != category:
|
||||||
|
continue
|
||||||
|
if province and row["province"] != province:
|
||||||
|
continue
|
||||||
|
if city and row["city"] != city:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"clause_id": row["id"],
|
||||||
|
"law_id": row["law_id"],
|
||||||
|
"law_name": row["name"],
|
||||||
|
"category": row["category"],
|
||||||
|
"chapter": row["chapter"],
|
||||||
|
"clause_no": row["clause_no"],
|
||||||
|
"content": row["content"],
|
||||||
|
"score": float(score),
|
||||||
|
"file_path": row["file_path"],
|
||||||
|
"province": row["province"],
|
||||||
|
"city": row["city"],
|
||||||
|
"publish_date": row["publish_date"],
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= top_k:
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def keyword_search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
top_k: int = 20,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
province: Optional[str] = None,
|
||||||
|
city: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""关键词精确搜索(按法规名 + 条号 + 条文内容匹配)
|
||||||
|
|
||||||
|
支持"法规名 条号"格式(如"郑州市劳动用工条例 第三十二条"),
|
||||||
|
也支持纯关键词搜索条文内容。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
结果列表,格式与 search() 一致,score 为匹配相关度(非 FAISS 距离)
|
||||||
|
"""
|
||||||
|
if not self._ready or not query.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = query.strip()
|
||||||
|
# 尝试解析"法规名 条号"格式
|
||||||
|
# 中文条号模式:第X条
|
||||||
|
import re
|
||||||
|
clause_match = re.search(r'(第[一二三四五六七八九十百千零\d]+条)', query)
|
||||||
|
clause_no = clause_match.group(1) if clause_match else None
|
||||||
|
# 法规名 = 去掉条号后的部分
|
||||||
|
law_name_query = re.sub(r'\s*第[一二三四五六七八九十百千零\d]+条\s*', '', query).strip()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
if clause_no and law_name_query:
|
||||||
|
# 精确匹配:法规名 LIKE + 条号 =
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.law_id, c.chapter, c.clause_no, c.content, c.faiss_idx,
|
||||||
|
l.name, l.category, l.publish_date, l.file_path, l.province, l.city
|
||||||
|
FROM clauses c JOIN laws l ON c.law_id = l.id
|
||||||
|
WHERE l.name LIKE ? AND c.clause_no = ?
|
||||||
|
""",
|
||||||
|
(f"%{law_name_query}%", clause_no),
|
||||||
|
).fetchall()
|
||||||
|
elif clause_no:
|
||||||
|
# 只按条号搜索
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.law_id, c.chapter, c.clause_no, c.content, c.faiss_idx,
|
||||||
|
l.name, l.category, l.publish_date, l.file_path, l.province, l.city
|
||||||
|
FROM clauses c JOIN laws l ON c.law_id = l.id
|
||||||
|
WHERE c.clause_no = ?
|
||||||
|
""",
|
||||||
|
(clause_no,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
# 纯关键词:搜索法规名或条文内容
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.law_id, c.chapter, c.clause_no, c.content, c.faiss_idx,
|
||||||
|
l.name, l.category, l.publish_date, l.file_path, l.province, l.city
|
||||||
|
FROM clauses c JOIN laws l ON c.law_id = l.id
|
||||||
|
WHERE l.name LIKE ? OR c.content LIKE ?
|
||||||
|
""",
|
||||||
|
(f"%{query}%", f"%{query}%"),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
# metadata 过滤
|
||||||
|
if category and row["category"] != category:
|
||||||
|
continue
|
||||||
|
if province and row["province"] != province:
|
||||||
|
continue
|
||||||
|
if city and row["city"] != city:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"clause_id": row["id"],
|
||||||
|
"law_id": row["law_id"],
|
||||||
|
"law_name": row["name"],
|
||||||
|
"category": row["category"],
|
||||||
|
"chapter": row["chapter"],
|
||||||
|
"clause_no": row["clause_no"],
|
||||||
|
"content": row["content"],
|
||||||
|
"score": 0.0, # 关键词搜索无相似度分数
|
||||||
|
"file_path": row["file_path"],
|
||||||
|
"province": row["province"],
|
||||||
|
"city": row["city"],
|
||||||
|
"publish_date": row["publish_date"],
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= top_k:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"关键词搜索失败: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""返回索引统计"""
|
||||||
|
if not self._ready:
|
||||||
|
return {
|
||||||
|
"total_laws": 0,
|
||||||
|
"total_clauses": 0,
|
||||||
|
"category_stats": {},
|
||||||
|
"index_built_at": None,
|
||||||
|
"faiss_dim": None,
|
||||||
|
"ready": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
total_laws = self._conn.execute("SELECT COUNT(*) FROM laws").fetchone()[0]
|
||||||
|
total_clauses = self._conn.execute("SELECT COUNT(*) FROM clauses").fetchone()[0]
|
||||||
|
cat_rows = self._conn.execute(
|
||||||
|
"SELECT category, COUNT(*) as cnt FROM laws GROUP BY category"
|
||||||
|
).fetchall()
|
||||||
|
category_stats = {row["category"]: row["cnt"] for row in cat_rows}
|
||||||
|
|
||||||
|
# 索引构建时间(取最新一条 law 的 indexed_at)
|
||||||
|
built_at_row = self._conn.execute(
|
||||||
|
"SELECT indexed_at FROM laws ORDER BY indexed_at DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
index_built_at = built_at_row["indexed_at"] if built_at_row else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_laws": total_laws,
|
||||||
|
"total_clauses": total_clauses,
|
||||||
|
"category_stats": category_stats,
|
||||||
|
"index_built_at": index_built_at,
|
||||||
|
"faiss_dim": self._dim,
|
||||||
|
"ready": True,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* app.css — Tailwind 之外的补充样式 */
|
||||||
|
/* 目前所有样式已在 index.html 的 <style> 中定义,此文件预留扩展 */
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>千源法规知识库 — QYLAW</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/tailwind.css?v=20260807">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root { --radius: 0.625rem; }
|
||||||
|
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||||
|
.mono { font-family: 'JetBrains Mono', 'SF Mono', monospace; }
|
||||||
|
.scroll-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
.scroll-thin::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||||
|
.scroll-thin::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
/* 导航激活态 */
|
||||||
|
.nav-active { background: #f1f5f9; color: #0f172a; font-weight: 600; }
|
||||||
|
.nav-inactive { color: #64748b; }
|
||||||
|
.nav-inactive:hover { background: #f8fafc; color: #334155; }
|
||||||
|
/* Markdown 渲染 */
|
||||||
|
.md-content h1 { font-size: 1.4em; font-weight: 700; margin: 12px 0 8px; }
|
||||||
|
.md-content h2 { font-size: 1.2em; font-weight: 600; margin: 10px 0 6px; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; }
|
||||||
|
.md-content h3 { font-size: 1.05em; font-weight: 600; margin: 8px 0 4px; }
|
||||||
|
.md-content p { margin: 6px 0; line-height: 1.7; }
|
||||||
|
.md-content ul { list-style: disc; padding-left: 1.5em; margin: 6px 0; }
|
||||||
|
.md-content ol { list-style: decimal; padding-left: 1.5em; margin: 6px 0; }
|
||||||
|
.md-content li { margin: 3px 0; line-height: 1.6; }
|
||||||
|
.md-content blockquote { border-left: 3px solid #cbd5e1; padding: 4px 12px; margin: 8px 0; background: #f8fafc; border-radius: 0 6px 6px 0; color: #475569; }
|
||||||
|
.md-content code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; font-family: 'JetBrains Mono', monospace; color: #e11d48; }
|
||||||
|
.md-content pre { background: #1e293b; color: #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 8px 0; overflow-x: auto; font-size: 13px; }
|
||||||
|
.md-content pre code { background: none; color: inherit; padding: 0; }
|
||||||
|
.md-content table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
|
||||||
|
.md-content th { background: #f8fafc; border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; font-weight: 600; }
|
||||||
|
.md-content td { border: 1px solid #e2e8f0; padding: 6px 10px; }
|
||||||
|
.md-content tr:nth-child(even) { background: #f8fafc; }
|
||||||
|
.md-content strong { font-weight: 600; }
|
||||||
|
.md-content a { color: #0f172a; text-decoration: underline; }
|
||||||
|
/* 条文高亮 */
|
||||||
|
.clause-highlight { background: #fef3c7; border-left: 3px solid #f59e0b; padding: 8px 12px; margin: 4px 0; border-radius: 0 4px 4px 0; }
|
||||||
|
/* 加载动画 */
|
||||||
|
.spinner { border: 2px solid #e2e8f0; border-top: 2px solid #0f172a; border-radius: 50%; width: 16px; height: 16px; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
/* 流式光标 */
|
||||||
|
.stream-cursor::after { content: '▊'; animation: blink 1s infinite; color: #0f172a; }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
/* 响应式:移动端 Sidebar */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#sidebar { display: none; }
|
||||||
|
#sidebar.open { display: flex; position: fixed; top: 56px; left: 0; bottom: 0; z-index: 50; }
|
||||||
|
#mobile-menu-btn { display: flex; }
|
||||||
|
}
|
||||||
|
#mobile-menu-btn { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#f8f9fb] text-gray-900 h-screen flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="h-14 bg-white/95 backdrop-blur border-b border-gray-200 flex items-center justify-between px-4 md:px-6 shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button id="mobile-menu-btn" class="md:hidden p-2 rounded-lg hover:bg-gray-100" aria-label="菜单">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-gray-900 flex items-center justify-center text-white font-bold text-sm">法</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-sm font-semibold">千源法规知识库</h1>
|
||||||
|
<p class="text-xs text-gray-500">17291 篇现行有效法律法规 · 语义检索 + RAG 问答</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div id="stats-badge" class="text-xs text-gray-500 hidden md:flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300" id="status-dot"></span>
|
||||||
|
<span id="status-text">加载中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside id="sidebar" class="w-52 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||||
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
<button data-page="rag" class="nav-item nav-active w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
<span>智能问答</span>
|
||||||
|
</button>
|
||||||
|
<button data-page="search" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||||
|
<span>语义检索</span>
|
||||||
|
</button>
|
||||||
|
<button data-page="browse" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||||
|
<span>法规浏览</span>
|
||||||
|
</button>
|
||||||
|
<button data-page="history" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
<span>历史记录</span>
|
||||||
|
</button>
|
||||||
|
<button data-page="settings" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
<span>设置</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div class="p-3 border-t border-gray-100">
|
||||||
|
<div id="sidebar-stats" class="text-xs text-gray-400 space-y-1">
|
||||||
|
<div>索引: <span id="stat-clauses">-</span> 条文</div>
|
||||||
|
<div>法规: <span id="stat-laws">-</span> 篇</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main id="content" class="flex-1 overflow-y-auto scroll-thin">
|
||||||
|
<!-- 页面动态渲染 -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js?v=20260807"></script>
|
||||||
|
<script src="/static/js/search.js?v=20260807"></script>
|
||||||
|
<script src="/static/js/rag.js?v=20260807"></script>
|
||||||
|
<script src="/static/js/browse.js?v=20260807"></script>
|
||||||
|
<script src="/static/js/history.js?v=20260807"></script>
|
||||||
|
<script src="/static/js/settings.js?v=20260807"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* app.js — 公共逻辑:路由、公共组件、API 封装
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 全局常量(供 rag.js / settings.js 共享) =====
|
||||||
|
const PROMPT_DEFAULTS = {
|
||||||
|
system_prompt: '你是法律助手,根据提供的法规条文回答问题,必须引用法规名和条号。',
|
||||||
|
template_simple: '你是一位耐心的法律科普助手。请根据以下法规条文,用通俗易懂的语言回答用户问题。\n要求:\n1. 避免法律术语,用日常语言解释\n2. 必须引用依据的法规名和条号,格式如"根据《XX法》第X条"\n3. 如果条文不足以回答,明确说明"根据现有法规无法完全回答"\n4. 不要编造法规\n\n【相关法规条文】\n{context}\n\n【用户问题】\n{question}',
|
||||||
|
template_professional: '你是一位专业的法律分析助手。请根据以下法规条文,对用户问题进行专业分析。\n要求:\n1. 使用规范法律术语\n2. 必须引用依据的法规名和条号,格式如"依据《XX法》第X条"\n3. 分析条文的适用条件、法律后果\n4. 如有多条相关,对比分析\n5. 指出条文的适用边界和可能的争议点\n6. 不要编造法规\n\n【相关法规条文】\n{context}\n\n【用户问题】\n{question}',
|
||||||
|
template_compare: '你是一位法规研究助手。请根据以下法规条文,对比分析不同法规对同一问题的规定。\n要求:\n1. 列出每条相关法规的具体规定\n2. 对比规定的异同\n3. 标注法规名、条号、发布日期\n4. 指出适用范围差异(如全国性 vs 地方性)\n5. 不要编造法规\n\n【相关法规条文】\n{context}\n\n【用户问题】\n{question}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATES = [
|
||||||
|
{ value: 'simple', label: '通俗解释' },
|
||||||
|
{ value: 'professional', label: '专业分析' },
|
||||||
|
{ value: 'compare', label: '对比条文' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 路由 =====
|
||||||
|
const PAGES = ['rag', 'search', 'browse', 'history', 'settings'];
|
||||||
|
let currentPage = 'rag';
|
||||||
|
|
||||||
|
/** 渲染指定页面(不修改 URL) */
|
||||||
|
function renderPage(page) {
|
||||||
|
if (!PAGES.includes(page)) page = 'search';
|
||||||
|
currentPage = page;
|
||||||
|
// 更新导航激活态
|
||||||
|
document.querySelectorAll('.nav-item').forEach(btn => {
|
||||||
|
if (btn.dataset.page === page) {
|
||||||
|
btn.classList.remove('nav-inactive');
|
||||||
|
btn.classList.add('nav-active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('nav-active');
|
||||||
|
btn.classList.add('nav-inactive');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 渲染页面
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
if (page === 'search') content.innerHTML = SearchPage.render();
|
||||||
|
if (page === 'rag') content.innerHTML = RagPage.render();
|
||||||
|
if (page === 'browse') content.innerHTML = BrowsePage.render();
|
||||||
|
if (page === 'history') content.innerHTML = HistoryPage.render();
|
||||||
|
if (page === 'settings') content.innerHTML = SettingsPage.render();
|
||||||
|
// 页面初始化
|
||||||
|
if (page === 'search') SearchPage.init();
|
||||||
|
if (page === 'rag') RagPage.init();
|
||||||
|
if (page === 'browse') BrowsePage.init();
|
||||||
|
if (page === 'history') HistoryPage.init();
|
||||||
|
if (page === 'settings') SettingsPage.init();
|
||||||
|
// 关闭移动端菜单
|
||||||
|
document.getElementById('sidebar').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导航到指定页面(修改 URL + 渲染) */
|
||||||
|
function navigate(page) {
|
||||||
|
if (!PAGES.includes(page)) return;
|
||||||
|
if (page === currentPage) return;
|
||||||
|
// 更新 URL(不触发 popstate)
|
||||||
|
history.pushState({ page }, '', `/${page}`);
|
||||||
|
renderPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 URL 路径解析当前页面 */
|
||||||
|
function pageFromPath() {
|
||||||
|
const path = window.location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
return PAGES.includes(path) ? path : 'rag';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听浏览器前进/后退
|
||||||
|
window.addEventListener('popstate', (e) => {
|
||||||
|
const page = (e.state && e.state.page) || pageFromPath();
|
||||||
|
renderPage(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 公共组件 =====
|
||||||
|
const UI = {
|
||||||
|
/** 加载中 spinner */
|
||||||
|
spinner(size = 16) {
|
||||||
|
return `<div class="spinner" style="width:${size}px;height:${size}px"></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 空状态 */
|
||||||
|
emptyState(title, desc, icon = '📋') {
|
||||||
|
return `
|
||||||
|
<div class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<div class="text-4xl mb-3">${icon}</div>
|
||||||
|
<div class="text-sm font-medium text-gray-600">${title}</div>
|
||||||
|
<div class="text-xs mt-1">${desc}</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 错误状态 */
|
||||||
|
errorState(msg, onRetry) {
|
||||||
|
const retryBtn = onRetry ? `<button onclick="${onRetry}" class="mt-3 px-3 py-1.5 text-xs bg-rose-50 text-rose-600 rounded-lg hover:bg-rose-100">重试</button>` : '';
|
||||||
|
return `
|
||||||
|
<div class="flex flex-col items-center justify-center py-16 text-rose-500">
|
||||||
|
<div class="text-3xl mb-2">⚠️</div>
|
||||||
|
<div class="text-sm font-medium">${msg}</div>
|
||||||
|
${retryBtn}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 类别标签 */
|
||||||
|
categoryBadge(category) {
|
||||||
|
const colors = {
|
||||||
|
'法律': 'bg-blue-50 text-blue-700',
|
||||||
|
'行政法规': 'bg-purple-50 text-purple-700',
|
||||||
|
'监察法规': 'bg-amber-50 text-amber-700',
|
||||||
|
'司法解释': 'bg-teal-50 text-teal-700',
|
||||||
|
'地方性法规': 'bg-emerald-50 text-emerald-700',
|
||||||
|
};
|
||||||
|
const cls = colors[category] || 'bg-gray-100 text-gray-600';
|
||||||
|
return `<span class="inline-block px-2 py-0.5 text-xs rounded ${cls}">${category}</span>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 分页 */
|
||||||
|
pagination(page, pageSize, total, onChange) {
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
if (totalPages <= 1) return '';
|
||||||
|
const prev = page > 1 ? `<button onclick="${onChange}(${page-1})" class="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">上一页</button>` : '';
|
||||||
|
const next = page < totalPages ? `<button onclick="${onChange}(${page+1})" class="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">下一页</button>` : '';
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-xs text-gray-500">共 ${total} 条,第 ${page}/${totalPages} 页</div>
|
||||||
|
<div class="flex gap-2">${prev}${next}</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 条文卡片 */
|
||||||
|
clauseCard(item, highlight = false) {
|
||||||
|
const badge = UI.categoryBadge(item.category);
|
||||||
|
const province = item.province ? `<span class="text-xs text-gray-400">[${item.province}${item.city ? ' · ' + item.city : ''}]</span>` : '';
|
||||||
|
const chapter = item.chapter ? `<span class="text-xs text-gray-400">${item.chapter}</span>` : '';
|
||||||
|
const clauseNo = item.clause_no ? `<span class="text-sm font-semibold text-gray-700">${item.clause_no}</span>` : '';
|
||||||
|
const score = item.score !== undefined ? `<span class="text-xs mono text-gray-400">相似度 ${item.score.toFixed(4)}</span>` : '';
|
||||||
|
const cls = highlight ? 'clause-highlight' : 'border border-gray-200 bg-white';
|
||||||
|
return `
|
||||||
|
<div class="${cls} rounded-lg p-3 mb-2">
|
||||||
|
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<span class="text-sm font-medium text-gray-900">${item.law_name}</span>
|
||||||
|
${badge}
|
||||||
|
${clauseNo}
|
||||||
|
${chapter}
|
||||||
|
${province}
|
||||||
|
${score}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 leading-relaxed">${item.content.substring(0, 200)}${item.content.length > 200 ? '...' : ''}</div>
|
||||||
|
${item.law_id ? `<button onclick="UI.viewLaw(${item.law_id})" class="mt-2 text-xs text-gray-500 hover:text-gray-900 underline">查看原文</button>` : ''}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 跳转查看原文 */
|
||||||
|
viewLaw(lawId) {
|
||||||
|
navigate('browse');
|
||||||
|
setTimeout(() => BrowsePage.viewLaw(lawId), 100);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== API 封装 =====
|
||||||
|
const API = {
|
||||||
|
async get(url) {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async post(url, body) {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
|
||||||
|
async put(url, body) {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(url) {
|
||||||
|
const resp = await fetch(url, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 检索 */
|
||||||
|
search(params) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return API.get(`/api/search?${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 法规列表 */
|
||||||
|
listLaws(params) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return API.get(`/api/laws?${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 法规详情 */
|
||||||
|
getLaw(lawId) {
|
||||||
|
return API.get(`/api/law/${lawId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 统计 */
|
||||||
|
stats() {
|
||||||
|
return API.get('/api/stats');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 初始化 =====
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 导航绑定
|
||||||
|
document.querySelectorAll('.nav-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => navigate(btn.dataset.page));
|
||||||
|
});
|
||||||
|
// 移动端菜单
|
||||||
|
document.getElementById('mobile-menu-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('sidebar').classList.toggle('open');
|
||||||
|
});
|
||||||
|
// 加载统计
|
||||||
|
loadStats();
|
||||||
|
// 从 URL 决定初始页
|
||||||
|
const initialPage = pageFromPath();
|
||||||
|
// 替换当前历史记录,确保有 state
|
||||||
|
history.replaceState({ page: initialPage }, '', `/${initialPage === 'rag' ? '' : initialPage}`);
|
||||||
|
renderPage(initialPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const resp = await API.stats();
|
||||||
|
const s = resp.data;
|
||||||
|
if (s && s.ready) {
|
||||||
|
document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-emerald-500';
|
||||||
|
document.getElementById('status-text').textContent = '就绪';
|
||||||
|
document.getElementById('stat-clauses').textContent = s.total_clauses.toLocaleString();
|
||||||
|
document.getElementById('stat-laws').textContent = s.total_laws.toLocaleString();
|
||||||
|
} else {
|
||||||
|
document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-amber-400';
|
||||||
|
document.getElementById('status-text').textContent = '索引未就绪';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-rose-500';
|
||||||
|
document.getElementById('status-text').textContent = '服务不可用';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* browse.js — 法规浏览页 + 原文详情
|
||||||
|
*/
|
||||||
|
const BrowsePage = {
|
||||||
|
state: { view: 'list', laws: [], total: 0, page: 1, pageSize: 50, category: '', province: '', keyword: '', loading: false, error: null, currentLaw: null },
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.view === 'detail' && this.state.currentLaw) {
|
||||||
|
return this.renderDetail();
|
||||||
|
}
|
||||||
|
return this.renderList();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
return `
|
||||||
|
<div class="p-4 md:p-6 max-w-5xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">法规浏览</h2>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<div class="flex gap-3 flex-wrap">
|
||||||
|
<input id="browse-keyword" type="text" placeholder="法规名关键词..."
|
||||||
|
class="flex-1 min-w-[200px] rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
value="${this.state.keyword}" onkeydown="if(event.key==='Enter')BrowsePage.doBrowse(1)">
|
||||||
|
<select id="browse-category" class="rounded-lg border border-gray-200 px-3 py-2 text-sm" onchange="BrowsePage.onFilterChange()">
|
||||||
|
<option value="">全部类别</option>
|
||||||
|
<option value="法律">法律</option>
|
||||||
|
<option value="行政法规">行政法规</option>
|
||||||
|
<option value="监察法规">监察法规</option>
|
||||||
|
<option value="司法解释">司法解释</option>
|
||||||
|
<option value="地方性法规">地方性法规</option>
|
||||||
|
</select>
|
||||||
|
<select id="browse-province" class="rounded-lg border border-gray-200 px-3 py-2 text-sm" onchange="BrowsePage.onFilterChange()">
|
||||||
|
<option value="">全部省份</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="BrowsePage.doBrowse(1)" class="px-4 py-2 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800">查询</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div id="browse-list">
|
||||||
|
${UI.emptyState('输入条件浏览法规', '可按类别、省份、关键词筛选')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDetail() {
|
||||||
|
const law = this.state.currentLaw;
|
||||||
|
const badge = UI.categoryBadge(law.category);
|
||||||
|
const province = law.province ? `<span class="text-xs text-gray-400">[${law.province}${law.city ? ' · ' + law.city : ''}]</span>` : '';
|
||||||
|
const date = law.publish_date ? `<span class="text-xs text-gray-400">发布: ${law.publish_date}</span>` : '';
|
||||||
|
|
||||||
|
// 目录树(章节 + 条号)
|
||||||
|
const toc = (law.clauses || []).map(c => {
|
||||||
|
const chapter = c.chapter ? c.chapter : '';
|
||||||
|
return `<div class="text-xs py-1 px-2 hover:bg-gray-50 cursor-pointer rounded" onclick="BrowsePage.scrollToClause(${c.clause_id})">
|
||||||
|
<span class="text-gray-400">${chapter}</span>
|
||||||
|
<span class="text-gray-700">${c.clause_no || ''}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="p-4 md:p-6 max-w-5xl mx-auto">
|
||||||
|
<button onclick="BrowsePage.backToList()" class="mb-4 text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
|
||||||
|
返回列表
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<h2 class="text-lg font-semibold">${law.name}</h2>
|
||||||
|
${badge}
|
||||||
|
${province}
|
||||||
|
${date}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- 目录树 -->
|
||||||
|
${toc ? `<aside class="w-48 shrink-0 hidden md:block">
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-3 sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto scroll-thin">
|
||||||
|
<div class="text-xs font-semibold text-gray-500 mb-2">目录</div>
|
||||||
|
${toc}
|
||||||
|
</div>
|
||||||
|
</aside>` : ''}
|
||||||
|
|
||||||
|
<!-- 原文 -->
|
||||||
|
<div class="flex-1 bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div class="md-content text-sm leading-relaxed">${marked.parse(law.content)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadProvinces();
|
||||||
|
if (this.state.category) document.getElementById('browse-category').value = this.state.category;
|
||||||
|
if (this.state.province) document.getElementById('browse-province').value = this.state.province;
|
||||||
|
if (this.state.laws.length === 0) this.doBrowse(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
provinces: ['北京市','天津市','河北省','山西省','内蒙古自治区','辽宁省','吉林省','黑龙江省','上海市','江苏省','浙江省','安徽省','福建省','江西省','山东省','河南省','湖北省','湖南省','广东省','广西壮族自治区','海南省','重庆市','四川省','贵州省','云南省','西藏自治区','陕西省','甘肃省','青海省','宁夏回族自治区','新疆维吾尔自治区'],
|
||||||
|
|
||||||
|
loadProvinces() {
|
||||||
|
const sel = document.getElementById('browse-province');
|
||||||
|
if (!sel) return;
|
||||||
|
this.provinces.forEach(p => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p; opt.textContent = p;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onFilterChange() {
|
||||||
|
this.state.category = document.getElementById('browse-category').value;
|
||||||
|
this.state.province = document.getElementById('browse-province').value;
|
||||||
|
},
|
||||||
|
|
||||||
|
async doBrowse(page) {
|
||||||
|
const kwInput = document.getElementById('browse-keyword');
|
||||||
|
if (kwInput) this.state.keyword = kwInput.value.trim();
|
||||||
|
this.state.page = page || 1;
|
||||||
|
this.state.view = 'list';
|
||||||
|
this.state.loading = true;
|
||||||
|
this.state.error = null;
|
||||||
|
this.renderListState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = { page: this.state.page, page_size: this.state.pageSize };
|
||||||
|
if (this.state.category) params.category = this.state.category;
|
||||||
|
if (this.state.province) params.province = this.state.province;
|
||||||
|
if (this.state.keyword) params.keyword = this.state.keyword;
|
||||||
|
|
||||||
|
const resp = await API.listLaws(params);
|
||||||
|
this.state.laws = resp.data.results || [];
|
||||||
|
this.state.total = resp.data.total || 0;
|
||||||
|
this.state.loading = false;
|
||||||
|
this.renderListState();
|
||||||
|
} catch (e) {
|
||||||
|
this.state.loading = false;
|
||||||
|
this.state.error = e.message;
|
||||||
|
this.renderListState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderListState() {
|
||||||
|
const el = document.getElementById('browse-list');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (this.state.loading) {
|
||||||
|
el.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载中...</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
el.innerHTML = UI.errorState(this.state.error, 'BrowsePage.doBrowse()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.laws.length) {
|
||||||
|
el.innerHTML = UI.emptyState('无法规', '试试调整筛选条件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = this.state.laws.map(l => `
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-3 mb-2 hover:border-gray-300 transition cursor-pointer" onclick="BrowsePage.viewLaw(${l.law_id})">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-medium text-gray-900">${l.name}</span>
|
||||||
|
${UI.categoryBadge(l.category)}
|
||||||
|
${l.province ? `<span class="text-xs text-gray-400">[${l.province}${l.city ? ' · ' + l.city : ''}]</span>` : ''}
|
||||||
|
${l.publish_date ? `<span class="text-xs text-gray-400">${l.publish_date}</span>` : ''}
|
||||||
|
<span class="text-xs text-gray-400 ml-auto">${l.clause_count} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const pager = UI.pagination(this.state.page, this.state.pageSize, this.state.total, 'BrowsePage.doBrowse');
|
||||||
|
el.innerHTML = `<div class="mb-3 text-sm text-gray-500">共 ${this.state.total} 篇法规</div>${rows}${pager}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewLaw(lawId) {
|
||||||
|
this.state.view = 'detail';
|
||||||
|
this.state.currentLaw = null;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载原文...</span></div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.getLaw(lawId);
|
||||||
|
this.state.currentLaw = resp.data;
|
||||||
|
content.innerHTML = this.renderDetail();
|
||||||
|
} catch (e) {
|
||||||
|
content.innerHTML = UI.errorState(e.message, `BrowsePage.viewLaw(${lawId})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
backToList() {
|
||||||
|
this.state.view = 'list';
|
||||||
|
this.state.currentLaw = null;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = this.renderList();
|
||||||
|
this.init();
|
||||||
|
this.renderListState();
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToClause(clauseId) {
|
||||||
|
// 简单实现:滚动到条文内容(原文中条文已渲染)
|
||||||
|
// TODO: 更精确的定位需要解析原文中的条号
|
||||||
|
const el = document.getElementById(`clause-${clauseId}`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
el.classList.add('clause-highlight');
|
||||||
|
setTimeout(() => el.classList.remove('clause-highlight'), 2000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* history.js — 历史记录页(检索历史 + 对话历史)
|
||||||
|
*/
|
||||||
|
const HistoryPage = {
|
||||||
|
state: {
|
||||||
|
tab: 'chat', // 'chat' 或 'search'
|
||||||
|
chatPage: 1,
|
||||||
|
chatPageSize: 20,
|
||||||
|
chatData: null,
|
||||||
|
searchPage: 1,
|
||||||
|
searchPageSize: 20,
|
||||||
|
searchData: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
detailChat: null, // 查看详情的对话
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const tab = this.state.tab;
|
||||||
|
return `
|
||||||
|
<div class="p-4 md:p-6 max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">历史记录</h2>
|
||||||
|
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button onclick="HistoryPage.setTab('chat')"
|
||||||
|
class="px-4 py-2 text-sm rounded-lg ${tab === 'chat' ? 'bg-gray-900 text-white' : 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'}">
|
||||||
|
对话历史
|
||||||
|
</button>
|
||||||
|
<button onclick="HistoryPage.setTab('search')"
|
||||||
|
class="px-4 py-2 text-sm rounded-lg ${tab === 'search' ? 'bg-gray-900 text-white' : 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'}">
|
||||||
|
检索历史
|
||||||
|
</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button onclick="HistoryPage.clearAll()"
|
||||||
|
class="px-3 py-2 text-xs text-rose-500 hover:bg-rose-50 rounded-lg">
|
||||||
|
清空${tab === 'chat' ? '对话' : '检索'}历史
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<div id="history-content">
|
||||||
|
${this._renderLoading()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadList();
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderLoading() {
|
||||||
|
return `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载中...</span></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTab(tab) {
|
||||||
|
this.state.tab = tab;
|
||||||
|
this.state.error = null;
|
||||||
|
this.state.detailChat = null;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = this.render();
|
||||||
|
this.loadList();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadList() {
|
||||||
|
this.state.loading = true;
|
||||||
|
this.state.error = null;
|
||||||
|
const el = document.getElementById('history-content');
|
||||||
|
if (el) el.innerHTML = this._renderLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.state.tab === 'chat') {
|
||||||
|
const resp = await API.get(`/api/history/chat?page=${this.state.chatPage}&page_size=${this.state.chatPageSize}`);
|
||||||
|
this.state.chatData = resp.data;
|
||||||
|
} else {
|
||||||
|
const resp = await API.get(`/api/history/search?page=${this.state.searchPage}&page_size=${this.state.searchPageSize}`);
|
||||||
|
this.state.searchData = resp.data;
|
||||||
|
}
|
||||||
|
this.state.loading = false;
|
||||||
|
this.renderList();
|
||||||
|
} catch (e) {
|
||||||
|
this.state.loading = false;
|
||||||
|
this.state.error = e.message;
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
const el = document.getElementById('history-content');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
el.innerHTML = UI.errorState(this.state.error, 'HistoryPage.loadList()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.tab === 'chat') {
|
||||||
|
el.innerHTML = this._renderChatList();
|
||||||
|
} else {
|
||||||
|
el.innerHTML = this._renderSearchList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderChatList() {
|
||||||
|
const data = this.state.chatData;
|
||||||
|
if (!data || !data.items || data.items.length === 0) {
|
||||||
|
return UI.emptyState('暂无对话历史', '在智能问答页提问后会自动记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = data.items.map(item => {
|
||||||
|
const tplLabel = { simple: '通俗解释', professional: '专业分析', compare: '对比条文' }[item.template] || item.template;
|
||||||
|
const statusBadge = item.status === 'error'
|
||||||
|
? '<span class="text-xs text-rose-500">错误</span>'
|
||||||
|
: item.status === 'streaming'
|
||||||
|
? '<span class="text-xs text-amber-500">生成中</span>'
|
||||||
|
: '';
|
||||||
|
const answerPreview = item.answer
|
||||||
|
? item.answer.substring(0, 120).replace(/\n/g, ' ') + (item.answer.length > 120 ? '...' : '')
|
||||||
|
: '<span class="text-gray-400 italic">无答案</span>';
|
||||||
|
const citationCount = item.citations ? item.citations.length : 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-3 hover:border-gray-300 transition">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<span class="text-xs text-gray-400">${item.created_at}</span>
|
||||||
|
<span class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">${tplLabel}</span>
|
||||||
|
${item.thinking_enabled ? '<span class="px-1.5 py-0.5 text-xs rounded bg-indigo-50 text-indigo-600">Thinking</span>' : ''}
|
||||||
|
${statusBadge}
|
||||||
|
${citationCount ? `<span class="text-xs text-gray-400">引用 ${citationCount} 条</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium text-gray-900 mb-1">${this._escapeHtml(item.question)}</div>
|
||||||
|
<div class="text-xs text-gray-500 leading-relaxed">${this._escapeHtml(answerPreview)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 shrink-0">
|
||||||
|
<button onclick="HistoryPage.viewChat(${item.id})"
|
||||||
|
class="px-2 py-1 text-xs text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded">查看</button>
|
||||||
|
<button onclick="HistoryPage.deleteChat(${item.id})"
|
||||||
|
class="px-2 py-1 text-xs text-rose-400 hover:text-rose-600 hover:bg-rose-50 rounded">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const pagination = UI.pagination(
|
||||||
|
data.page, data.page_size, data.total,
|
||||||
|
'HistoryPage.changeChatPage'
|
||||||
|
);
|
||||||
|
|
||||||
|
return items + pagination;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderSearchList() {
|
||||||
|
const data = this.state.searchData;
|
||||||
|
if (!data || !data.items || data.items.length === 0) {
|
||||||
|
return UI.emptyState('暂无检索历史', '在语义检索页查询后会自动记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = data.items.map(item => {
|
||||||
|
const filters = [];
|
||||||
|
if (item.category) filters.push(UI.categoryBadge(item.category));
|
||||||
|
if (item.province) filters.push(`<span class="text-xs text-gray-400">[${item.province}]</span>`);
|
||||||
|
return `
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-3 mb-2 hover:border-gray-300 transition">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span class="text-xs text-gray-400">${item.created_at}</span>
|
||||||
|
${filters.join('')}
|
||||||
|
<span class="text-xs text-gray-400">${item.result_count} 条结果</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-900 truncate">${this._escapeHtml(item.query)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 shrink-0">
|
||||||
|
<button onclick="HistoryPage.replaySearch(${JSON.stringify(item).replace(/"/g, '"')})"
|
||||||
|
class="px-2 py-1 text-xs text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded">重查</button>
|
||||||
|
<button onclick="HistoryPage.deleteSearch(${item.id})"
|
||||||
|
class="px-2 py-1 text-xs text-rose-400 hover:text-rose-600 hover:bg-rose-50 rounded">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const pagination = UI.pagination(
|
||||||
|
data.page, data.page_size, data.total,
|
||||||
|
'HistoryPage.changeSearchPage'
|
||||||
|
);
|
||||||
|
|
||||||
|
return items + pagination;
|
||||||
|
},
|
||||||
|
|
||||||
|
changeChatPage(page) {
|
||||||
|
this.state.chatPage = page;
|
||||||
|
this.loadList();
|
||||||
|
},
|
||||||
|
|
||||||
|
changeSearchPage(page) {
|
||||||
|
this.state.searchPage = page;
|
||||||
|
this.loadList();
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewChat(id) {
|
||||||
|
try {
|
||||||
|
const resp = await API.get(`/api/history/chat/${id}`);
|
||||||
|
this.state.detailChat = resp.data;
|
||||||
|
this._renderChatDetail();
|
||||||
|
} catch (e) {
|
||||||
|
this.state.error = e.message;
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderChatDetail() {
|
||||||
|
const chat = this.state.detailChat;
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
const tplLabel = { simple: '通俗解释', professional: '专业分析', compare: '对比条文' }[chat.template] || chat.template;
|
||||||
|
const thinkingHtml = chat.thinking
|
||||||
|
? `<details class="mb-4 group">
|
||||||
|
<summary class="cursor-pointer flex items-center gap-2 text-xs text-gray-400 hover:text-gray-600 py-2">
|
||||||
|
<svg class="w-3 h-3 transition-transform group-open:rotate-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
<span>Thinking 思考过程</span>
|
||||||
|
<span class="text-gray-300">${chat.thinking.length} 字</span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-100 max-h-96 overflow-y-auto scroll-thin">
|
||||||
|
<pre class="text-xs text-gray-500 whitespace-pre-wrap font-mono leading-relaxed">${this._escapeHtml(chat.thinking)}</pre>
|
||||||
|
</div>
|
||||||
|
</details>`
|
||||||
|
: '';
|
||||||
|
const answerHtml = chat.answer
|
||||||
|
? `<div class="md-content text-sm leading-relaxed">${marked.parse(chat.answer)}</div>`
|
||||||
|
: '<div class="text-sm text-gray-400 italic">无答案</div>';
|
||||||
|
const citationsHtml = chat.citations && chat.citations.length
|
||||||
|
? `<div class="mt-6 pt-4 border-t border-gray-200"><div class="text-xs font-semibold text-gray-500 mb-3">引用条文(${chat.citations.length})</div>${chat.citations.map(c => UI.clauseCard(c)).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const el = document.getElementById('history-content');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="mb-4">
|
||||||
|
<button onclick="HistoryPage.closeDetail()"
|
||||||
|
class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
返回列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<span class="text-xs text-gray-400">${chat.created_at}</span>
|
||||||
|
<span class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">${tplLabel}</span>
|
||||||
|
${chat.thinking_enabled ? '<span class="px-1.5 py-0.5 text-xs rounded bg-indigo-50 text-indigo-600">Thinking</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-gray-900 mb-3 pb-3 border-b border-gray-100">${this._escapeHtml(chat.question)}</div>
|
||||||
|
<div class="text-xs font-semibold text-gray-500 mb-3">AI 回答</div>
|
||||||
|
${thinkingHtml}
|
||||||
|
${answerHtml}
|
||||||
|
${citationsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeDetail() {
|
||||||
|
this.state.detailChat = null;
|
||||||
|
this.renderList();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteChat(id) {
|
||||||
|
if (!confirm('确认删除这条对话历史?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/history/chat/${id}`, { method: 'DELETE' });
|
||||||
|
this.loadList();
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败: ' + e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSearch(id) {
|
||||||
|
if (!confirm('确认删除这条检索历史?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/history/search/${id}`, { method: 'DELETE' });
|
||||||
|
this.loadList();
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败: ' + e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearAll() {
|
||||||
|
const type = this.state.tab === 'chat' ? '对话' : '检索';
|
||||||
|
if (!confirm(`确认清空全部${type}历史?此操作不可恢复。`)) return;
|
||||||
|
try {
|
||||||
|
const url = this.state.tab === 'chat' ? '/api/history/chat' : '/api/history/search';
|
||||||
|
await fetch(url, { method: 'DELETE' });
|
||||||
|
this.loadList();
|
||||||
|
} catch (e) {
|
||||||
|
alert('清空失败: ' + e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
replaySearch(item) {
|
||||||
|
// 跳转到检索页并填充查询条件
|
||||||
|
navigate('search');
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
if (input) input.value = item.query;
|
||||||
|
if (SearchPage.state) {
|
||||||
|
SearchPage.state.query = item.query;
|
||||||
|
SearchPage.state.category = item.category || '';
|
||||||
|
SearchPage.state.province = item.province || '';
|
||||||
|
if (item.category) {
|
||||||
|
const sel = document.getElementById('search-category');
|
||||||
|
if (sel) sel.value = item.category;
|
||||||
|
}
|
||||||
|
if (item.province) {
|
||||||
|
const sel = document.getElementById('search-province');
|
||||||
|
if (sel) sel.value = item.province;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自动触发检索
|
||||||
|
if (SearchPage.submit) SearchPage.submit();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
_escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* rag.js — 智能问答页(RAG + SSE 流式)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RagPage = {
|
||||||
|
state: {
|
||||||
|
question: '', template: 'simple', answer: '', citations: [], streaming: false, error: null,
|
||||||
|
thinking: '', // thinking 内容
|
||||||
|
thinkingEnabled: true, // thinking 开关(从 API 加载)
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const templates = [
|
||||||
|
{ value: 'simple', label: '通俗解释' },
|
||||||
|
{ value: 'professional', label: '专业分析' },
|
||||||
|
{ value: 'compare', label: '对比条文' },
|
||||||
|
];
|
||||||
|
return `
|
||||||
|
<div class="p-4 md:p-6 max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">智能问答</h2>
|
||||||
|
|
||||||
|
<!-- 输入区 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<textarea id="rag-input" rows="3" placeholder="输入你的问题,如:个人信息保护法对敏感个人信息有什么规定?"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 resize-none"
|
||||||
|
maxlength="1000" oninput="RagPage.onInput()">${this.state.question}</textarea>
|
||||||
|
<div class="flex items-center justify-between mt-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
${templates.map(t => `
|
||||||
|
<button onclick="RagPage.setTemplate('${t.value}')"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg ${this.state.template === t.value ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||||
|
${t.label}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-400">${this.state.question.length}/1000</span>
|
||||||
|
<button id="rag-submit" onclick="RagPage.submit()" class="px-4 py-2 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800 disabled:opacity-50">
|
||||||
|
提问
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 答案区 -->
|
||||||
|
<div id="rag-answer">
|
||||||
|
${UI.emptyState('提问开始对话', '系统将检索相关法规条文并由 AI 生成带引用的答案')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// 加载 LLM 参数(用于 thinking 状态显示)
|
||||||
|
this.loadLLMParams();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadLLMParams() {
|
||||||
|
try {
|
||||||
|
const resp = await API.get('/api/prompts');
|
||||||
|
this.state.thinkingEnabled = resp.data.llm_params.thinking_enabled;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载 LLM 参数失败:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput() {
|
||||||
|
const input = document.getElementById('rag-input');
|
||||||
|
if (input) this.state.question = input.value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTemplate(t) {
|
||||||
|
this.state.template = t;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
const input = document.getElementById('rag-input');
|
||||||
|
if (input) this.state.question = input.value.trim();
|
||||||
|
if (!this.state.question || this.state.streaming) return;
|
||||||
|
|
||||||
|
this.state.answer = '';
|
||||||
|
this.state.thinking = '';
|
||||||
|
this.state.citations = [];
|
||||||
|
this.state.streaming = true;
|
||||||
|
this.state.error = null;
|
||||||
|
this.renderAnswer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/rag', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
question: this.state.question,
|
||||||
|
template: this.state.template,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE 流式读取
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop(); // 保留不完整的行
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(line.slice(6));
|
||||||
|
if (evt.type === 'answer') {
|
||||||
|
this.state.answer += evt.content;
|
||||||
|
this.renderAnswer();
|
||||||
|
} else if (evt.type === 'thinking') {
|
||||||
|
this.state.thinking += evt.content;
|
||||||
|
this.renderAnswer();
|
||||||
|
} else if (evt.type === 'params') {
|
||||||
|
this.state.thinkingEnabled = evt.thinking_enabled;
|
||||||
|
} else if (evt.type === 'citations') {
|
||||||
|
this.state.citations = evt.clauses || [];
|
||||||
|
this.renderAnswer();
|
||||||
|
} else if (evt.type === 'no_result') {
|
||||||
|
this.state.error = evt.message;
|
||||||
|
this.renderAnswer();
|
||||||
|
} else if (evt.type === 'error') {
|
||||||
|
this.state.error = evt.message;
|
||||||
|
this.renderAnswer();
|
||||||
|
} else if (evt.type === 'done') {
|
||||||
|
// 完成
|
||||||
|
}
|
||||||
|
} catch (e) { /* 忽略解析错误 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state.streaming = false;
|
||||||
|
this.renderAnswer();
|
||||||
|
} catch (e) {
|
||||||
|
this.state.streaming = false;
|
||||||
|
this.state.error = e.message;
|
||||||
|
this.renderAnswer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAnswer() {
|
||||||
|
const el = document.getElementById('rag-answer');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
el.innerHTML = UI.errorState(this.state.error, 'RagPage.submit()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.answer && !this.state.citations.length && this.state.streaming) {
|
||||||
|
el.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">检索法规并生成答案中...</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.answer && !this.state.thinking && !this.state.citations.length) {
|
||||||
|
el.innerHTML = UI.emptyState('提问开始对话', '系统将检索相关法规条文并由 AI 生成带引用的答案');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// thinking 折叠区(仅 thinking 开启且有内容时显示)
|
||||||
|
const thinkingHtml = this.state.thinking
|
||||||
|
? `<details class="mb-4 group">
|
||||||
|
<summary class="cursor-pointer flex items-center gap-2 text-xs text-gray-400 hover:text-gray-600 py-2">
|
||||||
|
<svg class="w-3 h-3 transition-transform group-open:rotate-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
<span>Thinking 思考过程</span>
|
||||||
|
<span class="text-gray-300">${this.state.thinking.length} 字</span>
|
||||||
|
${this.state.streaming && !this.state.answer ? '<span class="text-emerald-500">思考中...</span>' : ''}
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-100 max-h-96 overflow-y-auto scroll-thin">
|
||||||
|
<pre class="text-xs text-gray-500 whitespace-pre-wrap font-mono leading-relaxed">${this._escapeHtml(this.state.thinking)}</pre>
|
||||||
|
</div>
|
||||||
|
</details>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const answerHtml = this.state.answer
|
||||||
|
? `<div class="md-content text-sm leading-relaxed ${this.state.streaming ? 'stream-cursor' : ''}">${marked.parse(this.state.answer)}</div>`
|
||||||
|
: (this.state.streaming && this.state.thinking ? '<div class="text-xs text-gray-400 py-2">等待生成答案...</div>' : '');
|
||||||
|
const citationsHtml = this.state.citations.length
|
||||||
|
? `<div class="mt-6 pt-4 border-t border-gray-200"><div class="text-xs font-semibold text-gray-500 mb-3">引用条文(${this.state.citations.length})</div>${this.state.citations.map(c => UI.clauseCard(c)).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div class="text-xs font-semibold text-gray-500 mb-3">AI 回答</div>
|
||||||
|
${thinkingHtml}
|
||||||
|
${answerHtml}
|
||||||
|
${this.state.streaming ? `<div class="flex items-center gap-2 mt-3 text-xs text-gray-400">${UI.spinner(14)} 生成中...</div>` : ''}
|
||||||
|
${citationsHtml}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* search.js — 语义检索页
|
||||||
|
*/
|
||||||
|
const SearchPage = {
|
||||||
|
state: { query: '', mode: 'semantic', category: '', province: '', city: '', results: [], total: 0, page: 1, pageSize: 20, loading: false, error: null },
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const isKeyword = this.state.mode === 'keyword';
|
||||||
|
return `
|
||||||
|
<div class="p-4 md:p-6 max-w-5xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">法规检索</h2>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<!-- 模式切换 -->
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<button onclick="SearchPage.setMode('semantic')"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg ${!isKeyword ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||||
|
语义检索
|
||||||
|
</button>
|
||||||
|
<button onclick="SearchPage.setMode('keyword')"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg ${isKeyword ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||||
|
精确查找
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-400 self-center ml-1">
|
||||||
|
${isKeyword ? '按法规名+条号精确匹配,如"郑州市劳动用工条例 第三十二条"' : '自然语言语义搜索,如"加班工资计算基数"'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mb-3">
|
||||||
|
<input id="search-input" type="text"
|
||||||
|
placeholder="${isKeyword ? '输入法规名和条号,如:郑州市劳动用工条例 第三十二条' : '输入查询,如:个人信息保护、竞业协议补偿金...'}"
|
||||||
|
class="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||||
|
value="${this.state.query}" maxlength="1000"
|
||||||
|
onkeydown="if(event.key==='Enter')SearchPage.doSearch()">
|
||||||
|
<button onclick="SearchPage.doSearch()" class="px-4 py-2 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800 flex items-center gap-2">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 flex-wrap text-sm">
|
||||||
|
<select id="filter-category" class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm" onchange="SearchPage.onFilterChange()">
|
||||||
|
<option value="">全部类别</option>
|
||||||
|
<option value="法律">法律</option>
|
||||||
|
<option value="行政法规">行政法规</option>
|
||||||
|
<option value="监察法规">监察法规</option>
|
||||||
|
<option value="司法解释">司法解释</option>
|
||||||
|
<option value="地方性法规">地方性法规</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-province" class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm" onchange="SearchPage.onFilterChange()">
|
||||||
|
<option value="">全部省份</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-xs text-gray-400 self-center">${this.state.query.length}/1000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果区 -->
|
||||||
|
<div id="search-results">
|
||||||
|
${UI.emptyState('输入查询开始检索', isKeyword ? '按法规名+条号精确匹配' : '支持自然语言查询,如"个人信息跨境传输的规定"')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
setMode(mode) {
|
||||||
|
this.state.mode = mode;
|
||||||
|
this.state.results = [];
|
||||||
|
this.state.total = 0;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = this.render();
|
||||||
|
this.init();
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// 加载省份列表(从统计或固定列表)
|
||||||
|
this.loadProvinces();
|
||||||
|
// 恢复筛选状态
|
||||||
|
if (this.state.category) document.getElementById('filter-category').value = this.state.category;
|
||||||
|
if (this.state.province) document.getElementById('filter-province').value = this.state.province;
|
||||||
|
},
|
||||||
|
|
||||||
|
provinces: ['北京市','天津市','河北省','山西省','内蒙古自治区','辽宁省','吉林省','黑龙江省','上海市','江苏省','浙江省','安徽省','福建省','江西省','山东省','河南省','湖北省','湖南省','广东省','广西壮族自治区','海南省','重庆市','四川省','贵州省','云南省','西藏自治区','陕西省','甘肃省','青海省','宁夏回族自治区','新疆维吾尔自治区'],
|
||||||
|
|
||||||
|
loadProvinces() {
|
||||||
|
const sel = document.getElementById('filter-province');
|
||||||
|
if (!sel) return;
|
||||||
|
this.provinces.forEach(p => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p; opt.textContent = p;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onFilterChange() {
|
||||||
|
this.state.category = document.getElementById('filter-category').value;
|
||||||
|
this.state.province = document.getElementById('filter-province').value;
|
||||||
|
if (this.state.query) this.doSearch();
|
||||||
|
},
|
||||||
|
|
||||||
|
async doSearch(page) {
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
if (input) this.state.query = input.value.trim();
|
||||||
|
if (!this.state.query) return;
|
||||||
|
|
||||||
|
// 自动判定模式:包含"第X条"且看起来像法规名+条号时,自动用精确查找
|
||||||
|
const hasClauseNo = /第[一二三四五六七八九十百千零\d]+条/.test(this.state.query);
|
||||||
|
const autoMode = hasClauseNo ? 'keyword' : this.state.mode;
|
||||||
|
|
||||||
|
this.state.page = page || 1;
|
||||||
|
this.state.loading = true;
|
||||||
|
this.state.error = null;
|
||||||
|
this.renderResults();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
query: this.state.query,
|
||||||
|
mode: autoMode,
|
||||||
|
top_k: 20,
|
||||||
|
page: this.state.page,
|
||||||
|
page_size: this.state.pageSize,
|
||||||
|
};
|
||||||
|
if (this.state.category) params.category = this.state.category;
|
||||||
|
if (this.state.province) params.province = this.state.province;
|
||||||
|
|
||||||
|
const resp = await API.search(params);
|
||||||
|
this.state.results = resp.data.results || [];
|
||||||
|
this.state.total = resp.data.total || 0;
|
||||||
|
this.state.loading = false;
|
||||||
|
this.renderResults();
|
||||||
|
} catch (e) {
|
||||||
|
this.state.loading = false;
|
||||||
|
this.state.error = e.message;
|
||||||
|
this.renderResults();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResults() {
|
||||||
|
const el = document.getElementById('search-results');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (this.state.loading) {
|
||||||
|
el.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">检索中...</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
el.innerHTML = UI.errorState(this.state.error, 'SearchPage.doSearch()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.results.length && this.state.query) {
|
||||||
|
el.innerHTML = UI.emptyState('无检索结果', '试试调整查询词或筛选条件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.results.length) {
|
||||||
|
el.innerHTML = UI.emptyState('输入查询开始检索', '支持自然语言查询');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = this.state.results.map(r => UI.clauseCard(r)).join('');
|
||||||
|
const pager = UI.pagination(this.state.page, this.state.pageSize, this.state.total, 'SearchPage.doSearch');
|
||||||
|
// 显示当前检索模式
|
||||||
|
const hasClauseNo = /第[一二三四五六七八九十百千零\d]+条/.test(this.state.query);
|
||||||
|
const autoMode = hasClauseNo ? 'keyword' : this.state.mode;
|
||||||
|
const modeLabel = autoMode === 'keyword' ? '精确查找' : '语义检索';
|
||||||
|
const autoHint = hasClauseNo && this.state.mode === 'semantic' ? ' <span class="text-xs text-amber-500">(检测到条号,自动切换精确查找)</span>' : '';
|
||||||
|
el.innerHTML = `<div class="mb-3 text-sm text-gray-500">找到 ${this.state.total} 条结果 <span class="text-xs text-gray-400">[${modeLabel}]</span>${autoHint}</div>${cards}${pager}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* settings.js — 设置页(系统提示词 + 模板正文 + LLM 参数)
|
||||||
|
* 所有配置持久化到 SQLite,通过 /api/prompts 读写
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SettingsPage = {
|
||||||
|
state: {
|
||||||
|
template: 'simple',
|
||||||
|
configs: null, // 从 API 加载的配置(含自定义值)
|
||||||
|
llmParams: null, // LLM 参数
|
||||||
|
systemPrompt: '', // 系统提示词(编辑框值,空=使用默认)
|
||||||
|
templateText: '', // 模板正文(编辑框值,空=使用默认)
|
||||||
|
saveStatus: '', // 保存状态提示
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.loading) {
|
||||||
|
return `<div class="p-4 md:p-6 max-w-3xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">设置</h2>
|
||||||
|
<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载配置中...</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (this.state.error) {
|
||||||
|
return `<div class="p-4 md:p-6 max-w-3xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">设置</h2>
|
||||||
|
${UI.errorState(this.state.error, 'SettingsPage.init()')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tpl = TEMPLATES.find(t => t.value === this.state.template);
|
||||||
|
const llm = this.state.llmParams || {};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="p-4 md:p-6 max-w-3xl mx-auto">
|
||||||
|
<h2 class="text-lg font-semibold mb-1">设置</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-6">配置系统提示词、问答模板和 LLM 参数,所有设置持久化到数据库。</p>
|
||||||
|
|
||||||
|
<!-- 系统提示词 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<label class="text-sm font-semibold text-gray-700">系统提示词(System Prompt)</label>
|
||||||
|
<button onclick="SettingsPage.resetSystemPrompt()" class="text-xs text-gray-400 hover:text-gray-600">恢复默认</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="settings-system-prompt" rows="2"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-none"
|
||||||
|
placeholder="留空使用默认:你是法律助手,根据提供的法规条文回答问题..."
|
||||||
|
oninput="SettingsPage.saveSystemPrompt(this.value)">${this._escapeHtml(this.state.systemPrompt)}</textarea>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">定义 AI 的角色和基本行为。留空使用默认值。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模板选择 + 正文 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="text-sm font-semibold text-gray-700">问答模板</label>
|
||||||
|
<button onclick="SettingsPage.resetTemplateText()" class="text-xs text-gray-400 hover:text-gray-600">恢复默认</button>
|
||||||
|
</div>
|
||||||
|
<!-- 模板切换 -->
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
${TEMPLATES.map(t => `
|
||||||
|
<button onclick="SettingsPage.setTemplate('${t.value}')"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg ${this.state.template === t.value ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||||
|
${t.label}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<textarea id="settings-template-text" rows="8"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-none"
|
||||||
|
placeholder="留空使用当前模板的预设正文。自定义时必须包含 {context} 和 {question} 占位符。"
|
||||||
|
oninput="SettingsPage.saveTemplateText(this.value)">${this._escapeHtml(this.state.templateText)}</textarea>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">自定义模板正文,必须包含 <code class="bg-gray-100 px-1 rounded">{context}</code> 和 <code class="bg-gray-100 px-1 rounded">{question}</code> 占位符。留空使用预设模板。</p>
|
||||||
|
|
||||||
|
<!-- 预设模板参考 -->
|
||||||
|
<details class="text-xs text-gray-400 mt-3">
|
||||||
|
<summary class="cursor-pointer hover:text-gray-600">查看预设模板参考(当前: ${tpl?.label || ''})</summary>
|
||||||
|
<pre class="mt-2 p-3 bg-gray-50 rounded-lg text-xs overflow-x-auto scroll-thin whitespace-pre-wrap">${this._escapeHtml(PROMPT_DEFAULTS['template_' + this.state.template] || '')}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LLM 参数 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<div class="text-sm font-semibold text-gray-700 mb-3">LLM 参数</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<!-- thinking 开关 -->
|
||||||
|
<div class="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<label class="text-xs text-gray-600">Thinking 思考</label>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="settings-thinking-toggle" class="sr-only peer"
|
||||||
|
${llm.thinking_enabled ? 'checked' : ''}
|
||||||
|
onchange="SettingsPage.saveLLMParam('llm_thinking_enabled', this.checked ? 'true' : 'false')">
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-gray-900 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-gray-900"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- temperature -->
|
||||||
|
<div class="bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="text-xs text-gray-600">Temperature</label>
|
||||||
|
<span id="settings-temperature-val" class="text-xs text-gray-900 font-mono">${llm.temperature ?? 0.3}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="settings-temperature" min="0" max="1" step="0.1"
|
||||||
|
value="${llm.temperature ?? 0.3}"
|
||||||
|
class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-gray-900"
|
||||||
|
oninput="document.getElementById('settings-temperature-val').textContent=this.value"
|
||||||
|
onchange="SettingsPage.saveLLMParam('llm_temperature', this.value)">
|
||||||
|
</div>
|
||||||
|
<!-- max_tokens -->
|
||||||
|
<div class="bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<label class="text-xs text-gray-600 block mb-1">Max Tokens(含 thinking)</label>
|
||||||
|
<input type="number" id="settings-max-tokens" min="256" max="32768" step="256"
|
||||||
|
value="${llm.max_tokens ?? 4096}"
|
||||||
|
class="w-full rounded border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-gray-900"
|
||||||
|
onchange="SettingsPage.saveLLMParam('llm_max_tokens', this.value)">
|
||||||
|
</div>
|
||||||
|
<!-- thinking_budget -->
|
||||||
|
<div class="bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<label class="text-xs text-gray-600 block mb-1">Thinking 预算(token)</label>
|
||||||
|
<input type="number" id="settings-thinking-budget" min="0" max="16384" step="256"
|
||||||
|
value="${llm.thinking_budget ?? 2048}"
|
||||||
|
class="w-full rounded border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-gray-900"
|
||||||
|
onchange="SettingsPage.saveLLMParam('llm_thinking_budget', this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-2">Thinking 开启时,AI 会先思考再回答(可折叠查看)。Max Tokens 是总 token 上限(含 thinking)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 保存状态 -->
|
||||||
|
<div id="settings-save-status" class="text-xs text-emerald-600 transition-opacity text-center" style="opacity:0"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadConfigs();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadConfigs() {
|
||||||
|
this.state.loading = true;
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
if (content) content.innerHTML = this.render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.get('/api/prompts');
|
||||||
|
this.state.configs = resp.data.configs;
|
||||||
|
this.state.llmParams = resp.data.llm_params;
|
||||||
|
// 系统提示词:直接显示数据库值(空则用默认值填充)
|
||||||
|
this.state.systemPrompt = this.state.configs.system_prompt || PROMPT_DEFAULTS.system_prompt;
|
||||||
|
// 当前模板正文:直接显示数据库值(空则用默认值填充)
|
||||||
|
this._loadTemplateText();
|
||||||
|
this.state.loading = false;
|
||||||
|
this.state.error = null;
|
||||||
|
if (content) content.innerHTML = this.render();
|
||||||
|
} catch (e) {
|
||||||
|
this.state.loading = false;
|
||||||
|
this.state.error = e.message;
|
||||||
|
if (content) content.innerHTML = this.render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_loadTemplateText() {
|
||||||
|
const tplKey = `template_${this.state.template}`;
|
||||||
|
this.state.templateText = this.state.configs[tplKey] || PROMPT_DEFAULTS[tplKey];
|
||||||
|
},
|
||||||
|
|
||||||
|
setTemplate(t) {
|
||||||
|
this.state.template = t;
|
||||||
|
if (this.state.configs) this._loadTemplateText();
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 系统提示词 =====
|
||||||
|
async saveSystemPrompt(val) {
|
||||||
|
this.state.systemPrompt = val;
|
||||||
|
this._showSaveStatus('保存中...');
|
||||||
|
try {
|
||||||
|
await API.put('/api/prompts', { system_prompt: val });
|
||||||
|
this._showSaveStatus('已保存');
|
||||||
|
} catch (e) {
|
||||||
|
this._showSaveStatus('保存失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetSystemPrompt() {
|
||||||
|
this.state.systemPrompt = '';
|
||||||
|
this._showSaveStatus('重置中...');
|
||||||
|
try {
|
||||||
|
await API.put('/api/prompts', { system_prompt: '' });
|
||||||
|
const el = document.getElementById('settings-system-prompt');
|
||||||
|
if (el) el.value = '';
|
||||||
|
this._showSaveStatus('已恢复默认');
|
||||||
|
} catch (e) {
|
||||||
|
this._showSaveStatus('重置失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 模板正文 =====
|
||||||
|
async saveTemplateText(val) {
|
||||||
|
this.state.templateText = val;
|
||||||
|
this._showSaveStatus('保存中...');
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
body[`template_${this.state.template}`] = val;
|
||||||
|
await API.put('/api/prompts', body);
|
||||||
|
this._showSaveStatus('已保存');
|
||||||
|
} catch (e) {
|
||||||
|
this._showSaveStatus('保存失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetTemplateText() {
|
||||||
|
this.state.templateText = '';
|
||||||
|
this._showSaveStatus('重置中...');
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
body[`template_${this.state.template}`] = '';
|
||||||
|
await API.put('/api/prompts', body);
|
||||||
|
const el = document.getElementById('settings-template-text');
|
||||||
|
if (el) el.value = '';
|
||||||
|
this._showSaveStatus('已恢复默认');
|
||||||
|
} catch (e) {
|
||||||
|
this._showSaveStatus('重置失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== LLM 参数 =====
|
||||||
|
async saveLLMParam(key, value) {
|
||||||
|
this._showSaveStatus('保存中...');
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
body[key] = String(value);
|
||||||
|
await API.put('/api/prompts', body);
|
||||||
|
this._showSaveStatus('已保存');
|
||||||
|
// 重新加载参数
|
||||||
|
const resp = await API.get('/api/prompts');
|
||||||
|
this.state.llmParams = resp.data.llm_params;
|
||||||
|
} catch (e) {
|
||||||
|
this._showSaveStatus('保存失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 工具方法 =====
|
||||||
|
_showSaveStatus(msg) {
|
||||||
|
this.state.saveStatus = msg;
|
||||||
|
const el = document.getElementById('settings-save-status');
|
||||||
|
if (el) {
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.opacity = '1';
|
||||||
|
setTimeout(() => { el.style.opacity = '0'; }, 2000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
faiss-cpu>=1.7.4
|
||||||
|
pydantic>=2.0.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# run.md — QYLAW 法律法规知识库 运行手册
|
||||||
|
|
||||||
|
> 项目:QYLAW 法律法规知识库
|
||||||
|
> 部署:192.168.110.114 (Docker, --network host)
|
||||||
|
> 更新:2026-08-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术栈
|
||||||
|
|
||||||
|
| 项目 | 版本/说明 |
|
||||||
|
|------|------|
|
||||||
|
| 后端 | Python 3.11 + FastAPI + Uvicorn |
|
||||||
|
| 向量库 | FAISS (faiss-cpu, HNSW 索引) |
|
||||||
|
| metadata | SQLite (Python 内置 sqlite3) |
|
||||||
|
| 向量化 | Qwen3-Embedding-0.6B (114:8003, 复用) |
|
||||||
|
| RAG 生成 | Qwen3.5-35B (114:7000, 复用) |
|
||||||
|
| 前端 | HTML + Vanilla JS + Tailwind CDN |
|
||||||
|
| 部署 | Docker (--network host) |
|
||||||
|
| 端口 | 8090 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、首次准备
|
||||||
|
|
||||||
|
### 2.1 数据准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh nvidia@192.168.110.114
|
||||||
|
# 密码: qy123.
|
||||||
|
|
||||||
|
# 确认 law-pack 已传输到 114
|
||||||
|
ls /data/law-pack-2026-07-01-markdown/
|
||||||
|
# 预期: 法律/ 行政法规/ 监察法规/ 司法解释/ 地方性法规/ 地方性法规区域映射.json
|
||||||
|
|
||||||
|
# 若不存在,从本机 rsync 传输(310MB)
|
||||||
|
# 在本机执行:
|
||||||
|
rsync -avz --progress /Users/freedak/Documents/AIDashboard/qy123/law-pack-2026-07-01-markdown/ \
|
||||||
|
nvidia@192.168.110.114:/data/law-pack-2026-07-01-markdown/
|
||||||
|
|
||||||
|
# 创建索引数据目录
|
||||||
|
mkdir -p /data/law-kb-data/faiss /data/law-kb-data/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 确认依赖服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# embedding 服务(8003)
|
||||||
|
curl -s http://localhost:8003/v1/models | python3 -m json.tool
|
||||||
|
|
||||||
|
# qwen35 服务(7000)
|
||||||
|
curl -s http://localhost:7000/v1/models | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、索引构建
|
||||||
|
|
||||||
|
### 3.1 全量构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# [Docker] 在容器内执行(构建脚本需要访问 embedding 服务)
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v /data/law-pack-2026-07-01-markdown:/data/law-pack-2026-07-01-markdown:ro \
|
||||||
|
-v /data/law-kb-data:/data/law-kb-data \
|
||||||
|
law-kb:latest \
|
||||||
|
python scripts/build_index.py --mode full
|
||||||
|
|
||||||
|
# [Native] 本地直接执行(需已安装依赖)
|
||||||
|
cd law-kb
|
||||||
|
python scripts/build_index.py --mode full
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出示例**:
|
||||||
|
```
|
||||||
|
============================================
|
||||||
|
QYLAW 索引构建 — 全量模式
|
||||||
|
时间: 2026-08-06 14:00:00
|
||||||
|
============================================
|
||||||
|
扫描目录: /data/law-pack-2026-07-01-markdown
|
||||||
|
[法律] 349 文件, 切片 12500 条
|
||||||
|
[行政法规] 606 文件, 切片 28000 条
|
||||||
|
...
|
||||||
|
跳过文件(null 映射): 23 篇
|
||||||
|
总切片: 520000 条
|
||||||
|
向量维度: 1024
|
||||||
|
FAISS 索引大小: 1.2 GB
|
||||||
|
耗时: 2.5 小时
|
||||||
|
索引文件: /data/law-kb-data/faiss/index.faiss
|
||||||
|
SQLite: /data/law-kb-data/metadata.db
|
||||||
|
============================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 增量构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# [Docker]
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v /data/law-pack-2026-07-01-markdown:/data/law-pack-2026-07-01-markdown:ro \
|
||||||
|
-v /data/law-kb-data:/data/law-kb-data \
|
||||||
|
law-kb:latest \
|
||||||
|
python scripts/build_index.py --mode incremental
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 索引验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# [Docker]
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v /data/law-pack-2026-07-01-markdown:/data/law-pack-2026-07-01-markdown:ro \
|
||||||
|
-v /data/law-kb-data:/data/law-kb-data \
|
||||||
|
law-kb:latest \
|
||||||
|
python scripts/verify_index.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、应用启停
|
||||||
|
|
||||||
|
### 4.1 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# [Docker] 启动 law-kb 容器
|
||||||
|
docker run -d --name law-kb --network host \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /data/law-pack-2026-07-01-markdown:/data/law-pack-2026-07-01-markdown:ro \
|
||||||
|
-v /data/law-kb-data:/data/law-kb-data \
|
||||||
|
law-kb:latest
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
curl -s http://localhost:8090/health
|
||||||
|
curl -s http://localhost:8090/api/stats | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop law-kb
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker restart law-kb
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs law-kb --tail 50
|
||||||
|
docker logs -f law-kb
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、构建与部署
|
||||||
|
|
||||||
|
### 5.1 构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 114 上构建
|
||||||
|
cd /data/project/law-kb # 或项目实际路径
|
||||||
|
docker build -t law-kb:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 一键部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用部署脚本(本机执行,自动 rsync + 构建 + 启动)
|
||||||
|
bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、备份与恢复
|
||||||
|
|
||||||
|
### 6.1 备份索引
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 索引文件可独立备份(FAISS + SQLite)
|
||||||
|
ssh nvidia@192.168.110.114
|
||||||
|
tar -czf /data/backup/law-kb-index-$(date +%Y%m%d).tar.gz \
|
||||||
|
-C /data/law-kb-data faiss/ metadata.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 恢复索引
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh nvidia@192.168.110.114
|
||||||
|
docker stop law-kb
|
||||||
|
tar -xzf /data/backup/law-kb-index-YYYYMMDD.tar.gz -C /data/law-kb-data/
|
||||||
|
docker start law-kb
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、排错
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决方案 |
|
||||||
|
|------|------|------|
|
||||||
|
| 启动后 stats 返回 ready=false | 索引未构建 | 执行全量构建(三、3.1) |
|
||||||
|
| 检索返回 503 | embedding 服务未启动 | `docker ps` 确认 embedding 容器运行 |
|
||||||
|
| RAG 返回 503 | qwen35 服务未启动 | `docker ps` 确认 qwen35 容器运行 |
|
||||||
|
| RAG 返回 no_result | 相似度低于阈值(0.3) | 检查索引质量或调整问题表述 |
|
||||||
|
| 构建脚本 OOM | 切片数过多内存不足 | 减小 EMBEDDING_BATCH_SIZE |
|
||||||
|
| 容器无法访问 8003/7000 | 未用 --network host | 确认启动命令含 `--network host` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、端口表
|
||||||
|
|
||||||
|
| 端口 | 服务 | 类型 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 8090 | law-kb | HTTP | QYLAW 知识库 Web + API |
|
||||||
|
| 8003 | embedding | HTTP | 向量化服务(复用) |
|
||||||
|
| 7000 | qwen35 | HTTP | RAG 生成服务(复用) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、环境变量
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `LAW_PACK_DIR` | /data/law-pack-2026-07-01-markdown | 法规原文目录 |
|
||||||
|
| `LAW_KB_DATA_DIR` | /data/law-kb-data | 索引数据目录 |
|
||||||
|
| `EMBEDDING_URL` | http://localhost:8003/v1 | embedding 服务地址 |
|
||||||
|
| `LLM_URL` | http://localhost:7000/v1 | LLM 服务地址 |
|
||||||
|
| `EMBEDDING_MODEL` | qwen3-embedding-0.6b | embedding 模型名 |
|
||||||
|
| `LLM_MODEL` | qwen3.5-35b | LLM 模型名 |
|
||||||
|
| `EMBEDDING_BATCH_SIZE` | 32 | embedding 批量大小 |
|
||||||
|
| `APP_PORT` | 8090 | 应用端口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、FAQ
|
||||||
|
|
||||||
|
**Q: 为什么用 --network host?**
|
||||||
|
A: 容器需要访问 114 本地的 8003(embedding)和 7000(qwen35)端口。host 网络模式下容器直接用宿主网络,无需 --add-host 或端口映射,最简配置。内网环境无安全顾虑。
|
||||||
|
|
||||||
|
**Q: 索引构建中断了怎么办?**
|
||||||
|
A: 构建脚本先写临时文件,成功后原子替换。中断不会损坏已有索引。直接重跑即可。
|
||||||
|
|
||||||
|
**Q: 地方性法规 null 映射文件如何补?**
|
||||||
|
A: 编辑 `地方性法规区域映射.json` 补充映射,然后执行增量构建。
|
||||||
|
|
||||||
|
**Q: 如何更新法规数据?**
|
||||||
|
A: 替换 `/data/law-pack-2026-07-01-markdown/` 下的文件,执行增量构建(三、3.2)。
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""索引构建脚本 — 全量/增量构建 FAISS 索引 + SQLite metadata
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/build_index.py --mode full # 全量构建
|
||||||
|
python scripts/build_index.py --mode incremental # 增量构建
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 扫描 law-pack 目录,按类别切片
|
||||||
|
2. 写入 SQLite metadata(laws + clauses 表)
|
||||||
|
3. 批量调用 embedding 服务向量化所有条文
|
||||||
|
4. 构建 FAISS HNSW 索引并持久化
|
||||||
|
5. 输出统计信息
|
||||||
|
|
||||||
|
安全:
|
||||||
|
- 先写临时文件,成功后原子替换
|
||||||
|
- 中断不损坏已有索引
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
# 添加项目根目录到 path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from app.config import (
|
||||||
|
LAW_PACK_DIR,
|
||||||
|
LAW_KB_DATA_DIR,
|
||||||
|
FAISS_INDEX_PATH,
|
||||||
|
SQLITE_PATH,
|
||||||
|
LOGS_DIR,
|
||||||
|
REGION_MAPPING_PATH,
|
||||||
|
CATEGORY_DIRS,
|
||||||
|
EMBEDDING_BATCH_SIZE,
|
||||||
|
HNSW_M,
|
||||||
|
HNSW_EF_CONSTRUCTION,
|
||||||
|
)
|
||||||
|
from scripts.parse_clause import (
|
||||||
|
Clause,
|
||||||
|
load_region_mapping,
|
||||||
|
scan_category_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== SQLite Schema =====
|
||||||
|
SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS laws (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
publish_date TEXT,
|
||||||
|
province TEXT,
|
||||||
|
city TEXT,
|
||||||
|
region_level TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
clause_count INTEGER DEFAULT 0,
|
||||||
|
file_mtime REAL DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clauses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
law_id INTEGER NOT NULL,
|
||||||
|
chapter TEXT,
|
||||||
|
clause_no TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
faiss_idx INTEGER,
|
||||||
|
FOREIGN KEY (law_id) REFERENCES laws(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clauses_law_id ON clauses(law_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clauses_faiss_idx ON clauses(faiss_idx);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laws_category ON laws(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laws_province ON laws(province);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
config_key TEXT NOT NULL UNIQUE,
|
||||||
|
config_value TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompt_configs_key ON prompt_configs(config_key);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_sqlite(db_path: Path):
|
||||||
|
"""初始化 SQLite(创建表)"""
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def reset_sqlite(db_path: Path):
|
||||||
|
"""重置 SQLite(全量构建时用)"""
|
||||||
|
if db_path.exists():
|
||||||
|
db_path.unlink()
|
||||||
|
return init_sqlite(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_index(mode: str = "full"):
|
||||||
|
"""构建索引"""
|
||||||
|
start_time = time.time()
|
||||||
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"QYLAW 索引构建 — {mode} 模式")
|
||||||
|
logger.info(f"时间: {timestamp}")
|
||||||
|
logger.info(f"法规目录: {LAW_PACK_DIR}")
|
||||||
|
logger.info(f"数据目录: {LAW_KB_DATA_DIR}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
LAW_KB_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(LAW_KB_DATA_DIR / "faiss").mkdir(parents=True, exist_ok=True)
|
||||||
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 加载区域映射
|
||||||
|
region_mapping = load_region_mapping(REGION_MAPPING_PATH)
|
||||||
|
logger.info(f"加载区域映射: {len(region_mapping)} 条")
|
||||||
|
|
||||||
|
# 扫描所有类别
|
||||||
|
all_clauses: List[Clause] = []
|
||||||
|
all_skipped: List[str] = []
|
||||||
|
category_stats: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
for category in CATEGORY_DIRS:
|
||||||
|
logger.info(f"扫描 [{category}]...")
|
||||||
|
clauses, skipped = scan_category_dir(LAW_PACK_DIR, category, region_mapping)
|
||||||
|
all_clauses.extend(clauses)
|
||||||
|
all_skipped.extend(skipped)
|
||||||
|
law_count = len(set(c.law_name for c in clauses))
|
||||||
|
category_stats[category] = {
|
||||||
|
"laws": law_count,
|
||||||
|
"clauses": len(clauses),
|
||||||
|
"skipped": len(skipped),
|
||||||
|
}
|
||||||
|
logger.info(f" [{category}] {law_count} 法规, {len(clauses)} 条文, {len(skipped)} 跳过")
|
||||||
|
|
||||||
|
logger.info(f"总计: {len(all_clauses)} 条文, {len(all_skipped)} 跳过文件")
|
||||||
|
|
||||||
|
if not all_clauses:
|
||||||
|
logger.error("无有效条文,终止构建")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 写入 SQLite
|
||||||
|
if mode == "full":
|
||||||
|
conn = reset_sqlite(SQLITE_PATH)
|
||||||
|
else:
|
||||||
|
conn = init_sqlite(SQLITE_PATH)
|
||||||
|
|
||||||
|
# 按法规分组
|
||||||
|
laws_map: Dict[str, Clause] = {} # law_name -> 首个 clause(取 metadata)
|
||||||
|
law_clauses: Dict[str, List[Clause]] = {}
|
||||||
|
for c in all_clauses:
|
||||||
|
if c.law_name not in laws_map:
|
||||||
|
laws_map[c.law_name] = c
|
||||||
|
law_clauses[c.law_name] = []
|
||||||
|
law_clauses[c.law_name].append(c)
|
||||||
|
|
||||||
|
# 写 laws 表
|
||||||
|
law_id_map: Dict[str, int] = {}
|
||||||
|
for law_name, first_clause in laws_map.items():
|
||||||
|
file_path = Path(first_clause.file_path)
|
||||||
|
file_mtime = file_path.stat().st_mtime if file_path.exists() else 0
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO laws (name, category, publish_date, province, city, region_level,
|
||||||
|
file_path, clause_count, file_mtime, indexed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
law_name,
|
||||||
|
first_clause.category,
|
||||||
|
first_clause.publish_date,
|
||||||
|
first_clause.province,
|
||||||
|
first_clause.city,
|
||||||
|
first_clause.region_level,
|
||||||
|
first_clause.file_path,
|
||||||
|
len(law_clauses[law_name]),
|
||||||
|
file_mtime,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
law_id_map[law_name] = cur.lastrowid
|
||||||
|
|
||||||
|
# 写 clauses 表(暂不写 faiss_idx,向量化后更新)
|
||||||
|
clause_rows: List[tuple] = []
|
||||||
|
for law_name, clauses in law_clauses.items():
|
||||||
|
law_id = law_id_map[law_name]
|
||||||
|
for c in clauses:
|
||||||
|
clause_rows.append((law_id, c.chapter, c.clause_no, c.content))
|
||||||
|
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO clauses (law_id, chapter, clause_no, content) VALUES (?, ?, ?, ?)",
|
||||||
|
clause_rows,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 获取 clause id 顺序(与写入顺序一致)
|
||||||
|
clause_ids = [row[0] for row in conn.execute("SELECT id FROM clauses ORDER BY id").fetchall()]
|
||||||
|
clause_texts = [row[0] for row in conn.execute("SELECT content FROM clauses ORDER BY id").fetchall()]
|
||||||
|
logger.info(f"SQLite 写入完成: {len(clause_ids)} 条文, {len(law_id_map)} 法规")
|
||||||
|
|
||||||
|
# 批量向量化
|
||||||
|
logger.info(f"开始向量化(批量大小 {EMBEDDING_BATCH_SIZE})...")
|
||||||
|
|
||||||
|
# 延迟导入 embedding 服务(避免循环依赖)
|
||||||
|
from app.services.embedding import embed_batch
|
||||||
|
|
||||||
|
embed_start = time.time()
|
||||||
|
try:
|
||||||
|
all_vecs = await embed_batch(clause_texts, batch_size=EMBEDDING_BATCH_SIZE)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"向量化失败: {e}")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
embed_time = time.time() - embed_start
|
||||||
|
logger.info(f"向量化完成: {len(all_vecs)} 向量, 耗时 {embed_time:.0f}s")
|
||||||
|
|
||||||
|
if len(all_vecs) != len(clause_ids):
|
||||||
|
logger.error(f"向量数 {len(all_vecs)} != 条文数 {len(clause_ids)}")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
vec_dim = len(all_vecs[0]) if all_vecs else 0
|
||||||
|
logger.info(f"向量维度: {vec_dim}")
|
||||||
|
|
||||||
|
# 更新 clauses 表的 faiss_idx
|
||||||
|
for i, cid in enumerate(clause_ids):
|
||||||
|
conn.execute("UPDATE clauses SET faiss_idx = ? WHERE id = ?", (i, cid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 构建 FAISS 索引
|
||||||
|
# 注:HNSW 构建内存峰值高(68万向量约 30GB),114 内存紧张时 OOM
|
||||||
|
# 改用 IndexFlatL2(暴力检索,零额外内存,68万向量检索 <100ms)
|
||||||
|
# 若内存充裕可改回 HNSW:faiss.IndexHNSWFlat(vec_dim, HNSW_M)
|
||||||
|
logger.info("构建 FAISS Flat 索引(暴力检索,省内存)...")
|
||||||
|
import faiss
|
||||||
|
|
||||||
|
vecs_array = np.array(all_vecs, dtype=np.float32)
|
||||||
|
index = faiss.IndexFlatL2(vec_dim)
|
||||||
|
index.add(vecs_array)
|
||||||
|
|
||||||
|
logger.info(f"FAISS 索引构建完成: {index.ntotal} 向量")
|
||||||
|
|
||||||
|
# 原子写入(先写临时文件,成功后替换)
|
||||||
|
tmp_faiss = FAISS_INDEX_PATH.with_suffix(".faiss.tmp")
|
||||||
|
faiss.write_index(index, str(tmp_faiss))
|
||||||
|
|
||||||
|
# 替换
|
||||||
|
if FAISS_INDEX_PATH.exists():
|
||||||
|
FAISS_INDEX_PATH.unlink()
|
||||||
|
tmp_faiss.rename(FAISS_INDEX_PATH)
|
||||||
|
logger.info(f"FAISS 索引持久化: {FAISS_INDEX_PATH}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 输出统计
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
index_size = FAISS_INDEX_PATH.stat().st_size / 1024 / 1024
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"mode": mode,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"total_clauses": len(all_clauses),
|
||||||
|
"total_laws": len(law_id_map),
|
||||||
|
"category_stats": category_stats,
|
||||||
|
"skipped_files": all_skipped,
|
||||||
|
"vector_dim": vec_dim,
|
||||||
|
"faiss_index_size_mb": round(index_size, 1),
|
||||||
|
"embed_time_seconds": round(embed_time, 0),
|
||||||
|
"total_time_seconds": round(total_time, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 写统计到日志文件
|
||||||
|
stats_path = LOGS_DIR / f"build_{time.strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
|
with open(stats_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(stats, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 控制台输出
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f" QYLAW 索引构建完成 — {mode} 模式")
|
||||||
|
print(f" 耗时: {total_time:.0f}s ({total_time/3600:.1f}h)")
|
||||||
|
print("=" * 60)
|
||||||
|
for cat, s in category_stats.items():
|
||||||
|
print(f" [{cat}] {s['laws']} 法规, {s['clauses']} 条文, {s['skipped']} 跳过")
|
||||||
|
print(f" 总切片: {len(all_clauses)} 条")
|
||||||
|
print(f" 向量维度: {vec_dim}")
|
||||||
|
print(f" FAISS 索引: {index_size:.1f} MB")
|
||||||
|
print(f" 向量化耗时: {embed_time:.0f}s")
|
||||||
|
print(f" 跳过文件: {len(all_skipped)} 篇")
|
||||||
|
print(f" 索引文件: {FAISS_INDEX_PATH}")
|
||||||
|
print(f" SQLite: {SQLITE_PATH}")
|
||||||
|
print(f" 统计日志: {stats_path}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="QYLAW 索引构建")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["full", "incremental"],
|
||||||
|
default="full",
|
||||||
|
help="构建模式: full=全量重建, incremental=增量更新",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
asyncio.run(build_index(args.mode))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# QYLAW 部署脚本 — 传输数据 + 构建镜像 + 启动容器
|
||||||
|
# 用法: bash scripts/deploy.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ===== 配置 =====
|
||||||
|
REMOTE_HOST="nvidia@192.168.110.114"
|
||||||
|
REMOTE_LAW_PACK="/data/law-pack-2026-07-01-markdown"
|
||||||
|
REMOTE_LAW_KB_DATA="/data/law-kb-data"
|
||||||
|
LOCAL_LAW_PACK="/Users/freedak/Documents/AIDashboard/qy123/law-pack-2026-07-01-markdown"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " QYLAW 法律法规知识库部署"
|
||||||
|
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
# ===== Step 1: 传输法规数据(若 114 上不存在)=====
|
||||||
|
echo ""
|
||||||
|
echo "[Step 1] 检查并传输法规数据..."
|
||||||
|
ssh "$REMOTE_HOST" "test -d $REMOTE_LAW_PACK && echo EXISTS || echo MISSING"
|
||||||
|
LAW_PACK_STATUS=$(ssh "$REMOTE_HOST" "test -d $REMOTE_LAW_PACK && echo EXISTS || echo MISSING")
|
||||||
|
|
||||||
|
if [ "$LAW_PACK_STATUS" = "MISSING" ]; then
|
||||||
|
echo " 法规数据不存在,开始 rsync 传输(310MB)..."
|
||||||
|
if [ ! -d "$LOCAL_LAW_PACK" ]; then
|
||||||
|
echo " [错误] 本地法规数据不存在: $LOCAL_LAW_PACK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rsync -avz --progress "$LOCAL_LAW_PACK/" "$REMOTE_HOST:$REMOTE_LAW_PACK/"
|
||||||
|
echo " [OK] 法规数据传输完成"
|
||||||
|
else
|
||||||
|
echo " [OK] 法规数据已存在,跳过传输"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== Step 2: 创建索引数据目录 =====
|
||||||
|
echo ""
|
||||||
|
echo "[Step 2] 创建索引数据目录..."
|
||||||
|
ssh "$REMOTE_HOST" "mkdir -p $REMOTE_LAW_KB_DATA/faiss $REMOTE_LAW_KB_DATA/logs"
|
||||||
|
echo " [OK] 目录就绪"
|
||||||
|
|
||||||
|
# ===== Step 3: 传输项目代码并构建镜像 =====
|
||||||
|
echo ""
|
||||||
|
echo "[Step 3] 传输代码并构建 Docker 镜像..."
|
||||||
|
ssh "$REMOTE_HOST" "mkdir -p /data/project/law-kb"
|
||||||
|
rsync -avz --exclude '__pycache__' --exclude '.git' --exclude 'law-kb-data' \
|
||||||
|
"$PROJECT_DIR/" "$REMOTE_HOST:/data/project/law-kb/"
|
||||||
|
|
||||||
|
echo " 构建 Docker 镜像..."
|
||||||
|
ssh "$REMOTE_HOST" "cd /data/project/law-kb && docker build -t law-kb:latest ."
|
||||||
|
echo " [OK] 镜像构建完成"
|
||||||
|
|
||||||
|
# ===== Step 4: 停止旧容器(若存在)=====
|
||||||
|
echo ""
|
||||||
|
echo "[Step 4] 停止旧容器..."
|
||||||
|
ssh "$REMOTE_HOST" "docker rm -f law-kb 2>/dev/null || true"
|
||||||
|
echo " [OK] 旧容器已清理"
|
||||||
|
|
||||||
|
# ===== Step 5: 启动新容器 =====
|
||||||
|
echo ""
|
||||||
|
echo "[Step 5] 启动 law-kb 容器..."
|
||||||
|
ssh "$REMOTE_HOST" "
|
||||||
|
docker run -d --name law-kb --network host --restart unless-stopped \
|
||||||
|
-v $REMOTE_LAW_PACK:$REMOTE_LAW_PACK:ro \
|
||||||
|
-v $REMOTE_LAW_KB_DATA:$REMOTE_LAW_KB_DATA \
|
||||||
|
-e LAW_PACK_DIR=$REMOTE_LAW_PACK \
|
||||||
|
-e LAW_KB_DATA_DIR=$REMOTE_LAW_KB_DATA \
|
||||||
|
-e EMBEDDING_URL=http://localhost:8003/v1 \
|
||||||
|
-e LLM_URL=http://localhost:7000/v1 \
|
||||||
|
law-kb:latest
|
||||||
|
"
|
||||||
|
echo " [OK] 容器已启动"
|
||||||
|
|
||||||
|
# ===== Step 6: 健康检查 =====
|
||||||
|
echo ""
|
||||||
|
echo "[Step 6] 健康检查..."
|
||||||
|
sleep 3
|
||||||
|
echo " 等待服务启动..."
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
HEALTH=$(ssh "$REMOTE_HOST" "curl -s -o /dev/null -w '%{http_code}' http://localhost:8090/health 2>/dev/null || echo 000")
|
||||||
|
if [ "$HEALTH" = "200" ]; then
|
||||||
|
echo " [OK] 服务健康(尝试 $i)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " 等待中...($i/10, HTTP $HEALTH)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
echo ""
|
||||||
|
echo " 索引状态:"
|
||||||
|
ssh "$REMOTE_HOST" "curl -s http://localhost:8090/api/stats 2>/dev/null | python3 -m json.tool 2>/dev/null || echo ' 统计接口未就绪(可能需要先构建索引)'"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " 部署完成!"
|
||||||
|
echo " 访问: http://192.168.110.114:8090"
|
||||||
|
echo ""
|
||||||
|
echo " 下一步:"
|
||||||
|
echo " 1. 若索引未构建,执行:"
|
||||||
|
echo " ssh $REMOTE_HOST"
|
||||||
|
echo " docker run --rm --network host \\"
|
||||||
|
echo " -v $REMOTE_LAW_PACK:$REMOTE_LAW_PACK:ro \\"
|
||||||
|
echo " -v $REMOTE_LAW_KB_DATA:$REMOTE_LAW_KB_DATA \\"
|
||||||
|
echo " law-kb:latest python scripts/build_index.py --mode full"
|
||||||
|
echo " 2. 构建完成后重启容器: docker restart law-kb"
|
||||||
|
echo "============================================"
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""检索质量评估脚本
|
||||||
|
|
||||||
|
从 metadata.db 随机抽取条款,自动生成测试 query,
|
||||||
|
调用 /api/search 接口,计算 Recall@K、MRR、NDCG 等指标。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 scripts/eval_retrieval.py [--sample 500] [--top-k 10] [--api http://localhost:8090]
|
||||||
|
|
||||||
|
两种测试模式:
|
||||||
|
1. 结构化 query:"法规名 条号"(如"郑州市劳动用工条例 第三十二条")
|
||||||
|
→ 测试精确查找模式(keyword)和语义检索模式(semantic)的命中差异
|
||||||
|
2. 内容 query:条款内容前 50 字
|
||||||
|
→ 测试语义检索是否能通过条文片段找到原文
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Tuple
|
||||||
|
|
||||||
|
# 添加项目根目录到 path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def build_eval_dataset(db_path: str, sample_size: int = 500) -> List[Dict]:
|
||||||
|
"""从数据库随机抽取条款,构建评估数据集
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"clause_id", "law_name", "clause_no", "content", "law_id"}, ...]
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# 随机抽取条款(排除内容过短的)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.law_id, c.clause_no, c.content,
|
||||||
|
l.name as law_name, l.category, l.province
|
||||||
|
FROM clauses c JOIN laws l ON c.law_id = l.id
|
||||||
|
WHERE length(c.content) > 20 AND c.clause_no IS NOT NULL
|
||||||
|
ORDER BY RANDOM() LIMIT ?
|
||||||
|
""",
|
||||||
|
(sample_size,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
dataset = []
|
||||||
|
for r in rows:
|
||||||
|
dataset.append({
|
||||||
|
"clause_id": r["id"],
|
||||||
|
"law_id": r["law_id"],
|
||||||
|
"law_name": r["law_name"],
|
||||||
|
"clause_no": r["clause_no"],
|
||||||
|
"content": r["content"],
|
||||||
|
"category": r["category"],
|
||||||
|
"province": r["province"],
|
||||||
|
})
|
||||||
|
return dataset
|
||||||
|
|
||||||
|
|
||||||
|
def generate_queries(item: Dict) -> Dict[str, str]:
|
||||||
|
"""为一个条款生成多种 query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"structured": "法规名 条号", "content_snippet": "条款前50字"}
|
||||||
|
"""
|
||||||
|
# 结构化 query:法规名 + 条号
|
||||||
|
structured = f"{item['law_name']} {item['clause_no']}"
|
||||||
|
|
||||||
|
# 内容 query:条款内容前 50 字(去掉换行)
|
||||||
|
content_clean = item["content"].replace("\n", " ").strip()
|
||||||
|
snippet = content_clean[:50]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"structured": structured,
|
||||||
|
"content_snippet": snippet,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_api(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
api_base: str,
|
||||||
|
query: str,
|
||||||
|
mode: str = "semantic",
|
||||||
|
top_k: int = 10,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""调用检索 API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
结果列表,每项含 clause_id/law_name/clause_no/score 等
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"query": query,
|
||||||
|
"mode": mode,
|
||||||
|
"top_k": top_k,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": top_k,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"{api_base}/api/search", params=params, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
results = data.get("data", {}).get("results", [])
|
||||||
|
# 补充 clause_id(检索 API 返回的字段名)
|
||||||
|
for r in results:
|
||||||
|
if "clause_id" not in r:
|
||||||
|
r["clause_id"] = r.get("id")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] 检索失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def compute_recall_at_k(results: List[Dict], gt_clause_id: int, k: int) -> float:
|
||||||
|
"""Recall@K:ground truth 是否在 Top-K 结果中"""
|
||||||
|
top_k = results[:k]
|
||||||
|
for r in top_k:
|
||||||
|
if r.get("clause_id") == gt_clause_id:
|
||||||
|
return 1.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mrr(results: List[Dict], gt_clause_id: int) -> float:
|
||||||
|
"""MRR:第一个相关文档的排名倒数"""
|
||||||
|
for i, r in enumerate(results, 1):
|
||||||
|
if r.get("clause_id") == gt_clause_id:
|
||||||
|
return 1.0 / i
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_ndcg_at_k(results: List[Dict], gt_clause_id: int, k: int) -> float:
|
||||||
|
"""NDCG@K:二值相关性(命中=1,未命中=0)"""
|
||||||
|
dcg = 0.0
|
||||||
|
for i, r in enumerate(results[:k], 1):
|
||||||
|
if r.get("clause_id") == gt_clause_id:
|
||||||
|
dcg = 1.0 / math.log2(i + 1)
|
||||||
|
break
|
||||||
|
# IDCG:理想情况下相关文档排第 1
|
||||||
|
idcg = 1.0 / math.log2(2) # = 1.0
|
||||||
|
return dcg / idcg if idcg > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_hit_rate(results: List[Dict], gt_clause_id: int) -> float:
|
||||||
|
"""Hit Rate:是否至少命中一个相关文档"""
|
||||||
|
for r in results:
|
||||||
|
if r.get("clause_id") == gt_clause_id:
|
||||||
|
return 1.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def run_eval(
|
||||||
|
dataset: List[Dict],
|
||||||
|
api_base: str,
|
||||||
|
top_k: int = 10,
|
||||||
|
modes: List[str] = None,
|
||||||
|
query_types: List[str] = None,
|
||||||
|
) -> Dict:
|
||||||
|
"""运行评估
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataset: 评估数据集
|
||||||
|
api_base: API 地址
|
||||||
|
top_k: Top-K
|
||||||
|
modes: 检索模式列表 ["semantic", "keyword"]
|
||||||
|
query_types: query 类型列表 ["structured", "content_snippet"]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
评估结果报告
|
||||||
|
"""
|
||||||
|
if modes is None:
|
||||||
|
modes = ["semantic", "keyword"]
|
||||||
|
if query_types is None:
|
||||||
|
query_types = ["structured", "content_snippet"]
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"sample_size": len(dataset),
|
||||||
|
"top_k": top_k,
|
||||||
|
"modes": modes,
|
||||||
|
"query_types": query_types,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"results": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for mode in modes:
|
||||||
|
for qtype in query_types:
|
||||||
|
key = f"{mode}_{qtype}"
|
||||||
|
print(f"\n=== 评估: mode={mode}, query_type={qtype} ===")
|
||||||
|
|
||||||
|
recalls = []
|
||||||
|
mrrs = []
|
||||||
|
ndcgs = []
|
||||||
|
hit_rates = []
|
||||||
|
latencies = []
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for i, item in enumerate(dataset):
|
||||||
|
queries = generate_queries(item)
|
||||||
|
query = queries.get(qtype, "")
|
||||||
|
if not query:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gt_id = item["clause_id"]
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
results = await search_api(client, api_base, query, mode, top_k)
|
||||||
|
latency = (time.time() - t0) * 1000
|
||||||
|
latencies.append(latency)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
errors += 1
|
||||||
|
recalls.append(0.0)
|
||||||
|
mrrs.append(0.0)
|
||||||
|
ndcgs.append(0.0)
|
||||||
|
hit_rates.append(0.0)
|
||||||
|
else:
|
||||||
|
recalls.append(compute_recall_at_k(results, gt_id, top_k))
|
||||||
|
mrrs.append(compute_mrr(results, gt_id))
|
||||||
|
ndcgs.append(compute_ndcg_at_k(results, gt_id, top_k))
|
||||||
|
hit_rates.append(compute_hit_rate(results, gt_id))
|
||||||
|
|
||||||
|
# 进度
|
||||||
|
if (i + 1) % 50 == 0:
|
||||||
|
avg_recall = sum(recalls) / len(recalls)
|
||||||
|
print(f" 进度: {i+1}/{len(dataset)} | Recall@{top_k}={avg_recall:.3f}")
|
||||||
|
|
||||||
|
n = len(recalls)
|
||||||
|
report["results"][key] = {
|
||||||
|
"mode": mode,
|
||||||
|
"query_type": qtype,
|
||||||
|
"count": n,
|
||||||
|
"errors": errors,
|
||||||
|
"recall_at_k": sum(recalls) / n if n else 0,
|
||||||
|
"mrr": sum(mrrs) / n if n else 0,
|
||||||
|
"ndcg_at_k": sum(ndcgs) / n if n else 0,
|
||||||
|
"hit_rate": sum(hit_rates) / n if n else 0,
|
||||||
|
"avg_latency_ms": sum(latencies) / len(latencies) if latencies else 0,
|
||||||
|
"p95_latency_ms": sorted(latencies)[int(len(latencies) * 0.95)] if latencies else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
r = report["results"][key]
|
||||||
|
print(f" 结果: Recall@{top_k}={r['recall_at_k']:.3f} | MRR={r['mrr']:.3f} | "
|
||||||
|
f"NDCG@{top_k}={r['ndcg_at_k']:.3f} | HitRate={r['hit_rate']:.3f} | "
|
||||||
|
f"avg={r['avg_latency_ms']:.0f}ms | errors={errors}")
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(report: Dict):
|
||||||
|
"""打印评估报告"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("检索质量评估报告")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"时间: {report['timestamp']}")
|
||||||
|
print(f"样本数: {report['sample_size']}")
|
||||||
|
print(f"Top-K: {report['top_k']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 表格输出
|
||||||
|
print(f"{'模式':<12} {'Query类型':<18} {'Recall@K':>10} {'MRR':>10} {'NDCG@K':>10} {'HitRate':>10} {'avg(ms)':>10} {'错误':>6}")
|
||||||
|
print("-" * 96)
|
||||||
|
for key, r in report["results"].items():
|
||||||
|
print(f"{r['mode']:<12} {r['query_type']:<18} "
|
||||||
|
f"{r['recall_at_k']:>10.3f} {r['mrr']:>10.3f} {r['ndcg_at_k']:>10.3f} "
|
||||||
|
f"{r['hit_rate']:>10.3f} {r['avg_latency_ms']:>10.0f} {r['errors']:>6}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
# 分析建议
|
||||||
|
sem_struct = report["results"].get("semantic_structured", {})
|
||||||
|
kw_struct = report["results"].get("keyword_structured", {})
|
||||||
|
sem_content = report["results"].get("semantic_content_snippet", {})
|
||||||
|
|
||||||
|
print("分析:")
|
||||||
|
if sem_struct and kw_struct:
|
||||||
|
if kw_struct["recall_at_k"] > sem_struct["recall_at_k"] + 0.1:
|
||||||
|
print(f" - 结构化查询(法规名+条号):精确查找(Recall={kw_struct['recall_at_k']:.3f})"
|
||||||
|
f" 显著优于语义检索(Recall={sem_struct['recall_at_k']:.3f})")
|
||||||
|
print(f" → 建议:用户输入法规名+条号时,自动切换到精确查找模式")
|
||||||
|
else:
|
||||||
|
print(f" - 结构化查询:语义检索(Recall={sem_struct['recall_at_k']:.3f})"
|
||||||
|
f" 与精确查找(Recall={kw_struct['recall_at_k']:.3f})相当")
|
||||||
|
|
||||||
|
if sem_content:
|
||||||
|
r = sem_content
|
||||||
|
if r["recall_at_k"] > 0.8:
|
||||||
|
print(f" - 内容片段查询:语义检索表现优秀(Recall={r['recall_at_k']:.3f})")
|
||||||
|
elif r["recall_at_k"] > 0.5:
|
||||||
|
print(f" - 内容片段查询:语义检索表现一般(Recall={r['recall_at_k']:.3f}),有提升空间")
|
||||||
|
else:
|
||||||
|
print(f" - 内容片段查询:语义检索表现较差(Recall={r['recall_at_k']:.3f}),需检查 embedding 模型")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="检索质量评估")
|
||||||
|
parser.add_argument("--sample", type=int, default=500, help="抽样数量(默认 500)")
|
||||||
|
parser.add_argument("--top-k", type=int, default=10, help="Top-K(默认 10)")
|
||||||
|
parser.add_argument("--api", type=str, default="http://localhost:8090", help="API 地址")
|
||||||
|
parser.add_argument("--db", type=str, default="/data/law-kb-data/metadata.db", help="SQLite 路径")
|
||||||
|
parser.add_argument("--seed", type=int, default=42, help="随机种子(默认 42)")
|
||||||
|
parser.add_argument("--output", type=str, default=None, help="结果输出 JSON 文件路径")
|
||||||
|
parser.add_argument("--modes", type=str, nargs="+", default=["semantic", "keyword"],
|
||||||
|
help="检索模式(默认 semantic keyword)")
|
||||||
|
parser.add_argument("--query-types", type=str, nargs="+",
|
||||||
|
default=["structured", "content_snippet"],
|
||||||
|
help="query 类型(默认 structured content_snippet)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
random.seed(args.seed)
|
||||||
|
|
||||||
|
print(f"构建评估数据集(抽样 {args.sample} 条)...")
|
||||||
|
dataset = build_eval_dataset(args.db, args.sample)
|
||||||
|
print(f"数据集大小: {len(dataset)}")
|
||||||
|
|
||||||
|
if not dataset:
|
||||||
|
print("错误:数据集为空,检查数据库路径")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 运行评估
|
||||||
|
report = asyncio.run(run_eval(
|
||||||
|
dataset=dataset,
|
||||||
|
api_base=args.api,
|
||||||
|
top_k=args.top_k,
|
||||||
|
modes=args.modes,
|
||||||
|
query_types=args.query_types,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 打印报告
|
||||||
|
print_report(report)
|
||||||
|
|
||||||
|
# 保存结果
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"结果已保存到: {args.output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""法规切片解析器 — 按"第X条"切片,提取 metadata
|
||||||
|
|
||||||
|
解析 markdown 法规文件:
|
||||||
|
- 法规名:文件名去 .md 后缀,去末尾 _YYYYMMDD
|
||||||
|
- 发布日期:文件名末尾 _YYYYMMDD
|
||||||
|
- 章节:跟踪"第X章"上下文
|
||||||
|
- 条文:按"第X条"切片,内容聚合到下一个"第X条"/"第X章"/"第X节"
|
||||||
|
- 地方性法规:从 地方性法规区域映射.json 附加省份/市级
|
||||||
|
|
||||||
|
用法:
|
||||||
|
from scripts.parse_clause import parse_law_file
|
||||||
|
clauses = parse_law_file("/data/law-pack/法律/中华人民共和国民法典_20200528.md", "法律")
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 中文数字正则(支持"第一""第二十二""第一百二十六"等)
|
||||||
|
CN_NUM = r"[一二三四五六七八九十百千零〇两]+"
|
||||||
|
# 条文开头:第X条 + 全角空格/普通空格
|
||||||
|
CLAUSE_RE = re.compile(rf"^第({CN_NUM})条[\s\u3000]+(.*)")
|
||||||
|
# 章节开头:第X章 + 标题
|
||||||
|
CHAPTER_RE = re.compile(rf"^第{CN_NUM}章[\s\u3000]+(.+)")
|
||||||
|
# 节开头:第X节
|
||||||
|
SECTION_RE = re.compile(rf"^第{CN_NUM}节[\s\u3000]+(.+)")
|
||||||
|
# 编开头:第X编
|
||||||
|
PART_RE = re.compile(rf"^第{CN_NUM}编[\s\u3000]+(.+)")
|
||||||
|
# 文件名日期后缀
|
||||||
|
DATE_SUFFIX_RE = re.compile(r"_(\d{8})$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Clause:
|
||||||
|
"""法规条文"""
|
||||||
|
law_name: str
|
||||||
|
category: str
|
||||||
|
chapter: Optional[str] = None # 当前章节,如"第一章 基本规定"
|
||||||
|
clause_no: str = "" # 条号,如"第一条" / "第143条"
|
||||||
|
content: str = "" # 条文内容
|
||||||
|
publish_date: Optional[str] = None # 发布日期 YYYY-MM-DD
|
||||||
|
file_path: str = "" # 原文路径
|
||||||
|
province: Optional[str] = None # 省份(地方性法规)
|
||||||
|
city: Optional[str] = None # 市级(地方性法规)
|
||||||
|
region_level: Optional[str] = None # 区域级别(省级/市级)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"law_name": self.law_name,
|
||||||
|
"category": self.category,
|
||||||
|
"chapter": self.chapter,
|
||||||
|
"clause_no": self.clause_no,
|
||||||
|
"content": self.content,
|
||||||
|
"publish_date": self.publish_date,
|
||||||
|
"file_path": self.file_path,
|
||||||
|
"province": self.province,
|
||||||
|
"city": self.city,
|
||||||
|
"region_level": self.region_level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_name_and_date(filename: str) -> tuple[str, Optional[str]]:
|
||||||
|
"""从文件名提取法规名和发布日期
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名(含或不含 .md 后缀)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(法规名, 发布日期 YYYY-MM-DD 或 None)
|
||||||
|
"""
|
||||||
|
stem = Path(filename).stem
|
||||||
|
match = DATE_SUFFIX_RE.search(stem)
|
||||||
|
if match:
|
||||||
|
date_str = match.group(1)
|
||||||
|
name = stem[: match.start()]
|
||||||
|
# 格式化日期 YYYY-MM-DD
|
||||||
|
date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
|
||||||
|
return name, date_formatted
|
||||||
|
return stem, None
|
||||||
|
|
||||||
|
|
||||||
|
def load_region_mapping(mapping_path: Path) -> Dict[str, Optional[Dict]]:
|
||||||
|
"""加载地方性法规区域映射
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{文件名: {region_name, province, level, region_id, province_id} | None}
|
||||||
|
"""
|
||||||
|
if not mapping_path.exists():
|
||||||
|
logger.warning(f"区域映射文件不存在: {mapping_path}")
|
||||||
|
return {}
|
||||||
|
with open(mapping_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_law_file(
|
||||||
|
filepath: str | Path,
|
||||||
|
category: str,
|
||||||
|
region_mapping: Optional[Dict] = None,
|
||||||
|
) -> List[Clause]:
|
||||||
|
"""解析单个法规文件,按"第X条"切片
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: 法规 markdown 文件路径
|
||||||
|
category: 法规类别(法律/行政法规/监察法规/司法解释/地方性法规)
|
||||||
|
region_mapping: 地方性法规区域映射(文件名 -> {province, city, ...})
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
条文列表;若文件无法识别"第X条"则返回空列表
|
||||||
|
"""
|
||||||
|
filepath = Path(filepath)
|
||||||
|
filename = filepath.name
|
||||||
|
law_name, publish_date = extract_name_and_date(filename)
|
||||||
|
|
||||||
|
# 地方性法规附加区域信息
|
||||||
|
province = None
|
||||||
|
city = None
|
||||||
|
region_level = None
|
||||||
|
if category == "地方性法规" and region_mapping is not None:
|
||||||
|
info = region_mapping.get(filename)
|
||||||
|
if info is None:
|
||||||
|
# 映射为 null,跳过(调用方负责记录)
|
||||||
|
return []
|
||||||
|
province = info.get("province")
|
||||||
|
region_level = info.get("level")
|
||||||
|
# 市级:region_name 如果不是省份名,则视为市级
|
||||||
|
region_name = info.get("region_name")
|
||||||
|
if region_name and province and region_name != province:
|
||||||
|
city = region_name
|
||||||
|
|
||||||
|
# 读取文件
|
||||||
|
try:
|
||||||
|
text = filepath.read_text(encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取失败 {filepath}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
clauses: List[Clause] = []
|
||||||
|
current_chapter: Optional[str] = None
|
||||||
|
current_clause: Optional[Clause] = None
|
||||||
|
current_content_lines: List[str] = []
|
||||||
|
|
||||||
|
def _flush_current():
|
||||||
|
"""将当前条文写入列表"""
|
||||||
|
nonlocal current_clause, current_content_lines
|
||||||
|
if current_clause is not None:
|
||||||
|
content = "\n".join(current_content_lines).strip()
|
||||||
|
if content:
|
||||||
|
current_clause.content = content
|
||||||
|
current_clause.chapter = current_chapter
|
||||||
|
clauses.append(current_clause)
|
||||||
|
current_clause = None
|
||||||
|
current_content_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# 跳过空行(但保留在内容中,后续 strip 处理)
|
||||||
|
if not stripped:
|
||||||
|
if current_clause is not None:
|
||||||
|
current_content_lines.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检测章节
|
||||||
|
chap_match = CHAPTER_RE.match(stripped)
|
||||||
|
if chap_match:
|
||||||
|
_flush_current()
|
||||||
|
current_chapter = stripped
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检测节(不切片,只更新上下文)
|
||||||
|
if SECTION_RE.match(stripped):
|
||||||
|
_flush_current()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检测编(不切片)
|
||||||
|
if PART_RE.match(stripped):
|
||||||
|
_flush_current()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检测条文
|
||||||
|
clause_match = CLAUSE_RE.match(stripped)
|
||||||
|
if clause_match:
|
||||||
|
_flush_current()
|
||||||
|
clause_no = f"第{clause_match.group(1)}条"
|
||||||
|
first_line = clause_match.group(2).strip()
|
||||||
|
current_clause = Clause(
|
||||||
|
law_name=law_name,
|
||||||
|
category=category,
|
||||||
|
clause_no=clause_no,
|
||||||
|
publish_date=publish_date,
|
||||||
|
file_path=str(filepath),
|
||||||
|
province=province,
|
||||||
|
city=city,
|
||||||
|
region_level=region_level,
|
||||||
|
)
|
||||||
|
current_content_lines = [first_line] if first_line else []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 普通行:若在条文中,追加到内容
|
||||||
|
if current_clause is not None:
|
||||||
|
current_content_lines.append(stripped)
|
||||||
|
|
||||||
|
_flush_current()
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
|
||||||
|
def scan_category_dir(
|
||||||
|
law_pack_dir: Path,
|
||||||
|
category: str,
|
||||||
|
region_mapping: Optional[Dict] = None,
|
||||||
|
) -> tuple[List[Clause], List[str]]:
|
||||||
|
"""扫描某类别目录下所有法规文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
law_pack_dir: 法规根目录
|
||||||
|
category: 类别(法律/行政法规/...)
|
||||||
|
region_mapping: 区域映射(仅地方性法规需要)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(条文列表, 跳过文件列表[null 映射或解析失败])
|
||||||
|
"""
|
||||||
|
cat_dir = law_pack_dir / category
|
||||||
|
if not cat_dir.exists():
|
||||||
|
logger.warning(f"类别目录不存在: {cat_dir}")
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
all_clauses: List[Clause] = []
|
||||||
|
skipped: List[str] = []
|
||||||
|
|
||||||
|
md_files = sorted(cat_dir.glob("*.md"))
|
||||||
|
for md_file in md_files:
|
||||||
|
clauses = parse_law_file(md_file, category, region_mapping)
|
||||||
|
if not clauses:
|
||||||
|
# 区分 null 映射跳过 vs 解析失败
|
||||||
|
if category == "地方性法规" and region_mapping is not None:
|
||||||
|
info = region_mapping.get(md_file.name)
|
||||||
|
if info is None:
|
||||||
|
skipped.append(f"[null映射] {md_file.name}")
|
||||||
|
else:
|
||||||
|
skipped.append(f"[无条文] {md_file.name}")
|
||||||
|
else:
|
||||||
|
skipped.append(f"[无条文] {md_file.name}")
|
||||||
|
else:
|
||||||
|
all_clauses.extend(clauses)
|
||||||
|
|
||||||
|
return all_clauses, skipped
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 自测:解析民法典
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
test_file = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
|
if test_file:
|
||||||
|
clauses = parse_law_file(test_file, "法律")
|
||||||
|
print(f"解析 {test_file}: {len(clauses)} 条")
|
||||||
|
for c in clauses[:3]:
|
||||||
|
print(f" {c.clause_no} [{c.chapter}] {c.content[:50]}...")
|
||||||
|
else:
|
||||||
|
print("用法: python parse_clause.py <法规文件路径>")
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""FAISS 索引重建脚本 — 从 SQLite 读取已有条文,重新向量化 + 构建 FAISS
|
||||||
|
|
||||||
|
用途:索引构建因 OOM/中断失败,但 SQLite metadata 已完好时,
|
||||||
|
跳过切片解析,仅重新向量化 + 构建 FAISS 索引。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/rebuild_faiss.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from app.config import (
|
||||||
|
SQLITE_PATH,
|
||||||
|
FAISS_INDEX_PATH,
|
||||||
|
LAW_KB_DATA_DIR,
|
||||||
|
EMBEDDING_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def rebuild():
|
||||||
|
"""从 SQLite 读取条文,重新向量化 + 构建 FAISS Flat 索引"""
|
||||||
|
start_time = time.time()
|
||||||
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"QYLAW FAISS 索引重建(跳过切片,从 SQLite 读取)")
|
||||||
|
logger.info(f"时间: {timestamp}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
if not SQLITE_PATH.exists():
|
||||||
|
logger.error(f"SQLite 不存在: {SQLITE_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(SQLITE_PATH))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# 读取所有条文(按 faiss_idx 排序,保证顺序一致)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, faiss_idx, content FROM clauses WHERE faiss_idx IS NOT NULL ORDER BY faiss_idx"
|
||||||
|
).fetchall()
|
||||||
|
total = len(rows)
|
||||||
|
logger.info(f"从 SQLite 读取: {total} 条文")
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.error("无条文,终止")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
clause_texts = [row["content"] for row in rows]
|
||||||
|
clause_ids = [row["id"] for row in rows]
|
||||||
|
|
||||||
|
# 向量化(分批写入预分配的 numpy 数组,避免 Python list 内存爆炸)
|
||||||
|
logger.info(f"开始向量化(批量大小 {EMBEDDING_BATCH_SIZE})...")
|
||||||
|
from app.services.embedding import embed_batch
|
||||||
|
|
||||||
|
embed_start = time.time()
|
||||||
|
|
||||||
|
# 先用第一批获取向量维度,然后预分配 numpy 数组
|
||||||
|
first_batch = clause_texts[:EMBEDDING_BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
first_vecs = await embed_batch(first_batch, batch_size=EMBEDDING_BATCH_SIZE)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"向量化失败: {e}")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
vec_dim = len(first_vecs[0])
|
||||||
|
logger.info(f"向量维度: {vec_dim}")
|
||||||
|
|
||||||
|
# 预分配 numpy 数组(68万 × 1024 × 4字节 ≈ 2.6GB,一次性分配)
|
||||||
|
vecs_array = np.zeros((total, vec_dim), dtype=np.float32)
|
||||||
|
vecs_array[:len(first_vecs)] = np.array(first_vecs, dtype=np.float32)
|
||||||
|
logger.info(f"预分配 numpy 数组: {total} × {vec_dim} ({vecs_array.nbytes / 1024**3:.1f} GB)")
|
||||||
|
|
||||||
|
# 分批向量化剩余条文,直接写入 numpy 数组
|
||||||
|
for i in range(EMBEDDING_BATCH_SIZE, total, EMBEDDING_BATCH_SIZE):
|
||||||
|
batch = clause_texts[i : i + EMBEDDING_BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
batch_vecs = await embed_batch(batch, batch_size=EMBEDDING_BATCH_SIZE)
|
||||||
|
vecs_array[i : i + len(batch_vecs)] = np.array(batch_vecs, dtype=np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"向量化批次 {i} 失败: {e}")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
if (i // EMBEDDING_BATCH_SIZE) % 1000 == 0:
|
||||||
|
logger.info(f"进度: {i}/{total} ({i*100//total}%)")
|
||||||
|
|
||||||
|
embed_time = time.time() - embed_start
|
||||||
|
logger.info(f"向量化完成: {total} 向量, 耗时 {embed_time:.0f}s")
|
||||||
|
|
||||||
|
# 释放 clause_texts 内存(不再需要)
|
||||||
|
del clause_texts
|
||||||
|
|
||||||
|
# 构建 FAISS Flat 索引(省内存,暴力检索)
|
||||||
|
logger.info("构建 FAISS Flat 索引...")
|
||||||
|
import faiss
|
||||||
|
|
||||||
|
index = faiss.IndexFlatL2(vec_dim)
|
||||||
|
index.add(vecs_array)
|
||||||
|
logger.info(f"FAISS 索引构建完成: {index.ntotal} 向量")
|
||||||
|
|
||||||
|
# 释放 numpy 数组(已写入 FAISS 索引)
|
||||||
|
del vecs_array
|
||||||
|
|
||||||
|
# 原子写入
|
||||||
|
FAISS_INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_faiss = FAISS_INDEX_PATH.with_suffix(".faiss.tmp")
|
||||||
|
faiss.write_index(index, str(tmp_faiss))
|
||||||
|
if FAISS_INDEX_PATH.exists():
|
||||||
|
FAISS_INDEX_PATH.unlink()
|
||||||
|
tmp_faiss.rename(FAISS_INDEX_PATH)
|
||||||
|
|
||||||
|
index_size = FAISS_INDEX_PATH.stat().st_size / 1024 / 1024
|
||||||
|
logger.info(f"FAISS 索引持久化: {FAISS_INDEX_PATH} ({index_size:.1f} MB)")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f" FAISS 索引重建完成")
|
||||||
|
print(f" 总耗时: {total_time:.0f}s ({total_time/60:.1f}min)")
|
||||||
|
print(f" 向量数: {total}")
|
||||||
|
print(f" 向量维度: {vec_dim}")
|
||||||
|
print(f" 索引大小: {index_size:.1f} MB")
|
||||||
|
print(f" 向量化耗时: {embed_time:.0f}s")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
asyncio.run(rebuild())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""索引验证脚本 — 预置典型查询,抽样验证检索质量
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/verify_index.py
|
||||||
|
|
||||||
|
输出每个查询的 Top-5 结果,供人工评估命中率。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from app.services.retriever import Retriever
|
||||||
|
from app.services.embedding import embed_text
|
||||||
|
|
||||||
|
|
||||||
|
# 预置典型查询(20 个,覆盖各类别)
|
||||||
|
TEST_QUERIES = [
|
||||||
|
# 法律
|
||||||
|
"个人信息保护",
|
||||||
|
"个人信息跨境传输",
|
||||||
|
"竞业协议补偿金",
|
||||||
|
"民法典婚姻家庭",
|
||||||
|
"合同违约责任",
|
||||||
|
"知识产权侵权赔偿",
|
||||||
|
"公司股东权利",
|
||||||
|
# 行政法规
|
||||||
|
"不动产登记流程",
|
||||||
|
"专利申请条件",
|
||||||
|
# 司法解释
|
||||||
|
"公益诉讼办案规则",
|
||||||
|
"刑事诉讼证据规则",
|
||||||
|
# 监察法规
|
||||||
|
"监察工作信息公开",
|
||||||
|
# 地方性法规
|
||||||
|
"垃圾分类管理",
|
||||||
|
"烟花爆竹禁放",
|
||||||
|
"物业管理规定",
|
||||||
|
"生态环境保护",
|
||||||
|
"城市市容管理",
|
||||||
|
"食品安全监管",
|
||||||
|
"道路交通管理",
|
||||||
|
"未成年人保护",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify():
|
||||||
|
"""执行验证"""
|
||||||
|
# 加载索引
|
||||||
|
retriever = Retriever.get_instance()
|
||||||
|
retriever.load()
|
||||||
|
|
||||||
|
if not retriever.is_ready():
|
||||||
|
print("索引未加载,请先执行构建")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print(" QYLAW 索引验证")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
hit_count = 0
|
||||||
|
total = len(TEST_QUERIES)
|
||||||
|
|
||||||
|
for i, query in enumerate(TEST_QUERIES, 1):
|
||||||
|
print(f"\n[{i}/{total}] 查询: {query}")
|
||||||
|
try:
|
||||||
|
query_vec = await embed_text(query)
|
||||||
|
results = retriever.search(query_vec, top_k=5)
|
||||||
|
if results:
|
||||||
|
print(f" Top-5 结果:")
|
||||||
|
for j, r in enumerate(results, 1):
|
||||||
|
score = r["score"]
|
||||||
|
law = r["law_name"]
|
||||||
|
clause = r.get("clause_no", "")
|
||||||
|
chapter = r.get("chapter", "")
|
||||||
|
content_preview = r["content"][:80].replace("\n", " ")
|
||||||
|
print(f" {j}. [{score:.4f}] {law} {clause} ({chapter})")
|
||||||
|
print(f" {content_preview}...")
|
||||||
|
# 简单命中率判断:Top-1 分数 > 0.5 视为命中
|
||||||
|
if results[0]["score"] > 0.5:
|
||||||
|
hit_count += 1
|
||||||
|
print(f" ✅ 命中")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 分数偏低")
|
||||||
|
else:
|
||||||
|
print(f" ❌ 无结果")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 错误: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(f" 验证完成: {hit_count}/{total} 命中 (Top-1 score > 0.5)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
asyncio.run(verify())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""切片解析单测"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from scripts.parse_clause import (
|
||||||
|
parse_law_file,
|
||||||
|
extract_name_and_date,
|
||||||
|
load_region_mapping,
|
||||||
|
scan_category_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 测试数据路径(相对于项目根)
|
||||||
|
PACK_DIR = Path(__file__).parent.parent.parent / "law-pack-2026-07-01-markdown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_name_and_date():
|
||||||
|
"""测试文件名解析"""
|
||||||
|
name, date = extract_name_and_date("中华人民共和国民法典_20200528.md")
|
||||||
|
assert name == "中华人民共和国民法典"
|
||||||
|
assert date == "2020-05-28"
|
||||||
|
|
||||||
|
name, date = extract_name_and_date("不动产登记暂行条例_20240310.md")
|
||||||
|
assert name == "不动产登记暂行条例"
|
||||||
|
assert date == "2024-03-10"
|
||||||
|
|
||||||
|
# 无日期后缀
|
||||||
|
name, date = extract_name_and_date("某法规.md")
|
||||||
|
assert name == "某法规"
|
||||||
|
assert date is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_civil_code():
|
||||||
|
"""测试民法典切片"""
|
||||||
|
f = PACK_DIR / "法律" / "中华人民共和国民法典_20200528.md"
|
||||||
|
if not f.exists():
|
||||||
|
return # 数据不存在时跳过
|
||||||
|
clauses = parse_law_file(f, "法律")
|
||||||
|
assert len(clauses) > 1000 # 民法典 1260 条
|
||||||
|
assert clauses[0].clause_no == "第一条"
|
||||||
|
assert clauses[0].law_name == "中华人民共和国民法典"
|
||||||
|
assert clauses[0].category == "法律"
|
||||||
|
assert clauses[0].publish_date == "2020-05-28"
|
||||||
|
assert "保护民事主体" in clauses[0].content
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_admin_regulation():
|
||||||
|
"""测试行政法规切片"""
|
||||||
|
f = PACK_DIR / "行政法规" / "不动产登记暂行条例_20240310.md"
|
||||||
|
if not f.exists():
|
||||||
|
return
|
||||||
|
clauses = parse_law_file(f, "行政法规")
|
||||||
|
assert len(clauses) > 0
|
||||||
|
assert clauses[0].category == "行政法规"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_local_regulation_with_mapping():
|
||||||
|
"""测试地方性法规 + 区域映射"""
|
||||||
|
mapping = load_region_mapping(PACK_DIR / "地方性法规区域映射.json")
|
||||||
|
f = PACK_DIR / "地方性法规" / "七台河市倭肯河流域水环境保护条例_20211224.md"
|
||||||
|
if not f.exists():
|
||||||
|
return
|
||||||
|
clauses = parse_law_file(f, "地方性法规", mapping)
|
||||||
|
assert len(clauses) > 0
|
||||||
|
assert clauses[0].province == "黑龙江省"
|
||||||
|
assert clauses[0].city == "七台河市"
|
||||||
|
assert clauses[0].region_level == "市级"
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_mapping_skipped():
|
||||||
|
"""测试 null 映射文件跳过"""
|
||||||
|
mapping = load_region_mapping(PACK_DIR / "地方性法规区域映射.json")
|
||||||
|
f = PACK_DIR / "地方性法规" / "~$壮族自治区巴马盘阳河流域生态环境保护条例_20150527.md"
|
||||||
|
if not f.exists():
|
||||||
|
return
|
||||||
|
clauses = parse_law_file(f, "地方性法规", mapping)
|
||||||
|
assert len(clauses) == 0 # null 映射应跳过
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 手动运行(无 pytest 时)
|
||||||
|
tests = [
|
||||||
|
test_extract_name_and_date,
|
||||||
|
test_parse_civil_code,
|
||||||
|
test_parse_admin_regulation,
|
||||||
|
test_parse_local_regulation_with_mapping,
|
||||||
|
test_null_mapping_skipped,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
try:
|
||||||
|
t()
|
||||||
|
print(f"✅ {t.__name__} 通过")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"❌ {t.__name__} 失败: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ {t.__name__} 跳过: {e}")
|
||||||
Reference in New Issue
Block a user