File size: 16,484 Bytes
7563305
 
 
 
 
 
 
 
 
22402d5
 
7563305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02c841f
7563305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6505c2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7563305
6505c2d
 
 
 
 
0a7459e
 
6505c2d
a0b0535
0a7459e
 
 
7563305
 
 
 
 
 
 
 
 
 
22402d5
 
 
 
7563305
 
22402d5
 
 
 
 
 
 
 
 
 
 
7563305
 
22402d5
 
7563305
 
ae15cb7
 
 
 
 
7563305
 
 
 
 
 
 
22402d5
7563305
 
22402d5
 
7563305
 
 
22402d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7563305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02c841f
 
 
 
 
 
 
 
 
7563305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76f2051
 
 
7563305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""
Recall — Module B: Learning Engine.  OWNER: Nikolai

The brain: scheduling (SM-2-lite), grading, adaptation, follow-up generation,
and the recap. Runs in STUB mode out of the box. Public signatures are fixed —
app.py depends on them.
"""
from __future__ import annotations

import re

import llm
from schema import (
    Card, GradeResult, Session, new_card, new_card_state, new_grade, validate_card,
)

# STUB is owned by llm (single source of truth) and read dynamically as
# `llm.STUB` so every module agrees and runtime/reload changes are honored.


# ---- Session lifecycle -----------------------------------------------------

def init_session(deck: list[Card]) -> Session:
    states = {c["id"]: new_card_state(c["id"]) for c in deck}
    return Session(
        deck=list(deck),
        states=states,
        queue=[c["id"] for c in deck],
        history=[],
        streak=0,
    )


WEAK_TOPIC_THRESHOLD = 3.0   # avg grade below this = a topic the user is weak on
WEAK_LOOKAHEAD = 4           # how far down the queue we'll reach to surface a weak card
GRADUATE_AT_CORRECT = 2      # correct answers needed before a card leaves the queue


def next_card(session: Session) -> Card | None:
    """
    Return the next card to study. Among the next few due cards we bias toward
    the user's weakest topic (lowest average grade so far) — so once the model
    sees you're shaky on a topic, that topic comes back sooner. With no history
    yet this is a no-op and we serve the queue in order.

    The chosen card is rotated to the front of the queue so `apply_result`'s
    "pop the front" contract still holds.
    """
    queue = session["queue"]
    if not queue:
        return None

    idx = _weak_biased_index(session)
    if idx > 0:
        queue.insert(0, queue.pop(idx))   # bring the weak-topic card to the front
    return _find(session, queue[0])


# ---- Grading ---------------------------------------------------------------

# Explicit non-answers ("idk", "don't know", "no idea", …), normalized to bare
# words. An answer that IS one of these — or is empty once normalized ("?", "...")
# — is a miss we score ourselves: the small grader otherwise ignores such input
# and "grades" the reference answer instead, hallucinating a 4/5 "correct".
_NON_ANSWER_PHRASES = {
    "idk", "dk", "dunno", "i dunno",
    "i dont know", "dont know", "don know", "i don know",
    "do not know", "i do not know", "no idea", "no clue",
    "not sure", "im not sure", "i am not sure",
    "no answer", "nothing", "none", "no", "na", "n a", "skip", "pass",
}


def _is_non_answer(user_answer: str) -> bool:
    """True if the input carries no real attempt — blank, punctuation-only, or a
    stock 'I don't know' phrase (apostrophes/typos tolerated)."""
    norm = re.sub(r"[^a-z0-9 ]", "", (user_answer or "").lower().replace("'", ""))
    norm = re.sub(r"\s+", " ", norm).strip()
    return not norm or norm in _NON_ANSWER_PHRASES


def grade_answer(card: Card, user_answer: str) -> GradeResult:
    # A non-answer (blank, "?", "idk", "I don't know", …) is unambiguously a miss
    # — score it ourselves before any model call. (The grader otherwise ignores
    # such input and "grades" the reference answer itself, hallucinating a 4/5
    # "correct".) Also saves a call.
    if _is_non_answer(user_answer):
        return new_grade(
            0,
            f"No worries — that's an honest \"I don't know.\" Take a look at the "
            f"reference answer and give it another go: {card['answer']}",
            card["topic"],
        )

    if llm.STUB:
        # Trivial heuristic so the stub demo "feels" responsive.
        ans = (user_answer or "").strip().lower()
        ref = card["answer"].strip().lower()
        overlap = len(set(ans.split()) & set(ref.split()))
        score = 5 if overlap >= 2 else (3 if overlap == 1 else 1)
        expl = ("Correct — you hit the key idea." if score >= 3
                else f"Not quite. Expected something like: {card['answer']}")
        return new_grade(score, expl, missed_concept=card["topic"])

    # Tone instruction is kept short and the strict "ONLY JSON" requirement is
    # reasserted as the LAST thing the model reads (in the user turn) — a verbose
    # "write warmly" preamble was nudging this small model into prose that didn't
    # parse, and recency improves format compliance.
    messages = [
        {"role": "system", "content":
         "You grade a student's answer against a reference answer.\n"
         "Scoring (be strict): 0-1 = wrong / names the wrong thing; 2 = partially "
         "relevant but misses the key idea; 3 = mostly correct, minor gap; "
         "4-5 = correct and complete. A factually wrong answer is 0-2, never 3.\n"
         "Write the feedback warmly, speaking to the learner as \"you\" (never "
         "\"the student\"): note what's right, then what's missing.\n"
         "JSON keys: score (0-5 int), explanation (spoken to \"you\"), "
         "missed_concept (what was wrong, or \"\").\n"
         "Example: {\"score\": 1, \"explanation\": \"Good instinct, but that's the "
         "wrong spot — the Calvin cycle runs in the stroma. Tie it to where the "
         "enzymes sit.\", \"missed_concept\": \"the specific location\"}"},
        {"role": "user", "content":
         f"Question: {card['question']}\nReference answer: {card['answer']}\n"
         f"Student answer: {user_answer}\n\n"
         "Grade it. Reply with ONLY the JSON object — no prose, no markdown fences."},
    ]
    # Parser + one repair retry; safe default if the model never returns JSON.
    # A generous 2048-token budget so even a long reasoning preamble can't push
    # the JSON object past the cutoff — truncated grades were the main
    # parse-failure source, and the grade JSON itself is tiny so the headroom is
    # nearly free (generation stops at the closing brace, not the limit).
    data = llm.chat_json(messages, max_tokens=2048)
    if not _valid_grade(data):
        return new_grade(
            2,
            "Couldn't grade automatically — compare your answer to the "
            f"reference: {card['answer']}",
            card["topic"],
        )
    explanation = _to_second_person(str(data.get("explanation", "")).strip())
    return new_grade(
        int(data["score"]),
        explanation or f"Reference answer: {card['answer']}",
        _to_second_person(str(data.get("missed_concept") or card["topic"]).strip()),
    )


# This small model still slips into the third person ("The student's answer…")
# perhaps half the time despite the prompt. These swaps are the grammatically
# SAFE ones — possessives only — so we never mangle subject-verb agreement (we
# leave "The student identifies…" alone rather than produce "You identifies…").
_SECOND_PERSON_SUBS = [
    (re.compile(r"\bthe student'?s answer\b", re.I), "your answer"),
    (re.compile(r"\bthe student'?s response\b", re.I), "your answer"),
    (re.compile(r"\bthe student'?s\b", re.I), "your"),
]


def _to_second_person(text: str) -> str:
    """Rewrite clinical third-person possessives to warm second person, matching
    the original capitalization ('The student's answer' -> 'Your answer')."""
    for pat, repl in _SECOND_PERSON_SUBS:
        text = pat.sub(
            lambda m, r=repl: r.capitalize() if m.group(0)[:1].isupper() else r,
            text,
        )
    return text


def _valid_grade(data) -> bool:
    """A grade is usable only if it carries a numeric, in-range score."""
    if not isinstance(data, dict) or "score" not in data:
        return False
    try:
        return 0 <= int(data["score"]) <= 5
    except (TypeError, ValueError):
        return False


# ---- Adaptation: SM-2-lite -------------------------------------------------

def apply_result(session: Session, card: Card, grade: GradeResult,
                 user_answer: str = "") -> Session:
    st = session["states"][card["id"]]
    st["reps"] += 1
    st["last_grade"] = grade["score"]

    # remove this card from the front of the queue
    if session["queue"] and session["queue"][0] == card["id"]:
        session["queue"].pop(0)

    if grade["correct"]:
        st["ease"] = min(3.0, st["ease"] + 0.1)
        st["interval"] = max(2, int(st["interval"] * st["ease"]))
        session["streak"] += 1
        # Graduate once the card has been answered correctly GRADUATE_AT_CORRECT
        # times — only then does it leave the queue for good. (reps counts every
        # answer, lapses counts the misses, so reps - lapses = correct answers.)
        # Re-enqueuing a card *every* time it was right is what made the queue
        # never drain: the session never ended and the same cards came back with
        # no forward progress. A still-learning card comes back later as before.
        corrects = st["reps"] - st["lapses"]
        if corrects < GRADUATE_AT_CORRECT:
            _insert_at(session, card["id"], st["interval"])  # comes back later
    else:
        st["lapses"] += 1
        st["ease"] = max(1.3, st["ease"] - 0.2)
        st["interval"] = 1
        session["streak"] = 0
        _insert_at(session, card["id"], 2)               # comes back soon

    session["history"].append({
        "card_id": card["id"],
        "user_answer": user_answer,
        "grade": grade["score"],
        "topic": card["topic"],
    })
    return session


def generate_followups(card: Card, grade: GradeResult, n: int = 2) -> list[Card]:
    """The money feature: new cards drilling exactly what was missed."""
    if llm.STUB:
        # Two canned drills so the demo shows the design's "+2 new questions"
        # adaptive moment. The real path below returns up to `n`.
        prompts = [
            f"[follow-up] In your own words, what's the key idea behind: {card['question']}",
            f"[follow-up] Restate: {card['question']}",
        ]
        return [
            new_card(
                p,
                card["answer"],
                topic=card["topic"],
                source_chunk=card["source_chunk"],
                difficulty=max(1, card["difficulty"] - 1),
                parent_id=card["id"],
            )
            for p in prompts[:n]
        ]

    messages = [
        {"role": "system", "content":
         "The student missed a concept. Generate follow-up quiz questions that "
         "drill it. Return ONLY a JSON array of OBJECTS with keys: question, answer, "
         "topic. Example (return ONE array exactly like this, no other text):\n"
         '[{"question": "What is X?", "answer": "X is Y.", "topic": "Topic A"}]'},
        {"role": "user", "content":
         f"Original question: {card['question']}\n"
         f"Missed concept: {grade['missed_concept']}\n"
         f"Source: {card['source_chunk']}\nGenerate {n} simpler follow-ups."},
    ]
    data = llm.extract_json(llm.chat(messages, max_tokens=400))
    out: list[Card] = []
    if isinstance(data, list):
        for item in data[:n]:
            if not isinstance(item, dict):
                continue
            c = new_card(
                str(item.get("question", "")).strip(),
                str(item.get("answer", "")).strip(),
                topic=str(item.get("topic", card["topic"])).strip() or card["topic"],
                source_chunk=card["source_chunk"],
                difficulty=max(1, card["difficulty"] - 1),
                parent_id=card["id"],
            )
            if validate_card(c):
                out.append(c)
    return out


def add_followups(session: Session, cards: list[Card]) -> Session:
    """Register generated follow-ups into the deck + queue (near-term)."""
    for c in cards:
        session["deck"].append(c)
        session["states"][c["id"]] = new_card_state(c["id"])
        _insert_at(session, c["id"], 1)
    return session


def grade_and_adapt(session: Session, user_answer: str) -> tuple[GradeResult | None, list[Card]]:
    """One full study step: grade the current card, apply the result, and on a
    miss generate + enqueue follow-ups. Returns (grade, injected_cards), with
    grade None only when the queue is empty.

    This is the canonical study-loop sequence. Both the Gradio app and the JSON
    server call it instead of re-implementing the next_card → grade → apply →
    follow-up dance, so the loop can never drift between the two frontends.
    """
    card = next_card(session)
    if card is None:
        return None, []
    grade = grade_answer(card, user_answer or "")
    apply_result(session, card, grade, user_answer=user_answer or "")
    injected: list[Card] = []
    if not grade["correct"]:
        fups = generate_followups(card, grade)
        if fups:
            add_followups(session, fups)
            injected = fups
    return grade, injected


def replace_card(session: Session, old_id: str, new: Card) -> Session:
    """Swap a card in place (used by the difficulty toggle, NAH-32).

    Replaces the deck entry, resets its CardState (it's effectively a new
    question), and rewrites every queue occurrence so the queue's
    "pop the front" contract still holds.
    """
    session["deck"] = [new if c["id"] == old_id else c for c in session["deck"]]
    session["states"].pop(old_id, None)
    session["states"][new["id"]] = new_card_state(new["id"])
    session["queue"] = [new["id"] if cid == old_id else cid
                        for cid in session["queue"]]
    return session


# ---- Recap -----------------------------------------------------------------

def recap(session: Session) -> dict:
    grades_by_topic: dict[str, list[int]] = {}
    for h in session["history"]:
        grades_by_topic.setdefault(h["topic"], []).append(h["grade"])

    # Same threshold the scheduler uses to decide what to resurface, so a topic
    # the recap calls "weak" is exactly one next_card brings back sooner.
    mastered = [t for t, g in grades_by_topic.items() if _avg(g) >= WEAK_TOPIC_THRESHOLD]
    weak = [t for t, g in grades_by_topic.items() if _avg(g) < WEAK_TOPIC_THRESHOLD]

    if llm.STUB:
        reflection = ("Solid start. You're strong on "
                      f"{', '.join(mastered) or 'nothing yet'}; "
                      f"{', '.join(weak) or 'no weak spots'} could use another pass.")
    else:
        msg = [
            {"role": "system", "content":
             "Write one encouraging sentence reflecting on a study session."},
            {"role": "user", "content":
             f"Mastered: {mastered}. Weak: {weak}. Streak: {session['streak']}."},
        ]
        reflection = llm.chat(msg, max_tokens=80)

    return {
        "mastered": mastered,
        "weak_topics": weak,
        "reflection": reflection,
        "streak": session["streak"],
        "answered": len(session["history"]),
    }


# ---- helpers ---------------------------------------------------------------

def _find(session: Session, card_id: str) -> Card | None:
    return next((c for c in session["deck"] if c["id"] == card_id), None)


def _topic_averages(session: Session) -> dict[str, float]:
    """Average grade per topic across answered history (empty until first answer)."""
    grades: dict[str, list[int]] = {}
    for h in session["history"]:
        grades.setdefault(h["topic"], []).append(h["grade"])
    return {t: _avg(g) for t, g in grades.items()}


def _weak_biased_index(session: Session) -> int:
    """
    Index into the queue of the card to serve next. Looks at the next
    WEAK_LOOKAHEAD cards and picks the one whose topic has the lowest average
    grade, as long as that topic is actually weak (avg < threshold). Returns 0
    (keep normal order) when nothing in reach is weak or there's no history yet.
    """
    queue = session["queue"]
    averages = _topic_averages(session)
    if not averages:
        return 0

    best_idx, best_avg = 0, None
    for i, card_id in enumerate(queue[:WEAK_LOOKAHEAD]):
        card = _find(session, card_id)
        if card is None:
            continue
        avg = averages.get(card["topic"])
        if avg is None or avg >= WEAK_TOPIC_THRESHOLD:
            continue
        if best_avg is None or avg < best_avg:
            best_idx, best_avg = i, avg
    return best_idx


def _insert_at(session: Session, card_id: str, pos: int) -> None:
    pos = max(0, min(pos, len(session["queue"])))
    session["queue"].insert(pos, card_id)


def _avg(xs: list[int]) -> float:
    return sum(xs) / len(xs) if xs else 0.0