reports.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851
  1. /**
  2. * core/library/reports.js —— 报告中心与五个类型页的数据口径(纯函数)。
  3. *
  4. * 输入:db(core.buildModel 的产物);输出:页面直接能画的结构。
  5. * 这里只做「归类 / 统计 / 对齐 / 排序」,不决定长什么样。
  6. *
  7. * 几条硬规则:
  8. * 1. 一份成果只归一个类型页(取 artifactType 的第一个映射;映射不到 → 收进「未归类」并在中心页明说)。
  9. * 2. 类型页的关键指标按 TYPE.metricLabels 的顺序**对齐**:未取到值的指标保留列位显示「—」,
  10. * 这样同一行的卡片可以直接横向比较,而不是各自排版。
  11. * 3. 「参照」一律给得出:每张卡有 relative = 本类型内该指标的位置(低/中/高 + 名次),
  12. * 没有历史数据时不编「本周新增」,改给「本周窗口」(有创建时间就能算,是真的)。
  13. * 4. 关键结论:能算的一律算(报告执行摘要、原文摘录、关键指标),算不出的如实标「正文未入知识库」,
  14. * 不编一句话。
  15. */
  16. import { STAGES, stageOf, ARTIFACT_TYPES, EVIDENCE_LEVELS, OPP_EVIDENCE, PRIORITIES, UPLOAD_METHODS, STAGE_TONE, evidenceSegments, uploadMethodLabel } from '../taxonomy/taxonomy.js';
  17. import { toTime, toIso, fmtAgo, cjkSpace } from '../kfs/util.js';
  18. import { reportOverview, findingsOf, pipelineSummary } from '../report.js';
  19. import { REPORT_TYPES, REPORT_TYPE_KEYS, reportTypeOf } from './types.js';
  20. const DAY = 86400000;
  21. const WEEK = 7 * DAY;
  22. /** artifactType(库里存的)→ 类型页 key。多映射时取第一个。 */
  23. export const TYPE_FOR_ARTIFACT = Object.freeze({
  24. category_report: 'category',
  25. assessment_report: 'concept',
  26. competitor_report: 'competitor',
  27. voc_report: 'voc',
  28. pain_list: 'pain',
  29. concept: 'concept',
  30. opportunity_card: 'competitor',
  31. });
  32. export const artifactTypeLabel = (t) => ARTIFACT_TYPES[t] || (t ? String(t) : '(未标类型)');
  33. /** 证据强度排序用(verified 最高) */
  34. const EV_RANK = Object.fromEntries(EVIDENCE_LEVELS.map((e) => [e.key, e.rank]));
  35. const evLabel = (k) => (EVIDENCE_LEVELS.find((e) => e.key === k) || EVIDENCE_LEVELS[3]).label;
  36. /** 从「成果标题 - 类型后缀」里取主题(seed 与 Agent 产出的标题都是这个格式) */
  37. export function subjectOf(artifact) {
  38. const t = String(artifact?.title || '').replace(/\s*-\s*[a-z_]+$/, '').trim();
  39. return t || '(未命名)';
  40. }
  41. /** 标题里 key 自身的出现次数(竞品篇数这类指标的真实量化口径) */
  42. export function countKey(title, key) {
  43. const hay = String(title || '');
  44. const needle = String(key || '');
  45. if (!needle) return 0;
  46. let n = 0;
  47. let i = hay.indexOf(needle);
  48. while (i !== -1) {
  49. n++;
  50. i = hay.indexOf(needle, i + needle.length);
  51. }
  52. return n;
  53. }
  54. /* ============================================================
  55. * 卡片字段(一份报告一张卡:阶段 / 品类 / 状态 / 证据 / 评分 / 有全文)
  56. * ============================================================ */
  57. const CATEGORIES = Object.freeze(['拌面', '汤面', '炒面', '杯面']);
  58. /** 证据档位:高 / 中 / 低 / 待补(成果按原文等级归并,机会卡按自己的三档) */
  59. export function evidenceTierOf(e) {
  60. if (e.kind === 'opportunity') {
  61. const lv = OPP_EVIDENCE.find((x) => x.key === e.evidenceKey);
  62. return lv ? lv.label : '待补';
  63. }
  64. const rank = EVIDENCE_LEVELS.find((x) => x.key === e.original)?.rank ?? 0;
  65. return rank >= 3 ? '高' : rank === 2 ? '中' : rank === 1 ? '低' : '待补';
  66. }
  67. /** 品类色块用:优先品类标签,其次标题里出现过的品类名(认不出就不算,不猜) */
  68. export function categoryOf(e) {
  69. const tags = [...(e.tags || []), ...(e.rawTitle ? [e.rawTitle] : []), ...(e.title ? [e.title] : [])];
  70. return CATEGORIES.find((c) => tags.some((t) => String(t).includes(c))) || '';
  71. }
  72. /** 卡片上要显示的「证据 / 引用 / 评分」四个数 + 视觉标记 */
  73. export function cardFacts(e) {
  74. const evidence = evidenceTierOf(e);
  75. const citations = e.citedBy || e.quoteCount || 0;
  76. return {
  77. stage: e.stage ? e.stage.code : '',
  78. stageTone: e.stage ? STAGE_TONE[e.stage.code] || 'slate' : 'slate',
  79. category: categoryOf(e),
  80. status: e.status || '',
  81. statusLabel: e.kind === 'opportunity' ? (e.status === 'approved' ? '已立项' : e.status === 'rejected' ? '已否决' : '候选') : '',
  82. priority: e.priority || '',
  83. evidence,
  84. evidenceSeg: Math.min(3, e.kind === 'opportunity'
  85. ? (OPP_EVIDENCE.find((x) => x.key === e.evidenceKey)?.rank ?? 0)
  86. : evidenceSegments(e.original)),
  87. citations,
  88. score: e.score != null ? Math.round(e.score) : null,
  89. fullText: e.body === 'text' || e.body === 'report',
  90. thumb: thumbOf(e),
  91. };
  92. }
  93. /**
  94. * 卡片缩略图:优先库里的截图字段(report.thumb / meta.thumbUrl),没有就用报告自身的结构画一张——
  95. * 章节条 + 结论点,来自真实数据,不编图。
  96. */
  97. export function thumbOf(e) {
  98. const a = e._a || {};
  99. const url = a.report?.thumb || a.meta?.thumbUrl || '';
  100. if (url) return { kind: 'img', url: String(url) };
  101. const r = a.report;
  102. if (!r) return null;
  103. const chapters = Object.values(r.chapters || {});
  104. const bars = chapters.slice(0, 7).map((c) => {
  105. const n = (Array.isArray(c.sections) ? c.sections : []).length;
  106. return Math.max(1, n);
  107. });
  108. const dots = Math.min(8, Object.keys(r.evidence || {}).length || e.quoteCount || 0);
  109. return { kind: 'chart', bars, dots };
  110. }
  111. /** 六个分类维度的取值与计数(每个值带数量;multi=true 的维度可多选) */
  112. export const CARD_DIMS = Object.freeze([
  113. { key: 'stage', label: '阶段', values: () => STAGES.map((s) => ({ v: s.code, label: s.code, tone: STAGE_TONE[s.code] })) },
  114. { key: 'cat', label: '品类', values: () => CATEGORIES.map((c) => ({ v: c, label: c })) },
  115. { key: 'pri', label: '优先级', values: () => PRIORITIES.map((p) => ({ v: p, label: cjkSpace(`${p} 级`) })) },
  116. { key: 'ev', label: '证据', values: () => [{ v: '高', label: '高' }, { v: '中', label: '中' }, { v: '低', label: '低' }, { v: '待补', label: '待补' }] },
  117. { key: 'm', label: '方式', values: () => Object.entries(UPLOAD_METHODS).map(([k, label]) => ({ v: k, label })) },
  118. { key: 'who', label: '人', values: () => [] },
  119. ]);
  120. /** 一组的维度取值是否命中当前筛选(同一维度内多选 = 或;维度之间 = 且) */
  121. const hit = (want, got) => !want.length || want.includes(got);
  122. export function cardMatches(e, q = {}) {
  123. const f = cardFacts(e);
  124. if (!hit(q.stage || [], f.stage)) return false;
  125. if (!hit(q.cat || [], f.category)) return false;
  126. if (!hit(q.pri || [], f.priority)) return false;
  127. if (!hit(q.ev || [], f.evidence)) return false;
  128. if (!hit(q.m || [], e.methodKey || '')) return false;
  129. if (q.who && e.owner !== q.who) return false;
  130. if (q.hasEv && !hasEvidence(e)) return false;
  131. if (q.full && !f.fullText) return false;
  132. if (q.recent && !(toTime(e.at) && Date.now() - toTime(e.at) <= WEEK)) return false;
  133. return true;
  134. }
  135. /** 每个维度值下还剩几份(按「除本维度外的全部筛选」算,叠加时数字也是真的) */
  136. export function cardFacetCounts(entries, q = {}) {
  137. return Object.fromEntries(CARD_DIMS.map((d) => {
  138. const rest = { ...q, [d.key]: [] };
  139. const pool = entries.filter((e) => cardMatches(e, rest));
  140. const counts = {};
  141. for (const e of pool) {
  142. const f = cardFacts(e);
  143. const k = { cat: 'category', pri: 'priority', ev: 'evidence' }[d.key] || d.key;
  144. const v = d.key === 'who' ? e.owner : d.key === 'm' ? e.methodKey : f[k];
  145. if (v) counts[v] = (counts[v] || 0) + 1;
  146. }
  147. return [d.key, counts];
  148. }));
  149. }
  150. export const CARD_SORTS = Object.freeze([['score', '评分'], ['new', '最新'], ['ev', '证据']]);
  151. export function sortEntries(entries, sort = 'new') {
  152. const rank = (e) => cardFacts(e).evidenceSeg;
  153. const arr = entries.slice();
  154. if (sort === 'score') arr.sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || (b.at || '').localeCompare(a.at || ''));
  155. else if (sort === 'ev') arr.sort((a, b) => rank(b) - rank(a) || (b.at || '').localeCompare(a.at || ''));
  156. else arr.sort((a, b) => (b.at || '').localeCompare(a.at || ''));
  157. return arr;
  158. }
  159. /* ============================================================
  160. * 三块结构:手头事实面 / 空态说明书 / 间距图例
  161. * ============================================================ */
  162. const FACTS = {
  163. category: [
  164. '品类 = 库里的 tags[0](如「拌面 / 汤面 / 炒面 / 杯面」);没有标签的成果不会假装属于某个品类。',
  165. '「赛道」是树状结构:品类 → 赛道 → SKU。S1 组 A 负责铺品类地图,别人的竞品报告会挂到对应赛道下。',
  166. '市场结构目前用 KfSource(type=category)承载:规模、增速、集中度、价格带分布。',
  167. ],
  168. competitor: [
  169. '每份竞品报告能定位到:哪个品类(tags[0])、哪个阶段(S1–S5)、哪条原声(引用的归纳)。',
  170. '对比矩阵的维度顺序固定:竞品 → 口感 / 价格 / 渠道 / 场景 → 空档,所有品牌按同一张表比。',
  171. '「差异化空档」= 没有任何一家占住的格子;空档必须能追到原文,不能只写「市场空白」。',
  172. ],
  173. voc: [
  174. '原文摘录来自报告的结论块(每条结论下挂的 quotes),带平台 · 作者 · 点赞;没有原文的结论会明确标出。',
  175. '情感 / 主题 / 人群切分由 VOC 报告的文本字段提供;字段没到就显示「未标注」,不从标题猜。',
  176. ],
  177. pain: [
  178. '频次 = 提到这条痛点的人 / 条数;严重度(1–5)由整理人标注。两者都有的痛点才能进二维图。',
  179. '「已被解决」的判定依据:该痛点关联的机会卡已立项,或报告里明确写出现有产品已覆盖。',
  180. ],
  181. concept: [
  182. '概念提案挂在机会卡下面:提案 → 关联机会卡 → 机会卡 → 产物。',
  183. '关键指标(目标人群 / 场景 / 一句话定位 / 卖点阶梯)来自 concept 的正文与标签。',
  184. ],
  185. };
  186. const LEGEND = {
  187. category: '实心竖线 = 当前这份;淡色底线 = 本类型里其他份数的中位水平。',
  188. competitor: '实心竖线 = 当前这份;淡色底线 = 本类型里其他份数的中位水平。',
  189. voc: '圆点 = 本份有原文的条数;横轴 0 → 最多的一份。',
  190. pain: '圆点 = 频次(说的人多不多);纵轴 = 严重度(让用户多难受)。',
  191. concept: '圆点 = 关联机会的多人平均分;横轴 0 → 100。',
  192. };
  193. export function handFacts(typeKey) {
  194. const t = reportTypeOf(typeKey);
  195. return { question: t?.question || '', looks: t?.looks || '', facts: FACTS[typeKey] || [], legend: LEGEND[typeKey] || '' };
  196. }
  197. /** 空态说明书:这个位置按设计将来会出现什么、谁来填哪个字段、填完长什么样 */
  198. export const EMPTY_PLAN = Object.freeze({
  199. category: {
  200. will: '按赛道列出每个品类的竞争结构:赛道 → 竞品数量 → 价格带 → 集中度。',
  201. who: 'S1 品类市场分析 · 各组品类调研 Agent',
  202. field: 'KfSource.sourceType = category(payload.track / payload.skuCount / payload.density)',
  203. looks: '一张「赛道 × 竞争密度」矩阵,点击赛道下钻到该赛道的玩家列表',
  204. },
  205. competitor: {
  206. will: '每个竞品品牌一张卡(口感 / 价格 / 渠道 / 场景 / 优缺点),所有人按同一张矩阵比。',
  207. who: '任一阶段 · 品类调研 Agent · 会话或 Skill 上传',
  208. field: 'KfArtifact.artifactType = competitor_report',
  209. looks: '竞品 × 维度的对比矩阵 + 差异化空档列表',
  210. },
  211. voc: {
  212. will: '原话摘录(平台 · 作者 · 点赞)→ 按主题聚合 → 情感比例 → 人群切分。',
  213. who: 'S2 用户需求与痛点 · 用户访谈 / 评论抓取',
  214. field: 'KfArtifact.artifactType = voc_report(或 report.evidence[*].quotes)',
  215. looks: '结论卡 + 每条结论旁的「N 条原文」锚点,点开看原话和出处',
  216. },
  217. pain: {
  218. will: '一条痛点一张卡:频次、严重度、是否已被解决、对应哪张机会卡。',
  219. who: 'S2 用户需求与痛点 · 整理人手工标注严重度',
  220. field: 'KfArtifact.artifactType = pain_list',
  221. looks: '频次(横轴)× 严重度(纵轴)的二维图,右上角是最要命的',
  222. },
  223. concept: {
  224. will: '每个方案:一句话定位、目标人群、场景、卖点阶梯、关联的机会卡。',
  225. who: 'S5 场景与卖点设计 · 概念设计 Agent',
  226. field: 'KfArtifact.artifactType = concept',
  227. looks: '方案卡片流,卡片上直接给关键指标,按关联机会卡的评分排序',
  228. },
  229. });
  230. /* ============================================================
  231. * 行 → 视图条目(所有类型共用同一个条目形状 → 横向对齐)
  232. * ============================================================ */
  233. /**
  234. * 一条成果 → 报告中心/类型页用的条目。
  235. * metrics 是字符串数组,长度与 type.metricLabels 一致,缺的填 '—'。
  236. */
  237. /** 类型页 / 阶段页的卡片条目:类型页的条目带上库里原始行(缩略图、结构化摘要用) */
  238. export function entryOfArtifact(db, a, typeKey) {
  239. const e = entryOfArtifactRow(db, a, typeKey);
  240. e._a = a;
  241. return e;
  242. }
  243. function entryOfArtifactRow(db, a, typeKey) {
  244. const report = a.report ? reportOverview(db, a.id) : null;
  245. const type = reportTypeOf(typeKey);
  246. const kpis = report?.kpis || [];
  247. const firstKpi = kpis[0] || null;
  248. const subject = subjectOf(a);
  249. const br = String(a.title || '').match(/(([^)]+))/)?.[1] || '';
  250. let metrics;
  251. if (typeKey === 'competitor') {
  252. metrics = [
  253. String(countKey(a.title, br) || 0),
  254. a.tags[0] || '—',
  255. a.quotes.length ? `原文 ${a.quotes.length}` : a.evidenceLabel,
  256. a.stageObj?.code || '—',
  257. ];
  258. } else if (typeKey === 'voc') {
  259. // 卡片顺序:原文条数 / 阶段 / 提到(标题主体被提到的次数,没有就是「—」)/ 证据
  260. const mentions = countKey(a.title, br);
  261. metrics = [String(a.quotes.length), a.stageObj?.code || '—', mentions ? String(mentions) : '—', a.evidenceLabel];
  262. } else if (typeKey === 'pain') {
  263. // 卡片顺序:频次(归并后,列表层回填)/ 严重度(标注了才有)/ 解决状态 / 阶段
  264. metrics = ['—', a.severity ? `${a.severity}/5` : '待标注', '—', a.stageObj?.code || '—'];
  265. } else if (typeKey === 'concept') {
  266. metrics = [subject || '—', '—', a.evidenceLabel, a.stageObj?.code || '—'];
  267. } else {
  268. metrics = [a.tags[0] || '—', '—', '—', a.stageObj?.code || '—'];
  269. }
  270. // 全文能不能看:report 正文 / rawText 正文 / 都没有
  271. const body = a.report ? 'report' : a.rawText ? 'text' : 'none';
  272. const lead = report?.executive?.[0] || report?.subtitle || firstLead(a);
  273. return {
  274. kind: 'artifact',
  275. id: a.id,
  276. _a: a,
  277. title: report?.title ? `${report.title}` : subject,
  278. rawTitle: a.title,
  279. titleSuffix: artifactTypeLabel(a.type),
  280. subject,
  281. typeKey,
  282. artifactType: a.type,
  283. stage: a.stageObj ? { code: a.stageObj.code, name: a.stageObj.name, label: a.stageObj.label } : null,
  284. owner: a.ownerName || '',
  285. agent: a.upload.agentName,
  286. agentId: a.upload.agentId,
  287. method: a.upload.methodLabel || '',
  288. methodKey: a.upload.uploadMethod,
  289. at: a.upload.uploadedAt || a.createdAt,
  290. evidence: a.evidenceLabel,
  291. evidenceKey: a.evidenceLevel,
  292. /** 与机会卡的三档(高/中/低)归一:只有 original(成果证据等级)能判断「有原文/佐证」 */
  293. original: a.evidenceLevel,
  294. metrics,
  295. lead,
  296. leadSource: report?.executive?.length ? '报告执行摘要' : report?.subtitle ? '报告副标题' : firstLead(a) ? '正文开头' : '',
  297. body,
  298. href: body === 'report' ? `/report/${a.id}` : '',
  299. hasReport: Boolean(a.report),
  300. quoteCount: report?.quoteCount ?? a.quotes.length,
  301. quotes: importantQuotes(db, a).slice(0, 2),
  302. people: a.quotes.map((q) => q.author).filter(Boolean).slice(0, 3),
  303. findings: report ? findingsOf(db, a.id).slice(0, 3) : [],
  304. report: report ? { chapterCount: report.chapterCount, conclusionCount: report.conclusionCount, refsInLib: report.refsInLib } : null,
  305. citedBy: a.citedByInsightIds.length,
  306. tags: a.tags,
  307. createdAt: a.createdAt,
  308. };
  309. }
  310. /** 正文开头(rawText)——截一段作为「关键结论」的替身,并标明是正文开头 */
  311. function firstLead(a) {
  312. const t = String(a.rawText || '').trim();
  313. if (!t || t.length < 8) return '';
  314. return t.length > 120 ? t.slice(0, 120) + '…' : t;
  315. }
  316. /** 报告里的原文(按点赞取前几条):结论 → quotes */
  317. export function importantQuotes(db, a) {
  318. if (!a.report) return [];
  319. const out = [];
  320. for (const f of findingsOf(db, a.id)) {
  321. const e = a.report.evidence?.[f.evId] || {};
  322. for (const q of e.quotes || []) {
  323. out.push({ text: String(q.text || ''), ref: String(q.ref || ''), likes: q.likes ?? null, author: String(q.author || ''), claim: f.conclusion });
  324. }
  325. }
  326. return out.sort((x, y) => (y.likes ?? -1) - (x.likes ?? -1)).slice(0, 6);
  327. }
  328. /** 一条机会卡 → 条目(竞品 / VOC 页的证据补充项、概念页的主项) */
  329. export function entryOfOpportunity(o, typeKey) {
  330. const linked = (o.linkedArtifacts || []).filter((x) => !x.missing);
  331. const hasEvidence = linked.some((x) => x.quotes.length > 0 || x.report) || o.quoteCount > 0;
  332. return {
  333. kind: 'opportunity',
  334. id: o.id,
  335. _a: o,
  336. title: o.title,
  337. rawTitle: o.title,
  338. titleSuffix: '机会卡',
  339. subject: o.title,
  340. typeKey,
  341. artifactType: 'opportunity_card',
  342. stage: o.stageObj ? { code: o.stageObj.code, name: o.stageObj.name, label: o.stageObj.label } : null,
  343. owner: o.upload.uploadedBy,
  344. agent: o.upload.agentName,
  345. agentId: o.upload.agentId,
  346. method: o.upload.methodLabel,
  347. methodKey: o.upload.uploadMethod,
  348. at: o.upload.uploadedAt,
  349. evidence: o.evidenceLabel,
  350. evidenceKey: o.evidenceLevel,
  351. original: '',
  352. // 与竞品报告同列对齐:竞品篇数 / 对位品类 / 证据 / 阶段
  353. metrics: typeKey === 'concept'
  354. ? [linked.length ? String(linked.length) : '—', o.stats.avg == null ? '—' : String(o.stats.avg), o.evidenceLabel, o.stageObj?.code || '—']
  355. : [String(o.linkedArtifactIds.length || 0), o.category || '—', o.evidenceLabel, o.stageObj?.code || '—'],
  356. lead: o.oneLiner || '',
  357. leadSource: o.oneLiner ? '机会卡一句话' : '',
  358. body: 'drawer',
  359. href: '',
  360. hasReport: Boolean(o.reportArtifact),
  361. quoteCount: o.quoteCount,
  362. quotes: [],
  363. findings: [],
  364. report: null,
  365. citedBy: o.citeCount,
  366. linkedCount: linked.length,
  367. score: o.stats.avg,
  368. status: o.status,
  369. priority: o.priority,
  370. tags: o.category ? [o.category] : [],
  371. hasEvidence,
  372. linkedPeople: [...new Set(linked.map((x) => x.ownerName).filter(Boolean))],
  373. createdAt: o.createdAt,
  374. };
  375. }
  376. /** KfSource 行 → 条目(品类分析的原材料) */
  377. export function entryOfSource(s, typeKey) {
  378. const p = s.payload && typeof s.payload === 'object' ? s.payload : {};
  379. return {
  380. kind: 'source',
  381. id: s.id,
  382. _a: s,
  383. title: s.title,
  384. rawTitle: s.title,
  385. titleSuffix: '资料',
  386. subject: s.title,
  387. sourceType: s.sourceType,
  388. typeKey,
  389. stage: null,
  390. owner: s.meta?.ownerName || '',
  391. agent: s.meta?.agentName || '',
  392. method: s.meta?.uploadMethod ? uploadMethodLabel(s.meta.uploadMethod) : '',
  393. at: s.createdAt,
  394. evidence: '—',
  395. metrics: [p.track || '—', p.players != null ? String(p.players) : '—', p.skuCount != null ? String(p.skuCount) : '—', p.density || '—'],
  396. lead: String(s.summary || s.rawText || ''),
  397. leadSource: '资料摘要',
  398. body: s.rawText ? 'text' : 'none',
  399. links: (s.sources || []).map((x) => x.ref).filter(Boolean),
  400. payload: p,
  401. placeholder: Boolean(s.meta?.placeholder),
  402. createdAt: s.createdAt,
  403. };
  404. }
  405. /** 品类条目的赛道:tags[0] → 资料 payload.track */
  406. function trackOf(e) {
  407. return e.tags?.[0] || e.payload?.track || '未归类';
  408. }
  409. /* ============================================================
  410. * 类型页取数
  411. * ============================================================ */
  412. /** 某个类型页的全部条目(不按阶段过滤),按时间倒序 */
  413. export function entriesForType(db, typeKey) {
  414. const type = reportTypeOf(typeKey);
  415. if (!type) return [];
  416. const arts = db.artifacts.filter((a) => TYPE_FOR_ARTIFACT[a.type] === typeKey).map((a) => entryOfArtifact(db, a, typeKey));
  417. if (typeKey === 'pain') fillPainMetrics(db, arts);
  418. const opps = type.fields.artifactTypes.includes('opportunity_card')
  419. ? db.opportunities.map((o) => entryOfOpportunity(o, typeKey))
  420. : [];
  421. const srcs = type.fields.sourceTypes?.length
  422. ? db.sources.filter((s) => type.fields.sourceTypes.includes(s.sourceType)).map((s) => entryOfSource(s, typeKey))
  423. : [];
  424. return [...arts, ...opps, ...srcs].sort((a, b) => (b.at || '').localeCompare(a.at || ''));
  425. }
  426. /** 痛点卡片:把「频次 / 解决状态」换成归并后的真实值(同一条痛点多人整理只算一条) */
  427. function fillPainMetrics(db, arts) {
  428. const bySubject = new Map(db.opportunities.map((o) => [o.title, o]));
  429. const groups = new Map();
  430. for (const e of arts) {
  431. const k = painKey(db.artifactById[e.id]);
  432. if (!groups.has(k)) groups.set(k, []);
  433. groups.get(k).push(e);
  434. }
  435. for (const [, items] of groups) {
  436. const verified = items.some(hasEvidence);
  437. const opp = [...bySubject.values()].find((o) => o.title === items[0].subject || o.title.includes(items[0].subject) || items[0].subject.includes(o.title));
  438. const sev = items.map((x) => db.artifactById[x.id]?.severity).filter((n) => Number.isFinite(n) && n > 0);
  439. for (const e of items) {
  440. e.metrics = [
  441. String(items.length),
  442. sev.length ? `${Math.round((sev.reduce((a, b) => a + b, 0) / sev.length) * 10) / 10}/5` : '待标注',
  443. opp ? opp.statusLabel : verified ? '未对照' : '待补证据',
  444. e.stage?.code || '—',
  445. ];
  446. e.frequency = items.length;
  447. e.solvedLabel = opp ? opp.statusLabel : '';
  448. e.linkedOpp = opp ? { id: opp.id, title: opp.title, status: opp.status, statusLabel: opp.statusLabel, score: opp.stats.avg } : null;
  449. }
  450. }
  451. }
  452. /**
  453. * 是否「有证据」:成果的原始证据等级为 verified / supported 才算(仅推测 / 无证据不算);
  454. * 机会卡按自己的三档(高 / 中 算,低 / 待补 不算)。类型页与阶段页共用这一条。
  455. */
  456. export const hasEvidence = (e) => Boolean(e && (e.original === 'verified' || e.original === 'supported' || e.evidenceKey === 'high' || e.evidenceKey === 'medium'));
  457. /** 阶段二次筛选 + 只看有证据(类型页 / 阶段页共用) */
  458. export function filterEntries(entries, { stage = '', evidenceOnly = false, mine = '' } = {}) {
  459. return entries.filter((e) => {
  460. if (stage && e.stage?.code !== stage) return false;
  461. if (evidenceOnly && !hasEvidence(e)) return false;
  462. if (mine && e.owner !== mine) return false;
  463. return true;
  464. });
  465. }
  466. /** 按阶段计数(给类型页的阶段筛选条提供「有几个」) */
  467. export function stageTally(entries) {
  468. const m = Object.fromEntries(STAGES.map((s) => [s.code, 0]));
  469. let none = 0;
  470. for (const e of entries) {
  471. if (e.stage?.code && m[e.stage.code] != null) m[e.stage.code]++;
  472. else none++;
  473. }
  474. return { byStage: m, none, total: entries.length };
  475. }
  476. /* ============================================================
  477. * 关键结论的段落级摘要(卡片上直接给) + 报告阅读页的章节摘要
  478. * ============================================================ */
  479. /**
  480. * 段落摘要:从报告章节里抽「最像结论的句子」——带数字 / 判断词的句子排前面,最多 3 条。
  481. * 这是纯文本抽取,不改写、不生成。
  482. */
  483. export function sectionDigest(section, limit = 2) {
  484. const texts = [];
  485. const push = (t) => { const s = String(t || '').trim(); if (s.length >= 6) texts.push(s); };
  486. const d = section?.data;
  487. switch (section?.kind) {
  488. case 'para': push(d?.text ?? section.text); break;
  489. case 'longpara': String(section.text || '').split(/\n{1,}/).forEach(push); break;
  490. case 'findings': (d || []).forEach((f) => push(f.conclusion)); break;
  491. case 'table': case 'matrix': (d?.rows || []).forEach((r) => push(Array.isArray(r) ? r[0] : r)); break;
  492. case 'two': (d?.can || []).slice(0, 1).forEach(push); break;
  493. case 'ladder': push(d?.statement); break;
  494. default: break;
  495. }
  496. const score = (s) => (/\d/.test(s) ? 2 : 0) + (/是|不是|应该|没有|只有|必须|杀|空档|空白|集中在/.test(s) ? 2 : 0);
  497. return texts
  498. .map((s, i) => ({ text: s.length > 90 ? s.slice(0, 90) + '…' : s, s: score(s), i }))
  499. .sort((a, b) => b.s - a.s || a.i - b.i)
  500. .slice(0, limit)
  501. .map((x) => x.text);
  502. }
  503. /** 章节 → 摘要(目录页 / 类型页给报告卡用:每章一句话) */
  504. export function chapterDigests(db, artifactId, limit = 6) {
  505. const a = db.artifactById[artifactId];
  506. if (!a?.report) return [];
  507. return (a.report.chapters || []).slice(0, limit).map((ch, i) => {
  508. const secs = Array.isArray(ch.sections) ? ch.sections : [];
  509. const digest = secs.flatMap((s) => sectionDigest(s, 1)).slice(0, 1);
  510. const firstFindings = secs.find((s) => s.kind === 'findings');
  511. return {
  512. id: ch.id || `ch${i + 1}`,
  513. no: ch.no ?? i + 1,
  514. title: String(ch.title || ''),
  515. summary: digest[0] || String(ch.lead || '').slice(0, 90),
  516. findings: (firstFindings?.data || []).slice(0, 3).map((f) => String(f.conclusion || '')),
  517. };
  518. });
  519. }
  520. /* ============================================================
  521. * 各类型页的「结构性认识」区块
  522. * ============================================================ */
  523. /** 赛道分布(品类页):赛道 → 份数、参与人、竞品篇数、平均证据 */
  524. export function tracksOf(db, entries) {
  525. const m = new Map();
  526. for (const e of entries) {
  527. const k = trackOf(e);
  528. if (!m.has(k)) m.set(k, { track: k, count: 0, people: new Set(), competitors: 0, evs: [], items: [] });
  529. const g = m.get(k);
  530. g.count++;
  531. if (e.owner) g.people.add(e.owner);
  532. if (e.artifactType === 'competitor_report') g.competitors++;
  533. g.evs.push(EV_RANK[e.evidenceKey] ?? 0);
  534. g.items.push(e);
  535. }
  536. return [...m.values()]
  537. .map((g) => ({
  538. track: g.track,
  539. count: g.count,
  540. people: [...g.people],
  541. competitors: g.competitors,
  542. avgEvidence: g.evs.length ? Math.round((g.evs.reduce((a, b) => a + b, 0) / g.evs.length) * 10) / 10 : 0,
  543. items: g.items,
  544. }))
  545. .sort((a, b) => b.count - a.count);
  546. }
  547. /** 覆盖阶段 × 份数的热力(品类 / 竞品页的「竞争密度」) */
  548. export function stageMatrix(entries) {
  549. const m = Object.fromEntries(STAGES.map((s) => [s.code, { total: 0, byType: {} }]));
  550. for (const e of entries) {
  551. const c = e.stage?.code;
  552. if (!c || !m[c]) continue;
  553. m[c].total++;
  554. const t = e.artifactType === 'opportunity_card' ? '机会卡' : artifactTypeLabel(e.artifactType);
  555. m[c].byType[t] = (m[c].byType[t] || 0) + 1;
  556. }
  557. return m;
  558. }
  559. /** 竞品对比矩阵:行 = 主题(竞品/场景),列 = 固定维度 */
  560. export function competitorMatrix(entries) {
  561. const rows = new Map();
  562. for (const e of entries) {
  563. const k = e.subject;
  564. if (!rows.has(k)) rows.set(k, { subject: k, items: [], stages: new Set(), quotes: 0, subjects: new Set() });
  565. const r = rows.get(k);
  566. r.items.push(e);
  567. if (e.stage) r.stages.add(e.stage.code);
  568. r.quotes += e.quoteCount || 0;
  569. for (const q of e.quotes || []) if (q.claim) r.subjects.add(q.claim);
  570. for (const p of e.people || []) if (p) r.subjects.add(`提到:${p}`);
  571. }
  572. const bestEv = (items) => {
  573. const top = items.slice().sort((a, b) => (EV_RANK[b.evidenceKey] ?? 0) - (EV_RANK[a.evidenceKey] ?? 0))[0];
  574. return top?.evidence || '—';
  575. };
  576. return [...rows.values()]
  577. .map((r) => ({
  578. subject: r.subject,
  579. count: r.items.length,
  580. stageCodes: STAGES.filter((s) => r.stages.has(s.code)).map((s) => s.code),
  581. quotes: r.quotes,
  582. evidence: bestEv(r.items),
  583. evidenceRank: Math.max(...r.items.map((x) => EV_RANK[x.evidenceKey] ?? 0), 0),
  584. highlights: [...r.subjects].slice(0, 2),
  585. items: r.items,
  586. }))
  587. .sort((a, b) => b.count - a.count || b.quotes - a.quotes);
  588. }
  589. /** 差异化空档:从矩阵里找只有一边有内容 / 覆盖阶段最少的行(数据驱动,不编) */
  590. export function gapsOf(matrix) {
  591. if (!matrix.length) return [];
  592. const maxStages = Math.max(...matrix.map((r) => r.stageCodes.length), 1);
  593. const gaps = [];
  594. for (const r of matrix) {
  595. if (r.stageCodes.length < maxStages) {
  596. const missing = STAGES.map((s) => s.code).filter((c) => !r.stageCodes.includes(c));
  597. gaps.push({ subject: r.subject, reason: `只覆盖 ${r.stageCodes.join(' / ') || '—'},${missing.join('、')} 还没人做`, metric: `${r.stageCodes.length}/${maxStages} 阶段`, items: r.items });
  598. }
  599. }
  600. return gaps.sort((a, b) => a.items.length - b.items.length).slice(0, 5);
  601. }
  602. /** VOC:原话池(带出处),按点赞倒序;以及「谁的原文最多」 */
  603. export function quotePool(db, entries) {
  604. const out = [];
  605. for (const e of entries) {
  606. if (e.kind !== 'artifact') continue;
  607. const a = db.artifactById[e.id];
  608. for (const q of importantQuotes(db, a)) out.push({ ...q, from: e.subject, fromId: e.id, owner: e.owner });
  609. }
  610. return out.sort((a, b) => (b.likes ?? -1) - (a.likes ?? -1));
  611. }
  612. /** VOC:有原文 vs 没原文(频次排行 / 证据缺口) */
  613. export function voiceRanking(entries) {
  614. return entries
  615. .filter((e) => e.kind === 'artifact')
  616. .map((e) => ({ ...e, voices: e.quoteCount }))
  617. .sort((a, b) => b.voices - a.voices || (EV_RANK[b.evidenceKey] ?? 0) - (EV_RANK[a.evidenceKey] ?? 0));
  618. }
  619. /** 痛点:频次 × 严重度。频次能算(被多少份成果提到 / 几条原文);严重度没有字段就是 null,不猜。 */
  620. export function painPoints(db, entries) {
  621. const rows = painRanking(db, entries);
  622. return rows.map((r, i) => ({
  623. ...r,
  624. frequency: r.mentions,
  625. severity: r.severity,
  626. solved: r.solvedBy.length > 0 || r.linkedOpps.some((o) => o.status === 'approved'),
  627. }));
  628. }
  629. /**
  630. * 痛点归并键:优先报告里第一条结论的锚点(同一条痛点被不同的人整理,报告会引用同一条结论);
  631. * 没有报告就用标题主体。只做「归并」,不推断内容。
  632. */
  633. export function painKey(artifact) {
  634. if (!artifact) return '';
  635. if (artifact.report?.evidence) {
  636. for (const ev of Object.values(artifact.report.evidence)) if (ev?.claim) return String(ev.claim).trim();
  637. }
  638. return subjectOf(artifact);
  639. }
  640. /** 痛点排行:同一主题被几份成果提到(标题主体相同)+ 证据 + 对应机会卡 */
  641. export function painRanking(db, entries) {
  642. const m = new Map();
  643. for (const e of entries) {
  644. if (e.kind !== 'artifact') continue;
  645. const k = painKey(db.artifactById[e.id]);
  646. if (!m.has(k)) m.set(k, { subject: k, items: [], people: new Set(), stages: new Set(), quotes: 0, severity: null, maxEv: 0 });
  647. const g = m.get(k);
  648. g.items.push(e);
  649. if (e.owner) g.people.add(e.owner);
  650. if (e.stage) g.stages.add(e.stage.code);
  651. g.quotes += e.quoteCount || 0;
  652. g.maxEv = Math.max(g.maxEv, EV_RANK[e.evidenceKey] ?? 0);
  653. }
  654. // 关联:机会卡标题主体 = 痛点主体(或包含)
  655. const bySubject = new Map(db.opportunities.map((o) => [o.title, o]));
  656. return [...m.values()]
  657. .map((g) => {
  658. const opps = [...bySubject.values()].filter((o) => o.title && (o.title === g.subject || o.title.includes(g.subject) || g.subject.includes(o.title)));
  659. const arts = g.items.map((e) => db.artifactById[e.id]).filter(Boolean);
  660. const sev = arts.map((a) => Number(a.severity)).filter((n) => Number.isFinite(n) && n > 0);
  661. return {
  662. id: g.items[0].id,
  663. subject: g.subject,
  664. mentions: g.items.length,
  665. people: [...g.people],
  666. stageCodes: STAGES.filter((s) => g.stages.has(s.code)).map((s) => s.code),
  667. quotes: g.quotes,
  668. severity: sev.length ? Math.round((sev.reduce((a, b) => a + b, 0) / sev.length) * 10) / 10 : null,
  669. evidenceRank: g.maxEv,
  670. linkedOpps: opps.map((o) => ({ id: o.id, title: o.title, status: o.status, statusLabel: o.statusLabel, score: o.stats.avg })),
  671. solvedBy: opps.filter((o) => o.status === 'approved'),
  672. items: g.items,
  673. };
  674. })
  675. .sort((a, b) => b.mentions - a.mentions || b.evidenceRank - a.evidenceRank);
  676. }
  677. /** 概念:方案 → 关联机会 → 关键指标 */
  678. export function conceptsOf(db, entries) {
  679. return entries.map((e) => {
  680. if (e.kind === 'opportunity') {
  681. const a = db.artifactById[e.id];
  682. return { ...e, opp: { id: e.id, title: e.title, score: e.score, status: e.status, statusLabel: db.opportunityById[e.id]?.statusLabel, priority: e.priority, oneLiner: a?.oneLiner || '' }, audience: '', scenario: '' };
  683. }
  684. const opp = db.opportunities.find((o) => o.title === e.subject);
  685. const p = db.projectById[db.artifactById[e.id]?.projectId] || null;
  686. return {
  687. ...e,
  688. opp: opp ? { id: opp.id, title: opp.title, score: opp.stats.avg, status: opp.status, statusLabel: opp.statusLabel, priority: opp.priority, oneLiner: opp.oneLiner } : null,
  689. audience: p?.targetUser || '',
  690. scenario: p?.scenario || '',
  691. };
  692. });
  693. }
  694. /* ============================================================
  695. * 报告中心
  696. * ============================================================ */
  697. /** 报告中心:每个类型几份 + 参照 + 最新几条 + 未归类 */
  698. export function reportCenter(db, { now = Date.now() } = {}) {
  699. const types = REPORT_TYPES.map((t) => {
  700. const entries = entriesForType(db, t.key);
  701. const recent = entries.filter((e) => toTime(e.at) && now - toTime(e.at) <= WEEK).length;
  702. const latest = entries.slice(0, 3);
  703. const people = new Set(entries.map((e) => e.owner).filter(Boolean));
  704. const withBody = entries.filter((e) => e.body !== 'none').length;
  705. return {
  706. type: t,
  707. count: entries.length,
  708. recentWeek: recent,
  709. people: people.size,
  710. withBody,
  711. noBody: entries.length - withBody,
  712. latest,
  713. all: entries,
  714. reportCount: entries.filter((e) => e.hasReport).length,
  715. stageTally: stageTally(entries),
  716. };
  717. });
  718. const mapped = new Set(Object.keys(TYPE_FOR_ARTIFACT));
  719. const unclassified = db.artifacts.filter((a) => !mapped.has(a.type)).map((a) => ({ id: a.id, title: a.title, type: a.type || '(空)', owner: a.ownerName, stage: a.stageObj?.code || '', at: a.createdAt }));
  720. // 类型页允许同一机会卡被不同视角引用;中心清单每条只出现一次,未归类成果也可打开。
  721. const unique = new Map();
  722. for (const group of types) for (const entry of group.all) {
  723. const key = `${entry.kind}:${entry.id}`;
  724. if (!unique.has(key)) unique.set(key, { ...entry, typeName: entry.kind === 'opportunity' ? '机会卡' : group.type.name });
  725. }
  726. for (const item of unclassified) unique.set(`artifact:${item.id}`, { ...entryOfArtifact(db, db.artifactById[item.id], 'other'), typeName: '未归类' });
  727. const entries = [...unique.values()];
  728. const all = types.reduce((n, t) => n + t.count, 0);
  729. const max = Math.max(...types.map((t) => t.count), 1);
  730. return {
  731. types: types.map((t) => ({ ...t, share: all ? Math.round((t.count / all) * 100) : 0, relative: Math.round((t.count / max) * 100) })),
  732. total: entries.length,
  733. entries,
  734. max,
  735. unclassified,
  736. stageTally: STAGES.map((s) => ({ stage: s, count: entries.filter(e => e.stage?.code === s.code).length })),
  737. _reportKeys: REPORT_TYPE_KEYS,
  738. };
  739. }
  740. /* ============================================================
  741. * 阶段产出(阶段页的「内容」视角)
  742. * ============================================================ */
  743. const GROUP_ORDER = [
  744. ['category', '品类分析'],
  745. ['competitor', '竞品分析'],
  746. ['voc', 'VOC / 用户洞察'],
  747. ['pain', '痛点清单'],
  748. ['concept', '概念提案'],
  749. ['opportunity', '机会卡'],
  750. ['other', '其他成果'],
  751. ];
  752. /** 一眼摘要:一个阶段产出了什么(按类型分组 + 卡片摘要) */
  753. export function stageOutcome(db, stageCode) {
  754. const stage = STAGES.find((s) => s.code === stageCode) || STAGES[0];
  755. const entries = [];
  756. for (const t of REPORT_TYPES) entries.push(...entriesForType(db, t.key).filter((e) => e.stage?.code === stage.code));
  757. // 映射不到类型的成果单独成组
  758. const mapped = new Set(Object.keys(TYPE_FOR_ARTIFACT));
  759. const others = db.artifacts.filter((a) => a.stageObj?.code === stage.code && !mapped.has(a.type))
  760. .map((a) => entryOfArtifact(db, a, 'other'));
  761. for (const o of others) entries.push(o);
  762. const oppCards = db.opportunities.filter((o) => o.stageObj?.code === stage.code).map((o) => entryOfOpportunity(o, 'opportunity'));
  763. const groups = GROUP_ORDER.map(([key, label]) => {
  764. let items;
  765. if (key === 'opportunity') items = oppCards;
  766. else if (key === 'other') items = others;
  767. else items = entries.filter((e) => e.typeKey === key && e.kind !== 'opportunity');
  768. // 「有证据」与类型页「只看有证据的」同一条口径(core.library.hasEvidence)
  769. return { key, label, count: items.length, items: items.sort((a, b) => (b.at || '').localeCompare(a.at || '')), verified: items.filter(hasEvidence).length };
  770. }).filter((g) => g.count > 0);
  771. const owners = new Map();
  772. for (const e of [...entries, ...oppCards]) if (e.owner) owners.set(e.owner, (owners.get(e.owner) || 0) + 1);
  773. return {
  774. stage,
  775. groups,
  776. total: groups.reduce((n, g) => n + g.count, 0),
  777. ownerCount: owners.size,
  778. owners: [...owners.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count),
  779. withBody: [...entries, ...oppCards].filter((e) => e.body !== 'none' && e.body !== 'drawer').length,
  780. };
  781. }
  782. /** 阶段级「五官」:给页头用的一行判断 */
  783. export function stageOutcomeSummary(rows) {
  784. const total = rows.reduce((n, r) => n + r.total, 0);
  785. const full = rows.filter((r) => r.total > 0).length;
  786. const top = rows.slice().sort((a, b) => b.total - a.total)[0];
  787. return { total, fullStages: full, topStage: top?.stage?.code || '', topCount: top?.total || 0 };
  788. }
  789. /* ============================================================
  790. * 时间格式(页面直接用)
  791. * ============================================================ */
  792. export const whenText = (v, now = Date.now()) => (toIso(v) ? fmtAgo(v, now) : cjkSpace('时间未记录')); // fmtAgo 在生成处插 U+2009;占位句同样过一遍,页面一律 ${whenText(...)} 即可
  793. export const stageCodeOf = (v) => stageOf(v)?.code || '';