Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

336 lines
11 KiB
Python

"""
Question Notebook API — persists quiz questions, bookmarks, and categories.
"""
from __future__ import annotations
import base64 as _b64
import logging
import uuid as _uuid
from fastapi import APIRouter, HTTPException, Query, Response
from pydantic import BaseModel, Field
from deeptutor.services.session import get_sqlite_session_store
from deeptutor.services.storage import get_attachment_store
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Models ────────────────────────────────────────────────────────
class AnswerImageItem(BaseModel):
"""Persisted reference to one image attached to a learner's answer.
The bytes live in the AttachmentStore at ``url``; we never round-trip
base64 back to the client so notebook lookups stay cheap.
"""
id: str = ""
url: str = ""
filename: str = ""
mime_type: str = ""
class NotebookEntryItem(BaseModel):
id: int
session_id: str
session_title: str = ""
turn_id: str = ""
question_id: str = ""
question: str
question_type: str = ""
options: dict[str, str] = {}
correct_answer: str = ""
explanation: str = ""
difficulty: str = ""
user_answer: str = ""
user_answer_images: list[AnswerImageItem] = []
is_correct: bool = False
bookmarked: bool = False
followup_session_id: str = ""
ai_judgment: str = ""
created_at: float
updated_at: float
categories: list[CategoryItem] | None = None
class NotebookEntryListResponse(BaseModel):
items: list[NotebookEntryItem]
total: int
class EntryUpdateRequest(BaseModel):
bookmarked: bool | None = None
followup_session_id: str | None = None
ai_judgment: str | None = None
class CategoryItem(BaseModel):
id: int
name: str
created_at: float = 0
entry_count: int = 0
class CategoryCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class CategoryRenameRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class CategoryAddRequest(BaseModel):
category_id: int
class AnswerImageUpload(BaseModel):
"""One image attached to the learner's answer.
Either ``base64`` (new upload) or ``url`` (re-submit of an already
persisted image) must be set. ``id`` is preserved when the client
sends one so the same logical image keeps a stable AttachmentStore
record across resubmissions.
"""
id: str = ""
base64: str = ""
url: str = ""
filename: str = "answer.png"
mime_type: str = "image/png"
class UpsertEntryRequest(BaseModel):
session_id: str
turn_id: str = ""
question_id: str
question: str
question_type: str = ""
options: dict[str, str] | None = None
correct_answer: str = ""
explanation: str = ""
difficulty: str = ""
user_answer: str = ""
# Optional: list of images attached as part of the learner's answer.
# ``None`` means "don't touch any previously-stored images on update";
# an empty list explicitly clears them.
user_answer_images: list[AnswerImageUpload] | None = None
is_correct: bool = False
# ── Entry endpoints ──────────────────────────────────────────────
async def _persist_answer_images(
session_id: str, images: list[AnswerImageUpload] | None
) -> list[dict[str, str]] | None:
"""Materialise base64 image uploads into the AttachmentStore.
Returns a list of ``{id, url, filename, mime_type}`` records suitable
for ``notebook_entries.user_answer_images_json``. ``None`` is returned
when ``images`` is ``None`` (no change to existing stored images).
Records whose bytes fail to upload are dropped from the result with
a warning — losing an image is better than failing the whole upsert.
"""
if images is None:
return None
attachment_store = get_attachment_store()
records: list[dict[str, str]] = []
for image in images:
record_id = (image.id or _uuid.uuid4().hex[:12]).strip()
filename = (image.filename or "answer.png").strip() or "answer.png"
mime_type = (image.mime_type or "image/png").strip() or "image/png"
url = (image.url or "").strip()
if not url and image.base64:
try:
raw_bytes = _b64.b64decode(image.base64, validate=False)
except Exception as exc:
logger.warning("answer image %s rejected: invalid base64 (%s)", filename, exc)
continue
try:
url = await attachment_store.put(
session_id=session_id,
attachment_id=record_id,
filename=filename,
data=raw_bytes,
mime_type=mime_type,
)
except Exception as exc:
logger.warning("attachment store rejected answer image %s: %s", filename, exc)
continue
if not url:
# No url and no base64 — nothing usable.
continue
records.append(
{
"id": record_id,
"url": url,
"filename": filename,
"mime_type": mime_type,
}
)
return records
@router.post("/entries/upsert")
async def upsert_single_entry(payload: UpsertEntryRequest):
store = get_sqlite_session_store()
images_records = await _persist_answer_images(payload.session_id, payload.user_answer_images)
item = payload.model_dump()
# The store expects ``user_answer_images`` as a plain list of dicts
# (or absent to mean "leave the stored images alone"). Strip the
# upload payload version and replace with the persisted records.
item.pop("user_answer_images", None)
if images_records is not None:
item["user_answer_images"] = images_records
try:
await store.upsert_notebook_entries(payload.session_id, [item])
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
entry = await store.find_notebook_entry(
payload.session_id, payload.question_id, turn_id=payload.turn_id
)
if entry is None:
raise HTTPException(status_code=500, detail="Upsert failed")
return entry
@router.get("/entries", response_model=NotebookEntryListResponse)
async def list_entries(
category_id: int | None = Query(default=None),
bookmarked: bool | None = Query(default=None),
is_correct: bool | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> NotebookEntryListResponse:
store = get_sqlite_session_store()
result = await store.list_notebook_entries(
category_id=category_id,
bookmarked=bookmarked,
is_correct=is_correct,
limit=limit,
offset=offset,
)
return NotebookEntryListResponse(
items=[NotebookEntryItem(**item) for item in result["items"]],
total=result["total"],
)
@router.get("/entries/lookup/by-question")
async def lookup_entry(
session_id: str = Query(...),
question_id: str = Query(...),
turn_id: str | None = Query(default=None),
missing_ok: bool = Query(
default=False,
description="Return 204 No Content instead of 404 when the entry is "
"absent — used by the quiz viewer to probe not-yet-saved questions "
"without logging noisy 404s.",
),
):
store = get_sqlite_session_store()
entry = await store.find_notebook_entry(session_id, question_id, turn_id=turn_id)
if entry is None:
if missing_ok:
return Response(status_code=204)
raise HTTPException(status_code=404, detail="Entry not found")
return entry
@router.get("/entries/{entry_id}", response_model=NotebookEntryItem)
async def get_entry(entry_id: int) -> NotebookEntryItem:
store = get_sqlite_session_store()
entry = await store.get_notebook_entry(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
return NotebookEntryItem(**entry)
@router.patch("/entries/{entry_id}")
async def update_entry(entry_id: int, payload: EntryUpdateRequest):
store = get_sqlite_session_store()
updates = payload.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await store.update_notebook_entry(entry_id, updates)
if not updated:
raise HTTPException(status_code=404, detail="Entry not found")
return {"updated": True, "id": entry_id}
@router.delete("/entries/{entry_id}")
async def delete_entry(entry_id: int):
store = get_sqlite_session_store()
deleted = await store.delete_notebook_entry(entry_id)
if not deleted:
raise HTTPException(status_code=404, detail="Entry not found")
return {"deleted": True, "id": entry_id}
# ── Entry ↔ Category linking ────────────────────────────────────
@router.post("/entries/{entry_id}/categories")
async def add_entry_to_category(entry_id: int, payload: CategoryAddRequest):
store = get_sqlite_session_store()
entry = await store.get_notebook_entry(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
ok = await store.add_entry_to_category(entry_id, payload.category_id)
if not ok:
raise HTTPException(status_code=400, detail="Failed to add to category")
return {"added": True, "entry_id": entry_id, "category_id": payload.category_id}
@router.delete("/entries/{entry_id}/categories/{category_id}")
async def remove_entry_from_category(entry_id: int, category_id: int):
store = get_sqlite_session_store()
removed = await store.remove_entry_from_category(entry_id, category_id)
if not removed:
raise HTTPException(status_code=404, detail="Link not found")
return {"removed": True, "entry_id": entry_id, "category_id": category_id}
# ── Category CRUD ────────────────────────────────────────────────
@router.get("/categories", response_model=list[CategoryItem])
async def list_categories():
store = get_sqlite_session_store()
return await store.list_categories()
@router.post("/categories", response_model=CategoryItem, status_code=201)
async def create_category(payload: CategoryCreateRequest):
store = get_sqlite_session_store()
try:
return await store.create_category(payload.name)
except Exception:
raise HTTPException(status_code=409, detail="Category name already exists")
@router.patch("/categories/{category_id}")
async def rename_category(category_id: int, payload: CategoryRenameRequest):
store = get_sqlite_session_store()
updated = await store.rename_category(category_id, payload.name)
if not updated:
raise HTTPException(status_code=404, detail="Category not found")
return {"updated": True, "id": category_id, "name": payload.name}
@router.delete("/categories/{category_id}")
async def delete_category(category_id: int):
store = get_sqlite_session_store()
deleted = await store.delete_category(category_id)
if not deleted:
raise HTTPException(status_code=404, detail="Category not found")
return {"deleted": True, "id": category_id}