Base URL: http://localhost:8000 — interactive docs at /docs (FastAPI
auto-generated). CORS is restricted to http://localhost:5173 and
http://127.0.0.1:5173 for GET, POST and OPTIONS.
| Method | Path | Purpose | Egress |
|---|---|---|---|
GET |
/api/health |
Liveness probe | none |
POST |
/api/analysis |
Local document metrics | none |
POST |
/api/profiles/analyze |
Pool samples into a style profile | none |
POST |
/api/profiles/compare |
Compare a document to a profile | none |
POST |
/api/style/compare |
Advanced style-fit comparison | none |
POST |
/api/issues/analyze |
Build a problem map | none |
GET |
/api/suggestions/status |
Is a provider configured? | none |
POST |
/api/suggestions |
Targeted alternative wordings | provider |
POST |
/api/suggestions/validate |
Validate a replacement locally | none |
Only one endpoint can contact an external provider, and only after an explicit user action. Every other endpoint is fully local.
Unhandled errors return a structured 500 — never a traceback or document text:
{ "detail": { "code": "INTERNAL_ERROR", "message": "The server could not complete this request." } }Suggestion endpoints return typed errors using the same envelope:
| Code | HTTP | Meaning |
|---|---|---|
PROVIDER_NOT_CONFIGURED |
503 | No provider configured — local features still work |
PROVIDER_AUTH_FAILED |
502 | Provider rejected the API key |
PROVIDER_RATE_LIMITED |
429 | Provider rate limit (never retried) |
PROVIDER_TIMEOUT |
504 | Provider did not respond in time (never retried) |
PROVIDER_UNAVAILABLE |
502 | Transient provider failure after one retry |
INVALID_PROVIDER_RESPONSE |
502 | Response could not be parsed into a valid shape |
INVALID_REQUEST |
422 | Missing or malformed field for the chosen mode |
STALE_ANALYSIS |
409 | Supplied text no longer matches the issue offsets |
STALE_SELECTION |
409 | Selection offsets no longer match the supplied text |
INVALID_TARGET_RANGE |
422 | Offsets fall outside the text |
SELECTION_TOO_LONG |
422 | Selection exceeds the per-request target limit |
UNSUPPORTED_ISSUE_SCOPE |
422 | A DOCUMENT-scope issue cannot target one passage |
Schema violations return FastAPI's standard 422 with a detail array.
{ "status": "ok" }Analyze text locally. Whitespace-only input is rejected with 422. Document
size is capped at 200,000 characters.
Request
{ "text": "Your document text." }Response — a typed, logically grouped tree (see METRICS.md for every field's definition):
{
"document": { "char_count": 0, "word_count": 0, "...": "..." },
"sentences": { "average": 0.0, "population_stddev": 0.0, "...": "..." },
"paragraphs": { "...": "..." },
"lexical": { "type_token_ratio": 0.0, "root_type_token_ratio": 0.0 },
"repetition": { "bigrams": [], "trigrams": [], "highlights": [] },
"transitions": [],
"sentence_openings": [],
"punctuation": { "raw": {}, "per_100_words": {} },
"contractions": { "count": 0, "per_100_words": 0.0 },
"readability": { "flesch_reading_ease": null, "flesch_kincaid_grade": null }
}Fields that are undefined for degenerate input (for example an average with no
words) are null, never NaN.
Pool writing samples into a personal style profile. Requires 2–12 non-blank samples, at most 60,000 characters each and 200,000 characters in total.
Request
{ "samples": [ { "label": "Essay", "text": "..." }, { "text": "..." } ] }Response — PersonalStyleProfile: coverage, reliability
(VERY_LOW / LOW / MODERATE / HIGH), pooled sentence, paragraph and
lexical style, per-feature stability across samples, language_habits and
opening_patterns. From the advanced-style layer it may also carry an
advanced block. Profiles built before that layer existed simply omit it and
are supported by legacy derivation.
Profiles are returned to the caller and kept in frontend memory only — there is no persistence and no account.
Compare one document against an existing profile.
Request
{ "text": "...", "profile": { "coverage": {}, "reliability": {} } }Response — ComparisonResult: per-metric differences plus the normalized
style distance. This is a measurement only — never a human/AI classification.
Advanced style comparison: one text against a profile.
Request
{ "text": "...", "profile": { "...": "..." } }Response — StyleFitResult: a reliability-weighted normalized style
distance (lower means closer to the profile), a confidence level
(HIGH / MODERATE / LIMITED) and the strongest explainable deviations.
When evidence is weak the result is reported as LIMITED_EVIDENCE rather than
a confident label. Measurement only — never a probability, human score or
authorship claim.
Build a problem map. The profile is optional: repetition detectors run without one, while profile-deviation detectors need it.
Request
{ "text": "...", "profile": null }Response — ProblemMap, a list of explainable issues. Each issue carries
an id, type, scope, severity, character_start / character_end
(UTF-16 code units), an excerpt taken verbatim from the source text, and a
human-readable explanation.
Issue types — SENTENCE_LENGTH_DEVIATION, REPEATED_SENTENCE_OPENING,
LOCAL_SENTENCE_LENGTH_MONOTONY, TRANSITION_OVERUSE,
LOCAL_PHRASE_REPETITION, PARAGRAPH_LENGTH_DEVIATION,
PARAGRAPH_SENTENCE_DENSITY, PUNCTUATION_STYLE_DEVIATION.
Scopes — SENTENCE, PARAGRAPH, LOCAL_PATTERN, DOCUMENT. Only the
first three can target a passage for suggestions.
Issues are capped per type and grouped so the map stays readable. A map is never a detector and never an automatic edit.
Whether the optional provider is configured. Never exposes keys, base URLs or model credentials.
Generate 1–3 targeted alternative wordings for one selected passage.
Requires an explicitly configured provider; otherwise 503.
Two entry paths share one pipeline, selected by mode:
"issue"(analyzer-guided) — the backend re-derives the target passage from the supplied text and the issue's offsets, never trusting a client-side excerpt alone. Mismatches are rejected asSTALE_ANALYSIS."selection"(manual) — the user selected text directly in the editor. No analysis, problem map or issue is required. The backend re-slices the text at the exact captured offsets and requires an exact match with the client'sselected_text, otherwiseSTALE_SELECTION.
Request
{
"text": "...",
"mode": "selection",
"issue": null,
"selection": { "start": 10, "end": 48, "selected_text": "..." },
"protected_terms": ["Acme Corp"],
"goal": "tighten this sentence"
}Response — SuggestionResponse: mode, issue_id (null in selection
mode), suggestions, the verified target_start / target_end, provider,
model, generated_at, warnings, and protected_items. Provider and model
identifiers are descriptive only — no secrets are ever returned.
Every suggestion is validated locally against the original passage before it reaches the response, and ranked by style fit (advisory — ranking never hides or drops a candidate).
Requests are bounded: at most 3 suggestions, 1600 output tokens, and exactly one retry on a transient 5xx. Auth failures, rate limits and malformed responses are never retried, and there is never a second "repair" call.
Validate one (possibly edited) replacement for a passage. Fully local and deterministic — no provider is involved, so this works even with no provider configured.
This is the endpoint the UI calls immediately before Apply, for both unedited suggestions and text the user changed via "Edit first".
Request
{
"text": "...",
"replacement": "...",
"mode": "selection",
"selection": { "start": 10, "end": 48, "selected_text": "..." },
"protected_terms": ["Acme Corp"]
}Response — SuggestionValidationResponse:
{
"valid": false,
"issue_id": null,
"target_start": 10,
"target_end": 48,
"protected_items": [],
"protected_count": 0,
"violations": [],
"semantic_validation": { "status": "PASS", "notes": [] }
}valid is authoritative. A replacement that changed protected content is
never reported as valid. semantic_validation is independent and advisory:
PASS / REVIEW / HIGH_RISK / UNAVAILABLE, where UNAVAILABLE is
reported honestly when the embedding backend is missing rather than defaulting
to PASS.
| Limit | Value |
|---|---|
| Document text | 200,000 characters |
| Profile samples | 2–12 |
| Characters per sample | 60,000 |
| Characters across all samples | 200,000 |
| Sample label | 120 characters |
| Suggestions per request | 3 |