feat: slim api.py to factory-only; all domain routes in dedicated modules

Replace 149-line api.py (with inline helpers, JSONL utilities, and ad-hoc
router registrations) with a 57-line pure factory. All business logic was
already extracted to domain modules in B1-B7; this removes the dead code
and adds the /api/corrections/* prefix alongside the /api/sft/* backward-
compat alias. Smoke tests updated to cover the new /api/corrections/ingest
and /api/dashboard routes.
This commit is contained in:
pyr0ball 2026-05-02 09:55:58 -07:00
parent 8fda821e15
commit 0904967320
2 changed files with 25 additions and 112 deletions

View file

@ -1,144 +1,53 @@
"""Avocet — FastAPI REST layer. """Avocet -- FastAPI app factory.
JSONL read/write helpers and FastAPI app instance. Mounts all domain routers and serves the Vue SPA.
Endpoints and static file serving are added in subsequent tasks. All business logic lives in the domain modules below.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import os
import subprocess as _subprocess
import yaml
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
_ROOT = Path(__file__).parent.parent
_DATA_DIR: Path = _ROOT / "data" # overridable in tests via set_data_dir()
_MODELS_DIR: Path = _ROOT / "models" # overridable in tests via set_models_dir()
_CONFIG_DIR: Path | None = None # None = use real path
def set_data_dir(path: Path) -> None:
"""Override data directory — used by tests."""
global _DATA_DIR
_DATA_DIR = path
def set_models_dir(path: Path) -> None:
"""Override models directory — used by tests."""
global _MODELS_DIR
_MODELS_DIR = path
def set_config_dir(path: Path | None) -> None:
"""Override config directory — used by tests."""
global _CONFIG_DIR
_CONFIG_DIR = path
def _config_file() -> Path:
if _CONFIG_DIR is not None:
return _CONFIG_DIR / "label_tool.yaml"
return _ROOT / "config" / "label_tool.yaml"
def _queue_file() -> Path:
return _DATA_DIR / "email_label_queue.jsonl"
def _score_file() -> Path:
return _DATA_DIR / "email_score.jsonl"
def _discarded_file() -> Path:
return _DATA_DIR / "discarded.jsonl"
def _read_jsonl(path: Path) -> list[dict]:
if not path.exists():
return []
lines = path.read_text(encoding="utf-8").splitlines()
return [json.loads(l) for l in lines if l.strip()]
def _write_jsonl(path: Path, records: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
text = "\n".join(json.dumps(r, ensure_ascii=False) for r in records)
path.write_text(text + "\n" if records else "", encoding="utf-8")
def _append_jsonl(path: Path, record: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _item_id(item: dict) -> str:
"""Stable content-hash ID — matches label_tool.py _entry_key dedup logic."""
key = (item.get("subject", "") + (item.get("body", "") or "")[:100])
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
def _normalize(item: dict) -> dict:
"""Normalize JSONL item to the Vue frontend schema.
label_tool.py stores: subject, body, from_addr, date, account (no id).
The Vue app expects: id, subject, body, from, date, source.
Both old (from_addr/account) and new (from/source) field names are handled.
"""
return {
"id": item.get("id") or _item_id(item),
"subject": item.get("subject", ""),
"body": item.get("body", ""),
"from": item.get("from") or item.get("from_addr", ""),
"date": item.get("date", ""),
"source": item.get("source") or item.get("account", ""),
}
app = FastAPI(title="Avocet API") app = FastAPI(title="Avocet API")
# -- Domain routers --------------------------------------------------------
from app.data.label import router as label_router from app.data.label import router as label_router
app.include_router(label_router, prefix="/api") app.include_router(label_router, prefix="/api")
from app.sft import router as sft_router from app.data.fetch import router as fetch_router
app.include_router(sft_router, prefix="/api/sft") app.include_router(fetch_router, prefix="/api")
from app.models import router as models_router from app.data.corrections import router as corrections_router
import app.models as _models_module app.include_router(corrections_router, prefix="/api/corrections")
app.include_router(models_router, prefix="/api/models")
# Backward-compat alias -- remove when Vue SPA is updated to /api/corrections/*
app.include_router(corrections_router, prefix="/api/sft")
from app.data.imitate import router as imitate_router
app.include_router(imitate_router, prefix="/api/imitate")
from app.eval.cforch import router as eval_router from app.eval.cforch import router as eval_router
app.include_router(eval_router, prefix="/api") app.include_router(eval_router, prefix="/api")
from app.imitate import router as imitate_router
app.include_router(imitate_router, prefix="/api/imitate")
from app.data.fetch import router as fetch_router
app.include_router(fetch_router, prefix="/api")
from app.train.train import router as train_router from app.train.train import router as train_router
app.include_router(train_router, prefix="/api/train") app.include_router(train_router, prefix="/api/train")
from app.dashboard import router as dashboard_router from app.dashboard import router as dashboard_router
app.include_router(dashboard_router, prefix="/api") app.include_router(dashboard_router, prefix="/api")
from app.models import router as models_router
app.include_router(models_router, prefix="/api/models")
# Static SPA — MUST be last (catches all unmatched paths) # -- Static SPA -- MUST be last (catches all unmatched paths) ---------------
_ROOT = Path(__file__).parent.parent
_DIST = _ROOT / "web" / "dist" _DIST = _ROOT / "web" / "dist"
if _DIST.exists(): if _DIST.exists():
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
# Serve index.html with no-cache so browsers always fetch fresh HTML after rebuilds.
# Hashed assets (/assets/index-abc123.js) can be cached forever — they change names
# when content changes (standard Vite cache-busting strategy).
_NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"} _NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"}
@app.get("/") @app.get("/")

View file

@ -28,6 +28,10 @@ def test_app_has_required_routes():
# Train routes # Train routes
assert "/api/train/jobs" in paths assert "/api/train/jobs" in paths
assert "/api/train/results" in paths assert "/api/train/results" in paths
# Dashboard
assert "/api/dashboard" in paths
# Corrections (new prefix)
assert "/api/corrections/ingest" in paths
@pytest.fixture @pytest.fixture