/* model.js —— 原始行 → 页面用的标准对象;所有派生数字在这里算,视图只负责画。 * * 四层归属规则(每条记录都能看出自己属于哪层): * raw layer=raw 调研事实(KfSource / 带 layer=raw 的成果) * process layer 为空或 process 学员与分身成果;历史字段 tier=pending 也归到这里 * sediment layer=sediment 归纳结论(KfInsight),或已被归纳引用的成果 * record layer=record 不合格留档;历史 tier=record 同样归这里 * * 另外每个成果都带 ownerKey / ownerName 作为"出身",没有出身的记录不可进沉淀层。 */ (function () { "use strict"; const T = window.KF_TAXONOMY; const U = window.KF_UTIL; function normalizeLayer(row, isInsight) { /* 归纳硬规则:没有出身的记录不许进沉淀层,缺 layer 的归纳落到记录层留档 */ if (isInsight) { if (row.layer && T.layerOf(row.layer)) return row.layer; return (row.derivedFrom || []).length ? "sediment" : "record"; } let l = row.layer; if (!l && row.tier) l = row.tier === "pending" ? "process" : row.tier; if (!l) return "process"; return T.layerOf(l) ? l : "process"; } function normArtifact(raw) { const layer = normalizeLayer(raw, false); const ownerKey = raw.ownerSession || raw.ownerName || ""; const quotes = Array.isArray(raw.sources) ? raw.sources : []; const ev = T.evidenceOf(raw.evidenceLevel, quotes.length > 0); const st = T.stageOf(raw.stage); /* 报告正文挂在成果的 report 字段上(结构见 README)。没有就是没有,不伪造。 */ const report = raw.report && typeof raw.report === "object" && !Array.isArray(raw.report) ? raw.report : null; return { kind: "artifact", id: raw.objectId, title: raw.title || "(未命名成果)", /* 演示数据标题形如「夜宵场景低卡高蛋白拌面 - opportunity_card」,把类型后缀剥掉做展示名 */ shortTitle: String(raw.title || "(未命名成果)").replace(/\s*-\s*[a-z_]+$/, ""), type: raw.artifactType || "", typeLabel: T.ARTIFACT_TYPES[raw.artifactType] || raw.artifactType || "成果", ownerName: raw.ownerName || "", ownerKey, stageObj: st, stage: st ? st.label : (raw.stage || "—"), layer, evidenceLevel: ev.key, evidenceLabel: ev.label, evidenceRank: ev.rank, evidenceUnaligned: raw.evidenceLevel === "verified" && quotes.length === 0, quotes, quoteCount: quotes.length, projectId: raw.projectId || "", content: raw.content && typeof raw.content === "object" ? raw.content : null, rawText: raw.rawText || "", summaryText: (raw.content && raw.content.summary) || raw.summary || "", report, tags: Array.isArray(raw.tags) ? raw.tags : [], createdAt: raw.createdAt || null, updatedAt: raw.updatedAt || null, uploadedBy: raw.uploadedBy || "", upload: normUpload(raw, raw.ownerName, raw.ownerSession), _raw: raw }; } /* 上传元数据:谁 + 哪个 Agent + 什么方式 + 什么时候。 * 五个字段缺任何一个都如实留空,UI 显示"未记录",不拿 createdAt 冒充 uploadedAt * (除了下面的 explicit 标记:老数据由 seed 脚本一次性补了 uploadedAt=createdAt,那是有据可查的)。 */ function normUpload(row, fallbackBy, fallbackKey) { const u = { by: row.uploadedBy || fallbackBy || "", byKey: row.uploadedByKey || fallbackKey || "", agentName: row.agentName || "", agentId: row.agentId || "", method: row.uploadMethod || "", at: row.uploadedAt || null }; u.complete = Boolean(u.by && u.agentName && u.method && u.at); return u; } /* 评分:多人打分,平均分前端算。非法分数(非数字 / 越界)直接丢弃,不参与平均。 */ function normScores(raw) { const list = Array.isArray(raw) ? raw : []; return list.map((s) => ({ user: (s && s.user) || "", score: s && typeof s.score === "number" && s.score >= 0 && s.score <= 100 ? s.score : null, comment: (s && s.comment) || "", at: (s && s.at) || null })).filter((s) => s.user && s.score != null) .sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0)); } function scoreStats(scores) { if (!scores.length) return { n: 0, avg: null, min: null, max: null, byUser: {} }; const sum = scores.reduce((n, s) => n + s.score, 0); const byUser = {}; scores.forEach((s) => { byUser[s.user] = s; }); /* 同一人多次打分:以最后一次为准(数组已按时间排好) */ const vals = scores.map((s) => s.score); return { n: scores.length, avg: Math.round((sum / scores.length) * 10) / 10, min: Math.min(...vals), max: Math.max(...vals), byUser }; } /* 机会卡:组织资产。状态/优先级/决策/评分是它自己的,不借项目的字段; * 关联成果只存 id,展示时再解析,成果被删了也不会让卡片消失(只标"关联成果已不存在")。 */ function normOpportunity(raw, artifactById, projectById) { const st = T.stageOf(raw.stage); const status = T.OPP_STATUS_KEYS.includes(raw.status) ? raw.status : "candidate"; const ev = T.oppEvidenceOf(raw.evidenceLevel); const scores = normScores(raw.scores); const arts = (Array.isArray(raw.linkedArtifacts) ? raw.linkedArtifacts : []) .map((id) => artifactById[id] || { missing: true, id }); const proj = projectById[raw.linkedProject] || null; const dec = raw.decision && typeof raw.decision === "object" ? raw.decision : null; return { kind: "opportunity", id: raw.objectId, title: raw.title || "(未命名机会)", oneLiner: raw.oneLiner || "", stageObj: st, stage: st ? st.label : (raw.stage || "—"), category: raw.category || "", priority: T.PRIORITIES.includes(raw.priority) ? raw.priority : "", status, statusObj: T.oppStatusOf(status), statusRaw: raw.status || "", evidenceKey: ev.key, evidenceLabel: ev.label, evidenceRank: ev.rank, quoteCount: Number(raw.quoteCount) || 0, citeCount: Number(raw.citeCount) || 0, scores, stats: scoreStats(scores), decision: dec ? { type: dec.type || "", label: dec.label || T.DECISIONS[dec.type] || "决策", reason: dec.reason || "", by: dec.by || "", at: dec.at || null } : null, decisionLog: Array.isArray(raw.decisionLog) ? raw.decisionLog : [], upload: normUpload(raw, "", raw.sourceSession || ""), linkedArtifacts: arts, linkedProject: proj, seedKey: raw.seedKey || "", createdAt: raw.createdAt || null, updatedAt: raw.updatedAt || null, _raw: raw }; } function normSource(raw) { const st = T.stageOf(raw.stage); return { kind: "source", id: raw.objectId, title: raw.title || "(未命名资料)", sourceType: raw.sourceType || "", sourceTypeLabel: T.SOURCE_TYPES[raw.sourceType] || raw.sourceType || "资料", ownerName: raw.ownerName || "调研 Agent", stageObj: st, stage: st ? st.label : (raw.stage || ""), layer: "raw", evidenceLevel: "none", evidenceLabel: "出处待补", evidenceRank: 0, quotes: [], quoteCount: 0, summaryText: raw.summary || "", rawText: raw.rawText || "", sources: Array.isArray(raw.sources) ? raw.sources : [], tags: Array.isArray(raw.tags) ? raw.tags : [], createdAt: raw.createdAt || null, _raw: raw }; } /* 成果池:只有过程层的成果参与筛选与归纳交叉(沉淀/记录层单独展示) */ function isProcessArtifact(a) { return a.layer === "process"; } /* 由成果记录反推:这个成员在哪个阶段最远 */ function furthestStage(arts) { let best = null; for (const a of arts) { if (a.stageObj && (!best || a.stageObj.no > best.no)) best = a.stageObj; } return best; } function normProject(raw, artifacts) { const mine = artifacts.filter((a) => a.projectId === raw.objectId); const st = T.stageOf(raw.stage); return { kind: "project", id: raw.objectId, title: raw.title || "(未命名项目)", ownerName: raw.ownerName || "", ownerKey: raw.ownerSession || raw.ownerName || "", stageObj: st || furthestStage(mine), stage: st ? st.label : (mine.length ? furthestStage(mine).label : "—"), status: raw.status || "", statusLabel: T.STATUS[raw.status] || raw.status || "—", priority: T.PRIORITIES.includes(raw.priority) ? raw.priority : (raw.priority || ""), category: raw.category || "", summary: raw.summary || "", score: typeof raw.score === "number" && !isNaN(raw.score) ? raw.score : null, layer: raw.layer || "process", lastActiveAt: U.latest([raw.updatedAt, raw.lastActiveAt, ...mine.map((a) => a.createdAt)]), createdAt: raw.createdAt || null, upload: normUpload(raw, raw.ownerName, raw.ownerSession), artifactIds: mine.map((a) => a.id), artifactCount: mine.length, processCount: mine.filter(isProcessArtifact).length }; } /* 归纳模型:交叉人数、原文证据数、来源记录、证据是否与级别对齐。 * 硬规则(任务书 3.1):没有出身的记录不许进沉淀层。 * - 被归纳引用的成果若缺 ownerKey,不升 sediment,计为"无出身"并在页面上标出; * - 归纳的支撑记录里只要有无出身的,归纳标记 originBroken,不显示为"可回灌"。 */ function normInsight(raw, artifactById, sourceById) { const pointed = Array.isArray(raw.derivedFrom) ? raw.derivedFrom : []; const artIds = Array.isArray(raw.derivedArtifacts) ? raw.derivedArtifacts.filter((id) => artifactById[id]) : pointed.filter((id) => artifactById[id]); const srcIds = Array.isArray(raw.derivedSources) ? raw.derivedSources.filter((id) => sourceById[id]) : pointed.filter((id) => sourceById[id]); const arts = artIds.map((id) => artifactById[id]); const owners = new Set(arts.map((a) => a.ownerKey).filter(Boolean)); const quoteCount = arts.reduce((n, a) => n + a.quoteCount, 0); const verifiedArts = arts.filter((a) => a.evidenceLevel === "verified").length; const mentionCount = raw.crossRefCount != null ? raw.crossRefCount : arts.length; const ownerCount = owners.size || raw.ownerCount || raw.distinctOwnerCount || 0; const noOrigin = arts.filter((a) => !a.ownerKey); /* 证据对齐:归纳自称有原文,但支撑记录里没有任何带原文的 */ const unaligned = verifiedArts === 0; return { kind: "insight", id: raw.objectId, title: raw.title || "(未命名结论)", body: raw.body || "", category: raw.category || "", layer: "sediment", artifactIds: artIds, sourceIds: srcIds, artifacts: arts, sources: srcIds.map((id) => sourceById[id]), ownerCount, owners: [...owners], mentionCount, quoteCount, verifiedArts, unaligned, noOriginCount: noOrigin.length, originBroken: noOrigin.length > 0, status: raw.status || "draft", reviewedBy: raw.reviewedBy || "", citedWith: Array.isArray(raw.citedWith) ? raw.citedWith : [], createdAt: raw.createdAt || null, updatedAt: raw.updatedAt || null, _raw: raw }; } function normSession(raw, memberById) { const st = T.stageOf(raw.stage); return { kind: "session", id: raw.objectId, studentName: raw.studentName || "", groupId: raw.groupId || "", stageObj: st, stage: st ? st.label : (raw.stage || "—"), step: raw.step || "", msgCount: raw.msgCount || 0, lastActiveAt: raw.lastActiveAt || raw.updatedAt || null, createdAt: raw.createdAt || null, hasSubmitted: Boolean(memberById && memberById.delivered), seed: Boolean(raw.seedKey) }; } function build(raw) { const artifacts = raw.artifact.map(normArtifact); const artifactById = {}; artifacts.forEach((a) => { artifactById[a.id] = a; }); const sources = raw.source.map(normSource); const sourceById = {}; sources.forEach((s) => { sourceById[s.id] = s; }); /* 被归纳引用的成果自动升入沉淀层(前提:有出身) */ const cited = new Set(); raw.insight.forEach((i) => (i.derivedFrom || []).forEach((id) => cited.add(id))); artifacts.forEach((a) => { a.citedByInsight = cited.has(a.id); if (a.citedByInsight && a.layer === "process" && a.ownerKey) a.layer = "sediment"; }); const projects = raw.project.map((p) => normProject(p, artifacts)); const projectById = {}; projects.forEach((p) => { projectById[p.id] = p; }); const insights = raw.insight.map((i) => normInsight(i, artifactById, sourceById)); /* 成员:人名优先作为身份,会话为进度来源 */ const memberMap = new Map(); function touchMember(name, key) { if (!name) return; let m = memberMap.get(name); if (!m) { m = { name, sessionIds: [], artifactIds: [], projectIds: [], stageObj: null, done: false, lastActiveAt: null, msgCount: 0, groupId: "", stuck: false, oppCount: 0, tookPart: false }; memberMap.set(name, m); } if (key && !m.sessionIds.includes(key)) m.sessionIds.push(key); return m; } /* 会话表给姓名、组、消息数;成果表是"这个人交了没有"的唯一权威来源 */ /* 同名会话可能有多条(换了小组/重新开始): * 消息数取最大的那条(不是累加——累加会把"重新开了一次对话"算成双倍), * 最后活动取最新的那条。 */ const byName = {}; raw.session.forEach((s) => { if (!s.studentName) return; const prev = byName[s.studentName]; const lastActiveAt = U.latest([s.lastActiveAt, prev && prev.lastActiveAt]); byName[s.studentName] = { ...s, msgCount: Math.max(s.msgCount || 0, prev ? prev.msgCount : 0), lastActiveAt, createdAt: U.latest([s.createdAt, prev && prev.createdAt]), /* 没有 lastActiveAt 的记录视为"起始时就在这里":不能把它的阶段当成最新活动 */ stale: !s.lastActiveAt, stage: (lastActiveAt && (s.lastActiveAt ? (!prev || U.timeOf(s.lastActiveAt) >= U.timeOf(prev.lastActiveAt)) : Boolean(prev && prev.lastActiveAt))) ? s.stage : (prev ? prev.stage : s.stage) }; }); Object.values(byName).forEach((s) => { const m = touchMember(s.studentName); if (!m) return; m.hasSession = true; m.groupId = m.groupId || s.groupId || ""; m.msgCount += s.msgCount || 0; const st = T.stageOf(s.stage); if (st && (!m.stageObj || st.no > m.stageObj.no)) m.stageObj = st; /* 历史数据里 lastActiveAt 常常缺失;updatedAt 只说明"记录被改过",不是活动时间。 * 优先 lastActiveAt;没有它的老记录按"课程开始"算(createdAt),不能算成刚刚活跃。 */ m.lastActiveAt = U.latest([m.lastActiveAt, s.lastActiveAt, s.stale ? s.createdAt : null]); m.stale = Boolean(m.stale || s.stale); }); artifacts.forEach((a) => { const m = touchMember(a.ownerName); if (!m) return; m.artifactIds.push(a.id); if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj; m.lastActiveAt = U.latest([m.lastActiveAt, a.createdAt]); }); projects.forEach((p) => { const m = touchMember(p.ownerName); if (!m) return; m.projectIds.push(p.id); m.lastActiveAt = U.latest([m.lastActiveAt, p.lastActiveAt]); }); /* 机会卡:池子。先建好,再回填到成员(谁的卡进了池子) */ const opportunities = raw.opportunity.map((o) => normOpportunity(o, artifactById, projectById)); /* 已提交 = 有过程层成果;卡住 = 在对话(≥5 条消息)但超时没有新成果。 * 阈值不写死:取"现场节奏"的 N 小时(KF_CONFIG.stuckHours,默认 8h), * 要么最后活动超时,要么就停在当前阶段超时——讲师在学员卡住的当下就该看到。 */ const stuckHours = (window.KF_CONFIG && window.KF_CONFIG.stuckHours) || 8; const members = [...memberMap.values()].map((m) => { m.done = m.artifactIds.length > 0; m.oppCount = opportunities.filter((o) => o.upload.by && o.upload.by === m.name).length; m.idleHours = m.lastActiveAt ? (Date.now() - new Date(m.lastActiveAt).getTime()) / 3600000 : null; /* 没有 lastActiveAt 时退回会话记录时间——它不是"活动时间",但不该因此判为"刚刚活跃" */ const since = m.idleHours == null && m.createdAt ? (Date.now() - new Date(m.createdAt).getTime()) / 3600000 : m.idleHours; m.stuck = !m.done && m.msgCount >= 5 && (since == null || since > stuckHours); m.stuckHours = m.stuck ? Math.floor(since || 0) : 0; return m; }); members.sort((a, b) => a.name.localeCompare(b.name, "zh")); const memberByName = Object.fromEntries(members.map((m) => [m.name, m])); const sessions = raw.session.map((s) => normSession(s, memberMap.get(s.studentName))); /* 阶段板块:每个阶段产出了什么。归属按成果自身的 stage 字段(谁在哪个阶段交的), * 机会卡单独作为 S3 的「产机会卡」口径统计,但不混进成果条数。 */ const byStage = T.STAGES.map((st) => { const arts = artifacts.filter((a) => a.stageObj && a.stageObj.no === st.no && a.layer !== "raw"); const opps = opportunities.filter((o) => o.stageObj && o.stageObj.no === st.no); return { stage: st, artifacts: arts, opportunities: opps }; }); window.__DBG = window.__DBG || []; window.__DBG.push(raw.session.length + " sessions; names=" + raw.session.map((x) => x.studentName + ":" + x.lastActiveAt).join(",")); return { artifacts, artifactById, sources, sourceById, projects, projectById, insights, insightById: Object.fromEntries(insights.map((i) => [i.id, i])), opportunities, opportunityById: Object.fromEntries(opportunities.map((o) => [o.id, o])), byStage, members, memberByName, sessions, stuckHours, loadedAt: raw.loadedAt, truncated: raw.truncated || [] }; } window.KF_MODEL = { build }; })();