import datetime import spacy _nlp = None def get_nlp(): global _nlp if _nlp is None: _nlp = spacy.load("fr_core_news_sm") return _nlp def word_info(word: str) -> dict: """Lemma + POS for a single word (instant, offline) — used by the Gender Checker tool (Day 5) to give the LLM a starting point. Gender itself is NOT reliable from spaCy on an isolated word (no determiner/agreement context to disambiguate, e.g. "pomme" alone tags Masc though it's feminine), so gender/articles are determined by the LLM instead.""" word = word.strip() doc = get_nlp()(word) tok = next((t for t in doc if not t.is_space), None) if tok is None: return {"word": word, "lemma": word, "pos": None} return {"word": tok.text, "lemma": tok.lemma_, "pos": tok.pos_} def annotate(text: str) -> dict: """Run spaCy annotation. Returns annotation dict matching DB JSONB schema.""" doc = get_nlp()(text) tokens = [] for tok in doc: gender = tok.morph.get("Gender") tokens.append({ "idx": tok.i, "text": tok.text, "pos": tok.pos_, "gender": gender[0] if gender else None, "lemma": tok.lemma_, "is_space": tok.is_space, "whitespace": tok.whitespace_, }) return {"tokens": tokens, "meanings": {}} MASC_COLOR = "#4A90D9" FEM_COLOR = "#D96B8A" def render_html(annotations: dict, colors_on: bool) -> str: tokens = annotations.get("tokens", []) parts = [] for tok in tokens: if tok["is_space"]: parts.append(tok.get("whitespace", " ")) continue text = tok["text"] gender = tok.get("gender") pos = tok.get("pos", "") lemma = tok.get("lemma", text) styles = ["cursor:pointer", "padding:1px 3px", "border-radius:3px"] if colors_on and pos == "NOUN": if gender == "Masc": styles += [f"background:{MASC_COLOR}1A", f"border-bottom:2px solid {MASC_COLOR}"] elif gender == "Fem": styles += [f"background:{FEM_COLOR}1A", f"border-bottom:2px solid {FEM_COLOR}"] safe = lambda s: (s or "").replace('"', """) parts.append( f'{text}' ) if tok.get("whitespace"): parts.append(tok["whitespace"]) return ( '
' + "".join(parts) + "
" + (_legend(colors_on)) ) def _legend(colors_on: bool) -> str: if not colors_on: return "" return ( f'
' f'masc.' f'  ' f'fém.' f'
' ) # ── Category detection ──────────────────────────────────────────────────────── _CATEGORY_KEYWORDS: dict[str, list[str]] = { "Greetings": [ "bonjour", "salut", "bonsoir", "bonne nuit", "au revoir", "à bientôt", "enchanté", "bienvenue", "merci", "s'il vous plaît", "excusez", "pardon", "comment allez", "comment vas", "je m'appelle", "présentations", ], "Numbers": [ "zéro", "un ", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "vingt", "trente", "cent", "mille", "nombre", "chiffre", "numéro", "combien", "compter", "premier", "deuxième", ], "Grammar": [ "verbe", "nom ", "adjectif", "adverbe", "conjugaison", "accord", "pluriel", "singulier", "genre", "article", "pronom", "préposition", "infinitif", "participe", "subjonctif", "imparfait", "passé composé", "futur", "conditionnel", "être", "avoir", "aller", "faire", "féminin", "masculin", "accord", ], "Food & Dining": [ "manger", "restaurant", "café", "menu", "plat", "entrée", "dessert", "boisson", "cuisine", "repas", "boire", "faim", "soif", "commander", "addition", "boulangerie", "pain", "fromage", "vin", "eau", "salade", "viande", "poisson", "légume", "fruit", ], "Transportation": [ "bus", "métro", "train", "voiture", "vélo", "taxi", "avion", "gare", "aéroport", "route", "voyager", "billet", "station", "conduire", "prendre", "ligne", "direction", "quai", "arrêt", ], "Family": [ "famille", "mère", "père", "frère", "sœur", "enfant", "parent", "grand-mère", "grand-père", "fils", "fille", "mari", "femme", "oncle", "tante", "cousin", "neveu", "nièce", "bébé", ], "Time & Calendar": [ "heure", "minute", "seconde", "jour", "semaine", "mois", "année", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche", "janvier", "février", "mars", "avril", "mai", "juin", "aujourd'hui", "demain", "hier", "maintenant", "matin", "soir", "midi", ], "Shopping": [ "acheter", "magasin", "prix", "argent", "euro", "boutique", "marché", "vêtement", "soldes", "payer", "cher", "bon marché", "taille", "coûter", "centimes", "monnaie", "caisse", "vendeur", ], "Weather": [ "temps", "pluie", "pleuvoir", "soleil", "ensoleillé", "nuage", "froid", "chaud", "neige", "neiger", "vent", "température", "météo", "saison", "orage", "brouillard", "degré", ], "Daily Life": [ "maison", "appartement", "chambre", "salon", "salle de bain", "dormir", "travailler", "école", "bureau", "quotidien", "matin", "réveiller", "habiter", "vivre", "routine", ], "Health": [ "santé", "médecin", "docteur", "hôpital", "pharmacie", "médicament", "malade", "douleur", "mal", "fièvre", "rhume", "allergie", "rendez-vous", "symptôme", "corps", ], "Places & Directions": [ "tout droit", "tournez à droite", "tournez à gauche", "prenez la rue", "à droite", "à gauche", "rue ", "avenue ", "boulevard", "arrondissement", "carte routière", "plan de ville", "itinéraire", "carrefour", "code postal", "adresse postale", ], "Hobbies & Leisure": [ "sport", "musique", "cinéma", "lecture", "voyager", "jouer", "regarder", "écouter", "aimer", "loisir", "vacances", "week-end", "danse", "peinture", "jardinage", ], } _NER_TO_CATEGORY = { "LOC": "Places & Directions", "GPE": "Places & Directions", "FAC": "Places & Directions", } # English synonyms for the same categories — used only for matching a # learner-typed "topic" field (which may be in English) against the sample # image bank, since _CATEGORY_KEYWORDS above is French-only. _CATEGORY_KEYWORDS_EN: dict[str, list[str]] = { "Greetings": ["greeting", "hello", "introduction"], "Numbers": ["number", "counting"], "Grammar": ["grammar", "verb", "tense", "conjugation", "article", "agreement"], "Food & Dining": [ "food", "restaurant", "cafe", "café", "menu", "eating", "drink", "meal", "dining", "cooking", "breakfast", "lunch", "dinner", ], "Transportation": ["transport", "bus", "train", "car", "taxi", "travel", "airport", "flight", "subway"], "Family": ["family", "mother", "father", "brother", "sister", "parent", "sibling", "relative"], "Time & Calendar": ["calendar", "schedule", "appointment"], "Shopping": ["shopping", "shop", "store", "market", "clothes", "clothing", "price"], "Weather": ["weather", "rain", "snow", "forecast", "climate"], "Daily Life": ["daily life", "routine", "house", "home", "apartment", "chores"], "Health": ["health", "doctor", "hospital", "medicine", "pharmacy", "sick"], "Places & Directions": [ "place", "direction", "road", "street", "map", "location", "route", "neighborhood", "neighbourhood", "city", ], "Hobbies & Leisure": ["hobby", "leisure", "sport", "music", "movie", "reading", "vacation", "weekend"], } def detect_topic_category(topic: str) -> str: """Map a free-text topic (French or English) onto one of the image-bank categories used by the visual exercise. Tries English synonyms first — a learner typing "road" or "directions" should match "Places & Directions", but some French category keywords (e.g. "direction" for Transportation) are substrings of unrelated English topic words, so English matching must win first. Falls back to French keyword scoring (detect_category) for French-typed topics. """ if not topic: return "General" topic_lower = topic.lower() for cat, keywords in _CATEGORY_KEYWORDS_EN.items(): if any(kw in topic_lower for kw in keywords): return cat return detect_category(topic) def detect_category(text: str) -> str: """Return the most likely topic category for a French lesson excerpt. Combines keyword scoring with a light spaCy NER pass. Runs in <5 ms on a 300-char snippet because the model is already loaded. """ if not text: return "General" text_lower = text.lower() scores: dict[str, int] = {} # Keyword scoring for cat, keywords in _CATEGORY_KEYWORDS.items(): score = sum(1 for kw in keywords if kw in text_lower) if score: scores[cat] = score # NER reinforcement: only boost a category that already has keyword matches. # This prevents NER alone from overriding clear keyword signals. try: doc = get_nlp()(text[:200]) for ent in doc.ents: bonus_cat = _NER_TO_CATEGORY.get(ent.label_) if bonus_cat and bonus_cat in scores: scores[bonus_cat] += 1 except Exception: pass if not scores: return "General" return max(scores, key=scores.__getitem__) def get_lesson_categories(pages: list[dict]) -> dict[str, list[dict]]: """Group a list of page dicts by their detected category. Each page dict must have at least {id, title, date, category}. Returns an ordered dict (alphabetical by category name) mapping category → [page, ...]. """ groups: dict[str, list[dict]] = {} for page in pages: cat = page.get("category") or "General" groups.setdefault(cat, []).append(page) return dict(sorted(groups.items())) def group_by_date(pages: list[dict]) -> dict[str, list[dict]]: """Group a list of page dicts by their date (most recent first). Each page dict must have at least {id, title, date}. Pages are assumed to already be ordered newest-first within each date group. """ groups: dict[str, list[dict]] = {} for page in pages: d = page.get("date") or "Undated" groups.setdefault(d, []).append(page) return dict(sorted(groups.items(), key=lambda kv: kv[0], reverse=True)) def format_date_header(date_str: str) -> str: """Render an ISO date string ('2026-06-09') as a friendly header ('Mon, Jun 9, 2026').""" try: d = datetime.date.fromisoformat(date_str) return d.strftime("%a, %b %-d, %Y") except (ValueError, TypeError): return date_str or "Undated"