index.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 入口与运行注册表
  4. * ----------------------------------------------------------------------------
  5. * startRun 立即返回运行记录(前端轮询),循环异步执行;成败都折叠进 rec,
  6. * 绝不向上抛(对齐 ai-fde 委派网关「永不抛出」的契约)。
  7. * 双模式:quick = 复用 nl-query 两段式(省预算);agent = harness 循环(默认)。
  8. * 运行态在内存(仿 syncState),终态落盘 data/agent/(trace.js)。
  9. */
  10. 'use strict';
  11. const path = require('path');
  12. const trace = require('./trace.js');
  13. const loop = require('./loop.js');
  14. const skills = require('./skills.js');
  15. const replay = require('./replay.js');
  16. const { mockReply } = require('./mock-model.js');
  17. const { pickRow } = require('./tools/lake.js');
  18. const nlQuery = require('../../nl-query.js');
  19. const runs = new Map(); // runId → rec
  20. const MAX_CACHED = 60;
  21. function isMock() { return process.env.NAPOS_AGENT_MOCK === '1'; }
  22. const corpusFile = (entity) => path.join(trace.AGENT_DIR, trace.sanitizeKey(entity), 'corpus.json');
  23. /** 快问自动分流:短问句且不含分析类词 → 快问(nl-query 两段式),否则深度分析 */
  24. function pickMode(q) {
  25. if (String(q || '').length > 18) return 'agent';
  26. if (/(为什么|怎么|为何|对比|环比|同比|变化|体检|对标|排名|归因|分析|趋势|影响|跨|哪家|哪些|问题)/.test(String(q || ''))) return 'agent';
  27. return 'quick';
  28. }
  29. function assertChatCfg(cfg) {
  30. for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) {
  31. if (!cfg[k]) {
  32. const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`);
  33. e.code = 'ASK_NO_MODEL';
  34. throw e;
  35. }
  36. }
  37. }
  38. function trimCache() {
  39. if (runs.size <= MAX_CACHED) return;
  40. for (const [id, rec] of runs) {
  41. if (rec.status !== 'running') runs.delete(id);
  42. if (runs.size <= MAX_CACHED) break;
  43. }
  44. }
  45. /**
  46. * 发起一次运行并立即返回记录。
  47. * @param {object} o { entity, question, mode: 'agent'|'quick', sessionId, cfg, svc }
  48. */
  49. function startRun(o) {
  50. const { cfg, svc } = o;
  51. const q = String(o.question || '').trim();
  52. if (!q) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; }
  53. if (!isMock()) assertChatCfg(cfg);
  54. const entity = trace.sanitizeKey(o.entity);
  55. if (!svc.store.getEntity(entity)) {
  56. const e = new Error('数据主体不存在,请刷新页面重试');
  57. e.code = 'ASK_NO_ENTITY';
  58. throw e;
  59. }
  60. const rec = trace.newRunRecord({
  61. entity,
  62. entityName: (svc.store.getEntity(entity) || {}).name,
  63. question: q,
  64. mode: o.mode,
  65. sessionId: o.sessionId ? trace.sanitizeKey(o.sessionId) : undefined,
  66. });
  67. const skill = skills.get(o.skill);
  68. if (skill) rec.skill = skill.key;
  69. // 自动分流:mode='auto'(前端默认档)按规则选快问/深度分析
  70. if (o.mode === 'auto') {
  71. rec.mode = pickMode(q);
  72. rec.autoPicked = true;
  73. }
  74. runs.set(rec.runId, rec);
  75. trimCache();
  76. trace.upsertSessionRun(entity, {
  77. sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: 'running', at: rec.startedAt,
  78. });
  79. // 异步执行:路由已返回,前端轮询 GET /api/agent/runs/:id
  80. (async () => {
  81. try {
  82. if (isMock()) {
  83. // mock 模式:quick/agent 都走脚本化假模型(快问=直接 final);auto 已折算进 rec.mode
  84. rec.result = rec.mode === 'quick'
  85. ? { answer: '(mock 模型)快问链路正常。', chart: null, appliedFrom: null, thought: '' }
  86. : await loop.runAgent({ rec, svc, cfg, mock: { next: (r) => mockReply(r) } });
  87. } else if (rec.mode === 'quick') {
  88. const history = trace.loadRecentConclusions(entity, rec.sessionId);
  89. const result = await nlQuery.ask({ q, entity, cfg, svc, history });
  90. rec.result = {
  91. answer: result.reply,
  92. chart: result.chart && result.chart.type !== 'none' ? result.chart : null,
  93. appliedFrom: result.applied || null,
  94. thought: '',
  95. // 快问同样可一键打开当期 HTML 分析报告(区间取本次查询钳制后的区间)
  96. reportUrl: result.applied && result.applied.from && result.applied.to
  97. ? `/api/reports/report-html?${new URLSearchParams({ entity, from: result.applied.from, to: result.applied.to }).toString()}`
  98. : null,
  99. // 图表数据源(快问不走工具步骤,行随结果携带;去 raw 控体积)
  100. chartRows: (result.rows || []).slice(0, 200).map(pickRow),
  101. };
  102. } else {
  103. rec.history = trace.loadRecentConclusions(entity, rec.sessionId);
  104. // 打法复用与 few-shot:真实模型模式下,从已验证语料命中相似问
  105. let plan = null;
  106. let fewShotsText = '';
  107. if (!o.skill && !isMock()) {
  108. try {
  109. plan = replay.findPlan(corpusFile(entity), q);
  110. if (!plan) {
  111. const shots = replay.fewShot(corpusFile(entity), q);
  112. if (shots.length) {
  113. fewShotsText = shots.map((s) => `- 问「${s.question}」→ ${s.steps.map((st) => st.tool).join(' → ')}`).join('\n');
  114. rec.fewShots = shots.map((s) => s.question);
  115. }
  116. }
  117. } catch { /* 语料读写失败不影响正常问答 */ }
  118. }
  119. if (plan) rec.replayFrom = plan.question;
  120. rec.result = await loop.runAgent({ rec, svc, cfg, mock: null, cloud: o.cloud || null, plan, fewShotsText });
  121. }
  122. rec.steps.push({ type: 'final', answer: rec.result.answer });
  123. rec.status = 'done';
  124. } catch (e) {
  125. if (e && e.code === 'AGENT_CANCELLED') rec.status = 'cancelled';
  126. else {
  127. rec.status = 'failed';
  128. rec.error = String((e && e.message) || e).slice(0, 500);
  129. }
  130. } finally {
  131. rec.finishedAt = new Date().toISOString();
  132. rec.cancelRequested = false;
  133. trace.pushEvent(rec, 'run.end', { status: rec.status, error: rec.error });
  134. // 成功且数字全部溯源的运行沉淀为已验证打法(相似问复用 / few-shot 语料)
  135. if (rec.status === 'done' && rec.mode === 'agent' && !isMock()
  136. && rec.provenance && rec.provenance.untracedCount === 0) {
  137. const steps = rec.steps
  138. .filter((s) => s.type === 'tool' && s.ok && replay.ALLOW_TOOLS.has(s.tool))
  139. .map((s) => ({ tool: s.tool, args: s.args }));
  140. if (steps.length && steps.length <= 8) {
  141. try { replay.record(corpusFile(entity), { question: q, steps }); } catch { /* 语料失败不影响主流程 */ }
  142. }
  143. }
  144. trace.persistRun(rec);
  145. trace.upsertSessionRun(entity, {
  146. sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: rec.status, at: rec.finishedAt,
  147. });
  148. }
  149. })();
  150. return rec;
  151. }
  152. function cancelRun(runId) {
  153. const rec = runs.get(String(runId || ''));
  154. if (rec && rec.status === 'running') rec.cancelRequested = true;
  155. return rec || null;
  156. }
  157. /** 内存优先,落盘兜底(服务重启后仍可回放历史运行) */
  158. function getRun(entity, runId) {
  159. const key = trace.sanitizeKey(entity);
  160. const id = String(runId || '').replace(/[^a-zA-Z0-9_-]/g, '');
  161. const rec = runs.get(id);
  162. if (rec && rec.entity === key) return rec;
  163. const saved = trace.loadRun(key, id);
  164. return saved && saved.entity === key ? saved : null;
  165. }
  166. function listRuns(entity, sessionId, limit = 20) {
  167. const key = trace.sanitizeKey(entity);
  168. const session = trace.loadSessions(key).find((s) => s.sessionId === sessionId);
  169. return session ? session.runs.slice(0, limit) : [];
  170. }
  171. function listSessions(entity) {
  172. const key = trace.sanitizeKey(entity);
  173. return trace.loadSessions(key).map((s) => {
  174. const last = (s.runs || [])[0] || {};
  175. return {
  176. sessionId: s.sessionId,
  177. title: s.title,
  178. createdAt: s.createdAt,
  179. updatedAt: s.updatedAt,
  180. runCount: (s.runs || []).length,
  181. lastQuestion: last.question || '',
  182. lastAt: last.at || s.updatedAt || s.createdAt || null,
  183. };
  184. });
  185. }
  186. function deleteSession(entity, sessionId) {
  187. const key = trace.sanitizeKey(entity);
  188. const id = String(sessionId || '').replace(/[^a-zA-Z0-9_-]/g, '');
  189. return trace.deleteSession(key, id);
  190. }
  191. /** 答案反馈:采纳 → 打法强制入语料;纠错 → 反馈日志(供评测提炼) */
  192. function feedback({ entity, runId, kind, note }) {
  193. const rec = getRun(entity, runId);
  194. if (!rec) return null;
  195. rec.feedback = { kind: kind === 'up' ? 'up' : 'down', note: String(note || '').slice(0, 300), at: new Date().toISOString() };
  196. trace.persistRun(rec);
  197. if (rec.feedback.kind === 'up') {
  198. const steps = rec.steps
  199. .filter((s) => s.type === 'tool' && s.ok && replay.ALLOW_TOOLS.has(s.tool))
  200. .map((s) => ({ tool: s.tool, args: s.args }));
  201. if (steps.length) {
  202. try { replay.record(corpusFile(rec.entity), { question: rec.question, steps }); } catch { /* ignore */ }
  203. }
  204. } else {
  205. trace.appendFeedbackLog(rec.entity, {
  206. at: rec.feedback.at, runId: rec.runId, question: rec.question, answer: (rec.result || {}).answer || '', note: rec.feedback.note,
  207. });
  208. }
  209. return rec;
  210. }
  211. module.exports = { startRun, cancelRun, getRun, listRuns, listSessions, deleteSession, feedback };