| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- /* 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 };
- })();
|