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:
2026-08-07 14:55:25 +08:00
commit 641e33b834
39 changed files with 5254 additions and 0 deletions
+38
View File
@@ -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
View File
@@ -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"]
+70
View File
@@ -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/`
+1
View File
@@ -0,0 +1 @@
"""QYLAW 法律法规知识库应用包"""
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""API 路由模块"""
+76
View File
@@ -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="清空失败")
+51
View File
@@ -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,
}
+88
View File
@@ -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,
}
+159
View File
@@ -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"},
)
+89
View File
@@ -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,
}
+20
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
"""服务层模块"""
+79
View File
@@ -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]
+300
View File
@@ -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
+147
View File
@@ -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,
}
+140
View File
@@ -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}")
+190
View File
@@ -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")
+264
View File
@@ -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,
}
+2
View File
@@ -0,0 +1,2 @@
/* app.css — Tailwind 之外的补充样式 */
/* 目前所有样式已在 index.html 的 <style> 中定义,此文件预留扩展 */
File diff suppressed because one or more lines are too long
+126
View File
@@ -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>
+264
View File
@@ -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 = '服务不可用';
}
}
+212
View File
@@ -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);
}
},
};
+332
View File
@@ -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, '&quot;')})"
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;
},
};
+207
View File
@@ -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;
},
};
+168
View File
@@ -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}`;
},
};
+258
View File
@@ -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;
},
};
+6
View File
@@ -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
+247
View File
@@ -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)。
+329
View File
@@ -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()
+107
View File
@@ -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 "============================================"
+348
View File
@@ -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()
+266
View File
@@ -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 <法规文件路径>")
+147
View File
@@ -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()
+101
View File
@@ -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()
+98
View File
@@ -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}")