model.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. /* model.js —— 原始行 → 页面用的标准对象;所有派生数字在这里算,视图只负责画。
  2. *
  3. * 四层归属规则(每条记录都能看出自己属于哪层):
  4. * raw layer=raw 调研事实(KfSource / 带 layer=raw 的成果)
  5. * process layer 为空或 process 学员与分身成果;历史字段 tier=pending 也归到这里
  6. * sediment layer=sediment 归纳结论(KfInsight),或已被归纳引用的成果
  7. * record layer=record 不合格留档;历史 tier=record 同样归这里
  8. *
  9. * 另外每个成果都带 ownerKey / ownerName 作为"出身",没有出身的记录不可进沉淀层。 */
  10. (function () {
  11. "use strict";
  12. const T = window.KF_TAXONOMY;
  13. const U = window.KF_UTIL;
  14. function normalizeLayer(row, isInsight) {
  15. /* 归纳硬规则:没有出身的记录不许进沉淀层,缺 layer 的归纳落到记录层留档 */
  16. if (isInsight) {
  17. if (row.layer && T.layerOf(row.layer)) return row.layer;
  18. return (row.derivedFrom || []).length ? "sediment" : "record";
  19. }
  20. let l = row.layer;
  21. if (!l && row.tier) l = row.tier === "pending" ? "process" : row.tier;
  22. if (!l) return "process";
  23. return T.layerOf(l) ? l : "process";
  24. }
  25. function normArtifact(raw) {
  26. const layer = normalizeLayer(raw, false);
  27. const ownerKey = raw.ownerSession || raw.ownerName || "";
  28. const quotes = Array.isArray(raw.sources) ? raw.sources : [];
  29. const ev = T.evidenceOf(raw.evidenceLevel, quotes.length > 0);
  30. const st = T.stageOf(raw.stage);
  31. /* 报告正文挂在成果的 report 字段上(结构见 README)。没有就是没有,不伪造。 */
  32. const report = raw.report && typeof raw.report === "object" && !Array.isArray(raw.report) ? raw.report : null;
  33. return {
  34. kind: "artifact",
  35. id: raw.objectId,
  36. title: raw.title || "(未命名成果)",
  37. /* 演示数据标题形如「夜宵场景低卡高蛋白拌面 - opportunity_card」,把类型后缀剥掉做展示名 */
  38. shortTitle: String(raw.title || "(未命名成果)").replace(/\s*-\s*[a-z_]+$/, ""),
  39. type: raw.artifactType || "",
  40. typeLabel: T.ARTIFACT_TYPES[raw.artifactType] || raw.artifactType || "成果",
  41. ownerName: raw.ownerName || "",
  42. ownerKey,
  43. stageObj: st,
  44. stage: st ? st.label : (raw.stage || "—"),
  45. layer,
  46. evidenceLevel: ev.key,
  47. evidenceLabel: ev.label,
  48. evidenceRank: ev.rank,
  49. evidenceUnaligned: raw.evidenceLevel === "verified" && quotes.length === 0,
  50. quotes,
  51. quoteCount: quotes.length,
  52. projectId: raw.projectId || "",
  53. content: raw.content && typeof raw.content === "object" ? raw.content : null,
  54. rawText: raw.rawText || "",
  55. summaryText: (raw.content && raw.content.summary) || raw.summary || "",
  56. report,
  57. tags: Array.isArray(raw.tags) ? raw.tags : [],
  58. createdAt: raw.createdAt || null,
  59. updatedAt: raw.updatedAt || null,
  60. uploadedBy: raw.uploadedBy || "",
  61. upload: normUpload(raw, raw.ownerName, raw.ownerSession),
  62. _raw: raw
  63. };
  64. }
  65. /* 上传元数据:谁 + 哪个 Agent + 什么方式 + 什么时候。
  66. * 五个字段缺任何一个都如实留空,UI 显示"未记录",不拿 createdAt 冒充 uploadedAt
  67. * (除了下面的 explicit 标记:老数据由 seed 脚本一次性补了 uploadedAt=createdAt,那是有据可查的)。 */
  68. function normUpload(row, fallbackBy, fallbackKey) {
  69. const u = {
  70. by: row.uploadedBy || fallbackBy || "",
  71. byKey: row.uploadedByKey || fallbackKey || "",
  72. agentName: row.agentName || "",
  73. agentId: row.agentId || "",
  74. method: row.uploadMethod || "",
  75. at: row.uploadedAt || null
  76. };
  77. u.complete = Boolean(u.by && u.agentName && u.method && u.at);
  78. return u;
  79. }
  80. /* 评分:多人打分,平均分前端算。非法分数(非数字 / 越界)直接丢弃,不参与平均。 */
  81. function normScores(raw) {
  82. const list = Array.isArray(raw) ? raw : [];
  83. return list.map((s) => ({
  84. user: (s && s.user) || "",
  85. score: s && typeof s.score === "number" && s.score >= 0 && s.score <= 100 ? s.score : null,
  86. comment: (s && s.comment) || "",
  87. at: (s && s.at) || null
  88. })).filter((s) => s.user && s.score != null)
  89. .sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
  90. }
  91. function scoreStats(scores) {
  92. if (!scores.length) return { n: 0, avg: null, min: null, max: null, byUser: {} };
  93. const sum = scores.reduce((n, s) => n + s.score, 0);
  94. const byUser = {};
  95. scores.forEach((s) => { byUser[s.user] = s; }); /* 同一人多次打分:以最后一次为准(数组已按时间排好) */
  96. const vals = scores.map((s) => s.score);
  97. return {
  98. n: scores.length,
  99. avg: Math.round((sum / scores.length) * 10) / 10,
  100. min: Math.min(...vals),
  101. max: Math.max(...vals),
  102. byUser
  103. };
  104. }
  105. /* 机会卡:组织资产。状态/优先级/决策/评分是它自己的,不借项目的字段;
  106. * 关联成果只存 id,展示时再解析,成果被删了也不会让卡片消失(只标"关联成果已不存在")。 */
  107. function normOpportunity(raw, artifactById, projectById) {
  108. const st = T.stageOf(raw.stage);
  109. const status = T.OPP_STATUS_KEYS.includes(raw.status) ? raw.status : "candidate";
  110. const ev = T.oppEvidenceOf(raw.evidenceLevel);
  111. const scores = normScores(raw.scores);
  112. const arts = (Array.isArray(raw.linkedArtifacts) ? raw.linkedArtifacts : [])
  113. .map((id) => artifactById[id] || { missing: true, id });
  114. const proj = projectById[raw.linkedProject] || null;
  115. const dec = raw.decision && typeof raw.decision === "object" ? raw.decision : null;
  116. return {
  117. kind: "opportunity",
  118. id: raw.objectId,
  119. title: raw.title || "(未命名机会)",
  120. oneLiner: raw.oneLiner || "",
  121. stageObj: st,
  122. stage: st ? st.label : (raw.stage || "—"),
  123. category: raw.category || "",
  124. priority: T.PRIORITIES.includes(raw.priority) ? raw.priority : "",
  125. status,
  126. statusObj: T.oppStatusOf(status),
  127. statusRaw: raw.status || "",
  128. evidenceKey: ev.key,
  129. evidenceLabel: ev.label,
  130. evidenceRank: ev.rank,
  131. quoteCount: Number(raw.quoteCount) || 0,
  132. citeCount: Number(raw.citeCount) || 0,
  133. scores,
  134. stats: scoreStats(scores),
  135. decision: dec ? {
  136. type: dec.type || "", label: dec.label || T.DECISIONS[dec.type] || "决策",
  137. reason: dec.reason || "", by: dec.by || "", at: dec.at || null
  138. } : null,
  139. decisionLog: Array.isArray(raw.decisionLog) ? raw.decisionLog : [],
  140. upload: normUpload(raw, "", raw.sourceSession || ""),
  141. linkedArtifacts: arts,
  142. linkedProject: proj,
  143. seedKey: raw.seedKey || "",
  144. createdAt: raw.createdAt || null,
  145. updatedAt: raw.updatedAt || null,
  146. _raw: raw
  147. };
  148. }
  149. function normSource(raw) {
  150. const st = T.stageOf(raw.stage);
  151. return {
  152. kind: "source",
  153. id: raw.objectId,
  154. title: raw.title || "(未命名资料)",
  155. sourceType: raw.sourceType || "",
  156. sourceTypeLabel: T.SOURCE_TYPES[raw.sourceType] || raw.sourceType || "资料",
  157. ownerName: raw.ownerName || "调研 Agent",
  158. stageObj: st,
  159. stage: st ? st.label : (raw.stage || ""),
  160. layer: "raw",
  161. evidenceLevel: "none",
  162. evidenceLabel: "出处待补",
  163. evidenceRank: 0,
  164. quotes: [],
  165. quoteCount: 0,
  166. summaryText: raw.summary || "",
  167. rawText: raw.rawText || "",
  168. sources: Array.isArray(raw.sources) ? raw.sources : [],
  169. tags: Array.isArray(raw.tags) ? raw.tags : [],
  170. createdAt: raw.createdAt || null,
  171. _raw: raw
  172. };
  173. }
  174. /* 成果池:只有过程层的成果参与筛选与归纳交叉(沉淀/记录层单独展示) */
  175. function isProcessArtifact(a) {
  176. return a.layer === "process";
  177. }
  178. /* 由成果记录反推:这个成员在哪个阶段最远 */
  179. function furthestStage(arts) {
  180. let best = null;
  181. for (const a of arts) {
  182. if (a.stageObj && (!best || a.stageObj.no > best.no)) best = a.stageObj;
  183. }
  184. return best;
  185. }
  186. function normProject(raw, artifacts) {
  187. const mine = artifacts.filter((a) => a.projectId === raw.objectId);
  188. const st = T.stageOf(raw.stage);
  189. return {
  190. kind: "project",
  191. id: raw.objectId,
  192. title: raw.title || "(未命名项目)",
  193. ownerName: raw.ownerName || "",
  194. ownerKey: raw.ownerSession || raw.ownerName || "",
  195. stageObj: st || furthestStage(mine),
  196. stage: st ? st.label : (mine.length ? furthestStage(mine).label : "—"),
  197. status: raw.status || "",
  198. statusLabel: T.STATUS[raw.status] || raw.status || "—",
  199. priority: T.PRIORITIES.includes(raw.priority) ? raw.priority : (raw.priority || ""),
  200. category: raw.category || "",
  201. summary: raw.summary || "",
  202. score: typeof raw.score === "number" && !isNaN(raw.score) ? raw.score : null,
  203. layer: raw.layer || "process",
  204. lastActiveAt: U.latest([raw.updatedAt, raw.lastActiveAt, ...mine.map((a) => a.createdAt)]),
  205. createdAt: raw.createdAt || null,
  206. upload: normUpload(raw, raw.ownerName, raw.ownerSession),
  207. artifactIds: mine.map((a) => a.id),
  208. artifactCount: mine.length,
  209. processCount: mine.filter(isProcessArtifact).length
  210. };
  211. }
  212. /* 归纳模型:交叉人数、原文证据数、来源记录、证据是否与级别对齐。
  213. * 硬规则(任务书 3.1):没有出身的记录不许进沉淀层。
  214. * - 被归纳引用的成果若缺 ownerKey,不升 sediment,计为"无出身"并在页面上标出;
  215. * - 归纳的支撑记录里只要有无出身的,归纳标记 originBroken,不显示为"可回灌"。 */
  216. function normInsight(raw, artifactById, sourceById) {
  217. const pointed = Array.isArray(raw.derivedFrom) ? raw.derivedFrom : [];
  218. const artIds = Array.isArray(raw.derivedArtifacts)
  219. ? raw.derivedArtifacts.filter((id) => artifactById[id])
  220. : pointed.filter((id) => artifactById[id]);
  221. const srcIds = Array.isArray(raw.derivedSources)
  222. ? raw.derivedSources.filter((id) => sourceById[id])
  223. : pointed.filter((id) => sourceById[id]);
  224. const arts = artIds.map((id) => artifactById[id]);
  225. const owners = new Set(arts.map((a) => a.ownerKey).filter(Boolean));
  226. const quoteCount = arts.reduce((n, a) => n + a.quoteCount, 0);
  227. const verifiedArts = arts.filter((a) => a.evidenceLevel === "verified").length;
  228. const mentionCount = raw.crossRefCount != null ? raw.crossRefCount : arts.length;
  229. const ownerCount = owners.size || raw.ownerCount || raw.distinctOwnerCount || 0;
  230. const noOrigin = arts.filter((a) => !a.ownerKey);
  231. /* 证据对齐:归纳自称有原文,但支撑记录里没有任何带原文的 */
  232. const unaligned = verifiedArts === 0;
  233. return {
  234. kind: "insight",
  235. id: raw.objectId,
  236. title: raw.title || "(未命名结论)",
  237. body: raw.body || "",
  238. category: raw.category || "",
  239. layer: "sediment",
  240. artifactIds: artIds,
  241. sourceIds: srcIds,
  242. artifacts: arts,
  243. sources: srcIds.map((id) => sourceById[id]),
  244. ownerCount,
  245. owners: [...owners],
  246. mentionCount,
  247. quoteCount,
  248. verifiedArts,
  249. unaligned,
  250. noOriginCount: noOrigin.length,
  251. originBroken: noOrigin.length > 0,
  252. status: raw.status || "draft",
  253. reviewedBy: raw.reviewedBy || "",
  254. citedWith: Array.isArray(raw.citedWith) ? raw.citedWith : [],
  255. createdAt: raw.createdAt || null,
  256. updatedAt: raw.updatedAt || null,
  257. _raw: raw
  258. };
  259. }
  260. function normSession(raw, memberById) {
  261. const st = T.stageOf(raw.stage);
  262. return {
  263. kind: "session",
  264. id: raw.objectId,
  265. studentName: raw.studentName || "",
  266. groupId: raw.groupId || "",
  267. stageObj: st,
  268. stage: st ? st.label : (raw.stage || "—"),
  269. step: raw.step || "",
  270. msgCount: raw.msgCount || 0,
  271. lastActiveAt: raw.lastActiveAt || raw.updatedAt || null,
  272. createdAt: raw.createdAt || null,
  273. hasSubmitted: Boolean(memberById && memberById.delivered),
  274. seed: Boolean(raw.seedKey)
  275. };
  276. }
  277. function build(raw) {
  278. const artifacts = raw.artifact.map(normArtifact);
  279. const artifactById = {};
  280. artifacts.forEach((a) => { artifactById[a.id] = a; });
  281. const sources = raw.source.map(normSource);
  282. const sourceById = {};
  283. sources.forEach((s) => { sourceById[s.id] = s; });
  284. /* 被归纳引用的成果自动升入沉淀层(前提:有出身) */
  285. const cited = new Set();
  286. raw.insight.forEach((i) => (i.derivedFrom || []).forEach((id) => cited.add(id)));
  287. artifacts.forEach((a) => {
  288. a.citedByInsight = cited.has(a.id);
  289. if (a.citedByInsight && a.layer === "process" && a.ownerKey) a.layer = "sediment";
  290. });
  291. const projects = raw.project.map((p) => normProject(p, artifacts));
  292. const projectById = {};
  293. projects.forEach((p) => { projectById[p.id] = p; });
  294. const insights = raw.insight.map((i) => normInsight(i, artifactById, sourceById));
  295. /* 成员:人名优先作为身份,会话为进度来源 */
  296. const memberMap = new Map();
  297. function touchMember(name, key) {
  298. if (!name) return;
  299. let m = memberMap.get(name);
  300. if (!m) {
  301. m = { name, sessionIds: [], artifactIds: [], projectIds: [], stageObj: null, done: false,
  302. lastActiveAt: null, msgCount: 0, groupId: "", stuck: false, oppCount: 0, tookPart: false };
  303. memberMap.set(name, m);
  304. }
  305. if (key && !m.sessionIds.includes(key)) m.sessionIds.push(key);
  306. return m;
  307. }
  308. /* 会话表给姓名、组、消息数;成果表是"这个人交了没有"的唯一权威来源 */
  309. /* 同名会话可能有多条(换了小组/重新开始):
  310. * 消息数取最大的那条(不是累加——累加会把"重新开了一次对话"算成双倍),
  311. * 最后活动取最新的那条。 */
  312. const byName = {};
  313. raw.session.forEach((s) => {
  314. if (!s.studentName) return;
  315. const prev = byName[s.studentName];
  316. const lastActiveAt = U.latest([s.lastActiveAt, prev && prev.lastActiveAt]);
  317. byName[s.studentName] = {
  318. ...s,
  319. msgCount: Math.max(s.msgCount || 0, prev ? prev.msgCount : 0),
  320. lastActiveAt,
  321. createdAt: U.latest([s.createdAt, prev && prev.createdAt]),
  322. /* 没有 lastActiveAt 的记录视为"起始时就在这里":不能把它的阶段当成最新活动 */
  323. stale: !s.lastActiveAt,
  324. stage: (lastActiveAt && (s.lastActiveAt ? (!prev || U.timeOf(s.lastActiveAt) >= U.timeOf(prev.lastActiveAt)) : Boolean(prev && prev.lastActiveAt)))
  325. ? s.stage : (prev ? prev.stage : s.stage)
  326. };
  327. });
  328. Object.values(byName).forEach((s) => {
  329. const m = touchMember(s.studentName);
  330. if (!m) return;
  331. m.hasSession = true;
  332. m.groupId = m.groupId || s.groupId || "";
  333. m.msgCount += s.msgCount || 0;
  334. const st = T.stageOf(s.stage);
  335. if (st && (!m.stageObj || st.no > m.stageObj.no)) m.stageObj = st;
  336. /* 历史数据里 lastActiveAt 常常缺失;updatedAt 只说明"记录被改过",不是活动时间。
  337. * 优先 lastActiveAt;没有它的老记录按"课程开始"算(createdAt),不能算成刚刚活跃。 */
  338. m.lastActiveAt = U.latest([m.lastActiveAt, s.lastActiveAt, s.stale ? s.createdAt : null]);
  339. m.stale = Boolean(m.stale || s.stale);
  340. });
  341. artifacts.forEach((a) => {
  342. const m = touchMember(a.ownerName);
  343. if (!m) return;
  344. m.artifactIds.push(a.id);
  345. if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj;
  346. m.lastActiveAt = U.latest([m.lastActiveAt, a.createdAt]);
  347. });
  348. projects.forEach((p) => {
  349. const m = touchMember(p.ownerName);
  350. if (!m) return;
  351. m.projectIds.push(p.id);
  352. m.lastActiveAt = U.latest([m.lastActiveAt, p.lastActiveAt]);
  353. });
  354. /* 机会卡:池子。先建好,再回填到成员(谁的卡进了池子) */
  355. const opportunities = raw.opportunity.map((o) => normOpportunity(o, artifactById, projectById));
  356. /* 已提交 = 有过程层成果;卡住 = 在对话(≥5 条消息)但超时没有新成果。
  357. * 阈值不写死:取"现场节奏"的 N 小时(KF_CONFIG.stuckHours,默认 8h),
  358. * 要么最后活动超时,要么就停在当前阶段超时——讲师在学员卡住的当下就该看到。 */
  359. const stuckHours = (window.KF_CONFIG && window.KF_CONFIG.stuckHours) || 8;
  360. const members = [...memberMap.values()].map((m) => {
  361. m.done = m.artifactIds.length > 0;
  362. m.oppCount = opportunities.filter((o) => o.upload.by && o.upload.by === m.name).length;
  363. m.idleHours = m.lastActiveAt ? (Date.now() - new Date(m.lastActiveAt).getTime()) / 3600000 : null;
  364. /* 没有 lastActiveAt 时退回会话记录时间——它不是"活动时间",但不该因此判为"刚刚活跃" */
  365. const since = m.idleHours == null && m.createdAt ? (Date.now() - new Date(m.createdAt).getTime()) / 3600000 : m.idleHours;
  366. m.stuck = !m.done && m.msgCount >= 5 && (since == null || since > stuckHours);
  367. m.stuckHours = m.stuck ? Math.floor(since || 0) : 0;
  368. return m;
  369. });
  370. members.sort((a, b) => a.name.localeCompare(b.name, "zh"));
  371. const memberByName = Object.fromEntries(members.map((m) => [m.name, m]));
  372. const sessions = raw.session.map((s) => normSession(s, memberMap.get(s.studentName)));
  373. /* 阶段板块:每个阶段产出了什么。归属按成果自身的 stage 字段(谁在哪个阶段交的),
  374. * 机会卡单独作为 S3 的「产机会卡」口径统计,但不混进成果条数。 */
  375. const byStage = T.STAGES.map((st) => {
  376. const arts = artifacts.filter((a) => a.stageObj && a.stageObj.no === st.no && a.layer !== "raw");
  377. const opps = opportunities.filter((o) => o.stageObj && o.stageObj.no === st.no);
  378. return { stage: st, artifacts: arts, opportunities: opps };
  379. });
  380. window.__DBG = window.__DBG || [];
  381. window.__DBG.push(raw.session.length + " sessions; names=" + raw.session.map((x) => x.studentName + ":" + x.lastActiveAt).join(","));
  382. return {
  383. artifacts, artifactById, sources, sourceById, projects, projectById,
  384. insights, insightById: Object.fromEntries(insights.map((i) => [i.id, i])),
  385. opportunities, opportunityById: Object.fromEntries(opportunities.map((o) => [o.id, o])),
  386. byStage,
  387. members, memberByName, sessions, stuckHours, loadedAt: raw.loadedAt, truncated: raw.truncated || []
  388. };
  389. }
  390. window.KF_MODEL = { build };
  391. })();