// 双 Agent 搜索框架 — 镜像回答 v0.1
//
// 设计目标: 用户搜一个问题 → 不返回简单的关键词搜索结果, 而是先
// "理解为什么问", 再"自动展开相关维度", 最后从全网/历史答主里
// 抓取并组装真人回答.
//
//   query
//     │
//     ▼
//   ┌───────────────┐
//   │ intentAgent   │ — 拆解意图: 表层目标 / 深层目标 / 情绪驱动
//   └───┬───────────┘
//       │
//       ▼
//   ┌───────────────┐
//   │ expansionAgent│ — 拓展维度: 根据深层目标自动联想 5-8 个搜索方向
//   └───┬───────────┘
//       │
//       ▼
//   ┌───────────────┐
//   │ searchAgent   │ — 并发执行每个维度的搜索 + 答主匹配
//   └───┬───────────┘
//       │
//       ▼
//   ┌───────────────┐
//   │ assembler     │ — 拼成生成式 UI 面板 + AI 解释
//   └───────────────┘
//
// 本文件提供框架骨架 + 演示数据. 生产实现把 demo() 里的 LLM_STUB
// 替换成 fetch /v1/chat/completions 即可.

// ─── LLM 调用 ──────────────────
// PROXY_BASE 自动适配:
//   - 本地 dev (loopback): 走 http://127.0.0.1:5174 (Python proxy)
//   - 部署到 Vercel: 走 同源相对路径 /api (Node serverless functions)
// 两边路由 shape 完全对齐, 前端业务代码不用改.
const PROXY_BASE = (typeof location !== 'undefined' && /^(127\.0\.0\.1|localhost)$/.test(location.hostname))
  ? 'http://127.0.0.1:5174'
  : '/api';

async function callLLM(model, messages, opts = {}) {
  // model 名带 'groq/' 前缀走 groq, 不然走统一 /llm
  const provider = opts.provider || (model.startsWith('groq/') ? 'groq' : 'llm');
  const body = {
    model,
    messages,
    temperature: opts.temperature ?? 0.4,
    max_tokens:  opts.maxTokens   ?? 1500,
    ...(opts.responseFormat ? { response_format: opts.responseFormat } : {}),
  };
  // 彩排模式: 通过 window.__forceLLMProvider = 'dashscope' 强制走兜底, 不打 Cerebras.
  // 这样你能模拟"现场没了 Cerebras"看链路切换是否丝滑.
  if (typeof window !== 'undefined' && window.__forceLLMProvider) {
    body._force_provider = window.__forceLLMProvider;
  }
  // Groq 的 qwen3-32b 是 thinking 模型, 必须关掉 think 才出 JSON.
  // Cerebras 的 qwen-3-235b-...-instruct-... 是 instruct 变体, 不接受这个参数.
  if (model.startsWith('qwen/qwen3')) {
    body.reasoning_effort = opts.reasoningEffort ?? 'none';
  } else if (opts.reasoningEffort) {
    body.reasoning_effort = opts.reasoningEffort;
  }
  const r = await fetch(`${PROXY_BASE}/${provider}`, {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify(body),
  });
  if (!r.ok) {
    const err = await r.text();
    throw new Error(`${provider} ${model} HTTP ${r.status}: ${err.slice(0, 200)}`);
  }
  // 后端给的 X-LLM-Provider 告诉这次实际是 cerebras 还是 dashscope (走 /llm 时).
  const actualProvider = r.headers.get('X-LLM-Provider') || provider;
  const data = await r.json();
  const content = data.choices?.[0]?.message?.content;
  // ─── Token 计数器 (会话级累计, 按 provider 分类) ─────────────────
  // 跑完 demo 在 console 输入 `window.__llmCost()` 看消耗汇总 + 估算 ¥.
  if (typeof window !== 'undefined' && data.usage) {
    window.__llmUsage = window.__llmUsage || {};
    const bucket = window.__llmUsage[actualProvider] = window.__llmUsage[actualProvider] || {
      calls: 0, prompt: 0, completion: 0, total: 0,
    };
    bucket.calls      += 1;
    bucket.prompt     += data.usage.prompt_tokens || 0;
    bucket.completion += data.usage.completion_tokens || 0;
    bucket.total      += data.usage.total_tokens || 0;
  }
  if (!content) throw new Error(`${provider} returned empty content`);
  return { content, usage: data.usage, raw: data };
}
// 兼容旧引用
const callGroq = callLLM;

// 解析 LLM 返回的 JSON. 容忍 ```json 围栏 / 前后无关文字 / 多段 JSON / 小毛病.
function parseJSON(text) {
  let s = text.trim();
  const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
  if (fence) s = fence[1].trim();

  // 从第一个 { 起做括号配平, 取第一个完整 JSON object. 避免 lastIndexOf
  // 跨多段 JSON 的问题.
  const first = s.indexOf('{');
  if (first === -1) throw new Error('no JSON object in response');
  let depth = 0, end = -1, inStr = false, esc = false;
  for (let i = first; i < s.length; i++) {
    const ch = s[i];
    if (inStr) {
      if (esc) { esc = false; continue; }
      if (ch === '\\') { esc = true; continue; }
      if (ch === '"') inStr = false;
      continue;
    }
    if (ch === '"') { inStr = true; continue; }
    if (ch === '{') depth++;
    else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
  }
  if (end === -1) throw new Error('unbalanced JSON braces');
  let candidate = s.slice(first, end + 1);
  try { return JSON.parse(candidate); }
  catch (e1) {
    // 小毛病修复: trailing comma / 全角符号
    let repaired = candidate
      .replace(/，/g, ',').replace(/、/g, ',').replace(/：/g, ':')
      .replace(/"/g, '"').replace(/"/g, '"')
      .replace(/'/g, "'").replace(/'/g, "'")
      .replace(/,(\s*[}\]])/g, '$1');
    try { return JSON.parse(repaired); }
    catch (e2) { throw new Error('JSON parse failed: ' + e1.message); }
  }
}

// 从 compound-mini 的 reasoning 字段里 parse 出 [{url, title, snippet}, ...]
// compound-mini 的 reasoning 形如:
//   <tool>search(query)</tool>
//   <output>Title: ...\nURL: https://...\nContent: ...\nScore: 0.xxx
//   Title: ...\nURL: ...\nContent: ...
//   </output>
function extractWebSources(reasoning) {
  if (!reasoning || typeof reasoning !== 'string') return [];
  const out = [];
  const re = /Title:\s*([^\n]+)\nURL:\s*([^\n]+)\nContent:\s*([\s\S]*?)(?=\n(?:Score:|Title:|<\/output))/g;
  let m;
  while ((m = re.exec(reasoning))) {
    const url = m[2].trim();
    if (!/^https?:\/\//.test(url)) continue;
    out.push({
      title: m[1].trim().slice(0, 120),
      url,
      snippet: m[3].trim().replace(/\s+/g, ' ').slice(0, 400),
    });
  }
  return out;
}

// ─── 真实联网搜索 (统一入口, 自动 Bocha → Tavily) ────────────────
// 走 proxy /search, 后端 try bocha → tavily. 响应已经 normalize.
// 彩排切换: window.__forceSearchProvider = 'tavily' 或 'bocha'.
async function tavilySearch(query, maxResults = 6) {
  const body = { query, max_results: maxResults };
  if (typeof window !== 'undefined' && window.__forceSearchProvider) {
    body._force_provider = window.__forceSearchProvider;
  }
  const r = await fetch(`${PROXY_BASE}/search`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`search ${r.status}`);
  // 后端给的 X-Search-Provider 告诉这次实际是哪条路径 (bocha / tavily)
  const provider = r.headers.get('X-Search-Provider') || 'unknown';
  if (typeof window !== 'undefined') window.__lastSearchProvider = provider;
  const data = await r.json();
  return (data.results || []).map(x => ({
    title: (x.title || '').slice(0, 120),
    url: x.url,
    snippet: (x.content || '').replace(/\s+/g, ' ').slice(0, 400),
  })).filter(s => s.url);
}

// ─── Agent 1: 拆解意图 + 用 Tavily 真实联网搜索 ─────────────────
// 先 Tavily 拿到 web sources, 再让 Cerebras Qwen3-235B 基于 source 内容
// 提取 relatedTerms — 这样别名表来自真实网页, 不是 LLM 凭记忆.
//
// 我们的题库只覆盖一个主题: 减肥 / 减重 / 体重管理 / 减脂.
// Intent agent 同时输出 isInScope — 用户问题是不是这个主题范围内的.
// pipeline 后续会用这个 flag 决定: 是正常走 Workers, 还是直接 state C 老实拒绝.
//
const LIBRARY_SCOPE_DESC = '减肥 / 减重 / 减脂 / 体重管理 (包含 GLP-1 药物 / 饮食法 / 运动 / 心理 / 手术 / 特殊人群 如孕产 PCOS BED)';

async function intentAgent(question) {
  // 1) Tavily 真联网, 拿到 URL + snippet
  let sources = [];
  try { sources = await tavilySearch(question, 6); }
  catch (e) { console.warn('[intent] Tavily failed:', e.message); }
  const webSearchText = sources.map(s => s.title + '。' + s.snippet).join('\n');

  // 2) Cerebras 基于 web 内容做意图分析 + 抽 relatedTerms + 判断 in-scope
  const SYSTEM = `你是镜像回答的意图分析器. 我给你用户问题 + Tavily 联网搜回来的几条网页 snippet, 你要:
1. 抽取 surfaceGoal (用户字面问的)
2. 锁定 rootTopic (1-3字根概念, 例: "减肥" / "AI 模型" / "国际政治")
3. **判断 isInScope**: 我们的答主题库主题范围是: 【${LIBRARY_SCOPE_DESC}】.
   - 用户问题的 rootTopic 跟这个范围沾边 (是同一类话题) → isInScope: true
   - 完全不同主题 (例如用户问国际政治 / 编程 / 房地产 / 娱乐 — 跟减肥无关) → isInScope: false
   - 边界情况 (例如 "怎么改善代谢综合征" 跟减肥强相关) → isInScope: true
   只看主题层面, 不看字面词. 字面上的"经济利益"在政治和医药里都有, 但话题不同就是不同.
4. relatedTerms: 列 8-15 个相关词:
   a) 用户原题里出现的核心动作/状态词
   b) web snippet 里实际出现的具体名词
   c) 中文常见同义词
   不要自己编造网上没提到的高深概念.
5. scoutFindings: 1-2 句基于 web snippet 的事实总结

只输出 JSON, 不要 markdown:
{"surfaceGoal":"...","rootTopic":"...","isInScope":true,"relatedTerms":["..."],"scoutFindings":"..."}`;
  const userMsg = `用户问题: ${question}\n\n联网搜到的网页 snippet:\n${webSearchText || '(无, Tavily 不可用)'}`;
  const { content, usage } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
    { role: 'system', content: SYSTEM },
    { role: 'user',   content: userMsg },
  ], { temperature: 0.3, maxTokens: 600 });
  const parsed = parseJSON(content);
  // 兜底: 如果 LLM 漏了 isInScope 字段, 默认 true (尽量不阻拦), 让后续 Worker / Judge 的底线再兜
  if (parsed.isInScope === undefined) parsed.isInScope = true;
  return { ...parsed, sources, webSearchText, _usage: usage };
}

// ─── Agent 2: 拓展维度 (qwen3-32b) ──────────────────────────────
// 不再写死 2 个. 让 LLM 自己看问题决定拆几个 — 简单问题 2-3 个, 复杂问题 4-6 个.
async function expansionAgent(intent) {
  const SYSTEM = `你把用户的问题拆成几个独立的搜索维度. 每个维度对应一个 worker, 并行去库里召回相关答主.

【拆几个维度】
- 简单问题 (单一明确诉求, 如"168 间歇断食真的有效吗") → 2-3 个就够
- 中等问题 (多个角度, 如"如何减肥") → 3-5 个 (饮食法 / 运动 / 药物 / 心理 / 等等)
- 复杂问题 (含特殊人群 / 多重约束, 如"13 岁青春期女儿减肥") → 4-6 个
- 维度上限 6 个 (再多就重复)
- 维度下限 2 个 (再少就没并行价值)

【拆维度的原则】
- 维度之间不重叠 — 每个维度的子问题应该独立可答
- 每个维度对应用户问题的一个独立切面 (不要简单按"先 / 后 / 总结" 那种结构性分)
- name 用 2-5 字 (例: "饮食法" / "药物选择" / "青少年禁忌" / "运动方式")
- subQuestion 是这个维度具体在问什么 (一句话)
- zhihuQueries 是用做内部检索的关键词, 2-4 个

输出严格 JSON: {"dimensions":[{"name":"维度名","subQuestion":"子问题","zhihuQueries":["q1","q2"]}]}`;
  const userMsg = `用户意图: ${intent.surfaceGoal || ''}
根概念: ${intent.rootTopic || ''}
联网相关词 (供参考, 不强制): ${(intent.relatedTerms || intent.popularMethods || []).slice(0, 8).join(' / ')}

按你的判断拆维度. 简单问题少, 复杂问题多.`;
  const { content, usage } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
    { role: 'system', content: SYSTEM },
    { role: 'user',   content: userMsg },
  ], { temperature: 0.4, maxTokens: 800 });
  return { ...parseJSON(content), _usage: usage };
}

// ─── 演示 (打印一次完整 pipeline 到 console) ─────────────────────
async function runDemo(question) {
  // eslint-disable-next-line no-console
  console.group(`%c[镜像回答 pipeline] ${question}`, 'color:#1772F6;font-weight:600');
  const intent = await intentAgent(question);
  console.log('① intentAgent →', intent);
  const expansion = await expansionAgent(intent);
  console.log('② expansionAgent →', expansion);
  console.log('③ searchAgent (省略实现, 真实环境会对每个 dimension 调用搜索 + 答主匹配)');
  console.log('④ assembler → 组装生成式 UI 面板 + AI 解释 (复用 EvalPanel 多份)');
  console.groupEnd();
  return { intent, expansion };
}

// ─── LLM stub: hand-crafted示例, 把真实 LLM 暂时替换掉 ──────────
const LLM_STUB = {
  intent(q) {
    // 模式匹配几个典型问句, 不命中就退回通用解析
    if (q.includes('减肥') || q.includes('减脂') || q.includes('瘦')) {
      return {
        surfaceGoal:      '想减重 / 想瘦下来',
        deepGoal:         '审美驱动 + 社交压力 + 健康担忧 三者通常混合',
        emotionalDrivers: ['对外貌焦虑', '社交比较', '对身体失控的恐慌'],
        urgency:          q.includes('司美格鲁肽') || q.includes('最新') ? 'high' : 'medium',
        rootTopic:        '减肥',
      };
    }
    if (q.includes('AI') || q.includes('模型')) {
      return {
        surfaceGoal:      '想知道当前最优 AI 工具',
        deepGoal:         '工作/学习场景里选型, 避免选错踩坑',
        emotionalDrivers: ['怕落伍', '怕花冤枉钱'],
        urgency:          'high',
        rootTopic:        'AI 模型',
      };
    }
    // 通用 fallback
    return {
      surfaceGoal:      q,
      deepGoal:         '需要进一步澄清',
      emotionalDrivers: [],
      urgency:          'medium',
      rootTopic:        q.slice(0, 8),
    };
  },

  expansion(intent) {
    // 按 rootTopic 返回不同的维度拓展
    if (intent.rootTopic === '减肥') {
      return {
        dimensions: [
          { name: '药物方案',  searchQuery: '司美格鲁肽 二甲双胍 替尔泊肽 最新',
            priority: intent.urgency === 'high' ? 1 : 2,
            why: '2024-2025 是减肥药新分子集中获批期, 时效性高的问题必须先查这条' },
          { name: '饮食协议',  searchQuery: '168 间歇性断食 低碳 代餐 临床',
            priority: 2,
            why: '减肥本质是热量缺口, 饮食是最直接的杠杆' },
          { name: '运动选择',  searchQuery: '减脂 HIIT 力量训练 跑步 关节',
            priority: 3,
            why: '运动只占减脂效率 30%, 但决定身材形态和长期可持续性' },
          { name: '心理结构',  searchQuery: '减脂 暴食 复胖 限制循环',
            priority: 3,
            why: '5 年内 80% 减重者复胖, 心理而非生理是关键变量' },
          { name: '评估指标',  searchQuery: 'BMI 体脂率 TOFI 内脏脂肪',
            priority: 4,
            why: '判断需不需要减、减到哪里' },
          { name: '极端体验',  searchQuery: '减重手术 极速节食 长期反弹',
            priority: 5,
            why: '边界案例, 一般人用不到但能校准期望' },
        ],
      };
    }
    if (intent.rootTopic === 'AI 模型') {
      return {
        dimensions: [
          { name: '最新发布',  searchQuery: 'Claude Opus 4 GPT-5 Gemini 2 最新',
            priority: 1, why: '时效性问题, 半年前的答案直接失效' },
          { name: '中文能力',  searchQuery: '中文 知识 推理 评测',
            priority: 2, why: '国内用户场景核心' },
          { name: '上下文+成本',  searchQuery: '上下文窗口 价格 token',
            priority: 3, why: '长文档 / 长对话 性能与成本权衡' },
          { name: '工具生态',  searchQuery: 'agent api 插件',
            priority: 4, why: '可用性 = 单模型能力 × 工具配套' },
        ],
      };
    }
    // 通用 fallback
    return {
      dimensions: [
        { name: '基础概念', searchQuery: intent.rootTopic + ' 是什么',
          priority: 1, why: '澄清定义' },
        { name: '最新进展', searchQuery: intent.rootTopic + ' 2025',
          priority: 2, why: '时效性' },
      ],
    };
  },
};

// ─── Mock library retrieval (代真 Zhihu API 用) ────────────────
// 真生产里这一步是调用知乎搜索 + 拉取答案; 演示里用我们 14 道题 +
// ~120 条答案做 deterministic keyword matching.
//
// 语义拓展不在 JS 里写死字典 — 由 intentAgent 联网真搜回来的 webSearchText
// 提供 (用户问 "减肥药", web 返回 "司美格鲁肽/奥利司他/代餐" 等具体产品名).
//
// extras = { query, rootTopic, relatedTerms[], webSearchText }
function searchMockLibrary(zhihuQueries = [], popularMethods = [], extras = {}) {
  if (typeof ANSWERS === 'undefined' || typeof QUESTIONS === 'undefined') return [];
  const tokens = new Set();
  const addStr = (str) => {
    if (!str || typeof str !== 'string') return;
    const s = str.trim().toLowerCase();
    if (s.length >= 2) tokens.add(s);
    // 按分隔符切
    for (const t of s.split(/[\s,，、。.?？!！:：;；()（）\[\]\-—~"'""''`「」《》<>\/\\]+/)) {
      if (t.length >= 2) tokens.add(t);
      // 中文 token (无空格的长串) 用 3-4 字滑窗 — 这样 web snippet 里的
      // "司美格鲁肽是常见减肥药" 能跟库里 "司美格鲁肽" 子串匹配, 不靠词典.
      if (t.length >= 4 && /^[一-龥]+$/.test(t)) {
        for (let len = 3; len <= 4; len++) {
          for (let i = 0; i + len <= t.length; i++) {
            tokens.add(t.slice(i, i + len));
          }
        }
      }
    }
  };
  for (const q of zhihuQueries) addStr(q);
  for (const m of popularMethods) addStr(m);
  addStr(extras.query);
  addStr(extras.rootTopic);
  addStr(extras.surfaceGoal);
  // intent 联网搜来的具体产品/方法名 (LLM 从 web 抓的)
  if (Array.isArray(extras.relatedTerms)) for (const t of extras.relatedTerms) addStr(t);
  // web 搜索返回的所有 snippet 文本 — 直接作为 token 池, 库里答案能 substring
  // 命中这些 snippet 里出现的真实词 (司美格鲁肽 / 代餐 / 奥利司他 等)
  if (extras.webSearchText) addStr(extras.webSearchText);
  const scored = [];
  for (const qid in ANSWERS) {
    const q = QUESTIONS[qid];
    if (!q) continue;
    const titleLower = q.title.toLowerCase();
    for (const a of ANSWERS[qid]) {
      const bodyLower = a.body.toLowerCase();
      let s = 0;
      for (const t of tokens) {
        if (titleLower.includes(t)) s += 2;
        if (bodyLower.includes(t)) s += 1;
      }
      if (s > 0) scored.push({ ...a, qid, qTitle: q.title, _matchScore: s });
    }
  }
  scored.sort((a, b) => b._matchScore - a._matchScore);
  // 多样性约束: 每题最多 8 条进候选池 (库里现在每题 20+ 答案, 主热题应该
  // 让 Worker 看到足够多再筛 95+). 总池上限 20 条, 让 Worker 有取舍空间.
  const seenAnsId = new Set();
  const perQid = new Map();
  const out = [];
  for (const c of scored) {
    if (seenAnsId.has(c.id)) continue;
    if ((perQid.get(c.qid) || 0) >= 8) continue;
    seenAnsId.add(c.id);
    perQid.set(c.qid, (perQid.get(c.qid) || 0) + 1);
    out.push(c);
    if (out.length >= 20) break;
  }
  return out;
}

// ─── Worker (按维度并发) ───────────────────────────────────────
//
// 重构后的 Worker 做"维度内相对排序", 不再做"全句绝对判断".
// 关键认知 (经验证 5 个 query 拿到的): 当用户问的是"13 岁女儿减肥",
// 库里没有任何答案直接写过青少年, 但有大量答案写过"营养原则 / 力量训练 /
// 普拉提 / 限制-暴食循环", 这些原则**可以迁移**到青春期女孩.
// 旧 Worker 问 "这条解决了用户问题吗?" → 全部 no → 空集.
// 新 Worker 问 "这条对 [维度 X] 这个切面有没有用?" → 永远有 top N.
//
async function workerAgent(originalQuestion, intent, subTask) {
  const candidates = searchMockLibrary(
    subTask.zhihuQueries || [],
    intent.popularMethods || intent.relatedTerms || [],
    {
      query:          originalQuestion,
      rootTopic:      intent.rootTopic,
      surfaceGoal:    intent.surfaceGoal,
      relatedTerms:   intent.relatedTerms || [],
      webSearchText:  intent.webSearchText || '',
    }
  );
  if (candidates.length === 0) return { dimension: subTask.name, subQuestion: subTask.subQuestion, candidates: [] };
  const SYSTEM = `你给一批候选答案做"维度内相对排序", 不是"是否解决用户问题"的绝对判断.

═══════════════════════════════════════════════
【最高优先级硬规则: 先判主题, 再判维度】
═══════════════════════════════════════════════
判断顺序:
1) 第一步, 看主题: 这条候选答案的主题 (它原本在讨论什么), 跟用户问题的主题, 是不是同一类的?
   - 同一类话题 (例: 用户问减肥, 答案讲 GLP-1 / 节食 / 运动) → 继续走第 2 步
   - 完全不同主题 (例: 用户问国际政治 / 房产 / 编程, 但答案讲减肥药) → dimensionFit ≤ 20, 写明"主题不符: 答案在讲 X, 用户问的是 Y", 不要因为某个字面词 (比如"经济利益") 在两个领域都出现而强行加分
2) 主题同类后, 再做维度内相对排序: 这条答案在 [当前维度] 这个切面上, 对组装答复有多大贡献.

═══════════════════════════════════════════════
【dimensionFit 尺度 (主题同类前提下)】
═══════════════════════════════════════════════
- 88-95: 这批里最直接对应该维度的
- 65-80: 中等贡献
- 45-60: 偏题但还沾边
- 25-44: 维度上几乎没贡献, 或段子类
- ≤ 20: 主题完全不同 (硬规则触发)

【迁移规则 — 主题同类前提下适用】
主题同类时, 一条写给一般成人的答案如果原则可以迁移到用户的具体场景, 在该维度上排前面是合理的.
例: 用户问 13 岁女儿减肥 (主题同 = 减肥), 一条母婴营养师写给哺乳期女性的"蛋白+钙+铁"配方, 主题同类 + 原则可迁移 → "健康饮食" 维度上可以高分.
反例: 用户问特朗普访华 (主题 = 国际政治), 一条讲司美格鲁肽药物经济性的答案, **主题完全不同**, 字面上"经济性" 词重合不能掩盖主题不同 → dimensionFit ≤ 20.

只输出 JSON: {"ranked":[{"idx":0,"dimensionFit":92,"reasoning":"一句话, 先说主题是否同类, 再说在该维度有何贡献","keywords":["关键词1","关键词2","关键词3"]},{"idx":3,"dimensionFit":88,...}]}

所有 ${candidates.length} 条都要出现在 ranked 数组里, 按 dimensionFit 降序排列.`;
  const userMsg =
    `用户原题: ${originalQuestion}\n` +
    `用户问题主题 (rootTopic): ${intent.rootTopic || '(未知)'}\n` +
    `用户意图: ${intent.surfaceGoal || ''}\n` +
    `═══ 当前维度: ${subTask.name} ═══\n` +
    `子问题 (这个维度具体在问什么): ${subTask.subQuestion || subTask.name}\n\n` +
    `候选共 ${candidates.length} 条 (前 160 字, 题目 = 答主原本回答的问题, 是判断"答案主题"的依据):\n` +
    candidates.map((c, i) => `[${i}] 题: ${c.qTitle}${c.category === 'witty' ? ' (段子类)' : ''}\n    正文: ${c.body.slice(0, 160)}`).join('\n\n') +
    `\n\n请把上面 ${candidates.length} 条 (idx 0 到 ${candidates.length - 1}) 全部排序输出. 先判主题是否同类 — 主题不同的一律 ≤ 20.`;
  // max_tokens 按候选数量动态调 — 每条评分约 180 token.
  const workerMaxTokens = Math.max(900, candidates.length * 200);
  const { content } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
    { role: 'system', content: SYSTEM },
    { role: 'user',   content: userMsg },
  ], { temperature: 0.25, maxTokens: workerMaxTokens });
  let parsed;
  try { parsed = parseJSON(content); } catch (e) {
    console.warn('[worker]', subTask.name, 'JSON parse failed:', e.message, 'content tail:', content.slice(-200));
    return { dimension: subTask.name, subQuestion: subTask.subQuestion, candidates: [] };
  }
  // 兼容: 旧 schema 用 scored, 新 schema 用 ranked
  const rawList = parsed.ranked || parsed.scored || [];
  const ranked = rawList
    .filter(s => s && Number.isInteger(s.idx) && s.idx >= 0 && s.idx < candidates.length)
    .map(s => {
      const c = candidates[s.idx];
      // 优先取 dimensionFit, 兼容旧 schema 用 answersQuestion / relevance
      const fit = s.dimensionFit != null ? s.dimensionFit :
                  s.answersQuestion != null ? s.answersQuestion :
                  s.relevance != null ? s.relevance : 50;
      return {
        ...c,
        dimension: subTask.name,
        dimensionFit: fit,
        // 向后兼容下游 (mirror-answer.jsx 等还在读这两个字段)
        relevance: fit,
        answersQuestion: fit,
        reasoning: s.reasoning,
        keywords: s.keywords,
      };
    })
    .sort((a, b) => (b.dimensionFit ?? 0) - (a.dimensionFit ?? 0));
  return { dimension: subTask.name, subQuestion: subTask.subQuestion, candidates: ranked };
}

// ─── Judge (per-dim top N + cross-dim dedup + composite 排序) ────
//
// 新方法论 (经第三方 Agent 验证 5 个 query 通过):
// 1) 每个维度独立取 top 3 (基于 Worker 的 dimensionFit 相对排名)
// 2) 跨维度合并 + 去重: 一条答案可能同时是多个维度的 top, 保留所有维度标签
// 3) composite 用于跨维度排序时打破平局: dimensionFit 主导 (50%), 其他作支撑
// 4) 输出 byDimension 给 Synthesizer 用 — 可以让"刘看山" 按维度组织答复, 并
//    标注哪些维度库里没直接命中 (诚实告诉用户)
//
// 入参变化: 接受 perDimensionResults (每维度独立 ranked), 不再接受 flat list.
//
async function judgeAgent(originalQuestion, intent, perDimensionResults, topicAvgVotes) {
  if (!perDimensionResults || perDimensionResults.length === 0) {
    return { qualifying: [], qualifyingCount: 0, byDimension: [], outOfScope: false };
  }

  // ─── Step 1: 每维度取 top 3 ─────────────────────────────────
  const PER_DIM_TOP_N = 3;
  const MIN_FIT_FOR_CONTENT = 60; // 这个维度的 top 1 < 60 算"维度盲区"
  const byDimension = perDimensionResults.map(r => {
    const sorted = (r.candidates || []).slice().sort((a, b) => (b.dimensionFit ?? 0) - (a.dimensionFit ?? 0));
    const topAnswers = sorted.slice(0, PER_DIM_TOP_N);
    const hasContent = topAnswers.length > 0 && (topAnswers[0]?.dimensionFit ?? 0) >= MIN_FIT_FOR_CONTENT;
    return {
      dimension: r.dimension,
      subQuestion: r.subQuestion || '',
      topAnswers,
      hasContent,
      topFit: topAnswers[0]?.dimensionFit ?? 0,
    };
  });

  // ─── 主题超范围检测 ───────────────────────────────────────────
  // 两个独立信号都要看:
  // (a) Intent agent 显式说 isInScope=false (用户问的主题跟题库根本不沾边)
  // (b) 所有维度 topFit 都 < 40 (Worker 集体认为这堆候选跟问题主题不同)
  // 任何一个触发, 直接走 state C, 不让 Synthesizer 强行拼答案.
  const intentSaysOutOfScope = intent && intent.isInScope === false;
  const allDimsLowFit = byDimension.length > 0 && byDimension.every(d => (d.topFit ?? 0) < 40);
  if (intentSaysOutOfScope || allDimsLowFit) {
    return {
      qualifying: [],
      qualifyingCount: 0,
      byDimension,
      outOfScope: true,
      outOfScopeReason: intentSaysOutOfScope
        ? 'intent: rootTopic 不在题库范围 (' + (intent.rootTopic || '?') + ')'
        : 'workers: 所有维度 topFit < 40, 候选与问题主题不符',
    };
  }

  // ─── Step 2: 跨维度合并 + 去重 (保留所有维度标签) ─────────
  const seen = new Map();
  for (const dim of byDimension) {
    for (const c of dim.topAnswers) {
      if (!c || !c.id) continue;
      if (!seen.has(c.id)) {
        seen.set(c.id, {
          ...c,
          dimensions: [dim.dimension],
          maxDimensionFit: c.dimensionFit ?? 0,
        });
      } else {
        const existing = seen.get(c.id);
        if (!existing.dimensions.includes(dim.dimension)) existing.dimensions.push(dim.dimension);
        if ((c.dimensionFit ?? 0) > existing.maxDimensionFit) {
          existing.maxDimensionFit = c.dimensionFit;
        }
      }
    }
  }
  const merged = Array.from(seen.values());
  if (merged.length === 0) {
    return { qualifying: [], qualifyingCount: 0, byDimension, outOfScope: false };
  }

  // ─── Step 3: composite (dimensionFit 主导, 其他作支撑) ─────
  // 权重: dimensionFit 50% + authority 20% + votes 15% + recency 15%
  const computed = merged.map(c => {
    const months = monthsSinceAgent(c.publishDate);
    const persona = (typeof getPersona === 'function') ? getPersona(c.authorId) : null;
    const fit = c.maxDimensionFit ?? 0;
    const authorityScore =
      persona?.credential === 'verified' ? 95 :
      persona?.credential === 'kol'      ? 82 :
      persona?.credential === 'expert'   ? 68 :
      persona?.badges?.length > 0        ? 55 : 38;
    const votesScore = c.voteUp > 0 ? Math.min(100, Math.log10(c.voteUp + 1) * 25) : 0;
    const recencyScore = months < 3  ? 100 : months < 6  ? 90 :
                         months < 12 ? 78  : months < 24 ? 60 : 35;
    const composite = Math.round(
      fit * 0.50 + authorityScore * 0.20 + votesScore * 0.15 + recencyScore * 0.15
    );
    return {
      ...c,
      composite,
      dimensionFit: fit,
      relevance: fit, answersQuestion: fit,
      _persona: persona,
    };
  });
  computed.sort((a, b) => b.composite - a.composite);

  // 不再用 95+ / 85+ 死阈值过滤 — top per-dim 已经保证内容质量,
  // 如果 Worker 觉得某维度全是垃圾 (top fit < 60), Judge 会把它标成
  // hasContent=false, 让 Synthesizer 自己决定如何 surface 这个盲区.
  // 至于 final qualifying, 我们丢弃 composite < 55 的 (兜底兜得不像样的).
  const qualifying = computed.filter(c => c.composite >= 55);
  if (qualifying.length === 0) {
    return { qualifying: [], qualifyingCount: 0, byDimension, outOfScope: false };
  }

  // ─── 顺手让 LLM 给每条写"为什么推荐"的三段, 加 caveat (覆盖所有 qualifying) ────
  // 新 schema 跟旧 schema 不同 — 不再写"答主背景 / 数据证据 / 相似点",
  // 改成"这条具体讲了什么 / 对你为什么有用 / 但要注意什么".
  // 重点是 caveat: 必须点出这条不完全契合用户场景的地方, 不能跳过.
  //
  // **不再 slice(0,8)** — 用户反馈 12 条候选只有前 3 条有正式推荐, 后面全套模板.
  // 根因: 旧 8 条上限 + LLM 单次 max_tokens 截断. 现在改成: 全部 qualifying 都写,
  // 但分 chunk (每 chunk 6 条) 并行调 LLM, 避免单次 token 太多被截断.
  const recSys = `你给每条答案写一段"为什么向用户推荐这条" 的解释. 输出 3 段, 每段独立一个角度:

【core_point — 这条具体讲了什么】
不要套答主背景的话术 ("某某医生在 X 领域工作 N 年"). 直接说**这条答案的核心观点 / 关键数据 / 具体做法**.
例: "答主给出了 GLP-1 用药期间的蛋白质阈值 — 1.6-2.0g/kg, 配合每周 2-3 次抗阻训练, 肌肉量保持率提高 47%."
40-70 字, 必须具体, 不能宽泛.

【why_fits — 对用户这个具体场景为什么有用】
把上面的 core_point 跟用户的具体处境对位. 不要说"对你有借鉴价值" 这种废话.
例: "你正在帮 13 岁女儿减重, 这条的关键不是药物本身, 而是它把蛋白质量化目标讲清楚了 — 青春期女孩同样在快速增肌成骨期, 蛋白阈值可以直接搬, 别一节食就把蛋白也砍了."
50-80 字.

【caveat — 但要注意 (必填, 不能跳过)】
这条答案不完全契合用户场景的地方 + 用的时候要警惕什么具体的点. 必须有, 这是写好推荐的关键.
例: "但答主是写给成人 GLP-1 用药者的, 抗阻训练强度 (大重量复合动作) 不能直接搬给 13 岁女孩. 给青春期女孩做力量训练, 用器械或弹力带起步, 8-12 次力竭就停, 不追大重量."
50-80 字.

【硬要求】
- 每条 recommendation 必须三段都填, caveat 不能为空字符串
- 不写"答主名 + 一个标签"这种公式化开头
- 引用答主原话里的具体数字 / 名词 / 做法, 不要换成自己的话
- 用第二人称"你"称呼用户
- 每一条候选都要写, 不能漏

只输出 JSON:
{"recommendations":[{"id":"answerId","recommendation":{"core_point":"...","why_fits":"...","caveat":"..."}}]}`;

  // 分 chunk 并行调, 每 chunk 6 条 — 这样单次 LLM 输出 ~2400 tokens 内, 不会被截断;
  // 多 chunk 并行总耗时跟 1 个 chunk 差不多.
  const CHUNK_SIZE = 6;
  const chunks = [];
  for (let i = 0; i < qualifying.length; i += CHUNK_SIZE) {
    chunks.push(qualifying.slice(i, i + CHUNK_SIZE));
  }
  const recMap = new Map();
  const runChunk = async (chunk) => {
    const recUser = `用户原题: ${originalQuestion}
用户场景关键词 (rootTopic): ${intent.rootTopic || '(未知)'}
用户意图: ${intent.surfaceGoal || ''}

候选 (含原帖标题 = 答主原本回答的问题, 是判断"是否完全契合用户场景"的依据):
` +
      chunk.map(c => {
        const p = c._persona;
        return `id:${c.id}|答主:${p?.name || '匿名'}${p?.credential ? '(' + p.credential + ')' : ''}|原帖题:${c.qTitle || '?'}|赞同:${c.voteUp}|正文 (前 200 字):${(c.body || '').slice(0, 200)}`;
      }).join('\n\n') +
      `\n\n以上共 ${chunk.length} 条, 请把每一条都写齐, 不要漏.`;
    try {
      const recMaxTokens = Math.max(1800, chunk.length * 500);
      const { content } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
        { role: 'system', content: recSys },
        { role: 'user',   content: recUser },
      ], { temperature: 0.4, maxTokens: recMaxTokens });
      const parsed = parseJSON(content);
      for (const r of parsed.recommendations || []) {
        if (r.id && r.recommendation) recMap.set(r.id, r.recommendation);
      }
    } catch (e) {
      console.warn('[judge] recommendation chunk 失败:', e.message);
    }
  };
  await Promise.all(chunks.map(runChunk));

  const qualifyingWithRec = qualifying.map(c => ({ ...c, recommendation: recMap.get(c.id) || null }));

  // 把 byDimension 里的 topAnswers 也更新成带 recommendation 的版本, 保持引用一致
  const recIdMap = new Map(qualifyingWithRec.map(c => [c.id, c]));
  const byDimEnriched = byDimension.map(d => ({
    ...d,
    topAnswers: d.topAnswers.map(c => recIdMap.get(c.id) || c),
  }));

  return {
    qualifying: qualifyingWithRec,
    qualifyingCount: qualifyingWithRec.length,
    byDimension: byDimEnriched,
  };
}

// ─── Synthesizer (刘看山的总结) ────────────────────────────────
//
// 新版接受 byDimension (维度结构化) + qualifying (跨维度排序后的列表).
// 输出两段:
// - section_synthesis: 按维度归纳, 每维度引用 top 答主名 + 关键证据
// - section_supplement: (a) 维度盲区 — 哪些维度库里没直接命中, 是从原则迁移的;
//                       (b) 联网搜到的相关词答主没提到的, 提示用户额外搜
//
async function synthesizerAgent(originalQuestion, intent, judgeResult, scoutFindings, relatedTerms = []) {
  const qualifying = judgeResult.qualifying || [];
  const byDimension = judgeResult.byDimension || [];
  const isOutOfScope = judgeResult.outOfScope === true;
  const state = qualifying.length >= 5 ? 'A' : qualifying.length >= 1 ? 'B' : 'C';

  // 维度盲区: top fit < 60 的维度, Synthesizer 要诚实告诉用户"这块库里没人直接写"
  const gapDimensions = byDimension.filter(d => !d.hasContent).map(d => d.dimension);
  const liveDimensions = byDimension.filter(d => d.hasContent);

  // ─── 主题超范围: 直接走快速分支, 不让 LLM 硬扯 ───────────────
  // synthesis = null (镜像主区不需要"我的看法"), supplement 让 LLM 写一段
  // 自然的"虽然没人正面回答过, 但我联网看到 X/Y" — 不要"客服"腔调.
  if (isOutOfScope) {
    const oosUserMsg =
      `用户问题: ${originalQuestion}\n\n` +
      `联网搜到的相关词: ${(relatedTerms || []).slice(0, 10).join(' / ') || '(无)'}\n` +
      `联网搜到的事实摘要: ${scoutFindings || '(无)'}\n\n` +
      `请你 (扮演刘看山) 写一句自然的"补充":\n` +
      `- 开头一句"虽然我们暂时没人正面回答过这个" (或同义变体)\n` +
      `- 后面接"但我联网看到 [关键词 1] / [关键词 2] 这几个相关词反复出现, 你可以再搜一下"\n` +
      `- 关键词从上面 relatedTerms 里挑 2-3 个最具体的名词 (产品/方法/概念)\n` +
      `- 不要说"题库", 不要说"答主题库", 不要说"我擅长 X / 不擅长 Y" — 用自然的中文\n` +
      `- 不要"综合归纳", 不要硬找类比\n` +
      `- 50-90 字, 第一人称"我"`;
    const oosSys = `你扮演刘看山. 用户问了一个我们答主库里暂时没人正面写过的问题. 给一句自然的补充, 不要"客服"腔调. 只输出 JSON: {"section_synthesis":null,"section_supplement":"一段 50-90 字的自然话"}`;
    try {
      const { content } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
        { role: 'system', content: oosSys },
        { role: 'user',   content: oosUserMsg },
      ], { temperature: 0.55, maxTokens: 350 });
      const parsed = parseJSON(content);
      return {
        section_synthesis: null,
        section_supplement: parsed.section_supplement || `虽然我们暂时没人正面回答过这个, 但我联网看到 ${(relatedTerms || []).slice(0, 2).join(' / ') || '一些相关方向'} 这几个词反复出现, 你可以再搜一下.`,
      };
    } catch (e) {
      return {
        section_synthesis: null,
        section_supplement: `虽然我们暂时没人正面回答过这个, 但我联网看到 ${(relatedTerms || []).slice(0, 2).join(' / ') || '一些相关方向'} 这几个词反复出现, 你可以再搜一下.`,
      };
    }
  }

  const SYSTEM = `你扮演刘看山 — 镜像回答的总结者. 输出**两段**, 必须严格分开.

═══════════════════════════════════════════════════
【section_synthesis — 按维度归纳 + 我的建议】
═══════════════════════════════════════════════════
我们把用户的问题拆成了 N 个维度, 每个维度从答主库里挑出了 top 答案.
你的任务: 按维度组织综合归纳, 让用户看清楚"每个切面上, 答主们的共识是什么".

写作模板, 第一人称, 150-220 字:
- 开头一句"我的整体看法": 直接表态 (不能两边都对)
- 中间按维度组织, 每个维度 1-2 句: 引用 1-2 位具体答主名 (从下面的列表里挑) + 他们的关键证据 + 在用户场景下怎么用
- 末尾一句"我建议你": 一句 actionable 的话, 像朋友推荐

状态 C (0 答主): 此字段 = null

═══════════════════════════════════════════════════
【section_supplement — 维度盲区 + 联网补充】
═══════════════════════════════════════════════════
两个目的, 任选一个或都写:
(a) 维度盲区: 如果有些维度库里没人直接写过 (gapDimensions), 诚实告诉用户"关于 X 维度, 答主库里没人正面写过, 上面 X 维度的建议是从相关原则迁移的, 你可以另外联网搜一下"
(b) 联网补充: 联网搜到的相关词答主都没提到的, 提醒用户"答主没讲到 Y / Z, 我联网看到这些挺新的, 你可以再搜"

要求:
- 80-130 字
- 必须列具体名词 (维度名 / 产品 / 方法), 从 gapDimensions / relatedTerms / scoutFindings 里挑
- 不编造网上没出现的概念
- 第一人称, 像"刘看山补充"
- 无论 A / B / C 状态都必须输出

【硬约束】
- 必须有判断, 不能两边都对
- 不感叹号, 不"强烈推荐", 不"小贴士"
- 不假装亲历
- 全程第一人称"我"

只输出 JSON: {"section_synthesis":"或null","section_supplement":"或null"}`;

  // 计算哪些 relatedTerms 答主"没怎么提到" — 简单包含匹配
  const allBodyText = qualifying.map(c => c.body || '').join(' ');
  const notMentioned = (relatedTerms || []).filter(t => t && !allBodyText.includes(t));

  // 把维度结构用文字呈现给 LLM
  const dimSection = liveDimensions.length === 0
    ? '(本次所有维度的命中度都偏低, 没有形成有效维度组织)\n'
    : liveDimensions.map(d => {
        const lines = d.topAnswers.slice(0, 2).map((c, i) => {
          const p = c._persona;
          const cred = p?.credential ? ` (${p.credential})` : '';
          return `    · ${p?.name || '匿名'}${cred} [赞 ${c.voteUp}]: ${(c.body || '').slice(0, 140)}…`;
        }).join('\n');
        return `维度: ${d.dimension}${d.subQuestion ? ' — ' + d.subQuestion : ''}\n${lines}`;
      }).join('\n\n');

  const userMsg =
    `用户原题: ${originalQuestion}\n` +
    `用户意图: ${intent.surfaceGoal || ''}\n` +
    `状态: ${state} (qualifying ${qualifying.length} 条, 维度 ${byDimension.length} 个, 盲区 ${gapDimensions.length} 个)\n\n` +
    `═══ 按维度的 top 答主 ═══\n${dimSection}\n\n` +
    `═══ 维度盲区 (这些维度库里没正面命中) ═══\n${gapDimensions.length > 0 ? gapDimensions.join(' / ') : '(无 - 所有维度都有命中)'}\n\n` +
    `═══ 联网搜到的相关词 ═══\n${(relatedTerms || []).join(' / ') || '(无)'}\n\n` +
    `═══ 联网事实摘要 ═══\n${scoutFindings || '(无)'}\n\n` +
    `**优先写的盲区维度名 + 答主没提到的联网词**:\n${[...gapDimensions, ...notMentioned].join(' / ') || '(答主已覆盖)'}`;

  const { content } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
    { role: 'system', content: SYSTEM },
    { role: 'user',   content: userMsg },
  ], { temperature: 0.6, maxTokens: 900 });
  try { return parseJSON(content); }
  catch { return { section_synthesis: null, section_supplement: null }; }
}

function monthsSinceAgent(dateStr) {
  if (!dateStr) return 99;
  const NOW_Y = 2026, NOW_M = 5;
  const [y, m] = String(dateStr).split('-').map(Number);
  if (!y || !m) return 99;
  return (NOW_Y - y) * 12 + (NOW_M - m);
}

// ─── Pipeline orchestrator ─────────────────────────────────────
// onProgress 每次都传一个 {phase, message, detail?} — detail 是给 loader
// 渲染"思考过程"用的, 比如 intent 拿到什么 / expansion 拆出几个维度.
async function runMirrorAnswerPipeline(question, opts = {}) {
  const onProgress = opts.onProgress || (() => {});
  // 1) intent + scout
  onProgress({ phase: 'intent', message: '在拆解你的问题…' });
  const intent = await intentAgent(question);
  onProgress({
    phase: 'intent-done',
    message: '已联网, 锁定话题: ' + (intent.rootTopic || '未知'),
    detail: {
      rootTopic: intent.rootTopic,
      relatedTerms: intent.relatedTerms || intent.popularMethods,
      scoutFindings: intent.scoutFindings,
      sourceCount: (intent.sources || []).length,
    },
  });
  // 2) expansion
  onProgress({ phase: 'expansion', message: '拆解搜索维度…' });
  const expansion = await expansionAgent(intent);
  onProgress({
    phase: 'expansion-done',
    message: '已拆出 ' + (expansion.dimensions?.length || 0) + ' 个搜索方向',
    detail: { dimensions: (expansion.dimensions || []).map(d => d.name) },
  });
  // 2.5) 主题超范围 early-exit — 用户问的话题完全不沾减肥, 跳过 Workers 省 token
  let workerResults;
  if (intent.isInScope === false) {
    onProgress({
      phase: 'out-of-scope',
      message: '检测到话题不在题库范围 (' + (intent.rootTopic || '?') + '), 跳过答主检索',
    });
    // 给 Judge 一个空的 perDim 结构, intent.isInScope=false 它会自己走 outOfScope 分支
    workerResults = (expansion.dimensions || []).map(d => ({
      dimension: d.name, subQuestion: d.subQuestion, candidates: [],
    }));
  } else {
    // 3) workers (parallel) — 每个维度独立做"维度内相对排序"
    onProgress({ phase: 'workers', message: '在知乎库里按维度召集答主…' });
    workerResults = await Promise.all(
      (expansion.dimensions || []).map(d =>
        workerAgent(question, intent, d).catch(e => {
          console.warn('worker failed:', d.name, e.message);
          return { dimension: d.name, subQuestion: d.subQuestion, candidates: [] };
        })
      )
    );
    const totalCandidates = workerResults.reduce((s, r) => s + (r.candidates?.length || 0), 0);
    onProgress({
      phase: 'workers-done',
      message: '召集到 ' + totalCandidates + ' 条 (跨 ' + workerResults.length + ' 个维度)',
      detail: { candidatesByDim: workerResults.map(r => ({ name: r.dimension, n: r.candidates?.length || 0 })) },
    });
  }
  // 4) judge — 每个维度取 top 3, 跨维度去重 + composite 排序
  onProgress({ phase: 'judge', message: '每维度取 top, 综合排序…' });
  const sampleQid = workerResults.flatMap(r => r.candidates || [])[0]?.qid
    || (typeof QUESTIONS !== 'undefined' && Object.keys(QUESTIONS)[0]) || 'q_w07';
  const topicAvgVotes = (typeof getTopicAvg === 'function') ? getTopicAvg(sampleQid) : 1000;
  const judged = await judgeAgent(question, intent, workerResults, topicAvgVotes);
  // 5) synthesizer — 按维度组织 + 标注盲区
  onProgress({ phase: 'synth', message: '刘看山按维度汇总…' });
  const synth = await synthesizerAgent(question, intent, judged, intent.scoutFindings, intent.relatedTerms || []);
  onProgress({ phase: 'done' });
  return {
    query: question,
    state: judged.qualifyingCount >= 5 ? 'A' : judged.qualifyingCount >= 1 ? 'B' : 'C',
    intent,
    expansion,
    qualifying: judged.qualifying,
    qualifyingCount: judged.qualifyingCount,
    byDimension: judged.byDimension || [],
    outOfScope: judged.outOfScope === true,            // ← 主题超范围 (前端可以据此显示特殊空态)
    outOfScopeReason: judged.outOfScopeReason || null,
    pushedAuthors: judged.qualifyingCount === 0 ? 5 : 0,
    scoutFindings: intent.scoutFindings ? [intent.scoutFindings] : [],
    sources: intent.sources || [],          // ← 可点击的 web 来源, UI 渲染用
    liukanshanSummary: {
      sectionSynthesis: synth.section_synthesis,
      sectionSupplement: synth.section_supplement,
      sources: intent.sources || [],         // ← 同上, 给刘看山补充段挂可点链接
    },
  };
}

// ─── 快速 is-in-scope 检测 (无 Tavily, 单次 LLM ≤ 1s) ─────────────
// 在 AskPage 点"发布"那一刻 silently 调用, 判断用户问的题是不是减肥相关.
// 不在范围 → 走离题分支 (神秘专家流程). 用便宜的小调用是为了不卡 publish toast.
async function quickIsInScopeCheck(question) {
  const SYS = `判断用户问题是否跟"减肥/减重/减脂/体重管理/暴食/代餐/GLP-1 药物/孕产体重/儿童肥胖"等主题相关.
- 直接相关 → isInScope:true
- 完全无关 (例: 编程/政治/历史/娱乐/旅游/AI 模型/天文物理) → isInScope:false
- 拿不准 (擦边) → isInScope:true (默认放行)
只输出 JSON, 不要别的: {"isInScope":true|false}`;
  try {
    const { content } = await callLLM('qwen-3-235b-a22b-instruct-2507', [
      { role: 'system', content: SYS },
      { role: 'user',   content: question },
    ], { temperature: 0.1, maxTokens: 60 });
    const parsed = parseJSON(content);
    return parsed.isInScope !== false;
  } catch (e) {
    console.warn('[quickIsInScopeCheck] failed, defaulting in-scope:', e.message);
    return true;
  }
}

// ─── 爱因斯坦"被镜像系统召集来" 的回答 ─────────────────────────────
// 离题分支专属. 必须以"谢邀, 人在普林斯顿, 刚下飞机" 开头 (知乎经典梗),
// 第一人称, 允许用物理/相对论/光速类比用户的问题. 350-500 字.
async function generateEinsteinReply(question) {
  const SYSTEM = `你扮演爱因斯坦, 回答知乎的一个问题. 知乎口吻, 不像 AI.

═══════ 硬约束 ═══════
1) 必须以"谢邀, 人在普林斯顿, 刚下飞机" 开头 (这是知乎经典梗, 一字不改)
2) 第一人称"我", 像真的爱因斯坦本人在写答案
3) 允许用 相对论 / 光速 / E=mc² / 量子 / 参考系 / 测不准 等物理概念给用户的问题做**意外的类比**或**反直觉的洞察**
4) 允许自嘲 ("我虽然是物理学家, 但今天接了个 X 的题…")
5) 回答必须紧扣用户问的内容, 不能跑去聊纯物理

═══════ 文风 ═══════
- 350-500 字, 3-4 段, 段间空行
- 不写小标题 / 不用 markdown / 不用感叹号堆砌 / 不堆 emoji
- 不说"AI 综合 / 总而言之 / 由此可见" 这类 AI 总结词
- 自然的中文段落, 像真的写答案`;

  const USER = `═══ 用户在知乎问的问题 ═══
${question}

═══ 任务 ═══
你是爱因斯坦, 被知乎"镜像系统"召集过来回答这个问题 — 因为没有别的答主答过.
写 350-500 字 3-4 段, 开头必须"谢邀, 人在普林斯顿, 刚下飞机".
之后用物理学家的视角找一个意外的角度切入用户问题.`;

  try {
    const resp = await fetch('http://127.0.0.1:5174/llm', {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify({
        model: 'qwen-3-235b-a22b-instruct-2507',
        messages: [
          { role: 'system', content: SYSTEM },
          { role: 'user',   content: USER },
        ],
        temperature: 0.9,
        max_tokens: 900,
      }),
    });
    if (!resp.ok) throw new Error('http ' + resp.status);
    const data = await resp.json();
    const text = data?.choices?.[0]?.message?.content?.trim();
    if (text) return text;
  } catch (e) {
    console.warn('[generateEinsteinReply] LLM failed, fallback:', e.message);
  }
  return `谢邀, 人在普林斯顿, 刚下飞机.

说实话这题超出了我的本行, 不过既然被推到我这里, 我从一个物理学家的角度试着说几句 — 也许会有点意外的启发.

你问的这件事, 我看了一下其实就像我当年研究光速一样: 表面上是一个具体的小问题, 但你只要换一个参考系去看, 它就变成了另一回事. 大多数人卡在"应该怎么做" 里面, 是因为他们默认了一个固定的参考系; 但只要你愿意把自己"加速"到另一个状态里, 答案常常自己就浮出来了.

我这答得很不专业, 但我想说: 偶尔问问"边界外的问题" 比循规蹈矩有意思多了. 你这题, 我喜欢.`;
}

Object.assign(window, {
  intentAgent, expansionAgent, workerAgent, judgeAgent, synthesizerAgent,
  searchMockLibrary, runMirrorAnswerPipeline, runDemo,
  quickIsInScopeCheck, generateEinsteinReply,
});

// ─── DevTools 工具: 在 console 跑 __llmCost() 看本次会话烧了多少 token + ¥
// 价目 (2026 当前估算):
//   Cerebras  qwen3-235b: $0.65/1M input, $0.65/1M output (按 token 平均算 ¥4.7/1M)
//   DashScope qwen3-235b: ¥2/1M input, ¥6/1M output
window.__llmCost = function () {
  const usage = window.__llmUsage || {};
  if (!Object.keys(usage).length) {
    console.log('没有调用记录. 跑一次 demo 再来.');
    return null;
  }
  const PRICES = {
    cerebras:  { in: 0.65 / 1_000_000, out: 0.65 / 1_000_000, currency: 'USD' },
    dashscope: { in: 2.0  / 1_000_000, out: 6.0  / 1_000_000, currency: 'CNY' },
    groq:      { in: 0.05 / 1_000_000, out: 0.10 / 1_000_000, currency: 'USD' },
  };
  const rows = [];
  for (const [provider, u] of Object.entries(usage)) {
    const p = PRICES[provider] || { in: 0, out: 0, currency: '?' };
    const cost = u.prompt * p.in + u.completion * p.out;
    rows.push({
      provider,
      calls: u.calls,
      'tokens (in/out/total)': `${u.prompt} / ${u.completion} / ${u.total}`,
      'cost': `${cost.toFixed(4)} ${p.currency}`,
    });
  }
  console.table(rows);
  return rows;
};
// 清零
window.__llmReset = function () { window.__llmUsage = {}; console.log('计数器清零'); };
