diff --git a/web/chat/README.md b/web/chat/README.md
index 4da246c..2e4bc0e 100644
--- a/web/chat/README.md
+++ b/web/chat/README.md
@@ -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
diff --git a/web/chat/assistant.css b/web/chat/assistant.css
new file mode 100644
index 0000000..7923fe4
--- /dev/null
+++ b/web/chat/assistant.css
@@ -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; }
diff --git a/web/chat/assistant.html b/web/chat/assistant.html
new file mode 100644
index 0000000..818616f
--- /dev/null
+++ b/web/chat/assistant.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+iAOP 对话助手(增强版)
+
+
+
+
+
+
+ iAOP 对话助手 NL 查询 / 报警解释 / 交接班报告 · PRD 5.4
+
+ ●
+ 推理服务检测中…
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 章节模板:生产概况/异常事项/安全注意事项/能耗/待办(handover_brief.ti.yaml)
+
+
+
+
+ DLP 拦截:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/chat/assistant.js b/web/chat/assistant.js
new file mode 100644
index 0000000..07e314f
--- /dev/null
+++ b/web/chat/assistant.js
@@ -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();
+})();
diff --git a/web/chat/citations.json b/web/chat/citations.json
new file mode 100644
index 0000000..69a756b
--- /dev/null
+++ b/web/chat/citations.json
@@ -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"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/web/chat/scripts/build_citations.py b/web/chat/scripts/build_citations.py
new file mode 100644
index 0000000..4edb735
--- /dev/null
+++ b/web/chat/scripts/build_citations.py
@@ -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())