| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851 |
- /**
- * core/library/reports.js —— 报告中心与五个类型页的数据口径(纯函数)。
- *
- * 输入:db(core.buildModel 的产物);输出:页面直接能画的结构。
- * 这里只做「归类 / 统计 / 对齐 / 排序」,不决定长什么样。
- *
- * 几条硬规则:
- * 1. 一份成果只归一个类型页(取 artifactType 的第一个映射;映射不到 → 收进「未归类」并在中心页明说)。
- * 2. 类型页的关键指标按 TYPE.metricLabels 的顺序**对齐**:未取到值的指标保留列位显示「—」,
- * 这样同一行的卡片可以直接横向比较,而不是各自排版。
- * 3. 「参照」一律给得出:每张卡有 relative = 本类型内该指标的位置(低/中/高 + 名次),
- * 没有历史数据时不编「本周新增」,改给「本周窗口」(有创建时间就能算,是真的)。
- * 4. 关键结论:能算的一律算(报告执行摘要、原文摘录、关键指标),算不出的如实标「正文未入知识库」,
- * 不编一句话。
- */
- import { STAGES, stageOf, ARTIFACT_TYPES, EVIDENCE_LEVELS, OPP_EVIDENCE, PRIORITIES, UPLOAD_METHODS, STAGE_TONE, evidenceSegments, uploadMethodLabel } from '../taxonomy/taxonomy.js';
- import { toTime, toIso, fmtAgo, cjkSpace } from '../kfs/util.js';
- import { reportOverview, findingsOf, pipelineSummary } from '../report.js';
- import { REPORT_TYPES, REPORT_TYPE_KEYS, reportTypeOf } from './types.js';
- const DAY = 86400000;
- const WEEK = 7 * DAY;
- /** artifactType(库里存的)→ 类型页 key。多映射时取第一个。 */
- export const TYPE_FOR_ARTIFACT = Object.freeze({
- category_report: 'category',
- assessment_report: 'concept',
- competitor_report: 'competitor',
- voc_report: 'voc',
- pain_list: 'pain',
- concept: 'concept',
- opportunity_card: 'competitor',
- });
- export const artifactTypeLabel = (t) => ARTIFACT_TYPES[t] || (t ? String(t) : '(未标类型)');
- /** 证据强度排序用(verified 最高) */
- const EV_RANK = Object.fromEntries(EVIDENCE_LEVELS.map((e) => [e.key, e.rank]));
- const evLabel = (k) => (EVIDENCE_LEVELS.find((e) => e.key === k) || EVIDENCE_LEVELS[3]).label;
- /** 从「成果标题 - 类型后缀」里取主题(seed 与 Agent 产出的标题都是这个格式) */
- export function subjectOf(artifact) {
- const t = String(artifact?.title || '').replace(/\s*-\s*[a-z_]+$/, '').trim();
- return t || '(未命名)';
- }
- /** 标题里 key 自身的出现次数(竞品篇数这类指标的真实量化口径) */
- export function countKey(title, key) {
- const hay = String(title || '');
- const needle = String(key || '');
- if (!needle) return 0;
- let n = 0;
- let i = hay.indexOf(needle);
- while (i !== -1) {
- n++;
- i = hay.indexOf(needle, i + needle.length);
- }
- return n;
- }
- /* ============================================================
- * 卡片字段(一份报告一张卡:阶段 / 品类 / 状态 / 证据 / 评分 / 有全文)
- * ============================================================ */
- const CATEGORIES = Object.freeze(['拌面', '汤面', '炒面', '杯面']);
- /** 证据档位:高 / 中 / 低 / 待补(成果按原文等级归并,机会卡按自己的三档) */
- export function evidenceTierOf(e) {
- if (e.kind === 'opportunity') {
- const lv = OPP_EVIDENCE.find((x) => x.key === e.evidenceKey);
- return lv ? lv.label : '待补';
- }
- const rank = EVIDENCE_LEVELS.find((x) => x.key === e.original)?.rank ?? 0;
- return rank >= 3 ? '高' : rank === 2 ? '中' : rank === 1 ? '低' : '待补';
- }
- /** 品类色块用:优先品类标签,其次标题里出现过的品类名(认不出就不算,不猜) */
- export function categoryOf(e) {
- const tags = [...(e.tags || []), ...(e.rawTitle ? [e.rawTitle] : []), ...(e.title ? [e.title] : [])];
- return CATEGORIES.find((c) => tags.some((t) => String(t).includes(c))) || '';
- }
- /** 卡片上要显示的「证据 / 引用 / 评分」四个数 + 视觉标记 */
- export function cardFacts(e) {
- const evidence = evidenceTierOf(e);
- const citations = e.citedBy || e.quoteCount || 0;
- return {
- stage: e.stage ? e.stage.code : '',
- stageTone: e.stage ? STAGE_TONE[e.stage.code] || 'slate' : 'slate',
- category: categoryOf(e),
- status: e.status || '',
- statusLabel: e.kind === 'opportunity' ? (e.status === 'approved' ? '已立项' : e.status === 'rejected' ? '已否决' : '候选') : '',
- priority: e.priority || '',
- evidence,
- evidenceSeg: Math.min(3, e.kind === 'opportunity'
- ? (OPP_EVIDENCE.find((x) => x.key === e.evidenceKey)?.rank ?? 0)
- : evidenceSegments(e.original)),
- citations,
- score: e.score != null ? Math.round(e.score) : null,
- fullText: e.body === 'text' || e.body === 'report',
- thumb: thumbOf(e),
- };
- }
- /**
- * 卡片缩略图:优先库里的截图字段(report.thumb / meta.thumbUrl),没有就用报告自身的结构画一张——
- * 章节条 + 结论点,来自真实数据,不编图。
- */
- export function thumbOf(e) {
- const a = e._a || {};
- const url = a.report?.thumb || a.meta?.thumbUrl || '';
- if (url) return { kind: 'img', url: String(url) };
- const r = a.report;
- if (!r) return null;
- const chapters = Object.values(r.chapters || {});
- const bars = chapters.slice(0, 7).map((c) => {
- const n = (Array.isArray(c.sections) ? c.sections : []).length;
- return Math.max(1, n);
- });
- const dots = Math.min(8, Object.keys(r.evidence || {}).length || e.quoteCount || 0);
- return { kind: 'chart', bars, dots };
- }
- /** 六个分类维度的取值与计数(每个值带数量;multi=true 的维度可多选) */
- export const CARD_DIMS = Object.freeze([
- { key: 'stage', label: '阶段', values: () => STAGES.map((s) => ({ v: s.code, label: s.code, tone: STAGE_TONE[s.code] })) },
- { key: 'cat', label: '品类', values: () => CATEGORIES.map((c) => ({ v: c, label: c })) },
- { key: 'pri', label: '优先级', values: () => PRIORITIES.map((p) => ({ v: p, label: cjkSpace(`${p} 级`) })) },
- { key: 'ev', label: '证据', values: () => [{ v: '高', label: '高' }, { v: '中', label: '中' }, { v: '低', label: '低' }, { v: '待补', label: '待补' }] },
- { key: 'm', label: '方式', values: () => Object.entries(UPLOAD_METHODS).map(([k, label]) => ({ v: k, label })) },
- { key: 'who', label: '人', values: () => [] },
- ]);
- /** 一组的维度取值是否命中当前筛选(同一维度内多选 = 或;维度之间 = 且) */
- const hit = (want, got) => !want.length || want.includes(got);
- export function cardMatches(e, q = {}) {
- const f = cardFacts(e);
- if (!hit(q.stage || [], f.stage)) return false;
- if (!hit(q.cat || [], f.category)) return false;
- if (!hit(q.pri || [], f.priority)) return false;
- if (!hit(q.ev || [], f.evidence)) return false;
- if (!hit(q.m || [], e.methodKey || '')) return false;
- if (q.who && e.owner !== q.who) return false;
- if (q.hasEv && !hasEvidence(e)) return false;
- if (q.full && !f.fullText) return false;
- if (q.recent && !(toTime(e.at) && Date.now() - toTime(e.at) <= WEEK)) return false;
- return true;
- }
- /** 每个维度值下还剩几份(按「除本维度外的全部筛选」算,叠加时数字也是真的) */
- export function cardFacetCounts(entries, q = {}) {
- return Object.fromEntries(CARD_DIMS.map((d) => {
- const rest = { ...q, [d.key]: [] };
- const pool = entries.filter((e) => cardMatches(e, rest));
- const counts = {};
- for (const e of pool) {
- const f = cardFacts(e);
- const k = { cat: 'category', pri: 'priority', ev: 'evidence' }[d.key] || d.key;
- const v = d.key === 'who' ? e.owner : d.key === 'm' ? e.methodKey : f[k];
- if (v) counts[v] = (counts[v] || 0) + 1;
- }
- return [d.key, counts];
- }));
- }
- export const CARD_SORTS = Object.freeze([['score', '评分'], ['new', '最新'], ['ev', '证据']]);
- export function sortEntries(entries, sort = 'new') {
- const rank = (e) => cardFacts(e).evidenceSeg;
- const arr = entries.slice();
- if (sort === 'score') arr.sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || (b.at || '').localeCompare(a.at || ''));
- else if (sort === 'ev') arr.sort((a, b) => rank(b) - rank(a) || (b.at || '').localeCompare(a.at || ''));
- else arr.sort((a, b) => (b.at || '').localeCompare(a.at || ''));
- return arr;
- }
- /* ============================================================
- * 三块结构:手头事实面 / 空态说明书 / 间距图例
- * ============================================================ */
- const FACTS = {
- category: [
- '品类 = 库里的 tags[0](如「拌面 / 汤面 / 炒面 / 杯面」);没有标签的成果不会假装属于某个品类。',
- '「赛道」是树状结构:品类 → 赛道 → SKU。S1 组 A 负责铺品类地图,别人的竞品报告会挂到对应赛道下。',
- '市场结构目前用 KfSource(type=category)承载:规模、增速、集中度、价格带分布。',
- ],
- competitor: [
- '每份竞品报告能定位到:哪个品类(tags[0])、哪个阶段(S1–S5)、哪条原声(引用的归纳)。',
- '对比矩阵的维度顺序固定:竞品 → 口感 / 价格 / 渠道 / 场景 → 空档,所有品牌按同一张表比。',
- '「差异化空档」= 没有任何一家占住的格子;空档必须能追到原文,不能只写「市场空白」。',
- ],
- voc: [
- '原文摘录来自报告的结论块(每条结论下挂的 quotes),带平台 · 作者 · 点赞;没有原文的结论会明确标出。',
- '情感 / 主题 / 人群切分由 VOC 报告的文本字段提供;字段没到就显示「未标注」,不从标题猜。',
- ],
- pain: [
- '频次 = 提到这条痛点的人 / 条数;严重度(1–5)由整理人标注。两者都有的痛点才能进二维图。',
- '「已被解决」的判定依据:该痛点关联的机会卡已立项,或报告里明确写出现有产品已覆盖。',
- ],
- concept: [
- '概念提案挂在机会卡下面:提案 → 关联机会卡 → 机会卡 → 产物。',
- '关键指标(目标人群 / 场景 / 一句话定位 / 卖点阶梯)来自 concept 的正文与标签。',
- ],
- };
- const LEGEND = {
- category: '实心竖线 = 当前这份;淡色底线 = 本类型里其他份数的中位水平。',
- competitor: '实心竖线 = 当前这份;淡色底线 = 本类型里其他份数的中位水平。',
- voc: '圆点 = 本份有原文的条数;横轴 0 → 最多的一份。',
- pain: '圆点 = 频次(说的人多不多);纵轴 = 严重度(让用户多难受)。',
- concept: '圆点 = 关联机会的多人平均分;横轴 0 → 100。',
- };
- export function handFacts(typeKey) {
- const t = reportTypeOf(typeKey);
- return { question: t?.question || '', looks: t?.looks || '', facts: FACTS[typeKey] || [], legend: LEGEND[typeKey] || '' };
- }
- /** 空态说明书:这个位置按设计将来会出现什么、谁来填哪个字段、填完长什么样 */
- export const EMPTY_PLAN = Object.freeze({
- category: {
- will: '按赛道列出每个品类的竞争结构:赛道 → 竞品数量 → 价格带 → 集中度。',
- who: 'S1 品类市场分析 · 各组品类调研 Agent',
- field: 'KfSource.sourceType = category(payload.track / payload.skuCount / payload.density)',
- looks: '一张「赛道 × 竞争密度」矩阵,点击赛道下钻到该赛道的玩家列表',
- },
- competitor: {
- will: '每个竞品品牌一张卡(口感 / 价格 / 渠道 / 场景 / 优缺点),所有人按同一张矩阵比。',
- who: '任一阶段 · 品类调研 Agent · 会话或 Skill 上传',
- field: 'KfArtifact.artifactType = competitor_report',
- looks: '竞品 × 维度的对比矩阵 + 差异化空档列表',
- },
- voc: {
- will: '原话摘录(平台 · 作者 · 点赞)→ 按主题聚合 → 情感比例 → 人群切分。',
- who: 'S2 用户需求与痛点 · 用户访谈 / 评论抓取',
- field: 'KfArtifact.artifactType = voc_report(或 report.evidence[*].quotes)',
- looks: '结论卡 + 每条结论旁的「N 条原文」锚点,点开看原话和出处',
- },
- pain: {
- will: '一条痛点一张卡:频次、严重度、是否已被解决、对应哪张机会卡。',
- who: 'S2 用户需求与痛点 · 整理人手工标注严重度',
- field: 'KfArtifact.artifactType = pain_list',
- looks: '频次(横轴)× 严重度(纵轴)的二维图,右上角是最要命的',
- },
- concept: {
- will: '每个方案:一句话定位、目标人群、场景、卖点阶梯、关联的机会卡。',
- who: 'S5 场景与卖点设计 · 概念设计 Agent',
- field: 'KfArtifact.artifactType = concept',
- looks: '方案卡片流,卡片上直接给关键指标,按关联机会卡的评分排序',
- },
- });
- /* ============================================================
- * 行 → 视图条目(所有类型共用同一个条目形状 → 横向对齐)
- * ============================================================ */
- /**
- * 一条成果 → 报告中心/类型页用的条目。
- * metrics 是字符串数组,长度与 type.metricLabels 一致,缺的填 '—'。
- */
- /** 类型页 / 阶段页的卡片条目:类型页的条目带上库里原始行(缩略图、结构化摘要用) */
- export function entryOfArtifact(db, a, typeKey) {
- const e = entryOfArtifactRow(db, a, typeKey);
- e._a = a;
- return e;
- }
- function entryOfArtifactRow(db, a, typeKey) {
- const report = a.report ? reportOverview(db, a.id) : null;
- const type = reportTypeOf(typeKey);
- const kpis = report?.kpis || [];
- const firstKpi = kpis[0] || null;
- const subject = subjectOf(a);
- const br = String(a.title || '').match(/(([^)]+))/)?.[1] || '';
- let metrics;
- if (typeKey === 'competitor') {
- metrics = [
- String(countKey(a.title, br) || 0),
- a.tags[0] || '—',
- a.quotes.length ? `原文 ${a.quotes.length}` : a.evidenceLabel,
- a.stageObj?.code || '—',
- ];
- } else if (typeKey === 'voc') {
- // 卡片顺序:原文条数 / 阶段 / 提到(标题主体被提到的次数,没有就是「—」)/ 证据
- const mentions = countKey(a.title, br);
- metrics = [String(a.quotes.length), a.stageObj?.code || '—', mentions ? String(mentions) : '—', a.evidenceLabel];
- } else if (typeKey === 'pain') {
- // 卡片顺序:频次(归并后,列表层回填)/ 严重度(标注了才有)/ 解决状态 / 阶段
- metrics = ['—', a.severity ? `${a.severity}/5` : '待标注', '—', a.stageObj?.code || '—'];
- } else if (typeKey === 'concept') {
- metrics = [subject || '—', '—', a.evidenceLabel, a.stageObj?.code || '—'];
- } else {
- metrics = [a.tags[0] || '—', '—', '—', a.stageObj?.code || '—'];
- }
- // 全文能不能看:report 正文 / rawText 正文 / 都没有
- const body = a.report ? 'report' : a.rawText ? 'text' : 'none';
- const lead = report?.executive?.[0] || report?.subtitle || firstLead(a);
- return {
- kind: 'artifact',
- id: a.id,
- _a: a,
- title: report?.title ? `${report.title}` : subject,
- rawTitle: a.title,
- titleSuffix: artifactTypeLabel(a.type),
- subject,
- typeKey,
- artifactType: a.type,
- stage: a.stageObj ? { code: a.stageObj.code, name: a.stageObj.name, label: a.stageObj.label } : null,
- owner: a.ownerName || '',
- agent: a.upload.agentName,
- agentId: a.upload.agentId,
- method: a.upload.methodLabel || '',
- methodKey: a.upload.uploadMethod,
- at: a.upload.uploadedAt || a.createdAt,
- evidence: a.evidenceLabel,
- evidenceKey: a.evidenceLevel,
- /** 与机会卡的三档(高/中/低)归一:只有 original(成果证据等级)能判断「有原文/佐证」 */
- original: a.evidenceLevel,
- metrics,
- lead,
- leadSource: report?.executive?.length ? '报告执行摘要' : report?.subtitle ? '报告副标题' : firstLead(a) ? '正文开头' : '',
- body,
- href: body === 'report' ? `/report/${a.id}` : '',
- hasReport: Boolean(a.report),
- quoteCount: report?.quoteCount ?? a.quotes.length,
- quotes: importantQuotes(db, a).slice(0, 2),
- people: a.quotes.map((q) => q.author).filter(Boolean).slice(0, 3),
- findings: report ? findingsOf(db, a.id).slice(0, 3) : [],
- report: report ? { chapterCount: report.chapterCount, conclusionCount: report.conclusionCount, refsInLib: report.refsInLib } : null,
- citedBy: a.citedByInsightIds.length,
- tags: a.tags,
- createdAt: a.createdAt,
- };
- }
- /** 正文开头(rawText)——截一段作为「关键结论」的替身,并标明是正文开头 */
- function firstLead(a) {
- const t = String(a.rawText || '').trim();
- if (!t || t.length < 8) return '';
- return t.length > 120 ? t.slice(0, 120) + '…' : t;
- }
- /** 报告里的原文(按点赞取前几条):结论 → quotes */
- export function importantQuotes(db, a) {
- if (!a.report) return [];
- const out = [];
- for (const f of findingsOf(db, a.id)) {
- const e = a.report.evidence?.[f.evId] || {};
- for (const q of e.quotes || []) {
- out.push({ text: String(q.text || ''), ref: String(q.ref || ''), likes: q.likes ?? null, author: String(q.author || ''), claim: f.conclusion });
- }
- }
- return out.sort((x, y) => (y.likes ?? -1) - (x.likes ?? -1)).slice(0, 6);
- }
- /** 一条机会卡 → 条目(竞品 / VOC 页的证据补充项、概念页的主项) */
- export function entryOfOpportunity(o, typeKey) {
- const linked = (o.linkedArtifacts || []).filter((x) => !x.missing);
- const hasEvidence = linked.some((x) => x.quotes.length > 0 || x.report) || o.quoteCount > 0;
- return {
- kind: 'opportunity',
- id: o.id,
- _a: o,
- title: o.title,
- rawTitle: o.title,
- titleSuffix: '机会卡',
- subject: o.title,
- typeKey,
- artifactType: 'opportunity_card',
- stage: o.stageObj ? { code: o.stageObj.code, name: o.stageObj.name, label: o.stageObj.label } : null,
- owner: o.upload.uploadedBy,
- agent: o.upload.agentName,
- agentId: o.upload.agentId,
- method: o.upload.methodLabel,
- methodKey: o.upload.uploadMethod,
- at: o.upload.uploadedAt,
- evidence: o.evidenceLabel,
- evidenceKey: o.evidenceLevel,
- original: '',
- // 与竞品报告同列对齐:竞品篇数 / 对位品类 / 证据 / 阶段
- metrics: typeKey === 'concept'
- ? [linked.length ? String(linked.length) : '—', o.stats.avg == null ? '—' : String(o.stats.avg), o.evidenceLabel, o.stageObj?.code || '—']
- : [String(o.linkedArtifactIds.length || 0), o.category || '—', o.evidenceLabel, o.stageObj?.code || '—'],
- lead: o.oneLiner || '',
- leadSource: o.oneLiner ? '机会卡一句话' : '',
- body: 'drawer',
- href: '',
- hasReport: Boolean(o.reportArtifact),
- quoteCount: o.quoteCount,
- quotes: [],
- findings: [],
- report: null,
- citedBy: o.citeCount,
- linkedCount: linked.length,
- score: o.stats.avg,
- status: o.status,
- priority: o.priority,
- tags: o.category ? [o.category] : [],
- hasEvidence,
- linkedPeople: [...new Set(linked.map((x) => x.ownerName).filter(Boolean))],
- createdAt: o.createdAt,
- };
- }
- /** KfSource 行 → 条目(品类分析的原材料) */
- export function entryOfSource(s, typeKey) {
- const p = s.payload && typeof s.payload === 'object' ? s.payload : {};
- return {
- kind: 'source',
- id: s.id,
- _a: s,
- title: s.title,
- rawTitle: s.title,
- titleSuffix: '资料',
- subject: s.title,
- sourceType: s.sourceType,
- typeKey,
- stage: null,
- owner: s.meta?.ownerName || '',
- agent: s.meta?.agentName || '',
- method: s.meta?.uploadMethod ? uploadMethodLabel(s.meta.uploadMethod) : '',
- at: s.createdAt,
- evidence: '—',
- metrics: [p.track || '—', p.players != null ? String(p.players) : '—', p.skuCount != null ? String(p.skuCount) : '—', p.density || '—'],
- lead: String(s.summary || s.rawText || ''),
- leadSource: '资料摘要',
- body: s.rawText ? 'text' : 'none',
- links: (s.sources || []).map((x) => x.ref).filter(Boolean),
- payload: p,
- placeholder: Boolean(s.meta?.placeholder),
- createdAt: s.createdAt,
- };
- }
- /** 品类条目的赛道:tags[0] → 资料 payload.track */
- function trackOf(e) {
- return e.tags?.[0] || e.payload?.track || '未归类';
- }
- /* ============================================================
- * 类型页取数
- * ============================================================ */
- /** 某个类型页的全部条目(不按阶段过滤),按时间倒序 */
- export function entriesForType(db, typeKey) {
- const type = reportTypeOf(typeKey);
- if (!type) return [];
- const arts = db.artifacts.filter((a) => TYPE_FOR_ARTIFACT[a.type] === typeKey).map((a) => entryOfArtifact(db, a, typeKey));
- if (typeKey === 'pain') fillPainMetrics(db, arts);
- const opps = type.fields.artifactTypes.includes('opportunity_card')
- ? db.opportunities.map((o) => entryOfOpportunity(o, typeKey))
- : [];
- const srcs = type.fields.sourceTypes?.length
- ? db.sources.filter((s) => type.fields.sourceTypes.includes(s.sourceType)).map((s) => entryOfSource(s, typeKey))
- : [];
- return [...arts, ...opps, ...srcs].sort((a, b) => (b.at || '').localeCompare(a.at || ''));
- }
- /** 痛点卡片:把「频次 / 解决状态」换成归并后的真实值(同一条痛点多人整理只算一条) */
- function fillPainMetrics(db, arts) {
- const bySubject = new Map(db.opportunities.map((o) => [o.title, o]));
- const groups = new Map();
- for (const e of arts) {
- const k = painKey(db.artifactById[e.id]);
- if (!groups.has(k)) groups.set(k, []);
- groups.get(k).push(e);
- }
- for (const [, items] of groups) {
- const verified = items.some(hasEvidence);
- const opp = [...bySubject.values()].find((o) => o.title === items[0].subject || o.title.includes(items[0].subject) || items[0].subject.includes(o.title));
- const sev = items.map((x) => db.artifactById[x.id]?.severity).filter((n) => Number.isFinite(n) && n > 0);
- for (const e of items) {
- e.metrics = [
- String(items.length),
- sev.length ? `${Math.round((sev.reduce((a, b) => a + b, 0) / sev.length) * 10) / 10}/5` : '待标注',
- opp ? opp.statusLabel : verified ? '未对照' : '待补证据',
- e.stage?.code || '—',
- ];
- e.frequency = items.length;
- e.solvedLabel = opp ? opp.statusLabel : '';
- e.linkedOpp = opp ? { id: opp.id, title: opp.title, status: opp.status, statusLabel: opp.statusLabel, score: opp.stats.avg } : null;
- }
- }
- }
- /**
- * 是否「有证据」:成果的原始证据等级为 verified / supported 才算(仅推测 / 无证据不算);
- * 机会卡按自己的三档(高 / 中 算,低 / 待补 不算)。类型页与阶段页共用这一条。
- */
- export const hasEvidence = (e) => Boolean(e && (e.original === 'verified' || e.original === 'supported' || e.evidenceKey === 'high' || e.evidenceKey === 'medium'));
- /** 阶段二次筛选 + 只看有证据(类型页 / 阶段页共用) */
- export function filterEntries(entries, { stage = '', evidenceOnly = false, mine = '' } = {}) {
- return entries.filter((e) => {
- if (stage && e.stage?.code !== stage) return false;
- if (evidenceOnly && !hasEvidence(e)) return false;
- if (mine && e.owner !== mine) return false;
- return true;
- });
- }
- /** 按阶段计数(给类型页的阶段筛选条提供「有几个」) */
- export function stageTally(entries) {
- const m = Object.fromEntries(STAGES.map((s) => [s.code, 0]));
- let none = 0;
- for (const e of entries) {
- if (e.stage?.code && m[e.stage.code] != null) m[e.stage.code]++;
- else none++;
- }
- return { byStage: m, none, total: entries.length };
- }
- /* ============================================================
- * 关键结论的段落级摘要(卡片上直接给) + 报告阅读页的章节摘要
- * ============================================================ */
- /**
- * 段落摘要:从报告章节里抽「最像结论的句子」——带数字 / 判断词的句子排前面,最多 3 条。
- * 这是纯文本抽取,不改写、不生成。
- */
- export function sectionDigest(section, limit = 2) {
- const texts = [];
- const push = (t) => { const s = String(t || '').trim(); if (s.length >= 6) texts.push(s); };
- const d = section?.data;
- switch (section?.kind) {
- case 'para': push(d?.text ?? section.text); break;
- case 'longpara': String(section.text || '').split(/\n{1,}/).forEach(push); break;
- case 'findings': (d || []).forEach((f) => push(f.conclusion)); break;
- case 'table': case 'matrix': (d?.rows || []).forEach((r) => push(Array.isArray(r) ? r[0] : r)); break;
- case 'two': (d?.can || []).slice(0, 1).forEach(push); break;
- case 'ladder': push(d?.statement); break;
- default: break;
- }
- const score = (s) => (/\d/.test(s) ? 2 : 0) + (/是|不是|应该|没有|只有|必须|杀|空档|空白|集中在/.test(s) ? 2 : 0);
- return texts
- .map((s, i) => ({ text: s.length > 90 ? s.slice(0, 90) + '…' : s, s: score(s), i }))
- .sort((a, b) => b.s - a.s || a.i - b.i)
- .slice(0, limit)
- .map((x) => x.text);
- }
- /** 章节 → 摘要(目录页 / 类型页给报告卡用:每章一句话) */
- export function chapterDigests(db, artifactId, limit = 6) {
- const a = db.artifactById[artifactId];
- if (!a?.report) return [];
- return (a.report.chapters || []).slice(0, limit).map((ch, i) => {
- const secs = Array.isArray(ch.sections) ? ch.sections : [];
- const digest = secs.flatMap((s) => sectionDigest(s, 1)).slice(0, 1);
- const firstFindings = secs.find((s) => s.kind === 'findings');
- return {
- id: ch.id || `ch${i + 1}`,
- no: ch.no ?? i + 1,
- title: String(ch.title || ''),
- summary: digest[0] || String(ch.lead || '').slice(0, 90),
- findings: (firstFindings?.data || []).slice(0, 3).map((f) => String(f.conclusion || '')),
- };
- });
- }
- /* ============================================================
- * 各类型页的「结构性认识」区块
- * ============================================================ */
- /** 赛道分布(品类页):赛道 → 份数、参与人、竞品篇数、平均证据 */
- export function tracksOf(db, entries) {
- const m = new Map();
- for (const e of entries) {
- const k = trackOf(e);
- if (!m.has(k)) m.set(k, { track: k, count: 0, people: new Set(), competitors: 0, evs: [], items: [] });
- const g = m.get(k);
- g.count++;
- if (e.owner) g.people.add(e.owner);
- if (e.artifactType === 'competitor_report') g.competitors++;
- g.evs.push(EV_RANK[e.evidenceKey] ?? 0);
- g.items.push(e);
- }
- return [...m.values()]
- .map((g) => ({
- track: g.track,
- count: g.count,
- people: [...g.people],
- competitors: g.competitors,
- avgEvidence: g.evs.length ? Math.round((g.evs.reduce((a, b) => a + b, 0) / g.evs.length) * 10) / 10 : 0,
- items: g.items,
- }))
- .sort((a, b) => b.count - a.count);
- }
- /** 覆盖阶段 × 份数的热力(品类 / 竞品页的「竞争密度」) */
- export function stageMatrix(entries) {
- const m = Object.fromEntries(STAGES.map((s) => [s.code, { total: 0, byType: {} }]));
- for (const e of entries) {
- const c = e.stage?.code;
- if (!c || !m[c]) continue;
- m[c].total++;
- const t = e.artifactType === 'opportunity_card' ? '机会卡' : artifactTypeLabel(e.artifactType);
- m[c].byType[t] = (m[c].byType[t] || 0) + 1;
- }
- return m;
- }
- /** 竞品对比矩阵:行 = 主题(竞品/场景),列 = 固定维度 */
- export function competitorMatrix(entries) {
- const rows = new Map();
- for (const e of entries) {
- const k = e.subject;
- if (!rows.has(k)) rows.set(k, { subject: k, items: [], stages: new Set(), quotes: 0, subjects: new Set() });
- const r = rows.get(k);
- r.items.push(e);
- if (e.stage) r.stages.add(e.stage.code);
- r.quotes += e.quoteCount || 0;
- for (const q of e.quotes || []) if (q.claim) r.subjects.add(q.claim);
- for (const p of e.people || []) if (p) r.subjects.add(`提到:${p}`);
- }
- const bestEv = (items) => {
- const top = items.slice().sort((a, b) => (EV_RANK[b.evidenceKey] ?? 0) - (EV_RANK[a.evidenceKey] ?? 0))[0];
- return top?.evidence || '—';
- };
- return [...rows.values()]
- .map((r) => ({
- subject: r.subject,
- count: r.items.length,
- stageCodes: STAGES.filter((s) => r.stages.has(s.code)).map((s) => s.code),
- quotes: r.quotes,
- evidence: bestEv(r.items),
- evidenceRank: Math.max(...r.items.map((x) => EV_RANK[x.evidenceKey] ?? 0), 0),
- highlights: [...r.subjects].slice(0, 2),
- items: r.items,
- }))
- .sort((a, b) => b.count - a.count || b.quotes - a.quotes);
- }
- /** 差异化空档:从矩阵里找只有一边有内容 / 覆盖阶段最少的行(数据驱动,不编) */
- export function gapsOf(matrix) {
- if (!matrix.length) return [];
- const maxStages = Math.max(...matrix.map((r) => r.stageCodes.length), 1);
- const gaps = [];
- for (const r of matrix) {
- if (r.stageCodes.length < maxStages) {
- const missing = STAGES.map((s) => s.code).filter((c) => !r.stageCodes.includes(c));
- gaps.push({ subject: r.subject, reason: `只覆盖 ${r.stageCodes.join(' / ') || '—'},${missing.join('、')} 还没人做`, metric: `${r.stageCodes.length}/${maxStages} 阶段`, items: r.items });
- }
- }
- return gaps.sort((a, b) => a.items.length - b.items.length).slice(0, 5);
- }
- /** VOC:原话池(带出处),按点赞倒序;以及「谁的原文最多」 */
- export function quotePool(db, entries) {
- const out = [];
- for (const e of entries) {
- if (e.kind !== 'artifact') continue;
- const a = db.artifactById[e.id];
- for (const q of importantQuotes(db, a)) out.push({ ...q, from: e.subject, fromId: e.id, owner: e.owner });
- }
- return out.sort((a, b) => (b.likes ?? -1) - (a.likes ?? -1));
- }
- /** VOC:有原文 vs 没原文(频次排行 / 证据缺口) */
- export function voiceRanking(entries) {
- return entries
- .filter((e) => e.kind === 'artifact')
- .map((e) => ({ ...e, voices: e.quoteCount }))
- .sort((a, b) => b.voices - a.voices || (EV_RANK[b.evidenceKey] ?? 0) - (EV_RANK[a.evidenceKey] ?? 0));
- }
- /** 痛点:频次 × 严重度。频次能算(被多少份成果提到 / 几条原文);严重度没有字段就是 null,不猜。 */
- export function painPoints(db, entries) {
- const rows = painRanking(db, entries);
- return rows.map((r, i) => ({
- ...r,
- frequency: r.mentions,
- severity: r.severity,
- solved: r.solvedBy.length > 0 || r.linkedOpps.some((o) => o.status === 'approved'),
- }));
- }
- /**
- * 痛点归并键:优先报告里第一条结论的锚点(同一条痛点被不同的人整理,报告会引用同一条结论);
- * 没有报告就用标题主体。只做「归并」,不推断内容。
- */
- export function painKey(artifact) {
- if (!artifact) return '';
- if (artifact.report?.evidence) {
- for (const ev of Object.values(artifact.report.evidence)) if (ev?.claim) return String(ev.claim).trim();
- }
- return subjectOf(artifact);
- }
- /** 痛点排行:同一主题被几份成果提到(标题主体相同)+ 证据 + 对应机会卡 */
- export function painRanking(db, entries) {
- const m = new Map();
- for (const e of entries) {
- if (e.kind !== 'artifact') continue;
- const k = painKey(db.artifactById[e.id]);
- if (!m.has(k)) m.set(k, { subject: k, items: [], people: new Set(), stages: new Set(), quotes: 0, severity: null, maxEv: 0 });
- const g = m.get(k);
- g.items.push(e);
- if (e.owner) g.people.add(e.owner);
- if (e.stage) g.stages.add(e.stage.code);
- g.quotes += e.quoteCount || 0;
- g.maxEv = Math.max(g.maxEv, EV_RANK[e.evidenceKey] ?? 0);
- }
- // 关联:机会卡标题主体 = 痛点主体(或包含)
- const bySubject = new Map(db.opportunities.map((o) => [o.title, o]));
- return [...m.values()]
- .map((g) => {
- const opps = [...bySubject.values()].filter((o) => o.title && (o.title === g.subject || o.title.includes(g.subject) || g.subject.includes(o.title)));
- const arts = g.items.map((e) => db.artifactById[e.id]).filter(Boolean);
- const sev = arts.map((a) => Number(a.severity)).filter((n) => Number.isFinite(n) && n > 0);
- return {
- id: g.items[0].id,
- subject: g.subject,
- mentions: g.items.length,
- people: [...g.people],
- stageCodes: STAGES.filter((s) => g.stages.has(s.code)).map((s) => s.code),
- quotes: g.quotes,
- severity: sev.length ? Math.round((sev.reduce((a, b) => a + b, 0) / sev.length) * 10) / 10 : null,
- evidenceRank: g.maxEv,
- linkedOpps: opps.map((o) => ({ id: o.id, title: o.title, status: o.status, statusLabel: o.statusLabel, score: o.stats.avg })),
- solvedBy: opps.filter((o) => o.status === 'approved'),
- items: g.items,
- };
- })
- .sort((a, b) => b.mentions - a.mentions || b.evidenceRank - a.evidenceRank);
- }
- /** 概念:方案 → 关联机会 → 关键指标 */
- export function conceptsOf(db, entries) {
- return entries.map((e) => {
- if (e.kind === 'opportunity') {
- const a = db.artifactById[e.id];
- 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: '' };
- }
- const opp = db.opportunities.find((o) => o.title === e.subject);
- const p = db.projectById[db.artifactById[e.id]?.projectId] || null;
- return {
- ...e,
- 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,
- audience: p?.targetUser || '',
- scenario: p?.scenario || '',
- };
- });
- }
- /* ============================================================
- * 报告中心
- * ============================================================ */
- /** 报告中心:每个类型几份 + 参照 + 最新几条 + 未归类 */
- export function reportCenter(db, { now = Date.now() } = {}) {
- const types = REPORT_TYPES.map((t) => {
- const entries = entriesForType(db, t.key);
- const recent = entries.filter((e) => toTime(e.at) && now - toTime(e.at) <= WEEK).length;
- const latest = entries.slice(0, 3);
- const people = new Set(entries.map((e) => e.owner).filter(Boolean));
- const withBody = entries.filter((e) => e.body !== 'none').length;
- return {
- type: t,
- count: entries.length,
- recentWeek: recent,
- people: people.size,
- withBody,
- noBody: entries.length - withBody,
- latest,
- all: entries,
- reportCount: entries.filter((e) => e.hasReport).length,
- stageTally: stageTally(entries),
- };
- });
- const mapped = new Set(Object.keys(TYPE_FOR_ARTIFACT));
- 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 }));
- // 类型页允许同一机会卡被不同视角引用;中心清单每条只出现一次,未归类成果也可打开。
- const unique = new Map();
- for (const group of types) for (const entry of group.all) {
- const key = `${entry.kind}:${entry.id}`;
- if (!unique.has(key)) unique.set(key, { ...entry, typeName: entry.kind === 'opportunity' ? '机会卡' : group.type.name });
- }
- for (const item of unclassified) unique.set(`artifact:${item.id}`, { ...entryOfArtifact(db, db.artifactById[item.id], 'other'), typeName: '未归类' });
- const entries = [...unique.values()];
- const all = types.reduce((n, t) => n + t.count, 0);
- const max = Math.max(...types.map((t) => t.count), 1);
- return {
- types: types.map((t) => ({ ...t, share: all ? Math.round((t.count / all) * 100) : 0, relative: Math.round((t.count / max) * 100) })),
- total: entries.length,
- entries,
- max,
- unclassified,
- stageTally: STAGES.map((s) => ({ stage: s, count: entries.filter(e => e.stage?.code === s.code).length })),
- _reportKeys: REPORT_TYPE_KEYS,
- };
- }
- /* ============================================================
- * 阶段产出(阶段页的「内容」视角)
- * ============================================================ */
- const GROUP_ORDER = [
- ['category', '品类分析'],
- ['competitor', '竞品分析'],
- ['voc', 'VOC / 用户洞察'],
- ['pain', '痛点清单'],
- ['concept', '概念提案'],
- ['opportunity', '机会卡'],
- ['other', '其他成果'],
- ];
- /** 一眼摘要:一个阶段产出了什么(按类型分组 + 卡片摘要) */
- export function stageOutcome(db, stageCode) {
- const stage = STAGES.find((s) => s.code === stageCode) || STAGES[0];
- const entries = [];
- for (const t of REPORT_TYPES) entries.push(...entriesForType(db, t.key).filter((e) => e.stage?.code === stage.code));
- // 映射不到类型的成果单独成组
- const mapped = new Set(Object.keys(TYPE_FOR_ARTIFACT));
- const others = db.artifacts.filter((a) => a.stageObj?.code === stage.code && !mapped.has(a.type))
- .map((a) => entryOfArtifact(db, a, 'other'));
- for (const o of others) entries.push(o);
- const oppCards = db.opportunities.filter((o) => o.stageObj?.code === stage.code).map((o) => entryOfOpportunity(o, 'opportunity'));
- const groups = GROUP_ORDER.map(([key, label]) => {
- let items;
- if (key === 'opportunity') items = oppCards;
- else if (key === 'other') items = others;
- else items = entries.filter((e) => e.typeKey === key && e.kind !== 'opportunity');
- // 「有证据」与类型页「只看有证据的」同一条口径(core.library.hasEvidence)
- return { key, label, count: items.length, items: items.sort((a, b) => (b.at || '').localeCompare(a.at || '')), verified: items.filter(hasEvidence).length };
- }).filter((g) => g.count > 0);
- const owners = new Map();
- for (const e of [...entries, ...oppCards]) if (e.owner) owners.set(e.owner, (owners.get(e.owner) || 0) + 1);
- return {
- stage,
- groups,
- total: groups.reduce((n, g) => n + g.count, 0),
- ownerCount: owners.size,
- owners: [...owners.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count),
- withBody: [...entries, ...oppCards].filter((e) => e.body !== 'none' && e.body !== 'drawer').length,
- };
- }
- /** 阶段级「五官」:给页头用的一行判断 */
- export function stageOutcomeSummary(rows) {
- const total = rows.reduce((n, r) => n + r.total, 0);
- const full = rows.filter((r) => r.total > 0).length;
- const top = rows.slice().sort((a, b) => b.total - a.total)[0];
- return { total, fullStages: full, topStage: top?.stage?.code || '', topCount: top?.total || 0 };
- }
- /* ============================================================
- * 时间格式(页面直接用)
- * ============================================================ */
- export const whenText = (v, now = Date.now()) => (toIso(v) ? fmtAgo(v, now) : cjkSpace('时间未记录')); // fmtAgo 在生成处插 U+2009;占位句同样过一遍,页面一律 ${whenText(...)} 即可
- export const stageCodeOf = (v) => stageOf(v)?.code || '';
|