French-Coach / gamify.py
Asma-F's picture
Deploy: French Coach app (MiniCPM4.1-8B ZeroGPU + React frontend)
4fd1234 verified
Raw
History Blame Contribute Delete
5.83 kB
"""
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)