Skip to content

Commit 7eff581

Browse files
committed
feat(notes): N5 闪卡只取核心层文本 + N2 合书测试答后 diff 高亮
1 parent 5004b44 commit 7eff581

8 files changed

Lines changed: 379 additions & 15 deletions

File tree

‎client/src/features/notes/components/ClosedBookTest.tsx‎

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
* ClosedBookTest — N2 合书测试模式
33
*
44
* @ai-context: 编辑器内容被遮罩隐藏后,AI 基于笔记文本生成 3-5 个回忆问题
5-
* (复用 useAIFeynmanQuestion),用户作答后"合书对照"解除遮罩核对原文。
6-
* 提取练习(retrieval practice):主动回忆比被动重读更能巩固记忆。
5+
* (复用 useAIFeynmanQuestion),用户作答后"合书对照"提交答案,逐条 diff
6+
* 高亮(正确=绿 / 错误=红 / 遗漏=琥珀,纯本地 bigram 相似度,见 diffRecall)。
7+
* 提取练习(retrieval practice):主动回忆 + 即时反馈比被动重读更能巩固记忆。
78
* AI 不可用时降级为空白自由回忆区,保证本地可用。
89
*/
910
import { useEffect, useState } from 'react';
1011
import { Button } from '@/components/ui';
1112
import { AIThinkingIndicator } from '@/components/ui/AIThinkingIndicator';
12-
import { EyeOff, Eye, X } from 'lucide-react';
13+
import { EyeOff, Check, X, RotateCcw } from 'lucide-react';
1314
import { useAIFeynmanQuestion } from '@/lib/ai/useAI';
1415
import type { FeynmanQuestionItem } from '@/lib/ai/types';
16+
import { diffRecallAgainstNote, type DiffSentence } from '../lib/diffRecall';
17+
import { cn } from '@/lib/utils';
1518

1619
/** 送入 AI 的笔记文本上限(控制 token 消耗) */
1720
const MAX_TEXT_LEN = 3000;
@@ -22,18 +25,25 @@ interface ClosedBookTestProps {
2225
noteTitle: string;
2326
/** 编辑器实时纯文本 */
2427
noteText: string;
25-
/** 合书对照:解除遮罩显示原文 */
26-
onReveal: () => void;
2728
/** 结束合书测试 */
2829
onClose: () => void;
2930
}
3031

31-
export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: ClosedBookTestProps) {
32+
/** 三态配色映射 */
33+
const DIFF_STYLE: Record<DiffSentence['kind'], { icon: React.ReactNode; row: string; label: string }> = {
34+
correct: { icon: <Check className="w-3.5 h-3.5" strokeWidth={2} />, row: 'border-emerald-500/30 bg-emerald-500/5', label: '回答到位' },
35+
wrong: { icon: <X className="w-3.5 h-3.5" strokeWidth={2} />, row: 'border-red-500/30 bg-red-500/5', label: '回忆偏差' },
36+
missing: { icon: <span className="w-1.5 h-1.5 rounded-full bg-amber-400" />, row: 'border-amber-500/30 bg-amber-500/5', label: '原文遗漏' },
37+
};
38+
39+
export function ClosedBookTest({ noteTitle, noteText, onClose }: ClosedBookTestProps) {
3240
const { loading, generateQuestions } = useAIFeynmanQuestion();
3341
const [questions, setQuestions] = useState<FeynmanQuestionItem[]>([]);
3442
const [fallback, setFallback] = useState(false);
3543
const [answers, setAnswers] = useState<string[]>([]);
3644
const [freeRecall, setFreeRecall] = useState('');
45+
// N2 提交对照结果:非 null 时展示逐条 diff 高亮
46+
const [diff, setDiff] = useState<DiffSentence[] | null>(null);
3747

3848
// 挂载时尝试 AI 出题;失败或内容不足则降级为自由回忆
3949
useEffect(() => {
@@ -69,6 +79,62 @@ export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: Close
6979
? freeRecall.trim().length > 0
7080
: answers.some((a) => a.trim().length > 0);
7181

82+
/** N2 提交对照:合并全部答案为一段回忆文本,逐条 diff 高亮 */
83+
const handleSubmit = () => {
84+
const recall = fallback
85+
? freeRecall
86+
: answers.map((a) => a.trim()).filter(Boolean).join('。');
87+
setDiff(diffRecallAgainstNote(recall, noteText));
88+
};
89+
90+
const handleRetry = () => {
91+
setDiff(null);
92+
if (!fallback) setAnswers(new Array(questions.length).fill(''));
93+
else setFreeRecall('');
94+
};
95+
96+
// N2 对照结果视图:三态逐条高亮 + 原文对应
97+
const renderDiff = () => (
98+
<div className="flex flex-col gap-2">
99+
<p className="text-b2 text-text-primary font-medium">对照结果——逐条核对,记住偏差处</p>
100+
<div className="flex flex-col gap-1.5">
101+
{diff?.map((d, i) => {
102+
const style = DIFF_STYLE[d.kind];
103+
return (
104+
<div
105+
key={i}
106+
className={cn('flex items-start gap-2 px-3 py-2 rounded-kb-md border text-b2', style.row)}
107+
>
108+
<span className={cn('mt-0.5 flex-shrink-0', d.kind === 'correct' && 'text-emerald-500', d.kind === 'wrong' && 'text-red-500')}>
109+
{style.icon}
110+
</span>
111+
<div className="flex flex-col gap-0.5 min-w-0">
112+
<span className="text-text-primary leading-relaxed">{d.text}</span>
113+
{d.kind === 'correct' && d.matched && d.matched !== d.text && (
114+
<span className="text-c1 text-text-tertiary truncate">↳ 原文:{d.matched}</span>
115+
)}
116+
{d.kind === 'missing' && (
117+
<span className="text-c1 text-amber-600/80">这条要点没想起来——回看笔记重点补一下</span>
118+
)}
119+
{d.kind === 'wrong' && (
120+
<span className="text-c1 text-red-500/80">原文中没有对应内容,可能是记串了</span>
121+
)}
122+
</div>
123+
</div>
124+
);
125+
})}
126+
</div>
127+
<div className="flex items-center justify-between gap-2 mt-2">
128+
<p className="text-c1 text-text-tertiary">绿=回答到位 · 红=回忆偏差 · 琥珀=原文遗漏</p>
129+
<div className="flex gap-2 flex-shrink-0">
130+
<Button variant="secondary" icon={<RotateCcw className="w-4 h-4" strokeWidth={1.5} />} onClick={handleRetry}>
131+
重新作答
132+
</Button>
133+
<Button onClick={onClose}>完成测试</Button>
134+
</div>
135+
</div>
136+
</div>
137+
);
72138
return (
73139
<div className="max-w-[640px] mx-auto my-kb-lg p-kb-lg rounded-kb-lg bg-bg-primary/95 backdrop-blur-xl border border-border/40 shadow-[0_8px_32px_rgba(0,0,0,0.08)]">
74140
{/* 头部 */}
@@ -97,6 +163,11 @@ export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: Close
97163
</div>
98164
)}
99165

166+
{/* N2 对照结果:提交后逐条 diff 高亮 */}
167+
{diff && renderDiff()}
168+
169+
{!diff && (
170+
<>
100171
{/* 降级:自由回忆区 */}
101172
{fallback && !loading && (
102173
<div className="flex flex-col gap-3">
@@ -138,15 +209,15 @@ export function ClosedBookTest({ noteTitle, noteText, onReveal, onClose }: Close
138209
{/* 操作区 */}
139210
<div className="flex items-center justify-between gap-2 mt-5">
140211
<p className="text-c1 text-text-tertiary">
141-
{answered ? '写得不错,对照原文看看?' : '先试着写点什么,哪怕只言片语'}
212+
{answered ? '写得不错,提交对照看看?' : '先试着写点什么,哪怕只言片语'}
142213
</p>
143214
<div className="flex gap-2 flex-shrink-0">
144215
<Button variant="secondary" onClick={onClose}>结束测试</Button>
145-
<Button icon={<Eye className="w-4 h-4" strokeWidth={1.5} />} onClick={onReveal}>
146-
合书对照
147-
</Button>
216+
<Button onClick={handleSubmit}>合书对照</Button>
148217
</div>
149218
</div>
219+
</>
220+
)}
150221
</div>
151222
);
152223
}

‎client/src/features/notes/components/ContentTierModal.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ import { cn } from '@/lib/utils';
1414
import { copyText } from '@/lib/utils/clipboard';
1515
import { useAIContentTier } from '@/lib/ai/hooks/useAIContentTier';
1616
import type { ContentTierItem } from '@/lib/ai/types';
17+
import { saveCoreTier } from '../lib/contentTierStore';
1718

1819
interface ContentTierModalProps {
1920
open: boolean;
2021
onClose: () => void;
2122
/** 当前笔记的实时纯文本 */
2223
noteText: string;
24+
/** 当前笔记 id(N5 核心层缓存键,供闪卡生成联动) */
25+
noteId?: string | null;
2326
}
2427

2528
const tierMeta = {
@@ -28,7 +31,7 @@ const tierMeta = {
2831
detail: { label: '参考细节', hint: '可策略性遗忘的旁支信息', accent: 'text-text-tertiary bg-bg-tertiary' },
2932
} as const;
3033

31-
export function ContentTierModal({ open, onClose, noteText }: ContentTierModalProps) {
34+
export function ContentTierModal({ open, onClose, noteText, noteId }: ContentTierModalProps) {
3235
const { tier, loading, error, analyze, reset } = useAIContentTier();
3336
const [focusCore, setFocusCore] = useState(false);
3437
const [expanded, setExpanded] = useState<Record<string, boolean>>({ core: true, support: true, detail: true });
@@ -38,7 +41,13 @@ export function ContentTierModal({ open, onClose, noteText }: ContentTierModalPr
3841
if (open) {
3942
setFocusCore(false);
4043
setExpanded({ core: true, support: true, detail: true });
41-
analyze(noteText);
44+
// N5 闭环:分层成功后缓存核心层文本,闪卡生成只取核心层
45+
void analyze(noteText).then((tier) => {
46+
if (tier?.core?.length && noteId) {
47+
const coreText = tier.core.map((item) => item.text).join('\n');
48+
saveCoreTier(noteId, coreText);
49+
}
50+
});
4251
} else {
4352
reset();
4453
}

‎client/src/features/notes/hooks/useNoteAI.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useFlashcardStore } from '@/features/flashcards/store/useFlashcardStore
1313
import { useAISummarize, useAIFlashcards } from '@/lib/ai/useAI';
1414
import { useAIErrorHandler } from '@/lib/ai/hooks/useAIErrorHandler';
1515
import { useToast } from '@/components/ui';
16+
import { getCoreTier } from '../lib/contentTierStore';
1617

1718
type SummaryData = NonNullable<ReturnType<typeof useAISummarize>['data']>;
1819

@@ -50,7 +51,11 @@ export function useNoteAI(editor: Editor | null, noteId: string | null) {
5051

5152
/** 将 AI 生成的卡片批量落库到目标牌组,返回卡片数 */
5253
const persistCards = useCallback(async (source: string): Promise<number> => {
53-
const result = await generateFlashcards(source);
54+
// N5 闭环:存在新鲜的核心层缓存时,闪卡只从核心层文本生成
55+
// (策略性遗忘 → 闪卡只记核心;缓存过期/缺失静默回退原文)
56+
const coreTier = getCoreTier(noteId);
57+
const effectiveSource = coreTier ?? source;
58+
const result = await generateFlashcards(effectiveSource);
5459
if (!result) throw new Error('generate failed');
5560
const targetDeckId = await ensureDefaultDeck();
5661
await Promise.all(
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Content tier store unit tests
3+
* B3 (N5): core-layer persistence bridging tiering to card generation
4+
* @ai-context 核心层缓存测试:7 天 TTL、静默降级、清除;jsdom 下直接用
5+
* localStorage 验证读写闭环。
6+
*/
7+
import { describe, it, expect, beforeEach } from 'vitest';
8+
import { saveCoreTier, getCoreTier, clearCoreTier } from './contentTierStore';
9+
10+
const NOTE_ID = 'note-1';
11+
12+
beforeEach(() => {
13+
localStorage.clear();
14+
});
15+
16+
describe('saveCoreTier / getCoreTier', () => {
17+
it('should persist core text and read it back fresh', () => {
18+
saveCoreTier(NOTE_ID, '核心概念一\n核心概念二');
19+
expect(getCoreTier(NOTE_ID)).toBe('核心概念一\n核心概念二');
20+
});
21+
22+
it('should trim stored text', () => {
23+
saveCoreTier(NOTE_ID, ' 核心文本 ');
24+
expect(getCoreTier(NOTE_ID)).toBe('核心文本');
25+
});
26+
27+
it('should ignore empty noteId or blank text', () => {
28+
saveCoreTier('', '核心');
29+
saveCoreTier(NOTE_ID, ' ');
30+
expect(getCoreTier('')).toBeNull();
31+
expect(getCoreTier(NOTE_ID)).toBeNull();
32+
});
33+
34+
it('should return null when no cache exists', () => {
35+
expect(getCoreTier(NOTE_ID)).toBeNull();
36+
expect(getCoreTier(null)).toBeNull();
37+
});
38+
39+
it('should return null for expired cache (older than 7 days)', () => {
40+
// 直接写入 8 天前的缓存,模拟过期
41+
const expired = {
42+
coreText: '过期核心',
43+
savedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
44+
};
45+
localStorage.setItem(`kb-core-tier:${NOTE_ID}`, JSON.stringify(expired));
46+
expect(getCoreTier(NOTE_ID)).toBeNull();
47+
});
48+
49+
it('should return null for corrupted cache payload', () => {
50+
localStorage.setItem(`kb-core-tier:${NOTE_ID}`, '{not-json');
51+
expect(getCoreTier(NOTE_ID)).toBeNull();
52+
// localStorage 抛错(隐私模式)也应静默返回 null
53+
localStorage.setItem(`kb-core-tier:${NOTE_ID}`, '{"coreText":""}');
54+
expect(getCoreTier(NOTE_ID)).toBeNull();
55+
});
56+
57+
it('should clear cache on demand', () => {
58+
saveCoreTier(NOTE_ID, '核心');
59+
clearCoreTier(NOTE_ID);
60+
expect(getCoreTier(NOTE_ID)).toBeNull();
61+
});
62+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* N5 核心层持久化 — 分层结果与闪卡生成的联动桥
3+
* Content-tier core persistence — bridges tiering to card generation
4+
*
5+
* @ai-context: 内容分层结果不落 SQLite(AI 生成物,会话性辅助),以
6+
* localStorage 按笔记缓存核心层文本 + 时间戳,有效期 7 天。闪卡生成
7+
* (useNoteAI.persistCards)读取此缓存:存在则仅用核心层文本作生成源,
8+
* 实现"策略性遗忘 → 闪卡只记核心"的闭环;过期/缺失静默回退原文。
9+
* @ai-context: Tier results are cached per note in localStorage with a
10+
* 7-day TTL; flashcard generation consumes only the core layer when a
11+
* fresh cache exists, falling back to full text silently otherwise.
12+
*/
13+
14+
const CORE_TIER_PREFIX = 'kb-core-tier:';
15+
/** 缓存有效期(毫秒):7 天 */
16+
const CORE_TIER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
17+
18+
interface CoreTierCache {
19+
coreText: string;
20+
savedAt: string;
21+
}
22+
23+
function keyOf(noteId: string): string {
24+
return `${CORE_TIER_PREFIX}${noteId}`;
25+
}
26+
27+
/** 保存笔记的核心层文本(分析成功后由 ContentTierModal 调用) */
28+
export function saveCoreTier(noteId: string, coreText: string): void {
29+
if (!noteId || !coreText.trim()) return;
30+
try {
31+
const cache: CoreTierCache = { coreText: coreText.trim(), savedAt: new Date().toISOString() };
32+
localStorage.setItem(keyOf(noteId), JSON.stringify(cache));
33+
} catch {
34+
/* 存储失败静默——不影响分层弹窗本身 */
35+
}
36+
}
37+
38+
/** 读取新鲜(7 天内)的核心层文本;无缓存/过期返回 null */
39+
export function getCoreTier(noteId: string | null): string | null {
40+
if (!noteId) return null;
41+
try {
42+
const raw = localStorage.getItem(keyOf(noteId));
43+
if (!raw) return null;
44+
const cache = JSON.parse(raw) as CoreTierCache;
45+
if (!cache.coreText) return null;
46+
const age = Date.now() - new Date(cache.savedAt).getTime();
47+
if (age < 0 || age > CORE_TIER_TTL_MS) return null;
48+
return cache.coreText;
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
/** 清除核心层缓存(笔记删除时可选调用) */
55+
export function clearCoreTier(noteId: string): void {
56+
try {
57+
localStorage.removeItem(keyOf(noteId));
58+
} catch {
59+
/* 静默 */
60+
}
61+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* N2 合书测试 diff 算法单元测试
3+
* Closed-book recall diff algorithm tests
4+
*
5+
* @ai-context: 覆盖三态判定——正确(高相似)、错误(无对应)、遗漏(原文
6+
* 未覆盖);空答案全遗漏;短句阈值防误判。
7+
*/
8+
import { describe, it, expect } from 'vitest';
9+
import { diffRecallAgainstNote, splitSentences } from './diffRecall';
10+
11+
const NOTE = '傅里叶变换将信号分解为不同频率的正弦波。频谱展示各频率分量的强度。逆变换可以把频谱还原为时域信号。';
12+
13+
describe('splitSentences', () => {
14+
it('should split by Chinese sentence punctuation and newlines', () => {
15+
expect(splitSentences('第一句。第二句!第三句?\n第四句')).toEqual(['第一句', '第二句', '第三句', '第四句']);
16+
});
17+
18+
it('should drop too-short fragments (single char)', () => {
19+
expect(splitSentences('好。完整的句子内容')).toEqual(['完整的句子内容']);
20+
});
21+
});
22+
23+
describe('diffRecallAgainstNote', () => {
24+
it('should mark answer sentence as correct when closely matching the note', () => {
25+
const diff = diffRecallAgainstNote('傅里叶变换将信号分解为不同频率的正弦波。', NOTE);
26+
expect(diff[0]).toMatchObject({ kind: 'correct', text: '傅里叶变换将信号分解为不同频率的正弦波' });
27+
});
28+
29+
it('should mark fabricated content as wrong', () => {
30+
const diff = diffRecallAgainstNote('拉普拉斯变换用于解微分方程。', NOTE);
31+
expect(diff[0]).toMatchObject({ kind: 'wrong' });
32+
});
33+
34+
it('should mark uncovered note sentences as missing', () => {
35+
const diff = diffRecallAgainstNote('频谱展示各频率分量的强度。', NOTE);
36+
const kinds = diff.map((d) => d.kind);
37+
expect(kinds).toContain('correct');
38+
expect(kinds.filter((k) => k === 'missing').length).toBe(2); // 另两句未覆盖
39+
});
40+
41+
it('should treat empty answer as all missing', () => {
42+
const diff = diffRecallAgainstNote('', NOTE);
43+
expect(diff.length).toBe(3);
44+
expect(diff.every((d) => d.kind === 'missing')).toBe(true);
45+
});
46+
47+
it('should not double-count one note sentence for two answer sentences', () => {
48+
const diff = diffRecallAgainstNote(
49+
'傅里叶变换将信号分解为不同频率的正弦波。逆变换可以把频谱还原为时域信号。',
50+
NOTE,
51+
);
52+
const missing = diff.filter((d) => d.kind === 'missing');
53+
expect(missing.map((d) => d.text)).toEqual(['频谱展示各频率分量的强度']);
54+
});
55+
56+
it('should use stricter threshold for short sentences to avoid false positives', () => {
57+
const note = '坚持每日复习。记忆会衰减。';
58+
const diff = diffRecallAgainstNote('坚持每天复习。', note);
59+
// 短句相似但非逐字 → 可能判 wrong 而非 correct(阈值上浮)
60+
const correctCount = diff.filter((d) => d.kind === 'correct').length;
61+
expect(correctCount).toBeLessThan(2);
62+
});
63+
});

0 commit comments

Comments
 (0)