Spaces:
Sleeping
A newer version of the Gradio SDK is available: 6.19.0
French Coach β Dev Log
A running record of what was built each day. Written for both technical and non-technical readers. Each entry gets appended after a build session completes.
Day 0 β 2026-06-06 β Infrastructure: getting the foundations in place
What changed (plain English)
Before this session, the project was just a single test file with no real structure. Now there is a proper development environment: one command (docker compose up) spins up the app and a database together, and the database is already set up with the right tables to store your French lessons, exercises, and points. You won't lose any data if you restart your computer β it's saved to a named volume. The app opens at http://localhost:7860.
What changed (technical)
docker-compose.ymlβ two-service stack:app(Python/Gradio, built from Dockerfile) +db(Postgres 16). DB healthcheck gates app startup so the app never starts before Postgres is ready. Named volumepgdatapersists data across container restarts.db/init.sqlβ full schema applied on first Postgres start:pagesβ stores notebook pages (raw text + cached spaCy annotations as JSONB)conceptsβ CEFR-tagged vocabulary/grammar topicsexercisesβ per-page exercises of any kind (text, dialogue, visual, pronunciation)pointsβ append-only participation ledger (CHECK amount > 0enforces no deductions)mistakesβ private table, defined but never written by the public Space
requirements.txtβ addedopenai(for OpenBMB API via OpenAI-compatible client) andpsycopg2-binary.env/.env.exampleβ all env vars documented: OpenBMB API keys,LLM_BACKEND,POSTGRES_PASSWORD,DATABASE_URL.DATABASE_URLin docker-compose overrides the.envvalue so the hostname is alwaysdb(the service name) inside Docker.syllabus.jsonβ placeholder; needs real Notion A1/A2 exportseed_texts/lesson_01_greetings.txtβ sample lesson for cold-start / demo
Day 1 β 2026-06-06 β Gender-colored editor with clickable word cards
What changed (plain English)
The app now does something genuinely useful: paste any French text and it instantly colour-codes the nouns β blue underline for masculine, rose underline for feminine. Click any word and a card pops up on the right showing the gender, the base form (lemma), and what part of speech it is. The word is also spoken aloud in French the moment you click it (using your browser's built-in voice). There's a toggle to turn the colours on and off. This is the core "see gender at a glance" feature from the project plan.
What changed (technical)
app.pyβ full rewrite from smoke-test to Day 1 prototype:annotate(text)β runs spaCyfr_core_news_sm, returns annotation dict matching the DBannotationsJSONB schema:{ "tokens": [{ idx, text, pos, gender, lemma, is_space, whitespace }] }render_html(annotations, colors_on)β converts annotation dict to<span data-token data-gender β¦>HTML; noun spans get coloured borders (hex with1Aalpha for background tint)show_word_card(click_data)β receives a JSON click payload, returns an HTML card with adata-speakbutton for TTSgr.Stateholds annotation JSON between events so toggling colours doesn't re-run spaCydemo.load(...)auto-annotates the sample text on page load
- JS event delegation β
PAGE_JSpassed tolaunch(js=...)runs once on page load and attaches a single listener todocument. This survivesgr.HTMLre-renders (which would kill any listeners attached to the HTML component's own DOM). This is the gotcha called out in CLAUDE.md Β§12 β proved working here. - TTS β word spoken immediately on token click via
SpeechSynthesisUtterance(lang: 'fr-FR'); also triggered by thedata-speakbutton in the word card.speechSynthesis.cancel()before each call prevents queuing. - Hidden Gradio textbox (
elem_id="word-click-data") bridges JS β Python: JS updates the textarea value using the React/native setter trick (required to trigger Gradio's change event), then dispatchesinputevent. Python.change()handler fires and updates the word card component. - Gotcha hit: Gradio 6 moved
js=fromBlocks(js=β¦)tolaunch(js=β¦)β fixed after seeing the UserWarning in container logs.
Day 1.5 β 2026-06-06 β Multi-user support with Hugging Face login
What changed (plain English)
The app now supports multiple users β each person's notes and data are kept completely separate. On the Hugging Face Space (the public version), visitors will see a "Sign in with Hugging Face" button in the top-right corner; only after signing in can they use the app. When you're running it locally on your own computer, it skips the login step automatically and uses a developer account so you can keep working without friction. If someone tries to use the Space without logging in, they see a polite message asking them to sign in rather than seeing someone else's data.
What changed (technical)
db/init.sqlβ addeduser_id TEXT NOT NULLtopages,exercises,points, andmistakestables. Added(user_id, created_at DESC)indexes on the three active tables for efficient per-user queries. Volume wiped and recreated since no real data existed yet (cleanest migration path).app.py:IS_SPACE = bool(os.environ.get("SPACE_ID"))β HF sets this env var automatically on Spaces; False locallyget_user_id(profile: gr.OAuthProfile | None) β str | Noneβ returnsprofile.usernameon Space (logged in),Noneon Space (logged out, blocks access),"dev_user"locally (bypasses auth)gr.LoginButton/gr.LogoutButtonrendered conditionally only whenIS_SPACEis True β avoids broken OAuth clicks in local dev- All event handlers (
process_text,toggle_colors,show_word_card) now acceptprofile: gr.OAuthProfile | None; Gradio auto-injects the current session's profile. Unauthenticated calls return a lock-screen prompt instead of content on_load(profile)replaces the olddemo.loadcall β checks auth, shows username in header, auto-annotates sample text for authenticated usersuser_displayMarkdown component in header showsπ€ usernamewhen logged in,π local devwhen running locally
- Gotcha hit:
gr.Markdowndoesn't acceptscale=β must wrap ingr.Column(scale=0)to control header layout width
Days 4β9 β 2026-06-06 β LLM word cards, notebook persistence, chat, exercises, gamification
What changed (plain English)
The app went from a clever annotation demo to a full French learning companion in one session. Click any word and you'll now see its English meaning and a grammar tip fetched live from the AI β shown instantly from cache if you've clicked it before. You can save your lesson notes with one click and the AI gives the page a sensible title automatically; all your saved pages appear in a sidebar and survive a browser refresh. A chat panel lets you ask any French question in plain English and get a helpful, encouraging answer. The Exercises tab has four types of practice generated directly from whatever you're studying: fill-in-the-blank, spoken dialogue (type your lines, the app reads the agent's lines aloud), photo-based exercises (upload a cafΓ© menu or street sign and get French exercises from it), and pronunciation practice (speak a phrase, the app transcribes it and gives gentle feedback). Every action earns points β they only ever go up β and the Summary tab shows an encouraging recap of the day's wins.
What changed (technical)
New modules (all in root):
nlp.pyβ spaCy helpers extracted fromapp.py:annotate(),render_html(),_legend(). Lazy-loads model on first call.llm.pyβ OpenBMB API clients (text + vision). Auto-detects served model name via/v1/models; falls back to env varMINICPM_MODEL/MINICPM_VISION_MODEL, then hardcoded name.chat()supports streaming via generator.chat_json()strips markdown code fences before parsing.get_word_meaning()andgenerate_page_title()are thin wrappers overchat_json.prompts.pyβ All LLM prompt templates in one place. Encouraging-tone constraint enforced here: feedback prompts explicitly ban the words wrong, error, mistake, fail, weak. Prompts for: word meaning, page title, text exercise, dialogue scene, dialogue feedback, visual exercise, daily summary, pronunciation target, pronunciation feedback.db.pyβget_cursor()context manager. New connection per call (psycopg2 thread-safety). Commits on clean exit, rolls back on exception.models.pyβPageandExercisedataclasses mirroring DB schema.notebook.pyβsave_page()(LLM title β DB insert),list_pages(),get_page(),update_annotations().exercises.pyβgenerate_text_exercise(),generate_dialogue(),dialogue_feedback(),generate_visual_exercise()(PIL β base64 β vision LLM β text LLM),generate_pronunciation_target(),get_pronunciation_feedback(). All save toexercisestable. HTML renderers co-located with generators.gamify.pyβtry_daily_open()(once-per-day guard),add_points(),get_total_points(),get_daily_stats()(5-column single-query),get_daily_summary()(LLM-generated with fallback). Point values: daily_open=5, saved_lesson=10, exercise_done=5, dialogue_turn=3, pronunciation=5, word_explored=1, photo_exercise=8.
app.py (major rewrite):
- 4-tab layout: Notebook | Chat | Exercises | Summary using
gr.Tabs user_id_state = gr.State(None)set inon_loadβ all handlers use this instead of threadingprofileeverywhere- Day 4 word card β
show_word_card()is now a generator: yields basic spaCy card immediately (< 1ms), then yields LLM-enriched card after API call. Meaning cached inann_state["meanings"][lemma]so repeat clicks are instant. Points awarded on first click per word. - Day 5 notebook β save/load/sidebar wired up;
pages_dropdownpopulated on load and after save. - Day 6 chat β
gr.Chatbotwith streaming via generator; lesson text passed as context in system prompt viaadditional_inputs. - Day 7 dialogue β
dialogue_stateholds full JSON + replies list; eachsend_dialogue_reply()call advances the turn, fetches LLM feedback, and updates the transcript HTML. - Day 8 visual β
gr.Image(type="pil")β PIL Image passed toexercises.generate_visual_exercise(). - Day 8 gamification β points awarded for every meaningful action; Summary tab triggers
get_daily_summary(). - Day 9 pronunciation β
speak_btn.click(fn=None, js=...)runs Web Speech API entirely client-side (no Python); transcript lands in thepronunciation-inputtextbox via the same React-setter trick as the word-click bridge. - Gotcha hit:
theme=also moved tolaunch()in Gradio 6 (same asjs=). requirements.txtβ addedPillowexplicitly.
LLM Backend Pivot β 2026-06-06 β Switched from OpenBMB to HF Inference (local) + ZeroGPU (Space)
What changed (plain English)
The free OpenBMB API we were using for the AI stopped accepting our key (returned "Unauthorized"). Rather than wait for it to come back, we switched to a more stable arrangement: when you're running the app locally, it now calls Hugging Face's hosted inference service using your HF token. When the app is deployed as a public Space, it will use ZeroGPU β a free GPU provided by Hugging Face that runs the model directly on the server. Both paths are handled by the same code; a single environment variable (LLM_BACKEND) controls which one runs. The working model for local dev is Qwen/Qwen2.5-7B-Instruct, which has an active HF Inference endpoint and gives sensible French coaching responses. Vision (photo exercises) still uses the OpenBMB vision endpoint as a fallback β MiniCPM-V isn't yet available on HF Inference.
Also fixed: the Chat Coach tab was broken β it was sending messages in the old Gradio tuple format (pairs of [user, assistant] strings) but Gradio 6.16 expects a flat list of {"role": ..., "content": ...} dicts. This was the error visible in the screenshot. Multi-turn conversation (context carried across messages) confirmed working after the fix.
What changed (technical)
llm.py(full rewrite) β three-backend router controlled byLLM_BACKENDenv var:huggingface_inferenceβInferenceClient.chat_completion()fromhuggingface_hub >= 0.24; supports streaming; lazy-init singleton. Default for local dev.zerogpuβ@spaces.GPUdecorated function created at module load time (required by the ZeroGPU runtime). Ifimport spacesfails (not on a Space), gracefully falls back toopenbmb. For Space deploy only.openbmbβ original OpenBMB OpenAI-compatible client; kept as legacy fallback. Vision stays on this endpoint.
.envβLLM_BACKEND=huggingface_inference,HUGGINGFACE_MODEL=Qwen/Qwen2.5-7B-Instruct(tested; confirmed working). OpenBMB keys kept for vision fallback..env.exampleβ documents all three backends and why ZeroGPU is Space-only.requirements.txtβ addedhuggingface_hub>=0.24(minimum forInferenceClient.chat_completion).requirements-space.txt(new file) βtransformers>=4.40,accelerate>=0.30,torch>=2.2; only installed on the Space (would bloat local image significantly).app.pyβ fixedchat_fn: history is now built/yielded in Gradio 6 messages format ({"role": ..., "content": ...}dicts). History iteration usesisinstance(item, dict)to handle both formats gracefully.history[-1]["content"] += chunkreplaceshistory[-1][1] += chunk.- Gotcha hit:
openbmb/MiniCPM4.1-8B-Instructdoesn't exist on HF Hub under that ID.openbmb/MiniCPM4-8Bexists but has no enabled inference provider.Qwen/Qwen2.5-7B-Instructconfirmed working β chat, streaming, and multi-turn all verified inside the Docker container.
Sprint Day 1 β 2026-06-09 β Smart Lesson Browser with Auto-Category Detection
What changed (plain English)
The sidebar on the Notebook tab is completely new. Instead of a plain dropdown list, it now shows all 40 of your saved lessons in two collapsible sections: By Date (newest first) and By Topic (auto-detected). Hover over any lesson and a tooltip pops up with the first 100 characters as a preview. Type in the search box to instantly filter by title β no page reload. Click any lesson in either section to load it straight into the editor. The app automatically guesses the topic (Grammar, Food & Dining, Greetings, Weather, etc.) by scanning the first 300 characters of each lesson for French vocabulary patterns; existing lessons got 11 distinct categories assigned on load.
What changed (technical)
db/init.sqlβ addedmetadata JSONB DEFAULT '{}'column topagestable; added GIN index on metadata. Migration applied live viaALTER TABLE pages ADD COLUMN IF NOT EXISTS metadata JSONB.nlp.pyβ two new functions:detect_category(text)β keyword scoring over 13 topic buckets (Greetings, Numbers, Grammar, Food & Dining, Transportation, Family, Time & Calendar, Shopping, Weather, Daily Life, Health, Places & Directions, Hobbies & Leisure). spaCy NER gives a +1 bonus to LOC-matching categories only if they already have keyword matches β NER reinforces, never creates. This preventsLOCentities from hijacking every lesson that mentions a city name.get_lesson_categories(pages)β groups a list of page dicts by detected category; returns an alphabetically sorteddict[category β [pages]].
notebook.pyβlist_pages()now queriesLEFT(raw_text, 300) AS snippet+metadata->>'category'in a single query. If stored category is blank (all pre-existing pages), it falls back todetect_category(snippet).save_page()now writes detected category intometadataat insert time so future queries are instant.app.pyβ major sidebar refactor:_safe_attr(s)β HTML attribute escaper (handles",',&, newlines)._render_sidebar_html(user_id)β builds the full collapsible sidebar HTML: search input, By Date<details open>, By Topic<details>(collapsed by default), hover tooltip div. Each lesson item is a<div class="fc-lesson-item" data-page-id="..." data-preview="...">card.- Removed
_page_choices()and allgr.Dropdown(choices=...)returns from handlers. Every handler that previously returned a Dropdown update now returns_render_sidebar_html(user_id)instead. - Sidebar UI:
pages_dropdown(Dropdown) βpages_sidebar_html(HTML) +sidebar_page_click(hidden Textbox). The hidden textbox receives a page UUID from JS and triggersload_page_handler. - Event wiring:
pages_dropdown.changeβsidebar_page_click.change;refresh_pages_btnβrefresh_sidebar_btn. PAGE_JSextended:window.fcSidebarSearch(q)filters.fc-lesson-itemdivs client-side; mouseover/mousemove/mouseout handlers position the preview tooltip; click on.fc-lesson-itemhighlights it, hides tooltip, and writes page UUID to the hidden Gradio textbox using the React-setter trick.
- Gotcha hit: spaCy NER assigns
LOCto many common French nouns (any proper noun can be detected as location). Giving unconditional +2 bonus caused 23/40 lessons to land in "Places & Directions". Fix: NER only reinforces (+1) categories that already have keyword matches. Result: 40 lessons spread across 11 categories.
Sprint Day 2 β 2026-06-09 β Curator pass, Resources tab, real lesson dates, editable titles
What changed (plain English)
The notebook now tells the difference between a class lesson and a "resource" page (your book list, online resource links, listening log) β resource pages are pulled out of the lecture sidebar and shown in a brand-new π Resources tab as nice link cards (with site icons) and a book list, instead of cluttering your lessons. Every saved page now also gets a friendlier auto-generated title and a one-line summary, and you can rename any page yourself with the new title field + "βοΈ Rename" button above the editor. The 20 imported "Class 1.1" β¦ "Class A2 U2 L2" lessons now have real, spaced-out dates running from April 28 through June 5, 2026, so they sort correctly in the "By Date" view.
What changed (technical)
prompts.pyβ replaced the oldPAGE_TITLE_SYSTEM(title-only) withCURATOR_SYSTEM: a single prompt that classifies a page as"lesson"or"resource"and returns{title, summary, page_type, links[], books[]}in one JSON response. Rules enforce Title Case titles, emptylinks/booksfor lessons, real URLs only, and""(never"N/A") for unknown book authors.curator.py(new) βcurate_page(raw_text)callsllm.chat_json(CURATOR_SYSTEM, ...), with a text-derived fallback (_fallback) if the LLM is unavailable. Sanitizes/truncates all fields (title β€80, link label β€120, book title β€200, etc.) and validatespage_type.notebook.py:save_page()now callscurator.curate_page()instead of the oldllm.generate_page_title(); storessummary,page_type,links,booksinmetadataalongsidecategory.list_pages()also returnspage_type(defaults to"lesson"for old rows).- New
list_resources(user_id)β returns resource-type pages with theirlinks/booksfor the Resources tab. - New
update_title(page_id, user_id, title)β lets the user override the auto-generated title.
llm.pyβ removed the now-unusedgenerate_page_title().app.py:- New
_safe_html()(escapes&<>for text content, vs._safe_attr()for attributes) and_domain(url)helpers. _render_sidebar_html()filters outpage_type == "resource"pages β they no longer appear in the lecture browser.- New
_render_resources_html(user_id)renders a.fc-resourcesblock: one.fc-resource-sectionper resource page, each with a.fc-link-gridof.fc-link-cards (Google favicon + label + domain, opens in a new tab) and/or a.fc-book-listof.fc-book-rows (π title + author Β· note). New CSS added for all of these. - New π Resources tab (between Notebook and Chat Coach) with a refresh button wired to
_render_resources_html. - Notebook tab: new
title_inputtextbox +rename_btn(hidden until a page is loaded/saved) above the editor, wired via newrename_page_handler.save_page_handler,load_page_handler,delete_page_handler,sidebar_click_handlerall updated to populate/clear the title field and toggle the rename button β required careful attention to keep return-tuple order in sync with eachoutputs=[...]list.
- New
backfill_class_dates.py(new, one-time, run from host) β assigns the 20 "Class N M" / "Class A2..." lessons consecutive dates starting April 28, 2026, every 2 days, in their natural curriculum order (1.1β1.5, 2.1β2.7, A2 1-3, A2 U1 L4-6, A2 U2 L1-2), ending June 5, 2026.backfill_curator.py(new, one-time, run inside the container) β re-runs the curator pass over all 36 existing pages so old lessons get the new friendly titles/summaries/page_type without the user re-saving anything. Result: 33 pages classified"lesson", 3 classified"resource"("Online Resource", "Book Recommendations", "Listening Log").- Gotcha hit: new
.pyfiles aren't visible inside the app container untildocker compose up -d --build(no volume mount) β hit this for bothcurator.pyandbackfill_curator.py. - Gotcha hit: first curator pass on "Book Recommendations" produced a lowercase title and
"author": "N/A"for a book with no listed author. Fixed by tighteningCURATOR_SYSTEM's title/author rules; full 36-page backfill confirmsauthor: ""instead of"N/A".
Sprint Day 3 β 2026-06-10 β Coach Agent generates self-checked mixed exercises
What changed (plain English)
The Exercises tab has a new π§ Coach practice set. Press "Generate" and the coach reads your current lesson, picks a balanced mix of 5β7 exercises (fill-in-the-blank, multiple choice, find-the-change, put-the-words-in-order, and translation), and walks you through them one at a time. Every answer gets warm, encouraging feedback right away β even when an answer isn't quite right, you still earn points and get a gentle tip toward the model answer. Behind the scenes, the coach also quietly notes which grammar topics your lesson covers, so the daily summary can name them as strengths and suggest what to try next.
What changed (technical)
prompts.pyβ added the Coach Agent's prompt set:COACH_PLAN_SYSTEM/coach_plan_user(identify 1-4 syllabus concept IDs + plan 5-7 items mixing the 5 exercise types),COACH_EXERCISE_SYSTEM/coach_exercise_user(per-type JSON shapes, with arevise_notehook for retries),COACH_CRITIQUE_SYSTEM/coach_critique_user(reviewer pass: correctness, single unambiguous answer, MC distractors, reorder word-set match, A1-A2 level), andCOACH_CHECK_SYSTEM/coach_check_user(lenient grading for free-text types β accepts spelling/accent variation, never uses shaming language). Also restoredTEXT_EXERCISE_SYSTEM(still used byapp.py's themed-Blocks fallback) and extendedDAILY_SUMMARY_SYSTEM/daily_summary_userto weave in covered/next concepts.exercises.pyβ new Coach Agent section:generate_exercise_set()runs PLAN β GENERATE β CRITIQUE β REVISE β RETURN;_generate_and_critique()bounds each item to 2 generation attempts, feeding the critique'sissueback as a revise hint if the first attempt fails review;_FALLBACK_EXERCISESgives one real exercise per type if the LLM is unreachable;_load_a1_a2_concepts()loads the A1/A2 slice ofsyllabus_full_a1_c2.jsonas the grounding menu;_mark_concepts_covered()upserts identified concepts intoconceptswithcovered_on = today;check_coach_exercise()grades fill_blank/multiple_choice by exact match and the other three types via the lenient LLM check, always awardingexercise_donepoints. Also restoredgenerate_text_exercise()/render_text_exercise()/render_exercise_feedback()(still called byapp.py).gamify.pyβ newget_concepts_progress()reads covered concept IDs from the DB and returns{covered, next}against the A1/A2 syllabus order;get_daily_summary()and the_fallback()text now both use this for "strengths + next focus".app_custom.pyβ newPOST /api/exercises/coachandPOST /api/exercises/coach/check, replacing the old single-item/api/exercises/textendpoints.frontend/βapi.jsswapsgenerateTextExercise/checkTextExerciseforgenerateCoachSet/checkCoachExercise;Exercises.jsxreplaces the old single fill-in-the-blankTextExercisewithCoachExercises, a one-item-at-a-time flow covering all 5 types (including a click-to-build word-chip UI forreorder), with a blue "nice try" / green "exactly right" feedback card β no red states; new styles inApp.css.API_CONTRACT.mdupdated to match.- Verified end-to-end via curl against the running
app-customcontainer:/api/exercises/coachreturned 2 grounded concepts + 7 mixed exercises (one per type plus extras) in ~18s; concepts were upserted withcovered_on = CURRENT_DATE;/api/exercises/coach/checktested for all 5 types (exact-match for fill_blank/multiple_choice, lenient LLM grading accepting missing accents/spaces for the rest, no shaming language);exercise_donepoints (+5 each) recorded;/api/summarynow names the covered concepts as strengths and suggests the next one. - Gotcha hit: the first full implementation pass removed
generate_text_exercise/render_text_exercise/render_exercise_feedback/TEXT_EXERCISE_SYSTEM, whichapp.py(the README-documented fallback Blocks UI) still calls. Restored all four in clearly-labeled "kept for app.py's fallback" sections before committing β keeps the degrade-gracefully fallback intact.
Sprint Day 4 β 2026-06-10 β Matched-image visual exercises + reliable TTS playback
What changed (plain English)
The Visual exercise tab now has a "β¨ Sample photo" mode β no upload needed. The app picks a photo that matches what your current lesson is about (a cafΓ© menu for food vocabulary, a mΓ©tro sign for transport, etc.) from a set of 15 ready-made scenes, and builds 3-5 exercises with hints from it. It keeps track of which photos you've already practiced with so you keep seeing fresh ones as you go. You can still upload your own photo in "π€ Upload your own" mode. Separately, every "π hear it" button β word cards, dialogue lines, pronunciation targets β now reliably speaks in a French voice instead of sometimes falling back to a default English-sounding one on first use.
What changed (technical)
generate_sample_images.py(new, one-off) β generates 15 topic-themed images via HFInferenceClient.text_to_image(..., model="black-forest-labs/FLUX.1-schnell"), one per topic bucket fromnlp.detect_category(Food & Dining and Daily Life have 2 each). Each entry also has a hand-written Englishdescriptionembedding the relevant French vocabulary β this is what grounds the exercises, not OCR/vision, since FLUX doesn't render legible in-image text reliably. Resizes to 640x640 JPEG (q=82, 46-114KB each, ~970KB total) and writesfrontend/public/sample_images/manifest.json. Run once inside theapp-customcontainer (no local Python env) and the outputdocker cp'd back to the host.db/init.sqlβ newuser_image_usage(user_id, image_id, used_at)table + index, applied to the running Postgres so the matched-image picker can avoid repeats per user.prompts.pyβ newVISUAL_TOPIC_EXERCISE_SYSTEM/visual_topic_exercise_user: builds 3-5 exercises (vocabulary/translation/question) with ahintfield from an image'sdescription+ (optionally) the current lesson text. Kept separate from the existing upload-flowVISUAL_EXERCISE_SYSTEM(2-3 exercises, no hints) so that flow's behavior is unchanged.exercises.pyβ new section:_load_sample_images()(cached manifest read),pick_sample_image(topic, user_id)(topic + unseen first, then any unseen, then least-recently-used, thenimages[0]as a final fallback),_mark_image_used(),generate_visual_topic_exercise(image, lesson_text, user_id)(callsllm.chat_jsonwith the topic prompt β no vision call).render_visual_exercises()extended to render ahintline per exercise when present, shared by both the upload and sample flows.app_custom.pyβ newPOST /api/exercises/visual/sample: detects the lesson's topic vianlp.detect_category, callspick_sample_image+generate_visual_topic_exercise, awardsphoto_exercisepoints, returns{image_url, topic, html}.frontend/βExercises.jsx'sVisualExerciseis now a mode toggle (β¨ Sample photo/π€ Upload your own, reusing the existing.fc-subtabstyles) over two components: newSampleVisualExercise(callsgenerateSampleVisualExercise, shows the matched image + exercises, "π Try another photo" to re-roll) andUploadVisualExercise(the original upload flow, unchanged).api.jsaddsgenerateSampleVisualExercise.App.cssadds one rule (.fc-visual-modes).API_CONTRACT.mddocuments the new endpoint.tts.jsβgetVoices()returns[]on first call in Chrome until thevoiceschangedevent fires, sospeak()/speakAll()could silently use a non-French default voice on first use. Now caches the voice list, refreshes it onvoiceschanged, and explicitly setsutterance.voiceto anfr-FR(or anyfr-*) voice when available, while still settinglang = 'fr-FR'as a baseline.- Verified end-to-end via curl against the rebuilt
app-customcontainer: a Food & Dining lesson text matchedfood_dining.jpgwith 3 hinted exercises; a second call for the same user cycled tofood_dining_2.jpg(confirmed viaSELECT * FROM user_image_usage); a Greetings lesson matchedgreetings.jpg; emptylesson_textfell back toDaily Life;/custom/sample_images/food_dining.jpgreturns200 image/jpeg;photo_exercisepoints (+8) recorded for each call. - Regression check:
/api/exercises/pronunciation/targetand/api/exercises/pronunciation/checkstill work correctly after theexercises.py/prompts.pychanges. The upload-based/api/exercises/visualcurrently returns a401 Unauthorizedfrom the OpenBMB vision endpoint β pre-existing and unrelated to this session's changes (no edits tollm.py); CLAUDE.md already flags this endpoint as subject to change. The new sample-photo flow has no dependency on it, since it doesn't call the vision model.
Sprint Day 5 β 2026-06-10 β Gender Checker, Translator, and a real Summary dashboard
What changed (plain English)
Two new tools live under the π€ Tools tab: a Gender Checker β type any French noun and instantly see its gender, articles (le/la, un/une), an example sentence, and a memory tip β and a Translator for EnglishβFrench with alternative phrasings and an in-context example you can hear spoken aloud. The β Summary tab is now a real dashboard: your total points, today's activity (lessons saved, exercises done, dialogue turns, words explored), a progress bar of A1-A2 concepts covered so far, and a gentle "ready to practice next" suggestion β alongside the existing encouraging recap. The app also picked up a small French-flag favicon. Just before this, the photo-exercise feature was simplified to drop the (currently non-working) photo-upload option, keeping only the "pick a matching photo for your lesson" mode that already works well.
What changed (technical)
- Pre-Day-5 cleanup (
91ee466): removed the upload-based visual exercise entirely βExercises.jsx's mode toggle andUploadVisualExercise,api.js'sgenerateVisualExercise, and the/api/exercises/visualendpoint (plus its now-unusedUploadFile/File/Form/PIL.Image/ioimports inapp_custom.py).VisualExerciseis now just the working sample-photo flow.app.py(the Blocks fallback) is unaffected β it callsexercises.generate_visual_exercise/llm.vision_chatdirectly, not the removed endpoint. nlp.pyβ newword_info(word): spaCy lemma + POS for a single word, instant/offline. Gotcha: spaCy'sfr_core_news_smmorphologizer needs determiner-agreement context to tag noun gender correctly β an isolated "pomme" tagsGender=Masc(wrong; it's feminine) while "la pomme" correctly tagsFem. So gender/articles for the Gender Checker come from the LLM, not spaCy;word_infoonly supplieslemma/posas a hint.prompts.pyβ newGENDER_CHECK_SYSTEM/gender_check_user(gender, le/la, un/une, example + translation, a memorable "pattern note"). NewTRANSLATE_SYSTEM/translate_user, revised mid-session: the LLM was inconsistent about whetherexample/example_translationheld the source or target language regardless of direction, so the schema is now language-explicit βexample_fris always French,example_enis always English.llm.pyβget_gender_check(word, pos)andtranslate_text(text, direction, lesson_text), bothchat_jsonwrappers with offline-safe fallbacks.gamify.pyβget_concepts_progress()now also returnscovered_count/total_count(size of the A1-A2 syllabus slice) for the dashboard's progress bar.app_custom.pyβ newPOST /api/gender-check(combinesnlp.word_info+llm.get_gender_check) andPOST /api/translate.GET /api/summaryextended to also returndaily_stats(fromgamify.get_daily_stats) andconcepts(fromgamify.get_concepts_progress).frontend/βTools.jsxrestructured into three subtabs reusing the.fc-subtabspattern: Gender Checker and Translator (both new) plus the existing paste-and-annotate flow renamed Text Checker.App.jsxnow passeslessonTexttoToolsso the Translator can offer "use my current lesson as context".Summary.jsxgained a stats grid, a concepts-covered progress bar with pills for recently-covered concepts, and a next-focus line. New CSS inApp.css:.fc-gender-result/.fc-gender-pills/.fc-gender-example/.fc-gender-pattern,.fc-translate-result/.fc-translate-main/.fc-translate-alts/.fc-translate-example,.fc-btn-icon(small inline speak buttons), and.fc-summary-stats/.fc-stat-card/.fc-summary-progress/.fc-progress-bar/.fc-progress-fill/.fc-summary-pills/.fc-summary-next.api.jsaddsgenderCheck/translateText.- Polish: replaced the default Vite favicon with a small French-tricolor square (
frontend/public/favicon.svg), referenced via<link rel="icon">inindex.html(Vite rewrites this to/custom/favicon.svgfor the Space-root build, served by the existing/customStaticFiles mount). - Verified end-to-end via curl against the rebuilt
app-customcontainer:/api/gender-checkfor "pomme" βFem/la/une(correct) and "arbre" βMasc/l'/un(correct vowel elision);/api/translateboth directions return the newexample_fr/example_enshape correctly;/api/summaryreturnsdaily_stats+conceptswithcovered_count/total_count;/custom/favicon.svgreturns200. API_CONTRACT.mdupdated: new/api/gender-check//api/translatesections,/api/summaryresponse shape, and the Tools screen's endpoint map.
Notion-style block editor β 2026-06-11 β A real notebook editor, not a textarea
What changed (plain English)
The Notebook's plain text box is now a proper block-based note editor, like Notion. Type # for a heading, - or 1. for a list, > for a highlighted note/quote, and --- for a divider β each converts as you type. Select any text to get a small floating toolbar for bold, italic, and strikethrough. Typing / on an empty line opens a menu to insert any block type. Everything you already use β gender colors, the word card, Save/Update/Delete, Chat, Exercises, Tools β keeps working exactly as before, and old lessons saved before this change open up just fine.
What changed (technical)
frontend/src/blocks.js(new) β pure helpers, no React.markdownToBlocks/blocksToMarkdownround-trip a small internal Markdown-ish dialect (# /## /###headings,-/*/1.lists,>quotes,---dividers,**bold**/*italic*/~~strike~~inline) to/from{id, type, html}block objects.blocksToPlainText/stripMarkdownstrip all markers for spaCy/LLM context.frontend/src/components/BlockEditor.jsx(new) β renders onecontentEditableelement per block (grouped<ul>/<ol>for consecutive list items). Uncontrolled-DOM pattern with ref callbacks + apendingFocusstate to restore caret position after structural edits (split on Enter, merge on Backspace, type-conversion via shortcuts or the/slash menu, exit-list on empty Enter). Aselectionchangelistener shows a floating Bold/Italic/Strikethrough toolbar usingdocument.execCommand.- Storage stays a single string β
raw_text/textis now this Markdown dialect instead of plain prose, but it's still just a string: no DB schema change, no API change, no new dependencies. Old plain-prose lessons parse as one paragraph block automatically. frontend/src/screens/Notebook.jsxβ swapped the<textarea>for<BlockEditor key={lessonId ?? 'new'} value={text} onChange={setText} />;/api/annotatecalls and thelessonTextsent to Chat/Exercises/Tools now usestripMarkdown(text)so spaCy/the LLM never see#/-/**/>markers.frontend/src/App.cssβ new block-editor styles:.fc-block-editorcontainer, heading sizes,.fc-block-quote(reuses the.fc-gender-patternaccent look with a left border),.fc-block-divider, list spacing,.fc-slash-menu/.fc-floating-toolbar(absolute-positioned dropdown/pill).- Bug found and fixed during testing: the
#/-/>/etc. auto-format shortcuts changed a block'stype(e.g.<p>β<h1>) but never restored focus to the new DOM element React creates for the new tag, so subsequent keystrokes went nowhere. Fixed by settingpendingFocus({ id, position: 'start' })after the type conversion in BlockEditor.jsx. - Verified via Playwright against the rebuilt
app-customcontainer: all block types + shortcuts + the/slash menu + floating toolbar work; an existing 55-block real lesson (with a Markdown table from the Notion import) loads with zero console errors; a new lesson with heading/bold/list/quote round-trips correctly through Save β Lessons search β reopen; gender-color annotation on the new content shows clean prose with no Markdown leakage.
Exercises & Tools UX upgrades β 2026-06-11 β Practice on your own topic, with help nearby
What changed (plain English)
Every exercise type β Coach, Dialogue, Visual, and Pronunciation β now has an optional "topic" box: leave it blank and the coach picks the topic for you (as before), or type something like "ordering food" or "le passΓ© composΓ©" to steer what gets generated. Visual (photo) exercises now generate at least 5 questions, and each one has its own answer box and a "Check answer" button with the same gentle, encouraging feedback as the other exercises β no more "show answer" only. While doing any exercise, a new π§ Tools button opens the Gender Checker and Translator in a side panel, so you can look something up without losing your place. The Translator (in Tools and in this new side panel) can now show up to 3 translators side by side, each with its own direction (EnglishβFrench or FrenchβEnglish) β handy for checking a few words or a sentence at once.
What changed (technical)
prompts.pyβcoach_plan_user, newdialogue_user, andvisual_topic_exercise_userall gained an optionaltopic: str = ""that appends a "Focus topic requested by the learner" line to the prompt when non-blank.coach_check_user's content fallback chain now also checksexercise.get("content"), needed because visual exercises store their prompt text undercontent.VISUAL_TOPIC_EXERCISE_SYSTEMnow asks for "5-6" exercises (was "3-5") to guarantee the user's "at least 5" requirement.exercises.pyβgenerate_exercise_set,generate_dialogue,generate_visual_topic_exercise, andgenerate_pronunciation_targetall take an optionaltopic: str = ""and thread it into the prompts above (with sensible defaults preserving old behavior forapp.py's Gradio mockup, which is otherwise untouched).app_custom.pyβ/api/exercises/coach,/api/exercises/dialogue,/api/exercises/visual/sample, and/api/exercises/pronunciation/targetall read an optionaltopicfrom the payload. For visual, if a topic is given,nlp.detect_category(topic)is tried first to pick the sample image (falling back to the lesson-based detection if the topic doesn't match a known category) β so e.g. typing "ordering food" can surface a Food & Dining photo even from an unrelated lesson. The visual endpoint no longer returns pre-renderedhtml; it returns{image_url, topic, image_summary, exercises}so the frontend can render interactive cards.llm.pyβchat_jsonnow takes an optionalmax_tokens(default 512, forwarded tochat()). Gotcha hit during testing: with the visual prompt now asking for 5-6 exercises, the JSON response was getting cut off at the default 512 tokens and silently falling back to{"exercises": []}. Fixed by callinggenerate_visual_topic_exercise'schat_jsonwithmax_tokens=1536.frontend/src/components/QuickTools.jsx(new) βGenderChecker(moved verbatim fromTools.jsx),TranslatorWidget(the oldTranslator, now with an optional "β remove" button), andTranslatorPanel(manages 1-3TranslatorWidgets in a responsive grid, "+ Add another translator" up to 3, hides remove buttons at 1). Shared byTools.jsxand the new Exercises side panel.frontend/src/screens/Tools.jsxβ now importsGenderChecker/TranslatorPanelfromQuickToolsinstead of defining them locally;TextCheckerand the screen wrapper are unchanged.frontend/src/screens/Exercises.jsxβVisualExerciserewritten: dropsdangerouslySetInnerHTML={{__html: data.html}}for React-rendered cards (one perdata.exercises[i]), each with its own{answer, feedback, checking, error}state, an<input>+ "Check answer" calling the existingcheckCoachExercise(same grading endpoint Coach exercises use), and feedback rendered with the same.fc-coach-feedback*classes.data.image_summaryshows as an italic caption under the photo. All four exercise components gained a topic<input>next to their generate/start button. The top-levelExercisescomponent gained a "π§ Tools" toggle, a.fc-exercises-layouttwo-column layout when open, and a stickyToolsPanel(mini Gender/Translate subtabs + "β Close") β this state lives above the per-subtab components, so it survives switching between Coach/Dialogue/Visual/Pronunciation.frontend/src/App.cssβ new.fc-exercises-layout(1fr/320px grid, collapses under 900px),.fc-tools-panel(sticky),.fc-translator-grid(responsiveauto-fitgrid for 1-3 translators),.fc-translator-widget/.fc-translator-remove(relative positioning for the "β"),.fc-visual-summary(italic caption), and.fc-translate-resultnow gets its ownmargin-top/border-topseparator since it's no longer nested inside a second.fc-card.- Verified via curl against the rebuilt
app-customcontainer:/api/exercises/visual/samplenow returns 5 structured exercises (previously fell back toexercises: []until themax_tokensfix) withimage_summary;/api/exercises/coach/checkcorrectly grades a visualvocabulary-type exercise via itscontentfield;/api/exercises/pronunciation/targetwithtopic: "ordering coffee at a cafΓ©"returns a phrase grounded in that topic.npm run build+docker compose up -d --build app-customsucceeded; served bundle hashes confirmed up to date.
Day 10 β 2026-06-15 β Live on Hugging Face Spaces (hackathon deadline day)
What changed (plain English)
French Coach is now live at https://build-small-hackathon-french-coach.hf.space under the build-small-hackathon org. Open it in any browser and you'll see the full themed Gradio UI β notebook sidebar, gender-coloured text, word cards, chat coach, all four exercise types, and the daily summary β all powered by MiniCPM4.1-8B via the OpenBMB API. This is the hackathon submission build.
What changed (technical)
README.mdβapp_file: app_custom.pyβapp_file: app.py(Gradio Blocks UI as entry point). The React / FastAPI custom UI (app_custom.py) is preserved in the repo for post-hackathon use, but the Gradio Blocks UI is the correct HFsdk: gradioentry point: the HF runner imports the module, finds thedemovariable, and callsdemo.launch()itself β no port conflict.app.pyβ Two HF Space compatibility fixes:gr.LoginButton+gr.LogoutButtonremoved: in Gradio 6, having aLoginButtontriggers OAuth setup, which requireshf_oauth: truein Space metadata and theOAUTH_CLIENT_IDsecret β neither configured. Their removal lets the app start cleanly.css,theme,jsmoved fromdemo.launch()args to thegr.Blocks()constructor: the HF SDK runner callsdemo.launch()without our custom args, so the only way to guarantee the French-themed CSS and JS fire is to bake them into theBlocksobject at definition time. Gradio 6 emits aUserWarningabout this (they want them inlaunch()), but the warning does not prevent the app from loading.
llm.pyβ Removedimport spacesand@spaces.GPUentirely fromllm.py(they belonged in the HFapp_fileper ZeroGPU static scan rules). Addedregister_gpu_fn(fn)injection point soapp_custom.pycan wire in the GPU function without a circular import β ready for when we re-enable ZeroGPU hardware.app_custom.pyβ Added@spaces.GPUfunction at the very top of the file (the correct location for ZeroGPU static scan), with atry/except ImportErrorso local dev works without the HF-pre-installedspacespackage. Callsllm.register_gpu_fn()right after import to wire it in.requirements.txtβ Addedtransformers>=4.40,accelerate>=0.30(needed for the ZeroGPU model-load path; harmless on cpu-basic).spacesintentionally NOT added β HF pre-installs the real ZeroGPUspacespackage;pip install spacesinstalls a different PyPI package that breaks the GPU function registration.- Hardware / secrets β Space changed from
zero-a10gtocpu-basic(break-glass: avoids ZeroGPU startup check entirely).LLM_BACKEND=openbmbset as Space secret β text generation calls MiniCPM4.1-8B via the OpenBMB free API. - Gotchas hit during this session:
- ZeroGPU "No @spaces.GPU function detected during startup": fired even with
@spaces.GPUinllm.py. Root cause: HF ZeroGPU static scan only inspectsapp_file(app_custom.py), not imported modules. Moving the decorator toapp_custom.pywas correct, but we still hit the port-conflict oncpu-basic(see below). - "Address already in use 7860": with
sdk: gradio, the HF runner starts its own server; ouruvicorn.run()in__main__clashed. Fix: switch toapp.py(demo-variable pattern) where the HF runner owns the server startup. pip install spacesinstalls a different PyPIspacespackage that does not register functions with the real ZeroGPU system; removing it fromrequirements.txtunblocks ZeroGPU for future use.
- ZeroGPU "No @spaces.GPU function detected during startup": fired even with