feat: 完成 issue #133 对话助手增强版(引用溯源/路由分级/DLP 拦截,直连推理服务)
This commit is contained in:
@@ -9,11 +9,39 @@
|
||||
web/chat/
|
||||
├── chat_api.py 对话后端 API(http.server):场景分发 + 统一 JSON
|
||||
├── chat_widget.html 前端对话组件(内联 HTML/CSS/JS,深色主题对齐驾驶舱)
|
||||
├── assistant.html 增强版对话页(#133:引用溯源/路由分级/DLP 拦截反馈)
|
||||
├── assistant.css 增强版样式
|
||||
├── assistant.js 增强版逻辑(直连推理服务 /v1/chat/completions)
|
||||
├── citations.json RAG 引用语料(scripts/build_citations.py 编译产物)
|
||||
├── scripts/
|
||||
│ └── build_citations.py 模板知识库文档 → 引用语料编译脚本
|
||||
├── tests/
|
||||
│ └── test_chat_api.py 场景分发 / 端点 / 错误处理测试
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 增强版对话页(issue #133 / PRD 5.4)
|
||||
|
||||
`assistant.html` 在 chat_widget 雏形上完善并直连已部署推理服务
|
||||
(`http://39.101.182.167:30800`,OpenAI 兼容):
|
||||
|
||||
- **消息流 + 元信息**:每条回答带路由分级 / 模型 / 耗时;
|
||||
- **RAG 引用溯源**(PRD 5.4 强制):`citations.json`(由
|
||||
`python web/chat/scripts/build_citations.py` 从 `templates/*/rag-kb/documents/`
|
||||
编译)关键词检索,回答下方列出「文档 + 章节 + 摘要」引用;未命中显式标注无引用;
|
||||
- **路由分级提示**:镜像 `core/llm-gateway/router.py`——敏感/核心 → 本地
|
||||
(数据不出厂),通用/脱敏 → 云端,DLP 命中 → 拦截(fail-closed);
|
||||
- **DLP 拦截反馈**:镜像 `dlp.py` 的 `DLP_DEFAULT_RULES`(身份证/手机号/邮箱/
|
||||
IPv4/访问密钥/密钥键值对/配方敏感词),命中即阻止外发并展示命中规则,
|
||||
可选择「转本地模型处理」;
|
||||
- **报警解释**(PRD 场景A):severity + 点位结构化输入 → 「原因 + 处置建议」,
|
||||
P0 告警附「需值班长确认」提示(PRD 高利害人工确认);
|
||||
- **交接班报告**(PRD 场景C):按 `handover_brief.ti.yaml` 章节模板
|
||||
(生产概况/异常事项/安全注意事项/能耗/待办)生成,耗时展示(目标 ≤ 2 分钟)。
|
||||
|
||||
运行:`cd web/chat && python -m http.server 8082`,打开
|
||||
`http://127.0.0.1:8082/assistant.html`(Chrome / Edge 最新两版)。
|
||||
|
||||
## 快速运行
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* iAOP 对话助手增强版样式(issue #133)。深色主题,与 chat_widget.html / cockpit 一致。 */
|
||||
:root {
|
||||
--bg: #0f172a; --surface: #111c33; --surface-2: #1e293b;
|
||||
--fg: #e2e8f0; --fg-muted: #94a3b8; --accent: #38bdf8; --warn: #f5a623;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: var(--bg); color: var(--fg); }
|
||||
#app { max-width: 860px; margin: 0 auto; padding: 16px; }
|
||||
|
||||
#topbar { display: flex; align-items: center; justify-content: space-between; }
|
||||
h1 { font-size: 18px; color: var(--accent); margin: 0; }
|
||||
h1 .sub { font-size: 12px; color: var(--fg-muted); font-weight: 400; }
|
||||
.hint { font-size: 11px; color: #64748b; }
|
||||
.dot { font-size: 13px; }
|
||||
.dot.ok { color: #2ecc71; }
|
||||
.dot.down { color: #ff3b30; }
|
||||
|
||||
#messages { height: 480px; overflow-y: auto; border: 1px solid var(--surface-2);
|
||||
border-radius: 8px; padding: 12px; background: var(--surface); margin-top: 10px; }
|
||||
.msg { margin: 10px 0; }
|
||||
.msg .who { font-size: 12px; color: var(--fg-muted); }
|
||||
.msg .text { display: inline-block; max-width: 88%; padding: 8px 12px; border-radius: 8px;
|
||||
white-space: pre-wrap; font-size: 14px; line-height: 1.6; }
|
||||
.user .text { background: #1d4ed8; }
|
||||
.bot .text { background: var(--surface-2); }
|
||||
.meta { font-size: 11px; color: #64748b; margin-top: 3px; display: flex; gap: 8px;
|
||||
flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* 路由分级标签(本地/云端/拦截) */
|
||||
.route-tag { padding: 1px 8px; border-radius: 10px; font-size: 11px; }
|
||||
.route-local { background: rgba(46,204,113,.15); color: #2ecc71; }
|
||||
.route-cloud { background: rgba(56,189,248,.15); color: var(--accent); }
|
||||
.route-block { background: rgba(255,59,48,.15); color: #ff3b30; }
|
||||
.human-tag { background: rgba(245,166,35,.15); color: var(--warn);
|
||||
padding: 1px 8px; border-radius: 10px; font-size: 11px; }
|
||||
|
||||
/* RAG 引用溯源(PRD 5.4 强制) */
|
||||
.citations { margin-top: 6px; font-size: 12px; border-left: 3px solid var(--accent);
|
||||
padding: 4px 10px; background: rgba(56,189,248,.06); border-radius: 0 6px 6px 0; }
|
||||
.citations .cite-title { color: var(--accent); font-size: 11px; margin-bottom: 2px; }
|
||||
.citations .cite-item { color: var(--fg-muted); margin: 2px 0; }
|
||||
.no-citation { margin-top: 6px; font-size: 11px; color: #64748b; }
|
||||
|
||||
/* 场景输入区 */
|
||||
.scenario-inputs { display: flex; gap: 8px; margin-top: 10px; align-items: center; }
|
||||
.scenario-inputs input { flex: 1; }
|
||||
|
||||
/* DLP 拦截反馈条 */
|
||||
#dlp-banner { margin-top: 10px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
|
||||
background: rgba(255,59,48,.10); border: 1px solid #ff3b30; color: #ffb4ad; }
|
||||
#dlp-banner button { margin-left: 8px; padding: 4px 10px; font-size: 12px; border: none;
|
||||
border-radius: 5px; cursor: pointer; background: var(--surface-2);
|
||||
color: var(--fg); }
|
||||
|
||||
#panel { margin-top: 10px; display: flex; gap: 8px; }
|
||||
select, input { padding: 8px; border-radius: 6px; border: 1px solid #334155;
|
||||
background: #0b1526; color: var(--fg); }
|
||||
input { flex: 1; }
|
||||
button#send { padding: 8px 16px; border: none; border-radius: 6px; background: #0ea5e9;
|
||||
color: #fff; cursor: pointer; }
|
||||
button#send:disabled { opacity: .5; }
|
||||
#footer-hint { margin-top: 8px; }
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>iAOP 对话助手(增强版)</title>
|
||||
<link rel="stylesheet" href="assistant.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- iAOP 对话助手增强版(issue #133 / PRD 5.4「④ LLM 网关 + RAG」)
|
||||
在 chat_widget.html(#77 雏形)之上完善:消息流 + RAG 引用溯源 +
|
||||
路由分级提示(本地/云端)+ DLP 拦截反馈,直连已部署推理服务。
|
||||
既有 chat_widget.html 保留不动(仍由 chat_api.py 托管联调)。 -->
|
||||
<div id="app">
|
||||
<header id="topbar">
|
||||
<h1>iAOP 对话助手 <span class="sub">NL 查询 / 报警解释 / 交接班报告 · PRD 5.4</span></h1>
|
||||
<div id="service-status">
|
||||
<span id="infer-dot" class="dot">●</span>
|
||||
<span id="infer-text" class="hint">推理服务检测中…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="messages"></div>
|
||||
|
||||
<!-- 报警解释场景的结构化输入(PRD 场景A:告警 → 原因+处置建议) -->
|
||||
<div id="alarm-inputs" class="scenario-inputs" hidden>
|
||||
<select id="alarm-severity">
|
||||
<option value="P0">P0(红色告警)</option>
|
||||
<option value="P1">P1(加强监控)</option>
|
||||
<option value="P2">P2(提示)</option>
|
||||
</select>
|
||||
<input id="alarm-point" placeholder="告警点位,如 CLF-01.TEMP">
|
||||
</div>
|
||||
<!-- 交接班报告场景输入(PRD 场景C:按模板章节生成,≤ 2 分钟) -->
|
||||
<div id="handover-inputs" class="scenario-inputs" hidden>
|
||||
<input id="handover-shift" placeholder="班次,如 早班 08:00-16:00">
|
||||
<span class="hint">章节模板:生产概况/异常事项/安全注意事项/能耗/待办(handover_brief.ti.yaml)</span>
|
||||
</div>
|
||||
|
||||
<!-- DLP 拦截反馈条(PRD 5.4:拦截可感知) -->
|
||||
<div id="dlp-banner" hidden>
|
||||
<strong>DLP 拦截:</strong><span id="dlp-detail"></span>
|
||||
<button id="btn-local-fallback">转本地模型处理</button>
|
||||
<button id="btn-dlp-dismiss">取消发送</button>
|
||||
</div>
|
||||
|
||||
<div id="panel">
|
||||
<select id="scenario">
|
||||
<option value="nl_query">自然语言查询</option>
|
||||
<option value="alarm_explain">报警解释</option>
|
||||
<option value="shift_handover">交接班报告</option>
|
||||
</select>
|
||||
<input id="question" placeholder="请输入问题,如:氯气流量最近1小时趋势">
|
||||
<button id="send">发送</button>
|
||||
</div>
|
||||
<div class="hint" id="footer-hint">
|
||||
路由分级:敏感/核心 → 本地(数据不出厂);脱敏/通用 → 云端;DLP 命中 → 拦截。
|
||||
回答附 RAG 引用溯源(知识库命中时)。
|
||||
</div>
|
||||
</div>
|
||||
<script src="assistant.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,283 @@
|
||||
/* iAOP 对话助手增强版(issue #133 / PRD 5.4「④ LLM 网关 + RAG」)。
|
||||
*
|
||||
* 在 chat_widget.html(#77 雏形)之上完善:
|
||||
* - 消息流 + 每消息元信息(路由分级 / 模型 / 耗时 / 高利害确认提示)
|
||||
* - RAG 引用溯源:citations.json(模板知识库语料)关键词检索,回答附引用列表
|
||||
* - 路由分级提示:镜像 core/llm-gateway/router.py 语义(local/cloud/block)
|
||||
* - DLP 拦截反馈:镜像 core/llm-gateway/dlp.py 的 DLP_DEFAULT_RULES 内置保底规则,
|
||||
* 命中即阻止外发并给出可感知的拦截条(可转本地处理)
|
||||
* - 三场景:NL 查询 / 报警解释(severity+点位 → 原因+处置建议,P0 提示人工确认)/
|
||||
* 交接班报告(按 handover_brief.ti.yaml 章节模板生成,计时展示 ≤2min 目标)
|
||||
*
|
||||
* 直连已部署推理服务 http://39.101.182.167:30800(OpenAI 兼容 /v1/chat/completions)。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
var INFER_BASE = "http://39.101.182.167:30800";
|
||||
var state = {
|
||||
model: null,
|
||||
citations: null, // citations.json 语料
|
||||
pending: null // 被 DLP 拦截待处置的消息
|
||||
};
|
||||
|
||||
/* ---------------- DLP 内置保底规则(镜像 dlp.py DLP_DEFAULT_RULES) ---------------- */
|
||||
var DLP_RULES = [
|
||||
{ name: "pii_id_card", category: "pii", kind: "regex",
|
||||
pattern: /\b\d{17}[\dXx]\b/, description: "身份证号(18 位)" },
|
||||
{ name: "pii_mobile", category: "pii", kind: "regex",
|
||||
pattern: /\b1[3-9]\d{9}\b/, description: "中国大陆手机号" },
|
||||
{ name: "pii_email", category: "pii", kind: "regex",
|
||||
pattern: /[\w.+-]+@[\w-]+\.[\w.-]+/, description: "邮箱地址" },
|
||||
{ name: "pii_ipv4", category: "pii", kind: "regex",
|
||||
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/, description: "IPv4 地址" },
|
||||
{ name: "credential_ak", category: "credential", kind: "regex",
|
||||
pattern: /\b(?:AKIA|LTAI)[0-9A-Z]{16,}\b/, description: "云访问密钥 ID" },
|
||||
{ name: "credential_kv", category: "credential", kind: "regex",
|
||||
pattern: /(?:access[_-]?key|secret|password|token)\s*[:=]\s*\S+/i,
|
||||
description: "密钥/口令键值对" },
|
||||
{ name: "process_formula", category: "process-keyword", kind: "keyword",
|
||||
pattern: "配方", description: "配方类敏感词" }
|
||||
];
|
||||
|
||||
function dlpCheck(text) {
|
||||
var hits = [];
|
||||
DLP_RULES.forEach(function (r) {
|
||||
var m = text.match(r.pattern);
|
||||
if (m) hits.push({ rule: r, matched: m[0] });
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
/* ---------------- 路由分级(镜像 router.py:block > local > cloud > 默认 local) ---------------- */
|
||||
// 通用/脱敏问法(可云端):不含点位/工艺敏感词的常识类问题
|
||||
var GENERIC_RE = /^(什么是|请解释|介绍一下|怎么理解|如何理解)/;
|
||||
// 本地(敏感/核心)线索:点位号、生产/工艺词
|
||||
var LOCAL_HINT_RE = /([A-Z]{2,}-\d+\.|[点位炉班批纯杂温度压力流量生产工艺工况])/;
|
||||
|
||||
function routeDecision(text, dlpHits) {
|
||||
if (dlpHits.length) {
|
||||
return { target: "block", reason: "DLP 出站检查命中,拦截外发(fail-closed)" };
|
||||
}
|
||||
if (GENERIC_RE.test(text) && !LOCAL_HINT_RE.test(text)) {
|
||||
return { target: "cloud", reason: "通用/脱敏问题,DLP 放行,可路由云端" };
|
||||
}
|
||||
return { target: "local", reason: "敏感/核心或未知(默认保守),本地处理(数据不出厂)" };
|
||||
}
|
||||
|
||||
/* ---------------- RAG 引用检索(citations.json 关键词重叠,Demo 级) ---------------- */
|
||||
function retrieveCitations(query, topK) {
|
||||
if (!state.citations) return [];
|
||||
var terms = query.replace(/[^\w一-鿿]+/g, " ").split(" ")
|
||||
.filter(function (t) { return t.length >= 2; });
|
||||
var scored = [];
|
||||
state.citations.documents.forEach(function (doc) {
|
||||
doc.sections.forEach(function (sec) {
|
||||
var hay = doc.title + " " + sec.heading + " " + sec.snippet;
|
||||
var score = 0;
|
||||
terms.forEach(function (t) { if (hay.indexOf(t) >= 0) score += t.length; });
|
||||
if (score > 0) {
|
||||
scored.push({ title: doc.title, source: doc.source,
|
||||
heading: sec.heading, snippet: sec.snippet, score: score });
|
||||
}
|
||||
});
|
||||
});
|
||||
scored.sort(function (a, b) { return b.score - a.score; });
|
||||
return scored.slice(0, topK || 3);
|
||||
}
|
||||
|
||||
/* ---------------- 推理服务 ---------------- */
|
||||
function checkHealth() {
|
||||
fetch(INFER_BASE + "/health").then(function (r) {
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
document.getElementById("infer-dot").className = "dot ok";
|
||||
document.getElementById("infer-text").textContent = "推理服务在线";
|
||||
}).catch(function () {
|
||||
document.getElementById("infer-dot").className = "dot down";
|
||||
document.getElementById("infer-text").textContent = "推理服务不可达";
|
||||
});
|
||||
}
|
||||
function loadModel() {
|
||||
return fetch(INFER_BASE + "/v1/models").then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
// 兼容 {"models":[...]} 与 OpenAI {"data":[{"id":...}]} 两种形态
|
||||
if (d && d.models && d.models.length) state.model = d.models[0];
|
||||
else if (d && d.data && d.data.length) state.model = d.data[0].id;
|
||||
}).catch(function () {});
|
||||
}
|
||||
function chatCompletions(messages) {
|
||||
return fetch(INFER_BASE + "/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: state.model || "iaop-ti-cl4-v1",
|
||||
messages: messages, max_tokens: 1024 })
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}).then(function (d) {
|
||||
var c = d && d.choices && d.choices[0];
|
||||
return (c && c.message && c.message.content) || "(空响应)";
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 消息渲染 ---------------- */
|
||||
function el(tag, cls, text) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text !== undefined) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
function addMsg(who, text, meta) {
|
||||
var box = document.getElementById("messages");
|
||||
var div = el("div", "msg " + who);
|
||||
div.appendChild(el("div", "who", who === "user" ? "我" : "iAOP"));
|
||||
var t = el("div", "text");
|
||||
t.textContent = text;
|
||||
div.appendChild(t);
|
||||
if (meta) {
|
||||
var m = el("div", "meta");
|
||||
meta.forEach(function (node) { m.appendChild(node); });
|
||||
div.appendChild(m);
|
||||
}
|
||||
box.appendChild(div);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
function routeTag(route) {
|
||||
var map = { local: ["route-local", "路由:本地(数据不出厂)"],
|
||||
cloud: ["route-cloud", "路由:云端(脱敏放行)"],
|
||||
block: ["route-block", "路由:拦截"] };
|
||||
var v = map[route] || map.local;
|
||||
return el("span", "route-tag " + v[0], v[1]);
|
||||
}
|
||||
function appendCitations(msgDiv, citations) {
|
||||
if (!citations.length) {
|
||||
msgDiv.appendChild(el("div", "no-citation", "未命中知识库,以上为模型直接回答(无引用)"));
|
||||
return;
|
||||
}
|
||||
var box = el("div", "citations");
|
||||
box.appendChild(el("div", "cite-title", "引用溯源(RAG,共 " + citations.length + " 条)"));
|
||||
citations.forEach(function (c, i) {
|
||||
box.appendChild(el("div", "cite-item",
|
||||
"[" + (i + 1) + "] 《" + c.title + "》 " + c.heading + " — " + c.snippet));
|
||||
});
|
||||
msgDiv.appendChild(box);
|
||||
}
|
||||
|
||||
/* ---------------- 场景 Prompt 构建 ---------------- */
|
||||
function buildMessages(scenario, question, citations) {
|
||||
if (scenario === "alarm_explain") {
|
||||
var sev = document.getElementById("alarm-severity").value;
|
||||
var point = document.getElementById("alarm-point").value.trim() || "(未填点位)";
|
||||
return [
|
||||
{ role: "system", content: "你是 iAOP 工业报警解释助手。针对告警给出结构化回答:"
|
||||
+ "【可能原因】分条列出;【处置建议】分条列出可执行步骤;【是否需要人工确认】。"
|
||||
+ "用简体中文,简洁专业。" },
|
||||
{ role: "user", content: "告警级别 " + sev + ",点位 " + point +
|
||||
",告警描述:" + question }
|
||||
];
|
||||
}
|
||||
if (scenario === "shift_handover") {
|
||||
var shift = document.getElementById("handover-shift").value.trim() || "本班";
|
||||
return [
|
||||
{ role: "system", content: "你是 iAOP 交接班报告生成助手。按以下章节模板生成摘要"
|
||||
+ "(对齐 handover_brief.ti.yaml):一、生产概况;二、异常事项;三、安全注意事项;"
|
||||
+ "四、能耗;五、待办(交下一班)。每章节 1-3 条,简体中文。" },
|
||||
{ role: "user", content: "请生成 " + shift + " 的交接班报告。本班关键信息:" + question }
|
||||
];
|
||||
}
|
||||
// nl_query:带 RAG 上下文
|
||||
var ctx = citations.map(function (c, i) {
|
||||
return "[" + (i + 1) + "] 《" + c.title + "》" + c.heading + ":" + c.snippet;
|
||||
}).join("\n");
|
||||
return [
|
||||
{ role: "system", content: "你是 iAOP 工业驾驶舱 NL 查询助手,回答工艺/质量/能耗问题。"
|
||||
+ (ctx ? "可参考以下知识库片段(回答中可用 [编号] 标注引用):\n" + ctx
|
||||
: "知识库未命中,直接回答即可。")
|
||||
+ "用简体中文,简洁准确。" },
|
||||
{ role: "user", content: question }
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------- 发送主流程 ---------------- */
|
||||
function doSend(question, forceLocal) {
|
||||
var scenario = document.getElementById("scenario").value;
|
||||
var citations = scenario === "nl_query" ? retrieveCitations(question, 3) : [];
|
||||
var route = forceLocal
|
||||
? { target: "local", reason: "DLP 拦截后用户确认转本地处理" }
|
||||
: routeDecision(question + " " + (citations.map(function (c) {
|
||||
return c.snippet; }).join(" ")), dlpCheck(question));
|
||||
|
||||
addMsg("user", question);
|
||||
document.getElementById("question").value = "";
|
||||
var sendBtn = document.getElementById("send");
|
||||
sendBtn.disabled = true;
|
||||
var started = Date.now();
|
||||
|
||||
chatCompletions(buildMessages(scenario, question, citations))
|
||||
.then(function (answer) {
|
||||
var cost = ((Date.now() - started) / 1000).toFixed(1);
|
||||
var meta = [routeTag(route.target),
|
||||
el("span", null, "模型: " + (state.model || "default")),
|
||||
el("span", null, "耗时: " + cost + "s" +
|
||||
(scenario === "shift_handover" ? "(目标 ≤ 2min)" : ""))];
|
||||
// 高利害人工确认(PRD line 333):P0 报警解释提示值班长确认
|
||||
if (scenario === "alarm_explain" &&
|
||||
document.getElementById("alarm-severity").value === "P0") {
|
||||
meta.push(el("span", "human-tag", "⚠ P0 高利害:需值班长确认后执行"));
|
||||
}
|
||||
var div = addMsg("bot", answer, meta);
|
||||
if (scenario === "nl_query") appendCitations(div, citations);
|
||||
})
|
||||
.catch(function (e) {
|
||||
addMsg("bot", "请求失败:" + e.message + "(请确认推理服务可达)");
|
||||
})
|
||||
.finally(function () { sendBtn.disabled = false; });
|
||||
}
|
||||
|
||||
function trySend() {
|
||||
var question = document.getElementById("question").value.trim();
|
||||
if (!question) return;
|
||||
var hits = dlpCheck(question);
|
||||
if (hits.length) {
|
||||
// DLP 拦截反馈(PRD 5.4:拦截可感知)——不发送,展示命中详情
|
||||
var banner = document.getElementById("dlp-banner");
|
||||
document.getElementById("dlp-detail").textContent =
|
||||
"命中规则 " + hits.map(function (h) {
|
||||
return h.rule.name + "(" + h.rule.description + ")";
|
||||
}).join("、") + ",已阻止外发云端(fail-closed)";
|
||||
banner.hidden = false;
|
||||
state.pending = question;
|
||||
return;
|
||||
}
|
||||
doSend(question, false);
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
(function init() {
|
||||
document.getElementById("send").onclick = trySend;
|
||||
document.getElementById("question").addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") trySend();
|
||||
});
|
||||
document.getElementById("scenario").onchange = function (e) {
|
||||
var v = e.target.value;
|
||||
document.getElementById("alarm-inputs").hidden = v !== "alarm_explain";
|
||||
document.getElementById("handover-inputs").hidden = v !== "shift_handover";
|
||||
document.getElementById("question").placeholder = {
|
||||
nl_query: "请输入问题,如:氯气流量最近1小时趋势",
|
||||
alarm_explain: "描述告警现象,如:炉温骤升至 920℃ 且持续 5 分钟",
|
||||
shift_handover: "输入本班关键事件/数据,如:氯化炉运行平稳,P1 告警 2 起已确认"
|
||||
}[v];
|
||||
};
|
||||
document.getElementById("btn-local-fallback").onclick = function () {
|
||||
document.getElementById("dlp-banner").hidden = true;
|
||||
if (state.pending) { doSend(state.pending, true); state.pending = null; }
|
||||
};
|
||||
document.getElementById("btn-dlp-dismiss").onclick = function () {
|
||||
document.getElementById("dlp-banner").hidden = true;
|
||||
state.pending = null;
|
||||
};
|
||||
fetch("citations.json").then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { state.citations = d; }).catch(function () {});
|
||||
checkHealth();
|
||||
loadModel();
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schema": "iAOP-chat-citations-v1",
|
||||
"documents": [
|
||||
{
|
||||
"title": "GB/T 5475 离子交换树脂取样方法",
|
||||
"source": "templates/resin/rag-kb/documents/GB-T-5475-离子交换树脂取样方法.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# GB/T 5475 离子交换树脂取样方法 - 规定离子交换树脂产品取样方法:从批中按规定数量抽取样品。 - 取样要求代表性:不同包装单元均匀取样,混合后缩分。 - 样品量:每批取样量按标准规定,样品密封保存并标识批次。 - 取样记录:批次号、取样时间、取样人、样品编号(对齐 sample_id)。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "GB/T 8144 阳离子交换树脂交换容量测定方法",
|
||||
"source": "templates/resin/rag-kb/documents/GB-T-8144-阳离子交换树脂交换容量测定方法.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# GB/T 8144 阳离子交换树脂交换容量测定方法 - 规定阳离子交换树脂交换容量(mmol/g)测定方法。 - 原理:树脂经酸/碱转型后,用标准溶液滴定测定交换容量。 - 结果用于质量分级:交换容量 ≥ 规定值(如 ≥ 4.2 mmol/g)为合格品。 - 测定结果按批次录入 LIMS(tag_id: LIMS."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "GB/T 8330 离子交换树脂湿真密度测定方法",
|
||||
"source": "templates/resin/rag-kb/documents/GB-T-8330-离子交换树脂湿真密度测定方法.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# GB/T 8330 离子交换树脂湿真密度测定方法 - 规定离子交换树脂湿真密度测定方法(比重瓶法)。 - 湿真密度反映树脂交联度与孔结构,与交换容量、强度相关。 - 测定条件:恒温(25℃)、湿态样品、排除气泡后称量。 - 结果记录:按批次录入,作为工艺优化与质量追溯的辅助指标。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "交接班报告生成规范",
|
||||
"source": "templates/resin/rag-kb/documents/交接班报告生成规范.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# 交接班报告生成规范 - 交接班报告须包含:生产概况、设备运行状态、异常与处置、安全注意事项、待办事项。 - 异常事项必须标注发生时间、处理人与处置结果。 - 待办事项需明确责任人与期望完成时间。 - 每班次结束前 30 分钟生成,当班班长审核后交下一班。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "交联度超标处置流程",
|
||||
"source": "templates/resin/rag-kb/documents/交联度超标处置流程.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# 交联度超标处置流程 1. 化验确认交联度超标(相对配方设计值偏差 > 5%)后,立即隔离该批树脂。 2. 通知当班班长与质检科,暂停同一配方后续批次投料。 3. 原因排查:DVB 计量偏差、搅拌不均、温度曲线漂移。 4. 处置选项:回用(与低交联度批次调配)或降级(非标用途),由 QE 评审放行。 5. 处置结果录"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "吸附树脂合成工艺规范",
|
||||
"source": "templates/resin/rag-kb/documents/吸附树脂合成工艺规范.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# 吸附树脂合成工艺规范 - 吸附树脂系列:ASC / ASD / HPR / ACD,采用悬浮聚合法,间歇/配方驱动生产。 - 主要单体:苯乙烯(ST)+ 交联剂二乙烯苯(DVB),按配方比例投料。 - 关键工艺参数:聚合温度 70–90℃,引发剂滴加速率按配方设定,搅拌转速 100–300 rpm。 - 交联度(D"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "树脂合成异常处置SOP",
|
||||
"source": "templates/resin/rag-kb/documents/树脂合成异常处置SOP.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# 树脂合成异常处置SOP - SOP-R-001:釜温超上限(>95℃)→ 立即停引发剂滴加、加大冷却水,10 分钟未回落按紧急排料流程。 - SOP-R-002:釜压异常升高 → 停止加热、检查放空阀,联系仪表检修。 - SOP-R-003:搅拌故障 → 降速至停止,启用备用搅拌(如有),未恢复则终止本批。 - S"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "离子交换树脂生产操作手册",
|
||||
"source": "templates/resin/rag-kb/documents/离子交换树脂生产操作手册.md",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "全文",
|
||||
"snippet": "# 离子交换树脂生产操作手册 - 开机前确认:原料计量罐、反应釜密封、加热/冷却系统、尾气处理正常。 - 投料顺序:先加去离子水与分散剂,再按配方加入苯乙烯/二乙烯苯与引发剂。 - 反应期间每 30 分钟记录一次釜温、釜压、搅拌转速。 - 聚合完成判断:釜温回落且无明显放热,取样测交联度。 - 洗涤:去离子水洗至 pH"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""对话助手 RAG 引用语料编译脚本(issue #133 / PRD 5.4 引用溯源)。
|
||||
|
||||
扫描行业模板知识库文档(``templates/*/rag-kb/documents/*.md``),抽取
|
||||
「文档标题 + 章节 + 章节摘要」编译为 ``web/chat/citations.json``,
|
||||
供增强版对话页(assistant.html)做前端检索与**引用溯源展示**。
|
||||
|
||||
与 rag-kb 内核的关系:``core/rag-kb`` 是完整检索管线(暂无 HTTP 服务),
|
||||
本脚本只把文档资产转成前端可 fetch 的静态语料(Demo 级),
|
||||
引用条目带「文档名 + 章节」,对齐 PRD 5.4「强制引用溯源」的展示语义。
|
||||
|
||||
用法(仓库根目录下):
|
||||
python web/chat/scripts/build_citations.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
_REPO_ROOT = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
_DOC_GLOB = os.path.join(_REPO_ROOT, "templates", "*", "rag-kb", "documents", "*.md")
|
||||
_OUT_PATH = os.path.join(_REPO_ROOT, "web", "chat", "citations.json")
|
||||
|
||||
#: 每个章节摘要的最大字符数(控制语料体积,前端检索只看要点)
|
||||
SNIPPET_MAX_CHARS = 160
|
||||
|
||||
|
||||
def extract_sections(path: str) -> dict:
|
||||
"""从一份 Markdown 文档抽取标题与章节摘要。"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
rel = os.path.relpath(path, _REPO_ROOT).replace(os.sep, "/")
|
||||
title_match = re.search(r"^#\s+(.+)$", text, flags=re.M)
|
||||
title = title_match.group(1).strip() if title_match else os.path.basename(path)
|
||||
|
||||
sections = []
|
||||
# 按 ## 章节切分;无章节时整篇作为一节
|
||||
parts = re.split(r"^##\s+(.+)$", text, flags=re.M)
|
||||
if len(parts) >= 3:
|
||||
for i in range(1, len(parts), 2):
|
||||
heading = parts[i].strip()
|
||||
body = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
snippet = re.sub(r"\s+", " ", body).strip()[:SNIPPET_MAX_CHARS]
|
||||
if snippet:
|
||||
sections.append({"heading": heading, "snippet": snippet})
|
||||
else:
|
||||
snippet = re.sub(r"\s+", " ", text).strip()[:SNIPPET_MAX_CHARS]
|
||||
if snippet:
|
||||
sections.append({"heading": "全文", "snippet": snippet})
|
||||
|
||||
return {"title": title, "source": rel, "sections": sections}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
docs = []
|
||||
for path in sorted(glob.glob(_DOC_GLOB)):
|
||||
try:
|
||||
docs.append(extract_sections(path))
|
||||
print(f"[OK] {os.path.basename(path)} "
|
||||
f"({len(docs[-1]['sections'])} 章节)")
|
||||
except Exception as exc: # noqa: BLE001 - 聚合全部失败
|
||||
print(f"[FAIL] {path}: {exc}")
|
||||
return 1
|
||||
if not docs:
|
||||
print("未找到任何知识库文档")
|
||||
return 1
|
||||
with open(_OUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump({"schema": "iAOP-chat-citations-v1", "documents": docs},
|
||||
f, ensure_ascii=False, indent=2)
|
||||
print(f"共 {len(docs)} 份文档 -> {os.path.relpath(_OUT_PATH, _REPO_ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user