从 0 到 1 构建一个「降 AI 率自查工作台」

降 AI 率是个技术活,这篇文章记录我如何从零搭建降 AI率自查工作台,所有关键代码都在文中。

在这里插入图片描述

1. 起因:每天只有 5 次机会的盲改

在利用AI辅助写作通常都会遇到AI率的检测,要过 AIGC 检测。手头最靠谱的免费检测器是腾讯朱雀,但它每天只有 5 次额度。

于是我的迭代节奏变成了这样:改一版稿 → 送检 → 等结果 → 看哪几段标红 → 再改 → 再送检。第 14 版人工特征率 61.54%,第 15 版 49.63%,第 16 版 37.93%,第 17 版又弹回 46.94%。

分数在反复横跳,而我能做的只是盯着一整页红绿片段猜"它到底讨厌什么"。这不是改稿,这是抽卡。

转折点是我把多版检测报告铺在一起对比,发现被标 0.99 高 AI 值的片段,高度集中在几种固定的"腔调"上:

  • 破折号引出补充、冒号引出清单
  • “首先/其次/最后”、三点式框架
  • “最被低估的是……”"本质上就是……"这类金句
  • "那一刻我意识到……"的顿悟式故事弧
  • 习题参考答案的"本题考查……"标准答案腔
  • 精确到小数点的指标故事(AUC 从 0.78 涨到 0.84)

这些是显性特征,是模型写作留下的指纹,规则可以捕捉。剩下的隐性判断(模型对语义的整体感觉)规则抓不住,只能靠"报告回写校准"慢慢逼近。

想明白这件事,工具的形态就定了:一个离线网页,左边是文本编辑,右边是规则引擎标红,再把送检报告喂回来进化规则。

2. 技术选型:为什么是一个单文件 HTML

我没有上 React/Vue,也没有起后端。理由很朴素:

  1. 这是个人工具,一个文件拷到哪都能用,双击就开;
  2. 数据都在 localStorage,不涉及隐私敏感的书稿上传;
  3. 规则引擎本质是正则 + 统计,几百行 JS 就够;
  4. 唯一需要联网的场景(加载 pdf.js、tesseract.js)都可以做成懒加载。

最后产出一个 1500 行左右的 降AI自查工作台.html,包含六个模块:闯关引导、自查检测、规则库、历史与校准、驾驶舱、报告分析。下面按搭建顺序讲核心代码。

3. 第一步:规则引擎

3.1 规则的数据结构

每条规则就是一个对象:正则 + 严重度 + 命中示例 + 修改建议。规则库从实战里长出来,一开始只有 8 条,最后稳定在 23 条:

const RULES = [
  {id:'dash', name:'破折号过密', cat:'标点句式', sev:3,
   re:/——/g,
   desc:'连续用「——」引出补充说明',
   advice:'改逗号或句号;多数情况直接重写后半句'},
  {id:'enum', name:'枚举连接词', cat:'结构路标', sev:4,
   re:/(首先|其次|再次|最后[,。]|第[一二三四五]点|其一|其二)/g,
   desc:'首先/其次/最后、第一点/第二点',
   advice:'打散顺序感:用「第一个坑…另一个…」或干脆各自成段'},
  {id:'epiphany', name:'顿悟句式', cat:'AI句式', sev:5,
   re:/(让我[明白意识到记住]|那一刻我|第一次[深深]?[体会意识]到|从那以后[,,]?我|这才[明白意识到])/g,
   desc:'「那一刻我意识到…」「从那以后我…」',
   advice:'检测器对顿悟故事弧极敏感,删掉顿悟框架,平铺直叙即可'},
  {id:'answer', name:'标准答案腔', cat:'内容模板', sev:5,
   re:/(第[一二三四五六七八九十0-9]+题|本题[考查考察]|答案[是:解析]|解题思路|易错[点题])/g,
   desc:'「本题考查…」「答案解析…」',
   advice:'改「写在最后/个人手记」,内容像跟朋友复盘'},
  // ……共 23 条,另含 3 条统计特征规则(见 3.4)
];

sev(severity)取 1~5,是这条规则的"毒性"。顿悟句式和标准答案腔给到了 5,因为实测它们出现在 0.99 高 AI 片段里的频率最高。

3.2 段落切分与标题识别

检测是段落级的,朱雀报告也是按片段给的值,所以段落是整个系统的基本单位:

function splitParas(text){
  return text.replace(/\r/g,'').split(/\n\s*\n/)
    .map(p=>p.replace(/\n/g,'').trim())
    .filter(p=>p.length>0);
}
function isHeading(p){
  return p.length<=30 && !/[。!?;]/.test(p) && /^[\d\.\s]*[^\d]/.test(p);
}

按空行分段,段内换行拍平;标题(短、无标点、常见编号开头)单独打标,不计入正文统计。

3.3 段落打分

每个段落跑一遍全部规则,命中就累计扣分,最后给出红/黄/绿三档:

function runCheck(){
  const text = document.getElementById('input').value.trim();
  const paras = splitParas(text);

  const results = paras.map((p, idx)=>{
    const hits = [];
    RULES.forEach(r=>{
      if(rulesOff.has(r.id)) return;        // 用户手动关闭的规则跳过
      const m = p.match(r.re);
      if(m && m.length){
        hits.push({rule:r, words:[...new Set(m)], cnt:m.length});
      }
    });
    let score = 0;
    hits.forEach(h=>{ score += getSev(h.rule) * 3 * Math.min(h.cnt, 3) });
    score = Math.min(100, score);
    const level = score>=45?'high':(score>=20?'mid':'low');
    return {idx:idx+1, text:p, chars:p.length, hits, score, level};
  });
}

两个细节:

  • Math.min(h.cnt, 3):同一条规则在一小段里命中 10 次和 3 次,危险程度差不多,做封顶防止一段把分数打满;
  • getSev(r) 不是直接读 r.sev,而是先查 ruleAdj(报告对比后人工调整过的严重度)。这为后面"规则进化"留了口子。

3.4 统计特征:三条不用正则的规则

光有正则不够,有三条特征是全文统计出来的,也是踩过坑才加上的:

// 单段超600字 → 极易整段被判 AIGC(v13 实测教训)
if(!isHeading(p) && p.length > 600) hits.push({rule:rToolong});

// 碎句段占比 > 45% → 拆碎句救不了AI率(v14→v15 教训)
if(shortRatio > 0.45 && p.length < 40) hits.push({rule:rTooshort});

// 段落长度变异系数 < 0.45 → 节奏过于均匀,机器感
const lens = bodyParas.map(p=>p.length);
const mean = lens.reduce((a,b)=>a+b,0)/lens.length;
const sd = Math.sqrt(lens.reduce((a,b)=>a+(b-mean)*(b-mean),0)/lens.length);
const cv = mean? sd/mean : 0;   // 变异系数

第三条是真正"反直觉"的经验:第 14 版我信了"拆短句降AI",把所有长段拆碎,结果人工特征率从 67% 跌到 35%。段落长短本身不是关键,节奏均匀才是问题。 长短交替、允许单句成段和 400 字长段共存,反而更安全。

3.5 总分公式

段落分聚合为全文风险分,我用的加权不是拍脑袋,是拿多版历史数据调出来的:

const highN = results.filter(r=>r.level==='high'&&!r.isHead).length;
const midN  = results.filter(r=>r.level==='mid' &&!r.isHead).length;
const bodyN = results.filter(r=>!r.isHead).length || 1;
const avg   = results.reduce((a,r)=>a+r.score,0)/results.length;
let total = Math.round(Math.min(100,
  avg*0.6 + (highN/bodyN)*40 + (midN/bodyN)*18));
const lv = total>=40?'high':(total>=20?'mid':'low');

平均分占 60%,红段占比权重最高。经验目标线:风险分 < 20 且红段清零,才值得消耗一次朱雀额度。

4. 第二步:版本存档与历史曲线

改稿是多次迭代的,必须能回答"这版比上版好在哪"。每次 runCheck 自动存档:

function paraHash(t){
  let h=0; for(let i=0;i<t.length;i++){ h=(h*31+t.charCodeAt(i))|0 }
  return 'p'+Math.abs(h);   // 段落指纹:报告回写的锚点
}
function saveVersion(text, total, lv, paraN, highN, chars){
  const changed = !versions.length
    || versions[versions.length-1].text !== text;
  if(!changed) return;                    // 没改就不存
  versions.push({text, total, lv, paraN, highN, chars, histT:Date.now()});
  if(versions.length>30) versions = versions.slice(-30);  // 滚动30版
  save('zhq_versions', versions);
}

paraHash 是整个闭环的关键设计:段落内容算一个 31 进制的滚动 hash,朱雀报告里"该段人工特征 46%"这个分数就挂在 hash 上。下次同一文本再自查,段落卡片上直接显示"朱雀人工 46%",等于官方给这段盖了章。

历史页画双线趋势:本地风险分 vs 朱雀人工分。攒够几个点,你就有了自己的换算表——“我本地 12 分,朱雀大概人工 90%+”。

5. 第三步:闯关引导,防止瞎花钱

工具很快有了新问题:它只告诉我"哪段红",不告诉我"现在该干什么"。于是加了一个漏斗式的闯关引导,把检测策略固化成 7 关:

const STAGES = [
  {id:0, title:'问清出版社要求',  tag:'免费·最重要'},
  {id:1, title:'本地高频自查',    tag:'免费·不限次'},   // 本工具,省朱雀额度
  {id:2, title:'免费平台交叉检测', tag:'免费·每天约12次'}, // PaperPass/PaperYY/朱雀
  {id:3, title:'驾驶舱手动改稿',  tag:'免费·人工精修'},
  {id:4, title:'付费校准',       tag:'约20~60元'},      // 万方/维普,<10%
  {id:5, title:'出版社同款预检',  tag:'约40~80元'},      // 知网/大雅,<10%
  {id:6, title:'交稿终检',       tag:'出版社执行'},
];

顺序的内核是省钱:本地不限次随便测 → 免费平台交叉(每天约 12 次额度)→ 付费校准只在 2~3 轮大改后做一次 → 出版社同款系统交稿前压轴。第一关是发一句话模板给编辑问清检测系统和红线——不同平台算法差异能到 20%+,问不清等于白改。

进度存 localStorage,回来接着闯。

6. 第四步:驾驶舱——从"标红"到"教我改"

规则引擎告诉你哪里有问题,但怎么改还得靠人。驾驶舱模块补上这一环:

  1. 范文收藏:检测出 AI 率 < 15% 的文章一键收藏进范文库,做风格画像(段长分布、第一人称频次、句长节奏),跟当前稿对比找差距;
  2. 逐段修改意见:每个红段拼装出具体的改法(规则 advice + 范文风格差距 + 朱雀段落分);
  3. 一键生成新文章:27 条确定性替换规则跑一遍——套话直接删、精确年份模糊化(“2019 年春天"→"前几年”)、顿悟句式降调、破折号改逗号、超长段拆分。

必须说明边界:自动替换只能消显性特征。 生成的新文章一定要人工通读润色,否则就是把一种 AI 腔换成另一种(v15 教训:自动改出来的"故事腔"被检测器识别得比原来还快)。

7. 第五步:报告分析——让规则库自己进化

这是整个工作台里我最得意的部分。前面所有规则都是我肉眼总结的,而报告分析模块做的是用真实送检数据验证和进化规则库,流程是:上传检测报告 → 解析片段和数值 → 对齐到自查版本 → 统计每条规则的判别力 → 调整权重、挖掘新规则。

7.1 解析报告文本

朱雀报告导出后是纯文本(或 PDF),格式大致是"片段N + 文本 + AIGC 0.xxxx"。解析核心两个函数:

function fragScoreOf(block){
  // 优先 AIGC 值(0.9982 → 人工特征 0.2%)
  const aigcM = block.match(/0\.\d{3,4}/);
  if(aigcM) return +((1 - parseFloat(aigcM[0]))*100).toFixed(1);
  // 其次「人工特征 46.94%」
  const pm = block.match(/(人工特征|人工痕迹|人工率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
  if(pm) return +pm[2];
  // 最后「AI特征 10.78%」→ 取补
  const am = block.match(/(AI特征|疑似AI|AIGC率?|AI率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
  if(am) return +(100 - +am[2]).toFixed(1);
  return null;
}
function cleanFragText(s){
  // 洗掉「片段1」「AIGC值 0.9982」「8.66%」这类报告自身的标记
  return s.replace(/片段\s*\d+/g,'')
    .replace(/(AIGC|AI特征|人工特征)[^\n\d]{0,6}\d+(\.\d+)?%?/g,'')
    .replace(/0\.\d{3,4}/g,'')
    .replace(/\d+(\.\d+)?\s*%/g,'')
    .replace(/\s*\n\s*/g,' ').trim();
}

7.2 片段对齐:6-gram 滑窗重叠

报告里的片段文本和我自查版本的段落不是逐字相同的(报告可能有截断、OCR 有错字)。对齐算法用归一化后的 6 字滑窗算重叠率:

function overlapRatio(fn, pn){
  // fn: 报告片段归一化文本, pn: 版本段落归一化文本
  if(fn.length<6) return 0;
  let hits=0, tot=0;
  for(let i=0;i+6<=fn.length;i+=3){      // 步长3,容忍部分错字
    tot++;
    if(pn.includes(fn.substr(i,6))) hits++;
  }
  return tot? hits/tot : 0;              // 0~1,>=0.15 视为对齐成功
}

步长取 3、窗口取 6 是平衡出来的:窗口太短会大量误对齐,太长则 OCR 错一个字就断。对齐成功后,片段的人工特征分通过 paraHash 回写到段落——从此规则库里每条规则都有"朱雀均分"列:命中该规则的段落,真实送检的平均人工特征率是多少。

7.3 规则判别力:lift 统计

有了对齐 + 分数,就能回答那个核心问题:“这条规则到底准不准?”

const ruleStats = RULES.map(r=>{
  // 命中该规则且对齐成功的片段
  const hitList = withScore.filter(x=>x.res.hits.some(h=>h.rule.id===r.id));
  if(!hitList.length) return {rule:r, nHit:0, lift:null};
  // 命中段的平均AI值 - 全体对齐段的平均AI值 = 提升度
  const avgHit = hitList.reduce((s,x)=>s+(100-x.frag.human),0)/hitList.length;
  return {rule:r, nHit:hitList.length, avgHit, lift: avgHit - baseAvg};
});

lift > 0 说明这条规则命中的段确实更 AI,一键盘升权;lift 长期为负就是误报,一键盘降权或直接关闭。严重度被写成 ruleAdj,前面 getSev 优先读它——规则库的权重从此由真实数据说话。

7.4 挖掘新规则

最后一环:官方报告里被标红、但现有 23 条规则没有覆盖的片段,说明存在我没总结到的模式。挖掘算法在高 AI 片段里找"反复出现的 4~8 字 n-gram",同时要求它不出现在低 AI 片段里:

function mineCandidates(matched){
  const highN = /* 高AI片段(人工<50%) 归一化文本数组 */;
  const lowN  = /* 低AI片段(人工>=70%) 归一化拼接 */  ;
  const kept = [];
  for(let L=8; L>=4 && kept.length<40; L--){        // 从长到短滑窗
    for(const t of highN){
      for(let i=0; i+L<=t.length && kept.length<40; i+=2){
        const s = t.substr(i,L);
        if(lowN.includes(s)) continue;              // 低AI段也有→不是区分性特征
        if(kept.some(k=>k.text.includes(s))) continue;
        let c=0; highN.forEach(x=>{ if(x.includes(s)) c++ });
        if(c<2) continue;                           // 至少2个高AI片段共享
        if(RULES.some(r=>r.re && new RegExp(r.re.source).test(s))) continue;
        kept.push({text:s, cnt:c});                 // 现有规则已覆盖的不要
      }
    }
  }
  return kept.sort((a,b)=>b.cnt-a.cnt).slice(0,6);  // Top6 候选
}

我真实跑过一次:从一批漏检红段里挖出了"整体迁移演练"这个模式——某类项目汇报的固定话术,23 条规则都没覆盖,挖掘器把它找出来存成了自定义规则。从那以后这个模式不再漏检。

8. 第六步:PDF 报告直接上传

朱雀导出的报告是 PDF,而且经常是扫描图版(无文字层)。上传逻辑分两条路:

function ensurePdfJs(cb){
  if(window.pdfjsLib){ cb(); return }
  const CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/';
  const s = document.createElement('script');
  s.src = CDN + 'pdf.min.js';
  s.onload = ()=>{
    window.pdfjsLib.GlobalWorkerOptions.workerSrc = CDN + 'pdf.worker.min.js';
    cb();
  };
  document.head.appendChild(s);   // 懒加载:只在用到时联网一次
}

文字版走 page.getTextContent() 直接取文本;取出来不足 30 字符就判定是扫描版,弹窗征求同意后走 OCR 分支:

async function ocrPdfPages(pdf){
  const worker = await Tesseract.createWorker('chi_sim', 1, { /* logger 显示进度 */ });
  const parts = [];
  for(let i=1; i<=pdf.numPages; i++){
    const page = await pdf.getPage(i);
    const viewport = page.getViewport({scale:2});   // 2倍渲染保证识别率
    const canvas = document.createElement('canvas');
    await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
    const { data: { text } } = await worker.recognize(canvas);
    if(text && text.trim()) parts.push(text.trim());
  }
  await worker.terminate();
  return parts.join('\n\n');
}

tesseract.js 的中文模型约 15MB,同样懒加载。OCR 完的文本自动进解析管线——反正后面有 6-gram 容错对齐,识别错几个字不影响。

9. 踩坑总结

这套工具跑了几个月后,几条血泪经验:

1. 规则引擎是启发式,不是检测器。 它的价值是"把每天 5 次的官方额度省到刀刃上",终检永远以官方系统为准。本地 0 分不保证过朱雀,但本地满屏红就别浪费送检次数了。

2. 警惕对单一策略过拟合。 v14 拆短句 → 分数腰斩;v15 故事化 → 故事模板被识别得比原来还快。任何"一招鲜"都会被检测器学进模型。所以规则库里同时有"段落过长"和"碎句过密"两条互相制衡的规则,写作侧的正确姿势是自然长短交替。

3. 换算表比单点分数重要。 本地风险分和真实 AI 率之间不是线性关系,但"本地 15 分以下 → 朱雀人工 90%+"这种区间规律,攒够 5~6 个点就稳定了。这就是历史双线 + 校准点设计的目的。

4. 数据闭环 > 规则数量。 23 条规则里最有价值的不是最初 8 条手写的,而是 lift 统计校正过权重的、和从漏检红段里挖出来的那几条。让送检报告成为规则库的训练数据,工具才越用越准。

10. 复用与延伸

后来我把规则引擎从 HTML 里抽出来,做成了独立的 CLI 脚本,接进了"书籍自动成章"的技能流水线——每一节生成后自动跑校验,红段未清零就只重写红段,循环最多 5 轮:

node ai_check.js chapter.txt
# 退出码 0 = 风险分<20 且红段清零;1 = 未达标
# 支持 --json(机器可读)、--threshold 15、--extra rules.json(自定义规则)

同一套思路可以平移到任何"输出要过某种自动审核"的场景:论文降重、文案合规、敏感表述扫描——本质都是显性特征规则化 + 官方反馈数据回写这个闭环。

完整源码是一个 1500 行的单文件 HTML,不依赖任何构建工具,保存下来双击就能跑。如果这篇文章帮你省下几次送检额度,就值了。

完整代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>降AI率自查工作台 · 出版过审引导</title>
<style>
  :root{
    --bg:#f5f7fa; --card:#ffffff; --line:#e4e8ef; --txt:#24292f; --sub:#57606a;
    --blue:#2f6fed; --blue-bg:#eaf1fe; --red:#e5484d; --red-bg:#fdebec;
    --yellow:#d48806; --yellow-bg:#fff7e6; --green:#2da44e; --green-bg:#eaf6ee;
    --radius:12px; --shadow:0 1px 3px rgba(16,24,40,.08),0 1px 2px rgba(16,24,40,.04);
  }
  *{margin:0;padding:0;box-sizing:border-box}
  body{background:var(--bg);color:var(--txt);font-family:-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;font-size:14px;line-height:1.7}
  .wrap{max-width:1080px;margin:0 auto;padding:20px 16px 60px}
  header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px}
  header h1{font-size:20px;font-weight:700}
  .badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;background:var(--blue-bg);color:var(--blue);font-weight:600}
  .badge.gray{background:#eef1f5;color:var(--sub)}
  nav{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
  nav button{padding:8px 18px;border:1px solid var(--line);background:var(--card);border-radius:8px;cursor:pointer;font-size:14px;color:var(--sub);font-weight:600}
  nav button.active{background:var(--blue);border-color:var(--blue);color:#fff}
  section{display:none}
  section.show{display:block}
  .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:18px;margin-bottom:14px}
  .card h2{font-size:16px;margin-bottom:10px}
  .muted{color:var(--sub);font-size:13px}
  .btn{display:inline-block;padding:8px 16px;border-radius:8px;border:1px solid var(--blue);background:var(--blue);color:#fff;cursor:pointer;font-size:14px;font-weight:600;text-decoration:none}
  .btn.ghost{background:#fff;color:var(--blue)}
  .btn.small{padding:4px 12px;font-size:13px}
  .btn.warn{border-color:var(--red);background:var(--red)}
  .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}

  /* ---------- 阶段引导 ---------- */
  .stage{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:16px 18px;margin-bottom:12px;position:relative}
  .stage.done{opacity:.65}
  .stage.current{border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-bg)}
  .stage-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  .stage-num{width:28px;height:28px;border-radius:50%;background:#eef1f5;color:var(--sub);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex-shrink:0}
  .stage.current .stage-num{background:var(--blue);color:#fff}
  .stage.done .stage-num{background:var(--green);color:#fff}
  .stage-title{font-weight:700;font-size:15px}
  .stage-tag{font-size:12px;padding:1px 8px;border-radius:999px;background:var(--green-bg);color:var(--green);font-weight:600}
  .stage-tag.free{background:var(--blue-bg);color:var(--blue)}
  .stage-tag.pay{background:var(--yellow-bg);color:var(--yellow)}
  .stage-body{margin-top:10px;padding-left:38px}
  .goal{padding:6px 12px;background:var(--green-bg);border-radius:8px;font-size:13px;color:#116327;margin-bottom:10px}
  .platforms{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
  .platforms a{padding:6px 14px;border:1px solid var(--line);border-radius:8px;text-decoration:none;color:var(--txt);font-size:13px;font-weight:600;background:#fff}
  .platforms a:hover{border-color:var(--blue);color:var(--blue)}
  .platforms a b{color:var(--blue)}
  .stage-body ul{margin:6px 0 6px 18px;color:var(--sub)}
  .stage-actions{margin-top:10px}
  .quote{background:#f6f8fa;border-left:3px solid var(--blue);padding:10px 12px;border-radius:0 8px 8px 0;font-size:13px;color:var(--sub);margin:8px 0}

  /* ---------- 自查 ---------- */
  .checker-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
  @media(max-width:900px){.checker-grid{grid-template-columns:1fr}}
  textarea{width:100%;height:320px;padding:12px;border:1px solid var(--line);border-radius:8px;font-family:inherit;font-size:14px;resize:vertical;line-height:1.8}
  textarea:focus{outline:2px solid var(--blue-bg);border-color:var(--blue)}
  .drop-hint{font-size:12px;color:var(--sub);margin-top:6px}
  .score-wrap{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
  .score-num{font-size:40px;font-weight:800;line-height:1}
  .lv-low{color:var(--green)} .lv-mid{color:var(--yellow)} .lv-high{color:var(--red)}
  .stats{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;color:var(--sub)}
  .stats b{color:var(--txt)}
  .top-rules{margin-top:12px}
  .top-rules .tr{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:13px}
  .tr-name{width:130px;flex-shrink:0;font-weight:600}
  .tr-bar{height:10px;border-radius:5px;background:var(--red);opacity:.85;min-width:4px}
  .tr-cnt{color:var(--sub)}
  .para-card{border:1px solid var(--line);border-left:4px solid var(--green);border-radius:8px;padding:10px 14px;margin-bottom:10px;background:#fff}
  .para-card.mid{border-left-color:var(--yellow);background:#fffdf7}
  .para-card.high{border-left-color:var(--red);background:#fff8f8}
  .para-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;cursor:pointer}
  .para-idx{font-weight:700;color:var(--sub);font-size:12px}
  .para-chars{font-size:12px;color:var(--sub)}
  .para-badge{font-size:11px;padding:1px 8px;border-radius:999px;font-weight:700}
  .para-badge.high{background:var(--red-bg);color:var(--red)}
  .para-badge.mid{background:var(--yellow-bg);color:var(--yellow)}
  .para-badge.low{background:var(--green-bg);color:var(--green)}
  .hit-tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
  .hit-tag{font-size:11px;padding:1px 8px;background:#f0f2f5;border-radius:4px;color:var(--sub)}
  .hit-tag.s5{background:var(--red-bg);color:var(--red)} .hit-tag.s4{background:#fdeee0;color:#c2570a} .hit-tag.s3{background:var(--yellow-bg);color:var(--yellow)}
  .para-text{font-size:13px;color:#444;margin-top:8px;white-space:pre-wrap;display:none}
  .para-card.open .para-text{display:block}
  .hit-detail{display:none;margin-top:8px;font-size:12.5px;color:var(--sub);background:#f6f8fa;border-radius:8px;padding:10px}
  .para-card.open .hit-detail{display:block}
  .hit-line{margin-bottom:6px}
  .hit-word{background:#fff;border:1px solid var(--line);border-radius:4px;padding:0 6px;margin-right:6px;font-weight:600;color:var(--txt)}
  .advice{color:#8a6d1a}
  .empty{padding:60px 0;text-align:center;color:var(--sub)}

  /* ---------- 规则库 ---------- */
  table{width:100%;border-collapse:collapse;font-size:13px}
  th{background:#f6f8fa;text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-size:12px;color:var(--sub)}
  td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
  .sev{font-weight:700}
  .sev5{color:var(--red)} .sev4{color:#c2570a} .sev3{color:var(--yellow)} .sev2{color:var(--sub)}
  .switch{cursor:pointer;user-select:none}
  .zq-avg{font-size:12px;color:var(--blue);font-weight:700}

  /* ---------- 历史 ---------- */
  .history-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);flex-wrap:wrap;font-size:13px}
  .trend{width:100%;height:160px}
  .input-line{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
  .input-line input{padding:5px 10px;border:1px solid var(--line);border-radius:6px;width:110px;font-size:13px}

  /* ---------- 驾驶舱 ---------- */
  .dash-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}
  @media(max-width:900px){.dash-grid{grid-template-columns:repeat(2,1fr)}}
  .kpi{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:12px 14px}
  .kpi .k{font-size:12px;color:var(--sub)}
  .kpi .v{font-size:22px;font-weight:800;margin-top:2px}
  .kpi .v.blue{color:var(--blue)} .kpi .v.green{color:var(--green)} .kpi .v.red{color:var(--red)} .kpi .v.yellow{color:var(--yellow)}
  .kpi .s{font-size:11px;color:var(--sub);margin-top:2px}
  .fav-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--line);flex-wrap:wrap;font-size:13px}
  .fav-rate{font-weight:800;color:var(--green)}
  .rate-input{padding:6px 10px;border:1px solid var(--line);border-radius:6px;width:90px;font-size:13px}
  .title-input{padding:6px 10px;border:1px solid var(--line);border-radius:6px;width:220px;font-size:13px}
  .adv-card{border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:8px;font-size:13px;background:#fff}
  .adv-head{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-weight:600}
  .adv-body{margin-top:6px;color:var(--sub);font-size:12.5px}
  .adv-body b{color:var(--txt)}
  .para-ref{background:var(--green-bg);border-left:3px solid var(--green);padding:8px 12px;border-radius:0 8px 8px 0;font-size:12.5px;color:#116327;margin:6px 0}
  .gen-area{width:100%;height:300px;padding:12px;border:1px solid var(--line);border-radius:8px;font-family:inherit;font-size:14px;resize:vertical;line-height:1.8;background:#f6fff8}
  .gen-stat{font-size:13px;color:var(--sub)}
  .gen-stat b{color:var(--green)}

  /* ---------- 报告分析 ---------- */
  .rep-frag{border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:8px;background:#fff}
  .rep-frag textarea{height:64px;margin-top:6px;font-size:13px}
  .version-row{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;cursor:pointer;font-size:13px;flex-wrap:wrap;background:#fff}
  .version-row:hover{border-color:var(--blue)}
  .version-row.sel{border-color:var(--blue);background:var(--blue-bg)}
  .version-row .sw{font-size:16px;color:var(--blue)}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <h1>降AI率自查工作台</h1>
    <span class="badge">规则启发式 · 离线运行</span>
    <span class="badge gray">免费不限次</span>
    <span class="badge gray">内置18轮实战规则库</span>
  </header>

  <nav>
    <button id="nav-guide" class="active" onclick="switchTab('guide')">闯关引导</button>
    <button id="nav-dash" onclick="switchTab('dash')">驾驶舱</button>
    <button id="nav-check" onclick="switchTab('check')">自查检测</button>
    <button id="nav-report" onclick="switchTab('report')">报告分析</button>
    <button id="nav-rules" onclick="switchTab('rules')">规则库</button>
    <button id="nav-history" onclick="switchTab('history')">历史与校准</button>
  </nav>

  <!-- ============ 闯关引导 ============ -->
  <section id="sec-guide" class="show">
    <div class="card">
      <h2>出版过审路线图</h2>
      <div class="muted">按顺序闯关。每一关达标后再进入下一关,避免把宝贵的付费检测次数浪费在未打磨的稿子上。红线值请以出版社答复为准填入第0关。</div>
    </div>
    <div id="stage-list"></div>
  </section>

  <!-- ============ 自查检测 ============ -->
  <section id="sec-check">
    <div class="checker-grid">
      <div>
        <div class="card">
          <h2>待检文本</h2>
          <textarea id="input" placeholder="把章节内容粘贴到这里(Word里复制即可),或直接拖入 .txt 文件&#10;&#10;docx 文件请先用配套脚本「docx转txt.py」转换后再拖入"></textarea>
          <div class="drop-hint" id="drop-hint">支持拖拽 .txt 文件 · 内容只在本机处理,不上传</div>
          <div class="row" style="margin-top:10px">
            <button class="btn" onclick="runCheck()">开始自查</button>
            <button class="btn ghost" onclick="loadSample()">载入示例</button>
            <button class="btn ghost" onclick="clearInput()">清空</button>
            <button class="btn ghost" onclick="exportReport()">导出报告</button>
          </div>
        </div>
        <div class="card">
          <h2>免费平台交叉检测</h2>
          <div class="muted">本地自查达标(红段清零、风险分&lt;20)后,再去这些平台做交叉验证。朱雀每天只有5次,省着用。</div>
          <div class="platforms" style="margin-top:8px">
            <a href="https://matrix.tencent.com/zhuge/" target="_blank">朱雀AI检测<b>(5次/天)</b></a>
            <a href="https://www.paperpass.com/" target="_blank">PaperPass<b>(5次/天)</b></a>
            <a href="https://www.paperyy.com/" target="_blank">PaperYY<b>(2篇/天)</b></a>
          </div>
        </div>
      </div>
      <div>
        <div class="card" id="result-card" style="display:none">
          <h2>检测结果</h2>
          <div class="score-wrap">
            <div class="score-num" id="score-num">-</div>
            <div>
              <div style="font-weight:700" id="score-lv">未检测</div>
              <div class="muted" id="score-note">启发式评分,用于定位问题段落,不等同于朱雀/知网分数</div>
            </div>
          </div>
          <div class="stats" style="margin-top:12px" id="score-stats"></div>
          <div class="top-rules" id="top-rules"></div>
        </div>
        <div class="card" id="para-card-wrap" style="display:none">
          <h2>段落明细 <span class="muted" style="font-size:12px;font-weight:400">(点击段落展开原文与命中详情)</span></h2>
          <div id="para-list"></div>
        </div>
        <div class="card" id="empty-state">
          <div class="empty">粘贴或拖入文本后,点击「开始自查」<br><span class="muted">先在本地反复打磨,再去消耗朱雀的每日额度</span></div>
        </div>
      </div>
    </div>
  </section>

  <!-- ============ 报告分析 ============ -->
  <section id="sec-report">
    <div class="card">
      <h2>① 导入检测报告</h2>
      <div class="muted">把朱雀 / PaperPass / 万方等平台的检测报告文本粘贴到下面,或直接上传 PDF。文字版 PDF 自动提取全文并解析;扫描/图片版 PDF 会自动调用浏览器 OCR(tesseract.js + 中文模型,约 15MB,首次需联网下载),逐页识别后解析。网页报告直接全选复制。可识别:总体「人工特征率 / AI特征率」,以及各片段文本 + AIGC值(如 0.9941)。解析后每个片段的数值和文本都可以手动修正。</div>
      <textarea id="rep-input" style="height:180px;margin-top:8px" placeholder="粘贴检测报告文本…&#10;&#10;示例格式:&#10;人工特征率 46.94%&#10;片段1&#10;说实话,AI项目里最被低估的是沟通成本……&#10;AIGC 0.9982&#10;&#10;(无数值的片段解析后在下方填人工特征%)"></textarea>
      <div class="row" style="margin-top:8px">
        <button class="btn" onclick="parseReport()">解析报告</button>
        <label class="btn ghost" style="cursor:pointer">上传 .pdf<input type="file" id="rep-pdf" accept="application/pdf,.pdf" style="display:none" onchange="loadRepPdf(this)"></label>
        <label class="btn ghost" style="cursor:pointer">上传 .txt/.csv/.json<input type="file" id="rep-file" accept=".txt,.csv,.json,.md" style="display:none" onchange="loadRepFile(this)"></label>
        <button class="btn ghost small" onclick="addFragRow()">+手动补片段</button>
        <button class="btn ghost small" onclick="document.getElementById('rep-input').value='';document.getElementById('rep-parse').innerHTML='';repParsed=null">清空</button>
      </div>
      <div id="rep-progress" style="margin-top:8px;font-size:13px;color:var(--sub);min-height:22px"></div>
      <div id="rep-parse" style="margin-top:10px"></div>
    </div>
    <div class="card">
      <h2>② 勾选对应的自查版本</h2>
      <div class="muted">每次在「自查检测」跑检测都会自动存一条版本记录(同稿去重)。勾选这份报告对应的是哪一版,再点「开始对比分析」。解析报告后会按文本重叠度自动预选最像的版本。</div>
      <div id="rep-versions" style="margin-top:10px"></div>
      <div class="row" style="margin-top:10px">
        <button class="btn" onclick="runRepCompare()">开始对比分析</button>
      </div>
    </div>
    <div class="card" id="rep-analysis-card" style="display:none">
      <h2>③ 对比分析 → 更新规则库</h2>
      <div class="muted">对齐报告片段与版本段落:报告判红的段落会回写段落级朱雀分;统计每条规则在红段/绿段中的判别力,给出升权、降权、关闭建议;未被规则覆盖的红段列出原文优先改;红段高频出现的固定模式可一键加入规则库。</div>
      <div id="rep-analysis" style="margin-top:10px"></div>
    </div>
  </section>

  <!-- ============ 驾驶舱 ============ -->
  <section id="sec-dash">
    <div class="dash-grid" id="dash-kpis"></div>

    <div class="card">
      <h2>① 合格文章收藏(AI率 &lt; 15%)</h2>
      <div class="muted">在外部平台(PaperPass / PaperYY / 朱雀)检测出 AI率 &lt; 15% 的文章,一键收藏进范文库。前提:先把该文章正文粘贴到「自查检测」页的待检文本框,再回来填AI率点收藏。范文库用于风格对标和逐段改写参考。</div>
      <div class="row" style="margin-top:10px">
        <input id="fav-rate" class="rate-input" placeholder="AI率 %" inputmode="decimal">
        <input id="fav-title" class="title-input" placeholder="范文标题(可留空自动命名)">
        <button class="btn" onclick="collectFav()">一键收藏当前文本</button>
      </div>
      <div id="fav-list" style="margin-top:10px"></div>
    </div>

    <div class="card">
      <h2>② 合格范文分析</h2>
      <div class="muted">点范文列表里的「分析」,看 AI率&lt;15% 的文章长什么样:段落节奏、句式特征、命中了哪些规则、最像人写的段落原文,并与当前稿逐项对比,差距最大的项就是优先要补的。</div>
      <div id="fav-analysis" style="margin-top:10px"><div class="muted">从上面范文库选一篇进行分析</div></div>
    </div>

    <div class="card">
      <h2>③ 当前稿逐段修改意见 → 一键生成新文章</h2>
      <div class="muted">对「自查检测」页待检文本框里的当前稿逐段生成修改意见;然后一键自动改写(套话删除、顿悟句降调、年份模糊化、括号展开、长段拆分等确定性替换)。自动生成后请人工通读润色,再送回自查复检。</div>
      <div class="row" style="margin-top:10px">
        <button class="btn" onclick="genAdvice()">生成逐段修改意见</button>
        <button class="btn ghost" onclick="autoRewrite()">一键自动生成新文章</button>
      </div>
      <div id="advice-list" style="margin-top:10px"></div>
      <div id="gen-wrap" style="display:none;margin-top:12px">
        <textarea id="gen-output" class="gen-area" readonly></textarea>
        <div class="gen-stat" id="gen-stat" style="margin-top:6px"></div>
        <div class="row" style="margin-top:8px">
          <button class="btn small" onclick="copyGen()">复制全文</button>
          <button class="btn ghost small" onclick="downloadGen()">下载 .txt</button>
          <button class="btn ghost small" onclick="sendGenToCheck()">送回自查复检</button>
        </div>
      </div>
    </div>
  </section>

  <!-- ============ 规则库 ============ -->
  <section id="sec-rules">
    <div class="card">
      <h2>AI特征规则库(<span id="rule-count">-</span>条)</h2>
      <div class="muted">来自第15章18轮送检实测总结:v9→v18逐轮对照朱雀报告定位出的高AI特征模式。关闭的规则不再参与计分。「朱雀均分」为该规则命中段落的人工登记分数均值,登记越多越准。</div>
      <div style="overflow-x:auto;margin-top:10px">
        <table>
          <thead><tr><th style="width:60px">启用</th><th style="width:120px">规则</th><th style="width:90px">类别</th><th style="width:60px">严重度</th><th>命中示例</th><th>改写建议</th><th style="width:80px">朱雀均分</th></tr></thead>
          <tbody id="rule-table"></tbody>
        </table>
      </div>
    </div>
  </section>

  <!-- ============ 历史与校准 ============ -->
  <section id="sec-history">
    <div class="card">
      <h2>分数趋势</h2>
      <div class="muted">本地风险分(蓝线)越低越好;录入朱雀人工特征分(红线)越高越好。两线一起看,可以逐步摸清「本地多少分 ≈ 朱雀多少分」的换算关系,省检测次数。</div>
      <svg class="trend" id="trend-svg" viewBox="0 0 640 160" preserveAspectRatio="none"></svg>
    </div>
    <div class="card">
      <h2>检测记录</h2>
      <div id="history-list"></div>
      <div class="row" style="margin-top:12px">
        <button class="btn ghost small" onclick="clearHistory()">清空历史</button>
      </div>
    </div>
    <div class="card">
      <h2>付费/官方检测入口</h2>
      <div class="muted">免费平台稳定达标后,按闯关引导依次进入付费校准和出版社同款预检。</div>
      <div class="platforms" style="margin-top:8px">
        <a href="https://check.wanfangdata.com.cn/" target="_blank">万方AIGC检测<b>(约2元/千字)</b></a>
        <a href="https://vpcs.fanyu.com/" target="_blank">维普检测<b>(约20元/篇)</b></a>
        <a href="https://cx.cnki.net/" target="_blank">知网检测<b>(约2元/千字)</b></a>
        <a href="http://www.dayainfo.com/" target="_blank">大雅检测<b>(书稿查重)</b></a>
      </div>
    </div>
  </section>
</div>

<script>
'use strict';
/* ================= 存储 ================= */
function load(k, d){ try{ return JSON.parse(localStorage.getItem(k)) ?? d }catch(e){ return d } }
function save(k, v){ try{ localStorage.setItem(k, JSON.stringify(v)) }catch(e){} }
let rulesOff = new Set(load('zhq_rules_off', []));
let history = load('zhq_history', []);
let stageDone = load('zhq_stage_done', []);
/* v2迁移:新增第3关「驾驶舱手动改稿」,原3/4/5关顺延为4/5/6 */
if(!load('zhq_stage_v2', false)){
  stageDone = stageDone.map(x=> x>=3? x+1 : x).filter((v,i,a)=>a.indexOf(v)===i).sort((a,b)=>a-b);
  save('zhq_stage_done', stageDone);
  save('zhq_stage_v2', true);
}
let zhuquePara = load('zhq_zhuque_para', {}); // {hash:{score,ts}}
let redLine = load('zhq_redline', '');

/* ================= 规则库 ================= */
const RULES = [
  {id:'dash', name:'破折号过密', cat:'标点句式', sev:3,
   re:/——/g,
   desc:'连续用「——」引出补充说明',
   advice:'改逗号或句号;多数情况直接重写后半句'},
  {id:'colonlist', name:'冒号列举', cat:'标点句式', sev:3,
   re:/(如下|包括以下|具体包括|主要分为|分为以下|主要有以下)[::]/g,
   desc:'「如下:/包括:/分为:」引出清单',
   advice:'删掉冒号列举结构,把要点揉进自然叙述'},
  {id:'paren', name:'括号解释过密', cat:'标点句式', sev:2,
   re:/([^)]{2,25})/g,
   desc:'高频用括号补充解释',
   advice:'括号内容改写进正文,或删掉'},
  {id:'enum', name:'枚举连接词', cat:'结构路标', sev:4,
   re:/(首先|其次|再次|最后[,。]|第[一二三四五]点|第[一二三四五]、|其一|其二)/g,
   desc:'首先/其次/最后、第一点/第二点',
   advice:'打散顺序感:用「第一个坑…另一个…」或干脆各自成段'},
  {id:'roadmap', name:'路线图路标词', cat:'结构路标', sev:4,
   re:/(接下来我们|接下来会|下面[我们]?[依次]?[介绍|讲解|梳理|看一下]|依次[讲解|介绍]|逐一[介绍|分析|讲解]|按顺序)/g,
   desc:'「下面依次讲解」「逐一介绍」',
   advice:'删路标句,内容自己会带读者走'},
  {id:'summary', name:'总结套话', cat:'结构路标', sev:4,
   re:/(综上所述|总而言之|总的来说|一句话总结|总之[,。])/g,
   desc:'综上所述/总而言之/总之',
   advice:'删掉,或改成具体的个人感受'},
  {id:'numstruct', name:'数量结构腔', cat:'结构路标', sev:3,
   re:/(几个方面|以下几个方面|几个环节|几条线|几个坑|几大[类步]|三大|四大|[三四五六]个维度|[两三四五]条原则)/g,
   desc:'「几个方面」「三条原则」式框架感',
   advice:'逐项独立叙述,不预告数量'},
  {id:'aphorism', name:'金句模板', cat:'AI句式', sev:4,
   re:/(最被低估的|最容易被[忽视忽略]|真正[的关键的问题的]|本质上是|归根结底|说到底[,,]?就是|不难发现|由此可见|这一点至关重要|尤为[重要关键]|比这更[重要关键])/g,
   desc:'「最被低估的是…」「本质上是…」',
   advice:'抽象结论绑定具体数字/事件,或直接删'},
  {id:'epiphany', name:'顿悟句式', cat:'AI句式', sev:5,
   re:/(让我[明白意识到记住]|那一刻我|第一次[深深]?[体会意识]到|从那以后[,,]?我|这才[明白意识到]|恍然大悟|彻底改变|深深[地]?[印在|刻在])/g,
   desc:'「那一刻我意识到…」「从那以后我…」',
   advice:'检测器对顿悟故事弧极敏感,删掉顿悟框架,平铺直叙即可'},
  {id:'emphasis', name:'强调套话', cat:'AI句式', sev:3,
   re:/(值得[一提注意]的是|需要[特别]?强调的是|特别[需要注意]的是|不得不提|更重要[的]是|需要指出)/g,
   desc:'「值得注意的是…」',
   advice:'删掉前缀,直接说事'},
  {id:'advice', name:'建议收尾腔', cat:'AI句式', sev:3,
   re:/(建议你|不妨[试试采用]|务实的[做法策略]|一个[实俗]用的[做法技巧]|这里我建议|我的建议是)/g,
   desc:'段落结尾给建议的套路',
   advice:'删「建议」壳,用具体做法替代'},
  {id:'parallel', name:'对仗排比', cat:'AI句式', sev:2,
   re:/(不仅[^。,]{1,15}[,,]?而且|既[^。,]{1,10}又|一方面[^。]{1,20}另一方面|既能[^。]{1,10}又能)/g,
   desc:'不仅…而且…/既…又…',
   advice:'保留一半即可,或拆成两句'},
  {id:'talkreader', name:'对话腔设问', cat:'AI句式', sev:2,
   re:/(你可能会[问觉得]|你是否[曾]?|有没有想过|试想一下|想象一下|你也许会[问好奇]|你可能会遇到)/g,
   desc:'「你可能会问…」',
   advice:'少量可留,密集出现改成直接陈述'},
  {id:'storyanchor', name:'故事锚点模板', cat:'内容模板', sev:4,
   re:/(20\d{2}年[春夏秋冬]?|几年前[,,]?我|那[一二]年|我[曾经]?做过一个[项目系统模型]|记得有一次|当时我[们]?[负责天真]|曾经接手)/g,
   desc:'「2019年春天…」「我做过一个项目…」',
   advice:'时间模糊化(前几年/早些时候),或换抱怨式/记忆式开场'},
  {id:'textbook', name:'教科书骨架', cat:'内容模板', sev:3,
   re:/(所谓[的Xx]?|优缺点[分别是]?|适用[场景情况]为|核心思想是|基本流程[是如下]|定义如下|其[主要]?特点是)/g,
   desc:'定义-特点-优缺点-适用场景',
   advice:'改成「我选型时按什么顺序排除」的叙述'},
  {id:'answer', name:'标准答案腔', cat:'内容模板', sev:5,
   re:/(第[一二三四五六七八九十0-9]+题|本题[考查考察]|这道题|[参考]?答案[是:解析]|解题思路|易错[点题]|考点[是包括])/g,
   desc:'「本题考查…」「答案解析…」',
   advice:'改「写在最后/个人手记」,内容像跟朋友复盘'},
  {id:'academic', name:'学术套话', cat:'内容模板', sev:3,
   re:/(随着[^。]{2,12}的[不断发展发展|进步]|在当今[^。,]{0,8}时代|在[^。,]{2,10}的背景下|日益[重要突出]|备受[关注瞩目]|蓬勃[发展涌现]|日新月异)/g,
   desc:'「随着…的发展」',
   advice:'直接删背景句,进入具体场景'},
  {id:'numberstory', name:'数字故事腔', cat:'内容模板', sev:2,
   re:/(AUC|F1|准确率)[从]? ?0\.\d+|涨了? ?0\.\d+|提升到? ?0\.\d+|只涨了|从 ?0\.\d+ [提升][到?高][^。]{0,6}/g,
   desc:'精确小数指标的叙事(AUC 0.78→0.84)',
   advice:'数字故事已被识别,改为克制的定性比较'},
  {id:'endconcl', name:'段末收束句', cat:'统计特征', sev:2,
   re:/(所以[,。]|因此[,。]|这意味着|这说明[,。]?|换句话说[,,]?$|简单来说[,,])/g,
   desc:'多段以因果/总结收尾',
   advice:'让部分段落以细节或动作收尾,别段段总结'},
  {id:'stepwords', name:'教程步骤感', cat:'统计特征', sev:4,
   re:/(安装完成后|打开终端|执行以下命令|运行结果如下|接下来输入|等待安装完成|配置环境变量|pip install|conda create|npm install)/g,
   desc:'操作手册式步骤引导',
   advice:'命令嵌进踩坑叙述,不做独立步骤块'},
  {id:'toolong', name:'段落过长', cat:'统计特征', sev:2,
   re:null, kind:'toolong',
   desc:'单段正文超600字',
   advice:'按「一段一个意思」拆分'},
  {id:'tooshort', name:'碎句过密', cat:'统计特征', sev:3,
   re:null, kind:'tooshort',
   desc:'全文短碎句段占比过高',
   advice:'v14教训:纯拆短句不降AI,恢复自然长短交替'},
  {id:'uniform', name:'段落长度过齐', cat:'统计特征', sev:2,
   re:null, kind:'uniform',
   desc:'各段长度方差过小,节奏均匀',
   advice:'段落长短交替,允许单句成段与长段共存'}
];

/* ---- 报告分析相关存储 ---- */
let ruleAdj = load('zhq_rule_adj', {});      // {id:{sev,ts,note}} 报告对比后调整的严重度
let customRules = load('zhq_custom_rules', []); // 报告挖掘出的自定义规则
let versions = load('zhq_versions', []);     // 自查版本记录
let repParsed = null, repSelVersion = null, repMatched = null;

/* 自定义规则合并进规则库 */
customRules.forEach(r=>{ try{ RULES.push(Object.assign({}, r, {re:new RegExp(r.reSource, 'g')})) }catch(e){} });

/* 有效严重度:报告对比调整 > 规则原始值 */
function getSev(r){ const a = ruleAdj[r.id]; return (a && a.sev)? a.sev : (r.sev||3) }

/* ================= 阶段引导 ================= */
const STAGES = [
  {id:0, title:'问清出版社要求', tag:'免费·最重要', free:true,
   goal:'拿到三件事:①用哪套检测系统 ②AI率红线 ③是否需随稿附报告',
   body:'<div class="quote">「老师好,想跟您确认下书稿的AIGC检测要求:1. 咱们用的是哪套检测系统?2. 对AI生成内容占比的上限要求是多少?3. 交稿时需要我附检测报告吗?」</div><ul><li>不同平台算法差异可达20%+,问清能省掉大量白改</li><li>通行红线:AI率≤20%,专业类≤15%,严格社≤10%</li></ul><div class="input-line" style="margin-top:8px">红线(%):<input id="redline-input" placeholder="如 10" value="' + (redLine||'') + '"> <button class="btn small" onclick="saveRedline()">保存红线</button></div>'},
  {id:1, title:'本地高频自查(本工具)', tag:'免费·不限次', free:true,
   goal:'红段清零,综合风险分 < 20,命中最多的3条规则改到消失',
   body:'<ul><li>每次改稿后先过这里,把朱雀的5次/天额度省下来</ul></ul><div class="stage-actions"><a class="btn small" href="#" onclick="switchTab(\'check\');return false">去自查 →</a></div>'},
  {id:2, title:'免费平台交叉检测', tag:'免费·每天约12次', free:true,
   goal:'AI率 < 15%,朱雀人工特征 ≥ 90%',
   body:'<ul><li>顺序:本工具达标 → PaperPass/PaperYY(量大管饱)→ 朱雀(最后压轴,每天5次)</li><li>重点改多平台共同标红的段落</li><li>回来把朱雀人工分录入「历史与校准」,建立换算表</li></ul><div class="platforms"><a href="https://matrix.tencent.com/zhuge/" target="_blank">朱雀AI检测 <b>5次/天</b></a><a href="https://www.paperpass.com/" target="_blank">PaperPass <b>5次/天</b></a><a href="https://www.paperyy.com/" target="_blank">PaperYY <b>2篇/天</b></a></div><div class="stage-actions"><a class="btn small" href="#" onclick="switchTab(\'history\');return false">录入朱雀分 →</a></div>'},
  {id:3, title:'驾驶舱手动改稿', tag:'免费·人工精修', free:true,
   goal:'免费平台测完后先在这里改稿:逐段意见 → 逐段人工修改 → 通读润色 → 复检风险分 < 20',
   body:'<ul><li>免费平台测出AI率或标红段落后,先别急着花付费检测的钱</li><li>在驾驶舱「生成逐段修改意见」拿到每个红段的具体改法,优先处理多平台共同标红的段落</li><li>可先用「一键自动生成新文章」打底,但<b>必须人工通读润色</b>,自动替换只能消显性特征</li><li>收藏过合格范文的话,先做「范文分析」看风格差距(段长/第一人称频次/句长),照着改</li><li>改完把新稿贴到「自查检测」复检,风险分 &lt; 20、红段清零,再进入付费校准</li></ul><div class="stage-actions"><a class="btn small" href="#" onclick="switchTab(\'dash\');return false">去驾驶舱改稿 →</a><a class="btn ghost small" href="#" onclick="switchTab(\'check\');return false">改完去复检 →</a></div>'},
  {id:4, title:'付费校准', tag:'约20~60元', pay:true,
   goal:'万方或维普 AI率 < 10%',
   body:'<ul><li>每2~3轮大改后测一次,不用每轮都测</li><li>顺手记录:本地风险分X时,万方测出Y%,积累换算关系</li></ul><div class="platforms"><a href="https://check.wanfangdata.com.cn/" target="_blank">万方AIGC检测 <b>≈2元/千字</b></a><a href="https://vpcs.fanyu.com/" target="_blank">维普检测 <b>≈20元/篇</b></a></div>'},
  {id:5, title:'出版社同款预检', tag:'约40~80元', pay:true,
   goal:'知网(或大雅)AI率 < 10%,报告留存',
   body:'<ul><li>书稿大概率走知网AIGC检测(图书专著场景),部分社用大雅</li><li>交稿前1~2周做,报告可随稿附上</li></ul><div class="platforms"><a href="https://cx.cnki.net/" target="_blank">知网检测 <b>≈2元/千字</b></a><a href="http://www.dayainfo.com/" target="_blank">大雅检测 <b>≈2元/千字</b></a></div>'},
  {id:6, title:'交稿终检', tag:'出版社执行', free:true,
   goal:'编辑用自家系统出报告,前面到位即走流程',
   body:'<ul><li>留好各阶段检测报告编号,可核验、可自证</li></ul>'}
];

function saveRedline(){
  const v = document.getElementById('redline-input').value.trim();
  redLine = v; save('zhq_redline', v);
  renderStages(); alert('已保存红线:' + (v? v+'%' : '未填写'));
}
function renderStages(){
  const wrap = document.getElementById('stage-list');
  const cur = stageDone.length;
  let html = '';
  STAGES.forEach((s,i)=>{
    const done = stageDone.includes(s.id);
    const current = !done && i === cur;
    html += '<div class="stage'+(done?' done':'')+(current?' current':'')+'">'
      + '<div class="stage-head"><div class="stage-num">'+(done?'✓':s.id)+'</div>'
      + '<div class="stage-title">'+s.title+'</div>'
      + '<span class="stage-tag'+(s.pay?' pay':' free')+'">'+s.tag+'</span></div>'
      + '<div class="stage-body">'+s.body;
    if(i===0 && redLine) html += '<div class="goal" style="margin-top:8px">已设红线:AI率 ≤ '+redLine+'%(第2关起按此执行)</div>';
    html += '<div class="stage-actions" style="margin-top:10px">';
    if(!done) html += '<button class="btn small" onclick="finishStage('+s.id+')">已完成本关,进入下一关</button>';
    else html += '<button class="btn ghost small" onclick="unfinishStage('+s.id+')">撤销完成</button>';
    html += '</div></div></div>';
  });
  wrap.innerHTML = html;
}
function finishStage(id){
  if(!stageDone.includes(id)){
    stageDone.push(id);
    stageDone.sort((a,b)=>a-b);
    save('zhq_stage_done', stageDone);
    renderStages();
    const next = STAGES.find(s=>!stageDone.includes(s.id));
    if(next) alert('进入第'+next.id+'关:'+next.title);
    else alert('全部通关!可以交稿了。');
  }
}
function unfinishStage(id){
  stageDone = stageDone.filter(x=>x!==id);
  save('zhq_stage_done', stageDone);
  renderStages();
}

/* ================= 检测引擎 ================= */
function paraHash(t){
  let h=0; for(let i=0;i<t.length;i++){ h=(h*31+t.charCodeAt(i))|0 }
  return 'p'+Math.abs(h);
}
function splitParas(text){
  return text.replace(/\r/g,'').split(/\n\s*\n/)
    .map(p=>p.replace(/\n/g,'').trim())
    .filter(p=>p.length>0);
}
function isHeading(p){ return p.length<=30 && !/[。!?;]/.test(p) && /^[\d\.\s]*[^\d]/.test(p); }

function runCheck(){
  const text = document.getElementById('input').value.trim();
  if(!text){ alert('请先粘贴文本或载入示例'); return }
  const paras = splitParas(text);
  if(!paras.length){ alert('未识别到段落'); return }

  const totalChars = text.replace(/\s/g,'').length;
  const bodyParas = paras.filter(p=>!isHeading(p));
  const shortParas = bodyParas.filter(p=>p.length<40 && !/[。!?]/.test(p));
  const shortRatio = bodyParas.length? shortParas.length/bodyParas.length : 0;

  const results = paras.map((p, idx)=>{
    const hits = [];
    const ph = paraHash(p);
    RULES.forEach(r=>{
      if(rulesOff.has(r.id)) return;
      if(r.kind === 'toolong'){
        if(!isHeading(p) && p.length > 600) hits.push({rule:r, words:['该段'+p.length+'字']});
        return;
      }
      if(r.kind === 'tooshort'){
        if(shortRatio > 0.45 && p.length < 40 && !/[。!?]/.test(p) && bodyParas.length>5)
          hits.push({rule:r, words:['短碎句段']});
        return;
      }
      if(r.kind === 'uniform'){
        return; // 全文级,后面统一处理
      }
      if(r.re){
        const m = p.match(r.re);
        if(m && m.length){
          const words = [...new Set(m)];
          hits.push({rule:r, words, cnt:m.length});
        }
      }
    });
    let score = 0;
    hits.forEach(h=>{ score += getSev(h.rule) * 3 * Math.min(h.cnt||h.words.length, 3) });
    score = Math.min(100, score);
    const level = score>=45?'high':(score>=20?'mid':'low');
    return {idx:idx+1, text:p, chars:p.length, hits, score, level, hash:ph, isHead:isHeading(p)};
  });

  // 全文级:uniform
  if(bodyParas.length >= 5){
    const lens = bodyParas.map(p=>p.length);
    const mean = lens.reduce((a,b)=>a+b,0)/lens.length;
    const sd = Math.sqrt(lens.reduce((a,b)=>a+(b-mean)*(b-mean),0)/lens.length);
    const cv = mean? sd/mean : 0;
    const rU = RULES.find(r=>r.id==='uniform');
    if(cv < 0.45 && !rulesOff.has('uniform')){
      // 附到第一个正文段
      const firstBody = results.find(x=>!x.isHead);
      if(firstBody){ firstBody.hits.push({rule:rU, words:['段落长度CV='+cv.toFixed(2)]}); firstBody.score=Math.min(100,firstBody.score+6); firstBody.level=firstBody.score>=45?'high':(firstBody.score>=20?'mid':'low'); }
    }
  }

  // 总分
  const highN = results.filter(r=>r.level==='high'&&!r.isHead).length;
  const midN = results.filter(r=>r.level==='mid'&&!r.isHead).length;
  const bodyN = results.filter(r=>!r.isHead).length || 1;
  const avg = results.reduce((a,r)=>a+r.score,0)/results.length;
  let total = Math.round(Math.min(100, avg*0.6 + (highN/bodyN)*40 + (midN/bodyN)*18));
  const lv = total>=40?'high':(total>=20?'mid':'low');

  renderResult(results, total, lv, {totalChars, paraN:results.length, bodyN, highN, midN, shortRatio});
  saveHistory(total, lv, results.length, highN, totalChars);
  saveVersion(text, total, lv, results.length, highN, totalChars);
  renderHistory(); renderRuleTable();
}

function renderResult(results, total, lv, st){
  document.getElementById('empty-state').style.display='none';
  document.getElementById('result-card').style.display='block';
  document.getElementById('para-card-wrap').style.display='block';
  const num = document.getElementById('score-num');
  num.textContent = total;
  num.className = 'score-num lv-'+lv;
  const lvTxt = {high:'高风险:先改红段', mid:'中风险:黄段继续打磨', low:'低风险:可去免费平台交叉'}[lv];
  document.getElementById('score-lv').textContent = lvTxt + '(风险分越低越好)';
  document.getElementById('score-stats').innerHTML =
    '字数 <b>'+st.totalChars+'</b> | 段落 <b>'+st.paraN+'</b>(正文'+st.bodyN+')| 红段 <b style="color:var(--red)">'+st.highN+'</b> | 黄段 <b style="color:var(--yellow)">'+st.midN+'</b> | 碎句段占比 <b>'+(st.shortRatio*100).toFixed(0)+'%</b>';

  // TOP规则
  const cnt = {};
  results.forEach(r=>r.hits.forEach(h=>{ cnt[h.rule.name]=(cnt[h.rule.name]||0)+1 }));
  const top = Object.entries(cnt).sort((a,b)=>b[1]-a[1]).slice(0,6);
  const maxC = top.length? top[0][1] : 1;
  document.getElementById('top-rules').innerHTML = top.length?
    '<div class="muted" style="margin-bottom:6px">命中最多的规则(优先处理):</div>'+
    top.map(([n,c])=>'<div class="tr"><div class="tr-name">'+n+'</div><div class="tr-bar" style="width:'+(c/maxC*220)+'px"></div><div class="tr-cnt">×'+c+'段</div></div>').join('')
    : '<div class="muted">无命中规则</div>';

  // 段落列表(红→黄→绿排序,同色按原序)
  const order = {high:0, mid:1, low:2};
  const sorted = results.slice().sort((a,b)=>(order[a.level]-order[b.level])||(a.idx-b.idx));
  document.getElementById('para-list').innerHTML = sorted.map(r=>{
    const zq = zhuquePara[r.hash];
    const zqTxt = zq? ' <span class="para-badge low">朱雀人工 '+zq.score+'%</span>' : '';
    const tags = r.hits.map(h=>'<span class="hit-tag s'+h.rule.sev+'">'+h.rule.name+' ×'+(h.cnt||h.words.length)+'</span>').join('');
    const detail = r.hits.length? r.hits.map(h=>
      '<div class="hit-line"><b>'+h.rule.name+'</b>('+h.rule.cat+'·严重度'+h.rule.sev+'):'+
      h.words.map(w=>'<span class="hit-word">'+w+'</span>').join('')+
      '<div class="advice">→ '+h.rule.advice+'</div></div>').join('')
      : '<div class="hit-line">未命中规则</div>';
    const head = r.isHead? ' <span class="para-badge low">标题</span>' : '';
    return '<div class="para-card '+r.level+'" id="pc-'+r.idx+'">'
      + '<div class="para-head" onclick="togglePara('+r.idx+')">'
      + '<span class="para-idx">段'+r.idx+'</span><span class="para-chars">'+r.chars+'字</span>'
      + '<span class="para-badge '+r.level+'">'+({high:'高风险',mid:'疑似',low:'低风险'}[r.level])+' '+r.score+'分</span>'
      + head + zqTxt
      + '<span class="muted" style="margin-left:auto;font-size:11px">点击展开 ▾</span></div>'
      + '<div class="hit-tags">'+ (tags||'<span class="hit-tag">无命中</span>') +'</div>'
      + '<div class="para-text">'+r.text+'</div>'
      + '<div class="hit-detail">'+detail
      + '<div style="margin-top:6px"><button class="btn ghost small" onclick="event.stopPropagation();markZhuque('+r.idx+')">登记该段朱雀分</button></div></div>'
      + '</div>';
  }).join('');
  window.__results = results;
}
function togglePara(i){
  document.getElementById('pc-'+i).classList.toggle('open');
}
function markZhuque(i){
  const r = window.__results && window.__results.find(x=>x.idx===i);
  if(!r) return;
  const v = prompt('该段在朱雀报告中的「人工特征率」(0-100,只填数字):', zhuquePara[r.hash]? zhuquePara[r.hash].score : '');
  if(v===null) return;
  const n = parseFloat(v);
  if(isNaN(n)||n<0||n>100){ alert('请输入0-100的数字'); return }
  zhuquePara[r.hash] = {score:n, ts:Date.now()};
  save('zhq_zhuque_para', zhuquePara);
  runCheck();
}

/* ================= 历史 ================= */
function saveHistory(total, lv, paraN, highN, chars){
  history.push({t:Date.now(), total, lv, paraN, highN, chars, zq:null});
  if(history.length>60) history = history.slice(-60);
  save('zhq_history', history);
}
function renderHistory(){
  const list = document.getElementById('history-list');
  if(!history.length){ list.innerHTML = '<div class="muted">暂无记录,跑一次自查就会出现在这里</div>'; drawTrend(); return }
  list.innerHTML = history.slice().reverse().map((h,idx)=>{
    const i = history.length-1-idx;
    const d = new Date(h.t);
    const ts = d.toLocaleDateString('zh-CN')+' '+d.toTimeString().slice(0,5);
    const zq = h.zq!==null? '<b style="color:var(--blue)">朱雀人工 '+h.zq+'%</b>' : '<span class="muted">未录入朱雀分</span>';
    const cls = h.lv==='high'?'sev5':(h.lv==='mid'?'sev3':'');
    return '<div class="history-row"><span class="muted">'+ts+'</span>'
      + '<span>风险分 <b class="'+cls+'">'+h.total+'</b></span>'
      + '<span class="muted">'+h.paraN+'段 / 红段'+h.highN+' / '+h.chars+'字</span>'
      + zq
      + '<button class="btn ghost small" onclick="inputZhuque('+i+')">录入朱雀分</button>'
      + '<button class="btn ghost small" onclick="delHistory('+i+')">删除</button></div>';
  }).join('');
  drawTrend();
}
function inputZhuque(i){
  const h = history[i];
  const v = prompt('这次检测对应稿子的朱雀「人工特征率」(0-100):', h.zq!==null? h.zq : '');
  if(v===null) return;
  const n = parseFloat(v);
  if(isNaN(n)||n<0||n>100){ alert('请输入0-100的数字'); return }
  h.zq = n; save('zhq_history', history);
  renderHistory();
}
function delHistory(i){
  history.splice(i,1); save('zhq_history', history); renderHistory();
}
function clearHistory(){
  if(!confirm('清空全部检测记录?此操作不可恢复。')) return;
  history = []; save('zhq_history', history); renderHistory();
}
function drawTrend(){
  const svg = document.getElementById('trend-svg');
  if(!history.length){ svg.innerHTML = '<text x="20" y="80" fill="#8a94a0" font-size="12">暂无数据</text>'; return }
  const W=640, H=160, pad=28;
  const n = history.length;
  const step = n>1? (W-2*pad)/(n-1) : 0;
  let p1='', p2='';
  history.forEach((h,i)=>{
    const x = pad + i*step;
    const y1 = H-pad - (h.total/100)*(H-2*pad);
    p1 += (i?'L':'M')+x+' '+y1+' ';
    if(h.zq!==null){
      const y2 = H-pad - (h.zq/100)*(H-2*pad);
      p2 += (p2?'L':'M')+x+' '+y2+' ';
    }
  });
  let g = '<line x1="'+pad+'" y1="'+(H-pad)+'" x2="'+(W-pad)+'" y2="'+(H-pad)+'" stroke="#e4e8ef"/>'
    + '<line x1="'+pad+'" y1="'+pad+'" x2="'+pad+'" y2="'+(H-pad)+'" stroke="#e4e8ef"/>'
    + '<text x="4" y="'+(pad+4)+'" font-size="10" fill="#8a94a0">100</text>'
    + '<text x="8" y="'+(H-pad)+'" font-size="10" fill="#8a94a0">0</text>';
  if(p2) g += '<path d="'+p2+'" fill="none" stroke="#e5484d" stroke-width="2" stroke-dasharray="4 3"/>';
  g += '<path d="'+p1+'" fill="none" stroke="#2f6fed" stroke-width="2"/>';
  history.forEach((h,i)=>{
    const x = pad+i*step, y1 = H-pad-(h.total/100)*(H-2*pad);
    g += '<circle cx="'+x+'" cy="'+y1+'" r="3" fill="#2f6fed"/>';
    if(h.zq!==null) g += '<circle cx="'+x+'" cy="'+(H-pad-(h.zq/100)*(H-2*pad))+'" r="3" fill="#e5484d"/>';
  });
  svg.innerHTML = g;
}

/* ================= 规则库表 ================= */
function renderRuleTable(){
  const tb = document.getElementById('rule-table');
  document.getElementById('rule-count').textContent = RULES.length;
  tb.innerHTML = RULES.map(r=>{
    const off = rulesOff.has(r.id);
    const hitParas = Object.entries(zhuquePara).length? null : null;
    // 朱雀均分:该规则命中的段落里已登记的
    let sum=0, cnt=0;
    if(window.__results){
      window.__results.forEach(res=>{
        if(res.hits.some(h=>h.rule.id===r.id) && zhuquePara[res.hash]!==undefined){ sum+=zhuquePara[res.hash].score; cnt++ }
      });
    }
    const zq = cnt? '<span class="zq-avg">'+(sum/cnt).toFixed(0)+'%</span>' : '<span class="muted" style="font-size:12px">-</span>';
    const sample = r.kind? r.desc : (r.desc);
    const es = getSev(r);
    const adjNote = ruleAdj[r.id]? '<div class="muted" style="font-size:11px">'+r.sev+'→'+ruleAdj[r.id].sev+'</div>' : '';
    return '<tr'+(off?' style="opacity:.5"':'')+'>'
      + '<td><span class="switch" onclick="toggleRule(\''+r.id+'\')">'+(off?'◻':'◼')+'</span></td>'
      + '<td><b>'+r.name+'</b></td><td class="muted">'+r.cat+'</td>'
      + '<td><span class="sev sev'+es+'">'+'●'.repeat(es>3?5:3)+'</span>'+adjNote+'</td>'
      + '<td class="muted">'+sample+'</td><td class="muted">'+r.advice+'</td>'
      + '<td>'+zq+'</td></tr>';
  }).join('');
}
function toggleRule(id){
  if(rulesOff.has(id)) rulesOff.delete(id); else rulesOff.add(id);
  save('zhq_rules_off', [...rulesOff]);
  renderRuleTable();
}

/* ================= 其他 ================= */
function switchTab(t){
  ['guide','dash','check','report','rules','history'].forEach(x=>{
    document.getElementById('sec-'+x).classList.toggle('show', x===t);
    document.getElementById('nav-'+x).classList.toggle('active', x===t);
  });
}
function clearInput(){ document.getElementById('input').value=''; }
function loadSample(){
  document.getElementById('input').value =
`首先,我们需要明确AI项目的目标。其次,要梳理数据来源。最后,制定评估方案。

——这是很多教程的说法,但真正的问题是:最容易被低估的其实是沟通成本。2019年春天,我做过一个用户流失预测项目,那一刻我深刻地意识到,需求对齐比模型选择更重要。

首先安装Anaconda。打开终端,执行以下命令:conda create -n book python=3.10。安装完成后,配置环境变量。接下来输入pip install xgboost,等待安装完成。

值得注意的是,XGBoost的AUC从0.78提升到0.84,只涨了0.01却花了一周时间。这让我明白,纸面指标最强的模型不一定最好。综上所述,模型选型要结合业务场景、维护成本、团队能力、部署环境四个方面综合考虑。

这道题考查的是过拟合的判断方法。解题思路如下:首先观察训练集与验证集指标差距,其次检查数据泄漏,最后参考答案给出正则化建议。

我把那段监控指标拆成了几条线。服务层的日志里藏着线索。`;
}
function exportReport(){
  if(!window.__results){ alert('请先执行一次检测'); return }
  const results = window.__results;
  const total = document.getElementById('score-num').textContent;
  const cnt = {};
  results.forEach(r=>r.hits.forEach(h=>{ cnt[h.rule.name]=(cnt[h.rule.name]||0)+1 }));
  const top = Object.entries(cnt).sort((a,b)=>b[1]-a[1]);
  let rep = '【AI特征自查报告】\n时间:'+new Date().toLocaleString('zh-CN')+'\n综合风险分:'+total+'(启发式,0-100,越低越好)\n\n段落明细:\n';
  results.forEach(r=>{
    if(r.isHead && !r.hits.length) return;
    const hs = r.hits.map(h=>h.rule.name+'×'+(h.cnt||h.words.length)).join('、') || '无';
    rep += '[段'+r.idx+'] '+r.score+'分('+r.level+') '+r.chars+'字 | '+hs+'\n';
  });
  rep += '\n命中最多的规则:\n'+top.map(([n,c],i)=>(i+1)+'. '+n+' ×'+c+'段').join('\n');
  rep += '\n\n提示:本报告为规则启发式自查结果,用于定位问题段落;最终请以朱雀/知网等官方检测为准。';
  if(navigator.clipboard && navigator.clipboard.writeText){
    navigator.clipboard.writeText(rep).then(()=>alert('报告已复制到剪贴板'), ()=>{ prompt('复制下面的报告:', rep) });
  }else{
    prompt('复制下面的报告:', rep);
  }
}

/* 拖拽txt */
document.addEventListener('dragover', e=>{ e.preventDefault(); document.getElementById('drop-hint').textContent='松开鼠标导入 .txt 文件'; });
document.addEventListener('dragleave', ()=>{ document.getElementById('drop-hint').textContent='支持拖拽 .txt 文件 · 内容只在本机处理,不上传'; });
document.addEventListener('drop', e=>{
  e.preventDefault();
  document.getElementById('drop-hint').textContent='支持拖拽 .txt 文件 · 内容只在本机处理,不上传';
  const f = e.dataTransfer.files[0];
  if(!f) return;
  if(!/\.txt$/i.test(f.name)){ alert('请拖入 .txt 文件(docx 请先用 docx转txt.py 转换)'); return }
  const reader = new FileReader();
  reader.onload = ()=>{ document.getElementById('input').value = reader.result; runCheck(); };
  reader.readAsText(f, 'utf-8');
});

/* ================= 驾驶舱 ================= */
let favs = load('zhq_favs', []);

/* 独立文本分析(不依赖DOM) */
function analyzeText(text){
  const paras = splitParas(text);
  const bodyParas = paras.filter(p=>!isHeading(p));
  const shortParas = bodyParas.filter(p=>p.length<40 && !/[。!?]/.test(p));
  const results = paras.map((p, idx)=>{
    const hits = [];
    RULES.forEach(r=>{
      if(rulesOff.has(r.id)) return;
      if(r.kind==='toolong'){ if(!isHeading(p) && p.length>600) hits.push({rule:r, words:['该段'+p.length+'字']}); return }
      if(r.kind==='tooshort' || r.kind==='uniform') return;
      if(r.re){ const m = p.match(r.re); if(m && m.length) hits.push({rule:r, words:[...new Set(m)], cnt:m.length}) }
    });
    let score = 0;
    hits.forEach(h=>{ score += getSev(h.rule)*3*Math.min(h.cnt||h.words.length,3) });
    score = Math.min(100, score);
    return {idx:idx+1, text:p, chars:p.length, hash:paraHash(p), hits, score, level:score>=45?'high':(score>=20?'mid':'low'), isHead:isHeading(p)};
  });
  const lens = bodyParas.map(p=>p.length);
  const mean = lens.length? lens.reduce((a,b)=>a+b,0)/lens.length : 0;
  const cv = lens.length>1? Math.sqrt(lens.reduce((a,b)=>a+(b-mean)*(b-mean),0)/lens.length)/(mean||1) : 0;
  const ruleCnt = {};
  results.forEach(r=>r.hits.forEach(h=>{ ruleCnt[h.rule.name]=(ruleCnt[h.rule.name]||0)+1 }));
  return {results, bodyN:bodyParas.length||paras.length, chars:text.replace(/\s/g,'').length,
          meanLen:mean, cv, shortRatio:bodyParas.length?shortParas.length/bodyParas.length:0, ruleCnt};
}

/* 风格画像 */
function styleProfile(text){
  const t = text.replace(/\s/g,'');
  const periods = (t.match(/。/g)||[]).length;
  const commas = (t.match(/,/g)||[]).length;
  const me = (t.match(/我/g)||[]).length;
  const questions = (t.match(/?/g)||[]).length;
  return {me, mePer1k:t.length? me/t.length*1000 : 0,
          avgSent:periods? t.length/periods : t.length,
          commaRatio:periods? commas/periods : commas, questions};
}

/* KPI 总览 */
function renderDash(){
  const kpis = document.getElementById('dash-kpis');
  const lastH = history[history.length-1];
  const risk = lastH? lastH.total : null;
  const zqLast = [...history].reverse().find(h=>h.zq!==null && h.zq!==undefined);
  const todayCnt = history.filter(h=>new Date(h.t).toDateString()===new Date().toDateString()).length;
  const cur = STAGES.find(s=>!stageDone.includes(s.id));
  kpis.innerHTML =
    '<div class="kpi"><div class="k">当前本地风险分</div><div class="v '+(risk===null?'':(risk>=40?'red':risk>=20?'yellow':'green'))+'">'+(risk===null?'-':risk)+'</div><div class="s">目标 &lt; 20</div></div>'
    + '<div class="kpi"><div class="k">最近朱雀人工分</div><div class="v '+(zqLast?(zqLast.zq>=90?'green':zqLast.zq>=70?'yellow':'red'):'')+'">'+(zqLast? zqLast.zq+'%':'-')+'</div><div class="s">目标 ≥ 90%</div></div>'
    + '<div class="kpi"><div class="k">合格范文库</div><div class="v blue">'+favs.length+'</div><div class="s">收藏线:AI率&lt;15%</div></div>'
    + '<div class="kpi"><div class="k">今日本地自查</div><div class="v">'+todayCnt+' 次</div><div class="s">'+(cur? '第'+cur.id+'关 · '+cur.title : '全部通关')+'</div></div>';
}

/* ① 收藏 */
function collectFav(){
  const rate = parseFloat(document.getElementById('fav-rate').value);
  const text = document.getElementById('input').value.trim();
  if(!text){ alert('收藏前提:先把该文章正文粘贴到「自查检测」页的待检文本框,再回来点收藏'); return }
  if(isNaN(rate) || rate<0 || rate>100){ alert('请填写这篇文章在外部平台检测出的AI率(0-100的数字)'); return }
  if(rate >= 15){ alert('AI率 '+rate+'% 未达收藏线(<15%)。先继续打磨,收藏线也是免费阶段的过关线。'); return }
  const title = document.getElementById('fav-title').value.trim() || ('合格范文 '+(favs.length+1));
  const a = analyzeText(text);
  favs.push({title, rate, text, paraN:a.results.length, chars:a.chars, ts:Date.now()});
  save('zhq_favs', favs);
  document.getElementById('fav-rate').value='';
  document.getElementById('fav-title').value='';
  renderFavs(); renderDash();
  alert('已收藏「'+title+'」(AI率 '+rate+'%,'+a.results.length+'段 / '+a.chars+'字)。\n去②里点「分析」看它的风格画像。');
}
function renderFavs(){
  const el = document.getElementById('fav-list');
  if(!favs.length){ el.innerHTML = '<div class="muted">范文库为空。在外部平台测出 AI率&lt;15% 后回来收藏,收藏的范文会用于风格对标。</div>'; return }
  el.innerHTML = favs.slice().reverse().map((f,ri)=>{
    const i = favs.length-1-ri;
    const d = new Date(f.ts).toLocaleDateString('zh-CN');
    return '<div class="fav-row"><span class="fav-rate">AI率 '+f.rate+'%</span>'
      + '<b>'+f.title+'</b>'
      + '<span class="muted">'+f.paraN+'段 / '+f.chars+'字 / '+d+'</span>'
      + '<button class="btn ghost small" onclick="analyzeFav('+i+')">分析</button>'
      + '<button class="btn ghost small" onclick="loadFavToInput('+i+')">载入待检框</button>'
      + '<button class="btn ghost small" onclick="delFav('+i+')">删除</button></div>';
  }).join('');
}
function loadFavToInput(i){
  document.getElementById('input').value = favs[i].text;
  switchTab('check');
  runCheck();
}
function delFav(i){
  if(!confirm('删除范文「'+favs[i].title+'」?')) return;
  favs.splice(i,1); save('zhq_favs', favs);
  renderFavs(); renderDash();
}

/* ② 范文分析 */
function analyzeFav(i){
  const f = favs[i];
  const a = analyzeText(f.text);
  const prof = styleProfile(f.text);
  const green = a.results.filter(r=>!r.isHead).sort((x,y)=>x.score-y.score).slice(0,3);
  const highN = a.results.filter(r=>r.level==='high'&&!r.isHead).length;
  const midN = a.results.filter(r=>r.level==='mid'&&!r.isHead).length;
  const topRules = Object.entries(a.ruleCnt).sort((x,y)=>y[1]-x[1]).slice(0,5);
  let html = '<div class="adv-card"><div class="adv-head">「'+f.title+'」风格画像(AI率 '+f.rate+'%)</div><div class="adv-body">'
    + '正文 <b>'+a.bodyN+'</b> 段 | 平均段长 <b>'+a.meanLen.toFixed(0)+'</b> 字 | 长短波动系数 <b>'+a.cv.toFixed(2)+'</b>(&gt;0.45 说明长短交替自然)<br>'
    + '「我」出现 <b>'+prof.me+'</b> 次(每千字 <b>'+prof.mePer1k.toFixed(1)+'</b> 次)| 平均句长 <b>'+prof.avgSent.toFixed(0)+'</b> 字 | 逗号/句号比 <b>'+prof.commaRatio.toFixed(1)+'</b> | 问句 <b>'+prof.questions+'</b> 处<br>'
    + '本地规则:红段 <b>'+highN+'</b>、黄段 <b>'+midN+'</b>'
    + (topRules.length? ',top命中:'+topRules.map(([n,c])=>n+'×'+c).join('、') : ',几乎无命中') + '</div></div>';
  html += '<div class="muted" style="margin:8px 0 2px">最像人写的段落(改稿时模仿这种节奏):</div>';
  green.forEach(g=>{ html += '<div class="para-ref">'+g.text.slice(0,150)+(g.text.length>150?'…':'')+'</div>' });
  const cur = document.getElementById('input').value.trim();
  if(cur){
    const ca = analyzeText(cur); const cp = styleProfile(cur);
    const curHigh = ca.results.filter(r=>r.level==='high'&&!r.isHead).length;
    const gaps = [];
    if(ca.meanLen > a.meanLen*1.6) gaps.push('段落太长,按句拆');
    if(ca.meanLen < a.meanLen*0.6 && ca.meanLen>0) gaps.push('段落太碎,合并');
    if(ca.cv < 0.45) gaps.push('段落长度太齐,做长短交替');
    if(cp.mePer1k < prof.mePer1k*0.5) gaps.push('第一人称太少,加「我」的判断和经历');
    if(ca.shortRatio > 0.45) gaps.push('碎句段过多,恢复自然节奏');
    html += '<div class="adv-card" style="margin-top:10px"><div class="adv-head">与当前稿对比</div><div class="adv-body">'
      + '平均段长:范文 <b>'+a.meanLen.toFixed(0)+'</b> 字 vs 当前 <b>'+ca.meanLen.toFixed(0)+'</b> 字<br>'
      + '长短波动:范文 <b>'+a.cv.toFixed(2)+'</b> vs 当前 <b>'+ca.cv.toFixed(2)+'</b><br>'
      + '「我」/千字:范文 <b>'+prof.mePer1k.toFixed(1)+'</b> vs 当前 <b>'+cp.mePer1k.toFixed(1)+'</b><br>'
      + '红段占比:范文 <b>'+(a.bodyN? (highN/a.bodyN*100).toFixed(0):0)+'%</b> vs 当前 <b>'+(ca.bodyN? (curHigh/ca.bodyN*100).toFixed(0):0)+'%</b><br>'
      + '→ 当前稿优先补:' + (gaps.length? gaps.join(';') : '各项已接近范文水平,重点做词句级微调') + '</div></div>';
  }
  document.getElementById('fav-analysis').innerHTML = html;
}

/* ③ 逐段修改意见 */
function genAdvice(){
  const text = document.getElementById('input').value.trim();
  if(!text){ alert('请先把当前稿粘贴到「自查检测」页的待检文本框'); return }
  const a = analyzeText(text);
  const order = {high:0, mid:1, low:2};
  const list = a.results.filter(r=>!r.isHead).sort((x,y)=>(order[x.level]-order[y.level])||(x.idx-y.idx));
  const highN = a.results.filter(r=>r.level==='high'&&!r.isHead).length;
  const midN = a.results.filter(r=>r.level==='mid'&&!r.isHead).length;
  const prov = favs.length? '(范文库已有 '+favs.length+' 篇,去②点「分析」可对比节奏)' : '(范文库为空:收藏一篇 AI率&lt;15% 的文章后可对比风格)';
  let html = '<div class="muted">共 '+a.bodyN+' 个正文段,红段 '+highN+'、黄段 '+midN+',按风险从高到低排列'+prov+'</div>';
  html += list.map(r=>{
    const items = r.hits.length
      ? r.hits.map(h=>'<div>· <b>'+h.rule.name+'</b>('+h.words.slice(0,3).join(' / ')+')→ '+h.rule.advice+'</div>').join('')
      : '<div>· 未命中显性规则,保持即可</div>';
    let extra = '';
    if(r.chars>600) extra = '<div>· 该段 '+r.chars+' 字过长:按「一段一个意思」从中间句号处拆开(一键生成会自动拆)</div>';
    else if(a.cv<0.45) extra = '<div>· 全文段落长度过齐(CV='+a.cv.toFixed(2)+'):相邻段做一长一短</div>';
    return '<div class="adv-card" style="border-left:4px solid '+(r.level==='high'?'var(--red)':r.level==='mid'?'var(--yellow)':'var(--green)')+'">'
      + '<div class="adv-head"><span class="para-idx">段'+r.idx+'</span><span class="para-badge '+r.level+'">'+({high:'高风险',mid:'疑似',low:'低风险'}[r.level])+' '+r.score+'分</span><span class="para-chars">'+r.chars+'字</span></div>'
      + '<div class="adv-body">'+items+extra+'</div></div>';
  }).join('');
  document.getElementById('advice-list').innerHTML = html;
}

/* 一键自动改写引擎 */
const REWRITES = [
  {re:/——/g, to:',', note:'破折号改逗号'},
  {re:/首先,?/g, to:'', note:'删「首先」'},
  {re:/其次,?/g, to:'', note:'删「其次」'},
  {re:/再次,?/g, to:'', note:'删「再次」'},
  {re:/最后,/g, to:'', note:'删「最后」'},
  {re:/第[一二三四五]点[,,]?/g, to:'', note:'删「第X点」'},
  {re:/(综上所述|总而言之|总的来说|一句话总结)[,,]?/g, to:'', note:'删总结套话'},
  {re:/总之,/g, to:'', note:'删「总之」'},
  {re:/(值得注意的是|需要[特别]?强调的是|需要指出的是|不得不提的是)[,,]?/g, to:'', note:'删强调套话'},
  {re:/更重要的是,/g, to:'', note:'删「更重要的是」'},
  {re:/(([^()]{2,25}))/g, to:'$1', note:'去括号解释'},
  {re:/20\d{2}年(春天|夏天|秋天|冬天|春|夏|秋|冬)?/g, to:'前几年', note:'年份模糊化'},
  {re:/(那一刻我|第一次深刻地意识到|从那以后,我)/g, to:'后来我', note:'顿悟句式降调'},
  {re:/(让我明白了?|让我意识到|让我记住)/g, to:'后来我才搞清楚', note:'顿悟句式降调'},
  {re:/(随着[^。,]{2,12}的(不断)?发展,?)/g, to:'', note:'删宏观背景句'},
  {re:/(在当今[^。,]{0,8}时代,?)/g, to:'', note:'删时代背景句'},
  {re:/(打开终端[,,]?执行以下命令[::])/g, to:'当时在终端里敲了', note:'教程腔口语化'},
  {re:/安装完成后[,,]?/g, to:'装好之后', note:'教程腔口语化'},
  {re:/(等待安装完成|等待运行完成)/g, to:'等它跑完', note:'教程腔口语化'},
  {re:/(本题考查[^。]{0,12}|这道题)/g, to:'', note:'删标准答案腔'},
  {re:/解题思路如下[::]/g, to:'', note:'删标准答案腔'},
  {re:/(不难发现|由此可见),/g, to:'', note:'删推论套话'},
  {re:/不仅([^。,]{1,15}),?而且/g, to:'$1,', note:'拆对仗排比'},
  {re:/(建议你|这里我建议|我的建议是)[,,]?/g, to:'我一般会', note:'建议腔个人化'},
  {re:/(不妨试试|不妨采用)/g, to:'可以试试', note:'建议腔口语化'},
  {re:/(务实的做法|务实的策略)/g, to:'这么干', note:'套话口语化'},
  {re:/(如下[::]|包括以下[::]|具体包括[::]|主要分为[::]|分为以下[::]|主要有以下[::])/g, to:'', note:'删冒号列举'}
];

function autoRewrite(){
  const text = document.getElementById('input').value.trim();
  if(!text){ alert('请先把当前稿粘贴到「自查检测」页的待检文本框'); return }
  const paras = splitParas(text);
  const out = []; const changes = []; let applyCnt = 0;
  paras.forEach((p, idx)=>{
    let np = p; const notes = [];
    REWRITES.forEach(rw=>{
      const m = np.match(rw.re);
      if(m && m.length){ notes.push(rw.note+'×'+m.length); applyCnt += m.length; np = np.replace(rw.re, rw.to); }
    });
    np = np.replace(/,{2,}/g,',').replace(/。,/g,'。').replace(/^[,。、\s]+/,'').trim();
    if(!np) return;
    if(!isHeading(np) && np.length>600){
      const mid = np.indexOf('。', Math.floor(np.length/2));
      const cut = mid===-1? Math.floor(np.length/2) : mid+1;
      const p1 = np.slice(0,cut).trim(), p2 = np.slice(cut).trim();
      if(p1) out.push(p1);
      if(p2) out.push(p2);
      notes.push('长段拆分');
    } else out.push(np);
    if(notes.length) changes.push({idx:idx+1, notes});
  });
  document.getElementById('gen-wrap').style.display='block';
  document.getElementById('gen-output').value = out.join('\n\n');
  document.getElementById('gen-stat').innerHTML = '共应用 <b>'+applyCnt+'</b> 处替换 + 结构调整,涉及 <b>'+changes.length+'</b> 个段落('
    + changes.slice(0,8).map(c=>'段'+c.idx+':'+c.notes.join('、')).join(';') + (changes.length>8?';…' : '') + ')。'
    + '<b>注意:自动改写是确定性词句替换,生成后请人工通读,重点检查语序是否通顺,再送回自查复检。</b>';
}
function copyGen(){
  const v = document.getElementById('gen-output').value;
  if(!v){ alert('请先生成新文章'); return }
  if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(v).then(()=>alert('已复制全文'), ()=>prompt('复制:', v)); }
  else prompt('复制:', v);
}
function downloadGen(){
  const v = document.getElementById('gen-output').value;
  if(!v){ alert('请先生成新文章'); return }
  const blob = new Blob([v], {type:'text/plain;charset=utf-8'});
  const el = document.createElement('a');
  el.href = URL.createObjectURL(blob);
  el.download = '改写稿_自动生成.txt';
  el.click();
}
function sendGenToCheck(){
  const v = document.getElementById('gen-output').value;
  if(!v){ alert('请先生成新文章'); return }
  document.getElementById('input').value = v;
  switchTab('check');
  runCheck();
}

/* ================= 报告分析 ================= */
function normText(t){
  return (t||'').replace(/[\s\u3000,。、;:?!“”‘’()()【】\[\]《》〈〉.,!?;:'"~\-—…·]/g,'');
}
function escapeRe(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') }

/* 版本记录:runCheck 每次自动存(同稿去重) */
function saveVersion(text, total, lv, paraN, highN, chars){
  const h = paraHash('V'+text);
  let v = versions.find(x=>x.hash===h);
  if(v){
    v.ts = Date.now(); v.total = total; v.lv = lv; v.paraN = paraN; v.highN = highN; v.chars = chars;
    v.histT = history.length? history[history.length-1].t : null;
  }else{
    v = {id:'v'+Date.now(), ts:Date.now(), hash:h, total, lv, paraN, highN, chars, text,
         histT: history.length? history[history.length-1].t : null};
    versions.push(v);
    if(versions.length>30) versions = versions.slice(-30);
  }
  save('zhq_versions', versions);
  renderRepVersions();
}
function renderRepVersions(){
  const el = document.getElementById('rep-versions');
  if(!el) return;
  if(!versions.length){ el.innerHTML = '<div class="muted">暂无版本记录:在「自查检测」页对稿子跑一次自查,会自动存为版本。</div>'; return }
  el.innerHTML = versions.slice().reverse().map((v,ri)=>{
    const i = versions.length-1-ri;
    const d = new Date(v.ts);
    const sel = repSelVersion && repSelVersion.id===v.id;
    const cls = v.lv==='high'?'sev5':(v.lv==='mid'?'sev3':'');
    return '<div class="version-row'+(sel?' sel':'')+'" onclick="selectVersion('+i+')">'
      + '<span class="sw">'+(sel?'◉':'◯')+'</span>'
      + '<span class="muted">'+d.toLocaleDateString('zh-CN')+' '+d.toTimeString().slice(0,5)+'</span>'
      + '<span>风险分 <b class="'+cls+'">'+v.total+'</b></span>'
      + '<span class="muted">'+v.paraN+'段 / '+v.chars+'字 / 红段'+v.highN+'</span>'
      + '<button class="btn ghost small" onclick="event.stopPropagation();loadVersionToInput('+i+')">载入待检框</button>'
      + '</div>';
  }).join('');
}
function selectVersion(i){ repSelVersion = versions[i]; renderRepVersions(); }
function loadVersionToInput(i){
  document.getElementById('input').value = versions[i].text;
  switchTab('check'); runCheck();
}

/* ---- ① 报告导入与解析 ---- */
function loadRepFile(inp){
  const f = inp.files[0]; if(!f) return;
  const reader = new FileReader();
  reader.onload = ()=>{ document.getElementById('rep-input').value = reader.result; parseReport(); };
  reader.readAsText(f, 'utf-8');
  inp.value = '';
}
/* PDF直接上传:懒加载pdf.js(仅此时联网一次),文字版直接提取,扫描版自动 OCR */
let repPdfBusy = false;
function setRepProgress(msg){
  document.getElementById('rep-progress').textContent = msg || '';
}
function ensurePdfJs(cb){
  if(window.pdfjsLib){ cb(); return }
  const CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/';
  const s = document.createElement('script');
  s.src = CDN + 'pdf.min.js';
  s.onload = ()=>{
    window.pdfjsLib.GlobalWorkerOptions.workerSrc = CDN + 'pdf.worker.min.js';
    cb();
  };
  s.onerror = ()=>{ repPdfBusy=false; alert('PDF解析组件加载失败(需联网加载一次,约1MB)。\n离线替代:用配套「报告转txt.py」转换后再上传txt。'); };
  document.head.appendChild(s);
}
function ensureTesseract(cb){
  if(window.Tesseract && window.Tesseract.createWorker){ cb(); return }
  setRepProgress('正在加载 OCR 组件,约 15MB 中文模型首次需联网下载…');
  const s = document.createElement('script');
  s.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@4/dist/tesseract.min.js';
  s.onload = ()=> cb();
  s.onerror = ()=>{ repPdfBusy=false; setRepProgress(''); alert('OCR 组件加载失败(需联网)。\n离线替代:用配套「报告转txt.py」导出页面图片后,再用本地 OCR 软件识别并粘贴文本。'); };
  document.head.appendChild(s);
}
async function ocrPdfPages(pdf){
  return new Promise((resolve, reject)=>{
    ensureTesseract(async ()=>{
      try{
        const worker = await Tesseract.createWorker('chi_sim', 1, {
          logger: m => {
            if(m.status === 'recognizing text'){
              setRepProgress('正在 OCR 第 ' + m.jobId + ' 页,进度 ' + Math.round(m.progress*100) + '%…');
            }
          }
        });
        let parts = [];
        for(let i=1; i<=pdf.numPages; i++){
          setRepProgress('正在渲染第 '+i+'/'+pdf.numPages+' 页…');
          const page = await pdf.getPage(i);
          const scale = 2;
          const viewport = page.getViewport({scale});
          const canvas = document.createElement('canvas');
          canvas.width = viewport.width;
          canvas.height = viewport.height;
          const ctx = canvas.getContext('2d');
          await page.render({canvasContext: ctx, viewport}).promise;
          setRepProgress('正在 OCR 第 '+i+'/'+pdf.numPages+' 页…');
          const { data: { text } } = await worker.recognize(canvas);
          if(text && text.trim()) parts.push(text.trim());
        }
        await worker.terminate();
        resolve({text: parts.join('\n\n'), count: parts.length});
      }catch(e){ reject(e); }
    });
  });
}
function loadRepPdf(inp){
  const f = inp.files[0]; if(!f || repPdfBusy) return;
  repPdfBusy = true;
  const name = f.name;
  ensurePdfJs(async ()=>{
    try{
      const buf = await f.arrayBuffer();
      const pdf = await window.pdfjsLib.getDocument({data: buf}).promise;
      let texts = [], emptyPages = 0;
      for(let i=1; i<=pdf.numPages; i++){
        const page = await pdf.getPage(i);
        const tc = await page.getTextContent();
        let t = (tc.items||[]).map(x=>x.str).join(' ').replace(/\s+/g,' ').trim();
        if(t.replace(/[^\u4e00-\u9fa5A-Za-z0-9]/g,'').length < 10){ emptyPages++; t=''; }
        texts.push(t);
      }
      let total = texts.filter(Boolean).join('\n\n');
      let ocrResult = null;
      if(total.replace(/\s/g,'').length < 30){
        const ok = confirm('「'+name+'」共 '+pdf.numPages+' 页,未检测到文字层,疑似扫描/图片版 PDF。\n\n将启用浏览器 OCR(tesseract.js 中文模型,首次约需下载 15MB),预计耗时 '+Math.max(10, pdf.numPages*6)+' 秒左右。\n\n确定开始识别吗?');
        if(!ok){ setRepProgress(''); repPdfBusy=false; return; }
        ocrResult = await ocrPdfPages(pdf);
        total = ocrResult.text;
        if(total.replace(/\s/g,'').length < 30){
          alert('OCR 后仍未识别到有效文字。可能是图片质量过低、非中文报告,或模型下载失败。\n请改用「报告转txt.py」导出页面图片后用本地 OCR 识别,再粘贴文本。');
          return;
        }
      }
      document.getElementById('rep-input').value = total;
      parseReport();
      if(ocrResult){
        setRepProgress('OCR 识别完成:'+ocrResult.count+'/'+pdf.numPages+' 页。下方列表可逐条校对。');
      }else if(emptyPages>0){
        setRepProgress('已提取 '+(pdf.numPages-emptyPages)+'/'+pdf.numPages+' 页文字,其中 '+emptyPages+' 页为扫描图未提取到。');
      }else{
        setRepProgress('');
      }
    }catch(e){
      alert('PDF解析失败:'+(e && e.message ? e.message : e)+'\n若反复失败,改用「报告转txt.py」转换。');
    }finally{
      repPdfBusy = false;
    }
  });
  inp.value = '';
}
function cleanFragText(s){
  return s.replace(/片段\s*\d+/g,'')
    .replace(/(AIGC|AI特征|疑似AI|AI生成|人工特征|人工痕迹)[^\n\d]{0,6}\d+(\.\d+)?%?/g,'')
    .replace(/0\.\d{3,4}/g,'')
    .replace(/\d+(\.\d+)?\s*%/g,'')
    .replace(/-{2,}/g,'')
    .replace(/[ \t]+/g,' ')
    .replace(/\s*\n\s*/g,' ')
    .trim();
}
function fragScoreOf(block){
  const aigcM = block.match(/0\.\d{3,4}/);
  if(aigcM) return +((1 - parseFloat(aigcM[0]))*100).toFixed(1);
  const pm = block.match(/(人工特征|人工痕迹|人工率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
  if(pm) return +pm[2];
  const am = block.match(/(AI特征|疑似AI|AIGC率?|AI率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
  if(am) return +(100 - +am[2]).toFixed(1);
  return null;
}
function readFragInputs(){
  if(!repParsed) return;
  document.querySelectorAll('.frag-human').forEach(inp=>{
    const i = +inp.getAttribute('data-i'); const v = parseFloat(inp.value);
    repParsed.frags[i].human = isNaN(v)? null : Math.max(0, Math.min(100, v));
  });
  document.querySelectorAll('.frag-text').forEach(t=>{
    repParsed.frags[+t.getAttribute('data-i')].text = t.value.trim();
  });
}
function parseReport(){
  const raw = document.getElementById('rep-input').value.trim();
  if(!raw){ alert('请先粘贴检测报告文本'); return }
  let human = null, ai = null, m;
  if((m = raw.match(/(人工特征|人工痕迹|人工成分|人工率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/))) human = +m[2];
  if((m = raw.match(/(AI特征|AI生成|疑似AI|AIGC率?|AI率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/))) ai = +m[2];
  if(human===null && ai!==null) human = +(100-ai).toFixed(2);
  if(ai===null && human!==null) ai = +(100-human).toFixed(2);
  // 片段切分:优先按「片段N」标记,否则按空行分块;均要求带数值且文本≥30字
  let blocks = [];
  const markerN = (raw.match(/片段\s*\d+/g)||[]).length;
  const parts = markerN>=2 ? raw.split(/(?=片段\s*\d+)/) : raw.split(/\n\s*\n/);
  parts.forEach(pt=>{
    pt = pt.trim(); if(!pt) return;
    const score = fragScoreOf(pt);
    if(score===null) return;
    const txt = cleanFragText(pt);
    if(txt.length < 30) return;
    blocks.push({text:txt, human:score});
  });
  repParsed = {human, ai, frags:blocks, ts:Date.now()};
  repMatched = null;
  document.getElementById('rep-analysis-card').style.display = 'none';
  document.getElementById('rep-analysis').innerHTML = '';
  renderRepParsed();
  if(!blocks.length){
    alert('未识别到带数值的片段。可在下方「+手动补片段」逐条录入片段文本和人工特征%,或检查报告格式(片段需带 AIGC 0.xxxx 或 百分比 数值)。');
  }
  // 按文本重叠度自动预选最像的版本
  if(blocks.length && versions.length){
    let best=null, bestR=-1;
    versions.forEach(v=>{
      const paras = splitParas(v.text).map(normText);
      let s=0, n=0;
      blocks.slice(0,5).forEach(f=>{
        const fn = normText(f.text);
        if(fn.length<12) return;
        let mx=0;
        paras.forEach(pn=>{
          let hits=0, tot=0;
          for(let i=0;i+6<=fn.length;i+=3){ tot++; if(pn.includes(fn.substr(i,6))) hits++; }
          mx = Math.max(mx, tot? hits/tot : 0);
        });
        s += mx; n++;
      });
      const r = n? s/n : 0;
      if(r>bestR){ bestR=r; best=v }
    });
    if(best && bestR>=0.15){
      repSelVersion = best;
      renderRepVersions();
      alert('已自动勾选最匹配的版本(平均重叠 '+Math.round(bestR*100)+'%,风险分 '+best.total+')。如不对请在②中改选。');
    }
  }
}
function renderRepParsed(){
  const el = document.getElementById('rep-parse');
  if(!repParsed){ el.innerHTML=''; return }
  const p = repParsed;
  let html = '<div class="adv-card"><div class="adv-head">解析结果</div><div class="adv-body">'
    + '总体:'+(p.human!==null? '人工特征 <b>'+p.human+'%</b>' : '未识别总体数值')
    + (p.ai!==null? ' | AI特征 <b>'+p.ai+'%</b>' : '')
    + ' | 识别片段 <b>'+p.frags.length+'</b> 个(数值与文本均可修改;数值填该片段的人工特征%)'
    + '</div></div>';
  html += p.frags.map((f,i)=>
    '<div class="rep-frag"><div class="row"><span class="muted" style="font-weight:700">片段'+(i+1)+'</span>'
    + '<span class="muted">人工特征%:</span><input class="rate-input frag-human" data-i="'+i+'" value="'+(f.human===null?'':f.human)+'" placeholder="如 46.9">'
    + '<span class="muted frag-match" id="frag-match-'+i+'"></span>'
    + '<button class="btn ghost small" onclick="delFrag('+i+')">删除</button></div>'
    + '<textarea class="frag-text" data-i="'+i+'">'+f.text+'</textarea></div>'
  ).join('');
  el.innerHTML = html;
}
function addFragRow(){
  readFragInputs();
  if(!repParsed) repParsed = {human:null, ai:null, frags:[], ts:Date.now()};
  repParsed.frags.push({text:'', human:null});
  renderRepParsed();
}
function delFrag(i){
  readFragInputs();
  repParsed.frags.splice(i,1);
  renderRepParsed();
}

/* ---- ③ 对比分析 ---- */
function overlapRatio(fn, pn){
  if(fn.length<6) return 0;
  let hits=0, tot=0;
  for(let i=0;i+6<=fn.length;i+=3){ tot++; if(pn.includes(fn.substr(i,6))) hits++; }
  return tot? hits/tot : 0;
}
function runRepCompare(){
  if(!repParsed || !repParsed.frags.length){ alert('请先在①导入并解析报告(或手动补片段)'); return }
  if(!repSelVersion){ alert('请先在②勾选报告对应的自查版本'); return }
  readFragInputs();
  const v = repSelVersion;
  const a = analyzeText(v.text);
  const matched = repParsed.frags.map((f, fi)=>{
    const fn = normText(f.text);
    let best=null, bestR=0;
    if(fn.length>=10){
      a.results.forEach(r=>{
        const ratio = overlapRatio(fn, normText(r.text));
        if(ratio>bestR){ bestR=ratio; best=r }
      });
    }
    return {frag:f, fi, res: bestR>=0.15? best : null, ratio:bestR};
  });
  matched.forEach(x=>{
    const tag = document.getElementById('frag-match-'+x.fi);
    if(tag) tag.innerHTML = x.res? '→ 对齐段'+x.res.idx+'(重叠'+Math.round(x.ratio*100)+'%)' : '<span style="color:var(--red)">未对齐</span>';
  });
  // 回写段落级朱雀分(规则库「朱雀均分」列因此变准)
  let updatedZq = 0;
  matched.forEach(x=>{
    if(x.res && x.frag.human!==null){ zhuquePara[x.res.hash] = {score:x.frag.human, ts:Date.now()}; updatedZq++; }
  });
  save('zhq_zhuque_para', zhuquePara);
  // 规则判别力统计:命中段的平均AI值 vs 全部对齐段平均AI值
  const withScore = matched.filter(x=>x.res && x.frag.human!==null);
  const baseAvg = withScore.length? withScore.reduce((s,x)=>s+(100-x.frag.human),0)/withScore.length : null;
  const ruleStats = RULES.map(r=>{
    const hitList = withScore.filter(x=>x.res.hits.some(h=>h.rule.id===r.id));
    if(!hitList.length) return {rule:r, nHit:0, avgHit:null, lift:null};
    const avgHit = hitList.reduce((s,x)=>s+(100-x.frag.human),0)/hitList.length;
    return {rule:r, nHit:hitList.length, avgHit, lift: baseAvg!==null? avgHit-baseAvg : null};
  });
  const uncovered = matched.filter(x=>x.frag.human!==null && x.frag.human<50 && !(x.res && x.res.level==='high'));
  const candidates = mineCandidates(matched);
  repMatched = {matched, ruleStats, baseAvg, updatedZq, uncovered, candidates,
                verT:v.histT, verTotal:v.total, matchedN: matched.filter(x=>x.res).length};
  renderRepAnalysis();
  renderRuleTable(); renderDash();
}
function mineCandidates(matched){
  const highs = matched.filter(x=>x.res && x.frag.human!==null && x.frag.human<50);
  const rawHigh = highs.map(x=>x.frag.text);
  if(rawHigh.length<2) return [];
  const highN = rawHigh.map(normText);
  const lowN = normText(matched.filter(x=>x.res && x.frag.human!==null && x.frag.human>=70).map(x=>x.frag.text).join(''));
  const kept = [];
  for(let L=8; L>=4 && kept.length<40; L--){
    for(let h=0; h<highN.length && kept.length<40; h++){
      const t = highN[h].slice(0,3000);
      for(let i=0; i+L<=t.length && kept.length<40; i+=2){
        const s = t.substr(i,L);
        if(lowN.includes(s)) continue;
        if(kept.some(k=>k.text.includes(s))) continue;
        let c=0; highN.forEach(x=>{ if(x.includes(s)) c++ });
        if(c<2) continue;
        let rc=0; rawHigh.forEach(rt=>{ if(rt.replace(/\s/g,'').includes(s)) rc++ });
        if(rc<2) continue;
        const covered = RULES.some(r=>{
          if(!r.re) return false;
          try{ return new RegExp(r.re.source).test(s) }catch(e){ return false }
        });
        if(!covered) kept.push({text:s, cnt:c});
      }
    }
  }
  return kept.sort((x,y)=>y.cnt-x.cnt || y.text.length-x.text.length).slice(0,6);
}
function renderRepAnalysis(){
  document.getElementById('rep-analysis-card').style.display = 'block';
  if(!repMatched || !repParsed){ document.getElementById('rep-analysis').innerHTML = '<div class="muted">请先在①解析报告并完成②③的对比分析。</div>'; return }
  let html = '<div class="adv-card"><div class="adv-head">总览</div><div class="adv-body">'
    + '报告人工特征 '+(repParsed.human!==null? '<b>'+repParsed.human+'%</b>' : '未识别(可回①补填)')
    + ' | 版本本地风险分 <b>'+m.verTotal+'</b>'
    + ' | 片段对齐 <b>'+m.matchedN+'/'+repParsed.frags.length+'</b>'
    + ' | 段落级朱雀分已回写 <b>'+m.updatedZq+'</b> 段'
    + (repParsed.human!==null? '<br>换算校验:本地风险分 '+m.verTotal+' vs 报告AI率 '+(100-repParsed.human).toFixed(1)+'% '
      + (Math.abs(m.verTotal-(100-repParsed.human))<15? ',接近,换算关系基本可靠' : ',偏差较大,继续积累校准点') : '')
    + '<div style="margin-top:6px"><button class="btn small" onclick="writeZqToHistory()">写入历史校准点(把报告人工分记到该版本的检测记录)</button></div>'
    + '</div></div>';
  // 段落对齐明细(漏检排最前)
  const rank = {miss:0, both:1, falsepos:2, bothgreen:3, nodata:4, noalign:5};
  const rows = m.matched.map(x=>{
    let k;
    if(!x.res) k = 'noalign';
    else if(x.frag.human===null) k = 'nodata';
    else if(x.frag.human<50) k = x.res.level==='high'?'both':'miss';
    else if(x.frag.human>=70) k = x.res.level==='high'?'falsepos':'bothgreen';
    else k = 'nodata';
    return {x, k};
  }).sort((p,q)=>rank[p.k]-rank[q.k]);
  html += '<div class="muted" style="margin:10px 0 4px">段落对齐明细(漏检排最前,优先改):</div>';
  html += rows.map(({x,k})=>{
    const f = x.frag;
    const hTxt = f.human===null? '未填' : f.human+'%';
    const hCol = f.human===null? 'var(--sub)' : (f.human<50?'var(--red)':f.human>=70?'var(--green)':'var(--yellow)');
    const local = x.res? '段'+x.res.idx+' · '+x.res.score+'分('+({high:'红',mid:'黄',low:'绿'}[x.res.level])+')' : '未对齐';
    const agree = {
      miss:'<span style="color:var(--red);font-weight:700">漏检:报告红、本地未红</span>',
      both:'<span style="color:var(--green);font-weight:700">双红:规则已抓到</span>',
      falsepos:'<span style="color:var(--yellow);font-weight:700">误报:本地红、报告绿</span>',
      bothgreen:'<span style="color:var(--green)">双绿</span>',
      nodata:'数值缺失', noalign:'<span style="color:var(--red)">未对齐版本段落(可删掉该片段或改选版本)</span>'}[k];
    const tags = x.res? (x.res.hits.slice(0,4).map(h=>h.rule.name).join('、')||'无命中') : '-';
    return '<div class="adv-card" style="border-left:4px solid '+((k==='miss'||k==='noalign')?'var(--red)':(k==='both'?'var(--red)':(k==='falsepos'?'var(--yellow)':'var(--green)')))+'">'
      + '<div class="adv-head">片段'+(x.fi+1)+' <span style="color:'+hCol+';font-weight:700">人工 '+hTxt+'</span> → '+local+'</div>'
      + '<div class="adv-body">'+agree+' | 命中规则:'+tags+'<br><span style="color:#666">'+f.text.slice(0,80)+(f.text.length>80?'…':'')+'</span></div></div>';
  }).join('');
  // 规则判别力与更新建议
  const sugg = m.ruleStats.filter(s=>s.nHit>=2 && s.lift!==null)
    .sort((x,y)=>Math.abs(y.lift)-Math.abs(x.lift)).slice(0,10);
  if(sugg.length){
    html += '<div class="muted" style="margin:10px 0 4px">规则判别力(命中段平均AI值 − 基准平均AI值,正得越多说明该规则真能抓AI,负得多说明误伤人写段落):</div>';
    sugg.forEach(s=>{
      const es = getSev(s.rule);
      const adjed = ruleAdj[s.rule.id];
      let sug, act='';
      if(s.lift>=8){ sug='<b style="color:var(--green)">有效规则:建议升权</b>'; act='<button class="btn small" onclick="applyRuleAdj(\''+s.rule.id+'\',1)">严重度+1</button>'; }
      else if(s.lift<=-8){ sug='<b style="color:var(--yellow)">判别力弱:建议降权或关闭</b>'; act='<button class="btn small" onclick="applyRuleAdj(\''+s.rule.id+'\',-1)">严重度-1</button> <button class="btn warn small" onclick="applyRuleOff(\''+s.rule.id+'\')">关闭</button>'; }
      else { sug='相关性不明显,维持现状'; }
      html += '<div class="adv-card"><div class="adv-head">'+s.rule.name+' <span class="muted" style="font-weight:400">(当前严重度 '+es+(adjed? ',已由 '+s.rule.sev+' 调整':'')+')</span></div>'
        + '<div class="adv-body">命中 <b>'+s.nHit+'</b> 段 | 命中段平均AI值 <b>'+s.avgHit.toFixed(1)+'</b> vs 基准 <b>'+m.baseAvg.toFixed(1)+'</b>('+(s.lift>=0?'+':'')+s.lift.toFixed(1)+')→ '+sug+' '+act+'</div></div>';
    });
  }else{
    html += '<div class="muted" style="margin:10px 0">对齐且带数值的片段不足,规则判别力统计暂不可用(至少需要2个对齐片段带人工特征%)。</div>';
  }
  // 未覆盖红段
  if(m.uncovered.length){
    html += '<div class="muted" style="margin:10px 0 4px">报告判红但规则未覆盖的片段('+m.uncovered.length+' 个,改稿优先处理):</div>';
    m.uncovered.forEach(x=>{
      html += '<div class="para-ref" style="border-color:var(--red);background:var(--red-bg);color:#8a1c21">'+x.frag.text.slice(0,120)+(x.frag.text.length>120?'…':'')+'</div>';
    });
  }
  // 红段高频模式
  if(m.candidates.length){
    html += '<div class="muted" style="margin:10px 0 4px">红段高频固定模式(自动挖掘:在多个红段重复出现、绿段没有,且现有规则未覆盖;人工确认后可加入规则库):</div>';
    m.candidates.forEach((c,i)=>{
      const added = customRules.some(r=>r.reSource===escapeRe(c.text));
      html += '<div class="adv-card"><div class="adv-head">「'+c.text+'」</div><div class="adv-body">出现在 '+c.cnt+' 个红段 '
        + (added? '<span style="color:var(--green);font-weight:700">(已在规则库)</span>' : '<button class="btn small" onclick="addCustomRule('+i+')">加入规则库</button>')
        + '</div></div>';
    });
  }
  document.getElementById('rep-analysis').innerHTML = html;
}
function applyRuleAdj(id, delta){
  const cur = getSev(RULES.find(r=>r.id===id) || {sev:3});
  const next = Math.max(1, Math.min(5, cur+delta));
  ruleAdj[id] = {sev:next, ts:Date.now(), note:'报告对比调整'};
  save('zhq_rule_adj', ruleAdj);
  renderRuleTable(); renderRepAnalysis();
}
function applyRuleOff(id){
  rulesOff.add(id); save('zhq_rules_off', [...rulesOff]);
  renderRuleTable(); renderRepAnalysis();
}
function addCustomRule(i){
  const c = repMatched && repMatched.candidates[i];
  if(!c) return;
  const esc = escapeRe(c.text);
  if(customRules.some(r=>r.reSource===esc)){ return }
  const cr = {id:'c'+Date.now(), name:'自动挖掘·'+c.text.slice(0,6), cat:'自动挖掘', sev:3,
    reSource:esc, desc:'红段高频模式「'+c.text+'」('+c.cnt+'个红段出现)', advice:'改写时优先处理该固定模式'};
  customRules.push(cr); save('zhq_custom_rules', customRules);
  RULES.push(Object.assign({}, cr, {re:new RegExp(esc,'g')}));
  renderRuleTable(); renderRepAnalysis();
}
function writeZqToHistory(){
  if(repParsed.human===null){ alert('报告未识别总体人工特征率,无法写入(可在①区补填后重新对比)'); return }
  const h = history.find(x=>x.t===repMatched.verT);
  if(!h){ alert('未找到该版本对应的检测记录(历史可能已清空);段落级朱雀分已更新。'); return }
  h.zq = repParsed.human; save('zhq_history', history);
  renderHistory(); renderDash();
  alert('已写入:该版本的检测记录关联朱雀人工分 '+repParsed.human+'%');
}

/* init */
renderStages(); renderRuleTable(); renderHistory(); renderDash(); renderFavs(); renderRepVersions();
</script>
</body>
</html>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AZ-直到世界的尽头

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值