""" app.py — Web backend for the Afaan Oromo grammar checker. Serves a single unified list of issues (rule-based + deep-learning), each with absolute character offsets into the submitted text, so the frontend can underline exact spans and show click-to-accept suggestion cards, Grammarly-style. Run: pip install -r requirements.txt uvicorn app:app --reload --port 8000 Then open http://localhost:8000 """ from __future__ import annotations import os from typing import List, Optional from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel from rule_checker import OromoGrammarChecker, split_sentences_with_offsets from dl_checker import DeepLearningChecker, DEFAULT_MODEL app = FastAPI(title="Afaan Oromo Grammar Checker") BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) @app.middleware("http") async def no_cache_static(request: Request, call_next): response = await call_next(request) if request.url.path.startswith("/static/"): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" return response rule_checker = OromoGrammarChecker() _dl_checker: Optional[DeepLearningChecker] = None def get_dl_checker() -> DeepLearningChecker: global _dl_checker if _dl_checker is None: model_name = os.environ.get("OROMO_DL_MODEL", DEFAULT_MODEL) _dl_checker = DeepLearningChecker(model_name=model_name) return _dl_checker class CheckRequest(BaseModel): text: str use_dl: bool = True class IssueOut(BaseModel): id: str start: int end: int text: str category: str severity: str message: str suggestions: List[str] = [] source: str # "rule" or "model" class CheckResponse(BaseModel): issues: List[IssueOut] dl_available: bool dl_error: Optional[str] = None @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse(request, "index.html", {}) @app.get("/api/health") async def health(): return {"status": "ok"} @app.post("/api/check", response_model=CheckResponse) async def check(req: CheckRequest): text = req.text issues: List[IssueOut] = [] # --- Rule-based layer --- rule_issues = rule_checker.check_text(text) for i, issue in enumerate(rule_issues): issues.append( IssueOut( id=f"rule-{i}", start=issue.start, end=issue.end, text=issue.text, category=issue.category, severity=issue.severity.value, message=issue.message, suggestions=issue.suggestions, source="rule", ) ) # --- Deep learning layer --- dl_available = False dl_error = None if req.use_dl: checker = get_dl_checker() dl_available = checker.available dl_error = checker.load_error if dl_available: sentences_with_offsets = split_sentences_with_offsets(text) scored = checker.check_sentences_with_offsets(sentences_with_offsets) for j, (sent_score, base_offset) in enumerate(scored): for k, tok in enumerate(sent_score.tokens): if not tok.is_suspicious: continue abs_start = base_offset + tok.start abs_end = base_offset + tok.end # Skip if a rule-based issue already covers this exact span # to avoid redundant duplicate cards for the same word. if any(iss.start == abs_start and iss.end == abs_end for iss in issues): continue issues.append( IssueOut( id=f"dl-{j}-{k}", start=abs_start, end=abs_end, text=tok.word, category="model-flagged", severity="warning", message=( f"The language model finds this word statistically " f"unusual in context (probability {tok.original_prob*100:.1f}%, " f"rank #{tok.original_rank})." ), suggestions=tok.top_suggestions, source="model", ) ) issues.sort(key=lambda x: x.start) return CheckResponse(issues=issues, dl_available=dl_available, dl_error=dl_error)