ask.mjs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. /**
  2. * ask.mjs — 三问追问交互(逐问等待用户)
  3. *
  4. * 采集完成后固定三轮问题(过去/现在/未来),逐问在终端等待用户输入;
  5. * 用户口述与采集素材做「关联标记」(口述里提到的项目名 ↔ 采集条目证据)。
  6. * 用户不答(直接回车 / 超时 / --non-interactive)→ 该问记空值并标注【待补充:用户口述】,
  7. * 上游用采集数据先行出报告——不阻塞、不编造用户感受。
  8. *
  9. * 用法:node ask.mjs --collect collect.json [--answers answers.json] [--timeout 120] [--non-interactive]
  10. */
  11. import fs from 'node:fs';
  12. import path from 'node:path';
  13. const QUESTIONS = [
  14. {
  15. key: 'past',
  16. tag: '第一问 · 过去',
  17. text: '您觉得企业之前 AI 落地不够原生、不够性感的地方是什么?过去的痛点或疑惑?(直接回车可跳过)',
  18. placeholder: '如:工具是有了,但都要一个个单独打开用,和业务流程是两张皮……',
  19. },
  20. {
  21. key: 'present',
  22. tag: '第二问 · 现在',
  23. text: '这两天具体感受与收获?(成果本身已从采集拿到,只问感受 + 哪个具体项目让您有感觉——说到项目名我会自动关联采集证据)',
  24. placeholder: '如:两天装了 8 个技能;最有感觉的是 xx 报告那次,一句话就出来了……',
  25. },
  26. {
  27. key: 'future',
  28. tag: '第三问 · 未来',
  29. text: '学到的这些细节之后,想回企业落地或探索的方向?畅想未来(直接回车可跳过)',
  30. placeholder: '如:想把这套技能包装成培训课,让每个业务部门都装上……',
  31. },
  32. ];
  33. /** 口述 → 采集素材关联标记:问题里提到的项目/文件关键词 ↔ 采集条目 */
  34. export function linkAnswersToEvidence(answers, collectResult) {
  35. const links = {};
  36. if (!collectResult || !Array.isArray(collectResult.items)) return links;
  37. const pool = collectResult.items;
  38. for (const q of QUESTIONS) {
  39. const text = answers[q.key];
  40. links[q.key] = [];
  41. if (!text) continue;
  42. const seen = new Set();
  43. // 提取口述中的候选关键词:≥2 个汉字的连续片段 + 采集条目摘要中的关键 token
  44. const grams = new Set();
  45. for (const m of text.match(/[一-龥A-Za-z0-9_-]{2,}/g) || []) {
  46. for (let i = 0; i < m.length - 1; i++) {
  47. for (const len of [6, 5, 4, 3, 2]) {
  48. if (i + len <= m.length) grams.add(m.slice(i, i + len));
  49. }
  50. }
  51. }
  52. for (const it of pool) {
  53. const hay = `${it.summary} ${it.project || ''} ${path.basename(it.path || '')}`;
  54. for (const g of grams) {
  55. if (hay.includes(g) && !seen.has(it.path)) {
  56. seen.add(it.path);
  57. links[q.key].push({ evidence: it.summary.slice(0, 80), source: it.source, path: it.path, time: it.time, matched: g });
  58. break;
  59. }
  60. }
  61. if (links[q.key].length >= 5) break;
  62. }
  63. }
  64. return links;
  65. }
  66. /** 自管理行读取:TTY 逐行等待;管道场景把缓冲中剩余的行依次消费(每问一行),EOF/超时给空答案 */
  67. function makeLineReader() {
  68. const isTTY = process.stdin.isTTY;
  69. const queue = [];
  70. let eof = false;
  71. let buf = '';
  72. let notify = null;
  73. process.stdin.setEncoding('utf-8');
  74. process.stdin.on('data', chunk => {
  75. buf += chunk;
  76. let idx;
  77. while ((idx = buf.indexOf('\n')) >= 0) {
  78. queue.push(buf.slice(0, idx));
  79. buf = buf.slice(idx + 1);
  80. }
  81. if (notify) { const n = notify; notify = null; n(); }
  82. });
  83. process.stdin.on('end', () => { eof = true; if (notify) { const n = notify; notify = null; n(); } });
  84. process.stdin.on('error', () => { eof = true; if (notify) { const n = notify; notify = null; n(); } });
  85. if (!isTTY) process.stdin.resume();
  86. return function nextLine(timeoutMs) {
  87. return new Promise(resolve => {
  88. if (queue.length > 0) return resolve(queue.shift());
  89. if (eof) return resolve('');
  90. let timer = null;
  91. const finish = val => {
  92. if (timer) clearTimeout(timer);
  93. notify = null;
  94. resolve(val);
  95. };
  96. if (timeoutMs > 0) timer = setTimeout(() => finish(''), timeoutMs);
  97. notify = () => {
  98. if (queue.length > 0) finish(queue.shift());
  99. else if (eof) finish(buf || '');
  100. /* 否则等下一块 data */
  101. };
  102. });
  103. };
  104. }
  105. export async function runAsk({ collectFile = null, answersFile = null, timeoutMs = 120000, nonInteractive = false } = {}) {
  106. let collectResult = null;
  107. if (collectFile) {
  108. try { collectResult = JSON.parse(fs.readFileSync(collectFile, 'utf-8')); } catch { collectResult = null; }
  109. }
  110. const answers = {};
  111. if (nonInteractive) {
  112. for (const q of QUESTIONS) answers[q.key] = '';
  113. console.log('[ask] 非交互模式:三轮问题全部标注【待补充:用户口述】,报告先行用采集数据生成。');
  114. } else {
  115. const nextLine = makeLineReader();
  116. for (const q of QUESTIONS) {
  117. console.log(`\n${'═'.repeat(60)}`);
  118. console.log(`【${q.tag}】${q.text}`);
  119. console.log(` (示例:${q.placeholder})`);
  120. process.stdout.write('> ');
  121. const line = await nextLine(timeoutMs);
  122. answers[q.key] = (line || '').trim();
  123. if (!answers[q.key]) console.log(' (已跳过 → 报告将标注【待补充:用户口述】)');
  124. }
  125. }
  126. for (const q of QUESTIONS) {
  127. if (!answers[q.key]) answers[q.key + '_pending'] = true;
  128. }
  129. const links = linkAnswersToEvidence(answers, collectResult);
  130. const result = { answeredAt: new Date().toISOString(), answers, evidenceLinks: links };
  131. if (answersFile) {
  132. fs.mkdirSync(path.dirname(path.resolve(answersFile)), { recursive: true });
  133. fs.writeFileSync(path.resolve(answersFile), JSON.stringify(result, null, 2));
  134. console.log(`\n已写出:${path.resolve(answersFile)}`);
  135. } else {
  136. console.log('\n<!--ANSWERS-BEGIN-->\n' + JSON.stringify(result, null, 2) + '\n<!--ANSWERS-END-->');
  137. }
  138. return result;
  139. }
  140. /* ── CLI ────────────────────────────────────────────────────── */
  141. function argVal(flag, dflt) {
  142. const i = process.argv.indexOf(flag);
  143. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
  144. }
  145. const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
  146. if (isMain) {
  147. runAsk({
  148. collectFile: argVal('--collect', null),
  149. answersFile: argVal('--answers', null),
  150. timeoutMs: Number(argVal('--timeout', '120000')),
  151. nonInteractive: process.argv.includes('--non-interactive'),
  152. }).catch(e => {
  153. console.error('[ask] 异常退出:', e.message);
  154. process.exit(2);
  155. });
  156. }