1 line
138 KiB
JSON
1 line
138 KiB
JSON
{"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"}}}} |