Spaces:
Runtime error
Runtime error
refactor: remove QuizAttempt model and related functionality across backend and frontend
Browse files- backend/alembic/versions/0027_quiz_attempts.py +0 -38
- backend/app/models/__init__.py +0 -2
- backend/app/models/content.py +0 -15
- backend/app/routers/artifacts.py +1 -32
- backend/app/services/artifact_feedback.py +1 -10
- frontend/src/api/endpoints.ts +0 -4
- frontend/src/api/types.ts +0 -4
- frontend/src/components/ArtifactFeedback.tsx +14 -2
- frontend/src/components/Quiz.tsx +8 -30
- frontend/src/styles/pages.css +25 -0
backend/alembic/versions/0027_quiz_attempts.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
"""quiz_attempts: a person's last finished run of a quiz, shown again when they reopen it
|
| 2 |
-
|
| 3 |
-
Revision ID: 0027_quiz_attempts
|
| 4 |
-
Revises: 0026_plan_copy
|
| 5 |
-
Create Date: 2026-10-08
|
| 6 |
-
"""
|
| 7 |
-
import sqlalchemy as sa
|
| 8 |
-
from alembic import op
|
| 9 |
-
|
| 10 |
-
revision = "0027_quiz_attempts"
|
| 11 |
-
down_revision = "0026_plan_copy"
|
| 12 |
-
branch_labels = None
|
| 13 |
-
depends_on = None
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def upgrade() -> None:
|
| 17 |
-
# 0001 builds from the live models, so a fresh database may already have the table.
|
| 18 |
-
if "quiz_attempts" in sa.inspect(op.get_bind()).get_table_names():
|
| 19 |
-
return
|
| 20 |
-
op.create_table(
|
| 21 |
-
"quiz_attempts",
|
| 22 |
-
sa.Column("id", sa.Uuid(), primary_key=True),
|
| 23 |
-
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 24 |
-
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 25 |
-
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
| 26 |
-
sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="CASCADE"), nullable=False),
|
| 27 |
-
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 28 |
-
sa.Column("score", sa.Integer(), nullable=False),
|
| 29 |
-
sa.Column("total", sa.Integer(), nullable=False),
|
| 30 |
-
sa.Column("answers", sa.JSON(), nullable=False),
|
| 31 |
-
sa.UniqueConstraint("artifact_id", "user_id"),
|
| 32 |
-
)
|
| 33 |
-
op.create_index("ix_quiz_attempts_workspace_id", "quiz_attempts", ["workspace_id"])
|
| 34 |
-
op.create_index("ix_quiz_attempts_artifact_id", "quiz_attempts", ["artifact_id"])
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def downgrade() -> None:
|
| 38 |
-
op.drop_table("quiz_attempts")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/models/__init__.py
CHANGED
|
@@ -18,7 +18,6 @@ from app.models.content import (
|
|
| 18 |
MessageFeedback,
|
| 19 |
Notebook,
|
| 20 |
NotebookDocument,
|
| 21 |
-
QuizAttempt,
|
| 22 |
ReportTemplateCache,
|
| 23 |
SocialAccount,
|
| 24 |
Source,
|
|
@@ -37,7 +36,6 @@ from app.models.support import SupportConversation, SupportMessage
|
|
| 37 |
from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
|
| 38 |
|
| 39 |
__all__ = [
|
| 40 |
-
"QuizAttempt",
|
| 41 |
"SupportConversation",
|
| 42 |
"SupportMessage",
|
| 43 |
"Artifact",
|
|
|
|
| 18 |
MessageFeedback,
|
| 19 |
Notebook,
|
| 20 |
NotebookDocument,
|
|
|
|
| 21 |
ReportTemplateCache,
|
| 22 |
SocialAccount,
|
| 23 |
Source,
|
|
|
|
| 36 |
from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
|
| 37 |
|
| 38 |
__all__ = [
|
|
|
|
| 39 |
"SupportConversation",
|
| 40 |
"SupportMessage",
|
| 41 |
"Artifact",
|
backend/app/models/content.py
CHANGED
|
@@ -173,21 +173,6 @@ class ArtifactFeedback(IdMixin, TimestampMixin, Base):
|
|
| 173 |
snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 174 |
|
| 175 |
|
| 176 |
-
class QuizAttempt(IdMixin, TimestampMixin, Base):
|
| 177 |
-
"""A person's last finished run of a quiz: what they answered and the score, so opening the quiz again shows the result.
|
| 178 |
-
One row per quiz and person; finishing it again replaces it."""
|
| 179 |
-
|
| 180 |
-
__tablename__ = "quiz_attempts"
|
| 181 |
-
__table_args__ = (UniqueConstraint("artifact_id", "user_id"),)
|
| 182 |
-
|
| 183 |
-
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 184 |
-
artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"), index=True)
|
| 185 |
-
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"))
|
| 186 |
-
score: Mapped[int] = mapped_column(Integer)
|
| 187 |
-
total: Mapped[int] = mapped_column(Integer)
|
| 188 |
-
answers: Mapped[list] = mapped_column(JSON, default=list) # per question: choice (indexes), text, correct
|
| 189 |
-
|
| 190 |
-
|
| 191 |
class ReportTemplateCache(IdMixin, TimestampMixin, Base):
|
| 192 |
"""The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
|
| 193 |
post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
|
|
|
|
| 173 |
snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
class ReportTemplateCache(IdMixin, TimestampMixin, Base):
|
| 177 |
"""The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
|
| 178 |
post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -11,7 +11,7 @@ from app.auth import Ctx, get_ctx
|
|
| 11 |
from app.config import settings
|
| 12 |
from app.infographics.image import ImageUnavailable, render_png
|
| 13 |
from app.infographics.themes import theme_id
|
| 14 |
-
from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook,
|
| 15 |
from app.routers.notebooks import ensure_archive_notebook
|
| 16 |
from app.services import artifact_feedback
|
| 17 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
|
@@ -345,37 +345,6 @@ def rate_artifact(artifact_id: uuid.UUID, body: FeedbackIn, ctx: Ctx = Depends(g
|
|
| 345 |
return {"feedback": artifact_feedback.feedback_out(row)}
|
| 346 |
|
| 347 |
|
| 348 |
-
class AttemptAnswer(BaseModel):
|
| 349 |
-
choice: list[int] = Field(default_factory=list, max_length=20)
|
| 350 |
-
text: str = Field("", max_length=2000)
|
| 351 |
-
correct: bool | None = None
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
class AttemptIn(BaseModel):
|
| 355 |
-
answers: list[AttemptAnswer] = Field(max_length=200)
|
| 356 |
-
score: int = Field(ge=0)
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
@router.put("/{artifact_id}/attempt")
|
| 360 |
-
def save_quiz_attempt(artifact_id: uuid.UUID, body: AttemptIn, ctx: Ctx = Depends(get_ctx)):
|
| 361 |
-
"""The person's finished run of a quiz, kept so the result shows when they open it again. Finishing again replaces it."""
|
| 362 |
-
a = get_artifact_or_404(ctx, artifact_id)
|
| 363 |
-
if a.type != "quiz" or a.status != "ready":
|
| 364 |
-
raise HTTPException(400, "Only a finished quiz keeps a result")
|
| 365 |
-
total = len((a.content_json or {}).get("questions") or [])
|
| 366 |
-
if len(body.answers) != total:
|
| 367 |
-
raise HTTPException(400, "The answers do not match the quiz")
|
| 368 |
-
row = ctx.db.scalar(select(QuizAttempt).where(QuizAttempt.artifact_id == a.id, QuizAttempt.user_id == ctx.user.id))
|
| 369 |
-
if not row:
|
| 370 |
-
row = QuizAttempt(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id)
|
| 371 |
-
ctx.db.add(row)
|
| 372 |
-
row.total = total
|
| 373 |
-
row.score = min(body.score, total)
|
| 374 |
-
row.answers = [x.model_dump() for x in body.answers]
|
| 375 |
-
ctx.db.commit()
|
| 376 |
-
return {"attempt": artifact_feedback.attempt_out(row)}
|
| 377 |
-
|
| 378 |
-
|
| 379 |
@router.get("/{artifact_id}/image")
|
| 380 |
def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "portrait"] = "landscape", ctx: Ctx = Depends(get_ctx)):
|
| 381 |
"""A finished infographic as a PNG, wide (the default) or tall, for its Download button."""
|
|
|
|
| 11 |
from app.config import settings
|
| 12 |
from app.infographics.image import ImageUnavailable, render_png
|
| 13 |
from app.infographics.themes import theme_id
|
| 14 |
+
from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
|
| 15 |
from app.routers.notebooks import ensure_archive_notebook
|
| 16 |
from app.services import artifact_feedback
|
| 17 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
|
|
|
| 345 |
return {"feedback": artifact_feedback.feedback_out(row)}
|
| 346 |
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
@router.get("/{artifact_id}/image")
|
| 349 |
def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "portrait"] = "landscape", ctx: Ctx = Depends(get_ctx)):
|
| 350 |
"""A finished infographic as a PNG, wide (the default) or tall, for its Download button."""
|
backend/app/services/artifact_feedback.py
CHANGED
|
@@ -6,7 +6,7 @@ import uuid
|
|
| 6 |
from sqlalchemy import select
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
|
| 9 |
-
from app.models import Artifact, ArtifactFeedback
|
| 10 |
|
| 11 |
# What each kind can be marked down for (the ids the app sends; anything else is dropped).
|
| 12 |
REASONS: dict[str, set[str]] = {
|
|
@@ -24,23 +24,14 @@ def feedback_out(f: ArtifactFeedback | None) -> dict | None:
|
|
| 24 |
return {"rating": "up" if f.rating > 0 else "down", "reasons": f.reasons or [], "comment": f.comment}
|
| 25 |
|
| 26 |
|
| 27 |
-
def attempt_out(a: QuizAttempt | None) -> dict | None:
|
| 28 |
-
return {"score": a.score, "total": a.total, "answers": a.answers or []} if a else None
|
| 29 |
-
|
| 30 |
-
|
| 31 |
def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]:
|
| 32 |
"""Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query."""
|
| 33 |
ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE]
|
| 34 |
rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where(
|
| 35 |
ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {}
|
| 36 |
-
quiz_ids = [uuid.UUID(i["id"]) for i in items if i.get("type") == "quiz"]
|
| 37 |
-
attempts = {a.artifact_id: a for a in db.scalars(select(QuizAttempt).where(
|
| 38 |
-
QuizAttempt.artifact_id.in_(quiz_ids), QuizAttempt.user_id == user_id))} if quiz_ids else {}
|
| 39 |
for i in items:
|
| 40 |
if i.get("type") in RATEABLE:
|
| 41 |
i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"])))
|
| 42 |
-
if i.get("type") == "quiz":
|
| 43 |
-
i["attempt"] = attempt_out(attempts.get(uuid.UUID(i["id"])))
|
| 44 |
return items
|
| 45 |
|
| 46 |
|
|
|
|
| 6 |
from sqlalchemy import select
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
|
| 9 |
+
from app.models import Artifact, ArtifactFeedback
|
| 10 |
|
| 11 |
# What each kind can be marked down for (the ids the app sends; anything else is dropped).
|
| 12 |
REASONS: dict[str, set[str]] = {
|
|
|
|
| 24 |
return {"rating": "up" if f.rating > 0 else "down", "reasons": f.reasons or [], "comment": f.comment}
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]:
|
| 28 |
"""Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query."""
|
| 29 |
ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE]
|
| 30 |
rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where(
|
| 31 |
ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {}
|
|
|
|
|
|
|
|
|
|
| 32 |
for i in items:
|
| 33 |
if i.get("type") in RATEABLE:
|
| 34 |
i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"])))
|
|
|
|
|
|
|
| 35 |
return items
|
| 36 |
|
| 37 |
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
|
| 2 |
import type {
|
| 3 |
AnswerFeedback,
|
| 4 |
-
QuizAttempt,
|
| 5 |
Artifact,
|
| 6 |
ArtifactType,
|
| 7 |
BillingStatus,
|
|
@@ -161,9 +160,6 @@ export const artifactsApi = {
|
|
| 161 |
/** Thumbs up or down (null takes it back) on a finished report, quiz, flashcard set or infographic. */
|
| 162 |
rate: (id: string, body: { rating: "up" | "down" | null; reasons?: string[]; comment?: string }) =>
|
| 163 |
put<{ feedback: AnswerFeedback | null }>(`/api/artifacts/${id}/feedback`, body),
|
| 164 |
-
/** A quiz's finished run, kept so the result shows when it is opened again. */
|
| 165 |
-
saveAttempt: (id: string, body: { answers: QuizAttempt["answers"]; score: number }) =>
|
| 166 |
-
put<{ attempt: QuizAttempt }>(`/api/artifacts/${id}/attempt`, body),
|
| 167 |
retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
|
| 168 |
/** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
|
| 169 |
slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
|
|
|
|
| 1 |
import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
|
| 2 |
import type {
|
| 3 |
AnswerFeedback,
|
|
|
|
| 4 |
Artifact,
|
| 5 |
ArtifactType,
|
| 6 |
BillingStatus,
|
|
|
|
| 160 |
/** Thumbs up or down (null takes it back) on a finished report, quiz, flashcard set or infographic. */
|
| 161 |
rate: (id: string, body: { rating: "up" | "down" | null; reasons?: string[]; comment?: string }) =>
|
| 162 |
put<{ feedback: AnswerFeedback | null }>(`/api/artifacts/${id}/feedback`, body),
|
|
|
|
|
|
|
|
|
|
| 163 |
retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
|
| 164 |
/** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
|
| 165 |
slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
|
frontend/src/api/types.ts
CHANGED
|
@@ -190,12 +190,8 @@ export type Artifact = {
|
|
| 190 |
provider?: string | null;
|
| 191 |
/** This person's thumbs up or down on a finished report, quiz, flashcard set or infographic. */
|
| 192 |
feedback?: AnswerFeedback | null;
|
| 193 |
-
/** A quiz: this person's last finished run (what they answered, the score), shown again when it is reopened. */
|
| 194 |
-
attempt?: QuizAttempt | null;
|
| 195 |
};
|
| 196 |
|
| 197 |
-
export type QuizAttempt = { score: number; total: number; answers: { choice: number[]; text: string; correct: boolean | null }[] };
|
| 198 |
-
|
| 199 |
export type TopicStatus = "rising" | "steady" | "dormant";
|
| 200 |
|
| 201 |
export type TopicNode = {
|
|
|
|
| 190 |
provider?: string | null;
|
| 191 |
/** This person's thumbs up or down on a finished report, quiz, flashcard set or infographic. */
|
| 192 |
feedback?: AnswerFeedback | null;
|
|
|
|
|
|
|
| 193 |
};
|
| 194 |
|
|
|
|
|
|
|
| 195 |
export type TopicStatus = "rising" | "steady" | "dormant";
|
| 196 |
|
| 197 |
export type TopicNode = {
|
frontend/src/components/ArtifactFeedback.tsx
CHANGED
|
@@ -62,8 +62,8 @@ export function ArtifactFeedback({ artifact, label, compact = false }: { artifac
|
|
| 62 |
const initial = artifact.feedback ?? null;
|
| 63 |
const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial);
|
| 64 |
const [open, setOpen] = useState(false);
|
| 65 |
-
const [reasons, setReasons] = useState<string[]>(
|
| 66 |
-
const [comment, setComment] = useState(
|
| 67 |
const [error, setError] = useState<string | null>(null);
|
| 68 |
const [thanks, setThanks] = useState(false);
|
| 69 |
const options = REASONS[artifact.type] ?? [];
|
|
@@ -116,6 +116,18 @@ export function ArtifactFeedback({ artifact, label, compact = false }: { artifac
|
|
| 116 |
{thanks && <span className="mono muted small">Thanks, saved.</span>}
|
| 117 |
{error && <span className="error-text small">Could not save that. Try again.</span>}
|
| 118 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
{open && fb && (
|
| 120 |
<div className="fb-why afb-why" ref={whyRef}>
|
| 121 |
<p className="mono muted small">{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}</p>
|
|
|
|
| 62 |
const initial = artifact.feedback ?? null;
|
| 63 |
const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial);
|
| 64 |
const [open, setOpen] = useState(false);
|
| 65 |
+
const [reasons, setReasons] = useState<string[]>(fb?.reasons ?? []);
|
| 66 |
+
const [comment, setComment] = useState(fb?.comment ?? "");
|
| 67 |
const [error, setError] = useState<string | null>(null);
|
| 68 |
const [thanks, setThanks] = useState(false);
|
| 69 |
const options = REASONS[artifact.type] ?? [];
|
|
|
|
| 116 |
{thanks && <span className="mono muted small">Thanks, saved.</span>}
|
| 117 |
{error && <span className="error-text small">Could not save that. Try again.</span>}
|
| 118 |
</div>
|
| 119 |
+
{!open && fb && (fb.comment || fb.reasons.length > 0) && (
|
| 120 |
+
<div className="afb-saved">
|
| 121 |
+
<span className="mono muted small">Your review</span>
|
| 122 |
+
{fb.reasons.length > 0 && (
|
| 123 |
+
<div className="fb-chips">
|
| 124 |
+
{fb.reasons.map((id) => <span key={id} className="fb-chip is-on">{options.find((o) => o.id === id)?.label ?? id}</span>)}
|
| 125 |
+
</div>
|
| 126 |
+
)}
|
| 127 |
+
{fb.comment && <p className="afb-saved-text">{fb.comment}</p>}
|
| 128 |
+
<button type="button" className="link-btn small" onClick={() => { setReasons(fb.reasons); setComment(fb.comment ?? ""); setOpen(true); }}>Edit</button>
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
{open && fb && (
|
| 132 |
<div className="fb-why afb-why" ref={whyRef}>
|
| 133 |
<p className="mono muted small">{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}</p>
|
frontend/src/components/Quiz.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useMemo, useState, type ReactNode } from "react";
|
| 2 |
-
import
|
| 3 |
-
import type { Artifact, ChatSummary,
|
| 4 |
import { ArtifactFeedback } from "./ArtifactFeedback";
|
| 5 |
import { CheckIcon, ChevronIcon } from "./icons/Icons";
|
| 6 |
import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
|
|
@@ -188,21 +188,11 @@ export function ResultScene({ score, total, tone }: { score: number; total: numb
|
|
| 188 |
}
|
| 189 |
|
| 190 |
/** The quiz itself, without a frame: in a modal (QuizPlayer) or inside a report. `onExit` adds a Close button. */
|
| 191 |
-
export function QuizRunner({ questions, onExit, feedback
|
| 192 |
-
questions: QuizQuestion[];
|
| 193 |
-
onExit?: () => void;
|
| 194 |
-
feedback?: ReactNode;
|
| 195 |
-
/** A finished run kept earlier: opening the quiz again starts on its result. */
|
| 196 |
-
saved?: QuizAttempt | null;
|
| 197 |
-
/** Called with the answers when the last question is done, so the result can be kept. */
|
| 198 |
-
onFinish?: (answers: QuizAttempt["answers"], score: number) => void;
|
| 199 |
-
}) {
|
| 200 |
-
const restored = saved && saved.answers.length === questions.length ? saved : null;
|
| 201 |
const [i, setI] = useState(0);
|
| 202 |
-
const [answers, setAnswers] = useState<Answer[]>(() =>
|
| 203 |
-
|
| 204 |
-
const [
|
| 205 |
-
const [finished, setFinished] = useState(!!restored); // the results have been reached: a question opened from them leads back to them
|
| 206 |
const [detail, setDetail] = useState(false); // the per-question results, under the score
|
| 207 |
|
| 208 |
const q = questions[i];
|
|
@@ -279,7 +269,6 @@ export function QuizRunner({ questions, onExit, feedback, saved, onFinish }: {
|
|
| 279 |
if (!last) return setI(i + 1);
|
| 280 |
setFinished(true);
|
| 281 |
setDone(true);
|
| 282 |
-
onFinish?.(answers.map((x) => ({ choice: x.choice, text: x.text, correct: x.correct })), score);
|
| 283 |
};
|
| 284 |
const pick = (k: number) => {
|
| 285 |
if (a.checked) return;
|
|
@@ -372,20 +361,11 @@ export function QuizRunner({ questions, onExit, feedback, saved, onFinish }: {
|
|
| 372 |
);
|
| 373 |
}
|
| 374 |
|
| 375 |
-
// A finished run is shown on every open of the quiz, also before the list is fetched again, so the latest copy is kept here.
|
| 376 |
-
const attempts = new Map<string, QuizAttempt>();
|
| 377 |
-
|
| 378 |
export function QuizPlayer({ artifact, onClose }: { artifact: Artifact; onClose: () => void }) {
|
| 379 |
const questions = useMemo(() => (artifact.content.questions ?? []) as QuizQuestion[], [artifact]);
|
| 380 |
-
const saved = attempts.get(artifact.id) ?? artifact.attempt ?? null;
|
| 381 |
-
const keep = (answers: QuizAttempt["answers"], score: number) => {
|
| 382 |
-
attempts.set(artifact.id, { score, total: questions.length, answers });
|
| 383 |
-
artifactsApi.saveAttempt(artifact.id, { answers, score }).catch(() => undefined); // the result is also kept here for this visit
|
| 384 |
-
};
|
| 385 |
return (
|
| 386 |
<StudyOverlay title={artifact.title} onClose={onClose}>
|
| 387 |
-
<QuizRunner questions={questions} onExit={onClose}
|
| 388 |
-
feedback={<ArtifactFeedback artifact={artifact} label="How was this quiz?" />} />
|
| 389 |
</StudyOverlay>
|
| 390 |
);
|
| 391 |
}
|
|
@@ -395,12 +375,10 @@ export function QuizBody({ artifact }: { artifact: Artifact }) {
|
|
| 395 |
const [playing, setPlaying] = useState(false);
|
| 396 |
const c = artifact.content;
|
| 397 |
const from = sourceSummary(c.source);
|
| 398 |
-
const last = attempts.get(artifact.id) ?? artifact.attempt ?? null; // read again whenever the player closes (the state change re-renders)
|
| 399 |
return (
|
| 400 |
<div className="artifact-body">
|
| 401 |
<p className="muted">{c.question_count ?? 0} questions 路 {c.difficulty ?? "medium"} 路 from {from}</p>
|
| 402 |
-
|
| 403 |
-
<button type="button" className="btn btn-small" onClick={() => setPlaying(true)}>{last ? "View result" : "Start quiz"}</button>
|
| 404 |
{playing && <QuizPlayer artifact={artifact} onClose={() => setPlaying(false)} />}
|
| 405 |
</div>
|
| 406 |
);
|
|
|
|
| 1 |
import { useMemo, useState, type ReactNode } from "react";
|
| 2 |
+
import type { GenerateBody } from "../api/endpoints";
|
| 3 |
+
import type { Artifact, ChatSummary, QuizQuestion, QuizQuestionType } from "../api/types";
|
| 4 |
import { ArtifactFeedback } from "./ArtifactFeedback";
|
| 5 |
import { CheckIcon, ChevronIcon } from "./icons/Icons";
|
| 6 |
import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
/** The quiz itself, without a frame: in a modal (QuizPlayer) or inside a report. `onExit` adds a Close button. */
|
| 191 |
+
export function QuizRunner({ questions, onExit, feedback }: { questions: QuizQuestion[]; onExit?: () => void; feedback?: ReactNode }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
const [i, setI] = useState(0);
|
| 193 |
+
const [answers, setAnswers] = useState<Answer[]>(() => questions.map(blank));
|
| 194 |
+
const [done, setDone] = useState(false);
|
| 195 |
+
const [finished, setFinished] = useState(false); // the results have been reached: a question opened from them leads back to them
|
|
|
|
| 196 |
const [detail, setDetail] = useState(false); // the per-question results, under the score
|
| 197 |
|
| 198 |
const q = questions[i];
|
|
|
|
| 269 |
if (!last) return setI(i + 1);
|
| 270 |
setFinished(true);
|
| 271 |
setDone(true);
|
|
|
|
| 272 |
};
|
| 273 |
const pick = (k: number) => {
|
| 274 |
if (a.checked) return;
|
|
|
|
| 361 |
);
|
| 362 |
}
|
| 363 |
|
|
|
|
|
|
|
|
|
|
| 364 |
export function QuizPlayer({ artifact, onClose }: { artifact: Artifact; onClose: () => void }) {
|
| 365 |
const questions = useMemo(() => (artifact.content.questions ?? []) as QuizQuestion[], [artifact]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
return (
|
| 367 |
<StudyOverlay title={artifact.title} onClose={onClose}>
|
| 368 |
+
<QuizRunner questions={questions} onExit={onClose} feedback={<ArtifactFeedback artifact={artifact} label="How was this quiz?" />} />
|
|
|
|
| 369 |
</StudyOverlay>
|
| 370 |
);
|
| 371 |
}
|
|
|
|
| 375 |
const [playing, setPlaying] = useState(false);
|
| 376 |
const c = artifact.content;
|
| 377 |
const from = sourceSummary(c.source);
|
|
|
|
| 378 |
return (
|
| 379 |
<div className="artifact-body">
|
| 380 |
<p className="muted">{c.question_count ?? 0} questions 路 {c.difficulty ?? "medium"} 路 from {from}</p>
|
| 381 |
+
<button type="button" className="btn btn-small" onClick={() => setPlaying(true)}>Start quiz</button>
|
|
|
|
| 382 |
{playing && <QuizPlayer artifact={artifact} onClose={() => setPlaying(false)} />}
|
| 383 |
</div>
|
| 384 |
);
|
frontend/src/styles/pages.css
CHANGED
|
@@ -9928,3 +9928,28 @@ body.nbv-resizing iframe {
|
|
| 9928 |
.modal.modal-wide.so-modal {
|
| 9929 |
height: min(760px, calc(100vh - 32px));
|
| 9930 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9928 |
.modal.modal-wide.so-modal {
|
| 9929 |
height: min(760px, calc(100vh - 32px));
|
| 9930 |
}
|
| 9931 |
+
|
| 9932 |
+
/* The review a person already gave, shown again under the thumbs when the quiz, cards, report or infographic is reopened */
|
| 9933 |
+
.afb-saved {
|
| 9934 |
+
display: grid;
|
| 9935 |
+
gap: 6px;
|
| 9936 |
+
justify-items: start;
|
| 9937 |
+
margin-top: 8px;
|
| 9938 |
+
padding: 10px 12px;
|
| 9939 |
+
border: 1px solid var(--line);
|
| 9940 |
+
border-radius: var(--radius-sm);
|
| 9941 |
+
background: var(--surface);
|
| 9942 |
+
}
|
| 9943 |
+
|
| 9944 |
+
.afb-saved .fb-chip {
|
| 9945 |
+
cursor: default;
|
| 9946 |
+
}
|
| 9947 |
+
|
| 9948 |
+
.afb-saved-text {
|
| 9949 |
+
margin: 0;
|
| 9950 |
+
color: var(--text-2);
|
| 9951 |
+
font-size: 0.88rem;
|
| 9952 |
+
line-height: 1.45;
|
| 9953 |
+
overflow-wrap: anywhere;
|
| 9954 |
+
white-space: pre-wrap;
|
| 9955 |
+
}
|