"""FastAPI rewrite service with the existing React interface.""" from __future__ import annotations import logging import sys from dataclasses import asdict from pathlib import Path ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field from app.auth.deps import optional_user from app.auth.supabase_client import AuthUser, auth_public_config from app.billing.quota import ( account_payload, assert_can_rewrite, get_account_state, get_guest_state, record_rewrite, ) from app.bootstrap import ensure_resources from app.config import ( APP_TITLE, AUTH_ENABLED, ENGINE_FORCE_REWRITE, ENGINE_LEXICAL_REFINEMENT, ENGINE_PARAPHRASE, ENGINE_PARAPHRASE_PRIMARY, ENGINE_PHRASE_REWRITE, ENGINE_REQUIRE_WORDING_CHANGE, ENGINE_USE_MINILM_SAFETY, GRAMMAR_MAX_CHARS, LANGUAGE_TOOL_LANGUAGE, LANGUAGE_TOOL_URL, MAX_CHARS, ) from app.engine.lexical import lexical_resource_available from app.engine.paraphrase import paraphrase_resource_available from app.engine.phrase import phrase_resource_available from app.engine.orchestrator import rewrite_document from app.pipeline.minilm import minilm_available from app.pipeline.grammar import ( check_grammar, languagetool_reachable, normalize_language, rules_fallback_result, ) from app.pipeline.nlp import spacy_available ensure_resources() logger = logging.getLogger("structural-rewrite") if not logger.handlers: logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") STATIC_DIR = ROOT / "frontend" / "dist" app = FastAPI( title=APP_TITLE, version="3.0.0", description="CPU-friendly, rule-based structural document rewriting API.", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) class RewriteRequest(BaseModel): # Tone/strength remain presentation-only legacy controls. model_config = ConfigDict(extra="ignore") text: str = Field(..., min_length=1) ml_polish: bool = False class GrammarRequest(BaseModel): text: str = Field(..., min_length=1) language: str = Field(default="en-US", min_length=2, max_length=16) def _client_ip(request: Request) -> str: forwarded = request.headers.get("x-forwarded-for") or "" if forwarded: return forwarded.split(",")[0].strip() if request.client and request.client.host: return request.client.host return "unknown" @app.get("/health") def health(): engines = ["structural-reorder", "grammar-safety"] engines.insert(0, "spacy" if spacy_available() else "regex-fallback") lt_ok = languagetool_reachable() if lt_ok: engines.append("languagetool") return { "status": "ok", "app": APP_TITLE, "service": "rewrite-api", "ui": "react" if STATIC_DIR.is_dir() else None, "engines": engines, "languagetool": { "configured": bool(LANGUAGE_TOOL_URL), "reachable": lt_ok, "default_language": LANGUAGE_TOOL_LANGUAGE or "en-US", }, "rewrite_engine": { "mode": ( "structural+phrase+lexical+ensure" if not ENGINE_PARAPHRASE else ( "structural+paraphrase-primary+phrase+ensure" if ENGINE_PARAPHRASE_PRIMARY else "structural+paraphrase-fallback+phrase+ensure" ) ), "force_rewrite": ENGINE_FORCE_REWRITE, "require_wording_change": ENGINE_REQUIRE_WORDING_CHANGE, "paraphrase": { "enabled": ENGINE_PARAPHRASE, "primary": ENGINE_PARAPHRASE_PRIMARY, "resource_available": ( paraphrase_resource_available() if ENGINE_PARAPHRASE else None ), }, "phrase_rewrite": { "enabled": ENGINE_PHRASE_REWRITE, "resource_available": ( phrase_resource_available() if ENGINE_PHRASE_REWRITE else None ), }, "minilm_safety": { "enabled": ENGINE_USE_MINILM_SAFETY, "resource_available": ( minilm_available() if ENGINE_USE_MINILM_SAFETY else None ), }, "lexical_refinement": { "enabled": ENGINE_LEXICAL_REFINEMENT, "request_control": "dynamic_budget_by_sentence_length", "ml_polish_boosts_changes": True, "resource_available": ( lexical_resource_available() if ENGINE_LEXICAL_REFINEMENT else None ), }, "pipeline": [ "ingest", "normalize", "segment (batched)", "classify", "parse (spaCy)", "plan", "templates", "rewrite (reorder)", "grammar", "safety", "paraphrase (optional)", "forced-cleft (optional legacy)", "phrase-rewrite (optional)", "lexical-refinement (optional)", "stitch", "consistency", ], }, } @app.get("/v1/auth/config") def api_auth_config(): return auth_public_config() @app.get("/v1/me") def api_me( request: Request, user: AuthUser | None = Depends(optional_user), ): if not AUTH_ENABLED: return {"auth_enabled": False, "account": None} if user is None: return { "auth_enabled": True, "account": account_payload(get_guest_state(_client_ip(request))), } return {"auth_enabled": True, "account": account_payload(get_account_state(user))} @app.post("/v1/rewrite") def api_rewrite( body: RewriteRequest, request: Request, user: AuthUser | None = Depends(optional_user), ): text = body.text.strip() if not text: raise HTTPException(status_code=400, detail="Text is empty.") if len(text) > MAX_CHARS: raise HTTPException( status_code=413, detail=f"Text is too long ({len(text):,} chars; max {MAX_CHARS:,}).", ) client_ip = _client_ip(request) input_words = len(text.split()) assert_can_rewrite(user, input_words, client_ip=client_ip) try: # Synonyms run when lexical refinement is enabled. Sentence length # chooses how many safe swaps to attempt; Extra word polish densifies # that dynamic budget. result = rewrite_document( text, use_lexical_refinement=ENGINE_LEXICAL_REFINEMENT or body.ml_polish, force_rewrite=ENGINE_FORCE_REWRITE, use_paraphrase=ENGINE_PARAPHRASE, lexical_polish=body.ml_polish, lexical_max_changes=None, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc account = record_rewrite(user, result.input_words, client_ip=client_ip) return { "rewrite": result.text, "sentences": [asdict(sentence) for sentence in result.sentences], "skipped": [asdict(sentence) for sentence in result.skipped], "mapping": result.mapping, "stats": asdict(result.stats), "meta": { "engine": result.engine, "input_words": result.input_words, "output_words": result.output_words, "seconds": result.stats.seconds, "changed": result.changed, "similarity": result.similarity, "notes": result.notes, "ml_polish_requested": body.ml_polish, "lexical_refined": result.stats.lexical_refined, }, "account": account_payload(account), } @app.post("/v1/grammar") def api_grammar(body: GrammarRequest): """Return LanguageTool issues without applying free-form rewrites.""" text = (body.text or "").strip() language = normalize_language(body.language or LANGUAGE_TOOL_LANGUAGE) if not text: logger.info("grammar rejected: empty text") raise HTTPException(status_code=400, detail="Paste some text to check.") if len(text) > GRAMMAR_MAX_CHARS: logger.info("grammar rejected: too long chars=%s max=%s", len(text), GRAMMAR_MAX_CHARS) raise HTTPException( status_code=413, detail=( f"Text is too long for grammar check ({len(text):,} chars). " f"Max is {GRAMMAR_MAX_CHARS:,} characters — shorten the draft or split into sections." ), ) try: result = check_grammar(text, language=language) logger.info( "grammar ok lang=%s words=%s issues=%s engine=%s", result.get("language"), result.get("input_words"), len(result.get("issues") or []), result.get("engine"), ) return result except HTTPException: raise except Exception: logger.exception("grammar check crashed — returning safe fallback") try: return rules_fallback_result(text, language) except Exception: logger.exception("grammar fallback also failed") raise HTTPException( status_code=500, detail="Grammar check failed on the server. Try a shorter text and retry.", ) from None def _register_frontend() -> None: if not STATIC_DIR.is_dir(): return assets = STATIC_DIR / "assets" if assets.is_dir(): app.mount("/assets", StaticFiles(directory=str(assets)), name="assets") index = STATIC_DIR / "index.html" @app.get("/") def serve_index(): return FileResponse(index) for route, filename, media_type in ( ("/favicon.ico", "favicon.svg", "image/svg+xml"), ("/favicon.svg", "favicon.svg", "image/svg+xml"), ("/zuzu-icon-512.png", "zuzu-icon-512.png", "image/png"), ("/zuzu-logo.png", "zuzu-logo.png", "image/png"), ("/apple-touch-icon.png", "apple-touch-icon.png", "image/png"), ): def serve_asset( file_name: str = filename, content_type: str = media_type, ): path = STATIC_DIR / file_name if not path.is_file(): raise HTTPException(status_code=404) return FileResponse(path, media_type=content_type) route_name = "static_" + route.strip("/").replace(".", "_").replace("-", "_") app.add_api_route(route, serve_asset, methods=["GET"], name=route_name) _register_frontend()