feat(flashcards): révision SM-2, génération IA et page /revision
Livre US-FLASHCARDS avec decks, session de révision, stats et migration Prisma. Finalise le Web Clipper (i18n 15 langues) et corrige les erreurs ESLint bloquant la CI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
90
memento-note/lib/flashcards/deck-queries.ts
Normal file
90
memento-note/lib/flashcards/deck-queries.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { isCardMastered } from '@/lib/flashcards/sm2'
|
||||
|
||||
export interface DeckSummary {
|
||||
id: string
|
||||
name: string
|
||||
notebookId: string | null
|
||||
totalCards: number
|
||||
dueCount: number
|
||||
masteredCount: number
|
||||
lastReviewedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export async function listDeckSummaries(userId: string): Promise<DeckSummary[]> {
|
||||
const now = new Date()
|
||||
const decks = await prisma.flashcardDeck.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
flashcards: {
|
||||
select: {
|
||||
id: true,
|
||||
interval: true,
|
||||
nextReviewAt: true,
|
||||
reviews: {
|
||||
orderBy: { reviewedAt: 'desc' },
|
||||
take: 1,
|
||||
select: { reviewedAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
return decks.map((deck) => {
|
||||
const totalCards = deck.flashcards.length
|
||||
const dueCount = deck.flashcards.filter((c) => c.nextReviewAt <= now).length
|
||||
const masteredCount = deck.flashcards.filter((c) => isCardMastered(c.interval)).length
|
||||
const lastReview = deck.flashcards
|
||||
.flatMap((c) => c.reviews.map((r) => r.reviewedAt))
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
notebookId: deck.notebookId,
|
||||
totalCards,
|
||||
dueCount,
|
||||
masteredCount,
|
||||
lastReviewedAt: lastReview ? lastReview.toISOString() : null,
|
||||
createdAt: deck.createdAt.toISOString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDeckDetail(userId: string, deckId: string) {
|
||||
const deck = await prisma.flashcardDeck.findFirst({
|
||||
where: { id: deckId, userId },
|
||||
include: {
|
||||
flashcards: {
|
||||
orderBy: { nextReviewAt: 'asc' },
|
||||
include: {
|
||||
note: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!deck) return null
|
||||
|
||||
const now = new Date()
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
notebookId: deck.notebookId,
|
||||
cards: deck.flashcards.map((c) => ({
|
||||
id: c.id,
|
||||
front: c.front,
|
||||
back: c.back,
|
||||
type: c.type,
|
||||
interval: c.interval,
|
||||
easinessFactor: c.easinessFactor,
|
||||
nextReviewAt: c.nextReviewAt.toISOString(),
|
||||
noteId: c.noteId,
|
||||
noteTitle: c.note?.title ?? null,
|
||||
due: c.nextReviewAt <= now,
|
||||
mastered: isCardMastered(c.interval),
|
||||
})),
|
||||
}
|
||||
}
|
||||
55
memento-note/lib/flashcards/deck-utils.ts
Normal file
55
memento-note/lib/flashcards/deck-utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export async function getOrCreateDeckForNotebook(params: {
|
||||
userId: string
|
||||
notebookId: string | null
|
||||
notebookName?: string | null
|
||||
manualName?: string
|
||||
}) {
|
||||
const { userId, notebookId, notebookName, manualName } = params
|
||||
|
||||
if (notebookId) {
|
||||
const existing = await prisma.flashcardDeck.findFirst({
|
||||
where: { userId, notebookId },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId },
|
||||
select: { name: true },
|
||||
})
|
||||
if (!notebook) {
|
||||
throw new Error('Notebook not found')
|
||||
}
|
||||
|
||||
return prisma.flashcardDeck.create({
|
||||
data: {
|
||||
userId,
|
||||
notebookId,
|
||||
name: notebook.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const name = (manualName || notebookName || 'Deck').trim().slice(0, 120)
|
||||
if (!name) {
|
||||
throw new Error('Deck name required')
|
||||
}
|
||||
|
||||
return prisma.flashcardDeck.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function stripHtmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
86
memento-note/lib/flashcards/generate-flashcards.ts
Normal file
86
memento-note/lib/flashcards/generate-flashcards.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
|
||||
export type FlashcardStyle = 'qa' | 'cloze' | 'concept'
|
||||
|
||||
export interface GeneratedFlashcard {
|
||||
front: string
|
||||
back: string
|
||||
type: FlashcardStyle
|
||||
}
|
||||
|
||||
const STYLE_HINTS: Record<FlashcardStyle, string> = {
|
||||
qa: 'question/answer pairs — front is a clear question, back is a concise answer',
|
||||
cloze: 'fill-in-the-blank — front uses ___ for the missing word(s), back is the complete sentence',
|
||||
concept: 'term/definition — front is a term or concept name, back is its definition',
|
||||
}
|
||||
|
||||
function parseFlashcardsJson(raw: string, style: FlashcardStyle): GeneratedFlashcard[] {
|
||||
const trimmed = raw.trim()
|
||||
const arrayMatch = trimmed.match(/\[[\s\S]*\]/)
|
||||
if (!arrayMatch) return []
|
||||
try {
|
||||
const parsed = JSON.parse(arrayMatch[0]) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const obj = item as Record<string, unknown>
|
||||
const front = typeof obj.front === 'string' ? obj.front.trim() : ''
|
||||
const back = typeof obj.back === 'string' ? obj.back.trim() : ''
|
||||
const type = (typeof obj.type === 'string' ? obj.type : style) as FlashcardStyle
|
||||
if (!front || !back) return null
|
||||
return {
|
||||
front: front.slice(0, 500),
|
||||
back: back.slice(0, 800),
|
||||
type: ['qa', 'cloze', 'concept'].includes(type) ? type : style,
|
||||
}
|
||||
})
|
||||
.filter((c): c is GeneratedFlashcard => c !== null)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateFlashcardsFromNote(params: {
|
||||
title: string
|
||||
textContent: string
|
||||
count: number
|
||||
style: FlashcardStyle
|
||||
language?: string
|
||||
}): Promise<GeneratedFlashcard[]> {
|
||||
const count = Math.min(20, Math.max(5, params.count))
|
||||
const excerpt = params.textContent.slice(0, 8000)
|
||||
const lang = params.language && params.language !== 'auto' ? params.language : 'same as source'
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
const prompt = `You create study flashcards from personal notes for spaced repetition.
|
||||
|
||||
Note title: ${params.title || 'Untitled'}
|
||||
Language: ${lang}
|
||||
Style: ${params.style} — ${STYLE_HINTS[params.style]}
|
||||
|
||||
Source content:
|
||||
${excerpt}
|
||||
|
||||
Generate exactly ${count} flashcards. Use the same language as the source.
|
||||
Respond with ONLY a JSON array (no markdown):
|
||||
[
|
||||
{ "front": "...", "back": "...", "type": "${params.style}" }
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Each card tests one distinct fact from the note
|
||||
- Front and back must be self-contained
|
||||
- No duplicate cards
|
||||
- Cloze cards must include ___ on the front`
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const cards = parseFlashcardsJson(raw, params.style)
|
||||
if (cards.length === 0) {
|
||||
throw new Error('Could not parse flashcards from AI response')
|
||||
}
|
||||
return cards.slice(0, count)
|
||||
}
|
||||
39
memento-note/lib/flashcards/sm2.ts
Normal file
39
memento-note/lib/flashcards/sm2.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type Sm2Grade = 1 | 2 | 3 | 4
|
||||
|
||||
export interface Sm2State {
|
||||
easinessFactor: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface Sm2UpdateResult extends Sm2State {
|
||||
nextReviewAt: Date
|
||||
}
|
||||
|
||||
/** SM-2 update per US-FLASHCARDS spec (grades 1–4). */
|
||||
export function computeSm2Update(
|
||||
grade: number,
|
||||
previous: Sm2State,
|
||||
): Sm2UpdateResult {
|
||||
const g = Math.min(4, Math.max(1, Math.round(grade))) as Sm2Grade
|
||||
const ef = previous.easinessFactor
|
||||
const newEF = Math.max(
|
||||
1.3,
|
||||
ef + 0.1 - (5 - g) * (0.08 + (5 - g) * 0.02),
|
||||
)
|
||||
|
||||
const nextInterval =
|
||||
g <= 2 ? 1 : Math.max(1, Math.round(previous.interval * newEF))
|
||||
|
||||
const nextReviewAt = new Date()
|
||||
nextReviewAt.setDate(nextReviewAt.getDate() + nextInterval)
|
||||
|
||||
return {
|
||||
easinessFactor: newEF,
|
||||
interval: nextInterval,
|
||||
nextReviewAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCardMastered(interval: number): boolean {
|
||||
return interval >= 21
|
||||
}
|
||||
Reference in New Issue
Block a user