feat: add v2 tester page and LLM logging
This commit is contained in:
parent
bba9dea59d
commit
be25619fa8
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue