Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 269 additions & 1 deletion src/memory/__tests__/extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ vi.mock('../../config.js', () => ({
},
}));

import { parseExtractionResponse, extractMemories } from '../extractor.js';
import { parseExtractionResponse, extractMemories, filterUngroundedMemories } from '../extractor.js';
import { initializeMemory, closeMemory, getMemoryStore } from '../init.js';

describe('parseExtractionResponse', () => {
Expand Down Expand Up @@ -224,6 +224,24 @@ describe('parseExtractionResponse', () => {
expect(result[0].type).toBe('state');
expect(result[0].ttl).toBe('2026-03-25');
});

it('should parse entities field when present', () => {
const raw = JSON.stringify([
{ type: 'fact', content: '候选人罗文锋拥有5年以上的虚幻引擎开发经验', confidence: 0.7, tags: [], ttl: null, metadata: {}, entities: ['罗文锋'] },
]);
const result = parseExtractionResponse(raw);
expect(result).toHaveLength(1);
expect(result[0].entities).toEqual(['罗文锋']);
});

it('should handle missing entities field gracefully', () => {
const raw = JSON.stringify([
{ type: 'fact', content: '项目使用 ESM + TypeScript 5.7 构建', confidence: 1.0, tags: [], ttl: null, metadata: {} },
]);
const result = parseExtractionResponse(raw);
expect(result).toHaveLength(1);
expect(result[0].entities).toBeUndefined();
});
});

describe('extractMemories', () => {
Expand Down Expand Up @@ -396,3 +414,253 @@ describe('extractMemories — processExtractedMemory integration', () => {
expect(updated!.evidenceCount).toBe(2);
});
});

describe('filterUngroundedMemories — entity-orphan guard', () => {
const conversation = '[姜黎]: 分析一下罗文锋的简历\n\n[助手]: 罗文锋有5年UE经验,参与过《卡库远古封印》开发。';

it('should keep memories whose entities appear in conversation', () => {
const memories = [{
type: 'fact' as const,
content: '候选人罗文锋有5年UE开发经验',
confidence: 0.7,
tags: [],
ttl: null,
metadata: {},
entities: ['罗文锋'],
}];
const result = filterUngroundedMemories(memories, conversation);
expect(result).toHaveLength(1);
});

it('should reject fact memories whose entities are NOT in conversation', () => {
const memories = [{
type: 'fact' as const,
content: '袁满于2025年11月应聘TapTap游戏工具客户端开发',
confidence: 0.7,
tags: [],
ttl: null,
metadata: {},
entities: ['袁满'],
}];
const result = filterUngroundedMemories(memories, conversation);
expect(result).toHaveLength(0);
});

it('should reject when ANY entity is missing from conversation', () => {
const memories = [{
type: 'fact' as const,
content: '罗文锋曾在刘宏伟团队工作',
confidence: 0.7,
tags: [],
ttl: null,
metadata: {},
entities: ['罗文锋', '刘宏伟'],
}];
const result = filterUngroundedMemories(memories, conversation);
// 刘宏伟 not in conversation
expect(result).toHaveLength(0);
});

it('should allow preference/state memories without entity check', () => {
const memories = [{
type: 'preference' as const,
content: '用户喜欢用TypeScript',
confidence: 0.8,
tags: [],
ttl: null,
metadata: {},
entities: ['不存在的人'],
}];
const result = filterUngroundedMemories(memories, conversation);
expect(result).toHaveLength(1);
});

it('should allow memories without entities field (backward compatible)', () => {
const memories = [{
type: 'fact' as const,
content: '项目使用ESM + TypeScript 5.7',
confidence: 1.0,
tags: [],
ttl: null,
metadata: {},
}];
const result = filterUngroundedMemories(memories, conversation);
expect(result).toHaveLength(1);
});

it('should allow memories with empty entities array', () => {
const memories = [{
type: 'decision' as const,
content: '选择 Vitest 而非 Jest',
confidence: 0.9,
tags: [],
ttl: null,
metadata: {},
entities: [],
}];
const result = filterUngroundedMemories(memories, conversation);
expect(result).toHaveLength(1);
});

it('should filter mixed batch correctly', () => {
const memories = [
{
type: 'fact' as const,
content: '罗文锋参与过《卡库远古封印》开发',
confidence: 0.7, tags: [], ttl: null, metadata: {},
entities: ['罗文锋', '卡库远古封印'],
},
{
type: 'fact' as const,
content: '张三在2025年11月被拒',
confidence: 0.7, tags: [], ttl: null, metadata: {},
entities: ['张三'],
},
{
type: 'preference' as const,
content: '用户偏好简洁回复',
confidence: 0.8, tags: [], ttl: null, metadata: {},
},
];
const result = filterUngroundedMemories(memories, conversation);
// 罗文锋 + 卡库远古封印 both present → keep
// 张三 not present → reject
// preference → always keep
expect(result).toHaveLength(2);
expect(result[0].content).toContain('罗文锋');
expect(result[1].content).toContain('偏好');
});
});

describe('entity-orphan guard — real incident reproduction', () => {
// Reproduce the actual bug: PM bot analyzed 袁满's resume in a thread,
// but fork semantics injected parent chat messages discussing 罗文锋/刘宏伟.
// The extraction LLM then attributed 罗文锋's rejection history to 袁满.

it('should block cross-candidate memory contamination (parseExtraction → filter pipeline)', () => {
// Simulate extraction LLM output that includes hallucinated cross-candidate facts
const llmResponse = JSON.stringify([
{
type: 'fact',
content: '候选人袁满拥有7年游戏开发经验,参与过GPT-SoVITS等开源项目',
confidence: 0.7,
tags: ['候选人'],
ttl: null,
metadata: {},
entities: ['袁满', 'GPT-SoVITS'],
},
{
type: 'fact',
content: '袁满于2025年11月应聘TapTap游戏工具客户端开发(UG方向),电话沟通后评分为2,判定不合适',
confidence: 0.7,
tags: ['候选人', '面试'],
ttl: null,
metadata: {},
entities: ['袁满'],
},
{
type: 'fact',
content: '袁满的GitHub开源贡献存在严重注水,GPT-SoVITS项目仅贡献了翻译文件',
confidence: 0.9,
tags: ['候选人', '开源'],
ttl: null,
metadata: {},
entities: ['袁满', 'GPT-SoVITS'],
},
]);

// The actual conversation only discussed 袁满 and GPT-SoVITS,
// NOT any November interview rejection
const conversation = [
'[杨志]: 看看这个候选人',
'[助手]: 我来分析一下袁满的简历。',
'袁满有7年游戏开发经验,声称参与GPT-SoVITS等开源项目。',
'经核实,GPT-SoVITS项目中袁满仅贡献了i18n翻译文件和README更新。',
].join('\n');

const memories = parseExtractionResponse(llmResponse);
expect(memories).toHaveLength(3);

const grounded = filterUngroundedMemories(memories, conversation);

// Memory 1: 袁满 + GPT-SoVITS both in conversation → KEEP
// Memory 2: 袁满 in conversation BUT "11月应聘" is hallucinated from
// parent chat context about another candidate.
// However, 袁满 IS in conversation, so entity check alone passes.
// This is the edge case — entity-orphan guard catches cases where
// the entity itself is absent, not where facts about the entity are wrong.
// Memory 3: 袁满 + GPT-SoVITS both in conversation → KEEP
expect(grounded).toHaveLength(3);
// Note: The entity-orphan guard catches the case where the ENTITY is absent
// (e.g., hallucinating 刘宏伟 into 袁满's thread). For the "correct entity,
// wrong facts" case, the prompt-level rules are the primary defense.
});

it('should block when hallucinated entity is not in conversation at all', () => {
// The CRITICAL case: LLM mentions an entity that was never in this conversation
// (e.g., 罗文锋's info leaking from injected memory into 袁满's analysis)
const llmResponse = JSON.stringify([
{
type: 'fact',
content: '候选人罗文锋于2025年11月20日曾应聘TapTap游戏工具客户端开发,被判定不合适',
confidence: 0.7,
tags: ['候选人'],
ttl: null,
metadata: {},
entities: ['罗文锋'],
},
]);

// Conversation is about 袁满, NOT 罗文锋
const conversation = [
'[杨志]: @土豆儿 分析一下这份简历',
'[助手]: 我来分析袁满的简历。袁满拥有7年游戏开发经验...',
].join('\n');

const memories = parseExtractionResponse(llmResponse);
const grounded = filterUngroundedMemories(memories, conversation);

// 罗文锋 not in conversation → BLOCKED
expect(grounded).toHaveLength(0);
});

it('should block when parent chat entity leaks into thread context', () => {
// Simulate: parent chat discussed 刘宏伟 being rejected in November,
// LLM extracts this as a fact about the current thread's candidate
const llmResponse = JSON.stringify([
{
type: 'fact',
content: '刘宏伟在2025年11月的电话面试中被lyz团队拒绝',
confidence: 0.7,
tags: ['面试'],
ttl: null,
metadata: {},
entities: ['刘宏伟'],
},
{
type: 'fact',
content: '罗文锋具有5年以上UE引擎开发经验,参与过《卡库远古封印》项目',
confidence: 0.8,
tags: ['候选人'],
ttl: null,
metadata: {},
entities: ['罗文锋', '卡库远古封印'],
},
]);

// Thread conversation only has 罗文锋, not 刘宏伟
const conversation = [
'[姜黎]: 分析一下罗文锋的简历',
'[助手]: 罗文锋有5年UE经验,参与过《卡库远古封印》的核心战斗系统开发...',
].join('\n');

const memories = parseExtractionResponse(llmResponse);
const grounded = filterUngroundedMemories(memories, conversation);

// 刘宏伟 not in conversation → first memory blocked
// 罗文锋 + 卡库远古封印 both present → second memory kept
expect(grounded).toHaveLength(1);
expect(grounded[0].content).toContain('罗文锋');
expect(grounded[0].content).toContain('卡库远古封印');
});
});
58 changes: 56 additions & 2 deletions src/memory/extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ interface ExtractedMemory {
tags: string[];
ttl: string | null;
metadata: Record<string, unknown>;
/** Key entities referenced in this memory (for validation against source text) */
entities?: string[];
}

const VALID_TYPES = new Set<string>(['fact', 'preference', 'state', 'decision', 'relation']);
Expand Down Expand Up @@ -69,9 +71,12 @@ const EXTRACTION_PROMPT = `你是一个记忆提取器。从以下对话中提
"confidence": 0.0~1.0,
"tags": ["tag1", "tag2"],
"ttl": "ISO 8601 日期" | null,
"metadata": {}
"metadata": {},
"entities": ["实体1", "实体2"]
}

entities 字段:列出 content 中引用的关键人名/项目名/组织名。系统会校验这些实体是否在对话中出现。

## 提取规则
- 只提取明确的、有长期价值的信息
- 判断标准:这条信息一周后还有价值吗?如果只在当前迭代有意义,不提取
Expand Down Expand Up @@ -108,6 +113,12 @@ const EXTRACTION_PROMPT = `你是一个记忆提取器。从以下对话中提
- 严格区分"谁在说"和"说的是谁",不要把说话者误认为被评估/被讨论的人
- 记忆中引用人名时,必须准确标注其角色 (评估者/候选人/负责人等)

## 实体溯源规则(重要)
- content 中提到的每个人名、项目名等关键实体,必须能在对话原文中找到明确出处
- 如果一个事实涉及特定人名但该人名在对话中没有直接出现,**放弃提取该记忆**
- 宁可少提取,也不能存入无法溯源的事实——错误的记忆比没有记忆危害更大
- 不要从上下文推断或拼凑不确定的事实关联

## 覆盖规则
当对话中出现事实更新或决策变更时 (如 "从 X 迁移到 Y"、"不再用 X 改用 Y"):
- 提取新记忆,系统会自动检测并覆盖旧的同类记忆
Expand Down Expand Up @@ -162,7 +173,11 @@ export async function extractMemories(
const memories = parseExtractionResponse(rawContent);
if (memories.length === 0) return;

const capped = memories.slice(0, MAX_MEMORIES_PER_EXTRACTION);
// Entity-orphan guard: reject memories whose entities can't be found in conversation
const grounded = filterUngroundedMemories(memories, conversation);
if (grounded.length === 0) return;

const capped = grounded.slice(0, MAX_MEMORIES_PER_EXTRACTION);

for (const mem of capped) {
await processExtractedMemory(mem, context, store);
Expand Down Expand Up @@ -234,6 +249,9 @@ function validateMemories(arr: unknown[]): ExtractedMemory[] {
metadata: typeof obj.metadata === 'object' && obj.metadata !== null
? obj.metadata as Record<string, unknown>
: {},
entities: Array.isArray(obj.entities)
? (obj.entities as unknown[]).filter((e): e is string => typeof e === 'string' && e.length > 0)
: undefined,
}))
.filter((mem) => {
// Reject too-short content
Expand All @@ -248,6 +266,42 @@ function validateMemories(arr: unknown[]): ExtractedMemory[] {
});
}

/**
* Entity-orphan guard: reject memories whose declared entities
* cannot be found in the source conversation text.
*
* This prevents the extraction LLM from storing hallucinated facts
* about people/projects that weren't actually discussed.
*/
export function filterUngroundedMemories(
memories: ExtractedMemory[],
conversation: string,
): ExtractedMemory[] {
return memories.filter((mem) => {
// Only validate entity-bearing types (fact, relation, decision)
// Preferences and states are about the user, not external entities
if (mem.type !== 'fact' && mem.type !== 'relation' && mem.type !== 'decision') {
return true;
}

const entities = mem.entities;
// If LLM didn't provide entities, allow the memory through
// (backwards compatible; prompt asks for entities but older models may omit)
if (!entities || entities.length === 0) return true;

// Every declared entity must appear somewhere in the conversation
const missing = entities.filter((entity) => !conversation.includes(entity));
if (missing.length > 0) {
logger.info(
{ content: mem.content.slice(0, 80), missing, type: mem.type },
'Memory rejected: entities not found in conversation (entity-orphan guard)',
);
return false;
}
return true;
});
}

async function processExtractedMemory(
mem: ExtractedMemory,
context: ExtractionContext,
Expand Down
2 changes: 1 addition & 1 deletion src/memory/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function injectMemories(
const fragment = formatMemories(results, context.chatId);
logger.info(
{ agentId: context.agentId, userId: context.userId, count: results.length, chars: fragment.length },
'Memories injected into system prompt',
'Memories injected into user prompt prefix',
);
return fragment;
} catch (err) {
Expand Down
Loading