story-summary: facts migration + recall enhancements

This commit is contained in:
2026-02-02 21:45:01 +08:00
parent d3f772073f
commit fb8ed8037c
8 changed files with 570 additions and 289 deletions

View File

@@ -1,5 +1,5 @@
// Story Summary - Store // Story Summary - Store
// L2 (events/characters/arcs) + L3 (world) 统一存储 // L2 (events/characters/arcs) + L3 (facts) 统一存储
import { getContext, saveMetadataDebounced } from "../../../../../../extensions.js"; import { getContext, saveMetadataDebounced } from "../../../../../../extensions.js";
import { chat_metadata } from "../../../../../../../script.js"; import { chat_metadata } from "../../../../../../../script.js";
@@ -20,7 +20,26 @@ export function getSummaryStore() {
chat_metadata.extensions ||= {}; chat_metadata.extensions ||= {};
chat_metadata.extensions[EXT_ID] ||= {}; chat_metadata.extensions[EXT_ID] ||= {};
chat_metadata.extensions[EXT_ID].storySummary ||= {}; chat_metadata.extensions[EXT_ID].storySummary ||= {};
return chat_metadata.extensions[EXT_ID].storySummary;
const store = chat_metadata.extensions[EXT_ID].storySummary;
// ★ 自动迁移旧数据
if (store.json && !store.json.facts) {
const hasOldData = store.json.world?.length || store.json.characters?.relationships?.length;
if (hasOldData) {
store.json.facts = migrateToFacts(store.json);
// 删除旧字段
delete store.json.world;
if (store.json.characters) {
delete store.json.characters.relationships;
}
store.updatedAt = Date.now();
saveSummaryStore();
xbLog.info(MODULE_ID, `自动迁移完成: ${store.json.facts.length} 条 facts`);
}
}
return store;
} }
export function saveSummaryStore() { export function saveSummaryStore() {
@@ -32,7 +51,6 @@ export function getKeepVisibleCount() {
return store?.keepVisibleCount ?? 3; return store?.keepVisibleCount ?? 3;
} }
// boundary隐藏边界由调用方决定语义LLM总结边界 or 向量边界)
export function calcHideRange(boundary) { export function calcHideRange(boundary) {
if (boundary == null || boundary < 0) return null; if (boundary == null || boundary < 0) return null;
@@ -48,42 +66,155 @@ export function addSummarySnapshot(store, endMesId) {
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// L3 世界状态合并 // Fact 工具函数
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
export function mergeWorldState(existingList, updates, floor) { /**
* 判断是否为关系类 fact
*/
export function isRelationFact(f) {
return /^对.+的/.test(f.p);
}
/**
* 生成 fact 的唯一键s + p
*/
function factKey(f) {
return `${f.s}::${f.p}`;
}
/**
* 生成下一个 fact ID
*/
function getNextFactId(existingFacts) {
let maxId = 0;
for (const f of existingFacts || []) {
const match = f.id?.match(/^f-(\d+)$/);
if (match) {
maxId = Math.max(maxId, parseInt(match[1], 10));
}
}
return maxId + 1;
}
// ═══════════════════════════════════════════════════════════════════════════
// Facts 合并KV 覆盖模型)
// ═══════════════════════════════════════════════════════════════════════════
export function mergeFacts(existingFacts, updates, floor) {
const map = new Map(); const map = new Map();
(existingList || []).forEach(item => { // 加载现有 facts
const key = `${item.category}:${item.topic}`; for (const f of existingFacts || []) {
map.set(key, item); if (!f.retracted) {
}); map.set(factKey(f), f);
}
}
(updates || []).forEach(up => { // 获取下一个 ID
if (!up.category || !up.topic) return; let nextId = getNextFactId(existingFacts);
const key = `${up.category}:${up.topic}`; // 应用更新
for (const u of updates || []) {
if (!u.s || !u.p) continue;
if (up.cleared === true) { const key = factKey(u);
// 删除操作
if (u.retracted === true) {
map.delete(key); map.delete(key);
return; continue;
} }
const content = up.content?.trim(); // 无 o 则跳过
if (!content) return; if (!u.o || !String(u.o).trim()) continue;
map.set(key, { // 覆盖或新增
category: up.category, const existing = map.get(key);
topic: up.topic, const newFact = {
content: content, id: existing?.id || `f-${nextId++}`,
floor: floor, s: u.s.trim(),
_addedAt: floor, p: u.p.trim(),
}); o: String(u.o).trim(),
}); since: floor,
};
// 关系类保留 trend
if (isRelationFact(newFact) && u.trend) {
newFact.trend = u.trend;
}
// 保留原始 _addedAt如果是更新
if (existing?._addedAt != null) {
newFact._addedAt = existing._addedAt;
} else {
newFact._addedAt = floor;
}
map.set(key, newFact);
}
return Array.from(map.values()); return Array.from(map.values());
} }
// ═══════════════════════════════════════════════════════════════════════════
// 旧数据迁移
// ═══════════════════════════════════════════════════════════════════════════
export function migrateToFacts(json) {
if (!json) return [];
// 已有 facts 则跳过迁移
if (json.facts?.length) return json.facts;
const facts = [];
let nextId = 1;
// 迁移 worldworldUpdate 的持久化结果)
for (const w of json.world || []) {
if (!w.category || !w.topic || !w.content) continue;
let s, p;
// 解析 topic 格式status/knowledge/relation 用 "::" 分隔
if (w.topic.includes('::')) {
[s, p] = w.topic.split('::').map(x => x.trim());
} else {
// inventory/rule 类
s = w.topic.trim();
p = w.category;
}
if (!s || !p) continue;
facts.push({
id: `f-${nextId++}`,
s,
p,
o: w.content.trim(),
since: w.floor ?? w._addedAt ?? 0,
_addedAt: w._addedAt ?? w.floor ?? 0,
});
}
// 迁移 relationships
for (const r of json.characters?.relationships || []) {
if (!r.from || !r.to) continue;
facts.push({
id: `f-${nextId++}`,
s: r.from,
p: `${r.to}的看法`,
o: r.label || '未知',
trend: r.trend,
since: r._addedAt ?? 0,
_addedAt: r._addedAt ?? 0,
});
}
return facts;
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// 数据合并L2 + L3 // 数据合并L2 + L3
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -96,11 +227,10 @@ export function mergeNewData(oldJson, parsed, endMesId) {
merged.events ||= []; merged.events ||= [];
merged.characters ||= {}; merged.characters ||= {};
merged.characters.main ||= []; merged.characters.main ||= [];
merged.characters.relationships ||= [];
merged.arcs ||= []; merged.arcs ||= [];
// L3 初始化 // L3 初始化不再迁移getSummaryStore 已处理)
merged.world ||= []; merged.facts ||= [];
// L2 数据合并 // L2 数据合并
if (parsed.keywords?.length) { if (parsed.keywords?.length) {
@@ -112,6 +242,7 @@ export function mergeNewData(oldJson, parsed, endMesId) {
merged.events.push(e); merged.events.push(e);
}); });
// newCharacters
const existingMain = new Set( const existingMain = new Set(
(merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name) (merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
); );
@@ -121,22 +252,7 @@ export function mergeNewData(oldJson, parsed, endMesId) {
} }
}); });
const relMap = new Map( // arcUpdates
(merged.characters.relationships || []).map(r => [`${r.from}->${r.to}`, r])
);
(parsed.newRelationships || []).forEach(r => {
const key = `${r.from}->${r.to}`;
const existing = relMap.get(key);
if (existing) {
existing.label = r.label;
existing.trend = r.trend;
} else {
r._addedAt = endMesId;
relMap.set(key, r);
}
});
merged.characters.relationships = Array.from(relMap.values());
const arcMap = new Map((merged.arcs || []).map(a => [a.name, a])); const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
(parsed.arcUpdates || []).forEach(update => { (parsed.arcUpdates || []).forEach(update => {
const existing = arcMap.get(update.name); const existing = arcMap.get(update.name);
@@ -159,12 +275,8 @@ export function mergeNewData(oldJson, parsed, endMesId) {
}); });
merged.arcs = Array.from(arcMap.values()); merged.arcs = Array.from(arcMap.values());
// L3 世界状态合并 // L3 factUpdates 合并
merged.world = mergeWorldState( merged.facts = mergeFacts(merged.facts, parsed.factUpdates || [], endMesId);
merged.world || [],
parsed.worldUpdate || [],
endMesId
);
return merged; return merged;
} }
@@ -242,13 +354,10 @@ export async function executeRollback(chatId, store, targetEndMesId, currentLeng
json.characters.main = (json.characters.main || []).filter(m => json.characters.main = (json.characters.main || []).filter(m =>
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
); );
json.characters.relationships = (json.characters.relationships || []).filter(r =>
(r._addedAt ?? 0) <= targetEndMesId
);
} }
// L3 回滚 // L3 facts 回滚
json.world = (json.world || []).filter(w => (w._addedAt ?? 0) <= targetEndMesId); json.facts = (json.facts || []).filter(f => (f._addedAt ?? 0) <= targetEndMesId);
store.json = json; store.json = json;
store.lastSummarizedMesId = targetEndMesId; store.lastSummarizedMesId = targetEndMesId;
@@ -285,10 +394,17 @@ export async function clearSummaryData(chatId) {
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// L3 数据读取(供 prompt.js 使用) // L3 数据读取(供 prompt.js / recall.js 使用)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
export function getWorldSnapshot() { export function getFacts() {
const store = getSummaryStore(); const store = getSummaryStore();
return store?.json?.world || []; return (store?.json?.facts || []).filter(f => !f.retracted);
}
export function getNewCharacters() {
const store = getSummaryStore();
return (store?.json?.characters?.main || []).map(m =>
typeof m === 'string' ? m : m.name
);
} }

View File

@@ -3,7 +3,7 @@
import { getContext } from "../../../../../../extensions.js"; import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js"; import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData } from "../data/store.js"; import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData, getFacts } from "../data/store.js";
import { generateSummary, parseSummaryJson } from "./llm.js"; import { generateSummary, parseSummaryJson } from "./llm.js";
const MODULE_ID = 'summaryGenerator'; const MODULE_ID = 'summaryGenerator';
@@ -11,46 +11,48 @@ const SUMMARY_SESSION_ID = 'xb9';
const MAX_CAUSED_BY = 2; const MAX_CAUSED_BY = 2;
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// worldUpdate 清洗 // factUpdates 清洗
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
function sanitizeWorldUpdate(parsed) { function sanitizeFacts(parsed) {
if (!parsed) return; if (!parsed) return;
const wu = Array.isArray(parsed.worldUpdate) ? parsed.worldUpdate : []; const updates = Array.isArray(parsed.factUpdates) ? parsed.factUpdates : [];
const ok = []; const ok = [];
for (const item of wu) { for (const item of updates) {
const category = String(item?.category || '').trim().toLowerCase(); const s = String(item?.s || '').trim();
const topic = String(item?.topic || '').trim(); const p = String(item?.p || '').trim();
if (!category || !topic) continue; if (!s || !p) continue;
// status/knowledge/relation 必须包含 "::" // 删除操作
if (['status', 'knowledge', 'relation'].includes(category) && !topic.includes('::')) { if (item.retracted === true) {
xbLog.warn(MODULE_ID, `丢弃不合格 worldUpdate: ${category}/${topic}`); ok.push({ s, p, retracted: true });
continue; continue;
} }
if (item.cleared === true) { const o = String(item?.o || '').trim();
ok.push({ category, topic, cleared: true }); if (!o) continue;
continue;
const fact = { s, p, o };
// 关系类保留 trend
if (/^对.+的/.test(p) && item.trend) {
const validTrends = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融'];
if (validTrends.includes(item.trend)) {
fact.trend = item.trend;
}
} }
const content = String(item?.content || '').trim(); ok.push(fact);
if (!content) continue;
ok.push({ category, topic, content });
} }
parsed.worldUpdate = ok; parsed.factUpdates = ok;
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// causedBy 清洗(事件因果边) // causedBy 清洗(事件因果边)
// - 允许引用:已存在事件 + 本次新输出事件
// - 限制长度0-2
// - 去重、剔除非法ID、剔除自引用
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
function sanitizeEventsCausality(parsed, existingEventIds) { function sanitizeEventsCausality(parsed, existingEventIds) {
@@ -61,7 +63,6 @@ function sanitizeEventsCausality(parsed, existingEventIds) {
const idRe = /^evt-\d+$/; const idRe = /^evt-\d+$/;
// 本次新输出事件ID集合允许引用
const newIds = new Set( const newIds = new Set(
events events
.map(e => String(e?.id || '').trim()) .map(e => String(e?.id || '').trim())
@@ -73,7 +74,6 @@ function sanitizeEventsCausality(parsed, existingEventIds) {
for (const e of events) { for (const e of events) {
const selfId = String(e?.id || '').trim(); const selfId = String(e?.id || '').trim();
if (!idRe.test(selfId)) { if (!idRe.test(selfId)) {
// id 不合格的话causedBy 直接清空,避免污染
e.causedBy = []; e.causedBy = [];
continue; continue;
} }
@@ -117,11 +117,6 @@ export function formatExistingSummaryForAI(store) {
parts.push(`\n【主要角色】${names.join("、")}`); parts.push(`\n【主要角色】${names.join("、")}`);
} }
if (data.characters?.relationships?.length) {
parts.push("【人物关系】");
data.characters.relationships.forEach(r => parts.push(`- ${r.from}${r.to}${r.label}${r.trend}`));
}
if (data.arcs?.length) { if (data.arcs?.length) {
parts.push("【角色弧光】"); parts.push("【角色弧光】");
data.arcs.forEach(a => parts.push(`- ${a.name}${a.trajectory}(进度${Math.round(a.progress * 100)}%`)); data.arcs.forEach(a => parts.push(`- ${a.name}${a.trajectory}(进度${Math.round(a.progress * 100)}%`));
@@ -187,7 +182,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
onStatus?.(`正在总结 ${slice.range}${slice.count}楼新内容)...`); onStatus?.(`正在总结 ${slice.range}${slice.count}楼新内容)...`);
const existingSummary = formatExistingSummaryForAI(store); const existingSummary = formatExistingSummaryForAI(store);
const existingWorld = store?.json?.world || []; const existingFacts = getFacts();
const nextEventId = getNextEventId(store); const nextEventId = getNextEventId(store);
const existingEventCount = store?.json?.events?.length || 0; const existingEventCount = store?.json?.events?.length || 0;
const useStream = config.trigger?.useStream !== false; const useStream = config.trigger?.useStream !== false;
@@ -196,7 +191,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
try { try {
raw = await generateSummary({ raw = await generateSummary({
existingSummary, existingSummary,
existingWorld, existingFacts,
newHistoryText: slice.text, newHistoryText: slice.text,
historyRange: slice.range, historyRange: slice.range,
nextEventId, nextEventId,
@@ -231,7 +226,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
return { success: false, error: "parse" }; return { success: false, error: "parse" };
} }
sanitizeWorldUpdate(parsed); sanitizeFacts(parsed);
const existingEventIds = new Set((store?.json?.events || []).map(e => e?.id).filter(Boolean)); const existingEventIds = new Set((store?.json?.events || []).map(e => e?.id).filter(Boolean));
sanitizeEventsCausality(parsed, existingEventIds); sanitizeEventsCausality(parsed, existingEventIds);
@@ -245,8 +240,8 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1}`); xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1}`);
if (parsed.worldUpdate?.length) { if (parsed.factUpdates?.length) {
xbLog.info(MODULE_ID, `世界状态更新: ${parsed.worldUpdate.length}`); xbLog.info(MODULE_ID, `Facts 更新: ${parsed.factUpdates.length}`);
} }
const newEventIds = (parsed.events || []).map(e => e.id); const newEventIds = (parsed.events || []).map(e => e.id);
@@ -255,7 +250,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
merged, merged,
endMesId: slice.endMesId, endMesId: slice.endMesId,
newEventIds, newEventIds,
l3Stats: { worldUpdate: parsed.worldUpdate?.length || 0 }, factStats: { updated: parsed.factUpdates?.length || 0 },
}); });
return { success: true, merged, endMesId: slice.endMesId, newEventIds }; return { success: true, merged, endMesId: slice.endMesId, newEventIds };

View File

@@ -1,7 +1,6 @@
// LLM Service // LLM Service
const PROVIDER_MAP = { const PROVIDER_MAP = {
// ...
openai: "openai", openai: "openai",
google: "gemini", google: "gemini",
gemini: "gemini", gemini: "gemini",
@@ -39,43 +38,37 @@ Incremental_Summary_Requirements:
- Causal_Chain: 为每个新事件标注直接前因事件IDcausedBy。仅在因果关系明确直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个只填 evt-数字,指向已存在或本次新输出事件。 - Causal_Chain: 为每个新事件标注直接前因事件IDcausedBy。仅在因果关系明确直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个只填 evt-数字,指向已存在或本次新输出事件。
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融) - Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0) - Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
- World_State_Tracking: 维护当前世界的硬性约束。解决"什么不能违反"。采用 KV 覆盖模型,追踪生死、物品归属、秘密知情、关系状态、环境规则等不可违背的事实。(覆盖式更新) - Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型s+p 为键)。
categories:
- status: 角色生死、位置锁定、重大状态
- inventory: 重要物品归属
- knowledge: 秘密的知情状态
- relation: 硬性关系(在一起/决裂)
- rule: 环境规则/契约限制
</task_settings> </task_settings>
--- ---
Story Analyst: Story Analyst:
[Responsibility Definition] [Responsibility Definition]
\`\`\`yaml \`\`\`yaml
analysis_task: analysis_task:
title: Incremental Story Summarization with World State title: Incremental Story Summarization with Knowledge Graph
Story Analyst: Story Analyst:
role: Antigravity role: Antigravity
task: >- task: >-
To analyze provided dialogue content against existing summary state, To analyze provided dialogue content against existing summary state,
extract only NEW plot elements, character developments, relationship extract only NEW plot elements, character developments, relationship
changes, arc progressions, AND world state changes, outputting changes, arc progressions, AND fact updates, outputting
structured JSON for incremental summary database updates. structured JSON for incremental summary database updates.
assistant: assistant:
role: Summary Specialist role: Summary Specialist
description: Incremental Story Summary & World State Analyst description: Incremental Story Summary & Knowledge Graph Analyst
behavior: >- behavior: >-
To compare new dialogue against existing summary, identify genuinely To compare new dialogue against existing summary, identify genuinely
new events and character interactions, classify events by narrative new events and character interactions, classify events by narrative
type and weight, track character arc progression with percentage, type and weight, track character arc progression with percentage,
maintain world state as key-value updates with clear flags, maintain facts as SPO triples with clear semantics,
and output structured JSON containing only incremental updates. and output structured JSON containing only incremental updates.
Must strictly avoid repeating any existing summary content. Must strictly avoid repeating any existing summary content.
user: user:
role: Content Provider role: Content Provider
description: Supplies existing summary state and new dialogue description: Supplies existing summary state and new dialogue
behavior: >- behavior: >-
To provide existing summary state (events, characters, relationships, To provide existing summary state (events, characters, arcs, facts)
arcs, world state) and new dialogue content for incremental analysis. and new dialogue content for incremental analysis.
interaction_mode: interaction_mode:
type: incremental_analysis type: incremental_analysis
output_format: structured_json output_format: structured_json
@@ -84,7 +77,7 @@ execution_context:
summary_active: true summary_active: true
incremental_only: true incremental_only: true
memory_album_style: true memory_album_style: true
world_state_tracking: true fact_tracking: true
\`\`\` \`\`\`
--- ---
Summary Specialist: Summary Specialist:
@@ -103,15 +96,17 @@ Acknowledged. Now reviewing the incremental summarization specifications:
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融 破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
[Arc Progress Tracking] [Arc Progress Tracking]
├─ trajectory: 完整弧光链描述(30字内) ├─ trajectory: 当前阶段描述(15字内)
├─ progress: 0.0 to 1.0 ├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻 └─ newMoment: 仅记录本次新增的关键时刻
[World State Maintenance] [Fact Tracking - SPO Triples]
├─ 维护方式: Key-Value 覆盖category + topic 为键 ├─ s: 主体(角色名/物品名
├─ 只输出有变化的条目 ├─ p: 谓词(属性名/对X的看法
├─ 清除时使用 cleared: true不要填 content ├─ o: 值(当前状态)
不记录情绪、衣着、临时动作 trend: 仅关系类填写
├─ retracted: 删除标记
└─ s+p 为键,相同键会覆盖旧值
Ready to process incremental summary requests with strict deduplication.`, Ready to process incremental summary requests with strict deduplication.`,
@@ -119,19 +114,19 @@ Ready to process incremental summary requests with strict deduplication.`,
Summary Specialist: Summary Specialist:
Specifications internalized. Please provide the existing summary state so I can: Specifications internalized. Please provide the existing summary state so I can:
1. Index all recorded events to avoid duplication 1. Index all recorded events to avoid duplication
2. Map current character relationships as baseline 2. Map current character list as baseline
3. Note existing arc progress levels 3. Note existing arc progress levels
4. Identify established keywords 4. Identify established keywords
5. Review current world state (category + topic baseline)`, 5. Review current facts (SPO triples baseline)`,
assistantAskContent: ` assistantAskContent: `
Summary Specialist: Summary Specialist:
Existing summary fully analyzed and indexed. I understand: Existing summary fully analyzed and indexed. I understand:
├─ Recorded events: Indexed for deduplication ├─ Recorded events: Indexed for deduplication
├─ Character relationships: Baseline mapped ├─ Character list: Baseline mapped
├─ Arc progress: Levels noted ├─ Arc progress: Levels noted
├─ Keywords: Current state acknowledged ├─ Keywords: Current state acknowledged
└─ World state: Baseline loaded └─ Facts: SPO baseline loaded
I will extract only genuinely NEW elements from the upcoming dialogue. I will extract only genuinely NEW elements from the upcoming dialogue.
Please provide the new dialogue content requiring incremental analysis.`, Please provide the new dialogue content requiring incremental analysis.`,
@@ -152,7 +147,7 @@ Before generating, observe the USER and analyze carefully:
- What NEW characters appeared for the first time? - What NEW characters appeared for the first time?
- What relationship CHANGES happened? - What relationship CHANGES happened?
- What arc PROGRESS was made? - What arc PROGRESS was made?
- What world state changes occurred? (status/inventory/knowledge/relation/rule) - What facts changed? (status/position/ownership/relationships)
## Output Format ## Output Format
\`\`\`json \`\`\`json
@@ -160,7 +155,7 @@ Before generating, observe the USER and analyze carefully:
"mindful_prelude": { "mindful_prelude": {
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?", "user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
"dedup_analysis": "已有X个事件本次识别Y个新事件", "dedup_analysis": "已有X个事件本次识别Y个新事件",
"world_changes": "识别到的世界状态变化概述,仅精选不记录则可能导致吃书的硬状态变化" "fact_changes": "识别到的事实变化概述"
}, },
"keywords": [ "keywords": [
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"} {"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
@@ -178,45 +173,35 @@ Before generating, observe the USER and analyze carefully:
} }
], ],
"newCharacters": ["仅本次首次出现的角色名"], "newCharacters": ["仅本次首次出现的角色名"],
"newRelationships": [
{"from": "A", "to": "B", "label": "基于全局的关系描述", "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融"}
],
"arcUpdates": [ "arcUpdates": [
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"} {"name": "角色名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
], ],
"worldUpdate": [ "factUpdates": [
{ {
"category": "status|inventory|knowledge|relation|rule", "s": "主体(角色名/物品名)",
"topic": "主体名称(人/物/关系/规则", "p": "谓词(属性名/对X的看法",
"content": "当前状态描述", "o": "当前",
"cleared": true "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融",
"retracted": false
} }
] ]
} }
\`\`\` \`\`\`
## Field Guidelines ## factUpdates 规则
- s+p 为键,相同键会覆盖旧值
### worldUpdate世界状态·硬约束KV表 - 状态类s=角色名, p=属性(生死/位置/状态等), o=值
- category 固定 5 选 1status / inventory / knowledge / relation / rule - 关系类s=角色A, p="对B的看法", o=描述, trend=趋势
- topic 命名规范: - 删除:设置 retracted: true不需要填 o
- status「角色名::状态类型」如 张三::生死、李四::位置、王五::伤势 - 只输出有变化的条目
- knowledge「角色名::知情事项」如 张三::知道某秘密、李四::知道真相 - 硬约束才记录,避免叙事化,确保少、硬、稳定
- relation「角色A::与角色B关系」如 张三::与李四关系
- inventory物品名称如 钥匙、信物、武器
- rule规则/契约名称,如 门禁时间、魔法契约、禁令
- content当前状态的简短描述
- cleared: true 表示该条目已失效需删除(不填 content
- status/knowledge/relation 的 topic 必须包含「::」分隔符
- 硬约束才记录,避免叙事化,确保少、硬、稳定、可覆盖
- 动态清理:若发现已有条目中存在不适合作为硬约束的内容(如衣着打扮、临时情绪、琐碎动作),本次输出中用 cleared: true 删除
## CRITICAL NOTES ## CRITICAL NOTES
- events.id 从 evt-{nextEventId} 开始编号 - events.id 从 evt-{nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复 - 仅输出【增量】内容,已有事件绝不重复
- keywords 是全局关键词,综合已有+新增 - keywords 是全局关键词,综合已有+新增
- causedBy 仅在因果明确时填写,允许为[]0-2个,详见上方 Causal_Chain 规则 - causedBy 仅在因果明确时填写,允许为[]0-2个
- worldUpdate 可为空数组 - factUpdates 可为空数组
- 合法JSON字符串值内部避免英文双引号 - 合法JSON字符串值内部避免英文双引号
- 用朴实、白描、有烟火气的笔触记录,避免比喻和意象 - 用朴实、白描、有烟火气的笔触记录,避免比喻和意象
</meta_protocol>`, </meta_protocol>`,
@@ -227,15 +212,14 @@ Before generating, observe the USER and analyze carefully:
├─ New dialogue received: ✓ Content parsed ├─ New dialogue received: ✓ Content parsed
├─ Deduplication engine: ✓ Active ├─ Deduplication engine: ✓ Active
├─ Event classification: ✓ Ready ├─ Event classification: ✓ Ready
├─ World state tracking: ✓ Enabled ├─ Fact tracking: ✓ Enabled
└─ Output format: ✓ JSON specification loaded └─ Output format: ✓ JSON specification loaded
[Material Verification] [Material Verification]
├─ Existing events: Indexed ({existingEventCount} recorded) ├─ Existing events: Indexed ({existingEventCount} recorded)
├─ Character baseline: Mapped ├─ Character baseline: Mapped
├─ Relationship baseline: Mapped
├─ Arc progress baseline: Noted ├─ Arc progress baseline: Noted
├─ World state: Baseline loaded ├─ Facts baseline: Loaded
└─ Output specification: ✓ Defined in <meta_protocol> └─ Output specification: ✓ Defined in <meta_protocol>
All checks passed. Beginning incremental extraction... All checks passed. Beginning incremental extraction...
{ {
@@ -280,39 +264,23 @@ function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
// 提示词构建 // 提示词构建
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
function formatWorldForLLM(worldList) { function formatFactsForLLM(facts) {
if (!worldList?.length) { if (!facts?.length) {
return '(空白,尚无世界状态记录)'; return '(空白,尚无事实记录)';
} }
const grouped = { status: [], inventory: [], knowledge: [], relation: [], rule: [] }; const lines = facts.map(f => {
const labels = { if (f.trend) {
status: '状态(生死/位置锁定)', return `- ${f.s} | ${f.p} | ${f.o} [${f.trend}]`;
inventory: '物品归属',
knowledge: '秘密/认知',
relation: '关系状态',
rule: '规则/约束'
};
worldList.forEach(w => {
if (grouped[w.category]) {
grouped[w.category].push(w);
} }
return `- ${f.s} | ${f.p} | ${f.o}`;
}); });
const parts = []; return lines.join('\n') || '(空白,尚无事实记录)';
for (const [cat, items] of Object.entries(grouped)) {
if (items.length > 0) {
const lines = items.map(w => ` - ${w.topic}: ${w.content}`).join('\n');
parts.push(`${labels[cat]}\n${lines}`);
}
}
return parts.join('\n\n') || '(空白,尚无世界状态记录)';
} }
function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, historyRange, nextEventId, existingEventCount) { function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, historyRange, nextEventId, existingEventCount) {
const worldStateText = formatWorldForLLM(existingWorld); const factsText = formatFactsForLLM(existingFacts);
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
.replace(/\{nextEventId\}/g, String(nextEventId)); .replace(/\{nextEventId\}/g, String(nextEventId));
@@ -324,7 +292,7 @@ function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, hi
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem }, { role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc }, { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary }, { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前世界状态>\n${worldStateText}\n</当前世界状态>` }, { role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前事实图谱>\n${factsText}\n</当前事实图谱>` },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent }, { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
{ role: 'user', content: `<新对话内容>${historyRange}\n${newHistoryText}\n</新对话内容>` } { role: 'user', content: `<新对话内容>${historyRange}\n${newHistoryText}\n</新对话内容>` }
]; ];
@@ -378,7 +346,7 @@ export function parseSummaryJson(raw) {
export async function generateSummary(options) { export async function generateSummary(options) {
const { const {
existingSummary, existingSummary,
existingWorld, existingFacts,
newHistoryText, newHistoryText,
historyRange, historyRange,
nextEventId, nextEventId,
@@ -401,7 +369,7 @@ export async function generateSummary(options) {
const promptData = buildSummaryMessages( const promptData = buildSummaryMessages(
existingSummary, existingSummary,
existingWorld, existingFacts,
newHistoryText, newHistoryText,
historyRange, historyRange,
nextEventId, nextEventId,

View File

@@ -6,7 +6,7 @@
import { getContext } from "../../../../../../extensions.js"; import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js"; import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore } from "../data/store.js"; import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js"; import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory, buildQueryText } from "../vector/recall.js"; import { recallMemory, buildQueryText } from "../vector/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/chunk-store.js"; import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/chunk-store.js";
@@ -111,10 +111,18 @@ function buildPostscript() {
// 格式化函数 // 格式化函数
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function formatWorldLines(world) { function formatFactsForInjection(facts) {
return [...(world || [])] const activeFacts = (facts || []).filter(f => !f.retracted);
.sort((a, b) => (b.floor || 0) - (a.floor || 0)) if (!activeFacts.length) return [];
.map(w => `- ${w.topic}${w.content}`); return activeFacts
.sort((a, b) => (b.since || 0) - (a.since || 0))
.map(f => {
const since = f.since ? ` (#${f.since + 1})` : '';
if (isRelationFact(f) && f.trend) {
return `- ${f.s} ${f.p}: ${f.o} [${f.trend}]${since}`;
}
return `- ${f.s}${f.p}: ${f.o}${since}`;
});
} }
function formatArcLine(a) { function formatArcLine(a) {
@@ -189,7 +197,7 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
// [1] 世界约束 // [1] 世界约束
lines.push(` [1] 世界约束 (上限 2000)`); lines.push(` [1] 世界约束 (上限 2000)`);
lines.push(` 选入: ${stats.world.count} 条 | 消耗: ${stats.world.tokens} tokens`); lines.push(` 选入: ${stats.facts.count} 条 | 消耗: ${stats.facts.tokens} tokens`);
lines.push(''); lines.push('');
// [2] 核心经历 + 过往背景 // [2] 核心经历 + 过往背景
@@ -229,7 +237,7 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
const pctStr = pct(tokens, total) + '%'; const pctStr = pct(tokens, total) + '%';
return ` ${label.padEnd(6)} ${'█'.repeat(width).padEnd(30)} ${String(tokens).padStart(5)} (${pctStr})`; return ` ${label.padEnd(6)} ${'█'.repeat(width).padEnd(30)} ${String(tokens).padStart(5)} (${pctStr})`;
}; };
lines.push(bar(stats.world.tokens, '约束')); lines.push(bar(stats.facts.tokens, '约束'));
lines.push(bar(stats.events.tokens + stats.evidence.tokens, '经历')); lines.push(bar(stats.events.tokens + stats.evidence.tokens, '经历'));
lines.push(bar(stats.orphans.tokens, '远期')); lines.push(bar(stats.orphans.tokens, '远期'));
lines.push(bar(recentOrphanStats?.tokens || 0, '待整理')); lines.push(bar(recentOrphanStats?.tokens || 0, '待整理'));
@@ -263,9 +271,9 @@ function buildNonVectorPrompt(store) {
const data = store.json || {}; const data = store.json || {};
const sections = []; const sections = [];
if (data.world?.length) { const factLines = formatFactsForInjection(getFacts(store));
const lines = formatWorldLines(data.world); if (factLines.length) {
sections.push(`[世界约束] 已确立的事实\n${lines.join("\n")}`); sections.push(`[定了的事] 已确立的事实\n${factLines.join("\n")}`);
} }
if (data.events?.length) { if (data.events?.length) {
@@ -330,7 +338,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
const assembled = { const assembled = {
world: { lines: [], tokens: 0 }, facts: { lines: [], tokens: 0 },
arcs: { lines: [], tokens: 0 }, arcs: { lines: [], tokens: 0 },
events: { direct: [], similar: [] }, events: { direct: [], similar: [] },
orphans: { lines: [], tokens: 0 }, orphans: { lines: [], tokens: 0 },
@@ -339,7 +347,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
const injectionStats = { const injectionStats = {
budget: { max: TOTAL_BUDGET_MAX, used: 0 }, budget: { max: TOTAL_BUDGET_MAX, used: 0 },
world: { count: 0, tokens: 0 }, facts: { count: 0, tokens: 0 },
arcs: { count: 0, tokens: 0 }, arcs: { count: 0, tokens: 0 },
events: { selected: 0, tokens: 0 }, events: { selected: 0, tokens: 0 },
evidence: { attached: 0, tokens: 0 }, evidence: { attached: 0, tokens: 0 },
@@ -360,16 +368,16 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// [优先级 1] 世界约束 - 最高优先级 // [优先级 1] 世界约束 - 最高优先级
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
const worldLines = formatWorldLines(data.world); const factLines = formatFactsForInjection(getFacts(store));
if (worldLines.length) { if (factLines.length) {
const l3Budget = { used: 0, max: Math.min(L3_MAX, total.max - total.used) }; const l3Budget = { used: 0, max: Math.min(L3_MAX, total.max - total.used) };
for (const line of worldLines) { for (const line of factLines) {
if (!pushWithBudget(assembled.world.lines, line, l3Budget)) break; if (!pushWithBudget(assembled.facts.lines, line, l3Budget)) break;
} }
assembled.world.tokens = l3Budget.used; assembled.facts.tokens = l3Budget.used;
total.used += l3Budget.used; total.used += l3Budget.used;
injectionStats.world.count = assembled.world.lines.length; injectionStats.facts.count = assembled.facts.lines.length;
injectionStats.world.tokens = l3Budget.used; injectionStats.facts.tokens = l3Budget.used;
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -599,8 +607,8 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const sections = []; const sections = [];
// 1. 世界约束 → 定了的事 // 1. 世界约束 → 定了的事
if (assembled.world.lines.length) { if (assembled.facts.lines.length) {
sections.push(`[定了的事] 已确立的事实\n${assembled.world.lines.join("\n")}`); sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`);
} }
// 2. 核心经历 → 印象深的事 // 2. 核心经历 → 印象深的事
if (assembled.events.direct.length) { if (assembled.events.direct.length) {
@@ -632,6 +640,8 @@ if (!sections.length) {
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` + `<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`; `${buildPostscript()}`;
// ★ 修复:先写回预算统计,再生成日志
injectionStats.budget.used = total.used + (assembled.recentOrphans.tokens || 0);
const injectionLogText = formatInjectionLog(injectionStats, details, recentOrphanStats); const injectionLogText = formatInjectionLog(injectionStats, details, recentOrphanStats);
return { promptText, injectionLogText, injectionStats }; return { promptText, injectionLogText, injectionStats };

View File

@@ -60,7 +60,7 @@
events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' }, events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' },
characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' }, characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' },
arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' }, arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' },
world: { title: '编辑世界状态', hint: '每行一条:category|topic|content。清除用category|topic|(留空)或 category|topic|cleared' } facts: { title: '编辑事实图谱', hint: '每行一条:主体|谓词|值|趋势(可选)。删除用:主体|谓词|(留空值)' }
}; };
const TREND_COLORS = { const TREND_COLORS = {
@@ -116,7 +116,7 @@
vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } } vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } }
}; };
let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] }; let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], facts: [] };
let localGenerating = false; let localGenerating = false;
let vectorGenerating = false; let vectorGenerating = false;
let relationChart = null; let relationChart = null;
@@ -1415,9 +1415,14 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
if (section === 'keywords') { if (section === 'keywords') {
ta.value = summaryData.keywords.map(k => `${k.text}|${k.weight || '一般'}`).join('\n'); ta.value = summaryData.keywords.map(k => `${k.text}|${k.weight || '一般'}`).join('\n');
} else if (section === 'world') { } else if (section === 'facts') {
ta.value = (summaryData.world || []) ta.value = (summaryData.facts || [])
.map(w => `${w.category || ''}|${w.topic || ''}|${w.content || ''}`) .filter(f => !f.retracted)
.map(f => {
const parts = [f.s, f.p, f.o];
if (f.trend) parts.push(f.trend);
return parts.join('|');
})
.join('\n'); .join('\n');
} else { } else {
ta.classList.add('hidden'); ta.classList.add('hidden');
@@ -1496,21 +1501,32 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
moments moments
}, oldArc); }, oldArc);
}).filter(a => a.name || a.trajectory || a.moments?.length); }).filter(a => a.name || a.trajectory || a.moments?.length);
} else if (section === 'world') { } else if (section === 'facts') {
const oldWorldMap = new Map((summaryData.world || []).map(w => [`${w.category}|${w.topic}`, w])); const oldMap = new Map((summaryData.facts || []).map(f => [`${f.s}::${f.p}`, f]));
parsed = ta.value parsed = ta.value
.split('\n') .split('\n')
.map(l => l.trim()) .map(l => l.trim())
.filter(Boolean) .filter(Boolean)
.map(line => { .map(line => {
const parts = line.split('|').map(s => s.trim()); const parts = line.split('|').map(s => s.trim());
const category = parts[0]; const s = parts[0];
const topic = parts[1]; const p = parts[1];
const content = parts.slice(2).join('|').trim(); const o = parts[2];
if (!category || !topic) return null; const trend = parts[3];
if (!content || content.toLowerCase() === 'cleared') return null; if (!s || !p) return null;
const key = `${category}|${topic}`; if (!o) return null;
return preserveAddedAt({ category, topic, content }, oldWorldMap.get(key)); const key = `${s}::${p}`;
const old = oldMap.get(key);
const fact = {
id: old?.id || `f-${Date.now()}`,
s, p, o,
since: old?.since ?? 0,
_addedAt: old?._addedAt ?? 0,
};
if (/^对.+的/.test(p) && trend) {
fact.trend = trend;
}
return fact;
}) })
.filter(Boolean); .filter(Boolean);
} }
@@ -1526,7 +1542,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
else if (section === 'events') { renderTimeline(parsed); $('stat-events').textContent = parsed.length; } else if (section === 'events') { renderTimeline(parsed); $('stat-events').textContent = parsed.length; }
else if (section === 'characters') renderRelations(parsed); else if (section === 'characters') renderRelations(parsed);
else if (section === 'arcs') renderArcs(parsed); else if (section === 'arcs') renderArcs(parsed);
else if (section === 'world') renderWorldState(parsed); else if (section === 'facts') renderFacts(parsed);
closeEditor(); closeEditor();
} }
@@ -1565,7 +1581,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
if (p.events) renderTimeline(p.events); if (p.events) renderTimeline(p.events);
if (p.characters) renderRelations(p.characters); if (p.characters) renderRelations(p.characters);
if (p.arcs) renderArcs(p.arcs); if (p.arcs) renderArcs(p.arcs);
if (p.world) renderWorldState(p.world); if (p.facts) renderFacts(p.facts);
$('stat-events').textContent = p.events?.length || 0; $('stat-events').textContent = p.events?.length || 0;
if (p.lastSummarizedMesId != null) $('stat-summarized').textContent = p.lastSummarizedMesId + 1; if (p.lastSummarizedMesId != null) $('stat-summarized').textContent = p.lastSummarizedMesId + 1;
if (p.stats) updateStats(p.stats); if (p.stats) updateStats(p.stats);
@@ -1582,12 +1598,12 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
$('stat-summarized').textContent = 0; $('stat-summarized').textContent = 0;
$('stat-pending').textContent = t; $('stat-pending').textContent = t;
$('summarized-count').textContent = 0; $('summarized-count').textContent = 0;
summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] }; summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], facts: [] };
renderKeywords([]); renderKeywords([]);
renderTimeline([]); renderTimeline([]);
renderRelations(null); renderRelations(null);
renderArcs([]); renderArcs([]);
renderWorldState([]); renderFacts([]);
break; break;
} }
@@ -1829,7 +1845,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
renderKeywords([]); renderKeywords([]);
renderTimeline([]); renderTimeline([]);
renderArcs([]); renderArcs([]);
renderWorldState([]); renderFacts([]);
bindEvents(); bindEvents();
@@ -1845,51 +1861,53 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
} }
function renderWorldState(world) { function renderFacts(facts) {
summaryData.world = world || []; summaryData.facts = facts || [];
const container = $('world-state-list'); const container = $('facts-list');
if (!container) return; if (!container) return;
if (!world?.length) { const activeFacts = (facts || []).filter(f => !f.retracted);
setHtml(container, '<div class="empty">暂无世界状态</div>');
return;
}
const labels = { if (!activeFacts.length) {
status: '状态', setHtml(container, '<div class="empty">暂无事实记录</div>');
inventory: '物品', return;
knowledge: '认知',
relation: '关系',
rule: '规则'
};
const categoryOrder = ['status', 'inventory', 'relation', 'knowledge', 'rule'];
const grouped = {};
world.forEach(w => {
const cat = w.category || 'other';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(w);
});
const html = categoryOrder
.filter(cat => grouped[cat]?.length)
.map(cat => {
const items = grouped[cat].sort((a, b) => (b.floor || 0) - (a.floor || 0));
return `
<div class="world-group">
<div class="world-group-title">${labels[cat] || cat}</div>
${items.map(w => `
<div class="world-item">
<span class="world-topic">${h(w.topic)}</span>
<span class="world-content">${h(w.content)}</span>
</div>
`).join('')}
</div>
`;
}).join('');
setHtml(container, html || '<div class="empty">暂无世界状态</div>');
} }
const relations = activeFacts.filter(f => /^.+/.test(f.p));
const states = activeFacts.filter(f => !/^对.+的/.test(f.p));
let html = '';
if (states.length) {
html += `<div class="fact-group">
<div class="fact-group-title">状态/属性</div>
${states.map(f => `
<div class="fact-item">
<span class="fact-subject">${h(f.s)}</span>
<span class="fact-predicate">${h(f.p)}</span>
<span class="fact-object">${h(f.o)}</span>
<span class="fact-since">#${(f.since || 0) + 1}</span>
</div>
`).join('')}
</div>`;
}
if (relations.length) {
html += `<div class="fact-group">
<div class="fact-group-title">人物关系</div>
${relations.map(f => `
<div class="fact-item">
<span class="fact-subject">${h(f.s)}</span>
<span class="fact-predicate">${h(f.p)}</span>
<span class="fact-object">${h(f.o)}</span>
${f.trend ? `<span class="fact-trend ${TREND_CLASS[f.trend] || ''}">${h(f.trend)}</span>` : ''}
<span class="fact-since">#${(f.since || 0) + 1}</span>
</div>
`).join('')}
</div>`;
}
setHtml(container, html);
}
})(); })();

View File

@@ -6,6 +6,101 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ═══════════════════════════════════════════════════════════════════════════
Facts (替换 World State)
═══════════════════════════════════════════════════════════════════════════ */
.facts {
flex: 0 0 auto;
}
.facts-list {
max-height: 200px;
overflow-y: auto;
padding-right: 4px;
}
.fact-group {
margin-bottom: 16px;
}
.fact-group:last-child {
margin-bottom: 0;
}
.fact-group-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--txt3);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--bdr2);
}
.fact-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
background: var(--bg3);
border: 1px solid var(--bdr2);
border-radius: 6px;
font-size: 0.8125rem;
flex-wrap: wrap;
}
.fact-item:hover {
border-color: var(--bdr);
background: var(--bg2);
}
.fact-subject {
font-weight: 600;
color: var(--txt);
}
.fact-predicate {
color: var(--txt3);
font-size: 0.75rem;
}
.fact-predicate::before {
content: '→';
margin-right: 4px;
}
.fact-object {
color: var(--hl);
font-weight: 500;
}
.fact-trend {
font-size: 0.6875rem;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.fact-since {
font-size: 0.625rem;
color: var(--txt3);
margin-left: auto;
}
@media (max-width: 768px) {
.facts-list {
max-height: 180px;
}
.fact-item {
padding: 6px 8px;
font-size: 0.75rem;
}
}
:root { :root {
--bg: #fafafa; --bg: #fafafa;
--bg2: #fff; --bg2: #fff;

View File

@@ -80,13 +80,13 @@
</div> </div>
<div class="right"> <div class="right">
<!-- World State --> <!-- Facts -->
<section class="card world-state"> <section class="card facts">
<div class="sec-head"> <div class="sec-head">
<div class="sec-title">世界状态</div> <div class="sec-title">事实图谱</div>
<button class="sec-btn" data-section="world">编辑</button> <button class="sec-btn" data-section="facts">编辑</button>
</div> </div>
<div class="world-state-list scroll" id="world-state-list"></div> <div class="facts-list scroll" id="facts-list"></div>
</section> </section>
<!-- Relations --> <!-- Relations -->

View File

@@ -11,7 +11,7 @@ import { getAllEventVectors, getAllChunkVectors, getChunksByFloors, getMeta } fr
import { embed, getEngineFingerprint } from './embedder.js'; import { embed, getEngineFingerprint } from './embedder.js';
import { xbLog } from '../../../core/debug-core.js'; import { xbLog } from '../../../core/debug-core.js';
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { getSummaryStore } from '../data/store.js'; import { getSummaryStore, getFacts, getNewCharacters, isRelationFact } from '../data/store.js';
import { filterText } from './text-filter.js'; import { filterText } from './text-filter.js';
import { import {
searchStateAtoms, searchStateAtoms,
@@ -258,6 +258,23 @@ function buildEntityLexicon(store, allEvents) {
const userName = normalize(name1); const userName = normalize(name1);
const set = new Set(); const set = new Set();
const facts = getFacts(store);
for (const f of facts) {
if (f?.retracted) continue;
const s = normalize(f?.s);
if (s) set.add(s);
if (isRelationFact(f)) {
const o = normalize(f?.o);
if (o) set.add(o);
}
}
const chars = getNewCharacters(store);
for (const m of chars || []) {
const s = normalize(typeof m === 'string' ? m : m?.name);
if (s) set.add(s);
}
for (const e of allEvents || []) { for (const e of allEvents || []) {
for (const p of e.participants || []) { for (const p of e.participants || []) {
const s = normalize(p); const s = normalize(p);
@@ -265,30 +282,11 @@ function buildEntityLexicon(store, allEvents) {
} }
} }
const json = store?.json || {}; for (const a of store?.json?.arcs || []) {
for (const m of json.characters?.main || []) {
const s = normalize(typeof m === 'string' ? m : m?.name);
if (s) set.add(s);
}
for (const a of json.arcs || []) {
const s = normalize(a?.name); const s = normalize(a?.name);
if (s) set.add(s); if (s) set.add(s);
} }
for (const w of json.world || []) {
const t = normalize(w?.topic);
if (t && !t.includes('::')) set.add(t);
}
for (const r of json.characters?.relationships || []) {
const from = normalize(r?.from);
const to = normalize(r?.to);
if (from) set.add(from);
if (to) set.add(to);
}
const stop = new Set([userName, '我', '你', '他', '她', '它', '用户', '角色', 'assistant'].map(normalize).filter(Boolean)); const stop = new Set([userName, '我', '你', '他', '她', '它', '用户', '角色', 'assistant'].map(normalize).filter(Boolean));
return Array.from(set) return Array.from(set)
@@ -296,6 +294,79 @@ function buildEntityLexicon(store, allEvents) {
.slice(0, 5000); .slice(0, 5000);
} }
function buildFactGraph(facts) {
const graph = new Map();
for (const f of facts || []) {
if (f?.retracted) continue;
if (!isRelationFact(f)) continue;
const s = normalize(f?.s);
const o = normalize(f?.o);
if (!s || !o) continue;
if (!graph.has(s)) graph.set(s, new Set());
if (!graph.has(o)) graph.set(o, new Set());
graph.get(s).add(o);
graph.get(o).add(s);
}
return graph;
}
function expandByFacts(presentEntities, facts, maxDepth = 2) {
const graph = buildFactGraph(facts);
const expanded = new Map();
const seeds = Array.from(presentEntities || []).map(normalize).filter(Boolean);
seeds.forEach(e => expanded.set(e, 1.0));
let frontier = [...seeds];
for (let d = 1; d <= maxDepth && frontier.length; d++) {
const next = [];
const decay = Math.pow(0.5, d);
for (const e of frontier) {
const neighbors = graph.get(e);
if (!neighbors) continue;
for (const neighbor of neighbors) {
if (!expanded.has(neighbor)) {
expanded.set(neighbor, decay);
next.push(neighbor);
}
}
}
frontier = next.slice(0, 20);
}
return Array.from(expanded.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([term]) => term);
}
function stripFloorTag(s) {
return String(s || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').trim();
}
export function buildEventEmbeddingText(ev) {
const parts = [];
if (ev?.title) parts.push(ev.title);
const people = (ev?.participants || []).join(' ');
if (people) parts.push(people);
if (ev?.type) parts.push(ev.type);
const summary = stripFloorTag(ev?.summary);
if (summary) parts.push(summary);
return parts.filter(Boolean).join(' ');
}
/** /**
* 从分段消息中提取实体,继承消息权重 * 从分段消息中提取实体,继承消息权重
* @param {string[]} segments * @param {string[]} segments
@@ -621,6 +692,7 @@ function formatRecallLog({
chunkPreFilterStats = null, chunkPreFilterStats = null,
l0Results = [], l0Results = [],
textGapInfo = null, textGapInfo = null,
expandedTerms = [],
}) { }) {
const lines = [ const lines = [
'\u2554' + '\u2550'.repeat(62) + '\u2557', '\u2554' + '\u2550'.repeat(62) + '\u2557',
@@ -663,6 +735,9 @@ function formatRecallLog({
} else { } else {
lines.push(' (无)'); lines.push(' (无)');
} }
if (expandedTerms?.length) {
lines.push(` 扩散: ${expandedTerms.join('、')}`);
}
lines.push(''); lines.push('');
lines.push('\u250c' + '\u2500'.repeat(61) + '\u2510'); lines.push('\u250c' + '\u2500'.repeat(61) + '\u2510');
@@ -757,12 +832,15 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options =
const lexicon = buildEntityLexicon(store, allEvents); const lexicon = buildEntityLexicon(store, allEvents);
const queryEntityWeights = extractEntitiesWithWeights(segments, weights, lexicon); const queryEntityWeights = extractEntitiesWithWeights(segments, weights, lexicon);
const queryEntities = Array.from(queryEntityWeights.keys()); const queryEntities = Array.from(queryEntityWeights.keys());
const facts = getFacts(store);
const expandedTerms = expandByFacts(queryEntities, facts, 2);
// 构建文本查询串:最后一条消息 + 实体 + 关键词 // 构建文本查询串:最后一条消息 + 实体 + 关键词
const lastSeg = segments[segments.length - 1] || ''; const lastSeg = segments[segments.length - 1] || '';
const queryTextForSearch = [ const queryTextForSearch = [
lastSeg, lastSeg,
...queryEntities, ...queryEntities,
...expandedTerms,
...(store?.json?.keywords || []).slice(0, 5).map(k => k.text), ...(store?.json?.keywords || []).slice(0, 5).map(k => k.text),
].join(' '); ].join(' ');
@@ -824,6 +902,7 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options =
chunkPreFilterStats, chunkPreFilterStats,
l0Results, l0Results,
textGapInfo, textGapInfo,
expandedTerms,
}); });
console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold'); console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold');