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

+
+ ● + 推理服务检测中… +
+
+ +
+ + + + + + + + + +
+ + + +
+ +
+ + + 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())