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
+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,
}