Skip to content

Commit c9d8b46

Browse files
committed
Merge remote-tracking branch 'origin/development' into 564-feat-enrich-ai-input-with-open-source-weather-apis
2 parents 6bf87d0 + da5da4f commit c9d8b46

7 files changed

Lines changed: 562 additions & 0 deletions

File tree

app/api/router.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
"""Aggregates every route module into a single API router.
22
33
Add new feature routers here; main.py only mounts this one router.
4+
5+
IMPORTANT: Never add prefix="/api/v1" to api_router itself — that would
6+
silently move the existing /templates and /forms routes and break the frontend.
7+
New v1 endpoints live in app/api/v1/ and are included via v1_router below.
48
"""
59

610
from fastapi import APIRouter
711

12+
from app.api.routes import forms, templates
13+
from app.api.v1.router import v1_router
814
from app.api.routes import forms, templates, weather, zipcode
915

1016
api_router = APIRouter()
1117
api_router.include_router(templates.router)
1218
api_router.include_router(forms.router)
19+
api_router.include_router(v1_router)
20+
1321
api_router.include_router(weather.router)
1422
api_router.include_router(zipcode.router)

app/api/schemas/system.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pydantic import BaseModel
2+
3+
4+
class ModelInfo(BaseModel):
5+
name: str
6+
size_gb: float
7+
quantization: str | None = None
8+
loaded: bool
9+
10+
11+
class CurrentLoad(BaseModel):
12+
active_requests: int
13+
queued_requests: int
14+
15+
16+
class ComponentHealth(BaseModel):
17+
status: str
18+
response_time_ms: int | None = None
19+
detail: str | None = None
20+
model_loaded: str | None = None
21+
ollama_version: str | None = None
22+
models_available: list[ModelInfo] | None = None
23+
current_load: CurrentLoad | None = None
24+
disk_free_gb: float | None = None
25+
26+
27+
class HealthComponents(BaseModel):
28+
database: ComponentHealth
29+
ollama: ComponentHealth
30+
whisper: ComponentHealth
31+
storage: ComponentHealth
32+
33+
34+
class HealthStatus(BaseModel):
35+
status: str
36+
version: str
37+
uptime_seconds: int | None = None
38+
components: HealthComponents | None = None
39+
40+
41+
class SchemaVersion(BaseModel):
42+
version: str
43+
released_at: str
44+
changelog: str | None = None
45+
breaking_changes: bool | None = None

app/api/v1/__init__.py

Whitespace-only changes.

app/api/v1/router.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Aggregates all /api/v1 route modules.
2+
3+
This router is included ADDITIVELY in app/api/router.py alongside the
4+
existing /templates and /forms routers. Never put prefix="/api/v1" on the
5+
top-level api_router — that would silently move the existing routes.
6+
"""
7+
8+
from fastapi import APIRouter
9+
10+
from app.api.v1.routes import system
11+
12+
v1_router = APIRouter(prefix="/api/v1")
13+
v1_router.include_router(system.router)

app/api/v1/routes/__init__.py

Whitespace-only changes.

app/api/v1/routes/system.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""System endpoints: GET /api/v1/health, /schema/incident, /schema/incident/versions.
2+
3+
Health contract: contracts/path/system.yaml + contracts/schemas/system.yaml
4+
"""
5+
6+
import shutil
7+
import time
8+
9+
import requests
10+
from fastapi import APIRouter
11+
from fastapi.responses import JSONResponse
12+
from sqlalchemy import text
13+
14+
from app.api.schemas.system import (
15+
ComponentHealth,
16+
HealthComponents,
17+
HealthStatus,
18+
ModelInfo,
19+
)
20+
from app.core.config import APP_VERSION, DATA_DIR, OLLAMA_HOST, WHISPER_HOST
21+
from app.db.database import engine
22+
23+
router = APIRouter(tags=["system"])
24+
25+
_START_TIME = time.monotonic()
26+
27+
# Tune-able thresholds. _SLOW_MS is patched in tests to trigger degraded
28+
# without mocking wall-clock time.
29+
_PROBE_TIMEOUT = 5
30+
_SLOW_MS = 5_000
31+
32+
33+
def _check_database() -> ComponentHealth:
34+
t0 = time.monotonic()
35+
try:
36+
with engine.connect() as conn:
37+
conn.execute(text("SELECT 1"))
38+
elapsed = int((time.monotonic() - t0) * 1000)
39+
return ComponentHealth(status="healthy", response_time_ms=elapsed)
40+
except Exception as exc:
41+
return ComponentHealth(status="unhealthy", detail=str(exc))
42+
43+
44+
def _check_ollama() -> ComponentHealth:
45+
t0 = time.monotonic()
46+
try:
47+
tags_resp = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=_PROBE_TIMEOUT)
48+
tags_resp.raise_for_status()
49+
elapsed = int((time.monotonic() - t0) * 1000)
50+
51+
tags_data = tags_resp.json()
52+
53+
ollama_version: str | None = None
54+
try:
55+
ver_resp = requests.get(f"{OLLAMA_HOST}/api/version", timeout=_PROBE_TIMEOUT)
56+
ver_resp.raise_for_status()
57+
ollama_version = ver_resp.json().get("version")
58+
except Exception:
59+
pass
60+
61+
running_names: set[str] = set()
62+
model_loaded: str | None = None
63+
try:
64+
ps_resp = requests.get(f"{OLLAMA_HOST}/api/ps", timeout=_PROBE_TIMEOUT)
65+
ps_resp.raise_for_status()
66+
running = ps_resp.json().get("models", [])
67+
running_names = {m.get("name", "") for m in running}
68+
if running:
69+
model_loaded = running[0].get("name")
70+
except Exception:
71+
pass
72+
73+
raw_models = tags_data.get("models", [])
74+
models_available: list[ModelInfo] = []
75+
for m in raw_models:
76+
name = m.get("name", "")
77+
size_gb = round(m.get("size", 0) / (1024 ** 3), 2)
78+
quantization = m.get("details", {}).get("quantization_level")
79+
models_available.append(
80+
ModelInfo(name=name, size_gb=size_gb, quantization=quantization, loaded=name in running_names)
81+
)
82+
83+
status = "degraded" if elapsed > _SLOW_MS else "healthy"
84+
85+
return ComponentHealth(
86+
status=status,
87+
response_time_ms=elapsed,
88+
model_loaded=model_loaded,
89+
ollama_version=ollama_version,
90+
models_available=models_available if models_available else None,
91+
# current_load is always None — Ollama exposes no queue-depth API;
92+
# populating it would require fabricating data.
93+
current_load=None,
94+
)
95+
except requests.exceptions.RequestException as exc:
96+
return ComponentHealth(status="unhealthy", detail=str(exc))
97+
98+
99+
def _check_whisper() -> ComponentHealth:
100+
# The onerahmet/openai-whisper-asr-webservice image does not expose /health.
101+
# Both compose files probe /docs (a FastAPI auto-endpoint always present).
102+
t0 = time.monotonic()
103+
try:
104+
resp = requests.get(f"{WHISPER_HOST}/docs", timeout=_PROBE_TIMEOUT)
105+
resp.raise_for_status()
106+
elapsed = int((time.monotonic() - t0) * 1000)
107+
return ComponentHealth(status="healthy", response_time_ms=elapsed)
108+
except requests.exceptions.RequestException as exc:
109+
return ComponentHealth(status="unhealthy", detail=str(exc))
110+
111+
112+
def _check_storage() -> ComponentHealth:
113+
try:
114+
usage = shutil.disk_usage(DATA_DIR)
115+
disk_free_gb = round(usage.free / (1024 ** 3), 2)
116+
return ComponentHealth(status="healthy", disk_free_gb=disk_free_gb)
117+
except OSError as exc:
118+
return ComponentHealth(status="unhealthy", detail=str(exc))
119+
120+
121+
@router.get(
122+
"/health",
123+
responses={
124+
200: {"model": HealthStatus, "description": "System healthy or degraded"},
125+
503: {"model": HealthStatus, "description": "System unhealthy, cannot serve requests"},
126+
},
127+
summary="System health check",
128+
)
129+
def get_health():
130+
database = _check_database()
131+
ollama = _check_ollama()
132+
whisper = _check_whisper()
133+
storage = _check_storage()
134+
135+
components = HealthComponents(
136+
database=database, ollama=ollama, whisper=whisper, storage=storage
137+
)
138+
139+
statuses = {database.status, ollama.status, whisper.status, storage.status}
140+
141+
if database.status == "unhealthy":
142+
overall = "unhealthy"
143+
elif "unhealthy" in statuses or "degraded" in statuses:
144+
overall = "degraded"
145+
else:
146+
overall = "healthy"
147+
148+
body = HealthStatus(
149+
status=overall,
150+
version=APP_VERSION,
151+
uptime_seconds=int(time.monotonic() - _START_TIME),
152+
components=components,
153+
)
154+
155+
http_status = 503 if overall == "unhealthy" else 200
156+
return JSONResponse(
157+
content=body.model_dump(exclude_none=True),
158+
status_code=http_status,
159+
)
160+
161+
162+
@router.get("/schema/incident", summary="Get canonical incident JSON Schema")
163+
def get_schema_incident():
164+
# PROVISIONAL: This 501 body shape is not canonical — it will be updated
165+
# to match the standardized ErrorResponse envelope once #543 lands.
166+
return JSONResponse(
167+
status_code=501,
168+
content={
169+
"error_code": "NOT_IMPLEMENTED",
170+
"message": "Incident schema not yet finalised — see issue #555",
171+
},
172+
)
173+
174+
175+
@router.get("/schema/incident/versions", summary="Get incident schema version history")
176+
def get_schema_versions():
177+
# PROVISIONAL: Same as /schema/incident — shape will align with #543.
178+
return JSONResponse(
179+
status_code=501,
180+
content={
181+
"error_code": "NOT_IMPLEMENTED",
182+
"message": "Schema version history not yet available — see issue #555",
183+
},
184+
)

0 commit comments

Comments
 (0)