refactor focus concepts: add focusTerms/focusCharacters and switch character filtering

This commit is contained in:
2026-02-15 18:58:51 +08:00
parent d7beead43a
commit ab8f2c9f40
5 changed files with 228 additions and 117 deletions

View File

@@ -19,6 +19,7 @@ import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/con
import { recallMemory } from "../vector/retrieval/recall.js";
import { getMeta } from "../vector/storage/chunk-store.js";
import { getEngineFingerprint } from "../vector/utils/embedder.js";
import { buildTrustedCharacters } from "../vector/retrieval/entity-lexicon.js";
// Metrics
import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js";
@@ -239,23 +240,11 @@ function buildPostscript() {
* @returns {Set<string>} 角色名称集合(标准化后)
*/
function getKnownCharacters(store) {
const names = new Set();
const arcs = store?.json?.arcs || [];
for (const a of arcs) {
if (a.name) names.add(normalize(a.name));
}
const main = store?.json?.characters?.main || [];
for (const m of main) {
const name = typeof m === 'string' ? m : m.name;
if (name) names.add(normalize(name));
}
const { name1, name2 } = getContext();
const names = buildTrustedCharacters(store, { name1, name2 }) || new Set();
// Keep name1 in known-character filtering domain to avoid behavior regression
// for L3 subject filtering (lexicon exclusion and filtering semantics are different concerns).
if (name1) names.add(normalize(name1));
if (name2) names.add(normalize(name2));
return names;
}
@@ -272,14 +261,14 @@ function parseRelationTarget(predicate) {
/**
* 按相关性过滤 facts
* @param {object[]} facts - 所有 facts
* @param {string[]} focusEntities - 焦点实体
* @param {string[]} focusCharacters - 焦点人物
* @param {Set<string>} knownCharacters - 已知角色
* @returns {object[]} 过滤后的 facts
*/
function filterConstraintsByRelevance(facts, focusEntities, knownCharacters) {
function filterConstraintsByRelevance(facts, focusCharacters, knownCharacters) {
if (!facts?.length) return [];
const focusSet = new Set((focusEntities || []).map(normalize));
const focusSet = new Set((focusCharacters || []).map(normalize));
return facts.filter(f => {
if (f._isState === true) return true;
@@ -304,13 +293,13 @@ function filterConstraintsByRelevance(facts, focusEntities, knownCharacters) {
/**
* Build people dictionary for constraints display.
* Primary source: selected event participants; fallback: focus entities.
* Primary source: selected event participants; fallback: focus characters.
*
* @param {object|null} recallResult
* @param {string[]} focusEntities
* @param {string[]} focusCharacters
* @returns {Map<string, string>} normalize(name) -> display name
*/
function buildConstraintPeopleDict(recallResult, focusEntities = []) {
function buildConstraintPeopleDict(recallResult, focusCharacters = []) {
const dict = new Map();
const add = (raw) => {
const display = String(raw || '').trim();
@@ -326,7 +315,7 @@ function buildConstraintPeopleDict(recallResult, focusEntities = []) {
}
if (dict.size === 0) {
for (const f of (focusEntities || [])) add(f);
for (const f of (focusCharacters || [])) add(f);
}
return dict;
@@ -375,16 +364,19 @@ function formatConstraintLine(f, includeSubject = false) {
* @param {{ people: Map<string, object[]>, world: object[] }} grouped
* @returns {string[]}
*/
function formatConstraintsStructured(grouped) {
function formatConstraintsStructured(grouped, order = 'desc') {
const lines = [];
const people = grouped?.people || new Map();
const world = grouped?.world || [];
const sorter = order === 'asc'
? ((a, b) => (a.since || 0) - (b.since || 0))
: ((a, b) => (b.since || 0) - (a.since || 0));
if (people.size > 0) {
lines.push('people:');
for (const [name, facts] of people.entries()) {
lines.push(` ${name}:`);
const sorted = [...facts].sort((a, b) => (b.since || 0) - (a.since || 0));
const sorted = [...facts].sort(sorter);
for (const f of sorted) {
lines.push(` ${formatConstraintLine(f, false)}`);
}
@@ -393,7 +385,7 @@ function formatConstraintsStructured(grouped) {
if (world.length > 0) {
lines.push('world:');
const sortedWorld = [...world].sort((a, b) => (b.since || 0) - (a.since || 0));
const sortedWorld = [...world].sort(sorter);
for (const f of sortedWorld) {
lines.push(` ${formatConstraintLine(f, true)}`);
}
@@ -402,6 +394,58 @@ function formatConstraintsStructured(grouped) {
return lines;
}
function tryConsumeConstraintLineBudget(line, budgetState) {
const cost = estimateTokens(line);
if (budgetState.used + cost > budgetState.max) return false;
budgetState.used += cost;
return true;
}
function selectConstraintsByBudgetDesc(grouped, budgetState) {
const selectedPeople = new Map();
const selectedWorld = [];
const people = grouped?.people || new Map();
const world = grouped?.world || [];
if (people.size > 0) {
if (!tryConsumeConstraintLineBudget('people:', budgetState)) {
return { people: selectedPeople, world: selectedWorld };
}
for (const [name, facts] of people.entries()) {
const header = ` ${name}:`;
if (!tryConsumeConstraintLineBudget(header, budgetState)) {
return { people: selectedPeople, world: selectedWorld };
}
const picked = [];
const sorted = [...facts].sort((a, b) => (b.since || 0) - (a.since || 0));
for (const f of sorted) {
const line = ` ${formatConstraintLine(f, false)}`;
if (!tryConsumeConstraintLineBudget(line, budgetState)) {
return { people: selectedPeople, world: selectedWorld };
}
picked.push(f);
}
selectedPeople.set(name, picked);
}
}
if (world.length > 0) {
if (!tryConsumeConstraintLineBudget('world:', budgetState)) {
return { people: selectedPeople, world: selectedWorld };
}
const sortedWorld = [...world].sort((a, b) => (b.since || 0) - (a.since || 0));
for (const f of sortedWorld) {
const line = ` ${formatConstraintLine(f, true)}`;
if (!tryConsumeConstraintLineBudget(line, budgetState)) {
return { people: selectedPeople, world: selectedWorld };
}
selectedWorld.push(f);
}
}
return { people: selectedPeople, world: selectedWorld };
}
// ─────────────────────────────────────────────────────────────────────────────
// 格式化函数
// ─────────────────────────────────────────────────────────────────────────────
@@ -703,7 +747,7 @@ function buildNonVectorPrompt(store) {
nonVectorKnownCharacters
);
const groupedConstraints = groupConstraintsForDisplay(filteredConstraints, nonVectorPeopleDict);
const constraintLines = formatConstraintsStructured(groupedConstraints);
const constraintLines = formatConstraintsStructured(groupedConstraints, 'asc');
if (constraintLines.length) {
sections.push(`[定了的事] 已确立的事实\n${constraintLines.join("\n")}`);
@@ -772,12 +816,12 @@ export function buildNonVectorPromptText() {
* @param {object} store - 存储对象
* @param {object} recallResult - 召回结果
* @param {Map<string, object>} causalById - 因果事件索引
* @param {string[]} focusEntities - 焦点实体
* @param {string[]} focusCharacters - 焦点人物
* @param {object} meta - 元数据
* @param {object} metrics - 指标对象
* @returns {Promise<{promptText: string, injectionStats: object, metrics: object}>}
*/
async function buildVectorPrompt(store, recallResult, causalById, focusEntities, meta, metrics) {
async function buildVectorPrompt(store, recallResult, causalById, focusCharacters, meta, metrics) {
const T_Start = performance.now();
const data = store.json || {};
@@ -825,21 +869,21 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const allFacts = getFacts();
const knownCharacters = getKnownCharacters(store);
const filteredConstraints = filterConstraintsByRelevance(allFacts, focusEntities, knownCharacters);
const constraintPeopleDict = buildConstraintPeopleDict(recallResult, focusEntities);
const filteredConstraints = filterConstraintsByRelevance(allFacts, focusCharacters, knownCharacters);
const constraintPeopleDict = buildConstraintPeopleDict(recallResult, focusCharacters);
const groupedConstraints = groupConstraintsForDisplay(filteredConstraints, constraintPeopleDict);
const constraintLines = formatConstraintsStructured(groupedConstraints);
if (metrics) {
metrics.constraint.total = allFacts.length;
metrics.constraint.filtered = allFacts.length - filteredConstraints.length;
}
const constraintBudget = { used: 0, max: Math.min(CONSTRAINT_MAX, total.max - total.used) };
const groupedSelectedConstraints = selectConstraintsByBudgetDesc(groupedConstraints, constraintBudget);
const constraintLines = formatConstraintsStructured(groupedSelectedConstraints, 'asc');
if (constraintLines.length) {
const constraintBudget = { used: 0, max: Math.min(CONSTRAINT_MAX, total.max - total.used) };
for (const line of constraintLines) {
if (!pushWithBudget(assembled.constraints.lines, line, constraintBudget)) break;
}
assembled.constraints.lines.push(...constraintLines);
assembled.constraints.tokens = constraintBudget.used;
total.used += constraintBudget.used;
injectionStats.constraint.count = assembled.constraints.lines.length;
@@ -867,7 +911,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const userName = String(name1 || "").trim();
const relevant = new Set(
[userName, ...(focusEntities || [])]
[userName, ...(focusCharacters || [])]
.map(s => String(s || "").trim())
.filter(Boolean)
);
@@ -1048,7 +1092,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const keepVisible = store.keepVisibleCount ?? 3;
// 收集未被事件消费的 L0按 rerankScore 降序
const focusSetForEvidence = new Set((focusEntities || []).map(normalize).filter(Boolean));
const focusSetForEvidence = new Set((focusCharacters || []).map(normalize).filter(Boolean));
const remainingL0 = l0Selected
.filter(l0 => !usedL0Ids.has(l0.id))
@@ -1279,7 +1323,9 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
l0Selected: recallResult?.l0Selected || [],
l1ByFloor: recallResult?.l1ByFloor || new Map(),
causalChain: recallResult?.causalChain || [],
focusEntities: recallResult?.focusEntities || [],
focusTerms: recallResult?.focusTerms || recallResult?.focusEntities || [],
focusEntities: recallResult?.focusTerms || recallResult?.focusEntities || [], // compat alias
focusCharacters: recallResult?.focusCharacters || [],
metrics: recallResult?.metrics || null,
};
@@ -1340,7 +1386,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
store,
recallResult,
causalById,
recallResult?.focusEntities || [],
recallResult?.focusCharacters || [],
meta,
recallResult?.metrics || null
);