model.test.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import { test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { buildModel } from '../src/core/kfs/build.js';
  4. import { forMe, poolOverview, liveBoard, stuckMembers, stageBoards } from '../src/core/kfs/stats.js';
  5. import { traceArtifact, traceInsight, reportEvidence, conclusionToLedger } from '../src/core/evidence/chain.js';
  6. import { reportOverview, findingsOf } from '../src/core/report.js';
  7. /* 一份最小但覆盖各字段形态的「库」:只在测试里构造,不联网 */
  8. const NOW = Date.parse('2026-10-29T12:00:00Z');
  9. const H = 3600000;
  10. const ago = (h) => new Date(NOW - h * H).toISOString();
  11. const raw = {
  12. project: [{ objectId: 'p1', title: '夜宵项目', ownerName: '张伟', ownerSession: 's1', stage: 'S1品类分析' }],
  13. artifact: [
  14. { objectId: 'a1', title: '夜宵卡 - opportunity_card', ownerName: '张伟', ownerSession: 's1', stage: 'S3 机会识别', evidenceLevel: 'supported', artifactType: 'opportunity_card', createdAt: ago(10), uploadMethod: 'session', agentName: '主分身', agentId: 'agent-1', uploadedAt: ago(10) },
  15. { objectId: 'a2', title: '夜宵痛点 - pain_list', ownerName: '李娜', ownerSession: 's2', stage: 'S2需求洞察', evidenceLevel: 'verified', sources: [], createdAt: ago(9) },
  16. { objectId: 'rep', title: '夜宵竞品 - competitor_report', ownerName: '张伟', ownerSession: 's1', stage: 'S1品类分析', evidenceLevel: 'supported', createdAt: ago(8),
  17. report: {
  18. title: '夜宵洞察', pipeline: [{ label: '采集', value: 2727 }, { label: '去噪', value: 1190 }, { label: '精准', value: 730 }],
  19. chapters: [{ id: 'ch2', no: 2, title: '嵌套洞察', stage: 'S2', sections: [
  20. { kind: 'findings', data: [{ id: 'f', conclusion: '软塌是工艺问题', attitude: '…', evidence: 'c1' }] },
  21. { kind: 'findings', data: [{ id: 'f', conclusion: '软塌是工艺问题', attitude: '…', evidence: 'c1' }] },
  22. ] }],
  23. evidence: { c1: { claim: '软塌是工艺问题', ownerCount: 2, quotes: [{ text: '泡完就坨', ref: '电商评论', likes: 302 }], derivedFrom: ['a2', 'ghost'] } },
  24. } },
  25. ],
  26. insight: [
  27. { objectId: 'i1', title: '拌面口感', derivedFrom: ['a2'], derivedArtifacts: ['a1'] },
  28. { objectId: 'i2', title: '无出身推断', derivedFrom: [], layer: 'sediment' },
  29. ],
  30. source: [{ objectId: 'src1', title: '品类概览', sourceType: 'category' }],
  31. session: [
  32. { objectId: 's1', studentName: '张伟', stage: 'S5概念设计', msgCount: 20, lastActiveAt: ago(20) },
  33. { objectId: 's3', studentName: '何雨', stage: 'S4验证评估', msgCount: 13, lastActiveAt: ago(30) }, // 开工了、没交、超时 → 卡住
  34. { objectId: 's4', studentName: '新人', stage: 'S1品类分析', msgCount: 2, lastActiveAt: ago(30) }, // 消息太少 → 不算卡住
  35. { objectId: 's5', studentName: '老数据', stage: 'S2需求洞察', msgCount: 9, createdAt: ago(40) }, // 没有 lastActiveAt
  36. ],
  37. opportunity: [
  38. { objectId: 'o1', title: '夜宵低卡', stage: 'S3 机会识别', priority: 'S', status: 'candidate', evidenceLevel: 'high', linkedArtifacts: ['a1'], linkedProject: 'p1',
  39. scores: [{ user: '李娜', score: 86, at: ago(3) }, { user: '吴敏', score: 90, at: ago(2) }], uploadedBy: '张伟', uploadedByKey: 's1', agentName: '主分身', uploadMethod: 'session', uploadedAt: ago(6) },
  40. { objectId: 'o2', title: '银发低钠', stage: 'S1 品类市场分析', priority: 'B', status: 'approved', evidenceLevel: 'medium', linkedArtifacts: ['ghost'], scores: [],
  41. uploadedBy: '赵磊', agentName: '调研 Agent', uploadMethod: 'skill', uploadedAt: ago(1) },
  42. ],
  43. };
  44. const db = buildModel(raw, { now: NOW });
  45. test('模型:成果 / 成员 / 机会卡计数与归属', () => {
  46. assert.equal(db.artifacts.length, 3);
  47. assert.equal(db.members.length, 6, '张伟/李娜/何雨/新人/老数据 + 机会卡上传人赵磊');
  48. assert.equal(db.artifactById.a1.shortTitle, '夜宵卡');
  49. assert.equal(db.artifactById.a2.evidenceLabel, '有佐证', 'verified 但无原文 → 降级');
  50. assert.equal(db.artifactById.a1.layer, 'sediment', '被归纳引用且有出身 → 沉淀');
  51. assert.equal(db.insightById.i2.layer, 'record', '没有出身的归纳只能留档');
  52. assert.deepEqual(db.artifactById.a2.citedByInsightIds, ['i1']);
  53. });
  54. test('视角一「给我的」:交了几份、哪个阶段没交', () => {
  55. const m = forMe(db, '张伟');
  56. assert.equal(m.count, 2);
  57. assert.deepEqual(m.stagesDone, ['S1', 'S3']);
  58. assert.deepEqual(m.stagesMissing, ['S2', 'S4', 'S5']);
  59. assert.equal(m.opps.length, 1);
  60. });
  61. test('视角二「池子」:总数 / 状态分布 / 最新 / 最高分', () => {
  62. const p = poolOverview(db);
  63. assert.equal(p.total, 2);
  64. assert.deepEqual(p.byStatus, { candidate: 1, approved: 1, rejected: 0 });
  65. assert.equal(p.latest3[0].id, 'o2', '最新上传在前');
  66. assert.equal(p.top.id, 'o1');
  67. assert.equal(p.top.stats.avg, 88);
  68. });
  69. test('卡住:只列开工了、没交、超时的人名', () => {
  70. const s = stuckMembers(db);
  71. assert.deepEqual(s.map((x) => x.name), ['老数据', '何雨'], '老数据没有 lastActiveAt,按会话 createdAt(40h) 算,也确实超时');
  72. assert.equal(s[1].stage, 'S4\u2009机会验证评估');
  73. assert.equal(s[1].stuckHours, 30);
  74. });
  75. test('视角三「现场看板」:五阶段 × 人', () => {
  76. const b = liveBoard(db);
  77. assert.equal(b.stages.length, 5);
  78. const s3 = b.stages.find((x) => x.stage.code === 'S3');
  79. assert.equal(s3.submitted, 1);
  80. assert.equal(s3.people.find((p) => p.name === '张伟').count, 1);
  81. assert.equal(b.stalled.length, 2);
  82. });
  83. test('五阶段板块:产出数 + 最新 + 谁产的', () => {
  84. const b = stageBoards(db);
  85. const s2 = b.find((x) => x.stage.code === 'S2');
  86. assert.equal(s2.count, 1);
  87. assert.deepEqual(s2.producers, [{ name: '李娜', count: 1 }]);
  88. assert.equal(b.find((x) => x.stage.code === 'S3').oppCount, 1, '机会卡按阶段单独算,不混进成果数');
  89. });
  90. test('证据链:结论 → 具体成果名和人名(不存在的如实标出)', () => {
  91. const e = reportEvidence(db, 'rep', 'c1');
  92. assert.equal(e.claim, '软塌是工艺问题');
  93. assert.deepEqual(e.derived.map((d) => d.name), ['夜宵痛点', '(库里已不存在)']);
  94. assert.deepEqual(e.derivedOwners, ['李娜']);
  95. assert.deepEqual(conclusionToLedger(db, 'rep', 'c1'), ['a2', 'ghost']);
  96. assert.equal(e.quotes[0].likes, 302);
  97. });
  98. test('证据链:成果 → 引用它的结论 / 归纳 / 机会卡', () => {
  99. const t = traceArtifact(db, 'a2');
  100. assert.deepEqual([...new Set(t.citing.map((c) => `${c.reportTitle}/${c.evId}`))], ['夜宵竞品/c1']);
  101. assert.deepEqual(t.insights.map((i) => i.title), ['拌面口感']);
  102. const t1 = traceArtifact(db, 'a1');
  103. assert.deepEqual(t1.opportunities.map((o) => o.title), ['夜宵低卡']);
  104. const ti = traceInsight(db, 'i1');
  105. assert.equal(ti.reports.length, 0, 'i1 只引用了不带报告正文的成果,如实为空');
  106. assert.deepEqual(traceInsight(db, 'i1').artifacts.map((a) => a.name), ['夜宵卡']);
  107. });