"""FastAPI backend for the Standpoint browser GUI.
This is the thin server behind the single-page GUI: it turns an edited table into a
positioning result the browser can render. The heavy lifting stays in the library:
`positioning()` runs the PCA, orientation, colouring, LLM pole naming, and analysis;
this module only exposes it over HTTP and serves the static page.
Endpoints
---------
GET / redirect to the GUI.
GET /gui the single-page editor + viewer (see `webgui.GUI_HTML`).
GET /api/example a starter table (CSV text) to populate the grid.
POST /api/position from an edited table, return the Vega-Lite spec + Markdown + YAML.
Run it with ``standpoint-gui`` (installed by the ``gui`` extra) or
``uvicorn standpoint.api:app``. It is intentionally *not* imported by the core
package, so the library and CLIs carry no web dependency.
"""
from __future__ import annotations
import io
from pathlib import Path
import ollama
import pandas as pd
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
PlainTextResponse,
RedirectResponse,
Response,
)
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from standpoint import (
DEFAULT_MODEL,
SUPPORTED_LANGS,
__version__,
analysis_markdown,
i18n,
parse_table,
positioning,
suggest_ratings,
)
from standpoint.webgui import GUI_HTML
# Excel MIME type for the .xlsx download response.
_XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
# Keep the OpenAPI version in step with the package (single source of truth).
app = FastAPI(title="Standpoint GUI", version=__version__)
# Static assets (the app icon set + web manifest) live next to this module so they
# ship as package data and resolve identically whether run from the repo or an
# installed wheel. Mounting them lets the page reference stable `/static/...` URLs.
_STATIC_DIR = Path(__file__).resolve().parent / "static"
if _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
@app.get("/favicon.ico", include_in_schema=False)
def favicon() -> FileResponse:
"""Serve the multi-size favicon browsers request from the site root by default."""
return FileResponse(_STATIC_DIR / "favicon.ico", media_type="image/x-icon")
@app.get("/site.webmanifest", include_in_schema=False)
def webmanifest() -> FileResponse:
"""Serve the PWA manifest (name, theme colours, install icons)."""
return FileResponse(_STATIC_DIR / "site.webmanifest", media_type="application/manifest+json")
# The grid is seeded from the tracked example so the GUI always mirrors it; this
# small built-in table is the fallback when the file isn't on disk (installed package).
_EXAMPLE_CSV = Path(__file__).resolve().parents[1] / "examples" / "programming_languages.csv"
_STARTER_TABLE = (
"Programming Language,Performance,Ease of Learning,Ecosystem,Concurrency,Type Safety,Job Market,Tooling\n"
"Python,2,5,5,2,2,5,4\n"
"Rust,5,2,3,5,5,3,4\n"
"Go,4,4,4,5,4,4,4\n"
"JavaScript,3,4,5,3,2,5,3\n"
"Java,4,3,5,4,4,5,4\n"
)
[docs]
class PositionRequest(BaseModel):
"""Body of ``POST /api/position``: one edited table plus a few options.
Attributes
----------
table : str
The edited comparison table as CSV text (first column = option names).
reference : str
Row index (as a string) or exact option name to place top-right.
lower : str
Comma-separated criteria where lower is better (e.g. ``"Price,Weight"``).
model : str
Ollama model used to name the axes and write the analysis.
"""
table: str
reference: str = "0"
lower: str = ""
model: str = DEFAULT_MODEL
lang: str = "" # "" = detect from the table; "en"/"fr"/"es" force the output language
[docs]
class PositionResponse(BaseModel):
"""What the browser needs to draw the quadrant and show the write-up."""
vega: dict
markdown: str
yaml: str
axes: dict[str, str]
poles: list[str]
reference: str
roles: dict[str, str]
slug: str # filename stem for exports, from the table's plural noun (e.g. "programming-languages")
@app.get("/", include_in_schema=False)
def index() -> RedirectResponse:
"""Redirect the site root to the GUI page."""
return RedirectResponse(url="/gui")
@app.get("/gui", response_class=HTMLResponse, include_in_schema=False)
def gui() -> str:
"""Serve the single-page table editor + quadrant viewer."""
return GUI_HTML
@app.get("/api/example", response_class=PlainTextResponse)
def example() -> str:
"""Return a starter table (CSV text) to populate an empty grid.
Prefer the tracked `examples/programming_languages.csv` so the GUI stays in sync
with it; fall back to the small built-in table when the file isn't present.
"""
try:
return _EXAMPLE_CSV.read_text(encoding="utf-8")
except OSError:
return _STARTER_TABLE
def _df_to_csv(df: pd.DataFrame) -> str:
"""Serialize a parsed table back to clean CSV for the grid (ints stay ints)."""
# `%g` drops the ".0" that read_excel / parse_table introduce, and blanks stay
# blank, so the grid shows "2" and "" rather than "2.0" and "nan".
return df.to_csv(float_format="%g")
@app.post("/api/upload", response_class=PlainTextResponse)
async def upload(file: UploadFile = File(...)) -> str:
"""Load an uploaded **CSV or XLSX** table and return it as CSV for the grid.
Parameters
----------
file : UploadFile
The uploaded file; ``.xlsx`` / ``.xls`` are read with pandas (openpyxl),
anything else is treated as CSV or Markdown via `parse_table`.
Returns
-------
str
The table as CSV text, ready to populate the editor grid.
Raises
------
HTTPException
400 if the file can't be read as a table.
"""
content = await file.read()
name = (file.filename or "").lower()
try:
if name.endswith((".xlsx", ".xls")):
df = pd.read_excel(io.BytesIO(content), index_col=0)
else:
df = parse_table(content.decode("utf-8", errors="replace"))
except Exception as exc: # unreadable spreadsheet / not a table
raise HTTPException(status_code=400, detail=f"Could not read the file: {exc}") from exc
return _df_to_csv(df)
[docs]
class TableText(BaseModel):
"""A table as CSV text: the body of the XLSX download request."""
table: str
@app.post("/api/download/xlsx")
def download_xlsx(req: TableText) -> Response:
"""Convert the edited table (CSV text) to an ``.xlsx`` file for download.
Parameters
----------
req : TableText
The current grid serialized to CSV.
Returns
-------
Response
The workbook bytes with an ``attachment`` disposition so the browser saves
``standpoint.xlsx``.
Raises
------
HTTPException
400 if the CSV can't be parsed into a table.
"""
try:
df = parse_table(req.table)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not parse the table: {exc}") from exc
buf = io.BytesIO()
df.to_excel(buf) # index = option names; openpyxl writes the .xlsx
return Response(
content=buf.getvalue(),
media_type=_XLSX_MIME,
headers={"Content-Disposition": "attachment; filename=standpoint.xlsx"},
)
def _slugify(text: str) -> str:
"""Turn a display name into a filename stem: lowercase, words joined by hyphens.
Used to name exports after the table's subject, so the "Programming Language"
example downloads as ``programming-languages.png`` rather than a generic stem.
"""
import re
import unicodedata
ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
return slug or "standpoint"
def _model_error(exc: Exception, model: str) -> HTTPException:
"""Map an Ollama failure to a clean 503 with an actionable, model-specific hint.
The two first-run failures (server not running, model not pulled) otherwise
surface as an opaque 500/404; here they become a message the UI can show verbatim.
"""
if isinstance(exc, ConnectionError):
return HTTPException(
status_code=503,
detail=(
"The local Ollama server is not reachable. Start it with `ollama serve`, "
f"then make sure the model is present: `ollama pull {model}`."
),
)
missing = getattr(exc, "status_code", None) == 404
hint = (
f"The model '{model}' is not installed. Pull it with `ollama pull {model}`."
if missing
else f"The local model '{model}' returned an error: {exc}"
)
return HTTPException(status_code=503, detail=hint)
@app.get("/api/i18n")
def i18n_strings(lang: str = "en") -> dict:
"""Return the GUI's localized strings for `lang` (falls back to English).
The single-page app fetches this to render every label, button, and message in
the language chosen by the header toggle, so the UI text lives in `i18n.yaml`
next to the LLM prompts rather than being hard-coded in the HTML.
"""
strings = i18n(lang).get("gui") or i18n("en")["gui"]
return {"lang": lang if lang in SUPPORTED_LANGS else "en", "strings": strings}
[docs]
class AutofillRequest(BaseModel):
"""Body of ``POST /api/autofill``: the names to score, plus model and language.
Attributes
----------
noun : str
The first-column word (what a row is, e.g. "Programming Language").
options : list[str]
Option (row) names to rate.
criteria : list[str]
Criterion (column) names to rate each option on.
model : str
Ollama model that produces the ratings.
lang : str
Force the prompt language; "" detects it from the names.
"""
noun: str = "Option"
options: list[str]
criteria: list[str]
model: str = DEFAULT_MODEL
lang: str = ""
@app.post("/api/autofill")
def autofill(req: AutofillRequest) -> dict:
""""Flemme" (lazy) auto-fill: score every option on every criterion via the model.
The user typed only the row and column names (common for a freshly uploaded CSV
that carries headers but no values); this asks the local model to fill the whole
ratings matrix from its own knowledge, offline.
Returns
-------
dict
``{"ratings": {option: {criterion: int}}}`` for the front-end to load into
the grid.
Raises
------
HTTPException
400 if no option / criterion was named; 503 if the model is unavailable.
"""
lang = req.lang if req.lang in SUPPORTED_LANGS else None
try:
ratings = suggest_ratings(req.noun, req.options, req.criteria, model=req.model, lang=lang)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except (ConnectionError, ollama.ResponseError) as exc:
raise _model_error(exc, req.model) from exc
return {"ratings": ratings}
@app.post("/api/position", response_model=PositionResponse)
def position(req: PositionRequest) -> PositionResponse:
"""Run the full positioning on an edited table and return everything to draw it.
Parameters
----------
req : PositionRequest
The edited table and options from the browser.
Returns
-------
PositionResponse
The Vega-Lite spec (rendered client-side by vega-embed), the Markdown
interpretation, the YAML dump, and the axis names / poles / roles.
Raises
------
HTTPException
400 if the table is empty or degenerate, or the reference is unknown;
the library's ``ValueError`` message is passed straight through to the UI.
"""
if not req.table.strip():
raise HTTPException(status_code=400, detail="The table is empty.")
# A numeric reference arrives as a string ("0"); pass ints through as ints so
# `positioning` treats it as a row index rather than an option name.
ref: int | str = int(req.reference) if req.reference.lstrip("-").isdigit() else req.reference
lower = [c.strip() for c in req.lower.split(",") if c.strip()]
# "" means detect from the table; a toggle value forces the whole deliverable
# (poles, title, narrative) into that language.
lang = req.lang if req.lang in SUPPORTED_LANGS else None
try:
pos = positioning(
req.table,
reference=ref,
lower_is_better=lower,
model=req.model,
lang=lang,
)
# The Markdown narrative is a separate call so a slow model doesn't block the
# spec; here we compute it inline since the whole request is already synchronous.
markdown = analysis_markdown(pos.result, pos.roles, pos.poles, model=req.model, lang=lang)
except ValueError as exc: # bad table / unknown reference -> a clean 400 for the UI
raise HTTPException(status_code=400, detail=str(exc)) from exc
except (ConnectionError, ollama.ResponseError) as exc: # model unavailable -> actionable 503
raise _model_error(exc, req.model) from exc
# One payload with everything the page draws from: the Vega-Lite spec for the
# chart, the Markdown write-up, the YAML dump for download, and the axis names /
# poles / per-option roles the front-end uses to colour the analysis. Bundling
# them means the browser draws the whole result from a single round-trip.
return PositionResponse(
vega=pos.to_vega(), # spec vega-embed renders client-side
markdown=markdown, # the written interpretation
yaml=pos.to_yaml(), # coordinates + coefficients, offered as a download
axes=pos.axes, # {'x': ..., 'y': ...} axis titles
poles=pos.poles, # the four pole labels
reference=pos.result.reference, # resolved name of the top-right anchor
roles=pos.role_of, # option -> role, drives the name tinting
slug=_slugify(pos.noun_plural), # export filename stem from the table's plural noun
)
[docs]
def main_gui() -> None:
"""Console entry point (``standpoint-gui``): serve the GUI on localhost:8000."""
import uvicorn
# Local-first: bind to loopback only, so the table never leaves the machine.
print("Standpoint GUI -> http://localhost:8000/gui (Ctrl-C to stop)")
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
if __name__ == "__main__":
main_gui()