365 lines
12 KiB
Python
365 lines
12 KiB
Python
"""
|
|
Murphy Grammar Engine & Handwritten Journaling (Task 16).
|
|
Structured micro-lessons based on Raymond Murphy's Essential Grammar in Use (A1-A2)
|
|
and English Grammar in Use (B1-B2).
|
|
100% offline, zero-dependency, pure Python stdlib + SQLite.
|
|
Features:
|
|
- Evening Deep Work Mode (5-7 min bite-sized lessons).
|
|
- Contrast-based pedagogical explanations (I do vs I am doing).
|
|
- Golden formulas for physical notebook & handwritten personal journaling prompts.
|
|
- 5 Cloze Tap micro-exercises per unit.
|
|
- Automated SRS Ingestion for grammar mistakes.
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
import random
|
|
from typing import Dict, Any, List, Optional
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
from app import config
|
|
|
|
_CURATED_MURPHY_UNITS_CACHE = None
|
|
|
|
def get_curated_murphy_units():
|
|
global _CURATED_MURPHY_UNITS_CACHE
|
|
if _CURATED_MURPHY_UNITS_CACHE is None:
|
|
seeds_file = config.BASE_DIR / 'data' / 'seeds' / 'grammar_units.json'
|
|
if seeds_file.is_file():
|
|
try:
|
|
with open(seeds_file, 'r', encoding='utf-8') as f:
|
|
_CURATED_MURPHY_UNITS_CACHE = json.load(f)
|
|
except Exception:
|
|
_CURATED_MURPHY_UNITS_CACHE = []
|
|
else:
|
|
_CURATED_MURPHY_UNITS_CACHE = []
|
|
return _CURATED_MURPHY_UNITS_CACHE
|
|
|
|
class _LazyUnitsList(list):
|
|
def __iter__(self):
|
|
return iter(get_curated_murphy_units())
|
|
def __len__(self):
|
|
return len(get_curated_murphy_units())
|
|
def __getitem__(self, idx):
|
|
return get_curated_murphy_units()[idx]
|
|
|
|
CURATED_MURPHY_UNITS = _LazyUnitsList()
|
|
|
|
|
|
def seed_grammar_units(conn: sqlite3.Connection) -> int:
|
|
"""
|
|
Populates grammar_units table with curated Murphy units.
|
|
Safe and idempotent (INSERT OR IGNORE).
|
|
"""
|
|
inserted = 0
|
|
for u in CURATED_MURPHY_UNITS:
|
|
contrast_json = json.dumps(u["contrast_explanation"], ensure_ascii=False)
|
|
exercises_json = json.dumps(u["exercises"], ensure_ascii=False)
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO grammar_units (
|
|
unit_num, level, category, title, subtitle, golden_formula,
|
|
contrast_explanation_json, journaling_prompt, exercises_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
u["unit_num"],
|
|
u["level"],
|
|
u["category"],
|
|
u["title"],
|
|
u.get("subtitle", ""),
|
|
u["golden_formula"],
|
|
contrast_json,
|
|
u["journaling_prompt"],
|
|
exercises_json,
|
|
),
|
|
)
|
|
if cursor.rowcount > 0:
|
|
inserted += 1
|
|
return inserted
|
|
|
|
|
|
def get_grammar_units_list(conn: sqlite3.Connection, user_id: int) -> List[Dict[str, Any]]:
|
|
"""
|
|
Returns list of all grammar units with user progress and handwritten status.
|
|
"""
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT
|
|
u.id, u.unit_num, u.level, u.category, u.title, u.subtitle,
|
|
p.is_completed, p.score, p.handwritten_logged, p.completed_at
|
|
FROM grammar_units u
|
|
LEFT JOIN grammar_progress p ON u.id = p.unit_id AND p.user_id = ?
|
|
ORDER BY u.unit_num ASC
|
|
""",
|
|
(user_id,),
|
|
).fetchall()
|
|
|
|
units = []
|
|
for r in rows:
|
|
units.append({
|
|
"id": r["id"],
|
|
"unit_num": r["unit_num"],
|
|
"level": r["level"],
|
|
"category": r["category"],
|
|
"title": r["title"],
|
|
"subtitle": r["subtitle"],
|
|
"is_completed": bool(r["is_completed"]),
|
|
"score": r["score"] or 0,
|
|
"handwritten_logged": bool(r["handwritten_logged"]),
|
|
"completed_at": r["completed_at"],
|
|
})
|
|
return units
|
|
|
|
|
|
def get_grammar_unit_detail(conn: sqlite3.Connection, user_id: int, unit_id: int) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Returns full content of a unit for lesson mode.
|
|
"""
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
u.id, u.unit_num, u.level, u.category, u.title, u.subtitle,
|
|
u.golden_formula, u.contrast_explanation_json, u.journaling_prompt, u.exercises_json,
|
|
p.is_completed, p.score, p.handwritten_logged, p.completed_at
|
|
FROM grammar_units u
|
|
LEFT JOIN grammar_progress p ON u.id = p.unit_id AND p.user_id = ?
|
|
WHERE u.id = ?
|
|
""",
|
|
(user_id, unit_id),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
try:
|
|
contrasts = json.loads(row["contrast_explanation_json"])
|
|
except Exception:
|
|
contrasts = []
|
|
|
|
try:
|
|
raw_exercises = json.loads(row["exercises_json"])
|
|
except Exception:
|
|
raw_exercises = []
|
|
|
|
# Clean exercises for client (shuffle options, don't expose target directly in payload)
|
|
exercises = []
|
|
for idx, ex in enumerate(raw_exercises):
|
|
opts = list(ex.get("options", []))
|
|
random.shuffle(opts)
|
|
exercises.append({
|
|
"idx": idx,
|
|
"sentence": ex["sentence"],
|
|
"clue": ex.get("clue", ""),
|
|
"options": opts,
|
|
})
|
|
|
|
return {
|
|
"id": row["id"],
|
|
"unit_num": row["unit_num"],
|
|
"level": row["level"],
|
|
"category": row["category"],
|
|
"title": row["title"],
|
|
"subtitle": row["subtitle"],
|
|
"golden_formula": row["golden_formula"],
|
|
"contrasts": contrasts,
|
|
"journaling_prompt": row["journaling_prompt"],
|
|
"exercises": exercises,
|
|
"is_completed": bool(row["is_completed"]),
|
|
"score": row["score"] or 0,
|
|
"handwritten_logged": bool(row["handwritten_logged"]),
|
|
"completed_at": row["completed_at"],
|
|
}
|
|
|
|
|
|
def process_grammar_answer(
|
|
conn: sqlite3.Connection,
|
|
user_id: int,
|
|
unit_id: int,
|
|
exercise_idx: int,
|
|
selected_option: str,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Validates answer to a micro-exercise.
|
|
If wrong, performs Automated Gap Ingestion into user's SRS active vocabulary
|
|
with tags 'grammar, murphy-unit-{unit_num}' and srs_state='learning'.
|
|
"""
|
|
unit_row = conn.execute(
|
|
"SELECT * FROM grammar_units WHERE id = ?", (unit_id,)
|
|
).fetchone()
|
|
|
|
if not unit_row:
|
|
return {"error": "Unit not found"}
|
|
|
|
try:
|
|
exercises = json.loads(unit_row["exercises_json"])
|
|
ex = exercises[exercise_idx]
|
|
except Exception:
|
|
return {"error": "Exercise not found"}
|
|
|
|
target = ex["target"].strip()
|
|
selected_clean = (selected_option or "").strip()
|
|
is_correct = selected_clean.lower() == target.lower()
|
|
explanation = ex.get("explanation", "")
|
|
full_sentence = ex["sentence"].replace("___", target)
|
|
unit_num = unit_row["unit_num"]
|
|
level = unit_row["level"]
|
|
|
|
card_created = False
|
|
card_updated = False
|
|
target_card_id = None
|
|
|
|
if not is_correct:
|
|
# =====================================================================
|
|
# AUTOMATED GAP INGESTION FOR GRAMMAR MISTAKE
|
|
# =====================================================================
|
|
# Term represents the grammatical pattern or key phrase
|
|
term_label = f"{target} ({unit_row['title'].split('(')[0].strip()})"
|
|
collection_name = "Грамматика Мерфи"
|
|
tags_str = f"grammar, murphy-unit-{unit_num}, {level.lower().replace('-', '_')}"
|
|
|
|
existing_card = conn.execute(
|
|
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?",
|
|
(user_id, term_label.lower()),
|
|
).fetchone()
|
|
|
|
if not existing_card:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO vocabulary_cards (
|
|
user_id, term, translation, context_sentence, phonetic, audio_url,
|
|
part_of_speech, definition_en, tags, notes, collection,
|
|
srs_reps, srs_interval, srs_ease, srs_state, error_count,
|
|
srs_next_review
|
|
) VALUES (?, ?, ?, ?, '', '', 'grammar', ?, ?, ?, ?, 0, 0, 2.5, 'learning', 1, CURRENT_TIMESTAMP)
|
|
""",
|
|
(
|
|
user_id,
|
|
term_label,
|
|
ex.get("clue", target),
|
|
full_sentence,
|
|
explanation,
|
|
tags_str,
|
|
f"Ошибка из упражнения Юнита {unit_num} (Мерфи)",
|
|
collection_name,
|
|
),
|
|
)
|
|
target_card_id = cur.lastrowid
|
|
card_created = True
|
|
else:
|
|
target_card_id = existing_card["id"]
|
|
conn.execute(
|
|
"""
|
|
UPDATE vocabulary_cards
|
|
SET srs_state = 'learning',
|
|
srs_next_review = CURRENT_TIMESTAMP,
|
|
error_count = error_count + 1
|
|
WHERE id = ?
|
|
""",
|
|
(target_card_id,),
|
|
)
|
|
card_updated = True
|
|
|
|
# Log into srs_logs
|
|
if target_card_id:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at)
|
|
VALUES (?, ?, 1, 0, 2.5, CURRENT_TIMESTAMP)
|
|
""",
|
|
(user_id, target_card_id),
|
|
)
|
|
|
|
return {
|
|
"correct": is_correct,
|
|
"correct_option": target,
|
|
"explanation": explanation,
|
|
"full_sentence": full_sentence,
|
|
"card_created": card_created,
|
|
"card_updated": card_updated,
|
|
"card_id": target_card_id,
|
|
}
|
|
|
|
|
|
def record_grammar_completion(
|
|
conn: sqlite3.Connection,
|
|
user_id: int,
|
|
unit_id: int,
|
|
score: int,
|
|
handwritten_logged: bool,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Saves unit completion score and handwritten journaling status.
|
|
"""
|
|
prog_row = conn.execute(
|
|
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
|
|
(user_id, unit_id),
|
|
).fetchone()
|
|
|
|
hw_val = 1 if handwritten_logged else 0
|
|
|
|
if prog_row:
|
|
conn.execute(
|
|
"""
|
|
UPDATE grammar_progress
|
|
SET is_completed = 1,
|
|
score = max(score, ?),
|
|
handwritten_logged = max(handwritten_logged, ?),
|
|
handwritten_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE handwritten_at END,
|
|
completed_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(score, hw_val, hw_val, prog_row["id"]),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO grammar_progress (
|
|
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at, completed_at
|
|
) VALUES (?, ?, 1, ?, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, CURRENT_TIMESTAMP)
|
|
""",
|
|
(user_id, unit_id, score, hw_val, hw_val),
|
|
)
|
|
|
|
return {"status": "ok", "unit_id": unit_id, "score": score, "handwritten_logged": handwritten_logged}
|
|
|
|
|
|
def update_handwritten_status(
|
|
conn: sqlite3.Connection,
|
|
user_id: int,
|
|
unit_id: int,
|
|
handwritten: bool,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Toggles handwritten paper notebook checkbox for a unit.
|
|
"""
|
|
hw_val = 1 if handwritten else 0
|
|
prog_row = conn.execute(
|
|
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
|
|
(user_id, unit_id),
|
|
).fetchone()
|
|
|
|
if prog_row:
|
|
conn.execute(
|
|
"""
|
|
UPDATE grammar_progress
|
|
SET handwritten_logged = ?,
|
|
handwritten_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END
|
|
WHERE id = ?
|
|
""",
|
|
(hw_val, hw_val, prog_row["id"]),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO grammar_progress (
|
|
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at
|
|
) VALUES (?, ?, 0, 0, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END)
|
|
""",
|
|
(user_id, unit_id, hw_val, hw_val),
|
|
)
|
|
|
|
return {"status": "ok", "unit_id": unit_id, "handwritten_logged": handwritten}
|