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:
@@ -0,0 +1 @@
|
||||
"""API 路由模块"""
|
||||
@@ -0,0 +1,76 @@
|
||||
"""历史记录 API — /api/history"""
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
|
||||
from app.services import history as hist_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/history/search")
|
||||
async def list_search_history(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""获取检索历史列表(分页,最新在前)"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
data = hist_service.get_search_history(page, page_size)
|
||||
return {"code": 0, "message": "ok", "data": data, "trace_id": trace_id}
|
||||
|
||||
|
||||
@router.get("/history/chat")
|
||||
async def list_chat_history(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""获取对话历史列表(分页,最新在前)"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
data = hist_service.get_chat_history(page, page_size)
|
||||
return {"code": 0, "message": "ok", "data": data, "trace_id": trace_id}
|
||||
|
||||
|
||||
@router.get("/history/chat/{chat_id}")
|
||||
async def get_chat_detail(chat_id: int, request: Request):
|
||||
"""获取单条对话历史详情"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
detail = hist_service.get_chat_detail(chat_id)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="对话记录不存在")
|
||||
return {"code": 0, "message": "ok", "data": detail, "trace_id": trace_id}
|
||||
|
||||
|
||||
@router.delete("/history/search/{history_id}")
|
||||
async def delete_search_history(history_id: int, request: Request):
|
||||
"""删除单条检索历史"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
if hist_service.delete_search_history(history_id):
|
||||
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
|
||||
@router.delete("/history/chat/{history_id}")
|
||||
async def delete_chat_history(history_id: int, request: Request):
|
||||
"""删除单条对话历史"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
if hist_service.delete_chat_history(history_id):
|
||||
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
|
||||
@router.delete("/history/search")
|
||||
async def clear_search_history(request: Request):
|
||||
"""清空全部检索历史"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
if hist_service.clear_search_history():
|
||||
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||
raise HTTPException(status_code=500, detail="清空失败")
|
||||
|
||||
|
||||
@router.delete("/history/chat")
|
||||
async def clear_chat_history(request: Request):
|
||||
"""清空全部对话历史"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
if hist_service.clear_chat_history():
|
||||
return {"code": 0, "message": "ok", "trace_id": trace_id}
|
||||
raise HTTPException(status_code=500, detail="清空失败")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""法规浏览 API — /api/laws, /api/law/{law_id}"""
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from typing import Optional
|
||||
|
||||
from app.services.law_reader import LawReader
|
||||
from app.config import BROWSE_PAGE_SIZE
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/laws")
|
||||
async def list_laws(
|
||||
request: Request,
|
||||
category: Optional[str] = Query(None, description="法规类别过滤"),
|
||||
province: Optional[str] = Query(None, description="省份过滤"),
|
||||
keyword: Optional[str] = Query(None, description="法规名关键词"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200, description="每页数量"),
|
||||
):
|
||||
"""法规列表(分页 + 过滤)"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
reader = LawReader.get_instance()
|
||||
result = reader.list_laws(
|
||||
category=category,
|
||||
province=province,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": result,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/law/{law_id}")
|
||||
async def get_law(law_id: int, request: Request):
|
||||
"""法规详情(含原文 + 条文列表)"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
reader = LawReader.get_instance()
|
||||
detail = reader.get_law(law_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="法规不存在")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": detail,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""提示词配置 API — /api/prompts"""
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.services.prompt_config import (
|
||||
get_all_configs,
|
||||
set_config,
|
||||
get_llm_params,
|
||||
DEFAULTS,
|
||||
TEMPLATE_KEY_MAP,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PromptUpdateRequest(BaseModel):
|
||||
"""提示词更新请求"""
|
||||
system_prompt: Optional[str] = None
|
||||
template_simple: Optional[str] = None
|
||||
template_professional: Optional[str] = None
|
||||
template_compare: Optional[str] = None
|
||||
# LLM 参数
|
||||
llm_thinking_enabled: Optional[str] = None # "true" / "false"
|
||||
llm_max_tokens: Optional[str] = None # 字符串,后端转 int
|
||||
llm_temperature: Optional[str] = None # 字符串,后端转 float
|
||||
llm_thinking_budget: Optional[str] = None # 字符串,后端转 int
|
||||
|
||||
|
||||
@router.get("/prompts")
|
||||
async def get_prompts(request: Request):
|
||||
"""获取所有提示词配置(含默认值)"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
configs = get_all_configs()
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"configs": configs,
|
||||
"defaults": DEFAULTS,
|
||||
"template_keys": list(TEMPLATE_KEY_MAP.keys()),
|
||||
"llm_params": get_llm_params(),
|
||||
},
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/prompts")
|
||||
async def update_prompts(req: PromptUpdateRequest, request: Request):
|
||||
"""更新提示词配置(空字符串表示恢复默认)"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
updated = []
|
||||
# 系统提示词
|
||||
if req.system_prompt is not None:
|
||||
if set_config("system_prompt", req.system_prompt):
|
||||
updated.append("system_prompt")
|
||||
# 模板正文
|
||||
for tpl_key in ["template_simple", "template_professional", "template_compare"]:
|
||||
val = getattr(req, tpl_key, None)
|
||||
if val is not None:
|
||||
if set_config(tpl_key, val):
|
||||
updated.append(tpl_key)
|
||||
# LLM 参数
|
||||
for llm_key in ["llm_thinking_enabled", "llm_max_tokens", "llm_temperature", "llm_thinking_budget"]:
|
||||
val = getattr(req, llm_key, None)
|
||||
if val is not None:
|
||||
if set_config(llm_key, val):
|
||||
updated.append(llm_key)
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"updated": updated},
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/prompts/reset")
|
||||
async def reset_prompts(request: Request):
|
||||
"""重置所有提示词配置为默认值"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
for key in DEFAULTS.keys():
|
||||
set_config(key, "")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"reset": list(DEFAULTS.keys())},
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""RAG 问答 API — /api/rag(SSE 流式)"""
|
||||
import json
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Optional
|
||||
|
||||
from app.services.retriever import Retriever
|
||||
from app.services.embedding import embed_text
|
||||
from app.services.llm import stream_chat
|
||||
from app.services.prompt_config import get_template_text, get_system_prompt
|
||||
from app.services.history import add_chat_history, update_chat_history
|
||||
from app.config import RAG_TOP_K, RAG_SIMILARITY_THRESHOLD, MAX_QUERY_LENGTH
|
||||
from app.models import RagRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_context(results: list) -> str:
|
||||
"""将检索结果拼装为 context 文本"""
|
||||
lines = []
|
||||
for i, r in enumerate(results, 1):
|
||||
meta = f"《{r['law_name']}》"
|
||||
if r.get("clause_no"):
|
||||
meta += f" {r['clause_no']}"
|
||||
if r.get("chapter"):
|
||||
meta += f"({r['chapter']})"
|
||||
if r.get("province"):
|
||||
meta += f" [{r['province']}]"
|
||||
lines.append(f"{i}. {meta}\n{r['content']}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
@router.post("/rag")
|
||||
async def rag(req: RagRequest, request: Request):
|
||||
"""RAG 问答(SSE 流式)
|
||||
|
||||
流程:向量化问题 → 检索 Top-10 → 检查相似度阈值 → 拼 prompt → 流式生成
|
||||
"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
|
||||
if len(req.question) > MAX_QUERY_LENGTH:
|
||||
raise HTTPException(status_code=400, detail="问题过长")
|
||||
|
||||
# 向量化问题
|
||||
try:
|
||||
query_vec = await embed_text(req.question)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"embedding 服务不可用: {str(e)}",
|
||||
)
|
||||
|
||||
# 检索
|
||||
retriever = Retriever.get_instance()
|
||||
if not retriever.is_ready():
|
||||
raise HTTPException(status_code=503, detail="索引未加载")
|
||||
|
||||
results = retriever.search(
|
||||
query_vec=query_vec,
|
||||
top_k=RAG_TOP_K,
|
||||
category=req.category,
|
||||
province=req.province,
|
||||
)
|
||||
|
||||
# 相似度阈值检查
|
||||
if not results or results[0]["score"] < RAG_SIMILARITY_THRESHOLD:
|
||||
async def no_result_stream():
|
||||
yield f"data: {json.dumps({'type': 'no_result', 'message': '未找到相关法规,无法回答'}, ensure_ascii=False)}\n\n"
|
||||
return StreamingResponse(no_result_stream(), media_type="text/event-stream")
|
||||
|
||||
# 拼 prompt(优先级:请求自定义 > 数据库配置 > 默认)
|
||||
if req.template_text and "{context}" in req.template_text and "{question}" in req.template_text:
|
||||
template = req.template_text
|
||||
else:
|
||||
template = get_template_text(req.template) # 从数据库读取
|
||||
context = _build_context(results)
|
||||
prompt = template.format(context=context, question=req.question)
|
||||
|
||||
# 系统提示词(优先级:请求自定义 > 数据库配置 > 默认)
|
||||
system_prompt = req.system_prompt if req.system_prompt else get_system_prompt()
|
||||
|
||||
# LLM 参数(从数据库读取)
|
||||
from app.services.prompt_config import get_llm_params
|
||||
llm_params = get_llm_params()
|
||||
|
||||
# 引用条文(先发送)
|
||||
citations = [
|
||||
{
|
||||
"law_id": r["law_id"],
|
||||
"law_name": r["law_name"],
|
||||
"category": r["category"],
|
||||
"chapter": r.get("chapter"),
|
||||
"clause_no": r.get("clause_no"),
|
||||
"content": r["content"],
|
||||
"score": r["score"],
|
||||
"file_path": r["file_path"],
|
||||
"province": r.get("province"),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
async def event_stream():
|
||||
# 先创建对话历史记录(status=streaming)
|
||||
chat_id = add_chat_history(
|
||||
question=req.question,
|
||||
template=req.template,
|
||||
category=req.category,
|
||||
province=req.province,
|
||||
thinking_enabled=llm_params["thinking_enabled"],
|
||||
status="streaming",
|
||||
)
|
||||
# 发送 chat_id 给前端(用于关联历史记录)
|
||||
yield f"data: {json.dumps({'type': 'chat_id', 'chat_id': chat_id}, ensure_ascii=False)}\n\n"
|
||||
# 发送引用条文
|
||||
yield f"data: {json.dumps({'type': 'citations', 'clauses': citations}, ensure_ascii=False)}\n\n"
|
||||
# 发送 LLM 参数(前端用于显示 thinking 状态等)
|
||||
yield f"data: {json.dumps({'type': 'params', 'thinking_enabled': llm_params['thinking_enabled']}, ensure_ascii=False)}\n\n"
|
||||
# 流式生成答案(区分 thinking 和 answer)
|
||||
thinking_acc = ""
|
||||
answer_acc = ""
|
||||
try:
|
||||
async for chunk_type, chunk_content in stream_chat(
|
||||
prompt,
|
||||
system=system_prompt,
|
||||
temperature=llm_params["temperature"],
|
||||
max_tokens=llm_params["max_tokens"],
|
||||
thinking_enabled=llm_params["thinking_enabled"],
|
||||
thinking_budget=llm_params["thinking_budget"],
|
||||
):
|
||||
if chunk_type == "thinking":
|
||||
thinking_acc += chunk_content
|
||||
elif chunk_type == "answer":
|
||||
answer_acc += chunk_content
|
||||
yield f"data: {json.dumps({'type': chunk_type, 'content': chunk_content}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
||||
# 流式完成,更新历史记录
|
||||
update_chat_history(
|
||||
chat_id=chat_id,
|
||||
thinking=thinking_acc,
|
||||
answer=answer_acc,
|
||||
citations=json.dumps(citations, ensure_ascii=False),
|
||||
status="done",
|
||||
)
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|
||||
# 记录错误状态
|
||||
update_chat_history(
|
||||
chat_id=chat_id,
|
||||
thinking=thinking_acc,
|
||||
answer=answer_acc,
|
||||
citations=json.dumps(citations, ensure_ascii=False),
|
||||
status="error",
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""检索 API — /api/search"""
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from typing import Optional
|
||||
|
||||
from app.services.retriever import Retriever
|
||||
from app.services.embedding import embed_text
|
||||
from app.services.history import add_search_history
|
||||
from app.config import MAX_QUERY_LENGTH
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search(
|
||||
request: Request,
|
||||
query: str = Query(..., min_length=1, max_length=MAX_QUERY_LENGTH, description="查询文本"),
|
||||
mode: str = Query("semantic", description="检索模式:semantic(语义) / keyword(关键词精确)"),
|
||||
category: Optional[str] = Query(None, description="法规类别过滤"),
|
||||
province: Optional[str] = Query(None, description="省份过滤"),
|
||||
city: Optional[str] = Query(None, description="市级过滤"),
|
||||
top_k: int = Query(20, ge=1, le=100, description="返回数量"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
):
|
||||
"""检索法规条文(语义检索或关键词精确搜索)
|
||||
|
||||
- mode=semantic: 向量化查询 → FAISS 检索 → metadata 过滤(默认)
|
||||
- mode=keyword: 按法规名+条号精确匹配,支持"郑州市劳动用工条例 第三十二条"
|
||||
"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
|
||||
retriever = Retriever.get_instance()
|
||||
if not retriever.is_ready():
|
||||
raise HTTPException(status_code=503, detail="索引未加载,请等待或检查索引文件")
|
||||
|
||||
if mode == "keyword":
|
||||
# 关键词精确搜索
|
||||
results = retriever.keyword_search(
|
||||
query=query,
|
||||
top_k=top_k * 3 if (category or province or city) else top_k,
|
||||
category=category,
|
||||
province=province,
|
||||
city=city,
|
||||
)
|
||||
else:
|
||||
# 语义检索
|
||||
try:
|
||||
query_vec = await embed_text(query)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"embedding 服务不可用: {str(e)}",
|
||||
)
|
||||
fetch_k = top_k * 3 if (category or province or city) else top_k
|
||||
results = retriever.search(
|
||||
query_vec=query_vec,
|
||||
top_k=fetch_k,
|
||||
category=category,
|
||||
province=province,
|
||||
city=city,
|
||||
)
|
||||
|
||||
# 分页
|
||||
total = len(results)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_results = results[start:end]
|
||||
|
||||
# 记录检索历史
|
||||
add_search_history(
|
||||
query=query,
|
||||
category=category,
|
||||
province=province,
|
||||
city=city,
|
||||
result_count=total,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"results": page_results,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""统计 API — /api/stats"""
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.services.retriever import Retriever
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats(request: Request):
|
||||
"""返回索引统计信息"""
|
||||
trace_id = getattr(request.state, "trace_id", "")
|
||||
retriever = Retriever.get_instance()
|
||||
data = retriever.get_stats()
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": data,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
Reference in New Issue
Block a user