feat: add v2 tester page and LLM logging

This commit is contained in:
Codex Agent 2025-10-24 11:30:36 +08:00
parent bba9dea59d
commit be25619fa8
2 changed files with 421 additions and 9 deletions

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List
from licensing_repo import (
@ -10,6 +11,15 @@ from licensing_repo import (
from lawrisk_service import ChatClient
logger = logging.getLogger(__name__)
if not logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[lawrisk_v2] %(levelname)s %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
def _compose_prompt(payload: Dict[str, Any]) -> str:
"""Build a natural-language prompt snippet from structured payload."""
region = payload.get("region", {})
@ -50,20 +60,27 @@ def _select_theme_options(query: str, catalog: List[Dict[str, str]]) -> List[str
"""Use LLM to choose relevant region-theme option ids."""
if not catalog:
return []
lines = [f"{item['option_id']}\t{item['display_name']}" for item in catalog]
options_block = "\n".join(lines)
display_entries = [item["display_name"] for item in catalog]
options_block = "\n".join(display_entries)
system_msg = (
"你是政务事项检索助手。根据用户提供的问题,"
"从给定的地区-主题列表中选择最相关的主题事项,返回其 option_id"
"输出 JSON 数组,例如: [\"region_uuid:theme_uuid\"]."
"从给定的地区-主题列表中选择最相关的主题事项,返回对应的地区·主题名称"
"输出 JSON 数组,例如: [\"市级 · 开办电影院\"]."
)
user_msg = (
f"用户问题: {query}\n\n"
"候选主题列表 (option_id<tab>地区·主题):\n"
"候选主题列表\n"
f"{options_block}\n\n"
"请仅输出 JSON 数组,内容为选择的 option_id。如果没有匹配,请输出 []."
"请仅输出 JSON 数组,内容为选择的地区·主题名称。如果没有匹配,请输出 []."
)
logger.info(
"[lawrisk_v2] LLM selection request | query=%s | catalog_size=%d",
query,
len(catalog),
)
logger.info("[lawrisk_v2] LLM system prompt: %s", system_msg)
logger.info("[lawrisk_v2] LLM user prompt: %s", user_msg)
chat = ChatClient()
content = chat.chat(
[
@ -73,6 +90,7 @@ def _select_theme_options(query: str, catalog: List[Dict[str, str]]) -> List[str
)
raw = content.strip()
logger.info("[lawrisk_v2] LLM raw response: %s", raw)
start = raw.find("[")
end = raw.rfind("]")
if start != -1 and end != -1 and end > start:
@ -92,11 +110,13 @@ def _select_theme_options(query: str, catalog: List[Dict[str, str]]) -> List[str
except Exception:
selected = []
known_ids = {item["option_id"] for item in catalog}
display_to_option = {item["display_name"]: item["option_id"] for item in catalog}
uniq: List[str] = []
for option_id in selected:
if option_id in known_ids and option_id not in uniq:
for display_name in selected:
option_id = display_to_option.get(display_name)
if option_id and option_id not in uniq:
uniq.append(option_id)
logger.info("[lawrisk_v2] LLM mapped option_ids: %s", uniq)
return uniq

392
static/v2_tester.html Normal file
View File

@ -0,0 +1,392 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>LawRisk V2 接口测试</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
padding: 0 1.5rem 2rem;
background: #f7f7f7;
}
h1, h2 {
font-weight: 600;
}
form {
margin-top: 1.5rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
input[type="text"] {
flex: 1 1 320px;
padding: 0.6rem 0.75rem;
font-size: 1rem;
border: 1px solid #bbb;
border-radius: 6px;
}
button {
padding: 0.6rem 1.1rem;
font-size: 1rem;
border-radius: 6px;
border: none;
cursor: pointer;
background: #2563eb;
color: #fff;
}
button[type="button"] {
background: #6b7280;
}
#status {
margin-top: 1rem;
min-height: 1.5rem;
color: #2563eb;
font-weight: 500;
}
.results {
margin-top: 1.5rem;
}
.subject-block {
background: #fff;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.25rem;
box-shadow: 0 8px 18px rgba(12, 45, 90, 0.05);
}
.subject-meta {
margin: 0.4rem 0 1rem;
color: #4b5563;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
}
th, td {
border: 1px solid #d1d5db;
padding: 0.6rem 0.75rem;
text-align: left;
vertical-align: top;
}
th {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
td ul {
margin: 0;
padding-left: 1.4rem;
}
td ul li {
margin-bottom: 0.4rem;
}
.empty-hint {
margin: 1rem 0 0;
padding: 0.75rem;
border-radius: 8px;
background: #fef3c7;
color: #7c2d12;
}
.supplementary {
margin-top: 1.5rem;
}
.supplementary h3 {
margin-bottom: 0.5rem;
}
.supplementary ul {
margin: 0;
padding-left: 1.5rem;
}
.raw-json {
margin-top: 1.25rem;
background: #111827;
color: #f3f4f6;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9rem;
display: none;
}
.toggle-raw {
margin-top: 0.75rem;
background: #10b981;
}
@media (max-width: 720px) {
body {
padding: 0 0.75rem 1.5rem;
}
table, th, td {
font-size: 0.92rem;
}
}
</style>
</head>
<body>
<main>
<h1>LawRisk V2 接口测试台</h1>
<p>输入问题后调用 <code>/fs-ai-asistant/api/workflow/lawrisk/v2</code>,将返回的主题及许可详情以表格展示。</p>
<form id="query-form">
<label for="query-input" class="visually-hidden">问题</label>
<input id="query-input" type="text" placeholder="例如:开办电影院需要哪些许可事项" autocomplete="off" required>
<button type="submit">查询</button>
<button type="button" id="reset-btn">清空</button>
</form>
<div id="status"></div>
<section class="results" id="results"></section>
<section class="supplementary" id="supplementary"></section>
<button type="button" class="toggle-raw" id="toggle-raw-btn" style="display:none;">显示原始 JSON</button>
<pre class="raw-json" id="raw-json"></pre>
</main>
<script>
const formEl = document.getElementById("query-form");
const queryInputEl = document.getElementById("query-input");
const resetBtnEl = document.getElementById("reset-btn");
const statusEl = document.getElementById("status");
const resultsEl = document.getElementById("results");
const supplementaryEl = document.getElementById("supplementary");
const rawBtnEl = document.getElementById("toggle-raw-btn");
const rawJsonEl = document.getElementById("raw-json");
let lastPayload = null;
formEl.addEventListener("submit", async (event) => {
event.preventDefault();
const query = queryInputEl.value.trim();
if (!query) {
statusEl.textContent = "请输入问题后再查询。";
return;
}
setLoading(true);
clearResults();
try {
const response = await fetch("/fs-ai-asistant/api/workflow/lawrisk/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
lastPayload = payload;
renderPayload(payload);
} catch (error) {
statusEl.textContent = `请求失败:${error.message}`;
statusEl.style.color = "#dc2626";
} finally {
setLoading(false);
}
});
resetBtnEl.addEventListener("click", () => {
queryInputEl.value = "";
queryInputEl.focus();
statusEl.textContent = "";
statusEl.style.color = "#2563eb";
clearResults();
lastPayload = null;
rawBtnEl.style.display = "none";
rawJsonEl.style.display = "none";
});
rawBtnEl.addEventListener("click", () => {
if (!lastPayload) return;
const isHidden = rawJsonEl.style.display === "none";
rawJsonEl.style.display = isHidden ? "block" : "none";
rawBtnEl.textContent = isHidden ? "隐藏原始 JSON" : "显示原始 JSON";
});
function setLoading(isLoading) {
if (isLoading) {
statusEl.textContent = "查询中,请稍候…";
statusEl.style.color = "#2563eb";
formEl.querySelector("button[type='submit']").disabled = true;
} else {
formEl.querySelector("button[type='submit']").disabled = false;
}
}
function clearResults() {
resultsEl.innerHTML = "";
supplementaryEl.innerHTML = "";
rawJsonEl.textContent = "";
}
function renderPayload(payload) {
if (!payload || typeof payload !== "object") {
statusEl.textContent = "响应格式不正确。";
statusEl.style.color = "#dc2626";
return;
}
if (!payload.success) {
statusEl.textContent = payload.message ? `接口返回错误:${payload.message}` : "接口执行失败。";
statusEl.style.color = "#dc2626";
return;
}
const data = payload.data || {};
const subjects = Array.isArray(data.risk_subject) ? data.risk_subject : [];
statusEl.textContent = subjects.length
? `已匹配 ${subjects.length} 个主题事项,共含 ${countPermits(subjects)} 条许可信息。`
: "未检索到相关主题或许可事项。";
statusEl.style.color = subjects.length ? "#065f46" : "#dc2626";
if (subjects.length) {
subjects.forEach((subject, idx) => {
resultsEl.appendChild(renderSubjectBlock(subject, idx));
});
}
renderSupplementary(data);
rawBtnEl.style.display = "inline-block";
rawBtnEl.textContent = "显示原始 JSON";
rawJsonEl.style.display = "none";
rawJsonEl.textContent = JSON.stringify(payload, null, 2);
}
function renderSubjectBlock(subject, index) {
const block = document.createElement("section");
block.className = "subject-block";
const title = document.createElement("h2");
title.textContent = subject.display_name || `主题 ${index + 1}`;
block.appendChild(title);
const meta = document.createElement("p");
meta.className = "subject-meta";
const regionName = (subject.region && subject.region.name) || "未知地区";
const themeName = (subject.theme && subject.theme.name) || "未知主题事项";
meta.textContent = `地区:${regionName} 主题事项:${themeName}`;
block.appendChild(meta);
const permits = Array.isArray(subject.permits) ? subject.permits : [];
if (!permits.length) {
const emptyHint = document.createElement("div");
emptyHint.className = "empty-hint";
emptyHint.textContent = "该主题未关联具体许可事项。";
block.appendChild(emptyHint);
return block;
}
const table = document.createElement("table");
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
[
"许可事项",
"事项属性",
"子项概述",
"经营范围",
"风险提示",
"责任联系方式",
"管辖范围",
].forEach((label) => {
const th = document.createElement("th");
th.textContent = label;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
permits.forEach((permit) => {
const tr = document.createElement("tr");
tr.appendChild(createTextCell(permit.name || "(未命名许可)"));
tr.appendChild(createTextCell(permit.permit_status || "—"));
tr.appendChild(createTextCell(permit.subitem_summary || "—"));
const scopes = Array.isArray(permit.business_scopes)
? permit.business_scopes
.map((scope) => typeof scope.description === "string" ? scope.description : "")
.filter(Boolean)
.join("、")
: "";
tr.appendChild(createTextCell(scopes || "—"));
tr.appendChild(createRiskCell(Array.isArray(permit.risks) ? permit.risks : []));
tr.appendChild(createTextCell(permit.responsible_contact || "—"));
tr.appendChild(createTextCell(permit.jurisdiction_scope || "—"));
tbody.appendChild(tr);
});
table.appendChild(tbody);
block.appendChild(table);
return block;
}
function createTextCell(text) {
const td = document.createElement("td");
td.textContent = text || "—";
return td;
}
function createRiskCell(risks) {
const td = document.createElement("td");
if (!risks.length) {
td.textContent = "—";
return td;
}
const list = document.createElement("ul");
risks.forEach((risk, idx) => {
const li = document.createElement("li");
const parts = [];
if (risk.risk_content) parts.push(`${idx + 1}. ${risk.risk_content}`);
if (risk.legal_basis) parts.push(`法律依据:${risk.legal_basis}`);
if (risk.document_no) parts.push(`文号:${risk.document_no}`);
if (risk.summary) parts.push(`摘要:${risk.summary}`);
li.textContent = parts.join(" ");
list.appendChild(li);
});
td.appendChild(list);
return td;
}
function renderSupplementary(data) {
const recQuestions = Array.isArray(data.questionExtend) ? data.questionExtend : [];
if (!recQuestions.length) return;
const title = document.createElement("h3");
title.textContent = "推荐追问";
supplementaryEl.appendChild(title);
const list = document.createElement("ul");
recQuestions.forEach((question) => {
const li = document.createElement("li");
li.textContent = question;
list.appendChild(li);
});
supplementaryEl.appendChild(list);
}
function countPermits(subjects) {
return subjects.reduce((sum, subject) => {
const permits = Array.isArray(subject.permits) ? subject.permits.length : 0;
return sum + permits;
}, 0);
}
</script>
</body>
</html>