Files

1 line
138 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ashley-33/lexscope", "files": {"js/review.js": {"7882af24cff34ae081b33bdd0271c6d1cd473430": {"t": 1785746584.507187, "n": 13815, "h": "cbb17b6f30fe8d52", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, coverage:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('【解析失败】模型返回的内容无法解析为 JSON(可能未按要求输出),请重试或改用 deepseek-reasoner。');\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('【解析失败】模型返回内容不是有效的 JSON 对象,请重试。');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n // 用词统一:内规 → 受审文本\n if (typeof f.problem === 'string') f.problem = f.problem.replace(/内规/g, '受审文本');\n if (typeof f.suggestion === 'string') f.suggestion = f.suggestion.replace(/内规/g, '受审文本');\n if (typeof f.title === 'string') f.title = f.title.replace(/内规/g, '受审文本');\n });\n return { findings };\n}\n\n/**\n * 时效核查:根据\"库内版本\"与联网检索摘要,判断各库内法规是否疑似有更新版本。\n * @param {Object} p\n * @param {Array} p.items [{name, version_date, snippets:[{title,content,url}]}]\n * @param {Object} p.settings\n * @returns {Promise<Array>} [{name, found_version, note}] —— 仅疑似有新版的\n */\nexport async function judgeFreshness({ items, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey || !Array.isArray(items) || !items.length) return [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const list = items.map((it, i) =>\n `${i + 1}. 法规:${it.name}\\n 库内版本:${it.version_date || '未知'}\\n 检索摘要:${(it.snippets || []).map((sn) => (sn.title || '') + ' ' + (sn.content || '')).join(' | ').slice(0, 900)}`\n ).join('\\n\\n');\n const sys = '你是法规时效核查员。根据每部法规的\"库内版本\"与\"检索摘要\",判断是否【疑似存在比库内版本更新的、现行有效的修订版本】。\\n- 仅当摘要中出现明确更晚的发布/修订/施行年份(晚于库内版本)时才判 suspected_newer=true;不确定一律 false,宁可不报、避免误扰。\\n- found_version 填你从摘要看到的疑似新版日期或文号(没有则留空)。\\n严格只输出 JSON:{\"alerts\":[{\"name\":\"...\",\"suspected_newer\":true,\"found_version\":\"...\",\"note\":\"一句话依据\"}]}';\n const base = { model: s.modelName || '', temperature: 0.1, max_tokens: 1500, messages: [{ role: 'system', content: sys }, { role: 'user', content: '待核查法规:\\n\\n' + list }] };\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) content = await callModel(url, modelKey, base);\n else return [];\n }\n let obj;\n try { obj = extractJSON(content); } catch (e) { return []; }\n const alerts = Array.isArray(obj && obj.alerts) ? obj.alerts : [];\n return alerts.filter((a) => a && a.suspected_newer)\n .map((a) => ({ name: a.name, found_version: a.found_version || '', note: a.note || '' }));\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return { queries: [], categories: [] };\n }\n const qs = Array.isArray(obj && obj.queries)\n ? obj.queries.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7)\n : [];\n const cats = Array.isArray(obj && obj.categories)\n ? obj.categories.filter((c) => typeof c === 'string' && c.trim()).map((c) => c.trim())\n : [];\n return { queries: qs, categories: cats };\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('【网络错误】无法连接 AI 模型接口,可能是断网、接口地址有误或浏览器 CORS 拦截。DeepSeek 一般可直连;若接口需代理,请在「设置 → 转发前缀」配置。(' + (netErr && netErr.message ? netErr.message : String(netErr)) + ')');\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(modelErrorMessage(resp.status, raw));\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('【返回异常】AI 模型返回的不是合法 JSON,无法读取,请重试。');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('【返回为空】AI 模型未返回内容,请重试或更换模型。');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n// 把模型接口的各类错误规整成带【标签】的标准提示(便于一眼识别 + 与帮助文档对照)\nfunction modelErrorMessage(status, raw) {\n const detail = extractErrorMessage(raw) || '';\n const r = String(raw || '') + ' ' + detail;\n if (status === 401 || status === 403 || /authentication|api[_\\s-]?key|invalid.*key|unauthor|鉴权|认证/i.test(r))\n return '【模型 Key 无效】AI 模型 API Key 不正确或已过期。请在「设置」核对:Key 应以 sk- 开头(到 platform.deepseek.com 重新复制),「模型名称」框填 deepseek-reasoner 或 deepseek-chat——不要把模型名误填进 Key 框。';\n if (status === 402 || /insufficient|balance|余额|欠费|payment required/i.test(r))\n return '【模型余额不足】DeepSeek 账户余额不足,请登录 platform.deepseek.com 充值后重试。';\n if (status === 429 || /rate.?limit|too many|频繁|quota/i.test(r))\n return '【请求过于频繁】触发了模型限流,请稍候片刻再试。';\n if (status === 404 || /model.*(not.*found|not.*exist|does not exist)|no such model|未找到.*模型/i.test(r))\n return '【模型名称错误】「设置 → 模型名称」填写有误,请填 deepseek-reasoner 或 deepseek-chat。';\n if (status === 400)\n return '【请求被拒(400)】' + (detail || '请检查「设置」里的接口地址与模型名称是否正确。');\n if (status >= 500)\n return '【模型服务异常(' + status + ')】AI 服务暂时不可用,请稍后重试。';\n return '【模型调用失败(' + status + ')】' + (detail || '未知错误');\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、coverage。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n // ── 用词统一:模型偶尔仍说\"内规\",一律改为\"受审文本\"(逐字引用字段不动)──\n const fix = (s) => (typeof s === 'string' ? s.replace(/内规/g, '受审文本') : s);\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.problem = fix(f.problem); f.suggestion = fix(f.suggestion); f.title = fix(f.title);\n });\n coverage.forEach((c) => {\n if (!c || typeof c !== 'object') return;\n c.note = fix(c.note); c.topic = fix(c.topic);\n });\n\n return { summary, findings, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "a30e32d13d46e244d9fccaf4c14e9c5b81735de8": {"t": 1785765976.7149274, "n": 13815, "h": "cbb17b6f30fe8d52", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, coverage:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('【解析失败】模型返回的内容无法解析为 JSON(可能未按要求输出),请重试或改用 deepseek-reasoner。');\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('【解析失败】模型返回内容不是有效的 JSON 对象,请重试。');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n // 用词统一:内规 → 受审文本\n if (typeof f.problem === 'string') f.problem = f.problem.replace(/内规/g, '受审文本');\n if (typeof f.suggestion === 'string') f.suggestion = f.suggestion.replace(/内规/g, '受审文本');\n if (typeof f.title === 'string') f.title = f.title.replace(/内规/g, '受审文本');\n });\n return { findings };\n}\n\n/**\n * 时效核查:根据\"库内版本\"与联网检索摘要,判断各库内法规是否疑似有更新版本。\n * @param {Object} p\n * @param {Array} p.items [{name, version_date, snippets:[{title,content,url}]}]\n * @param {Object} p.settings\n * @returns {Promise<Array>} [{name, found_version, note}] —— 仅疑似有新版的\n */\nexport async function judgeFreshness({ items, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey || !Array.isArray(items) || !items.length) return [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const list = items.map((it, i) =>\n `${i + 1}. 法规:${it.name}\\n 库内版本:${it.version_date || '未知'}\\n 检索摘要:${(it.snippets || []).map((sn) => (sn.title || '') + ' ' + (sn.content || '')).join(' | ').slice(0, 900)}`\n ).join('\\n\\n');\n const sys = '你是法规时效核查员。根据每部法规的\"库内版本\"与\"检索摘要\",判断是否【疑似存在比库内版本更新的、现行有效的修订版本】。\\n- 仅当摘要中出现明确更晚的发布/修订/施行年份(晚于库内版本)时才判 suspected_newer=true;不确定一律 false,宁可不报、避免误扰。\\n- found_version 填你从摘要看到的疑似新版日期或文号(没有则留空)。\\n严格只输出 JSON:{\"alerts\":[{\"name\":\"...\",\"suspected_newer\":true,\"found_version\":\"...\",\"note\":\"一句话依据\"}]}';\n const base = { model: s.modelName || '', temperature: 0.1, max_tokens: 1500, messages: [{ role: 'system', content: sys }, { role: 'user', content: '待核查法规:\\n\\n' + list }] };\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) content = await callModel(url, modelKey, base);\n else return [];\n }\n let obj;\n try { obj = extractJSON(content); } catch (e) { return []; }\n const alerts = Array.isArray(obj && obj.alerts) ? obj.alerts : [];\n return alerts.filter((a) => a && a.suspected_newer)\n .map((a) => ({ name: a.name, found_version: a.found_version || '', note: a.note || '' }));\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return { queries: [], categories: [] };\n }\n const qs = Array.isArray(obj && obj.queries)\n ? obj.queries.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7)\n : [];\n const cats = Array.isArray(obj && obj.categories)\n ? obj.categories.filter((c) => typeof c === 'string' && c.trim()).map((c) => c.trim())\n : [];\n return { queries: qs, categories: cats };\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('【网络错误】无法连接 AI 模型接口,可能是断网、接口地址有误或浏览器 CORS 拦截。DeepSeek 一般可直连;若接口需代理,请在「设置 → 转发前缀」配置。(' + (netErr && netErr.message ? netErr.message : String(netErr)) + ')');\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(modelErrorMessage(resp.status, raw));\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('【返回异常】AI 模型返回的不是合法 JSON,无法读取,请重试。');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('【返回为空】AI 模型未返回内容,请重试或更换模型。');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n// 把模型接口的各类错误规整成带【标签】的标准提示(便于一眼识别 + 与帮助文档对照)\nfunction modelErrorMessage(status, raw) {\n const detail = extractErrorMessage(raw) || '';\n const r = String(raw || '') + ' ' + detail;\n if (status === 401 || status === 403 || /authentication|api[_\\s-]?key|invalid.*key|unauthor|鉴权|认证/i.test(r))\n return '【模型 Key 无效】AI 模型 API Key 不正确或已过期。请在「设置」核对:Key 应以 sk- 开头(到 platform.deepseek.com 重新复制),「模型名称」框填 deepseek-reasoner 或 deepseek-chat——不要把模型名误填进 Key 框。';\n if (status === 402 || /insufficient|balance|余额|欠费|payment required/i.test(r))\n return '【模型余额不足】DeepSeek 账户余额不足,请登录 platform.deepseek.com 充值后重试。';\n if (status === 429 || /rate.?limit|too many|频繁|quota/i.test(r))\n return '【请求过于频繁】触发了模型限流,请稍候片刻再试。';\n if (status === 404 || /model.*(not.*found|not.*exist|does not exist)|no such model|未找到.*模型/i.test(r))\n return '【模型名称错误】「设置 → 模型名称」填写有误,请填 deepseek-reasoner 或 deepseek-chat。';\n if (status === 400)\n return '【请求被拒(400)】' + (detail || '请检查「设置」里的接口地址与模型名称是否正确。');\n if (status >= 500)\n return '【模型服务异常(' + status + ')】AI 服务暂时不可用,请稍后重试。';\n return '【模型调用失败(' + status + ')】' + (detail || '未知错误');\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、coverage。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n // ── 用词统一:模型偶尔仍说\"内规\",一律改为\"受审文本\"(逐字引用字段不动)──\n const fix = (s) => (typeof s === 'string' ? s.replace(/内规/g, '受审文本') : s);\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.problem = fix(f.problem); f.suggestion = fix(f.suggestion); f.title = fix(f.title);\n });\n coverage.forEach((c) => {\n if (!c || typeof c !== 'object') return;\n c.note = fix(c.note); c.topic = fix(c.topic);\n });\n\n return { summary, findings, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "1560819827640604566c78f549a151a6b559e3a9": {"t": 1785765978.0634127, "n": 14028, "h": "f5b7ac007fa85f1c", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('【解析失败】模型返回的内容无法解析为 JSON(可能未按要求输出),请重试或改用 deepseek-reasoner。');\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('【解析失败】模型返回内容不是有效的 JSON 对象,请重试。');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n // 用词统一:内规 → 受审文本\n if (typeof f.problem === 'string') f.problem = f.problem.replace(/内规/g, '受审文本');\n if (typeof f.suggestion === 'string') f.suggestion = f.suggestion.replace(/内规/g, '受审文本');\n if (typeof f.title === 'string') f.title = f.title.replace(/内规/g, '受审文本');\n });\n return { findings };\n}\n\n/**\n * 时效核查:根据\"库内版本\"与联网检索摘要,判断各库内法规是否疑似有更新版本。\n * @param {Object} p\n * @param {Array} p.items [{name, version_date, snippets:[{title,content,url}]}]\n * @param {Object} p.settings\n * @returns {Promise<Array>} [{name, found_version, note}] —— 仅疑似有新版的\n */\nexport async function judgeFreshness({ items, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey || !Array.isArray(items) || !items.length) return [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const list = items.map((it, i) =>\n `${i + 1}. 法规:${it.name}\\n 库内版本:${it.version_date || '未知'}\\n 检索摘要:${(it.snippets || []).map((sn) => (sn.title || '') + ' ' + (sn.content || '')).join(' | ').slice(0, 900)}`\n ).join('\\n\\n');\n const sys = '你是法规时效核查员。根据每部法规的\"库内版本\"与\"检索摘要\",判断是否【疑似存在比库内版本更新的、现行有效的修订版本】。\\n- 仅当摘要中出现明确更晚的发布/修订/施行年份(晚于库内版本)时才判 suspected_newer=true;不确定一律 false,宁可不报、避免误扰。\\n- found_version 填你从摘要看到的疑似新版日期或文号(没有则留空)。\\n严格只输出 JSON:{\"alerts\":[{\"name\":\"...\",\"suspected_newer\":true,\"found_version\":\"...\",\"note\":\"一句话依据\"}]}';\n const base = { model: s.modelName || '', temperature: 0.1, max_tokens: 1500, messages: [{ role: 'system', content: sys }, { role: 'user', content: '待核查法规:\\n\\n' + list }] };\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) content = await callModel(url, modelKey, base);\n else return [];\n }\n let obj;\n try { obj = extractJSON(content); } catch (e) { return []; }\n const alerts = Array.isArray(obj && obj.alerts) ? obj.alerts : [];\n return alerts.filter((a) => a && a.suspected_newer)\n .map((a) => ({ name: a.name, found_version: a.found_version || '', note: a.note || '' }));\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return { queries: [], categories: [] };\n }\n const qs = Array.isArray(obj && obj.queries)\n ? obj.queries.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7)\n : [];\n const cats = Array.isArray(obj && obj.categories)\n ? obj.categories.filter((c) => typeof c === 'string' && c.trim()).map((c) => c.trim())\n : [];\n return { queries: qs, categories: cats };\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('【网络错误】无法连接 AI 模型接口,可能是断网、接口地址有误或浏览器 CORS 拦截。DeepSeek 一般可直连;若接口需代理,请在「设置 → 转发前缀」配置。(' + (netErr && netErr.message ? netErr.message : String(netErr)) + ')');\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(modelErrorMessage(resp.status, raw));\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('【返回异常】AI 模型返回的不是合法 JSON,无法读取,请重试。');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('【返回为空】AI 模型未返回内容,请重试或更换模型。');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n// 把模型接口的各类错误规整成带【标签】的标准提示(便于一眼识别 + 与帮助文档对照)\nfunction modelErrorMessage(status, raw) {\n const detail = extractErrorMessage(raw) || '';\n const r = String(raw || '') + ' ' + detail;\n if (status === 401 || status === 403 || /authentication|api[_\\s-]?key|invalid.*key|unauthor|鉴权|认证/i.test(r))\n return '【模型 Key 无效】AI 模型 API Key 不正确或已过期。请在「设置」核对:Key 应以 sk- 开头(到 platform.deepseek.com 重新复制),「模型名称」框填 deepseek-reasoner 或 deepseek-chat——不要把模型名误填进 Key 框。';\n if (status === 402 || /insufficient|balance|余额|欠费|payment required/i.test(r))\n return '【模型余额不足】DeepSeek 账户余额不足,请登录 platform.deepseek.com 充值后重试。';\n if (status === 429 || /rate.?limit|too many|频繁|quota/i.test(r))\n return '【请求过于频繁】触发了模型限流,请稍候片刻再试。';\n if (status === 404 || /model.*(not.*found|not.*exist|does not exist)|no such model|未找到.*模型/i.test(r))\n return '【模型名称错误】「设置 → 模型名称」填写有误,请填 deepseek-reasoner 或 deepseek-chat。';\n if (status === 400)\n return '【请求被拒(400)】' + (detail || '请检查「设置」里的接口地址与模型名称是否正确。');\n if (status >= 500)\n return '【模型服务异常(' + status + ')】AI 服务暂时不可用,请稍后重试。';\n return '【模型调用失败(' + status + ')】' + (detail || '未知错误');\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n // ── 用词统一:模型偶尔仍说\"内规\",一律改为\"受审文本\"(逐字引用字段不动)──\n const fix = (s) => (typeof s === 'string' ? s.replace(/内规/g, '受审文本') : s);\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.problem = fix(f.problem); f.suggestion = fix(f.suggestion); f.title = fix(f.title);\n });\n coverage.forEach((c) => {\n if (!c || typeof c !== 'object') return;\n c.note = fix(c.note); c.topic = fix(c.topic);\n });\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "ac81a9ddce8b05abb20994a305676bdc0566f6de": {"t": 1785765979.5888445, "n": 12217, "h": "14a6d672cb7dbd5b", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('【解析失败】模型返回的内容无法解析为 JSON(可能未按要求输出),请重试或改用 deepseek-reasoner。');\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('【解析失败】模型返回内容不是有效的 JSON 对象,请重试。');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n // 用词统一:内规 → 受审文本\n if (typeof f.problem === 'string') f.problem = f.problem.replace(/内规/g, '受审文本');\n if (typeof f.suggestion === 'string') f.suggestion = f.suggestion.replace(/内规/g, '受审文本');\n if (typeof f.title === 'string') f.title = f.title.replace(/内规/g, '受审文本');\n });\n return { findings };\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return { queries: [], categories: [] };\n }\n const qs = Array.isArray(obj && obj.queries)\n ? obj.queries.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7)\n : [];\n const cats = Array.isArray(obj && obj.categories)\n ? obj.categories.filter((c) => typeof c === 'string' && c.trim()).map((c) => c.trim())\n : [];\n return { queries: qs, categories: cats };\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('【网络错误】无法连接 AI 模型接口,可能是断网、接口地址有误或浏览器 CORS 拦截。DeepSeek 一般可直连;若接口需代理,请在「设置 → 转发前缀」配置。(' + (netErr && netErr.message ? netErr.message : String(netErr)) + ')');\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(modelErrorMessage(resp.status, raw));\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('【返回异常】AI 模型返回的不是合法 JSON,无法读取,请重试。');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('【返回为空】AI 模型未返回内容,请重试或更换模型。');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n// 把模型接口的各类错误规整成带【标签】的标准提示(便于一眼识别 + 与帮助文档对照)\nfunction modelErrorMessage(status, raw) {\n const detail = extractErrorMessage(raw) || '';\n const r = String(raw || '') + ' ' + detail;\n if (status === 401 || status === 403 || /authentication|api[_\\s-]?key|invalid.*key|unauthor|鉴权|认证/i.test(r))\n return '【模型 Key 无效】AI 模型 API Key 不正确或已过期。请在「设置」核对:Key 应以 sk- 开头(到 platform.deepseek.com 重新复制),「模型名称」框填 deepseek-reasoner 或 deepseek-chat——不要把模型名误填进 Key 框。';\n if (status === 402 || /insufficient|balance|余额|欠费|payment required/i.test(r))\n return '【模型余额不足】DeepSeek 账户余额不足,请登录 platform.deepseek.com 充值后重试。';\n if (status === 429 || /rate.?limit|too many|频繁|quota/i.test(r))\n return '【请求过于频繁】触发了模型限流,请稍候片刻再试。';\n if (status === 404 || /model.*(not.*found|not.*exist|does not exist)|no such model|未找到.*模型/i.test(r))\n return '【模型名称错误】「设置 → 模型名称」填写有误,请填 deepseek-reasoner 或 deepseek-chat。';\n if (status === 400)\n return '【请求被拒(400)】' + (detail || '请检查「设置」里的接口地址与模型名称是否正确。');\n if (status >= 500)\n return '【模型服务异常(' + status + ')】AI 服务暂时不可用,请稍后重试。';\n return '【模型调用失败(' + status + ')】' + (detail || '未知错误');\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n // ── 用词统一:模型偶尔仍说\"内规\",一律改为\"受审文本\"(逐字引用字段不动)──\n const fix = (s) => (typeof s === 'string' ? s.replace(/内规/g, '受审文本') : s);\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.problem = fix(f.problem); f.suggestion = fix(f.suggestion); f.title = fix(f.title);\n });\n coverage.forEach((c) => {\n if (!c || typeof c !== 'object') return;\n c.note = fix(c.note); c.topic = fix(c.topic);\n });\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "c9c196a6665e41e393c70dc8786c6ff372953f80": {"t": 1785765981.0360994, "n": 11996, "h": "e96064159b429176", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('【解析失败】模型返回的内容无法解析为 JSON(可能未按要求输出),请重试或改用 deepseek-reasoner。');\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('【解析失败】模型返回内容不是有效的 JSON 对象,请重试。');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n // 用词统一:内规 → 受审文本\n if (typeof f.problem === 'string') f.problem = f.problem.replace(/内规/g, '受审文本');\n if (typeof f.suggestion === 'string') f.suggestion = f.suggestion.replace(/内规/g, '受审文本');\n if (typeof f.title === 'string') f.title = f.title.replace(/内规/g, '受审文本');\n });\n return { findings };\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('【未配置模型 Key】请在「设置」中填写 AI 模型 API Key(DeepSeek 的 sk- 开头 Key)');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return [];\n }\n const qs = Array.isArray(obj && obj.queries) ? obj.queries : [];\n return qs.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('【网络错误】无法连接 AI 模型接口,可能是断网、接口地址有误或浏览器 CORS 拦截。DeepSeek 一般可直连;若接口需代理,请在「设置 → 转发前缀」配置。(' + (netErr && netErr.message ? netErr.message : String(netErr)) + ')');\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(modelErrorMessage(resp.status, raw));\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('【返回异常】AI 模型返回的不是合法 JSON,无法读取,请重试。');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('【返回为空】AI 模型未返回内容,请重试或更换模型。');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n// 把模型接口的各类错误规整成带【标签】的标准提示(便于一眼识别 + 与帮助文档对照)\nfunction modelErrorMessage(status, raw) {\n const detail = extractErrorMessage(raw) || '';\n const r = String(raw || '') + ' ' + detail;\n if (status === 401 || status === 403 || /authentication|api[_\\s-]?key|invalid.*key|unauthor|鉴权|认证/i.test(r))\n return '【模型 Key 无效】AI 模型 API Key 不正确或已过期。请在「设置」核对:Key 应以 sk- 开头(到 platform.deepseek.com 重新复制),「模型名称」框填 deepseek-reasoner 或 deepseek-chat——不要把模型名误填进 Key 框。';\n if (status === 402 || /insufficient|balance|余额|欠费|payment required/i.test(r))\n return '【模型余额不足】DeepSeek 账户余额不足,请登录 platform.deepseek.com 充值后重试。';\n if (status === 429 || /rate.?limit|too many|频繁|quota/i.test(r))\n return '【请求过于频繁】触发了模型限流,请稍候片刻再试。';\n if (status === 404 || /model.*(not.*found|not.*exist|does not exist)|no such model|未找到.*模型/i.test(r))\n return '【模型名称错误】「设置 → 模型名称」填写有误,请填 deepseek-reasoner 或 deepseek-chat。';\n if (status === 400)\n return '【请求被拒(400)】' + (detail || '请检查「设置」里的接口地址与模型名称是否正确。');\n if (status >= 500)\n return '【模型服务异常(' + status + ')】AI 服务暂时不可用,请稍后重试。';\n return '【模型调用失败(' + status + ')】' + (detail || '未知错误');\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n // ── 用词统一:模型偶尔仍说\"内规\",一律改为\"受审文本\"(逐字引用字段不动)──\n const fix = (s) => (typeof s === 'string' ? s.replace(/内规/g, '受审文本') : s);\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.problem = fix(f.problem); f.suggestion = fix(f.suggestion); f.title = fix(f.title);\n });\n coverage.forEach((c) => {\n if (!c || typeof c !== 'object') return;\n c.note = fix(c.note); c.topic = fix(c.topic);\n });\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "7839dffc3c9b0b6ae27bcb598da7586517bfffd7": {"t": 1785765982.7673564, "n": 10805, "h": "f0686ab0aaeabf26", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('模型返回内容无法解析为 JSON:' + (e && e.message ? e.message : String(e)));\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('模型返回内容不是有效的 JSON 对象');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n // 用词统一:内规 → 受审文本\n if (typeof f.problem === 'string') f.problem = f.problem.replace(/内规/g, '受审文本');\n if (typeof f.suggestion === 'string') f.suggestion = f.suggestion.replace(/内规/g, '受审文本');\n if (typeof f.title === 'string') f.title = f.title.replace(/内规/g, '受审文本');\n });\n return { findings };\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return [];\n }\n const qs = Array.isArray(obj && obj.queries) ? obj.queries : [];\n return qs.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('调用 AI 模型失败(网络错误,可在「设置」中配置代理前缀绕过 CORS):' + (netErr && netErr.message ? netErr.message : String(netErr)));\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n const msg = extractErrorMessage(raw);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(`调用 AI 模型失败(HTTP ${resp.status}):${msg || raw || '无返回内容'}`);\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('AI 模型返回内容不是合法 JSON,无法读取');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('AI 模型返回为空,未取得审查结果');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n // ── 用词统一:模型偶尔仍说\"内规\",一律改为\"受审文本\"(逐字引用字段不动)──\n const fix = (s) => (typeof s === 'string' ? s.replace(/内规/g, '受审文本') : s);\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.problem = fix(f.problem); f.suggestion = fix(f.suggestion); f.title = fix(f.title);\n });\n coverage.forEach((c) => {\n if (!c || typeof c !== 'object') return;\n c.note = fix(c.note); c.topic = fix(c.topic);\n });\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "5d1f9cc92ec837549b79c225b7a8750f7f70f041": {"t": 1785765984.3762968, "n": 10102, "h": "fa9e8eb3e9764c73", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage, CRITIC_PROMPT, buildCriticMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('模型返回内容无法解析为 JSON:' + (e && e.message ? e.message : String(e)));\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('模型返回内容不是有效的 JSON 对象');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 查漏复审(第二遍):基于已发现问题,找出被遗漏的新增问题。\n * @returns {Promise<{findings:Array}>} 仅新增的、已做反幻觉校验的 findings\n */\nexport async function reviewCritic({ docText, regs, existing, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) return { findings: [] };\n const regList = Array.isArray(regs) ? regs : [];\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: CRITIC_PROMPT },\n { role: 'user', content: buildCriticMessage(docText, regList, existing) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 12000, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try { obj = extractJSON(content); } catch (e) { return { findings: [] }; }\n const findings = Array.isArray(obj && obj.findings) ? obj.findings : [];\n\n // 反幻觉校验(同主审查)\n const urlSet = new Set();\n const idToUrl = new Map();\n regList.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n f.round = f.round || round;\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) f.linkSuspect = true;\n });\n return { findings };\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return [];\n }\n const qs = Array.isArray(obj && obj.queries) ? obj.queries : [];\n return qs.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('调用 AI 模型失败(网络错误,可在「设置」中配置代理前缀绕过 CORS):' + (netErr && netErr.message ? netErr.message : String(netErr)));\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n const msg = extractErrorMessage(raw);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(`调用 AI 模型失败(HTTP ${resp.status}):${msg || raw || '无返回内容'}`);\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('AI 模型返回内容不是合法 JSON,无法读取');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('AI 模型返回为空,未取得审查结果');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "a646b548fb7e468f31290f637e7d05d3eb1fe50b": {"t": 1785765985.8060334, "n": 8294, "h": "bcad8c3585528fa1", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 16000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('模型返回内容无法解析为 JSON:' + (e && e.message ? e.message : String(e)));\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('模型返回内容不是有效的 JSON 对象');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return [];\n }\n const qs = Array.isArray(obj && obj.queries) ? obj.queries : [];\n return qs.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('调用 AI 模型失败(网络错误,可在「设置」中配置代理前缀绕过 CORS):' + (netErr && netErr.message ? netErr.message : String(netErr)));\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n const msg = extractErrorMessage(raw);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(`调用 AI 模型失败(HTTP ${resp.status}):${msg || raw || '无返回内容'}`);\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('AI 模型返回内容不是合法 JSON,无法读取');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('AI 模型返回为空,未取得审查结果');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "1101f780f690a2892007ad1250acf3edeafd536d": {"t": 1785765988.5999565, "n": 8293, "h": "d042417eb8fe4fb9", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 8000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('模型返回内容无法解析为 JSON:' + (e && e.message ? e.message : String(e)));\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('模型返回内容不是有效的 JSON 对象');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return [];\n }\n const qs = Array.isArray(obj && obj.queries) ? obj.queries : [];\n return qs.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('调用 AI 模型失败(网络错误,可在「设置」中配置代理前缀绕过 CORS):' + (netErr && netErr.message ? netErr.message : String(netErr)));\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n const msg = extractErrorMessage(raw);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(`调用 AI 模型失败(HTTP ${resp.status}):${msg || raw || '无返回内容'}`);\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('AI 模型返回内容不是合法 JSON,无法读取');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('AI 模型返回为空,未取得审查结果');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n // ── coverage(审查覆盖·逐条对照)──\n let coverage = obj.coverage || [];\n if (!Array.isArray(coverage)) coverage = [];\n\n return { summary, findings, suggestedSearches, coverage };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "e5d77afc055030a2a93a07dc900395df5abf6ec2": {"t": 1785765990.07509, "n": 8168, "h": "7b233d34b609cf38", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON, TOPIC_PROMPT, buildTopicMessage } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 8000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('模型返回内容无法解析为 JSON:' + (e && e.message ? e.message : String(e)));\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('模型返回内容不是有效的 JSON 对象');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 让模型读内规,提取用于联网检索\"外规\"的检索词(法律法规名 + 主题)。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @returns {Promise<string[]>} 4-7 条检索词;失败时返回 [](由调用方兜底)\n */\nexport async function extractQueries({ docText, settings }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n const url = s.proxy ? s.proxy + (s.modelEndpoint || '') : (s.modelEndpoint || '');\n const messages = [\n { role: 'system', content: TOPIC_PROMPT },\n { role: 'user', content: buildTopicMessage(docText) },\n ];\n const base = { model: s.modelName || '', temperature: 0.2, max_tokens: 700, messages };\n\n let content;\n try {\n content = await callModel(url, modelKey, { ...base, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, base);\n } else {\n throw err;\n }\n }\n\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n return [];\n }\n const qs = Array.isArray(obj && obj.queries) ? obj.queries : [];\n return qs.filter((q) => typeof q === 'string' && q.trim()).map((q) => q.trim()).slice(0, 7);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('调用 AI 模型失败(网络错误,可在「设置」中配置代理前缀绕过 CORS):' + (netErr && netErr.message ? netErr.message : String(netErr)));\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n const msg = extractErrorMessage(raw);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(`调用 AI 模型失败(HTTP ${resp.status}):${msg || raw || '无返回内容'}`);\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('AI 模型返回内容不是合法 JSON,无法读取');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('AI 模型返回为空,未取得审查结果');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n return { summary, findings, suggestedSearches };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}, "d235357eb83cd05be51ef96d0560ca76e603557c": {"t": 1785765992.2910407, "n": 6848, "h": "fb295603d50c43d5", "c": "// review.js — 调用 OpenAI 兼容 Chat Completions 执行联网合规审查\n// 设计契约见 README;仅依赖 config.js,不依赖 state.js。\n// 浏览器原生 ES module。\n\nimport { SYSTEM_PROMPT, buildUserMessage, extractJSON } from './config.js';\n\n// 合法的风险类型集合(用于兜底统计 counts)\nconst RISK_TYPES = ['conflict', 'omission', 'ultra_vires', 'ambiguous', 'wording', 'outdated'];\n\n/**\n * 执行一轮合规审查。\n * @param {Object} p\n * @param {string} p.docText 内规全文\n * @param {Array} p.regs 已选外规数组(含 id,title,clauseNo,quote,url)\n * @param {string} p.mode 'fast' | 'full'\n * @param {Object} p.settings { modelEndpoint, modelName, modelKey, proxy }\n * @param {number} [p.round=1] 当前审查轮次\n * @returns {Promise<{summary:Object, findings:Array, suggestedSearches:Array}>}\n */\nexport async function runReview({ docText, regs, mode, settings, round = 1 }) {\n const s = settings || {};\n const modelKey = (s.modelKey || '').trim();\n if (!modelKey) {\n throw new Error('未配置 AI 模型 API Key,请在「设置」中填写');\n }\n\n const regList = Array.isArray(regs) ? regs : [];\n const endpoint = s.modelEndpoint || '';\n const url = s.proxy ? s.proxy + endpoint : endpoint;\n const modelName = s.modelName || '';\n\n const messages = [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: buildUserMessage(docText, regList, mode) },\n ];\n\n const baseBody = {\n model: modelName,\n temperature: 0.2,\n max_tokens: 8000,\n messages,\n };\n\n // 第一次带 response_format=json_object;若 400 且报不支持 response_format,则去掉重试。\n let content;\n try {\n content = await callModel(url, modelKey, { ...baseBody, response_format: { type: 'json_object' } });\n } catch (err) {\n if (err && err._retryWithoutResponseFormat) {\n content = await callModel(url, modelKey, baseBody);\n } else {\n throw err;\n }\n }\n\n // 解析 JSON\n let obj;\n try {\n obj = extractJSON(content);\n } catch (e) {\n throw new Error('模型返回内容无法解析为 JSON:' + (e && e.message ? e.message : String(e)));\n }\n if (!obj || typeof obj !== 'object') {\n throw new Error('模型返回内容不是有效的 JSON 对象');\n }\n\n return normalize(obj, regList, round);\n}\n\n/**\n * 发送一次请求并取回 message.content。\n * 若疑似不支持 response_format(状态码 400 且 message 含 response_format),\n * 抛出带 _retryWithoutResponseFormat 标志的 Error 由上层重试。\n */\nasync function callModel(url, modelKey, body) {\n let resp;\n try {\n resp = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: 'Bearer ' + modelKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n } catch (netErr) {\n // 网络层异常(CORS、断网、DNS 等)\n throw new Error('调用 AI 模型失败(网络错误,可在「设置」中配置代理前缀绕过 CORS):' + (netErr && netErr.message ? netErr.message : String(netErr)));\n }\n\n if (!resp.ok) {\n const raw = await safeText(resp);\n const msg = extractErrorMessage(raw);\n // 判断是否疑似不支持 response_format\n if (resp.status === 400 && /response_format/i.test(raw || '') && body.response_format) {\n const e = new Error('模型不支持 response_format');\n e._retryWithoutResponseFormat = true;\n throw e;\n }\n throw new Error(`调用 AI 模型失败(HTTP ${resp.status}):${msg || raw || '无返回内容'}`);\n }\n\n let data;\n try {\n data = await resp.json();\n } catch (e) {\n throw new Error('AI 模型返回内容不是合法 JSON,无法读取');\n }\n\n const content = data\n && data.choices\n && data.choices[0]\n && data.choices[0].message\n && data.choices[0].message.content;\n\n if (!content || !String(content).trim()) {\n throw new Error('AI 模型返回为空,未取得审查结果');\n }\n return content;\n}\n\n// 安全读取响应文本,不抛异常\nasync function safeText(resp) {\n try {\n return await resp.text();\n } catch (e) {\n return '';\n }\n}\n\n// 从错误响应体中尽量提取人类可读的 message\nfunction extractErrorMessage(raw) {\n if (!raw) return '';\n try {\n const j = JSON.parse(raw);\n if (j && j.error) {\n if (typeof j.error === 'string') return j.error;\n if (j.error.message) return j.error.message;\n }\n if (j && j.message) return j.message;\n } catch (e) {\n // 非 JSON,原样返回截断后的文本\n }\n return String(raw).slice(0, 500);\n}\n\n/**\n * 规整模型返回:补 round、反幻觉校验、summary 兜底统计与评分、suggestedSearches。\n */\nfunction normalize(obj, regs, round) {\n // ── findings ──\n const findings = Array.isArray(obj.findings) ? obj.findings : [];\n\n // 用 regs 的 url 建立 Set(去掉空值);id -> url 映射用于按 source_id 补链。\n const urlSet = new Set();\n const idToUrl = new Map();\n regs.forEach((r) => {\n if (r && r.url) urlSet.add(r.url);\n if (r && r.id != null) idToUrl.set(String(r.id), r.url || '');\n });\n\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n // 补轮次\n f.round = f.round || round;\n\n // 若 source_url 缺失但 source_id 命中 regs,则补 source_url\n if (!f.source_url && f.source_id != null && idToUrl.has(String(f.source_id))) {\n const hit = idToUrl.get(String(f.source_id));\n if (hit) f.source_url = hit;\n }\n\n // 反幻觉校验:source_url 必须在 regs 的 url 集合内\n f.verified = !!(f.source_url && urlSet.has(f.source_url));\n if (!f.verified) {\n f.linkSuspect = true;\n } else {\n // 校验通过时清除可能存在的可疑标记\n if (f.linkSuspect) f.linkSuspect = false;\n }\n });\n\n // ── summary ──\n const summary = buildSummary(obj.summary, findings);\n\n // ── suggestedSearches ──\n let suggestedSearches = obj.suggested_searches || obj.suggestedSearches || [];\n if (!Array.isArray(suggestedSearches)) suggestedSearches = [];\n\n return { summary, findings, suggestedSearches };\n}\n\n/**\n * 构造/补全 summary。\n * 若模型给出的 summary 缺失或 counts 缺失,则按 findings 重新统计并评分。\n */\nfunction buildSummary(rawSummary, findings) {\n const hasSummary = rawSummary && typeof rawSummary === 'object';\n const hasCounts = hasSummary && rawSummary.counts && typeof rawSummary.counts === 'object';\n\n if (hasSummary && hasCounts) {\n // 模型给的 summary 足够完整,直接采用(但确保字段类型合理)\n return rawSummary;\n }\n\n // 兜底:根据 findings 的 risk_type 统计 counts\n const counts = { conflict: 0, omission: 0, ultra_vires: 0, ambiguous: 0, wording: 0, outdated: 0 };\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n const t = f.risk_type;\n if (RISK_TYPES.includes(t)) {\n counts[t] = (counts[t] || 0) + 1;\n }\n });\n\n // 按 severity 计分:high*12 / medium*5 / low*2,wording 不计。\n let high = 0;\n let medium = 0;\n let low = 0;\n findings.forEach((f) => {\n if (!f || typeof f !== 'object') return;\n if (f.risk_type === 'wording') return; // wording 不扣分\n const sev = f.severity;\n if (sev === 'high') high += 1;\n else if (sev === 'medium') medium += 1;\n else if (sev === 'low') low += 1;\n });\n\n let score = 100 - high * 12 - medium * 5 - low * 2;\n if (score < 0) score = 0;\n\n const risk_level = score < 60 ? 'high' : (score < 80 ? 'medium' : 'low');\n\n const score_breakdown = `基础分100;高危 ${high}×12=${high * 12},中危 ${medium}×5=${medium * 5},低危 ${low}×2=${low * 2}(措辞不计);最终得分 ${score}`;\n\n return { score, score_breakdown, risk_level, counts };\n}\n"}}}}