'use strict'; /** * 智能问数智能体 · 入口与运行注册表 * ---------------------------------------------------------------------------- * startRun 立即返回运行记录(前端轮询),循环异步执行;成败都折叠进 rec, * 绝不向上抛(对齐 ai-fde 委派网关「永不抛出」的契约)。 * 双模式:quick = 复用 nl-query 两段式(省预算);agent = harness 循环(默认)。 * 运行态在内存(仿 syncState),终态落盘 data/agent/(trace.js)。 */ 'use strict'; const path = require('path'); const trace = require('./trace.js'); const loop = require('./loop.js'); const skills = require('./skills.js'); const replay = require('./replay.js'); const { mockReply } = require('./mock-model.js'); const { pickRow } = require('./tools/lake.js'); const nlQuery = require('../../nl-query.js'); const runs = new Map(); // runId → rec const MAX_CACHED = 60; function isMock() { return process.env.NAPOS_AGENT_MOCK === '1'; } const corpusFile = (entity) => path.join(trace.AGENT_DIR, trace.sanitizeKey(entity), 'corpus.json'); /** 快问自动分流:短问句且不含分析类词 → 快问(nl-query 两段式),否则深度分析 */ function pickMode(q) { if (String(q || '').length > 18) return 'agent'; if (/(为什么|怎么|为何|对比|环比|同比|变化|体检|对标|排名|归因|分析|趋势|影响|跨|哪家|哪些|问题)/.test(String(q || ''))) return 'agent'; return 'quick'; } function assertChatCfg(cfg) { for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) { if (!cfg[k]) { const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`); e.code = 'ASK_NO_MODEL'; throw e; } } } function trimCache() { if (runs.size <= MAX_CACHED) return; for (const [id, rec] of runs) { if (rec.status !== 'running') runs.delete(id); if (runs.size <= MAX_CACHED) break; } } /** * 发起一次运行并立即返回记录。 * @param {object} o { entity, question, mode: 'agent'|'quick', sessionId, cfg, svc } */ function startRun(o) { const { cfg, svc } = o; const q = String(o.question || '').trim(); if (!q) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; } if (!isMock()) assertChatCfg(cfg); const entity = trace.sanitizeKey(o.entity); if (!svc.store.getEntity(entity)) { const e = new Error('数据主体不存在,请刷新页面重试'); e.code = 'ASK_NO_ENTITY'; throw e; } const rec = trace.newRunRecord({ entity, entityName: (svc.store.getEntity(entity) || {}).name, question: q, mode: o.mode, sessionId: o.sessionId ? trace.sanitizeKey(o.sessionId) : undefined, }); const skill = skills.get(o.skill); if (skill) rec.skill = skill.key; // 自动分流:mode='auto'(前端默认档)按规则选快问/深度分析 if (o.mode === 'auto') { rec.mode = pickMode(q); rec.autoPicked = true; } runs.set(rec.runId, rec); trimCache(); trace.upsertSessionRun(entity, { sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: 'running', at: rec.startedAt, }); // 异步执行:路由已返回,前端轮询 GET /api/agent/runs/:id (async () => { try { if (isMock()) { // mock 模式:quick/agent 都走脚本化假模型(快问=直接 final);auto 已折算进 rec.mode rec.result = rec.mode === 'quick' ? { answer: '(mock 模型)快问链路正常。', chart: null, appliedFrom: null, thought: '' } : await loop.runAgent({ rec, svc, cfg, mock: { next: (r) => mockReply(r) } }); } else if (rec.mode === 'quick') { const history = trace.loadRecentConclusions(entity, rec.sessionId); const result = await nlQuery.ask({ q, entity, cfg, svc, history }); rec.result = { answer: result.reply, chart: result.chart && result.chart.type !== 'none' ? result.chart : null, appliedFrom: result.applied || null, thought: '', // 快问同样可一键打开当期 HTML 分析报告(区间取本次查询钳制后的区间) reportUrl: result.applied && result.applied.from && result.applied.to ? `/api/reports/report-html?${new URLSearchParams({ entity, from: result.applied.from, to: result.applied.to }).toString()}` : null, // 图表数据源(快问不走工具步骤,行随结果携带;去 raw 控体积) chartRows: (result.rows || []).slice(0, 200).map(pickRow), }; } else { rec.history = trace.loadRecentConclusions(entity, rec.sessionId); // 打法复用与 few-shot:真实模型模式下,从已验证语料命中相似问 let plan = null; let fewShotsText = ''; if (!o.skill && !isMock()) { try { plan = replay.findPlan(corpusFile(entity), q); if (!plan) { const shots = replay.fewShot(corpusFile(entity), q); if (shots.length) { fewShotsText = shots.map((s) => `- 问「${s.question}」→ ${s.steps.map((st) => st.tool).join(' → ')}`).join('\n'); rec.fewShots = shots.map((s) => s.question); } } } catch { /* 语料读写失败不影响正常问答 */ } } if (plan) rec.replayFrom = plan.question; rec.result = await loop.runAgent({ rec, svc, cfg, mock: null, cloud: o.cloud || null, plan, fewShotsText }); } rec.steps.push({ type: 'final', answer: rec.result.answer }); rec.status = 'done'; } catch (e) { if (e && e.code === 'AGENT_CANCELLED') rec.status = 'cancelled'; else { rec.status = 'failed'; rec.error = String((e && e.message) || e).slice(0, 500); } } finally { rec.finishedAt = new Date().toISOString(); rec.cancelRequested = false; trace.pushEvent(rec, 'run.end', { status: rec.status, error: rec.error }); // 成功且数字全部溯源的运行沉淀为已验证打法(相似问复用 / few-shot 语料) if (rec.status === 'done' && rec.mode === 'agent' && !isMock() && rec.provenance && rec.provenance.untracedCount === 0) { const steps = rec.steps .filter((s) => s.type === 'tool' && s.ok && replay.ALLOW_TOOLS.has(s.tool)) .map((s) => ({ tool: s.tool, args: s.args })); if (steps.length && steps.length <= 8) { try { replay.record(corpusFile(entity), { question: q, steps }); } catch { /* 语料失败不影响主流程 */ } } } trace.persistRun(rec); trace.upsertSessionRun(entity, { sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: rec.status, at: rec.finishedAt, }); } })(); return rec; } function cancelRun(runId) { const rec = runs.get(String(runId || '')); if (rec && rec.status === 'running') rec.cancelRequested = true; return rec || null; } /** 内存优先,落盘兜底(服务重启后仍可回放历史运行) */ function getRun(entity, runId) { const key = trace.sanitizeKey(entity); const id = String(runId || '').replace(/[^a-zA-Z0-9_-]/g, ''); const rec = runs.get(id); if (rec && rec.entity === key) return rec; const saved = trace.loadRun(key, id); return saved && saved.entity === key ? saved : null; } function listRuns(entity, sessionId, limit = 20) { const key = trace.sanitizeKey(entity); const session = trace.loadSessions(key).find((s) => s.sessionId === sessionId); return session ? session.runs.slice(0, limit) : []; } function listSessions(entity) { const key = trace.sanitizeKey(entity); return trace.loadSessions(key).map((s) => { const last = (s.runs || [])[0] || {}; return { sessionId: s.sessionId, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, runCount: (s.runs || []).length, lastQuestion: last.question || '', lastAt: last.at || s.updatedAt || s.createdAt || null, }; }); } function deleteSession(entity, sessionId) { const key = trace.sanitizeKey(entity); const id = String(sessionId || '').replace(/[^a-zA-Z0-9_-]/g, ''); return trace.deleteSession(key, id); } /** 答案反馈:采纳 → 打法强制入语料;纠错 → 反馈日志(供评测提炼) */ function feedback({ entity, runId, kind, note }) { const rec = getRun(entity, runId); if (!rec) return null; rec.feedback = { kind: kind === 'up' ? 'up' : 'down', note: String(note || '').slice(0, 300), at: new Date().toISOString() }; trace.persistRun(rec); if (rec.feedback.kind === 'up') { const steps = rec.steps .filter((s) => s.type === 'tool' && s.ok && replay.ALLOW_TOOLS.has(s.tool)) .map((s) => ({ tool: s.tool, args: s.args })); if (steps.length) { try { replay.record(corpusFile(rec.entity), { question: rec.question, steps }); } catch { /* ignore */ } } } else { trace.appendFeedbackLog(rec.entity, { at: rec.feedback.at, runId: rec.runId, question: rec.question, answer: (rec.result || {}).answer || '', note: rec.feedback.note, }); } return rec; } module.exports = { startRun, cancelRun, getRun, listRuns, listSessions, deleteSession, feedback };