10-build.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. /**
  2. * core/kfs/build.js —— 原始 Parse 行 → 只读业务模型 db。
  3. *
  4. * 这是「前端算出来的数」的唯一口径;与库里的字段一一对应,不做任何需要网络才能验证的推断。
  5. * 容错优先:字段缺失 → 留空并标出,不编造。
  6. */
  7. import { stageOf, evidenceOf, normalizeLayer, uploadMethodLabel, STAGES } from '../taxonomy/taxonomy.js';
  8. import { toIso, toTime, latest } from './util.js';
  9. import { normalizeOpportunity } from '../opportunity/opportunity.js';
  10. import { indexBoardMembers } from '../member-groups.js';
  11. import { resolveEmployeeRows } from './employee.js';
  12. export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckHours: 8 } } = {}) {
  13. const input = {
  14. project: Array.isArray(raw.project) ? raw.project : [],
  15. artifact: Array.isArray(raw.artifact) ? raw.artifact : [],
  16. insight: Array.isArray(raw.insight) ? raw.insight : [],
  17. source: Array.isArray(raw.source) ? raw.source : [],
  18. session: Array.isArray(raw.session) ? raw.session : [],
  19. opportunity: Array.isArray(raw.opportunity) ? raw.opportunity : [],
  20. };
  21. // Group writes and the public reader resolve the original records identically.
  22. // Employee captions may enrich presentation, but must never change a member ID.
  23. const directory = indexBoardMembers(input);
  24. const rows = resolveEmployeeRows(input);
  25. for (const kind of ['session', 'artifact', 'project', 'opportunity']) rows[kind] = rows[kind].map((r) => {
  26. const id = directory.memberId(kind, r), member = directory.memberById[id];
  27. const field = kind === 'session' ? 'studentName' : kind === 'opportunity' ? 'uploadedBy' : 'ownerName';
  28. return { ...r, __memberId: id, ...(member ? { [field]: member.displayName } : {}) };
  29. });
  30. /* ---------- 成果 ---------- */
  31. const citedIds = new Set();
  32. for (const ins of rows.insight) {
  33. for (const id of Array.isArray(ins.derivedFrom) ? ins.derivedFrom : []) if (id) citedIds.add(id);
  34. for (const id of Array.isArray(ins.derivedArtifacts) ? ins.derivedArtifacts : []) if (id) citedIds.add(id);
  35. }
  36. const artifacts = rows.artifact.map((r) => normArtifact(r, citedIds, toTime));
  37. const artifactById = Object.fromEntries(artifacts.map((a) => [a.id, a]));
  38. /* 被归纳引用的成果:回填「被哪些结论引用」(双向证据链的下行方向) */
  39. const insightRefs = new Map();
  40. for (const ins of rows.insight) {
  41. for (const id of [...(ins.derivedFrom || []), ...(ins.derivedArtifacts || [])]) {
  42. if (!id || !artifactById[id]) continue;
  43. if (!insightRefs.has(id)) insightRefs.set(id, []);
  44. insightRefs.get(id).push(ins.objectId);
  45. }
  46. }
  47. for (const a of artifacts) a.citedByInsightIds = insightRefs.get(a.id) || [];
  48. /* ---------- 项目 ---------- */
  49. const projects = rows.project.map((r) => {
  50. const mine = artifacts.filter((a) => a.projectId === r.objectId);
  51. const st = stageOf(r.stageCode || r.stage);
  52. const own = mine.filter((a) => a.stageObj).sort((x, y) => y.stageObj.no - x.stageObj.no);
  53. return {
  54. id: r.objectId,
  55. title: String(r.title || '(未命名项目)'),
  56. ownerName: String(r.ownerName || ''),
  57. memberId: r.__memberId,
  58. employee: r.employee || null,
  59. progressState: String(r.progressState || ''),
  60. currentConceptId: String(r.currentConceptRef?.objectId || ''),
  61. pendingQuestions: r.meta?.pendingQuestions || [],
  62. ownerKey: String(r.ownerSession || r.ownerName || ''),
  63. stage: st || (own[0] ? own[0].stageObj : null),
  64. category: String(r.category || ''),
  65. summary: String(r.summary || ''),
  66. targetUser: String(r.targetUser || ''),
  67. scenario: String(r.scenario || ''),
  68. score: typeof r.score === 'number' ? r.score : null,
  69. artifactIds: mine.map((a) => a.id),
  70. lastActiveAt: latest([r.updatedAt, ...mine.map((a) => a.createdAt)]),
  71. };
  72. });
  73. const projectById = Object.fromEntries(projects.map((p) => [p.id, p]));
  74. /* ---------- 归纳 ---------- */
  75. const sourceById = {};
  76. const sources = rows.source.map((r) => ({
  77. id: r.objectId,
  78. title: String(r.title || '(未命名资料)'),
  79. sourceType: String(r.sourceType || ''),
  80. summary: String(r.summary || ''),
  81. rawText: String(r.rawText || ''),
  82. sources: Array.isArray(r.sources) ? r.sources : [],
  83. /** 业务内容放 payload(库里字段未完全定下来);展示层只读,不推断 */
  84. payload: r.payload && typeof r.payload === 'object' ? r.payload : {},
  85. meta: r.meta && typeof r.meta === 'object' ? r.meta : {},
  86. tags: Array.isArray(r.tags) ? r.tags : [],
  87. layer: 'raw',
  88. createdAt: toIso(r.createdAt),
  89. }));
  90. for (const s of sources) sourceById[s.id] = s;
  91. const insights = rows.insight.map((r) => {
  92. const pointed = Array.isArray(r.derivedFrom) ? r.derivedFrom.filter(Boolean) : [];
  93. const artIds = (Array.isArray(r.derivedArtifacts) && r.derivedArtifacts.length ? r.derivedArtifacts : pointed).filter((id) => artifactById[id]);
  94. const arts = artIds.map((id) => artifactById[id]);
  95. const owners = [...new Set(arts.map((a) => a.ownerName).filter(Boolean))];
  96. const noOrigin = arts.filter((a) => !a.ownerKey);
  97. const verifiedArts = arts.filter((a) => a.evidenceLevel === 'verified').length;
  98. return {
  99. id: r.objectId,
  100. title: String(r.title || '(未命名结论)'),
  101. body: String(r.body || ''),
  102. category: String(r.category || ''),
  103. layer: normalizeLayer(r, { isInsight: true, hasOwner: owners.length > 0 }),
  104. artifactIds: artIds,
  105. sourceIds: pointed.filter((id) => sourceById[id]),
  106. artifacts: arts,
  107. owners,
  108. ownerCount: owners.length || Number(r.ownerCount) || 0,
  109. mentionCount: Number(r.mentionCount ?? r.crossRefCount ?? arts.length),
  110. quoteCount: arts.reduce((n, a) => n + a.quoteCount, 0),
  111. verifiedArts,
  112. evidenceLevel: r.evidenceLevel || '',
  113. unaligned: verifiedArts === 0,
  114. originBroken: noOrigin.length > 0,
  115. citedWith: Array.isArray(r.citedWith) ? r.citedWith : [],
  116. reportRoles: r.reportRoles && typeof r.reportRoles === 'object' ? r.reportRoles : {},
  117. reviewedBy: String(r.reviewedBy || ''),
  118. createdAt: toIso(r.createdAt),
  119. };
  120. });
  121. const insightById = Object.fromEntries(insights.map((i) => [i.id, i]));
  122. /* ---------- 机会卡(池子) ---------- */
  123. const opportunities = rows.opportunity.map((r) => normalizeOpportunity(r, { artifactById, projectById, toTime }));
  124. const opportunityById = Object.fromEntries(opportunities.map((o) => [o.id, o]));
  125. /* ---------- 成员:稳定身份决定归属,名称只用于展示 ---------- */
  126. const members = new Map();
  127. const touch = (name, key = '', memberId = '') => {
  128. const n = String(name || '').trim();
  129. if (!n) return null;
  130. const id = memberId || 'legacy-name:' + encodeURIComponent(n);
  131. if (!members.has(id)) {
  132. members.set(id, { id, name: n, keys: [], sessionIds: [], artifactIds: [], projectIds: [], oppIds: [],
  133. stageObj: null, msgCount: 0, groupId: '', lastActiveAt: null, lastMsgAt: null, done: false,
  134. stuck: false, stuckHours: 0, stale: false, stagesSubmitted: new Set() });
  135. }
  136. const m = members.get(id);
  137. if (key && !m.keys.includes(key)) m.keys.push(key);
  138. return m;
  139. };
  140. for (const s of rows.session) {
  141. const m = touch(s.studentName, s.meta?.participantKey || '', s.__memberId);
  142. if (!m) continue;
  143. m.sessionIds.push(s.objectId);
  144. if (s.employee) m.employee = s.employee;
  145. m.groupId = m.groupId || String(s.groupId || '');
  146. m.msgCount = Math.max(m.msgCount, Number(s.msgCount) || 0);
  147. const st = stageOf(s.stageCode || s.stage);
  148. if (st && (!m.stageObj || st.no > m.stageObj.no)) m.stageObj = st;
  149. // lastActiveAt 缺失时退回会话 createdAt(「一开课就在」,不是「刚刚活跃」)
  150. if (!s.lastActiveAt) m.stale = true;
  151. m.lastMsgAt = latest([m.lastMsgAt, s.lastActiveAt]);
  152. m.lastActiveAt = latest([m.lastActiveAt, s.lastActiveAt, s.lastActiveAt ? null : s.createdAt]);
  153. }
  154. for (const a of artifacts) {
  155. const m = touch(a.ownerName, a.ownerKey, a.memberId);
  156. if (!m) continue;
  157. m.artifactIds.push(a.id);
  158. if (a.employee) m.employee = a.employee;
  159. if (a.stageObj) m.stagesSubmitted.add(a.stageObj.code);
  160. if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj;
  161. m.lastActiveAt = latest([m.lastActiveAt, a.createdAt]);
  162. }
  163. for (const p of projects) {
  164. const m = touch(p.ownerName, p.ownerKey, p.memberId);
  165. if (!m) continue;
  166. m.projectIds.push(p.id);
  167. if (p.employee) m.employee = p.employee;
  168. if(p.progressState==='blocked')m.blocked=true;
  169. if(p.stage&&(!m.stageObj||p.stage.no>m.stageObj.no))m.stageObj=p.stage;
  170. m.lastActiveAt = latest([m.lastActiveAt, p.lastActiveAt]);
  171. }
  172. for (const o of opportunities) {
  173. const m = touch(o.upload.uploadedBy, o.upload.uploadedByKey, rows.opportunity.find((r) => r.objectId === o.id)?.__memberId);
  174. if (m) { m.oppIds.push(o.id); if (o.upload.employee) m.employee = o.upload.employee; }
  175. }
  176. const stuckMs = (Number(stuck.stuckHours) || 8) * 3600000;
  177. const memberList = [...members.values()].map((m) => {
  178. m.done = m.artifactIds.length > 0;
  179. m.stagesSubmitted = STAGES.filter((s) => m.stagesSubmitted.has(s.code)).map((s) => s.code);
  180. const idleMs = m.lastActiveAt ? now - toTime(m.lastActiveAt) : null;
  181. m.idleHours = idleMs == null ? null : Math.floor(idleMs / 3600000);
  182. m.stuck = Boolean(m.blocked) || (!m.done && m.msgCount >= (stuck.minMsgs || 5) && (idleMs == null || idleMs > stuckMs));
  183. m.stuckHours = m.stuck ? Math.floor((idleMs || 0) / 3600000) : 0;
  184. return m;
  185. }).sort((a, b) => a.name.localeCompare(b.name, 'zh'));
  186. const memberByName = Object.fromEntries(memberList.map((m) => [m.name, m]));
  187. return {
  188. artifacts, artifactById, projects, projectById, insights, insightById,
  189. sources, sourceById, opportunities, opportunityById, members: memberList, memberByName,
  190. memberById: Object.fromEntries(memberList.map((m) => [m.id, m])),
  191. loadedAt: raw.loadedAt || null, truncated: raw.truncated || [], now,
  192. };
  193. }
  194. function normArtifact(r, citedIds, toTimeFn) {
  195. const ownerKey = String(r.ownerSession || r.uploadedByKey || r.ownerName || '');
  196. const quotes = Array.isArray(r.sources) ? r.sources.filter((q) => q && (q.text || q.ref)) : [];
  197. const ev = evidenceOf(r.evidenceLevel, quotes.length > 0);
  198. const st = stageOf(r.stageCode || r.stage);
  199. const method = String(r.uploadMethod || (['session', 'skill', 'manual'].includes(r.uploadedBy) ? r.uploadedBy : ''));
  200. return {
  201. id: r.objectId,
  202. title: String(r.title || '(未命名成果)'),
  203. // 演示数据标题形如「… - opportunity_card」,剥掉类型后缀做展示名
  204. shortTitle: String(r.title || '(未命名成果)').replace(/\s*-\s*[a-z_]+$/, ''),
  205. type: String(r.artifactType || ({market_trend_report:'category_report',competitor_analysis_report:'competitor_report',voc_report:'voc_report',pain_point_report:'pain_list',product_concept_report:'concept'}[r.reportType]) || ''),
  206. ownerName: String(r.ownerName || ''),
  207. memberId: r.__memberId,
  208. employee: r.employee || null,
  209. ownerKey,
  210. stageObj: st,
  211. stage: st ? st.label : r.stage || '—',
  212. layer: normalizeLayer(r, { cited: citedIds.has(r.objectId), hasOwner: Boolean(ownerKey) }),
  213. evidenceLevel: ev.key,
  214. evidenceLabel: ev.label,
  215. evidenceRank: ev.rank,
  216. evidenceUnaligned: r.evidenceLevel === 'verified' && quotes.length === 0,
  217. quotes,
  218. quoteCount: quotes.length,
  219. evidenceCount: Number(r.evidenceCount) || 0,
  220. projectId: String(r.projectRef?.objectId || r.projectId || ''),
  221. sessionId: String(r.sessionRef?.objectId || r.ownerSession || ''),
  222. workshopKey: String(r.workshopKey || ''),
  223. reportSeriesKey: String(r.reportSeriesKey || ''),
  224. version: Number.isSafeInteger(r.version) && r.version > 0 ? r.version : null,
  225. dataMode: String(r.dataMode || ''),
  226. groupId: String(r.groupId || ''),
  227. rawText: String(r.rawText || ''),
  228. report: r.report && typeof r.report === 'object' && !Array.isArray(r.report) ? r.report : null,
  229. /** 痛点严重度(整理人标注,1–5);没标就是 null,展示层标「待标注」,不猜 */
  230. severity: Number(r.severity) > 0 ? Number(r.severity) : null,
  231. tags: Array.isArray(r.tags) ? r.tags : [],
  232. upload: {
  233. uploadedBy: String(r.ownerName || ''),
  234. uploadedByKey: ownerKey,
  235. agentName: String(r.agentName || ''),
  236. agentId: String(r.agentId || ''),
  237. uploadMethod: method,
  238. methodLabel: uploadMethodLabel(method),
  239. uploadedAt: toIso(r.uploadedAt || r.createdAt),
  240. },
  241. createdAt: toIso(r.createdAt),
  242. updatedAt: toIso(r.updatedAt),
  243. };
  244. }