/* 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(); })();