Skip to content

Commit 95b0609

Browse files
committed
feat: add wrong-answer note for cross-session quiz review
Persist structured quiz mistakes on submission and expose them in a new review page accessible from the utility sidebar. Backend: - New wrong_answers SQLite table (cascade on session delete) with add/list/count/update/delete store methods - Hook POST /api/v1/sessions/{id}/quiz-results to also insert one row per incorrect answer, preserving the existing [Quiz Performance] text message consumed by the LLM context builder - New GET /api/v1/wrong-answers (resolved filter + pagination), PATCH /{id} (resolved toggle), DELETE /{id} Frontend: - New /wrong-answers page under the (utility) route group with unresolved/all filter, mark-as-resolved, delete, and session link - Sidebar nav entry using the NotebookPen icon - English and Chinese i18n strings for all new UI text Tests: - 8 store tests covering filter, cascade, resolved toggle, delete - 6 router tests including a regression test that record_quiz_results still writes the [Quiz Performance] text message
1 parent 2734f48 commit 95b0609

11 files changed

Lines changed: 982 additions & 2 deletions

File tree

deeptutor/api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ async def selective_access_log(request, call_next):
214214
tutorbot,
215215
unified_ws,
216216
vision_solver,
217+
wrong_answers,
217218
)
218219

219220
# Include routers
@@ -227,6 +228,7 @@ async def selective_access_log(request, call_next):
227228
app.include_router(guide.router, prefix="/api/v1/guide", tags=["guide"])
228229
app.include_router(memory.router, prefix="/api/v1/memory", tags=["memory"])
229230
app.include_router(sessions.router, prefix="/api/v1/sessions", tags=["sessions"])
231+
app.include_router(wrong_answers.router, prefix="/api/v1/wrong-answers", tags=["wrong-answers"])
230232
app.include_router(settings.router, prefix="/api/v1/settings", tags=["settings"])
231233
app.include_router(system.router, prefix="/api/v1/system", tags=["system"])
232234
app.include_router(plugins_api.router, prefix="/api/v1/plugins", tags=["plugins"])

deeptutor/api/routers/sessions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,14 @@ async def record_quiz_results(session_id: str, payload: QuizResultsRequest):
9999
content=content,
100100
capability="deep_question",
101101
)
102+
wrong_answer_count = await store.add_wrong_answers(
103+
session_id,
104+
[item.model_dump() for item in payload.answers],
105+
)
102106
return {
103107
"recorded": True,
104108
"session_id": session_id,
105109
"answer_count": len(payload.answers),
110+
"wrong_answer_count": wrong_answer_count,
106111
"content": content,
107112
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
Wrong answer notebook API — persists and exposes quiz mistakes for review.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from fastapi import APIRouter, HTTPException, Query
8+
from pydantic import BaseModel
9+
10+
from deeptutor.services.session import get_sqlite_session_store
11+
12+
router = APIRouter()
13+
14+
15+
class WrongAnswerItem(BaseModel):
16+
id: int
17+
session_id: str
18+
session_title: str = ""
19+
question_id: str = ""
20+
question: str
21+
user_answer: str = ""
22+
correct_answer: str = ""
23+
resolved: bool
24+
created_at: float
25+
resolved_at: float | None = None
26+
27+
28+
class WrongAnswerListResponse(BaseModel):
29+
items: list[WrongAnswerItem]
30+
total: int
31+
32+
33+
class WrongAnswerResolveRequest(BaseModel):
34+
resolved: bool
35+
36+
37+
@router.get("", response_model=WrongAnswerListResponse)
38+
async def list_wrong_answers(
39+
resolved: bool | None = Query(default=None),
40+
limit: int = Query(default=50, ge=1, le=200),
41+
offset: int = Query(default=0, ge=0),
42+
) -> WrongAnswerListResponse:
43+
store = get_sqlite_session_store()
44+
items = await store.list_wrong_answers(
45+
resolved=resolved, limit=limit, offset=offset
46+
)
47+
total = await store.count_wrong_answers(resolved=resolved)
48+
return WrongAnswerListResponse(
49+
items=[WrongAnswerItem(**item) for item in items],
50+
total=total,
51+
)
52+
53+
54+
@router.patch("/{wrong_answer_id}")
55+
async def update_wrong_answer(
56+
wrong_answer_id: int,
57+
payload: WrongAnswerResolveRequest,
58+
):
59+
store = get_sqlite_session_store()
60+
updated = await store.update_wrong_answer_resolved(
61+
wrong_answer_id, payload.resolved
62+
)
63+
if not updated:
64+
raise HTTPException(status_code=404, detail="Wrong answer not found")
65+
return {"updated": True, "id": wrong_answer_id, "resolved": payload.resolved}
66+
67+
68+
@router.delete("/{wrong_answer_id}")
69+
async def delete_wrong_answer(wrong_answer_id: int):
70+
store = get_sqlite_session_store()
71+
deleted = await store.delete_wrong_answer(wrong_answer_id)
72+
if not deleted:
73+
raise HTTPException(status_code=404, detail="Wrong answer not found")
74+
return {"deleted": True, "id": wrong_answer_id}

deeptutor/services/session/sqlite_store.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,24 @@ def _initialize(self) -> None:
145145
146146
CREATE INDEX IF NOT EXISTS idx_turn_events_turn_seq
147147
ON turn_events(turn_id, seq);
148+
149+
CREATE TABLE IF NOT EXISTS wrong_answers (
150+
id INTEGER PRIMARY KEY AUTOINCREMENT,
151+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
152+
question_id TEXT DEFAULT '',
153+
question TEXT NOT NULL,
154+
user_answer TEXT DEFAULT '',
155+
correct_answer TEXT DEFAULT '',
156+
resolved INTEGER NOT NULL DEFAULT 0,
157+
created_at REAL NOT NULL,
158+
resolved_at REAL
159+
);
160+
161+
CREATE INDEX IF NOT EXISTS idx_wrong_answers_session
162+
ON wrong_answers(session_id, created_at DESC);
163+
164+
CREATE INDEX IF NOT EXISTS idx_wrong_answers_resolved
165+
ON wrong_answers(resolved, created_at DESC);
148166
"""
149167
)
150168
columns = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
@@ -731,6 +749,164 @@ async def get_session_with_messages(self, session_id: str) -> dict[str, Any] | N
731749
session["active_turns"] = await self.list_active_turns(session_id)
732750
return session
733751

752+
def _add_wrong_answers_sync(
753+
self,
754+
session_id: str,
755+
items: list[dict[str, Any]],
756+
) -> int:
757+
if not items:
758+
return 0
759+
now = time.time()
760+
with self._connect() as conn:
761+
session = conn.execute(
762+
"SELECT id FROM sessions WHERE id = ?",
763+
(session_id,),
764+
).fetchone()
765+
if session is None:
766+
raise ValueError(f"Session not found: {session_id}")
767+
inserted = 0
768+
for item in items:
769+
if item.get("is_correct"):
770+
continue
771+
question = (item.get("question") or "").strip()
772+
if not question:
773+
continue
774+
conn.execute(
775+
"""
776+
INSERT INTO wrong_answers (
777+
session_id, question_id, question, user_answer,
778+
correct_answer, resolved, created_at, resolved_at
779+
) VALUES (?, ?, ?, ?, ?, 0, ?, NULL)
780+
""",
781+
(
782+
session_id,
783+
item.get("question_id") or "",
784+
question,
785+
item.get("user_answer") or "",
786+
item.get("correct_answer") or "",
787+
now,
788+
),
789+
)
790+
inserted += 1
791+
conn.commit()
792+
return inserted
793+
794+
async def add_wrong_answers(
795+
self,
796+
session_id: str,
797+
items: list[dict[str, Any]],
798+
) -> int:
799+
return await self._run(self._add_wrong_answers_sync, session_id, items)
800+
801+
def _list_wrong_answers_sync(
802+
self,
803+
resolved: bool | None,
804+
limit: int,
805+
offset: int,
806+
) -> list[dict[str, Any]]:
807+
query = """
808+
SELECT
809+
w.id,
810+
w.session_id,
811+
COALESCE(s.title, '') AS session_title,
812+
w.question_id,
813+
w.question,
814+
w.user_answer,
815+
w.correct_answer,
816+
w.resolved,
817+
w.created_at,
818+
w.resolved_at
819+
FROM wrong_answers w
820+
LEFT JOIN sessions s ON s.id = w.session_id
821+
"""
822+
params: list[Any] = []
823+
if resolved is not None:
824+
query += " WHERE w.resolved = ?"
825+
params.append(1 if resolved else 0)
826+
query += " ORDER BY w.created_at DESC LIMIT ? OFFSET ?"
827+
params.extend([limit, offset])
828+
with self._connect() as conn:
829+
rows = conn.execute(query, tuple(params)).fetchall()
830+
return [
831+
{
832+
"id": int(row["id"]),
833+
"session_id": row["session_id"],
834+
"session_title": row["session_title"] or "",
835+
"question_id": row["question_id"] or "",
836+
"question": row["question"],
837+
"user_answer": row["user_answer"] or "",
838+
"correct_answer": row["correct_answer"] or "",
839+
"resolved": bool(row["resolved"]),
840+
"created_at": float(row["created_at"]),
841+
"resolved_at": (
842+
float(row["resolved_at"]) if row["resolved_at"] is not None else None
843+
),
844+
}
845+
for row in rows
846+
]
847+
848+
async def list_wrong_answers(
849+
self,
850+
resolved: bool | None = None,
851+
limit: int = 50,
852+
offset: int = 0,
853+
) -> list[dict[str, Any]]:
854+
return await self._run(
855+
self._list_wrong_answers_sync, resolved, limit, offset
856+
)
857+
858+
def _count_wrong_answers_sync(self, resolved: bool | None) -> int:
859+
query = "SELECT COUNT(*) AS count FROM wrong_answers"
860+
params: tuple[Any, ...] = ()
861+
if resolved is not None:
862+
query += " WHERE resolved = ?"
863+
params = (1 if resolved else 0,)
864+
with self._connect() as conn:
865+
row = conn.execute(query, params).fetchone()
866+
return int(row["count"]) if row else 0
867+
868+
async def count_wrong_answers(self, resolved: bool | None = None) -> int:
869+
return await self._run(self._count_wrong_answers_sync, resolved)
870+
871+
def _update_wrong_answer_resolved_sync(
872+
self,
873+
wrong_answer_id: int,
874+
resolved: bool,
875+
) -> bool:
876+
now = time.time() if resolved else None
877+
with self._connect() as conn:
878+
cur = conn.execute(
879+
"""
880+
UPDATE wrong_answers
881+
SET resolved = ?, resolved_at = ?
882+
WHERE id = ?
883+
""",
884+
(1 if resolved else 0, now, wrong_answer_id),
885+
)
886+
conn.commit()
887+
return cur.rowcount > 0
888+
889+
async def update_wrong_answer_resolved(
890+
self,
891+
wrong_answer_id: int,
892+
resolved: bool,
893+
) -> bool:
894+
return await self._run(
895+
self._update_wrong_answer_resolved_sync, wrong_answer_id, resolved
896+
)
897+
898+
def _delete_wrong_answer_sync(self, wrong_answer_id: int) -> bool:
899+
with self._connect() as conn:
900+
cur = conn.execute(
901+
"DELETE FROM wrong_answers WHERE id = ?",
902+
(wrong_answer_id,),
903+
)
904+
conn.commit()
905+
return cur.rowcount > 0
906+
907+
async def delete_wrong_answer(self, wrong_answer_id: int) -> bool:
908+
return await self._run(self._delete_wrong_answer_sync, wrong_answer_id)
909+
734910

735911
_instance: SQLiteSessionStore | None = None
736912

0 commit comments

Comments
 (0)