| """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):
|
|
|
| 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:
|
|
|
|
|
|
|
| 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()
|
|
|