""" Osage Language API — FastAPI backend for RAG-powered Osage queries. Provides: /query — Full RAG: retrieve context + LLM generation /search — Lightweight dictionary/grammar search (no LLM) /health — Health check Usage: conda activate osage uvicorn app.api:app --host 0.0.0.0 --port 8000 uvicorn app.api:app --host 0.0.0.0 --port 8000 --reload # dev mode # With LoRA adapter: OSAGE_ADAPTER=adapters uvicorn app.api:app --port 8000 """ import os import sys import time from pathlib import Path from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field BASE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(BASE)) # ── Models ──────────────────────────────────────────────────────────────────── class QueryRequest(BaseModel): question: str = Field(..., description="Question about the Osage language") n_results: int = Field(12, description="Number of context chunks to retrieve") class QueryResponse(BaseModel): response: str sources: list[dict] elapsed: float class SearchRequest(BaseModel): query: str = Field(..., description="Search term (Osage or English)") n_results: int = Field(20, description="Number of results to return") class SearchResponse(BaseModel): results: list[dict] count: int class TranslateRequest(BaseModel): text: str = Field(..., description="Text to translate (English or Osage script)") max_results: int = Field(20, description="Maximum number of matches to return") class TranslateResponse(BaseModel): tier: int query: str direction: str matches: list[dict] count: int elapsed_ms: float class ReportPayload(BaseModel): # Everything optional so clients with partial state can still submit. # Two field-name schemes are accepted (frontend's flag() emits the second # set; legacy callers / scripts use the first): # input/expected/got/tab AND query/osage|apa|english/source # The endpoint normalizes them when writing to reports.jsonl. model_config = {"extra": "allow"} # tolerate any extra free-form fields input: str = Field("", description="User's original query (legacy)") expected: str = Field("", description="User's correction (legacy)") got: str = Field("", description="What the app returned (legacy)") note: str = Field("", description="Free-text comment") tab: str = Field("", description="Active tab (legacy: translate/teacher/...)") direction: str = Field("", description="e2o / o2e / other") ua: str = Field("", description="User-agent string for debugging") # Accept ts as int OR ISO string OR float; we only store it as-is. ts: str | int | float = Field(0, description="Client timestamp (ISO or ms epoch)") # New-schema fields used by translate.js OsageReports.flag(): query: str = Field("", description="User's original query (new)") osage: str = Field("", description="Osage-script form returned") apa: str = Field("", description="APA form returned") english: str = Field("", description="English form returned (or teacher answer)") source: str = Field("", description="translate / teacher / ai-teacher") context: str = Field("", description="Free-form context (e.g. 'Tab: Teacher')") # ── App Setup ───────────────────────────────────────────────────────────────── generator = None translator = None teacher = None ai_llm = None # Sandboxed LLM for AI Teacher tab only @asynccontextmanager async def lifespan(app: FastAPI): """Load the translator (fast, no GPU) and optionally the generator (needs GPU).""" global generator, translator, teacher, ai_llm # Tier 1: always load — fast dictionary lookup, no GPU from app.translate import OsageTranslator print("Loading Tier 1 translator...") translator = OsageTranslator() print(f"Translator ready! ({translator.entry_count} entries)") try: from app.teacher import OsageTeacher teacher = OsageTeacher() print("Teacher ready!") except Exception as e: print(f"Teacher not loaded (non-fatal): {e}") teacher = None # AI Teacher LLM: separate from main teacher, uses HF Inference API try: from app.llm_wrapper import LLMWrapper ai_llm = LLMWrapper.create(backend="auto") if ai_llm.is_available: print(f"AI Teacher LLM ready: {ai_llm.backend_name}") else: print("AI Teacher LLM: no HF_TOKEN — AI Teacher tab will show T1 answers only") ai_llm = None except Exception as e: print(f"AI Teacher LLM not loaded (non-fatal): {e}") ai_llm = None # Tier 2: load LLM generator only if not in lightweight mode if not os.environ.get("OSAGE_LIGHTWEIGHT"): try: from rag.generator import OsageGenerator adapter = os.environ.get("OSAGE_ADAPTER") if adapter and not Path(adapter).is_absolute(): adapter = str(BASE / adapter) print(f"Loading Osage generator (adapter={adapter})...") generator = OsageGenerator(adapter_path=adapter) print("Generator ready!") except Exception as e: print(f"Generator not loaded (Tier 2 unavailable): {e}") else: print("Lightweight mode — Tier 2 (LLM) disabled") yield app = FastAPI( title="Osage Language API", description="RAG-powered API for Osage (Wazhazhe ie) language queries", version="0.1.0", lifespan=lifespan, ) # CORS — allow the static site and localhost dev servers app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) # Serve static files at /static static_dir = BASE / "app" / "static" if static_dir.exists(): app.mount("/static", StaticFiles(directory=str(static_dir), html=True), name="static") # Serve local audio clips at /audio (dictionary / Sonny / Osage app). # Used by Phase C play buttons. URL format: /audio/sonny/.mp3. audio_dir = BASE / "data" / "audio" if audio_dir.exists(): app.mount("/audio", StaticFiles(directory=str(audio_dir)), name="audio") # ── Routes ──────────────────────────────────────────────────────────────────── @app.get("/") async def root(): """Redirect root to static UI.""" from fastapi.responses import RedirectResponse return RedirectResponse(url="/static/index.html") @app.get("/health") async def health(): return {"status": "ok", "generator_loaded": generator is not None, "teacher_loaded": teacher is not None, "ai_llm": ai_llm.backend_name if ai_llm else "none"} # Per-session teacher instances for follow-up context _teacher_sessions: dict = {} def _get_teacher(session_id: str | None): """Get or create a teacher instance for this session.""" if not session_id or teacher is None: return teacher if session_id not in _teacher_sessions: # Reuse the translator from the global teacher, just track context separately from app.teacher import OsageTeacher t = OsageTeacher.__new__(OsageTeacher) t.__dict__.update(teacher.__dict__) # share loaded data t._last_topic = None t._last_verb = None t._last_query = None t._last_answer = None _teacher_sessions[session_id] = t # Evict old sessions (keep last 50) if len(_teacher_sessions) > 50: oldest = list(_teacher_sessions.keys())[0] del _teacher_sessions[oldest] return _teacher_sessions[session_id] @app.get("/ask/{query}") async def ask(query: str, session: str | None = None): """Full teacher pipeline: translation, grammar, error check, drills, conjugation. This is the primary student-facing endpoint. Routes queries to the appropriate handler based on intent detection. Pass ?session=ID for follow-up context across requests. """ t = _get_teacher(session) if t is None: return {"error": "Teacher not loaded"} result = t.ask(query) # Add disambiguation alternatives for ambiguous lookups # When confidence is low/medium/proposed OR answer has "Also:", show alternatives if result.get("confidence") in ("low", "medium", "proposed", "none"): try: tr = t.translator.translate(query, max_results=5) matches = tr.get("matches", []) if len(matches) >= 2: seen = set() alts = [] for m in matches[:6]: eng = m.get("english", "").split(",")[0].strip()[:40] apa = m.get("apa", "") key = eng.lower() if key not in seen and eng: seen.add(key) alts.append({"apa": apa, "english": eng, "osage": m.get("osage_script", "")}) if len(alts) >= 2: result["alternatives"] = alts except Exception: pass return result @app.get("/ask-ai/{query}") async def ask_ai(query: str, session: str | None = None): """AI-enhanced teacher: T1 translation + LLM explanation. Always attempts LLM enrichment. Falls back to T1-only if LLM unavailable. This is the endpoint for the sandboxed AI Teacher tab. The LLM (ai_llm) is completely separate from the main Teacher pipeline. """ t = _get_teacher(session) if t is None: return {"error": "Teacher not loaded"} # Step 1: Get T1 answer (always fast, always available) t1_result = t.ask(query) t1_answer = t1_result.get("answer", "") # Step 2: Try LLM enrichment — but SKIP for routes where T1 is already complete # Drills, conjugation, marker explanations, verified lookups don't benefit from LLM t1_route = t1_result.get("route", "") t1_conf = t1_result.get("confidence", "") skip_llm_routes = {"drill", "conjugation", "verb_class_explanation", "marker_comparison", "marker_explanation", "examples", "topic_lookup", "topic_more", "error_check", "how_do_you_say", "meta"} skip_llm = (t1_route in skip_llm_routes or t1_conf == "proposed" or # constrained generator output — unreliable (t1_conf == "verified" and t1_route in ("tier1", "tier1+grammar"))) if ai_llm is not None and not skip_llm: try: from app.llm_wrapper import format_llm_prompt from rag.t1_context import get_t1_data, format_t1_context, route_query # Build T1 context for the LLM t1_data = get_t1_data(t.translator, query) t1_context = format_t1_context(query, t1_data) # Grammar hits if relevant grammar_text = "" route = route_query(query) if route != "tier1": grammar_hits = t.grammar.search(query, limit=3) if grammar_hits: grammar_text = t.grammar.format_for_prompt(grammar_hits, max_chars=2000) is_grammar = route != "tier1" system, user_msg = format_llm_prompt( query, t1_context, grammar_text, is_grammar=is_grammar, t1_answer=t1_answer, ) llm_response = ai_llm.generate(system, user_msg, max_tokens=512) if llm_response: return { "answer": llm_response, "tier": "tier1+tier2", "confidence": t1_result.get("confidence", "medium"), "route": "ai_teacher", "t1_answer": t1_answer, "t1_matches": t1_result.get("t1_matches", 0), "grammar_hits": t1_result.get("grammar_hits", 0), "elapsed_ms": t1_result.get("elapsed_ms", 0), "llm_backend": ai_llm.backend_name, } except Exception: pass # LLM failed — fall through to T1 # Fallback: return T1 answer with note t1_result["tier"] = t1_result.get("tier", "tier1") if ai_llm is None: t1_result["llm_note"] = "AI not available — showing verified T1 answer" return t1_result @app.post("/ask") async def ask_post(req: QueryRequest): """POST version of /ask for longer queries.""" if teacher is None: return {"error": "Teacher not loaded"} result = teacher.ask(req.question) return result @app.post("/query", response_model=QueryResponse) async def query(req: QueryRequest): """ Full RAG query: retrieve context from ChromaDB, generate response with LLM. This is the main endpoint for the "Ask the Teacher" feature. """ if generator is None: return QueryResponse(response="Generator not loaded", sources=[], elapsed=0) result = generator.query(req.question, n_results=req.n_results) return QueryResponse( response=result["response"], sources=result["sources"], elapsed=result["elapsed"], ) @app.post("/search", response_model=SearchResponse) async def search(req: SearchRequest): """ Lightweight search across dictionary, grammar, and examples. No LLM generation — just retrieval results. """ if generator is None: return SearchResponse(results=[], count=0) results = generator.search(req.query, n_results=req.n_results) return SearchResponse(results=results, count=len(results)) @app.get("/search/{query}") async def search_get(query: str, n: int = 20): """GET endpoint for simple search.""" if generator is None: return {"results": [], "count": 0} results = generator.search(query, n_results=n) return {"results": results, "count": len(results)} @app.post("/translate", response_model=TranslateResponse) async def translate(req: TranslateRequest): """ Tier 1: Fast dictionary lookup translation. No LLM needed. Handles English→Osage and Osage→English automatically. """ if translator is None: return TranslateResponse(tier=1, query=req.text, direction="unknown", matches=[], count=0, elapsed_ms=0) result = translator.translate(req.text, max_results=req.max_results) return TranslateResponse(**{k: result[k] for k in ["tier", "query", "direction", "matches", "count", "elapsed_ms"]}) @app.get("/translate/{text}") async def translate_get(text: str, n: int = 20): """GET endpoint for quick translation lookup.""" if translator is None: return {"tier": 1, "query": text, "matches": [], "count": 0, "elapsed_ms": 0} return translator.translate(text, max_results=n) @app.get("/gloss/{text}") async def gloss(text: str): """Word-by-word interlinear gloss of an Osage sentence.""" if translator is None: return {"glosses": [], "elapsed_ms": 0} return translator.gloss(text) @app.get("/analyze/{text}") async def analyze(text: str): """Morphological analysis of an Osage word or sentence.""" if translator is None: return {"input": text, "analyses": [], "elapsed_ms": 0} return translator.analyze(text) @app.get("/conjugate/{text}") async def conjugate(text: str): """Show all known conjugated forms for a verb.""" if translator is None: return {"verb": text, "forms": [], "count": 0, "elapsed_ms": 0} return translator.conjugation_table(text) @app.get("/sentence/{text}") async def find_sentence(text: str, n: int = 10): """Find closest Osage sentence pair for English input.""" if translator is None: return {"matches": [], "count": 0, "elapsed_ms": 0} return translator.find_sentence(text, max_results=n) @app.get("/drill") async def drill_new( topic: str = "", difficulty: str = "beginner", type: str = "", exclude: str = "", prefer: str = "", chapter: int = 0, ): """Generate one practice exercise as structured JSON. Query params: topic — 'greetings', 'animals', 'family', 'body', 'food', 'verbs' difficulty — 'beginner' | 'intermediate' | 'advanced' type — 'translate_e2o' | 'translate_o2e' | 'conjugate' | 'sentence_e2o'; empty = pick from difficulty level exclude — comma-separated answer_english values recently seen prefer — comma-separated English words to PREFER (spaced repetition: weak words due for review) chapter — Pratt chapter id (1-23). Restricts vocab to entries whose English overlaps that chapter's examples. Falls back to unfiltered vocab if <5 entries match. """ if translator is None: return {"error": "Translator not loaded"} from app.drill_mode import DrillEngine drill = DrillEngine(translator) excluded = {e.strip().lower() for e in (exclude or "").split(",") if e.strip()} preferred = [e.strip().lower() for e in (prefer or "").split(",") if e.strip()] chapter_id = chapter or None ex = None # If a preferred list is given, try to construct a drill directly # from the first preferred word's dictionary entry (bypasses random # sampling which rarely hits specific words). if preferred: drill._get_vocab() # ensure vocab_cache populated # Respect chapter filter on preferred: only keep prefer words # that also live in the chapter vocab when chapter is set. pool = drill._chapter_filtered_vocab(chapter_id) if chapter_id else drill._vocab_cache eng_index = {} for entry in pool: eng_index.setdefault(entry["english"].lower().strip(), entry) for pref in preferred: if pref in excluded: continue entry = eng_index.get(pref) if not entry: continue # Build a translate_e2o exercise directly from the matched entry ex = { "type": "translate_e2o", "prompt": f"How do you say \"{entry['english']}\" in Osage?", "answer_apa": entry.get("apa", ""), "answer_osage": entry.get("osage_script", ""), "answer_english": entry["english"], "hint": f"This word is from the {entry.get('source', 'dictionary')}.", "difficulty": difficulty, "topic": topic or None, "chapter": chapter_id, "spaced_repetition": True, } break # Random fallback (when no prefer match): up to 12 retries to avoid excluded if ex is None: best_fallback = None for attempt in range(12): cand = drill.generate( exercise_type=(type or None), difficulty=difficulty, topic=(topic or None), chapter=chapter_id, ) if not cand: continue ans_eng = (cand.get("answer_english") or "").lower().strip() if ans_eng in excluded: continue if best_fallback is None: best_fallback = cand break ex = best_fallback # Audio URL for the answer (if any) if ex: url = translator.find_audio( osage=ex.get("answer_osage", ""), english=ex.get("answer_english", ""), ) if url: ex["audio_url"] = url return ex or {"error": "drill generation failed"} @app.get("/drill/topics") async def drill_topics(): """List the drill topic categories the server knows about.""" if translator is None: return {"topics": []} from app.drill_mode import DrillEngine drill = DrillEngine(translator) return {"topics": list(drill.TOPICS.keys()) + ["verbs"]} @app.get("/full-translate/{text}") async def full_translate(text: str, n: int = 10): """ Full translation pipeline: teacher-enhanced lookup + glossing + analysis. Routes through teacher pipeline for 99.9% accuracy, with raw translator data included for display (gloss, conjugation, analysis). """ if translator is None: return {"error": "Translator not loaded"} from app.translate import _is_osage_script, _is_apa_text, _normalize_english is_osage = _is_osage_script(text) is_apa = not is_osage and _is_apa_text(text) word_count = len(text.strip().split()) # Get teacher answer (enhanced pipeline) if available teacher_answer = None if teacher is not None: try: tr = teacher.ask(text) teacher_answer = tr.get("answer", "") except Exception: pass result = { "query": text, "is_osage": is_osage, "is_apa": is_apa, } if is_apa: # APA input: route through translate() which handles APA detection result["lookup"] = translator.translate(text, max_results=n) # Also convert to Osage script for gloss/analysis try: from scripts.orthography_converter import apa_to_osage_script osage_words = [] for w in text.split(): try: osage_words.append(apa_to_osage_script(w)) except Exception: osage_words.append(w) osage_text = " ".join(osage_words) if _is_osage_script(osage_text): result["gloss"] = translator.gloss(osage_text) result["analysis"] = translator.analyze(osage_text) except Exception: pass elif is_osage: # For Osage input: lookup + gloss + analyze result["lookup"] = translator.translate_fuzzy(text, max_results=n) result["gloss"] = translator.gloss(text) result["analysis"] = translator.analyze(text) elif word_count == 1: # Single English word: lookup + conjugation result["lookup"] = translator.translate_fuzzy(text, max_results=n) matches = result["lookup"].get("matches", []) if matches: top_osage = matches[0].get("osage_script", "") if top_osage: conj = translator.conjugation_table(top_osage) if conj.get("count", 0) > 1: result["conjugation"] = conj else: # Multi-word English: try E2O composition FIRST, then sentence match e2o = translator.compose_to_osage(text) result["sentences"] = translator.find_sentence(text, max_results=5) if e2o: # E2O composition succeeded — use it as primary result result["lookup"] = { "matches": [{ "osage_script": e2o["osage_script"], "apa": e2o["apa"], "english": e2o["english"], "source": e2o["source"], "match_type": e2o["match_type"], "score": e2o["score"], "explain": e2o.get("explain"), }], "count": 1, "direction": "english_to_osage", } else: # Fall back to sentence match or fuzzy lookup sent_matches = result["sentences"].get("matches", []) if sent_matches and sent_matches[0].get("similarity", 0) >= 0.4: best = sent_matches[0] result["lookup"] = { "matches": [{ "osage_script": best["osage_script"], "apa": best["apa"], "english": best["english"], "source": best["source"], "match_type": "sentence", "score": 0.0, }], "count": 1, "direction": "english_to_osage", } else: result["lookup"] = translator.translate_fuzzy(text, max_results=n) # Word-by-word decomposition: look up each content word stop_words = {"i", "a", "an", "the", "is", "am", "are", "was", "were", "to", "of", "in", "for", "on", "with", "at", "by", "and", "or", "but", "not", "do", "does", "did", "will", "would", "can", "could", "my", "your", "his", "her", "it", "this", "that", "some", "very", "really", "just", "also", "too"} words = [w.strip(".,!?") for w in text.lower().split()] content_words = [w for w in words if w and w not in stop_words] word_results = [] for word in content_words: lookup = translator.translate(word, max_results=3) matches = lookup.get("matches", []) # Also try stemmed forms if not matches: lookup = translator.translate_fuzzy(word, max_results=3) matches = lookup.get("matches", []) if matches: best = matches[0] word_results.append({ "english": word, "osage_script": best["osage_script"], "apa": best["apa"], "osage_english": best["english"][:40], "found": True, }) else: word_results.append({ "english": word, "osage_script": "", "apa": "", "osage_english": "", "found": False, }) result["word_breakdown"] = word_results # Phase C: attach audio URLs to any returned matches so the UI can # render a play button for dictionary-backed pronunciations. if result.get("lookup") and result["lookup"].get("matches"): for m in result["lookup"]["matches"]: if not m.get("audio_url"): url = translator.find_audio( osage=m.get("osage_script", ""), english=m.get("english", ""), ) if url: m["audio_url"] = url # Include teacher-enhanced answer if available (richer than raw translator) if teacher_answer: result["teacher_answer"] = teacher_answer return result # In-process rate-limit state for /report. IP → (window_start_ts, count). # Simple fixed-window counter; resets each minute. Not shared across workers. _REPORT_RATE: dict[str, tuple[float, int]] = {} _REPORT_RATE_LIMIT = 10 # reports per IP per minute @app.post("/report") async def submit_report(payload: ReportPayload, request: Request): """Append a user bug report to data/feedback/reports.jsonl. Client-side reports stored in LocalStorage (keyed REPORT_KEY) can also be POSTed here when the user opts into "Send automatically". Rate limited to 10/min/IP; returns {ok, queued} or 429. """ client_ip = (request.client.host if request.client else "unknown") or "unknown" now = time.time() win_start, count = _REPORT_RATE.get(client_ip, (now, 0)) if now - win_start > 60: win_start, count = now, 0 if count >= _REPORT_RATE_LIMIT: from fastapi import HTTPException raise HTTPException(status_code=429, detail="rate_limited") _REPORT_RATE[client_ip] = (win_start, count + 1) fb_dir = Path(__file__).resolve().parent.parent / "data" / "feedback" fb_dir.mkdir(parents=True, exist_ok=True) fb_path = fb_dir / "reports.jsonl" # Coalesce the two field-name schemes. Frontend (translate.js flag()) sends # query/osage/apa/english/source; legacy clients send input/got/tab. user_query = (payload.query or payload.input or "")[:2000] user_expected = (payload.expected or "")[:2000] app_returned = (payload.got or payload.english or payload.osage or payload.apa or "")[:2000] active_tab = (payload.source or payload.tab or "")[:64] record = { "server_ts": int(now * 1000), "client_ts": payload.ts, "input": user_query, "expected": user_expected, "got": app_returned, "note": payload.note[:2000], "tab": active_tab, "direction": payload.direction[:16], "osage": payload.osage[:2000], "apa": payload.apa[:2000], "context": payload.context[:512], "ua": payload.ua[:512], "ip_hash": str(hash(client_ip) & 0xFFFFFFFF), } import json as _json with open(fb_path, "a", encoding="utf-8") as f: f.write(_json.dumps(record, ensure_ascii=False) + "\n") return {"ok": True, "queued": True} @app.get("/report/export") async def export_reports(request: Request): """Stream reports.jsonl. Guarded by REPORTS_EXPORT_TOKEN env var. Enables `scripts/review_feedback.py --fetch ` to pull reports from a deployed instance (HF Space). Returns 404 when the token env is unset (feature disabled) or the token doesn't match. """ expected = os.environ.get("REPORTS_EXPORT_TOKEN", "") if not expected: from fastapi import HTTPException raise HTTPException(status_code=404) supplied = request.headers.get("x-reports-token") or request.query_params.get("token", "") if supplied != expected: from fastapi import HTTPException raise HTTPException(status_code=404) fb_path = Path(__file__).resolve().parent.parent / "data" / "feedback" / "reports.jsonl" if not fb_path.exists(): return Response(content="", media_type="application/x-ndjson") return Response(content=fb_path.read_bytes(), media_type="application/x-ndjson") # ── Speaker Review (read-only, first cut) ────────────────────────────────── # # Surfaces pending items from the three review queues (Phase 2 grammar # content, LF1932 cedilla candidates, user reports) in a format the speaker # review UI at /static/review.html can render. Read-only: no write-back yet. # # Token-guarded with REVIEW_TOKEN env var so the endpoint can ship in the # sovereign image without exposing the queue publicly when unset, mirrors # the /report/export pattern. def _check_review_token(request: Request) -> None: expected = os.environ.get("REVIEW_TOKEN", "") # When REVIEW_TOKEN is not set, allow access on localhost only (dev/sovereign mode). # Production HF Space must set the token to expose this endpoint. if not expected: client_host = (request.client.host if request.client else "") if client_host not in ("127.0.0.1", "localhost", "::1", ""): from fastapi import HTTPException raise HTTPException(status_code=404) return supplied = request.headers.get("x-review-token") or request.query_params.get("token", "") if supplied != expected: from fastapi import HTTPException raise HTTPException(status_code=404) @app.get("/review/pending") async def review_pending(request: Request, category: str = "grammar", limit: int = 200): """Return pending items for speaker review. category=grammar → pending Phase 2 content items (any reviewed != approved/pratt_sourced/revised) category=lf → uncorrected LF1932 entries with a `g` (cedilla candidates) category=reports → flagged user reports awaiting triage """ import json as _json _check_review_token(request) base_path = Path(__file__).resolve().parent.parent out = {"category": category, "items": []} if category == "grammar": files = [ "seed_templates.json", "verb_paradigms_generated.json", "grammar_explanations_generated.json", "error_corrections_generated.json", "error_corrections_advanced.json", "conjugation_drills_generated.json", "sentence_breakdowns_generated.json", "translation_exercises_generated.json", "vocabulary_exercises_generated.json", "common_qa_generated.json", ] approved_set = {"approved", "pratt_sourced", "revised"} for fname in files: fpath = base_path / "data" / "grammar_content" / fname if not fpath.exists(): continue for it in _json.loads(fpath.read_text()): if it.get("reviewed") not in approved_set: out["items"].append({"_file": fname, **it}) if len(out["items"]) >= limit: break if len(out["items"]) >= limit: break elif category == "lf": # Filter out malformed extractions: some LF entries have English # definition text leaked into the headword field (e.g., "a'-ba my # stooping shoulders." or "hiu'dse a gorilla; a big"). Hide them # from the speaker review queue — they need a separate cleanup pass. _LF_JUNK_TOKENS = { "my", "your", "his", "her", "the", "with", "from", "and", "of", "by", "to", "is", "as", "on", "in", "at", "for", "or", "an", "a", "stooping", "shoulders", "backward", "forward", "down", "up", "near", "toward", "moving", "sitting", "lying", "standing", "sleeping", "hidden", "appearing", "expression", "action", "having", "plenty", "this", "that", "gorilla", "big", "small", "many", "few", "all", "some", "made", "make", "given", "give", "side", "person", "people", "thing", "things", "take", "took", "hold", "before", "after", "while", "since", "during", "between", } def _lf_is_junk(h: str) -> bool: if not h.strip(): return True # All-uppercase headwords are section headers (e.g., "OSAGE-ENGLISH"), # not real entries. Real LF headwords are lowercase or capitalized # only on the first letter (proper names like "Wa-ko"-da"). stripped = h.replace("-", "").replace("'", "").replace(" ", "") if stripped and stripped == stripped.upper() and any(c.isalpha() for c in stripped): return True tokens = h.split() if len(tokens) < 2: return False for tok in tokens: clean = tok.strip(".,;:?!\"'()[]{}").lower() if clean in _LF_JUNK_TOKENS: return True return False lf_path = base_path / "data" / "processed" / "la_flesche_1932_dictionary.jsonl" if lf_path.exists(): with open(lf_path) as f: for i, line in enumerate(f): if not line.strip(): continue e = _json.loads(line) h = e.get("headword_lf", "") if "g" not in h.lower() or "gth" in h.lower(): continue if e.get("headword_lf_corrected"): continue if _lf_is_junk(h): continue out["items"].append({ "lf_index": i, "headword_lf": h, "page_pdf": e.get("page_pdf", 0), "page_book": e.get("page_book"), "definitions_lf": e.get("definitions_lf", []), }) if len(out["items"]) >= limit: break elif category == "reports": fb_path = base_path / "data" / "feedback" / "reports.jsonl" if fb_path.exists(): with open(fb_path) as f: for line in f: if not line.strip(): continue out["items"].append(_json.loads(line)) if len(out["items"]) >= limit: break else: from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"unknown category: {category!r}") out["count"] = len(out["items"]) return out class ReviewDecision(BaseModel): category: str = Field(..., description="grammar | lf | reports") item_id: str = Field(..., description="grammar item id, or `lf_`, or report id/timestamp") decision: str = Field(..., description="approve|reject|revise (grammar) | cedilla|real_g|skip (lf) | fixed|broken|dismiss (reports)") reviewer: str = Field("", description="Speaker initials or name") notes: str = Field("", description="Optional reviewer notes / revision request / cedilla positions") corrected_headword: str = Field("", description="LF only: the corrected headword string with ç placed where it belongs") @app.post("/review/decision") async def review_decision(request: Request, body: ReviewDecision): """Record a speaker's review decision. All decisions are appended to data/feedback/review_decisions.jsonl as an immutable audit log. Some decisions also update canonical sources: grammar: approve/reject → rewrite the item's `reviewed` field in its source JSON lf: cedilla → append to lf_visual_corrections.jsonl with speaker_review source reports: fixed/broken → append to user_reports_{fixed,broken}.jsonl """ import json as _json import datetime as _dt _check_review_token(request) base_path = Path(__file__).resolve().parent.parent fb_dir = base_path / "data" / "feedback" fb_dir.mkdir(exist_ok=True) audit_path = fb_dir / "review_decisions.jsonl" record = { "ts": _dt.datetime.utcnow().isoformat(timespec="seconds") + "Z", "category": body.category, "item_id": body.item_id, "decision": body.decision, "reviewer": body.reviewer.strip() or "anonymous", "notes": body.notes, } if body.corrected_headword: record["corrected_headword"] = body.corrected_headword # Always append to audit log (idempotent enough — duplicate decisions just re-log). with open(audit_path, "a", encoding="utf-8") as f: f.write(_json.dumps(record, ensure_ascii=False) + "\n") applied = {"audit": True} if body.category == "grammar": # Update the item's reviewed field in its source JSON files = [ "seed_templates.json", "verb_paradigms_generated.json", "grammar_explanations_generated.json", "error_corrections_generated.json", "error_corrections_advanced.json", "conjugation_drills_generated.json", "sentence_breakdowns_generated.json", "translation_exercises_generated.json", "vocabulary_exercises_generated.json", "common_qa_generated.json", ] new_status = {"approve": "approved", "reject": "rejected", "revise": "pending"}.get(body.decision) if new_status is None: from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"unknown grammar decision: {body.decision!r}") found = False for fname in files: fpath = base_path / "data" / "grammar_content" / fname if not fpath.exists(): continue items = _json.loads(fpath.read_text()) changed = False for it in items: if it.get("id") == body.item_id: it["reviewed"] = new_status if record["reviewer"]: it["reviewer"] = record["reviewer"] it["reviewed_at"] = record["ts"][:10] if body.notes: it["review_notes"] = body.notes changed = True found = True break if changed: fpath.write_text(_json.dumps(items, ensure_ascii=False, indent=2) + "\n") applied["file"] = fname break applied["item_found"] = found elif body.category == "lf": # Parse lf_ id if not body.item_id.startswith("lf_"): from fastapi import HTTPException raise HTTPException(status_code=400, detail="lf decisions need item_id like 'lf_'") try: lf_idx = int(body.item_id[3:]) except ValueError: from fastapi import HTTPException raise HTTPException(status_code=400, detail="lf_ must be an integer") # For 'cedilla' decision, append to lf_visual_corrections.jsonl if body.decision == "cedilla": if not body.corrected_headword: from fastapi import HTTPException raise HTTPException(status_code=400, detail="cedilla decision requires corrected_headword") # Find original headword for the index lf_path = base_path / "data" / "processed" / "la_flesche_1932_dictionary.jsonl" orig_h = None page_pdf = 0 with open(lf_path) as f: for i, line in enumerate(f): if i == lf_idx: e = _json.loads(line) orig_h = e.get("headword_lf", "") page_pdf = e.get("page_pdf", 0) break if orig_h is None: from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"lf_index {lf_idx} not found") positions = [j for j, (a, b) in enumerate(zip(orig_h, body.corrected_headword)) if a != b] corr_path = base_path / "data" / "processed" / "lf_visual_corrections.jsonl" row = { "lf_index": lf_idx, "lf_headword": orig_h, "lf_page_pdf": page_pdf, "corrected_headword": body.corrected_headword, "positions_changed": positions, "correction_source": "speaker_review", "confidence": "high", "reviewer": record["reviewer"], "reviewed_at": record["ts"][:10], "rule": f"speaker review — {body.notes}" if body.notes else "speaker review", } with open(corr_path, "a", encoding="utf-8") as f: f.write(_json.dumps(row, ensure_ascii=False) + "\n") applied["lf_correction_appended"] = True elif body.decision in ("real_g", "skip"): # Just log — no data change. Future runs of correction-mining can read the # audit log to skip these indices. pass else: from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"unknown lf decision: {body.decision!r}") elif body.category == "reports": # Move report to fixed/broken/dismiss reports_path = fb_dir / "reports.jsonl" eval_dir = base_path / "data" / "eval" target_path = None if body.decision == "fixed": target_path = eval_dir / "user_reports_fixed.jsonl" elif body.decision == "broken": target_path = eval_dir / "user_reports_broken.jsonl" elif body.decision != "dismiss": from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"unknown reports decision: {body.decision!r}") # Read all reports, find the matching one (by timestamp / id), move it. if reports_path.exists(): kept, moved = [], [] with open(reports_path) as f: for line in f: if not line.strip(): continue rec = _json.loads(line) if str(rec.get("id", "")) == body.item_id or str(rec.get("timestamp", "")) == body.item_id: moved.append(rec) else: kept.append(rec) with open(reports_path, "w") as f: for rec in kept: f.write(_json.dumps(rec, ensure_ascii=False) + "\n") if target_path and moved: with open(target_path, "a") as f: for rec in moved: rec.setdefault("triaged_by", record["reviewer"]) rec.setdefault("triaged_at", record["ts"][:10]) if body.notes: rec["triage_notes"] = body.notes f.write(_json.dumps(rec, ensure_ascii=False) + "\n") applied["reports_moved"] = len(moved) else: from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"unknown category: {body.category!r}") return {"ok": True, "applied": applied, "audit_record": record}