| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- /**
- * core/kfs/build.js —— 原始 Parse 行 → 只读业务模型 db。
- *
- * 这是「前端算出来的数」的唯一口径;与库里的字段一一对应,不做任何需要网络才能验证的推断。
- * 容错优先:字段缺失 → 留空并标出,不编造。
- */
- import { stageOf, evidenceOf, normalizeLayer, uploadMethodLabel, STAGES } from '../taxonomy/taxonomy.js';
- import { toIso, toTime, latest } from './util.js';
- import { normalizeOpportunity } from '../opportunity/opportunity.js';
- import { indexBoardMembers } from '../member-groups.js';
- import { resolveEmployeeRows } from './employee.js';
- export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckHours: 8 } } = {}) {
- const input = {
- project: Array.isArray(raw.project) ? raw.project : [],
- artifact: Array.isArray(raw.artifact) ? raw.artifact : [],
- insight: Array.isArray(raw.insight) ? raw.insight : [],
- source: Array.isArray(raw.source) ? raw.source : [],
- session: Array.isArray(raw.session) ? raw.session : [],
- opportunity: Array.isArray(raw.opportunity) ? raw.opportunity : [],
- };
- // Group writes and the public reader resolve the original records identically.
- // Employee captions may enrich presentation, but must never change a member ID.
- const directory = indexBoardMembers(input);
- const rows = resolveEmployeeRows(input);
- for (const kind of ['session', 'artifact', 'project', 'opportunity']) rows[kind] = rows[kind].map((r) => {
- const id = directory.memberId(kind, r), member = directory.memberById[id];
- const field = kind === 'session' ? 'studentName' : kind === 'opportunity' ? 'uploadedBy' : 'ownerName';
- return { ...r, __memberId: id, ...(member ? { [field]: member.displayName } : {}) };
- });
- /* ---------- 成果 ---------- */
- const citedIds = new Set();
- for (const ins of rows.insight) {
- for (const id of Array.isArray(ins.derivedFrom) ? ins.derivedFrom : []) if (id) citedIds.add(id);
- for (const id of Array.isArray(ins.derivedArtifacts) ? ins.derivedArtifacts : []) if (id) citedIds.add(id);
- }
- const artifacts = rows.artifact.map((r) => normArtifact(r, citedIds, toTime));
- const artifactById = Object.fromEntries(artifacts.map((a) => [a.id, a]));
- /* 被归纳引用的成果:回填「被哪些结论引用」(双向证据链的下行方向) */
- const insightRefs = new Map();
- for (const ins of rows.insight) {
- for (const id of [...(ins.derivedFrom || []), ...(ins.derivedArtifacts || [])]) {
- if (!id || !artifactById[id]) continue;
- if (!insightRefs.has(id)) insightRefs.set(id, []);
- insightRefs.get(id).push(ins.objectId);
- }
- }
- for (const a of artifacts) a.citedByInsightIds = insightRefs.get(a.id) || [];
- /* ---------- 项目 ---------- */
- const projects = rows.project.map((r) => {
- const mine = artifacts.filter((a) => a.projectId === r.objectId);
- const st = stageOf(r.stageCode || r.stage);
- const own = mine.filter((a) => a.stageObj).sort((x, y) => y.stageObj.no - x.stageObj.no);
- return {
- id: r.objectId,
- title: String(r.title || '(未命名项目)'),
- ownerName: String(r.ownerName || ''),
- memberId: r.__memberId,
- employee: r.employee || null,
- progressState: String(r.progressState || ''),
- currentConceptId: String(r.currentConceptRef?.objectId || ''),
- pendingQuestions: r.meta?.pendingQuestions || [],
- ownerKey: String(r.ownerSession || r.ownerName || ''),
- stage: st || (own[0] ? own[0].stageObj : null),
- category: String(r.category || ''),
- summary: String(r.summary || ''),
- targetUser: String(r.targetUser || ''),
- scenario: String(r.scenario || ''),
- score: typeof r.score === 'number' ? r.score : null,
- artifactIds: mine.map((a) => a.id),
- lastActiveAt: latest([r.updatedAt, ...mine.map((a) => a.createdAt)]),
- };
- });
- const projectById = Object.fromEntries(projects.map((p) => [p.id, p]));
- /* ---------- 归纳 ---------- */
- const sourceById = {};
- const sources = rows.source.map((r) => ({
- id: r.objectId,
- title: String(r.title || '(未命名资料)'),
- sourceType: String(r.sourceType || ''),
- summary: String(r.summary || ''),
- rawText: String(r.rawText || ''),
- sources: Array.isArray(r.sources) ? r.sources : [],
- /** 业务内容放 payload(库里字段未完全定下来);展示层只读,不推断 */
- payload: r.payload && typeof r.payload === 'object' ? r.payload : {},
- meta: r.meta && typeof r.meta === 'object' ? r.meta : {},
- tags: Array.isArray(r.tags) ? r.tags : [],
- layer: 'raw',
- createdAt: toIso(r.createdAt),
- }));
- for (const s of sources) sourceById[s.id] = s;
- const insights = rows.insight.map((r) => {
- const pointed = Array.isArray(r.derivedFrom) ? r.derivedFrom.filter(Boolean) : [];
- const artIds = (Array.isArray(r.derivedArtifacts) && r.derivedArtifacts.length ? r.derivedArtifacts : pointed).filter((id) => artifactById[id]);
- const arts = artIds.map((id) => artifactById[id]);
- const owners = [...new Set(arts.map((a) => a.ownerName).filter(Boolean))];
- const noOrigin = arts.filter((a) => !a.ownerKey);
- const verifiedArts = arts.filter((a) => a.evidenceLevel === 'verified').length;
- return {
- id: r.objectId,
- title: String(r.title || '(未命名结论)'),
- body: String(r.body || ''),
- category: String(r.category || ''),
- layer: normalizeLayer(r, { isInsight: true, hasOwner: owners.length > 0 }),
- artifactIds: artIds,
- sourceIds: pointed.filter((id) => sourceById[id]),
- artifacts: arts,
- owners,
- ownerCount: owners.length || Number(r.ownerCount) || 0,
- mentionCount: Number(r.mentionCount ?? r.crossRefCount ?? arts.length),
- quoteCount: arts.reduce((n, a) => n + a.quoteCount, 0),
- verifiedArts,
- evidenceLevel: r.evidenceLevel || '',
- unaligned: verifiedArts === 0,
- originBroken: noOrigin.length > 0,
- citedWith: Array.isArray(r.citedWith) ? r.citedWith : [],
- reportRoles: r.reportRoles && typeof r.reportRoles === 'object' ? r.reportRoles : {},
- reviewedBy: String(r.reviewedBy || ''),
- createdAt: toIso(r.createdAt),
- };
- });
- const insightById = Object.fromEntries(insights.map((i) => [i.id, i]));
- /* ---------- 机会卡(池子) ---------- */
- const opportunities = rows.opportunity.map((r) => normalizeOpportunity(r, { artifactById, projectById, toTime }));
- const opportunityById = Object.fromEntries(opportunities.map((o) => [o.id, o]));
- /* ---------- 成员:稳定身份决定归属,名称只用于展示 ---------- */
- const members = new Map();
- const touch = (name, key = '', memberId = '') => {
- const n = String(name || '').trim();
- if (!n) return null;
- const id = memberId || 'legacy-name:' + encodeURIComponent(n);
- if (!members.has(id)) {
- members.set(id, { id, name: n, keys: [], sessionIds: [], artifactIds: [], projectIds: [], oppIds: [],
- stageObj: null, msgCount: 0, groupId: '', lastActiveAt: null, lastMsgAt: null, done: false,
- stuck: false, stuckHours: 0, stale: false, stagesSubmitted: new Set() });
- }
- const m = members.get(id);
- if (key && !m.keys.includes(key)) m.keys.push(key);
- return m;
- };
- for (const s of rows.session) {
- const m = touch(s.studentName, s.meta?.participantKey || '', s.__memberId);
- if (!m) continue;
- m.sessionIds.push(s.objectId);
- if (s.employee) m.employee = s.employee;
- m.groupId = m.groupId || String(s.groupId || '');
- m.msgCount = Math.max(m.msgCount, Number(s.msgCount) || 0);
- const st = stageOf(s.stageCode || s.stage);
- if (st && (!m.stageObj || st.no > m.stageObj.no)) m.stageObj = st;
- // lastActiveAt 缺失时退回会话 createdAt(「一开课就在」,不是「刚刚活跃」)
- if (!s.lastActiveAt) m.stale = true;
- m.lastMsgAt = latest([m.lastMsgAt, s.lastActiveAt]);
- m.lastActiveAt = latest([m.lastActiveAt, s.lastActiveAt, s.lastActiveAt ? null : s.createdAt]);
- }
- for (const a of artifacts) {
- const m = touch(a.ownerName, a.ownerKey, a.memberId);
- if (!m) continue;
- m.artifactIds.push(a.id);
- if (a.employee) m.employee = a.employee;
- if (a.stageObj) m.stagesSubmitted.add(a.stageObj.code);
- if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj;
- m.lastActiveAt = latest([m.lastActiveAt, a.createdAt]);
- }
- for (const p of projects) {
- const m = touch(p.ownerName, p.ownerKey, p.memberId);
- if (!m) continue;
- m.projectIds.push(p.id);
- if (p.employee) m.employee = p.employee;
- if(p.progressState==='blocked')m.blocked=true;
- if(p.stage&&(!m.stageObj||p.stage.no>m.stageObj.no))m.stageObj=p.stage;
- m.lastActiveAt = latest([m.lastActiveAt, p.lastActiveAt]);
- }
- for (const o of opportunities) {
- const m = touch(o.upload.uploadedBy, o.upload.uploadedByKey, rows.opportunity.find((r) => r.objectId === o.id)?.__memberId);
- if (m) { m.oppIds.push(o.id); if (o.upload.employee) m.employee = o.upload.employee; }
- }
- const stuckMs = (Number(stuck.stuckHours) || 8) * 3600000;
- const memberList = [...members.values()].map((m) => {
- m.done = m.artifactIds.length > 0;
- m.stagesSubmitted = STAGES.filter((s) => m.stagesSubmitted.has(s.code)).map((s) => s.code);
- const idleMs = m.lastActiveAt ? now - toTime(m.lastActiveAt) : null;
- m.idleHours = idleMs == null ? null : Math.floor(idleMs / 3600000);
- m.stuck = Boolean(m.blocked) || (!m.done && m.msgCount >= (stuck.minMsgs || 5) && (idleMs == null || idleMs > stuckMs));
- m.stuckHours = m.stuck ? Math.floor((idleMs || 0) / 3600000) : 0;
- return m;
- }).sort((a, b) => a.name.localeCompare(b.name, 'zh'));
- const memberByName = Object.fromEntries(memberList.map((m) => [m.name, m]));
- return {
- artifacts, artifactById, projects, projectById, insights, insightById,
- sources, sourceById, opportunities, opportunityById, members: memberList, memberByName,
- memberById: Object.fromEntries(memberList.map((m) => [m.id, m])),
- loadedAt: raw.loadedAt || null, truncated: raw.truncated || [], now,
- };
- }
- function normArtifact(r, citedIds, toTimeFn) {
- const ownerKey = String(r.ownerSession || r.uploadedByKey || r.ownerName || '');
- const quotes = Array.isArray(r.sources) ? r.sources.filter((q) => q && (q.text || q.ref)) : [];
- const ev = evidenceOf(r.evidenceLevel, quotes.length > 0);
- const st = stageOf(r.stageCode || r.stage);
- const method = String(r.uploadMethod || (['session', 'skill', 'manual'].includes(r.uploadedBy) ? r.uploadedBy : ''));
- return {
- id: r.objectId,
- title: String(r.title || '(未命名成果)'),
- // 演示数据标题形如「… - opportunity_card」,剥掉类型后缀做展示名
- shortTitle: String(r.title || '(未命名成果)').replace(/\s*-\s*[a-z_]+$/, ''),
- 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]) || ''),
- ownerName: String(r.ownerName || ''),
- memberId: r.__memberId,
- employee: r.employee || null,
- ownerKey,
- stageObj: st,
- stage: st ? st.label : r.stage || '—',
- layer: normalizeLayer(r, { cited: citedIds.has(r.objectId), hasOwner: Boolean(ownerKey) }),
- evidenceLevel: ev.key,
- evidenceLabel: ev.label,
- evidenceRank: ev.rank,
- evidenceUnaligned: r.evidenceLevel === 'verified' && quotes.length === 0,
- quotes,
- quoteCount: quotes.length,
- evidenceCount: Number(r.evidenceCount) || 0,
- projectId: String(r.projectRef?.objectId || r.projectId || ''),
- sessionId: String(r.sessionRef?.objectId || r.ownerSession || ''),
- workshopKey: String(r.workshopKey || ''),
- reportSeriesKey: String(r.reportSeriesKey || ''),
- version: Number.isSafeInteger(r.version) && r.version > 0 ? r.version : null,
- dataMode: String(r.dataMode || ''),
- groupId: String(r.groupId || ''),
- rawText: String(r.rawText || ''),
- report: r.report && typeof r.report === 'object' && !Array.isArray(r.report) ? r.report : null,
- /** 痛点严重度(整理人标注,1–5);没标就是 null,展示层标「待标注」,不猜 */
- severity: Number(r.severity) > 0 ? Number(r.severity) : null,
- tags: Array.isArray(r.tags) ? r.tags : [],
- upload: {
- uploadedBy: String(r.ownerName || ''),
- uploadedByKey: ownerKey,
- agentName: String(r.agentName || ''),
- agentId: String(r.agentId || ''),
- uploadMethod: method,
- methodLabel: uploadMethodLabel(method),
- uploadedAt: toIso(r.uploadedAt || r.createdAt),
- },
- createdAt: toIso(r.createdAt),
- updatedAt: toIso(r.updatedAt),
- };
- }
|