diff --git a/README.md b/README.md index c4246d5..a3f9530 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,18 @@ included — and the box locks. Time remaining at commit is recorded, because a polished answer submitted with most of the clock unspent was not composed in the box. -**Paste is disabled** in the answer field (`paste` and `drop` are both blocked — -drag-and-drop text would otherwise walk straight past a paste-only guard). +**Paste is detected, not blocked.** Blocking it only teaches a candidate to retype +what they pasted; recording it tells us which answer to interrogate. React fires one +change event per input, so ordinary typing arrives as a stream of single-character +deltas — a single event that adds a whole paragraph did not come from a keyboard. +That answer is silently marked. Nothing is prevented and no warning is shown, so the +paste appears to have worked. **One adaptive follow-up, before grading.** Once answers are in, the answer least -likely to have been typed by its author is selected — weighting typing rate by -length, so a suspiciously fast essay outranks a fast one-liner — and a single +likely to have been typed by its author is selected. A recorded paste wins outright, +since it is evidence rather than inference, with ties going to the largest single +injection; failing that, typing rate weighted by length, so a suspiciously fast essay +outranks a fast one-liner. A single follow-up is generated that quotes that answer's specific wording back and pushes on it. Same clock, same no-paste rule. Grading happens only after this round, so a candidate cannot bank a score and abandon the round they cannot pass. @@ -65,12 +71,15 @@ Measured on `psf/requests`, same repo and same time budget: | Pasted AI answer | committed with 63s of 75s left, could not defend its wording | **0/100** | | Genuine author | typed distinct answers, defended the follow-up | **100/100** | -**What this does not do.** The timer and the paste block are client-side. They +**What this does not do.** The timer and the paste detector are client-side. They raise the cost of casual cheating; they do not stop anyone willing to call the API -directly with a forged `seconds_left`. The follow-up round is the measure that -actually holds, because it demands understanding at response time regardless of how -the request was made. Server-issued timestamps at generation, with elapsed time -computed server-side, are the real fix and are not built yet. +directly with a forged `seconds_left` and `flagged_paste: false`. Detection also has +a seam of its own: pasting after a pause long enough to look like thinking clears the +timing guard, and dictation software can legitimately commit a long phrase in one +event. The follow-up round is the measure that actually holds, because it demands +understanding at response time regardless of how the request was made. Server-issued +timestamps at generation, with elapsed time computed server-side, are the real fix and +are not built yet. ### Known limitation: comprehension is not difficulty diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index a21fa09..b85b7a2 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -39,6 +39,10 @@ class QuizAnswer(BaseModel): # Countdown remaining when the answer was committed. A long answer submitted with # most of the clock still left is the signal that it was not typed from scratch. seconds_left: Optional[float] = None + # Client-observed paste signal: a single input event that added a paragraph. + # Recorded silently — the candidate is never told it was noticed. + flagged_paste: bool = False + paste_delta: int = 0 # largest flagged single-event delta, used to rank flags class QuizSubmitRequest(BaseModel): diff --git a/backend/app/services/quiz_service.py b/backend/app/services/quiz_service.py index ab2548e..cbd67b7 100644 --- a/backend/app/services/quiz_service.py +++ b/backend/app/services/quiz_service.py @@ -42,10 +42,14 @@ def pick_suspect_answer(answers: list[dict], time_limit: int = TIME_LIMIT_SECOND """ Choose the answer least likely to have been typed by its author. - Typing pace is the signal. A long, polished answer committed with most of the - clock still unspent was not composed in the box — nobody writes 900 considered - characters in twelve seconds. Weighting rate by length keeps a fast one-liner - from outranking a suspiciously fast essay. + A recorded paste wins outright: the client saw a whole paragraph arrive in one + input event, which is evidence rather than inference. Ties among flagged answers + go to the largest single injection. + + Otherwise typing pace is the signal. A long, polished answer committed with most + of the clock still unspent was not composed in the box — nobody writes 900 + considered characters in twelve seconds. Weighting rate by length keeps a fast + one-liner from outranking a suspiciously fast essay. Falls back to the first non-empty answer, then to the first answer, so the follow-up round always happens even with no timing data at all. @@ -54,6 +58,12 @@ def pick_suspect_answer(answers: list[dict], time_limit: int = TIME_LIMIT_SECOND if not answered: return answers[0] if answers else None + # A recorded paste outranks every timing heuristic — it is direct evidence rather + # than an inference. Among several, push on the largest single injection. + pasted = [a for a in answered if a.get("flagged_paste")] + if pasted: + return max(pasted, key=lambda a: a.get("paste_delta") or 0) + timed = [a for a in answered if a.get("seconds_left") is not None] if not timed: return answered[0] diff --git a/frontend/src/features/quiz/QuizPage.jsx b/frontend/src/features/quiz/QuizPage.jsx index 0b3fb9e..1518e15 100644 --- a/frontend/src/features/quiz/QuizPage.jsx +++ b/frontend/src/features/quiz/QuizPage.jsx @@ -17,6 +17,9 @@ export default function QuizPage() { // Live countdown per question, kept in a ref so ticking never re-renders the page. const timeLeft = useRef({}); + // Paste signals per question. Also a ref — recording must stay invisible, and + // re-rendering on it would risk leaking that something was noticed. + const inputSignal = useRef({}); // Guards against the auto-submit firing twice (expiry racing a manual click). const sent = useRef({ answers: false, followup: false }); @@ -34,6 +37,7 @@ export default function QuizPage() { setFollowupAnswer(""); setExpired(new Set()); timeLeft.current = {}; + inputSignal.current = {}; sent.current = { answers: false, followup: false }; try { const data = await generateQuiz(repoUrl); @@ -57,6 +61,8 @@ export default function QuizPage() { question_id: q.id, answer: answers[q.id] || "", seconds_left: timeLeft.current[q.id] ?? null, + flagged_paste: inputSignal.current[q.id]?.flagged_paste ?? false, + paste_delta: inputSignal.current[q.id]?.paste_delta ?? 0, })); setFollowup(await submitQuiz(quiz.quiz_id, payload)); } catch (e) { @@ -109,8 +115,7 @@ export default function QuizPage() { {quiz && !followup && !result && (

- {limit}s per question · typing only, paste is disabled · answers lock when the - timer runs out + {limit}s per question · answers lock when the timer runs out

{quiz.questions.map((q) => ( (timeLeft.current[id] = s)} onExpire={markExpired} + onInputSignal={(id, sig) => (inputSignal.current[id] = sig)} onAnswerChange={(id, val) => setAnswers((prev) => ({ ...prev, [id]: val }))} /> ))} diff --git a/frontend/src/features/quiz/components/QuestionCard.jsx b/frontend/src/features/quiz/components/QuestionCard.jsx index de932ad..43ba2bb 100644 --- a/frontend/src/features/quiz/components/QuestionCard.jsx +++ b/frontend/src/features/quiz/components/QuestionCard.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { createInputTracker } from "../pasteDetect"; /** * One timed question. @@ -8,9 +9,10 @@ import { useEffect, useRef, useState } from "react"; * having its timers throttled. On expiry the answer is committed as-is — blank * included — and the box locks. * - * Paste is blocked so the answer has to be typed. This is a speed bump, not a - * security control: it stops the casual paste from another tab, and the real - * signal is the typing pace reported via onTick. + * Paste is deliberately NOT blocked. It is recorded instead: a single change event + * that adds a paragraph is reported upward as flagged_paste. The candidate sees no + * warning and nothing is prevented, so a paste still looks to them like it worked. + * The flag decides which answer gets pushed on in the follow-up round. */ export default function QuestionCard({ question, @@ -18,9 +20,12 @@ export default function QuestionCard({ onAnswerChange, onExpire, onTick, + onInputSignal, timeLimit = 75, }) { const [left, setLeft] = useState(timeLimit); + const tracker = useRef(null); + if (tracker.current === null) tracker.current = createInputTracker(); const cbs = useRef({ onExpire, onTick }); cbs.current = { onExpire, onTick }; @@ -45,12 +50,17 @@ export default function QuestionCard({ return () => clearInterval(handle); }, [question.id, timeLimit]); + function handleChange(e) { + const next = e.target.value; + tracker.current.record(answer || "", next, Date.now()); + onInputSignal?.(question.id, tracker.current.snapshot()); + onAnswerChange(question.id, next); + } + const locked = left === 0; const mins = Math.floor(left / 60); const secs = String(left % 60).padStart(2, "0"); - const block = (e) => e.preventDefault(); - return (

@@ -67,11 +77,9 @@ export default function QuestionCard({