diff --git a/modules/story-summary/vector/llm/atom-extraction.js b/modules/story-summary/vector/llm/atom-extraction.js index 9c31e44..1a600ef 100644 --- a/modules/story-summary/vector/llm/atom-extraction.js +++ b/modules/story-summary/vector/llm/atom-extraction.js @@ -63,6 +63,8 @@ const SYSTEM_PROMPT = `你是场景摘要器。从一轮对话中提取1-2个场 - 只从正文内容中识别角色名,不要把标签名(如 user、assistant)当作角色 - r 使用动作模板短语:“动作+对象/结果”(例:“提出交易条件”、“拒绝对方请求”、“当众揭露秘密”、“安抚对方情绪”) - r 不要写人名,不要复述整句,不要写心理描写或评价词 +- r 正例(合格):提出交易条件、拒绝对方请求、当众揭露秘密、安抚对方情绪、强行打断发言、转移谈话焦点 +- r 反例(不合格):我觉得她现在很害怕、他突然非常生气地大喊起来、user开始说话、assistant解释了很多细节 - 每个锚点 1-3 条 ## where @@ -87,6 +89,46 @@ const JSON_PREFILL = '{"anchors":['; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +const ACTION_STRIP_WORDS = [ + '突然', '非常', '有些', '有点', '轻轻', '悄悄', '缓缓', '立刻', + '马上', '然后', '并且', '而且', '开始', '继续', '再次', '正在', +]; + +function clamp(v, min, max) { + return Math.max(min, Math.min(max, v)); +} + +function sanitizeActionPhrase(raw) { + let text = String(raw || '') + .normalize('NFKC') + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .trim(); + if (!text) return ''; + + text = text + .replace(/[,。!?、;:,.!?;:"'“”‘’()()[\]{}<>《》]/g, '') + .replace(/\s+/g, ''); + + for (const word of ACTION_STRIP_WORDS) { + text = text.replaceAll(word, ''); + } + + text = text.replace(/(地|得|了|着|过)+$/g, ''); + + if (text.length < 2) return ''; + if (text.length > 12) text = text.slice(0, 12); + return text; +} + +function calcAtomQuality(scene, edges, where) { + const sceneLen = String(scene || '').length; + const sceneScore = clamp(sceneLen / 80, 0, 1); + const edgeScore = clamp((edges?.length || 0) / 3, 0, 1); + const whereScore = where ? 1 : 0; + const quality = 0.55 * sceneScore + 0.35 * edgeScore + 0.10 * whereScore; + return Number(quality.toFixed(3)); +} + // ============================================================================ // 清洗与构建 // ============================================================================ @@ -103,7 +145,7 @@ function sanitizeEdges(raw) { .map(e => ({ s: String(e.s || '').trim(), t: String(e.t || '').trim(), - r: String(e.r || '').trim().slice(0, 30), + r: sanitizeActionPhrase(e.r), })) .filter(e => e.s && e.t && e.r) .slice(0, 3); @@ -127,6 +169,7 @@ function anchorToAtom(anchor, aiFloor, idx) { if (scene.length < 15) return null; const edges = sanitizeEdges(anchor.edges); const where = String(anchor.where || '').trim(); + const quality = calcAtomQuality(scene, edges, where); return { atomId: `atom-${aiFloor}-${idx}`, @@ -139,6 +182,7 @@ function anchorToAtom(anchor, aiFloor, idx) { // ═══ 图结构层(扩散的 key) ═══ edges, where, + quality, }; } diff --git a/modules/story-summary/vector/retrieval/diffusion.js b/modules/story-summary/vector/retrieval/diffusion.js index 4bf0d86..6136dbe 100644 --- a/modules/story-summary/vector/retrieval/diffusion.js +++ b/modules/story-summary/vector/retrieval/diffusion.js @@ -47,20 +47,23 @@ const CONFIG = { MAX_ITER: 50, // hard iteration cap (typically converges in 15-25) // Edge weight channel coefficients - // No standalone WHO channel: rely on interaction/action/location only. + // Candidate generation uses WHAT/HOW only. + // WHO/WHERE are reweight-only signals. GAMMA: { - what: 0.55, // interaction pair overlap — Szymkiewicz-Simpson - where: 0.15, // location exact match — binary + what: 0.45, // interaction pair overlap — Szymkiewicz-Simpson how: 0.30, // action-term co-occurrence — Jaccard + who: 0.15, // endpoint entity overlap — Jaccard (reweight-only) + where: 0.10, // location exact match — damped (reweight-only) }, WHERE_MAX_GROUP_SIZE: 16, // skip location-only pair expansion for over-common places WHERE_FREQ_DAMP_PIVOT: 6, // location freq <= pivot keeps full WHERE score WHERE_FREQ_DAMP_MIN: 0.20, // lower bound for damped WHERE contribution + HOW_MAX_GROUP_SIZE: 24, // skip ultra-common action terms to avoid dense pair explosion // Post-verification (Cosine Gate) - COSINE_GATE: 0.45, // min cosine(queryVector, stateVector) - SCORE_FLOOR: 0.10, // min finalScore = PPR_normalized × cosine - DIFFUSION_CAP: 100, // max diffused nodes (excluding seeds) + COSINE_GATE: 0.48, // min cosine(queryVector, stateVector) + SCORE_FLOOR: 0.12, // min finalScore = PPR_normalized × cosine + DIFFUSION_CAP: 80, // max diffused nodes (excluding seeds) }; // ═══════════════════════════════════════════════════════════════════════════ @@ -226,25 +229,27 @@ function extractAllFeatures(allAtoms, excludeEntities = new Set()) { /** * Build inverted index: value → list of atom indices * @param {object[]} features - * @returns {{ entityIndex: Map, locationIndex: Map }} + * @returns {{ whatIndex: Map, howIndex: Map, locationFreq: Map }} */ function buildInvertedIndices(features) { - const entityIndex = new Map(); - const locationIndex = new Map(); + const whatIndex = new Map(); + const howIndex = new Map(); + const locationFreq = new Map(); for (let i = 0; i < features.length; i++) { - for (const e of features[i].entities) { - if (!entityIndex.has(e)) entityIndex.set(e, []); - entityIndex.get(e).push(i); + for (const pair of features[i].interactionPairs) { + if (!whatIndex.has(pair)) whatIndex.set(pair, []); + whatIndex.get(pair).push(i); + } + for (const action of features[i].actionTerms) { + if (!howIndex.has(action)) howIndex.set(action, []); + howIndex.get(action).push(i); } const loc = features[i].location; - if (loc) { - if (!locationIndex.has(loc)) locationIndex.set(loc, []); - locationIndex.get(loc).push(i); - } + if (loc) locationFreq.set(loc, (locationFreq.get(loc) || 0) + 1); } - return { entityIndex, locationIndex }; + return { whatIndex, howIndex, locationFreq }; } /** @@ -277,30 +282,32 @@ function buildGraph(allAtoms, excludeEntities = new Set()) { const T0 = performance.now(); const features = extractAllFeatures(allAtoms, excludeEntities); - const { entityIndex, locationIndex } = buildInvertedIndices(features); - const locationFreq = new Map(); - for (const [loc, indices] of locationIndex.entries()) { - locationFreq.set(loc, indices.length); - } + const { whatIndex, howIndex, locationFreq } = buildInvertedIndices(features); - // Candidate pairs: share ≥1 entity or same location + // Candidate pairs: only WHAT/HOW can create edges + const pairSetByWhat = new Set(); + const pairSetByHow = new Set(); const pairSet = new Set(); - collectPairsFromIndex(entityIndex, pairSet, N); - let skippedLocationGroups = 0; - for (const [loc, indices] of locationIndex.entries()) { - if (!loc) continue; - if (indices.length > CONFIG.WHERE_MAX_GROUP_SIZE) { - skippedLocationGroups++; + collectPairsFromIndex(whatIndex, pairSetByWhat, N); + let skippedHowGroups = 0; + for (const [term, indices] of howIndex.entries()) { + if (!term) continue; + if (indices.length > CONFIG.HOW_MAX_GROUP_SIZE) { + skippedHowGroups++; continue; } - const oneLocMap = new Map([[loc, indices]]); - collectPairsFromIndex(oneLocMap, pairSet, N); + const oneHowMap = new Map([[term, indices]]); + collectPairsFromIndex(oneHowMap, pairSetByHow, N); } + for (const p of pairSetByWhat) pairSet.add(p); + for (const p of pairSetByHow) pairSet.add(p); - // Compute three-channel edge weights for all candidates + // Compute edge weights for all candidates const neighbors = Array.from({ length: N }, () => []); let edgeCount = 0; - const channelStats = { what: 0, where: 0, how: 0 }; + const channelStats = { what: 0, where: 0, how: 0, who: 0 }; + let reweightWhoUsed = 0; + let reweightWhereUsed = 0; for (const packed of pairSet) { const i = Math.floor(packed / N); @@ -310,6 +317,8 @@ function buildGraph(allAtoms, excludeEntities = new Set()) { const fj = features[j]; const wWhat = overlapCoefficient(fi.interactionPairs, fj.interactionPairs); + const wHow = jaccard(fi.actionTerms, fj.actionTerms); + const wWho = jaccard(fi.entities, fj.entities); let wWhere = 0.0; if (fi.location && fi.location === fj.location) { const freq = locationFreq.get(fi.location) || 1; @@ -319,12 +328,12 @@ function buildGraph(allAtoms, excludeEntities = new Set()) { ); wWhere = damp; } - const wHow = jaccard(fi.actionTerms, fj.actionTerms); const weight = CONFIG.GAMMA.what * wWhat + - CONFIG.GAMMA.where * wWhere + - CONFIG.GAMMA.how * wHow; + CONFIG.GAMMA.how * wHow + + CONFIG.GAMMA.who * wWho + + CONFIG.GAMMA.where * wWhere; if (weight > 0) { neighbors[i].push({ target: j, weight }); @@ -332,8 +341,11 @@ function buildGraph(allAtoms, excludeEntities = new Set()) { edgeCount++; if (wWhat > 0) channelStats.what++; - if (wWhere > 0) channelStats.where++; if (wHow > 0) channelStats.how++; + if (wWho > 0) channelStats.who++; + if (wWhere > 0) channelStats.where++; + if (wWho > 0) reweightWhoUsed++; + if (wWhere > 0) reweightWhereUsed++; } } @@ -341,12 +353,28 @@ function buildGraph(allAtoms, excludeEntities = new Set()) { xbLog.info(MODULE_ID, `Graph: ${N} nodes, ${edgeCount} edges ` + - `(what=${channelStats.what} where=${channelStats.where} how=${channelStats.how}) ` + - `(whereSkippedGroups=${skippedLocationGroups}) ` + + `(candidate_by_what=${pairSetByWhat.size} candidate_by_how=${pairSetByHow.size}) ` + + `(what=${channelStats.what} how=${channelStats.how} who=${channelStats.who} where=${channelStats.where}) ` + + `(reweight_who_used=${reweightWhoUsed} reweight_where_used=${reweightWhereUsed}) ` + + `(howSkippedGroups=${skippedHowGroups}) ` + `(${buildTime}ms)` ); - return { neighbors, edgeCount, channelStats, buildTime }; + const totalPairs = N > 1 ? (N * (N - 1)) / 2 : 0; + const edgeDensity = totalPairs > 0 ? Number((edgeCount / totalPairs * 100).toFixed(2)) : 0; + + return { + neighbors, + edgeCount, + channelStats, + buildTime, + candidatePairs: pairSet.size, + pairsFromWhat: pairSetByWhat.size, + pairsFromHow: pairSetByHow.size, + reweightWhoUsed, + reweightWhereUsed, + edgeDensity, + }; } // ═══════════════════════════════════════════════════════════════════════════ @@ -673,6 +701,12 @@ export function diffuseFromSeeds(seeds, allAtoms, stateVectors, queryVector, met graphNodes: N, graphEdges: 0, channelStats: graph.channelStats, + candidatePairs: graph.candidatePairs, + pairsFromWhat: graph.pairsFromWhat, + pairsFromHow: graph.pairsFromHow, + edgeDensity: graph.edgeDensity, + reweightWhoUsed: graph.reweightWhoUsed, + reweightWhereUsed: graph.reweightWhereUsed, time: graph.buildTime, }); xbLog.info(MODULE_ID, 'No graph edges — skipping diffusion'); @@ -719,6 +753,12 @@ export function diffuseFromSeeds(seeds, allAtoms, stateVectors, queryVector, met graphNodes: N, graphEdges: graph.edgeCount, channelStats: graph.channelStats, + candidatePairs: graph.candidatePairs, + pairsFromWhat: graph.pairsFromWhat, + pairsFromHow: graph.pairsFromHow, + edgeDensity: graph.edgeDensity, + reweightWhoUsed: graph.reweightWhoUsed, + reweightWhereUsed: graph.reweightWhereUsed, buildTime: graph.buildTime, iterations, convergenceError: finalError, @@ -726,6 +766,9 @@ export function diffuseFromSeeds(seeds, allAtoms, stateVectors, queryVector, met cosineGatePassed: gateStats.passed, cosineGateFiltered: gateStats.filtered, cosineGateNoVector: gateStats.noVector, + postGatePassRate: pprActivated > 0 + ? Math.round((gateStats.passed / pprActivated) * 100) + : 0, finalCount: diffused.length, scoreDistribution: diffused.length > 0 ? calcScoreStats(diffused.map(d => d.finalScore)) @@ -783,7 +826,14 @@ function fillMetricsEmpty(metrics) { cosineGateNoVector: 0, finalCount: 0, scoreDistribution: { min: 0, max: 0, mean: 0 }, - byChannel: { what: 0, where: 0, how: 0 }, + byChannel: { what: 0, where: 0, how: 0, who: 0 }, + candidatePairs: 0, + pairsFromWhat: 0, + pairsFromHow: 0, + edgeDensity: 0, + reweightWhoUsed: 0, + reweightWhereUsed: 0, + postGatePassRate: 0, time: 0, }; } @@ -803,9 +853,16 @@ function fillMetrics(metrics, data) { cosineGatePassed: data.cosineGatePassed || 0, cosineGateFiltered: data.cosineGateFiltered || 0, cosineGateNoVector: data.cosineGateNoVector || 0, + postGatePassRate: data.postGatePassRate || 0, finalCount: data.finalCount || 0, scoreDistribution: data.scoreDistribution || { min: 0, max: 0, mean: 0 }, - byChannel: data.channelStats || { what: 0, where: 0, how: 0 }, + byChannel: data.channelStats || { what: 0, where: 0, how: 0, who: 0 }, + candidatePairs: data.candidatePairs || 0, + pairsFromWhat: data.pairsFromWhat || 0, + pairsFromHow: data.pairsFromHow || 0, + edgeDensity: data.edgeDensity || 0, + reweightWhoUsed: data.reweightWhoUsed || 0, + reweightWhereUsed: data.reweightWhereUsed || 0, time: data.time || 0, }; } diff --git a/modules/story-summary/vector/retrieval/metrics.js b/modules/story-summary/vector/retrieval/metrics.js index 63d84c6..6d235e8 100644 --- a/modules/story-summary/vector/retrieval/metrics.js +++ b/modules/story-summary/vector/retrieval/metrics.js @@ -118,15 +118,22 @@ export function createMetrics() { seedCount: 0, graphNodes: 0, graphEdges: 0, + candidatePairs: 0, + pairsFromWhat: 0, + pairsFromHow: 0, + edgeDensity: 0, + reweightWhoUsed: 0, + reweightWhereUsed: 0, iterations: 0, convergenceError: 0, pprActivated: 0, cosineGatePassed: 0, cosineGateFiltered: 0, cosineGateNoVector: 0, + postGatePassRate: 0, finalCount: 0, scoreDistribution: { min: 0, max: 0, mean: 0 }, - byChannel: { what: 0, where: 0, how: 0 }, + byChannel: { what: 0, where: 0, how: 0, who: 0 }, time: 0, }, @@ -169,6 +176,7 @@ export function createMetrics() { eventPrecisionProxy: 0, l1AttachRate: 0, rerankRetentionRate: 0, + diffusionEffectiveRate: 0, potentialIssues: [], }, }; @@ -368,9 +376,12 @@ export function formatMetricsLog(metrics) { lines.push('[Diffusion] PPR Spreading Activation'); lines.push(`├─ seeds: ${m.diffusion.seedCount}`); lines.push(`├─ graph: ${m.diffusion.graphNodes} nodes, ${m.diffusion.graphEdges} edges`); + lines.push(`├─ candidate_pairs: ${m.diffusion.candidatePairs || 0} (what=${m.diffusion.pairsFromWhat || 0}, how=${m.diffusion.pairsFromHow || 0})`); + lines.push(`├─ edge_density: ${m.diffusion.edgeDensity || 0}%`); if (m.diffusion.graphEdges > 0) { const ch = m.diffusion.byChannel || {}; - lines.push(`│ └─ by_channel: what=${ch.what || 0}, where=${ch.where || 0}, how=${ch.how || 0}`); + lines.push(`│ ├─ by_channel: what=${ch.what || 0}, how=${ch.how || 0}, who=${ch.who || 0}, where=${ch.where || 0}`); + lines.push(`│ └─ reweight_used: who=${m.diffusion.reweightWhoUsed || 0}, where=${m.diffusion.reweightWhereUsed || 0}`); } if (m.diffusion.iterations > 0) { lines.push(`├─ ppr: ${m.diffusion.iterations} iterations, ε=${Number(m.diffusion.convergenceError).toExponential(1)}`); @@ -378,8 +389,10 @@ export function formatMetricsLog(metrics) { lines.push(`├─ activated (excl seeds): ${m.diffusion.pprActivated}`); if (m.diffusion.pprActivated > 0) { lines.push(`├─ cosine_gate: ${m.diffusion.cosineGatePassed} passed, ${m.diffusion.cosineGateFiltered} filtered`); + const passPrefix = m.diffusion.cosineGateNoVector > 0 ? '│ ├─' : '│ └─'; + lines.push(`${passPrefix} pass_rate: ${m.diffusion.postGatePassRate || 0}%`); if (m.diffusion.cosineGateNoVector > 0) { - lines.push(`│ └─ no_vector: ${m.diffusion.cosineGateNoVector}`); + lines.push(`│ ├─ no_vector: ${m.diffusion.cosineGateNoVector}`); } } lines.push(`├─ final_injected: ${m.diffusion.finalCount}`); @@ -435,6 +448,7 @@ export function formatMetricsLog(metrics) { lines.push(`├─ event_precision_proxy: ${m.quality.eventPrecisionProxy}`); lines.push(`├─ l1_attach_rate: ${m.quality.l1AttachRate}%`); lines.push(`├─ rerank_retention_rate: ${m.quality.rerankRetentionRate}%`); + lines.push(`├─ diffusion_effective_rate: ${m.quality.diffusionEffectiveRate}%`); if (m.quality.potentialIssues && m.quality.potentialIssues.length > 0) { lines.push(`└─ potential_issues:`); @@ -642,6 +656,10 @@ export function detectIssues(metrics) { issues.push('All PPR-activated nodes failed cosine gate - graph structure diverged from query semantics'); } + m.quality.diffusionEffectiveRate = m.diffusion.pprActivated > 0 + ? Math.round((m.diffusion.finalCount / m.diffusion.pprActivated) * 100) + : 0; + if (m.diffusion.cosineGateNoVector > 5) { issues.push(`${m.diffusion.cosineGateNoVector} PPR nodes missing vectors - L0 vectorization may be incomplete`); } @@ -650,5 +668,9 @@ export function detectIssues(metrics) { issues.push(`Slow diffusion (${m.diffusion.time}ms) - graph may be too dense`); } + if (m.diffusion.pprActivated > 0 && (m.diffusion.postGatePassRate < 20 || m.diffusion.postGatePassRate > 60)) { + issues.push(`Diffusion post-gate pass rate out of target (${m.diffusion.postGatePassRate}%)`); + } + return issues; }