ask.mjs 8.8 KB

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