Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
nakasyou committed Nov 12, 2024
1 parent 0b7ffb8 commit 491d8df
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 58 deletions.
3 changes: 3 additions & 0 deletions src/islands/ai-quiz/QuizScreen.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.nanoha-sheet {
@apply bg-secondary-container text-on-secondary-container;
}
154 changes: 107 additions & 47 deletions src/islands/ai-quiz/QuizScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { QuizDB, type Quizzes } from './storage'
import type { QuizContent } from './constants'
import { shuffle } from '../../utils/arr'
import { icon } from '../../utils/icons'
import type { TextNoteData } from '../note/components/notes/TextNote/types'
import './QuizScreen.css'

const QuizSelection = (props: {
text: string
Expand Down Expand Up @@ -66,8 +68,8 @@ const SelectAnswerScreen = (props: {
😒低正答率 (
{Math.round(
(props.quiz.rate.correct / props.quiz.rate.proposed) * 10000,
) / 100}%
)
) / 100}
% )
</div>
}
>
Expand Down Expand Up @@ -134,20 +136,53 @@ const CorrectShow = (props: {
)
}

const ExplainContent = (props: {
quiz: GeneratedQuiz
}) => {
return <div class="h-full flex flex-col justify-between gap-2">
<div class="h-1/2">
<div class="font-bold">✨AI による解説</div>
<div class="overflow-y-auto p-2 border h-full">{props.quiz.content.explanation}</div>
</div>
<div class="h-1/2">
<div class="font-bold">📒使用されたノート</div>
<div class="overflow-y-auto p-2 border h-1/2">
<div innerHTML={props.quiz.usedNote.canToJsonData.html} />
</div>
</div>
</div>
}
/** 不正解時 */
const ExplainScreen = (props: {
quiz: GeneratedQuiz
selected: string[]

onEnd(): void
}) => {
const [getIsShownExplain, setIsShownExplain] = createSignal(false)
const [getIsTopExplain, setIsTopExplain] = createSignal(false)

return (
<div class="h-full flex flex-col justify-between p-2">
<Show when={getIsShownExplain()}>
<div classList={{ 'translate-y-[100%] opacity-0': !getIsTopExplain() }} class="p-2 transition-all bg-background border border-outlined w-full h-dvh fixed top-0 left-0 z-30">
<div class="flex justify-between items-center">
<div class="text-2xl">解説</div>
<button onClick={() => {
setIsTopExplain(false)
setTimeout(() => {
setIsShownExplain(false)
}, 200)
}} type="button" innerHTML={icon('x')} class="w-10 h-10" />
</div>
<ExplainContent quiz={props.quiz} />
</div>
</Show>
<div>
<div class="text-3xl text-center my-2">😒不正解..</div>
<div class="text-center p-2">{props.quiz.content.question}</div>
</div>
<div class="grid place-items-center">
<div class="grid md:grid-cols-2 p-2 place-items-center">
<div class="grid grid-cols-3 gap-2">
<div>選択肢</div>
<div>あなたの回答</div>
Expand Down Expand Up @@ -185,8 +220,22 @@ const ExplainScreen = (props: {
)}
</For>
</div>
<div class="hidden md:block">
<ExplainContent quiz={props.quiz}/>
</div>
</div>
<div class="grid place-items-center">
<button
class="flex items-center text-button md:hidden"
type="button"
onClick={() => {
setIsShownExplain(true)
setTimeout(() => setIsTopExplain(true), 50)
}}
>
<div innerHTML={icon('sparkles')} class="w-8 h-8" />
解説を見る
</button>
<button
class="flex items-center filled-button"
type="button"
Expand Down Expand Up @@ -246,26 +295,27 @@ const ResultScreen = (props: {
)
}

const QUESTION_COUNT = 10
export const QuizScreen = (props: {
notes: MargedNoteData[]
noteId: number
}) => {
const [getQuizzes, setQuizzes] = createSignal<GeneratedQuiz[]>([])
const [getQuizIndex, setQuizIndex] = createSignal(0)
const [getQuizIndex, setQuizIndex] = createSignal(-1)
const [getIsShownCorrect, setIsShownCorrect] = createSignal(false)
const [getIsShownExplain, setIsShownExplain] = createSignal(false)
const [getSelected, setSelected] = createSignal<string[]>([])
const [getCorrectCount, setCorrectCount] = createSignal(0)
const currentQuiz = createMemo(() => getQuizzes()[getQuizIndex()])

const isFinished = createMemo(() => getQuizIndex() === 10)
const isFinished = createMemo(() => getQuizIndex() >= QUESTION_COUNT)

let quizManager!: QuizManager

const nextRound = async () => {
// Generate
const generated = await quizManager.generateQuizzes(
10,
QUESTION_COUNT,
props.notes,
props.noteId,
)
Expand Down Expand Up @@ -313,48 +363,58 @@ export const QuizScreen = (props: {
}

return (
<div class="h-full">
<Show
when={currentQuiz()}
fallback={
<Show
when={isFinished()}
fallback={
<div class="h-full grid place-items-center">生成中...</div>
}
>
<ResultScreen
all={10}
correct={getCorrectCount()}
onFinish={() => finish()}
onNextRound={() => nextRound()}
/>
</Show>
}
>
{(quiz) => (
<Show
when={getIsShownExplain()}
fallback={
<SelectAnswerScreen quiz={quiz()} onAnswer={(s) => answered(s)} />
}
>
<ExplainScreen
quiz={currentQuiz()!}
selected={getSelected()}
onEnd={() => nextQuiz()}
/>
</Show>
)}
</Show>
<Show when={getIsShownCorrect()}>
<CorrectShow
onEndShow={() => {
setIsShownCorrect(false)
nextQuiz()
}}
/>
<div class="h-full flex flex-col">
<Show when={!isFinished()}>
<div>
{getQuizIndex() + 1} / {QUESTION_COUNT}
</div>
</Show>
<div class="grow">
<Show
when={currentQuiz()}
fallback={
<Show
when={isFinished()}
fallback={
<div class="h-full grid place-items-center">生成中...</div>
}
>
<ResultScreen
all={QUESTION_COUNT}
correct={getCorrectCount()}
onFinish={() => finish()}
onNextRound={() => nextRound()}
/>
</Show>
}
>
{(quiz) => (
<Show
when={getIsShownExplain()}
fallback={
<SelectAnswerScreen
quiz={quiz()}
onAnswer={(s) => answered(s)}
/>
}
>
<ExplainScreen
quiz={quiz()}
selected={getSelected()}
onEnd={() => nextQuiz()}
/>
</Show>
)}
</Show>
<Show when={getIsShownCorrect()}>
<CorrectShow
onEndShow={() => {
setIsShownCorrect(false)
nextQuiz()
}}
/>
</Show>
</div>
</div>
)
}
58 changes: 48 additions & 10 deletions src/islands/ai-quiz/quiz-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { MargedNoteData } from '../note/components/notes-utils'
import type { TextNoteData } from '../note/components/notes/TextNote/types'
import type { QuizDB, Quizzes } from './storage'
import { shuffle } from '../../utils/arr'
import { sha256 } from '../shared/hash'

const generateQuizzesFromAI = async (text: string): Promise<QuizContent[]> => {
const gemini = getGoogleGenerativeAI()
Expand Down Expand Up @@ -53,46 +54,81 @@ export interface GeneratedQuiz {
proposed: number
correct: number
}
usedNote: TextNoteData
}
export class QuizManager {
#db: QuizDB
constructor(db: QuizDB) {
this.#db = db
}
async #getNeverProposedQuizzes(noteId: number) {
async #getNeverProposedQuizzes(noteId: number, notes: MargedNoteData[]) {
const noteHashes = new Set(await Promise.all(notes.map(note => sha256(JSON.stringify(note.canToJsonData)))))

const quizzes = await this.#db.quizzes.where({
noteId,
proposeCount: 0
}).toArray()

const toDeleteIndexes: number[] = []
for (let i = 0; i < quizzes.length; i++) {
const quiz = quizzes[i]!
if (!noteHashes.has(quiz.noteHash)) {
await this.#db.quizzes.delete(quiz.id!)

toDeleteIndexes.push(i)
}
}

for (const toDeleteIndex of toDeleteIndexes.reverse()) {
quizzes.splice(toDeleteIndex, 1)
}

return quizzes
}
async getLowCorrectRateQuizzes(noteId: number) {
async getLowCorrectRateQuizzes(noteId: number, notes: MargedNoteData[]) {
const noteHashes = new Set(await Promise.all(notes.map(note => sha256(JSON.stringify(note.canToJsonData)))))

const quizzes = (await this.#db.quizzes.where({
noteId
}).toArray())
.filter(q => q.proposeCount > 0).sort((a, b) => (a.correctCount / a.proposeCount) - (b.correctCount / b.proposeCount))

const toDeleteIndexes: number[] = []
for (let i = 0; i < quizzes.length; i++) {
const quiz = quizzes[i]!
if (!noteHashes.has(quiz.noteHash)) {
await this.#db.quizzes.delete(quiz.id!)

toDeleteIndexes.push(i)
}
}
for (const toDeleteIndex of toDeleteIndexes.reverse()) {
quizzes.splice(toDeleteIndex, 1)
}

return quizzes
}
async #addProposedQuizz(notes: MargedNoteData[], noteId: number) {
const textNotes = notes.filter(note => note.type === 'text') as TextNoteData[]
const randomTextNote = textNotes[Math.floor(textNotes.length * Math.random())]
const generated = await generateQuizzesFromAI(randomTextNote?.canToJsonData.html ?? '')

const quizzes = generated.map(content => ({
const quizzes = await Promise.all(generated.map(async content => ({
content,
correctCount: 0,
proposeCount: 0,
noteDataId: randomTextNote?.id ?? '',
noteId
} satisfies Quizzes))
noteId,
noteHash: await sha256(JSON.stringify(randomTextNote?.canToJsonData))
} satisfies Quizzes)))

await this.#db.quizzes.bulkAdd(quizzes)
}
async generateQuizzes(n: number, notes: MargedNoteData[], noteId: number): Promise<GeneratedQuiz[]> {
const quizzes: Map<number, GeneratedQuiz> = new Map()

// First, propose 5 low rate quizzes
const lowRates = await this.getLowCorrectRateQuizzes(noteId)
const lowRates = await this.getLowCorrectRateQuizzes(noteId, notes)
for (let i = 0; i < 5; i++) {
const lowRateQuiz = lowRates[i]
if (!lowRateQuiz) {
Expand All @@ -105,25 +141,27 @@ export class QuizManager {
noteDataId: lowRateQuiz.noteDataId,
rate: {
proposed: lowRateQuiz.proposeCount, correct: lowRateQuiz.correctCount
}
},
usedNote: notes.find(note => note.id === lowRateQuiz.noteDataId)! as TextNoteData
})
}

// Second, generate quizzes
while (true) {
const gotQuizzes = shuffle(await this.#getNeverProposedQuizzes(noteId))
const gotQuizzes = shuffle(await this.#getNeverProposedQuizzes(noteId, notes))
for (const quiz of gotQuizzes) {
quizzes.set(quiz.id ?? 0, {
id: quiz.id ?? 0,
content: quiz.content,
reason: 'new',
noteDataId: quiz.noteDataId,
rate: { proposed: quiz.proposeCount, correct: quiz.correctCount }
rate: { proposed: quiz.proposeCount, correct: quiz.correctCount },
usedNote: notes.find(note => note.id === quiz.noteDataId)! as TextNoteData
})
}

if ([...quizzes.keys()].length >= n) {
return shuffle([...quizzes.values()])
return shuffle([...quizzes.values()]).slice(0, n)
}
await this.#addProposedQuizz(notes, noteId)
}
Expand Down
4 changes: 3 additions & 1 deletion src/islands/ai-quiz/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface Quizzes {
noteId: number
/** ノートの中のノートの ID */
noteDataId: string
/** ノートの SHA256 */
noteHash: string

/** 問題 */
content: QuizContent
Expand All @@ -25,7 +27,7 @@ export class QuizDB extends Dexie {

this.version(1).stores({
quizzes:
'id++, noteId, noteDataId, content, proposeCount, correctCount',
'id++, noteId, noteDataId, noteHash, content, proposeCount, correctCount',
})

this.quizzes = this.table('quizzes')
Expand Down
12 changes: 12 additions & 0 deletions src/islands/shared/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const sha256 = async (input: string): Promise<string> => {
const encoder = new TextEncoder()

const data = encoder.encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))

// Convert bytes to hex
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')

return hashHex
}

0 comments on commit 491d8df

Please sign in to comment.