any2human / app /main.py
idnameraj's picture
Disable generative T5 paraphrase; keep spaCy + phrase/lexical + MiniLM safety.
a70e724
Raw
History Blame Contribute Delete
11.5 kB
"""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()