18d96193ab
auth.py
secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py
send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py
add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py
All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py
POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py
join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py
Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py
_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py
Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
import uuid
|
|
from fastapi import APIRouter, HTTPException, Depends
|
|
from pydantic import BaseModel
|
|
from typing import Dict, Optional
|
|
from app.models import SystemCreate, SystemRecord
|
|
from app.internal import firestore as fstore
|
|
from app.internal.auth import require_admin_token, bootstrap_limiter
|
|
|
|
router = APIRouter(prefix="/systems", tags=["systems"])
|
|
|
|
|
|
class VocabularyTermBody(BaseModel):
|
|
term: str
|
|
|
|
|
|
class TenCodesBody(BaseModel):
|
|
ten_codes: Dict[str, str]
|
|
|
|
|
|
class AiFlagsBody(BaseModel):
|
|
stt_enabled: Optional[bool] = None
|
|
correlation_enabled: Optional[bool] = None
|
|
|
|
|
|
@router.get("")
|
|
async def list_systems():
|
|
return await fstore.collection_list("systems")
|
|
|
|
|
|
@router.get("/{system_id}")
|
|
async def get_system(system_id: str):
|
|
system = await fstore.doc_get("systems", system_id)
|
|
if not system:
|
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
|
return system
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
|
|
system_id = str(uuid.uuid4())
|
|
doc = SystemRecord(system_id=system_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.")
|
|
await fstore.doc_update("systems", system_id, body.model_dump())
|
|
return {**existing, **body.model_dump()}
|
|
|
|
|
|
@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}
|
|
|
|
|
|
# ── 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}
|