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 @@
"""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;
},
};