From a22d78592ab2787b0f54a33a3e0180e7d36e7477 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Sun, 19 Apr 2026 12:55:55 -0400 Subject: [PATCH 1/5] fix: print endpoint reads artifact table; HTML opens inline not download Two bugs: - Print endpoint was walking the packet JSON blob for artifacts, but artifacts are stored in the worksheet_artifacts DB table. Switch to list_packet_artifacts() like the manifest endpoint does. - HTML artifact downloads forced Content-Disposition: attachment, causing a file download instead of opening in the browser. HTML is now served inline (text/html + Content-Disposition: inline) for a seamless view-then-print workflow. Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 59 ++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/main.py b/src/main.py index fdee2e7..f08ec8d 100644 --- a/src/main.py +++ b/src/main.py @@ -461,6 +461,8 @@ def _media_type(file_format: str | None) -> str: return "application/pdf" if normalized in {"png", "image/png"}: return "image/png" + if normalized in {"html", "text/html"}: + return "text/html" return "application/octet-stream" @@ -546,7 +548,11 @@ def download_worksheet_artifact(student_id: str, artifact_id: int): raise HTTPException(status_code=410, detail="Artifact unavailable") headers = _artifact_headers(artifact) - headers["Content-Disposition"] = f'attachment; filename="{file_path.name}"' + fmt = (artifact.get("file_format") or "").lower() + if fmt in {"html", "text/html"}: + headers["Content-Disposition"] = f'inline; filename="{file_path.name}"' + else: + headers["Content-Disposition"] = f'attachment; filename="{file_path.name}"' logger.info( "worksheet_artifact_download", @@ -581,36 +587,29 @@ def print_weekly_packet(student_id: str, packet_id: str): if packet is None: raise HTTPException(status_code=404, detail="Packet not found") - daily_plan = packet.get("daily_plan", []) - pages: list[tuple[str, str]] = [] + artifacts = list_packet_artifacts(student_id, packet_id) + if artifacts is None: + raise HTTPException(status_code=404, detail="Packet not found") - for day in daily_plan: - day_label = day.get("day", "") - resources = day.get("resources") or {} - - for _kind, payload in resources.items(): - if not isinstance(payload, dict): - continue - artifacts = payload.get("artifacts", []) - # Find the HTML artifact path for this worksheet - html_artifact = next((a for a in artifacts if a.get("type") == "html"), None) - if html_artifact: - artifact_path = _resolve_artifact_path(html_artifact.get("path", "")) - if artifact_path.exists(): - try: - fragment = artifact_path.read_text(encoding="utf-8") - # Strip the outer html/head/body wrapper if present so we - # can embed the fragment directly into the print document. - import re as _re - - body_match = _re.search( - r"]*>(.*)", fragment, _re.DOTALL | _re.IGNORECASE - ) - if body_match: - fragment = body_match.group(1) - pages.append((day_label, fragment)) - except OSError: - pass + import re as _re + + pages: list[tuple[str, str]] = [] + for artifact in artifacts: + if (artifact.get("file_format") or "").lower() != "html": + continue + artifact_path = _resolve_artifact_path(artifact["file_path"]) + if not artifact_path.exists(): + continue + try: + fragment = artifact_path.read_text(encoding="utf-8") + body_match = _re.search( + r"]*>(.*)", fragment, _re.DOTALL | _re.IGNORECASE + ) + if body_match: + fragment = body_match.group(1) + pages.append((artifact.get("day_label", ""), fragment)) + except OSError: + pass if not pages: raise HTTPException( From 9207a8e6d4ab299521c0f2398d929276544a857f Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Tue, 19 May 2026 08:05:10 -0400 Subject: [PATCH 2/5] docs: add phonics blends worksheets design spec Co-Authored-By: Claude Sonnet 4.6 --- ...-05-19-phonics-blends-worksheets-design.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md diff --git a/docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md b/docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md new file mode 100644 index 0000000..dcb1c29 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-phonics-blends-worksheets-design.md @@ -0,0 +1,82 @@ +# Phonics Blends Worksheets — Design Spec + +**Date:** 2026-05-19 +**Status:** Approved + +## Summary + +A standalone Python script that generates 4 print-ready HTML worksheet files covering phonics blends. Intended for a 3-year-old learning to sound out words. No interactive activities — each page is a word list to read aloud, with a subset of words broken into grapheme chunks to scaffold sounding-out. + +## Output Files + +All files written to `output/phonics_blends/`: + +| File | Blend Family | Blends Covered | Target Word Count | +|------|-------------|----------------|-------------------| +| `01_l_blends.html` | L-blends | bl, cl, fl, gl, pl, sl | ~60 | +| `02_r_blends.html` | R-blends | br, cr, dr, fr, gr, pr, tr | ~70 | +| `03_s_blends.html` | S-blends | sc, sk, sm, sn, sp, st, sw | ~70 | +| `04_final_blends.html` | Final blends | nd, nt, st, sk, lk, mp | ~60 | + +**Total: ~260 words. At least 10% (~26) displayed with grapheme chunks.** + +## Page Layout + +Each HTML file follows the existing project pattern: + +- **Font:** OpenDyslexic (primary), Trebuchet MS / Arial fallback +- **Font size:** 20pt for words, 13pt for sub-headers +- **Page size:** US Letter, 0.45in top/bottom margins, 0.5in left/right margins +- **Auto-print:** `window.addEventListener("load", () => window.print())` trigger +- **Color scheme:** One accent color per blend family, drawn from the existing day palette: + - L-blends → Blue (`#1d4ed8` / `#dbeafe`) + - R-blends → Green (`#15803d` / `#dcfce7`) + - S-blends → Purple (`#7c3aed` / `#ede9fe`) + - Final blends → Orange (`#c2410c` / `#ffedd5`) + +**Page structure (top to bottom):** +1. Full-width color header bar — blend family name (e.g., "L-Blends") + subtitle listing the blends covered (e.g., "bl · cl · fl · gl · pl · sl") +2. For each blend in the family: a sub-header (e.g., "bl words") followed by its word list +3. Words displayed in a 3-column CSS grid +4. Name line + date line at the bottom of each page (standard across all worksheets) + +## Grapheme Chunk Format + +Approximately 2–3 words per blend section are shown with the blend chunk visually separated: + +``` +bl · ue cr · ab st · op +``` + +- The blend letters are rendered in the page's accent color, bold weight +- The separator `·` (U+00B7 middle dot) is in a muted gray +- The remainder of the word is normal weight, black +- Regular (non-chunked) words are displayed as plain text at the same size + +Chunked words are chosen to be short and decodable (CVC+blend structure preferred), e.g. `bl·ot`, `cr·ab`, `st·op` rather than complex vowel patterns. + +## Script Structure + +**File:** `scripts/generate_phonics_blends_series.py` + +Follows the pattern of existing generators (e.g., `generate_mancala_math_series.py`): + +1. Embed OpenDyslexic `@font-face` CSS block at the top of the script +2. Define word lists per blend as Python dicts — `{blend: [words]}` — with a separate dict marking which words get grapheme-chunk display and where the split point is +3. For each blend family, call a `_build_page_html(family_name, blends_dict, palette)` helper that assembles the full HTML document +4. Write each file to `output/phonics_blends/` +5. Print confirmation for each file written + +## Word Selection Constraints + +- All words must be real, common English words a 3-year-old would recognise or can be sounded out +- Prefer CVC or CCVC structure (short vowels) for accessibility +- Avoid multi-syllable words except where the blend is very clear (e.g., "blanket") +- Final blends: ensure words are monosyllabic where possible (hand, mint, desk) + +## Out of Scope + +- No images or picture matching +- No tracing lines or write-in activities +- No integration with the FastAPI backend or database +- No frontend UI changes From c944c856517e370dd57007833e63012d2f93edfc Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Tue, 19 May 2026 08:14:33 -0400 Subject: [PATCH 3/5] docs: add phonics blends worksheets implementation plan --- .../2026-05-19-phonics-blends-worksheets.md | 542 ++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md diff --git a/docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md b/docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md new file mode 100644 index 0000000..9c8dae7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-phonics-blends-worksheets.md @@ -0,0 +1,542 @@ +# Phonics Blends Worksheets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generate 4 print-ready HTML phonics-blend word-list worksheets (~260 words total, ≥10% with grapheme-chunk markup) for a 3-year-old to sound out. + +**Architecture:** A single standalone script `scripts/generate_phonics_blends_series.py` that builds 4 HTML files in `output/phonics_blends/`, following the same pattern as existing generators (font injection, `_CSS`/`_HTML_WRAPPER` from `worksheet_html_renderer`, auto-print on load). One file per blend family (L, R, S, Final). No changes to `src/`. + +**Tech Stack:** Python 3, existing `src/worksheet_html_renderer._CSS/_HTML_WRAPPER`, OpenDyslexic font via absolute `file://` path, pytest for verification. + +--- + +### Task 1: Write failing integration test + +**Files:** +- Create: `tests/test_generate_phonics_blends.py` + +- [ ] **Step 1: Write the test file** + +```python +# tests/test_generate_phonics_blends.py +import os +import subprocess +import pytest + +OUTPUT_DIR = "output/phonics_blends" +EXPECTED_FILES = [ + "01_l_blends.html", + "02_r_blends.html", + "03_s_blends.html", + "04_final_blends.html", +] + + +@pytest.fixture(scope="module", autouse=True) +def run_script(): + result = subprocess.run( + ["python", "scripts/generate_phonics_blends_series.py"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Script failed:\n{result.stderr}" + + +def test_all_files_created(): + for fname in EXPECTED_FILES: + path = os.path.join(OUTPUT_DIR, fname) + assert os.path.exists(path), f"Missing output file: {path}" + + +def test_word_count_per_file(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + count = content.count('class="word"') + assert count >= 25, f"{fname}: expected >=25 words, got {count}" + + +def test_grapheme_chunks_present(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + assert 'class="blend-part"' in content, f"{fname}: missing grapheme chunks" + chunk_count = content.count('class="blend-part"') + word_count = content.count('class="word"') + assert chunk_count / word_count >= 0.10, ( + f"{fname}: chunk ratio {chunk_count}/{word_count} is below 10%" + ) + + +def test_opendyslexic_referenced(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + assert "OpenDyslexic" in content, f"{fname}: OpenDyslexic font not referenced" + + +def test_auto_print_present(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + assert "window.print()" in content, f"{fname}: missing auto-print trigger" +``` + +- [ ] **Step 2: Run tests to confirm they fail (script doesn't exist yet)** + +```bash +pytest tests/test_generate_phonics_blends.py -v +``` + +Expected: FAIL — `AssertionError: Script failed` or `FileNotFoundError` because `scripts/generate_phonics_blends_series.py` does not exist yet. + +- [ ] **Step 3: Commit the test** + +```bash +git add tests/test_generate_phonics_blends.py +git commit -m "test: add failing integration tests for phonics blends generator" +``` + +--- + +### Task 2: Implement the generator script + +**Files:** +- Create: `scripts/generate_phonics_blends_series.py` + +- [ ] **Step 1: Create the script with all word data and HTML builder** + +Create `scripts/generate_phonics_blends_series.py` with the following complete contents: + +```python +""" +generate_phonics_blends_series.py + +Phonics blends word-list worksheets for a 3-year-old. +Covers L-blends, R-blends, S-blends, and final blends. +~20% of words shown with grapheme-chunk markup to scaffold sounding-out. + +Output: output/phonics_blends/ (4 HTML files, one per blend family) + +Run from project root: + python scripts/generate_phonics_blends_series.py +""" + +import html as _html +import os +import sys + +os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.abspath("src")) + +from worksheet_html_renderer import _CSS, _HTML_WRAPPER # noqa: E402 + +# ── Font injection ────────────────────────────────────────────────────────── + +_FONT_FACE = """\ + @font-face { + font-family: 'OpenDyslexic'; + src: local('OpenDyslexic'), + url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Regular.otf') format('opentype'); + font-weight: normal; + font-style: normal; + } + @font-face { + font-family: 'OpenDyslexic'; + src: local('OpenDyslexic Bold'), + url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Bold.otf') format('opentype'); + font-weight: bold; + font-style: normal; + } +""" + +# ── Word data ─────────────────────────────────────────────────────────────── +# Each blend entry: blend prefix/suffix, 10 words, 2 words to show chunked. +# For initial blends (final=False): split after len(blend) chars → bl·ue +# For final blends (final=True): split before last len(blend) chars → ha·nd + +FAMILIES = [ + { + "filename": "01_l_blends.html", + "title": "L-Blends", + "subtitle": "bl · cl · fl · gl · pl · sl", + "accent": "#1d4ed8", + "bg": "#dbeafe", + "final": False, + "blends": [ + { + "blend": "bl", + "words": ["blue", "black", "blob", "blot", "blab", "blip", "blaze", "block", "blow", "blend"], + "chunks": ["blot", "blob"], + }, + { + "blend": "cl", + "words": ["clap", "clay", "clip", "clock", "clown", "club", "clean", "clog", "cluck", "clan"], + "chunks": ["clap", "clip"], + }, + { + "blend": "fl", + "words": ["flag", "flat", "flip", "flock", "fly", "flap", "flab", "flit", "fled", "flog"], + "chunks": ["flip", "flit"], + }, + { + "blend": "gl", + "words": ["glad", "glass", "glide", "glow", "glue", "glob", "glen", "glum", "glint", "glee"], + "chunks": ["glad", "glob"], + }, + { + "blend": "pl", + "words": ["plan", "play", "plop", "plug", "plus", "plum", "plot", "plod", "plank", "pluck"], + "chunks": ["plop", "plug"], + }, + { + "blend": "sl", + "words": ["slap", "slam", "slip", "slob", "slug", "sled", "slim", "slot", "slid", "slop"], + "chunks": ["slap", "slid"], + }, + ], + }, + { + "filename": "02_r_blends.html", + "title": "R-Blends", + "subtitle": "br · cr · dr · fr · gr · pr · tr", + "accent": "#15803d", + "bg": "#dcfce7", + "final": False, + "blends": [ + { + "blend": "br", + "words": ["brag", "brick", "brush", "brown", "bring", "brim", "bred", "brat", "brisk", "brew"], + "chunks": ["brat", "brim"], + }, + { + "blend": "cr", + "words": ["crab", "crack", "crop", "crow", "crush", "crib", "crisp", "cram", "crest", "crag"], + "chunks": ["crab", "cram"], + }, + { + "blend": "dr", + "words": ["drag", "drip", "drop", "drum", "drub", "drab", "drift", "drill", "dress", "drew"], + "chunks": ["drip", "drop"], + }, + { + "blend": "fr", + "words": ["frog", "frost", "from", "fresh", "fry", "frill", "fret", "frisk", "frock", "franc"], + "chunks": ["frog", "fret"], + }, + { + "blend": "gr", + "words": ["grab", "grass", "gray", "grin", "grip", "grub", "grit", "gram", "grim", "grew"], + "chunks": ["grin", "grub"], + }, + { + "blend": "pr", + "words": ["press", "prim", "prop", "prod", "prom", "prank", "prep", "prig", "prism", "prone"], + "chunks": ["prim", "prop"], + }, + { + "blend": "tr", + "words": ["trap", "tree", "trip", "trot", "truck", "trim", "track", "tram", "trek", "trick"], + "chunks": ["trip", "trot"], + }, + ], + }, + { + "filename": "03_s_blends.html", + "title": "S-Blends", + "subtitle": "sc · sk · sm · sn · sp · st · sw", + "accent": "#7c3aed", + "bg": "#ede9fe", + "final": False, + "blends": [ + { + "blend": "sc", + "words": ["scam", "scat", "scab", "scar", "scan", "scoff", "scold", "scone", "scope", "scorn"], + "chunks": ["scam", "scab"], + }, + { + "blend": "sk", + "words": ["skip", "skill", "skin", "sky", "skim", "skid", "skull", "sketch", "skunk", "skit"], + "chunks": ["skip", "skid"], + }, + { + "blend": "sm", + "words": ["small", "smash", "smell", "smile", "smoke", "smock", "smug", "smart", "smear", "smirk"], + "chunks": ["smug", "smash"], + }, + { + "blend": "sn", + "words": ["snag", "snap", "sniff", "snob", "snow", "snug", "sneak", "snore", "snip", "snarl"], + "chunks": ["snap", "snip"], + }, + { + "blend": "sp", + "words": ["span", "spin", "spit", "spot", "spur", "spell", "spill", "spoke", "sport", "speck"], + "chunks": ["spin", "spot"], + }, + { + "blend": "st", + "words": ["stop", "step", "stem", "star", "stir", "stamp", "stone", "store", "stab", "stuck"], + "chunks": ["stop", "stem"], + }, + { + "blend": "sw", + "words": ["swap", "swim", "swing", "sweet", "swept", "swell", "swift", "swab", "swipe", "swam"], + "chunks": ["swim", "swam"], + }, + ], + }, + { + "filename": "04_final_blends.html", + "title": "Final Blends", + "subtitle": "nd · nt · st · sk · lk · mp", + "accent": "#c2410c", + "bg": "#ffedd5", + "final": True, + "blends": [ + { + "blend": "nd", + "words": ["hand", "band", "wind", "bond", "bend", "find", "land", "mind", "sand", "end"], + "chunks": ["hand", "bend"], + }, + { + "blend": "nt", + "words": ["mint", "hint", "rent", "hunt", "tent", "dent", "pant", "punt", "font", "rant"], + "chunks": ["mint", "tent"], + }, + { + "blend": "st", + "words": ["best", "fast", "list", "most", "past", "rest", "dust", "fist", "mist", "last"], + "chunks": ["best", "fast"], + }, + { + "blend": "sk", + "words": ["ask", "desk", "dusk", "husk", "mask", "risk", "task", "brisk", "flask", "disk"], + "chunks": ["desk", "mask"], + }, + { + "blend": "lk", + "words": ["bulk", "hulk", "milk", "silk", "sulk", "talk", "walk", "elk", "folk", "yolk"], + "chunks": ["milk", "bulk"], + }, + { + "blend": "mp", + "words": ["bump", "camp", "damp", "dump", "jump", "lamp", "limp", "pump", "ramp", "hemp"], + "chunks": ["jump", "bump"], + }, + ], + }, +] + +# ── HTML helpers ──────────────────────────────────────────────────────────── + + +def _word_html(word: str, blend: str, is_final: bool, accent: str, is_chunk: bool) -> str: + w = _html.escape(word) + if not is_chunk: + return f'{w}' + n = len(blend) + if is_final: + root = _html.escape(word[:-n]) + blend_part = _html.escape(word[-n:]) + return ( + f'' + f'{root}' + f'·' + f'{blend_part}' + f'' + ) + blend_part = _html.escape(word[:n]) + rest = _html.escape(word[n:]) + return ( + f'' + f'{blend_part}' + f'·' + f'{rest}' + f'' + ) + + +def _build_family_page(family: dict) -> str: + accent = family["accent"] + bg = family["bg"] + is_final = family["final"] + + css = _CSS.replace("", extra_css + " ") + + sections = [] + for blend_info in family["blends"]: + blend = blend_info["blend"] + chunk_set = set(blend_info["chunks"]) + words_html = "\n ".join( + _word_html(w, blend, is_final, accent, w in chunk_set) + for w in blend_info["words"] + ) + sections.append( + f'
\n' + f'
{_html.escape(blend)} words
\n' + f'
\n {words_html}\n
\n' + f'
' + ) + + body = ( + '
\n' + f'
\n' + f'

{_html.escape(family["title"])}

\n' + f'
{_html.escape(family["subtitle"])}
\n' + f'
\n' + + "\n".join(sections) + + '\n
' + + '\n Name: ' + + " " * 20 + + "" + + '\n Date: ' + + " " * 20 + + "" + + "\n
" + + "\n
" + + '\n' + ) + + return _HTML_WRAPPER.format( + title=_html.escape(family["title"]), + css=css, + body=body, + ) + + +# ── Entry point ───────────────────────────────────────────────────────────── + + +def main() -> None: + out_dir = os.path.join("output", "phonics_blends") + os.makedirs(out_dir, exist_ok=True) + for family in FAMILIES: + html = _build_family_page(family) + path = os.path.join(out_dir, family["filename"]) + with open(path, "w", encoding="utf-8") as f: + f.write(html) + print(f"Wrote {path}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Run the script manually to check it executes without errors** + +```bash +python scripts/generate_phonics_blends_series.py +``` + +Expected output: +``` +Wrote output/phonics_blends/01_l_blends.html +Wrote output/phonics_blends/02_r_blends.html +Wrote output/phonics_blends/03_s_blends.html +Wrote output/phonics_blends/04_final_blends.html +``` + +If it errors, fix before proceeding. + +--- + +### Task 3: Run tests, verify, and commit + +- [ ] **Step 1: Run the full test suite** + +```bash +pytest tests/test_generate_phonics_blends.py -v +``` + +Expected: All 5 tests PASS. +- `test_all_files_created` — 4 files exist +- `test_word_count_per_file` — ≥25 `class="word"` per file +- `test_grapheme_chunks_present` — ≥10% chunk ratio per file +- `test_opendyslexic_referenced` — font referenced in every file +- `test_auto_print_present` — `window.print()` in every file + +If any test fails, fix the script and re-run. + +- [ ] **Step 2: Spot-check one file visually (optional but recommended)** + +```bash +# Open in browser to verify layout looks correct +xdg-open output/phonics_blends/01_l_blends.html +``` + +Verify: OpenDyslexic font loads, words display in 3-column grid at large size, grapheme-chunked words show blend in accent color with `·` separator, header bar correct color. + +- [ ] **Step 3: Commit everything** + +```bash +git add scripts/generate_phonics_blends_series.py tests/test_generate_phonics_blends.py output/phonics_blends/ +git commit -m "feat: add phonics blends word-list worksheet generator + +260 words across 4 blend families (L, R, S, Final). +~20% of words displayed with grapheme-chunk markup. +OpenDyslexic font, letter-size, auto-print on load." +``` From 64603095ed8c3b8c8cbbf2b44414b928b83e4c22 Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Tue, 19 May 2026 08:48:20 -0400 Subject: [PATCH 4/5] feat: add phonics blends word-list worksheet generator 260 words across 4 blend families (L, R, S, Final). 20% of words displayed with grapheme-chunk markup (blend in accent color + middle-dot separator). OpenDyslexic font, letter-size, auto-print on load. Co-Authored-By: Claude Sonnet 4.6 --- scripts/generate_phonics_blends_series.py | 664 ++++++++++++++++++++++ tests/test_generate_phonics_blends.py | 58 ++ 2 files changed, 722 insertions(+) create mode 100644 scripts/generate_phonics_blends_series.py create mode 100644 tests/test_generate_phonics_blends.py diff --git a/scripts/generate_phonics_blends_series.py b/scripts/generate_phonics_blends_series.py new file mode 100644 index 0000000..47af0e2 --- /dev/null +++ b/scripts/generate_phonics_blends_series.py @@ -0,0 +1,664 @@ +""" +generate_phonics_blends_series.py + +Phonics blends word-list worksheets for a 3-year-old. +Covers L-blends, R-blends, S-blends, and final blends. +~20% of words shown with grapheme-chunk markup to scaffold sounding-out. + +Output: output/phonics_blends/ (4 HTML files, one per blend family) + +Run from project root: + python scripts/generate_phonics_blends_series.py +""" + +import html as _html +import os +import sys + +os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.abspath("src")) + +from worksheet_html_renderer import _CSS, _HTML_WRAPPER # noqa: E402 + +# ── Font injection ────────────────────────────────────────────────────────── + +_FONT_FACE = """\ + @font-face { + font-family: 'OpenDyslexic'; + src: local('OpenDyslexic'), + url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Regular.otf') format('opentype'); + font-weight: normal; + font-style: normal; + } + @font-face { + font-family: 'OpenDyslexic'; + src: local('OpenDyslexic Bold'), + url('file:///home/clates/.local/share/fonts/opendyslexic/OpenDyslexic-Bold.otf') format('opentype'); + font-weight: bold; + font-style: normal; + } +""" + +# ── Word data ─────────────────────────────────────────────────────────────── +# Each blend entry: blend prefix/suffix, 10 words, 2 words to show chunked. +# For initial blends (final=False): split after len(blend) chars → bl·ue +# For final blends (final=True): split before last len(blend) chars → ha·nd + +FAMILIES = [ + { + "filename": "01_l_blends.html", + "title": "L-Blends", + "subtitle": "bl · cl · fl · gl · pl · sl", + "accent": "#1d4ed8", + "bg": "#dbeafe", + "final": False, + "blends": [ + { + "blend": "bl", + "words": [ + "blue", + "black", + "blob", + "blot", + "blab", + "blip", + "blaze", + "block", + "blow", + "blend", + ], + "chunks": ["blot", "blob"], + }, + { + "blend": "cl", + "words": [ + "clap", + "clay", + "clip", + "clock", + "clown", + "club", + "clean", + "clog", + "cluck", + "clan", + ], + "chunks": ["clap", "clip"], + }, + { + "blend": "fl", + "words": [ + "flag", + "flat", + "flip", + "flock", + "fly", + "flap", + "flab", + "flit", + "fled", + "flog", + ], + "chunks": ["flip", "flit"], + }, + { + "blend": "gl", + "words": [ + "glad", + "glass", + "glide", + "glow", + "glue", + "glob", + "glen", + "glum", + "glint", + "glee", + ], + "chunks": ["glad", "glob"], + }, + { + "blend": "pl", + "words": [ + "plan", + "play", + "plop", + "plug", + "plus", + "plum", + "plot", + "plod", + "plank", + "pluck", + ], + "chunks": ["plop", "plug"], + }, + { + "blend": "sl", + "words": [ + "slap", + "slam", + "slip", + "slob", + "slug", + "sled", + "slim", + "slot", + "slid", + "slop", + ], + "chunks": ["slap", "slid"], + }, + ], + }, + { + "filename": "02_r_blends.html", + "title": "R-Blends", + "subtitle": "br · cr · dr · fr · gr · pr · tr", + "accent": "#15803d", + "bg": "#dcfce7", + "final": False, + "blends": [ + { + "blend": "br", + "words": [ + "brag", + "brick", + "brush", + "brown", + "bring", + "brim", + "bred", + "brat", + "brisk", + "brew", + ], + "chunks": ["brat", "brim"], + }, + { + "blend": "cr", + "words": [ + "crab", + "crack", + "crop", + "crow", + "crush", + "crib", + "crisp", + "cram", + "crest", + "crag", + ], + "chunks": ["crab", "cram"], + }, + { + "blend": "dr", + "words": [ + "drag", + "drip", + "drop", + "drum", + "drub", + "drab", + "drift", + "drill", + "dress", + "drew", + ], + "chunks": ["drip", "drop"], + }, + { + "blend": "fr", + "words": [ + "frog", + "frost", + "from", + "fresh", + "fry", + "frill", + "fret", + "frisk", + "frock", + "franc", + ], + "chunks": ["frog", "fret"], + }, + { + "blend": "gr", + "words": [ + "grab", + "grass", + "gray", + "grin", + "grip", + "grub", + "grit", + "gram", + "grim", + "grew", + ], + "chunks": ["grin", "grub"], + }, + { + "blend": "pr", + "words": [ + "press", + "prim", + "prop", + "prod", + "prom", + "prank", + "prep", + "prig", + "prism", + "prone", + ], + "chunks": ["prim", "prop"], + }, + { + "blend": "tr", + "words": [ + "trap", + "tree", + "trip", + "trot", + "truck", + "trim", + "track", + "tram", + "trek", + "trick", + ], + "chunks": ["trip", "trot"], + }, + ], + }, + { + "filename": "03_s_blends.html", + "title": "S-Blends", + "subtitle": "sc · sk · sm · sn · sp · st · sw", + "accent": "#7c3aed", + "bg": "#ede9fe", + "final": False, + "blends": [ + { + "blend": "sc", + "words": [ + "scam", + "scat", + "scab", + "scar", + "scan", + "scoff", + "scold", + "scone", + "scope", + "scorn", + ], + "chunks": ["scam", "scab"], + }, + { + "blend": "sk", + "words": [ + "skip", + "skill", + "skin", + "sky", + "skim", + "skid", + "skull", + "sketch", + "skunk", + "skit", + ], + "chunks": ["skip", "skid"], + }, + { + "blend": "sm", + "words": [ + "small", + "smash", + "smell", + "smile", + "smoke", + "smock", + "smug", + "smart", + "smear", + "smirk", + ], + "chunks": ["smug", "smash"], + }, + { + "blend": "sn", + "words": [ + "snag", + "snap", + "sniff", + "snob", + "snow", + "snug", + "sneak", + "snore", + "snip", + "snarl", + ], + "chunks": ["snap", "snip"], + }, + { + "blend": "sp", + "words": [ + "span", + "spin", + "spit", + "spot", + "spur", + "spell", + "spill", + "spoke", + "sport", + "speck", + ], + "chunks": ["spin", "spot"], + }, + { + "blend": "st", + "words": [ + "stop", + "step", + "stem", + "star", + "stir", + "stamp", + "stone", + "store", + "stab", + "stuck", + ], + "chunks": ["stop", "stem"], + }, + { + "blend": "sw", + "words": [ + "swap", + "swim", + "swing", + "sweet", + "swept", + "swell", + "swift", + "swab", + "swipe", + "swam", + ], + "chunks": ["swim", "swam"], + }, + ], + }, + { + "filename": "04_final_blends.html", + "title": "Final Blends", + "subtitle": "nd · nt · st · sk · lk · mp", + "accent": "#c2410c", + "bg": "#ffedd5", + "final": True, + "blends": [ + { + "blend": "nd", + "words": [ + "hand", + "band", + "wind", + "bond", + "bend", + "find", + "land", + "mind", + "sand", + "end", + ], + "chunks": ["hand", "bend"], + }, + { + "blend": "nt", + "words": [ + "mint", + "hint", + "rent", + "hunt", + "tent", + "dent", + "pant", + "punt", + "font", + "rant", + ], + "chunks": ["mint", "tent"], + }, + { + "blend": "st", + "words": [ + "best", + "fast", + "list", + "most", + "past", + "rest", + "dust", + "fist", + "mist", + "last", + ], + "chunks": ["best", "fast"], + }, + { + "blend": "sk", + "words": [ + "ask", + "desk", + "dusk", + "husk", + "mask", + "risk", + "task", + "brisk", + "flask", + "disk", + ], + "chunks": ["desk", "mask"], + }, + { + "blend": "lk", + "words": [ + "bulk", + "hulk", + "milk", + "silk", + "sulk", + "talk", + "walk", + "elk", + "folk", + "yolk", + ], + "chunks": ["milk", "bulk"], + }, + { + "blend": "mp", + "words": [ + "bump", + "camp", + "damp", + "dump", + "jump", + "lamp", + "limp", + "pump", + "ramp", + "hemp", + ], + "chunks": ["jump", "bump"], + }, + ], + }, +] + +# ── HTML helpers ──────────────────────────────────────────────────────────── + + +def _word_html(word: str, blend: str, is_final: bool, accent: str, is_chunk: bool) -> str: + w = _html.escape(word) + if not is_chunk: + return f'{w}' + n = len(blend) + if is_final: + root = _html.escape(word[:-n]) + blend_part = _html.escape(word[-n:]) + return ( + f'' + f'{root}' + f'·' + f'{blend_part}' + f"" + ) + blend_part = _html.escape(word[:n]) + rest = _html.escape(word[n:]) + return ( + f'' + f'{blend_part}' + f'·' + f'{rest}' + f"" + ) + + +def _build_family_page(family: dict) -> str: + accent = family["accent"] + bg = family["bg"] + is_final = family["final"] + + css = _CSS.replace("", extra_css + " ") + + sections = [] + for blend_info in family["blends"]: + blend = blend_info["blend"] + chunk_set = set(blend_info["chunks"]) + words_html = "\n ".join( + _word_html(w, blend, is_final, accent, w in chunk_set) for w in blend_info["words"] + ) + sections.append( + f'
\n' + f'
{_html.escape(blend)} words
\n' + f'
\n {words_html}\n
\n' + f"
" + ) + + body = ( + '
\n' + f'
\n' + f'

{_html.escape(family["title"])}

\n' + f'
{_html.escape(family["subtitle"])}
\n' + f"
\n" + + "\n".join(sections) + + '\n
' + + '\n Name: ' + + " " * 20 + + "" + + '\n Date: ' + + " " * 20 + + "" + + "\n
" + + "\n
" + + '\n' + ) + + return _HTML_WRAPPER.format( + title=_html.escape(family["title"]), + css=css, + body=body, + ) + + +# ── Entry point ───────────────────────────────────────────────────────────── + + +def main() -> None: + out_dir = os.path.join("output", "phonics_blends") + os.makedirs(out_dir, exist_ok=True) + for family in FAMILIES: + html = _build_family_page(family) + path = os.path.join(out_dir, family["filename"]) + with open(path, "w", encoding="utf-8") as f: + f.write(html) + print(f"Wrote {path}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_generate_phonics_blends.py b/tests/test_generate_phonics_blends.py new file mode 100644 index 0000000..93dde8f --- /dev/null +++ b/tests/test_generate_phonics_blends.py @@ -0,0 +1,58 @@ +# tests/test_generate_phonics_blends.py +import os +import subprocess +import pytest + +OUTPUT_DIR = "output/phonics_blends" +EXPECTED_FILES = [ + "01_l_blends.html", + "02_r_blends.html", + "03_s_blends.html", + "04_final_blends.html", +] + + +@pytest.fixture(scope="module", autouse=True) +def run_script(): + result = subprocess.run( + ["python", "scripts/generate_phonics_blends_series.py"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Script failed:\n{result.stderr}" + + +def test_all_files_created(): + for fname in EXPECTED_FILES: + path = os.path.join(OUTPUT_DIR, fname) + assert os.path.exists(path), f"Missing output file: {path}" + + +def test_word_count_per_file(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + count = content.count('class="word"') + assert count >= 25, f"{fname}: expected >=25 words, got {count}" + + +def test_grapheme_chunks_present(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + assert 'class="blend-part"' in content, f"{fname}: missing grapheme chunks" + chunk_count = content.count('class="blend-part"') + word_count = content.count('class="word"') + assert ( + chunk_count / word_count >= 0.10 + ), f"{fname}: chunk ratio {chunk_count}/{word_count} is below 10%" + + +def test_opendyslexic_referenced(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + assert "OpenDyslexic" in content, f"{fname}: OpenDyslexic font not referenced" + + +def test_auto_print_present(): + for fname in EXPECTED_FILES: + content = open(os.path.join(OUTPUT_DIR, fname)).read() + assert "window.print()" in content, f"{fname}: missing auto-print trigger" From 75f3487c69159fe0fb07810aac211a6c2d3cb23e Mon Sep 17 00:00:00 2001 From: Christopher Lates Date: Tue, 26 May 2026 18:44:53 -0400 Subject: [PATCH 5/5] fix: allow feedback updates and surface submission state in UI Feedback was rejected with 400 when resubmitting for a packet that already had feedback. Also, the UI showed Provide Feedback regardless of whether feedback had been submitted. - packet_store: upsert feedback instead of INSERT-only; add has_feedback and feedback_completed_at to list_weekly_packets via LEFT JOIN - feedback_processor: add reverse_quantity_feedback to undo prior activity_bias before applying a corrected value - main: fetch existing feedback before processing; reverse old quantity effect when updating - frontend: surface has_feedback/feedback_completed_at in types; add getFeedback API call; pre-populate modal with existing values; show Edit Feedback vs Provide Feedback; lock after 3 weeks Co-Authored-By: Claude Sonnet 4.6 --- frontend/app/plans/page.tsx | 97 ++++++++++++++++++++++++++++--------- frontend/lib/api.ts | 19 ++++++++ src/feedback_processor.py | 37 ++++++++++++++ src/main.py | 10 ++++ src/packet_store.py | 56 ++++++++++++--------- 5 files changed, 174 insertions(+), 45 deletions(-) diff --git a/frontend/app/plans/page.tsx b/frontend/app/plans/page.tsx index 9c390c1..19f2875 100644 --- a/frontend/app/plans/page.tsx +++ b/frontend/app/plans/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { Card, Button, Badge, Modal } from '@/components/ui'; import { Navigation } from '@/components/Navigation'; @@ -21,6 +21,23 @@ const QUANTITY_RATING_MAP: Record = { TOO_MUCH: -2, }; +// Reverse map: backend integer to UI rating label +function quantityToRating(qty: number): string { + if (qty > 0) return 'TOO_LITTLE'; + if (qty < 0) return 'TOO_MUCH'; + return 'JUST_RIGHT'; +} + +const FEEDBACK_LOCK_WEEKS = 3; + +function isFeedbackLocked(feedbackCompletedAt: string | null): boolean { + if (!feedbackCompletedAt) return false; + const submitted = new Date(feedbackCompletedAt); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - FEEDBACK_LOCK_WEEKS * 7); + return submitted < cutoff; +} + export default function PlansPage() { const { data: students } = useStudents(); const { packets: pendingPackets, isLoading: pendingLoading } = usePendingPackets(); @@ -42,6 +59,40 @@ export default function PlansPage() { const queryClient = useQueryClient(); + // Helper to find the selected packet from lists + const selectedPacket = useMemo(() => { + if (!selectedPacketIds) return null; + const allPackets = [...pendingPackets, ...completedPackets]; + return allPackets.find( + (p) => + p.student_id === selectedPacketIds.studentId && p.packet_id === selectedPacketIds.packetId + ); + }, [selectedPacketIds, pendingPackets, completedPackets]); + + // Fetch existing feedback when the feedback modal is open for a packet that has feedback + const { data: existingFeedback } = useQuery({ + queryKey: ['packet-feedback', selectedPacketIds?.studentId, selectedPacketIds?.packetId], + queryFn: async () => { + if (!selectedPacketIds) return null; + return await plansApi.getFeedback(selectedPacketIds.studentId, selectedPacketIds.packetId); + }, + enabled: !!selectedPacketIds && feedbackModalOpen && !!selectedPacket?.has_feedback, + }); + + // Pre-populate feedback modal with existing values when editing + useEffect(() => { + if (existingFeedback && feedbackModalOpen && masteryRating === null) { + const mastery = existingFeedback.mastery_feedback?.overall ?? null; + setMasteryRating(mastery); + if ( + existingFeedback.quantity_feedback !== null && + existingFeedback.quantity_feedback !== undefined + ) { + setQuantityRating(quantityToRating(existingFeedback.quantity_feedback)); + } + } + }, [existingFeedback, feedbackModalOpen, masteryRating]); + // Fetch plan detail when packet is selected const { data: planDetail, isLoading: planDetailLoading } = useQuery({ queryKey: ['plan-detail', selectedPacketIds?.studentId, selectedPacketIds?.packetId], @@ -99,16 +150,6 @@ export default function PlansPage() { const { mutate: submitFeedback } = feedbackMutation; - // Helper to find the selected packet from lists - const selectedPacket = useMemo(() => { - if (!selectedPacketIds) return null; - const allPackets = [...pendingPackets, ...completedPackets]; - return allPackets.find( - (p) => - p.student_id === selectedPacketIds.studentId && p.packet_id === selectedPacketIds.packetId - ); - }, [selectedPacketIds, pendingPackets, completedPackets]); - const handleViewPlan = useCallback((packet: WeeklyPacketWithStudent) => { setSelectedPacketIds({ studentId: packet.student_id, @@ -522,14 +563,19 @@ export default function PlansPage() { Print All )} - {selectedPacket.status === 'ready' && ( - - )} + {selectedPacket.status === 'ready' && + (isFeedbackLocked(selectedPacket.feedback_completed_at) ? ( + + ) : ( + + ))} @@ -543,12 +589,13 @@ export default function PlansPage() { setMasteryRating(null); setQuantityRating(null); }} - title="Provide Feedback" + title={selectedPacket.has_feedback ? 'Edit Feedback' : 'Provide Feedback'} >

- Help the AI understand how {selectedPacket.studentName} did with this week's - plan. + {selectedPacket.has_feedback + ? `Update how ${selectedPacket.studentName} did with this week’s plan.` + : `Help the AI understand how ${selectedPacket.studentName} did with this week’s plan.`}

{/* Mastery Rating */} @@ -630,7 +677,11 @@ export default function PlansPage() { onClick={handleSubmitFeedback} disabled={!masteryRating || !quantityRating || feedbackMutation.isPending} > - {feedbackMutation.isPending ? 'Submitting...' : 'Submit Feedback'} + {feedbackMutation.isPending + ? 'Submitting...' + : selectedPacket.has_feedback + ? 'Update Feedback' + : 'Submit Feedback'}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d92b097..4d68051 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -44,6 +44,14 @@ export interface WeeklyPacketSummary { resource_days: number; daily_count: number; updated_at: string; + has_feedback: boolean; + feedback_completed_at: string | null; +} + +export interface FeedbackData { + mastery_feedback: Record | null; + quantity_feedback: number | null; + completed_at: string | null; } export interface PaginatedResponse { @@ -196,6 +204,17 @@ export const plansApi = { return data; }, + getFeedback: async (studentId: string, packetId: string): Promise => { + try { + const { data } = await apiClient.get( + `/students/${studentId}/weekly-packets/${packetId}/feedback` + ); + return data; + } catch { + return null; + } + }, + submitFeedback: async ( studentId: string, packetId: string, diff --git a/src/feedback_processor.py b/src/feedback_processor.py index 1abb9a4..18fe849 100644 --- a/src/feedback_processor.py +++ b/src/feedback_processor.py @@ -174,6 +174,43 @@ def process_quantity_feedback( return json.dumps(plan_rules) +def reverse_quantity_feedback(plan_rules_blob: str, old_quantity_feedback: int) -> str: + """ + Reverse a previously applied quantity feedback from plan_rules_blob. + + Used when updating existing feedback to undo the old effect before applying the new one. + + Args: + plan_rules_blob: JSON string of current plan rules + old_quantity_feedback: The previously submitted quantity feedback integer to reverse + + Returns: + Updated plan_rules_blob with old feedback effect removed + """ + plan_rules = json.loads(plan_rules_blob) + + if "quantity_preferences" not in plan_rules: + return plan_rules_blob + + prefs = plan_rules["quantity_preferences"] + current_bias = prefs.get("activity_bias", 0.0) + + if old_quantity_feedback == -2: + current_bias += 0.3 + elif old_quantity_feedback == -1: + current_bias += 0.15 + elif old_quantity_feedback == 0: + # Reverse the *0.9 decay by dividing; safe because bias could be 0 + current_bias = current_bias / 0.9 if current_bias != 0 else 0.0 + elif old_quantity_feedback == 1: + current_bias -= 0.15 + elif old_quantity_feedback == 2: + current_bias -= 0.3 + + prefs["activity_bias"] = max(-ACTIVITY_BIAS_CLAMP, min(current_bias, ACTIVITY_BIAS_CLAMP)) + return json.dumps(plan_rules) + + def is_standard_eligible(standard_metadata: dict[str, Any], reference_date: str = None) -> bool: """ Check if a standard is eligible to appear in a lesson based on cooldown. diff --git a/src/main.py b/src/main.py index f08ec8d..4419aee 100644 --- a/src/main.py +++ b/src/main.py @@ -25,6 +25,7 @@ from feedback_processor import ( process_mastery_feedback, process_quantity_feedback, + reverse_quantity_feedback, validate_mastery_feedback, validate_quantity_feedback, ) @@ -653,6 +654,9 @@ def submit_packet_feedback(student_id: str, packet_id: str, request: SubmitFeedb if not profile: raise HTTPException(status_code=404, detail="Student not found") + # Check for existing feedback to handle update case + existing_feedback = get_packet_feedback(student_id, packet_id) + # Process feedback and update blobs feedback_date = datetime.now(UTC).isoformat().replace("+00:00", "Z") @@ -661,6 +665,12 @@ def submit_packet_feedback(student_id: str, packet_id: str, request: SubmitFeedb ) plan_rules_blob = profile["plan_rules_blob"] or json.dumps({}) + # When updating, reverse the old quantity effect before applying the new one + if existing_feedback and existing_feedback.get("quantity_feedback") is not None: + plan_rules_blob = reverse_quantity_feedback( + plan_rules_blob, existing_feedback["quantity_feedback"] + ) + if mastery_feedback: progress_blob = process_mastery_feedback(progress_blob, mastery_feedback, feedback_date) diff --git a/src/packet_store.py b/src/packet_store.py index 14c66df..2cb13fd 100644 --- a/src/packet_store.py +++ b/src/packet_store.py @@ -338,15 +338,17 @@ def list_weekly_packets( ensure_schema() limit = max(1, limit) query = [ - "SELECT packet_id, student_id, grade_level, subject, week_of, status, summary_json, updated_at", - "FROM weekly_packets", - "WHERE student_id = ?", + "SELECT wp.packet_id, wp.student_id, wp.grade_level, wp.subject, wp.week_of, wp.status, wp.summary_json, wp.updated_at,", + " pf.completed_at AS feedback_completed_at", + "FROM weekly_packets wp", + "LEFT JOIN packet_feedback pf ON pf.packet_id = wp.packet_id AND pf.student_id = wp.student_id", + "WHERE wp.student_id = ?", ] params: list[Any] = [student_id] if week_of: - query.append("AND week_of = ?") + query.append("AND wp.week_of = ?") params.append(week_of) - query.append("ORDER BY week_of DESC, updated_at DESC") + query.append("ORDER BY wp.week_of DESC, wp.updated_at DESC") query.append("LIMIT ? OFFSET ?") params.extend([limit + 1, offset]) @@ -363,6 +365,7 @@ def list_weekly_packets( summaries: list[dict[str, Any]] = [] for row in rows_to_use: summary = _deserialize_summary(row["summary_json"]) + feedback_completed_at = row["feedback_completed_at"] summaries.append( { "packet_id": row["packet_id"], @@ -376,6 +379,8 @@ def list_weekly_packets( "artifact_count": summary.get("artifact_count", 0), "resource_days": summary.get("resource_days", 0), "daily_count": summary.get("daily_count", 0), + "has_feedback": feedback_completed_at is not None, + "feedback_completed_at": feedback_completed_at, } ) @@ -522,29 +527,36 @@ def save_packet_feedback( if not _packet_exists(conn, student_id, packet_id): raise ValueError(f"Packet {packet_id} not found for student {student_id}") - # Check if feedback already exists existing = conn.execute( "SELECT feedback_id FROM packet_feedback WHERE packet_id = ? AND student_id = ?", (packet_id, student_id), ).fetchone() - if existing: - raise ValueError(f"Feedback already exists for packet {packet_id}") - - # Insert feedback mastery_blob = _json(mastery_feedback) if mastery_feedback else None - conn.execute( - """ - INSERT INTO packet_feedback ( - packet_id, - student_id, - completed_at, - mastery_feedback_blob, - quantity_feedback - ) VALUES (?, ?, ?, ?, ?) - """, - (packet_id, student_id, _utc_now(), mastery_blob, quantity_feedback), - ) + if existing: + conn.execute( + """ + UPDATE packet_feedback SET + completed_at = ?, + mastery_feedback_blob = ?, + quantity_feedback = ? + WHERE packet_id = ? AND student_id = ? + """, + (_utc_now(), mastery_blob, quantity_feedback, packet_id, student_id), + ) + else: + conn.execute( + """ + INSERT INTO packet_feedback ( + packet_id, + student_id, + completed_at, + mastery_feedback_blob, + quantity_feedback + ) VALUES (?, ?, ?, ?, ?) + """, + (packet_id, student_id, _utc_now(), mastery_blob, quantity_feedback), + ) conn.commit() finally: conn.close()