File size: 11,495 Bytes
39cfcd1 155974e fe5d482 155974e 39cfcd1 155974e 4c5fda9 1103f3b 39cfcd1 155974e 4c5fda9 b387e01 4c5fda9 155974e 27d974a 67f284e 74087c2 0435b8d b543594 aeab924 31bce5e 0435b8d 27d974a 74087c2 0435b8d aeab924 39cfcd1 0435b8d 3b361f1 155974e 39cfcd1 fe5d482 1103f3b 39cfcd1 1103f3b 39cfcd1 1103f3b 155974e c212805 39cfcd1 155974e 39cfcd1 c212805 155974e 2d0fe75 7063659 2d0fe75 4c5fda9 155974e 39cfcd1 155974e 7063659 155974e 39cfcd1 155974e 7063659 8f6d79d a70e724 67f284e 31bce5e 0435b8d b543594 0435b8d aeab924 0435b8d 74087c2 d06306b bf1d8e6 74087c2 8f6d79d 0435b8d aeab924 b543594 8f6d79d 3b361f1 155974e b387e01 4c5fda9 b387e01 4c5fda9 b387e01 155974e b387e01 4c5fda9 b387e01 39cfcd1 b387e01 39cfcd1 b387e01 155974e d06306b c212805 bf1d8e6 67f284e 0435b8d d06306b c212805 155974e b387e01 39cfcd1 b387e01 155974e 39cfcd1 8f6d79d 39cfcd1 155974e 39cfcd1 1103f3b 6f7cc69 1103f3b c212805 155974e b387e01 155974e 2d0fe75 fe5d482 39cfcd1 2d0fe75 7063659 2d0fe75 fe5d482 2d0fe75 27d974a 2d0fe75 27d974a 2d0fe75 fe5d482 7063659 fe5d482 7063659 fe5d482 7063659 fe5d482 27d974a fe5d482 27d974a 2d0fe75 1103f3b 39cfcd1 1103f3b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | """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()
|