Spaces:
Running
Running
| """ | |
| Points ledger and daily summary. | |
| Points are ALWAYS additive — never deducted. | |
| """ | |
| import json | |
| import logging | |
| import os | |
| from db import get_cursor | |
| import llm | |
| import prompts | |
| logger = logging.getLogger(__name__) | |
| _SYLLABUS_PATH = os.path.join(os.path.dirname(__file__), "syllabus_full_a1_c2.json") | |
| _a1_a2_concepts: list[dict] | None = None | |
| POINT_VALUES = { | |
| "daily_open": 5, | |
| "saved_lesson": 10, | |
| "exercise_done": 5, | |
| "dialogue_turn": 3, | |
| "pronunciation": 5, | |
| "word_explored": 1, | |
| "photo_exercise": 8, | |
| } | |
| def try_daily_open(user_id: str) -> int: | |
| """Award daily-open points once per calendar day. Returns points awarded (0 if already awarded).""" | |
| try: | |
| with get_cursor() as cur: | |
| cur.execute( | |
| "SELECT COUNT(*) AS n FROM points " | |
| "WHERE user_id = %s AND reason = 'daily_open' AND earned_at::date = CURRENT_DATE", | |
| (user_id,), | |
| ) | |
| if cur.fetchone()["n"] > 0: | |
| return 0 | |
| amount = POINT_VALUES["daily_open"] | |
| cur.execute( | |
| "INSERT INTO points (user_id, reason, amount) VALUES (%s, %s, %s)", | |
| (user_id, "daily_open", amount), | |
| ) | |
| return amount | |
| except Exception as e: | |
| logger.warning("try_daily_open failed: %s", e) | |
| return 0 | |
| def add_points(user_id: str, reason: str) -> int: | |
| """Award points for an action. Returns points awarded.""" | |
| amount = POINT_VALUES.get(reason, 2) | |
| try: | |
| with get_cursor() as cur: | |
| cur.execute( | |
| "INSERT INTO points (user_id, reason, amount) VALUES (%s, %s, %s)", | |
| (user_id, reason, amount), | |
| ) | |
| except Exception as e: | |
| logger.warning("add_points failed: %s", e) | |
| return amount | |
| def get_total_points(user_id: str) -> int: | |
| try: | |
| with get_cursor() as cur: | |
| cur.execute( | |
| "SELECT COALESCE(SUM(amount), 0) AS total FROM points WHERE user_id = %s", | |
| (user_id,), | |
| ) | |
| return int(cur.fetchone()["total"]) | |
| except Exception: | |
| return 0 | |
| def get_daily_stats(user_id: str) -> dict: | |
| try: | |
| with get_cursor() as cur: | |
| cur.execute( | |
| """SELECT | |
| (SELECT COUNT(*) FROM pages WHERE user_id=%s AND date=CURRENT_DATE) AS pages_today, | |
| (SELECT COUNT(*) FROM exercises WHERE user_id=%s AND created_at::date=CURRENT_DATE) AS exercises_today, | |
| (SELECT COUNT(*) FROM points WHERE user_id=%s AND reason='dialogue_turn' | |
| AND earned_at::date=CURRENT_DATE) AS dialogue_turns, | |
| (SELECT COUNT(*) FROM points WHERE user_id=%s AND reason='word_explored' | |
| AND earned_at::date=CURRENT_DATE) AS words_clicked, | |
| (SELECT COALESCE(SUM(amount),0) FROM points WHERE user_id=%s) AS total_points""", | |
| (user_id,)*5, | |
| ) | |
| return {k: int(v) for k, v in dict(cur.fetchone()).items()} | |
| except Exception: | |
| return {"pages_today":0,"exercises_today":0,"dialogue_turns":0,"words_clicked":0,"total_points":0} | |
| def _load_a1_a2_concepts() -> list[dict]: | |
| global _a1_a2_concepts | |
| if _a1_a2_concepts is None: | |
| try: | |
| with open(_SYLLABUS_PATH, encoding="utf-8") as f: | |
| concepts = json.load(f)["concepts"] | |
| _a1_a2_concepts = [c for c in concepts if c.get("cefr_level") in ("A1", "A2")] | |
| except Exception as e: | |
| logger.warning("_load_a1_a2_concepts failed: %s", e) | |
| _a1_a2_concepts = [] | |
| return _a1_a2_concepts | |
| def get_concepts_progress() -> dict: | |
| """Concepts the Coach Agent has identified as covered, plus the next one | |
| up in syllabus order — powers the Summary tab's strengths + next focus.""" | |
| try: | |
| with get_cursor() as cur: | |
| cur.execute("SELECT id FROM concepts WHERE covered_on IS NOT NULL") | |
| covered_ids = {r["id"] for r in cur.fetchall()} | |
| except Exception: | |
| covered_ids = set() | |
| a1_a2 = _load_a1_a2_concepts() | |
| covered = [c["name"] for c in a1_a2 if c["id"] in covered_ids] | |
| next_concept = next((c["name"] for c in a1_a2 if c["id"] not in covered_ids), None) | |
| return { | |
| "covered": covered, | |
| "next": next_concept, | |
| "covered_count": len(covered), | |
| "total_count": len(a1_a2), | |
| } | |
| def get_daily_summary(user_id: str) -> str: | |
| stats = get_daily_stats(user_id) | |
| concepts = get_concepts_progress() | |
| result = llm.chat([ | |
| {"role": "system", "content": prompts.DAILY_SUMMARY_SYSTEM}, | |
| {"role": "user", "content": prompts.daily_summary_user(stats, concepts)}, | |
| ]) | |
| if result.startswith("⚠"): | |
| return _fallback(stats, concepts) | |
| return result | |
| def _fallback(stats: dict, concepts: dict | None = None) -> str: | |
| total = stats.get("total_points", 0) | |
| pages = stats.get("pages_today", 0) | |
| ex = stats.get("exercises_today", 0) | |
| lines = [f"You've earned **{total} points** — great work!"] | |
| if pages: | |
| lines.append(f"You saved {pages} lesson{'s' if pages > 1 else ''} today.") | |
| if ex: | |
| lines.append(f"You completed {ex} exercise{'s' if ex > 1 else ''}.") | |
| covered = (concepts or {}).get("covered") or [] | |
| if covered: | |
| lines.append(f"You're building skills in: {', '.join(covered[-3:])}.") | |
| next_concept = (concepts or {}).get("next") | |
| if next_concept: | |
| lines.append(f"Ready to practice next: {next_concept}.") | |
| else: | |
| lines.append("Ready to explore next: more vocabulary and dialogue practice. 🇫🇷") | |
| return "\n\n".join(lines) | |