import uuid from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel from typing import Dict, List, Optional from app.models import AreaContextBody, SystemCreate, SystemRecord from app.internal import firestore as fstore from app.internal import area_context as area_ctx from app.internal.auth import ( require_admin_token, require_node_service_or_firebase_token, resolve_caller_org_id, bootstrap_limiter, ) from app.internal.tenancy import FOUNDING_ORG_ID router = APIRouter(prefix="/systems", tags=["systems"]) class VocabularyTermBody(BaseModel): term: str class TenCodesBody(BaseModel): ten_codes: Dict[str, str] class PendingTermBody(BaseModel): talkgroup_id: int term: str class AiFlagsBody(BaseModel): stt_enabled: Optional[bool] = None correlation_enabled: Optional[bool] = None @router.get("") async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)): org_id = await resolve_caller_org_id(decoded) if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour return await fstore.collection_list("systems") return await fstore.collection_list("systems", org_id=org_id) @router.get("/{system_id}") async def get_system(system_id: str, decoded: dict = Depends(require_node_service_or_firebase_token)): system = await fstore.doc_get("systems", system_id) if not system: raise HTTPException(404, f"System '{system_id}' not found.") org_id = await resolve_caller_org_id(decoded) if org_id is not None and system.get("org_id") != org_id: raise HTTPException(404, f"System '{system_id}' not found.") return system @router.post("", status_code=201) async def create_system( body: SystemCreate, org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."), _: dict = Depends(require_admin_token), ): system_id = str(uuid.uuid4()) doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump()) await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False) return doc @router.put("/{system_id}") async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)): existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") # exclude_unset, or every field the caller omitted gets written as its # default and silently erases what was there. The systems page PUTs only # {name, type, config}, so a plain model_dump() wiped ten_codes on every # save — they are edited through PUT /{id}/ten-codes and were never in this # payload. area_context (server-26#36) would have been the second casualty. patch = body.model_dump(exclude_unset=True) # The form sends config.talkgroups[] in full, which would erase the resolved # anchor and the pending-term queue the backend put there. Same class of bug # as ten_codes above; the backend merges its own fields back rather than # taking dictation from the client (server-26#36). if "config" in patch: patch["config"] = area_ctx.merge_config(patch["config"], existing.get("config")) if "area_context" in patch: patch["area_context"] = area_ctx.merge_server_fields( area_ctx.normalize(patch["area_context"]), existing.get("area_context") ) await fstore.doc_update("systems", system_id, patch) # Geocoding the anchor is a write-time job — a place changes when someone # edits a town name, not every five minutes — but the operator should not # wait on Maps to see their save land. area_ctx.schedule_refresh(system_id) return {**existing, **patch} @router.delete("/{system_id}", status_code=204) async def delete_system(system_id: str, _: dict = Depends(require_admin_token)): existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") await fstore.doc_delete("systems", system_id) # ── Per-system AI flag overrides ────────────────────────────────────────────── @router.put("/{system_id}/ai-flags") async def update_system_ai_flags( system_id: str, body: AiFlagsBody, _: dict = Depends(require_admin_token), ): """ Set per-system AI flag overrides. Only fields included in the body are written; omitted fields remain unchanged (or absent, meaning inherit global). Pass null to clear an override and fall back to the global flag. """ existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") current: dict = existing.get("ai_flags") or {} for field, value in body.model_dump(exclude_unset=True).items(): if value is None: current.pop(field, None) # clear override → inherit global else: current[field] = value await fstore.doc_update("systems", system_id, {"ai_flags": current}) return {"ok": True, "ai_flags": current} # ── Ten-codes endpoints ──────────────────────────────────────────────────────── @router.get("/{system_id}/ten-codes") async def get_ten_codes(system_id: str): """Return the ten-code dictionary for a system.""" system = await fstore.doc_get("systems", system_id) if not system: raise HTTPException(404, f"System '{system_id}' not found.") return {"ten_codes": system.get("ten_codes") or {}} @router.put("/{system_id}/ten-codes") async def update_ten_codes( system_id: str, body: TenCodesBody, _: dict = Depends(require_admin_token), ): """Replace the ten-code dictionary for a system.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") await fstore.doc_update("systems", system_id, {"ten_codes": body.ten_codes}) return {"ok": True, "ten_codes": body.ten_codes} # ── Area context ────────────────────────────────────────────────────────────── @router.get("/{system_id}/area-context") async def get_area_context(system_id: str, _: dict = Depends(require_admin_token)): system = await fstore.doc_get("systems", system_id) if not system: raise HTTPException(404, f"System '{system_id}' not found.") return {"area_context": system.get("area_context") or {}} @router.put("/{system_id}/area-context") async def update_area_context( system_id: str, body: AreaContextBody, _: dict = Depends(require_admin_token), ): """ Replace the system-wide area context used by the corrector and the verifier. Ground truth about where this system operates — municipality, county, state, and the local names whose sound Whisper mangles. Per-talkgroup overrides live inside config.talkgroups[] and rank ABOVE this (server-26#36), so a multi-county system narrows per channel rather than replacing this wholesale. Leaving it entirely empty is legitimate and meaningful: it says nothing here is true of every talkgroup. The derived anchor (`center`, `radius_km`, `resolved_from`, `resolved_at`) is never taken from the body — it is carried forward and then recomputed here. Its own route rather than a field on PUT /systems/{id} for the same reason ten-codes has one: the systems form does not carry it, and folding it into that payload is how ten_codes kept getting wiped. """ existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") area = area_ctx.merge_server_fields( area_ctx.normalize(body.model_dump(exclude_none=True)), existing.get("area_context"), ) await fstore.doc_update("systems", system_id, {"area_context": area}) # Awaited, not scheduled: this route exists to edit the place, so the caller # should get back the anchor its edit produced. Talkgroups are refreshed with # it because their anchor derives from the merged place, not their own. patch = await area_ctx.refresh_anchors({**existing, "area_context": area}) if patch: await fstore.doc_update("systems", system_id, patch) area = patch.get("area_context", area) return {"ok": True, "area_context": area} # -- Talkgroup-level pending local knowledge (server-26#37) -------------------- @router.get("/{system_id}/talkgroup-pending") async def list_talkgroup_pending(system_id: str, _: dict = Depends(require_admin_token)): """ Every pending local-knowledge proposal on this system, by talkgroup. Proposals are made at talkgroup level and are never promoted to the system automatically — a wrong term on one channel misleads one channel, the same term system-wide misleads every channel on it. """ system = await fstore.doc_get("systems", system_id) if not system: raise HTTPException(404, f"System '{system_id}' not found.") out = [] for tg in ((system.get("config") or {}).get("talkgroups") or []): if not isinstance(tg, dict): continue pending = tg.get(area_ctx.PENDING_KEY) or [] if pending: out.append({ "talkgroup_id": tg.get("id"), "talkgroup_name": tg.get("name"), "pending": pending, }) return {"talkgroups": out} @router.post("/{system_id}/talkgroup-pending/approve") async def approve_talkgroup_pending( system_id: str, body: PendingTermBody, _: dict = Depends(require_admin_token) ): """Move a pending term into that talkgroup's local_knowledge.""" if not await area_ctx.resolve_pending(system_id, body.talkgroup_id, body.term, approve=True): raise HTTPException(404, "No such pending term on that talkgroup.") return {"ok": True} @router.post("/{system_id}/talkgroup-pending/dismiss") async def dismiss_talkgroup_pending( system_id: str, body: PendingTermBody, _: dict = Depends(require_admin_token) ): """Drop a pending term without adding it.""" if not await area_ctx.resolve_pending(system_id, body.talkgroup_id, body.term, approve=False): raise HTTPException(404, "No such pending term on that talkgroup.") return {"ok": True} # ── Vocabulary endpoints ─────────────────────────────────────────────────────── @router.get("/{system_id}/vocabulary") async def get_vocabulary(system_id: str): """Return approved vocabulary and pending induction suggestions.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") from app.internal.vocabulary_learner import get_vocabulary as _get return await _get(system_id) @router.post("/{system_id}/vocabulary/bootstrap", status_code=202) async def bootstrap_vocabulary( system_id: str, decoded: dict = Depends(require_admin_token), ): """Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") bootstrap_limiter.check(system_id) from app.internal.vocabulary_learner import bootstrap_system_vocabulary terms = await bootstrap_system_vocabulary(system_id) return {"added": len(terms), "terms": terms} @router.post("/{system_id}/vocabulary/terms") async def add_vocabulary_term( system_id: str, body: VocabularyTermBody, _: dict = Depends(require_admin_token), ): """Manually add a term to the approved vocabulary.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") from app.internal.vocabulary_learner import add_term await add_term(system_id, body.term.strip()) return {"ok": True} @router.delete("/{system_id}/vocabulary/terms") async def remove_vocabulary_term( system_id: str, body: VocabularyTermBody, _: dict = Depends(require_admin_token), ): """Remove a term from the approved vocabulary.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") from app.internal.vocabulary_learner import remove_term await remove_term(system_id, body.term) return {"ok": True} @router.post("/{system_id}/vocabulary/pending/approve") async def approve_pending( system_id: str, body: VocabularyTermBody, _: dict = Depends(require_admin_token), ): """Move a pending induction suggestion into the approved vocabulary.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") from app.internal.vocabulary_learner import approve_pending_term await approve_pending_term(system_id, body.term) return {"ok": True} @router.post("/{system_id}/vocabulary/pending/dismiss") async def dismiss_pending( system_id: str, body: VocabularyTermBody, _: dict = Depends(require_admin_token), ): """Dismiss a pending induction suggestion without adding it.""" existing = await fstore.doc_get("systems", system_id) if not existing: raise HTTPException(404, f"System '{system_id}' not found.") from app.internal.vocabulary_learner import dismiss_pending_term await dismiss_pending_term(system_id, body.term) return {"ok": True}