Source code for standpoint.api

"""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 pandas as pd
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
from pydantic import BaseModel

from standpoint import (
    DEFAULT_MODEL,
    __version__,
    analysis_markdown,
    parse_table,
    positioning,
)
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__)

# 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
[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]
@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"}, ) @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()] try: pos = positioning( req.table, reference=ref, lower_is_better=lower, model=req.model, ) except ValueError as exc: # bad table / unknown reference -> a clean 400 for the UI raise HTTPException(status_code=400, detail=str(exc)) from exc # 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) # 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 )
[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()