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:
parent
8fda821e15
commit
0904967320
2 changed files with 25 additions and 112 deletions
133
app/api.py
133
app/api.py
|
|
@ -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("/")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue