Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7189ba03e4 | ||
|
|
ef1e3d7f9d | ||
|
|
b430cf32f2 | ||
|
|
93fa3a6054 | ||
|
|
de03f5bcaf | ||
|
|
0651bfe07a | ||
|
|
c4656a9607 | ||
|
|
a9d1d2475a | ||
|
|
85393bdb26 | ||
|
|
8b6c170265 | ||
|
|
b7222230bd | ||
|
|
bdb57ae75a | ||
|
|
29c2fb11b9 | ||
|
|
865b5b4317 | ||
|
|
0635de8dac | ||
|
|
3df427f914 |
@@ -44,3 +44,6 @@ recordings/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Out of scope - not a deployed service (server-26#56)
|
||||
drb-telegram-bot/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Gate B3 (server-26#43) -- Engineering Scope
|
||||
Owner: CTO. Scope only -- no implementation. Ship date unchanged: 2026-09-30.
|
||||
|
||||
## 1. The two defaults, testable
|
||||
- EMS exclusion: for any call whose talkgroup is classified medical, the AI pipeline (Whisper STT + GPT-4o-mini intelligence.py extraction + correlation) must not run. Opt-in only via a per-customer contract flag. Test: upload a call on a talkgroup marked medical, calls/{id}.transcript stays null, no incident_ids.
|
||||
- Name suppression: no surface serving a calls or incidents document (API, frontend render, alert webhook, future export/Discord/API-key tiers) may return an unredacted transcript, summary, or title to any account -- public, comped, or paid -- until an E&O policy is bound (#43 comment 1). Test: same document, two reads -- direct Firestore read and /incidents/{id} API read -- both redacted.
|
||||
|
||||
## 2. Talkgroup-granularity gap
|
||||
feature_flags.py:80-107 (resolve_flags) only layers a per-system ai_flags dict (routers/systems.py:107-129, flat {flag_name: bool}, no talkgroup key) on top of the global default. DEFERREDs own entry for this file says the fix shape is talkgroup_ai_flags: {tgid: {...}} on the system doc, consulted where flag() is built. That field does not exist. Without it, "EMS excluded, rest of the system processed" is not buildable -- the flag is all-on/all-off per system, and most systems mix EMS with police/fire dispatch under one system_id (the exact case BUSINESS_MODEL section 5.5 is trying to protect against). This data-model change is a hard prerequisite, not an enhancement: add talkgroup_ai_flags: {tgid: {stt_enabled, correlation_enabled}} to the system doc, consult it in resolve_flags() before the system-level flag, default every unclassified talkgroup on a system that has at least one confirmed-medical talkgroup to excluded until explicitly classified.
|
||||
|
||||
## 3. Redaction design -- write time, not read time
|
||||
Pick: compute and store a redacted copy alongside the raw one, at extraction/summarization time. Two sentences: the frontend reads Firestore directly for calls/incidents (CLAUDE.md gotcha -- middleware.ts is UX-only, Firestore rules are the real boundary), and Firestore rules can allow/deny a whole document but cannot mask one field inside it -- so a redaction step that only runs inside c2-cores API responses leaves the exact same unredacted transcript/summary/title readable by any authenticated browser via onSnapshot/getDocs against the collection directly. The only enforcement point that actually covers both paths is: the client-readable document never contains the unredacted field. Raw content moves to a field/subcollection excluded from client-facing Firestore rules and readable only server-side by c2-core (satisfies "never deletion, reversible the day a policy binds" -- #43 comment 1).
|
||||
incident.title is template-composed from tag/location/talkgroup (incident_correlator.py:380-389), not LLM freeform -- already name-free by construction, no redaction needed there. The actual carriers are calls.transcript (models.py:165) and the GPT summary (summarizer.py:146-161, built directly from raw transcripts, no name-avoidance instruction today).
|
||||
|
||||
## 4. A premise in #43 does not hold
|
||||
#43s body says "entities are already extracted, so the redaction has a data source to work from." Not true as of this read. intelligence.pys extraction prompt (_PROMPT_TEMPLATE, lines 24-72) has no person-name field -- it extracts tags, incident_type, location, vehicles, units, cleared_units, severity. units is explicitly restricted to "unit IDs or officer numbers... never infer or guess" (line 57) -- radio callsigns, not private-citizen names. There is no structured entity to redact against. Redaction must run against unstructured free text (transcript + GPT summary), via a new regex/NER-style pass with its own unmeasured false-negative rate -- the same class of problem #48 raised about the extractor, one level down, on code that does not exist yet.
|
||||
|
||||
## 5. Surface inventory (complete)
|
||||
- drb-frontend: app/incidents/page.tsx, app/incidents/[id]/page.tsx, app/calls/page.tsx, components/CallRow.tsx, components/CallSpineEntry.tsx -- render title/summary/transcript. Every one is backed by a direct Firestore listener per the section 3 gotcha, not just the page component -- any future onSnapshot/getDocs against calls/incidents inherits the same exposure and must be audited, not assumed covered.
|
||||
- drb-c2-core API: routers/calls.py, routers/incidents.py (JSON responses).
|
||||
- drb-c2-core/app/internal/alerter.py:56,68 -- transcript_snippet (200 chars, raw, unredacted today) written into alert_events and POSTed to the customers own Discord webhook. This is the live, sellable Pro-tier "Alerting" feature (BUSINESS_MODEL section 3.4 item 1) -- highest-priority surface, it is the actual product hook for the beachhead segment.
|
||||
- drb-server-discord-bot: checked app/commands/radio.py, app/commands/trips.py -- embeds today are node status/help/trip content only, no incident transcript/summary rendering exists yet. Nothing to redact today; must inherit this design the day incident-to-Discord posting ships.
|
||||
- drb-telegram-bot: app/handlers/__init__.py is a stub, no incident-surfacing code exists. Same note as above.
|
||||
- Not yet built, but must inherit the design when built: CSV export, Network-tier API access (lib/apiKeys.ts is an in-memory stub per DEFERRED.md).
|
||||
|
||||
## 6. Out of scope for #43
|
||||
- Raw-audio/live-relay exclusion of EMS talkgroups -- the ruling excludes them from the AI pipeline only, not from live audio/Discord voice relay.
|
||||
- Building an accurate NER model -- a heuristic/regex redactor is scope; measuring or improving its accuracy is a follow-on issue (mirrors #48, on the redactor instead of the extractor).
|
||||
- Retroactive redaction of historical calls/incidents already in Firestore (no backfill infra exists -- same unscoped-backfill pattern already logged in DEFERRED.md for _verified_pin). Tracked as a new follow-on issue at ship time, not built now.
|
||||
- A UI for classifying talkgroups as EMS/medical beyond a minimal toggle reusing the existing per-system ai-flags PUT route pattern (routers/systems.py:107).
|
||||
|
||||
## 7. Needs a CEO/owner ruling
|
||||
- Urgent -- is the comped (friends/family) tier suspended today? #43 comment 1 states suppression must hold "on every surface -- public, comped and paid," and #79 comment says no login proceeds until this ships -- but the comped tier is described in BUSINESS_MODEL section 3.2 as already live with "todays live full product," unredacted. Either comped access is in active breach of the ruling right now, or it is meant to be paused pending this ship date. My recommendation: pause comped access to incident detail/transcript views (or accept and log the breach explicitly) until #43 ships -- silently continuing is worse than either choice on record.
|
||||
- How is a talkgroup classified EMS/medical? Recommend: name-pattern heuristic (reusing the existing _TG_SUFFIX_RE EMS/rescue matching in intelligence.py:101-108) as the default classification, manual override in the system editor, and default-exclude on no match rather than default-include -- a false negative here is the exact liability #43 exists to prevent.
|
||||
- Does exclusion/redaction apply retroactively to already-processed calls? Recommend: prospective only for 2026-09-30; backfill is a separate follow-on issue (see section 6).
|
||||
|
||||
## 8. Effort estimate vs 2026-09-30
|
||||
Roughly 6-10 engineering-days, agent-buildable (no human/contractor per GOALS.md), contingent on the section 7 rulings landing quickly -- they gate the design, not just the code:
|
||||
- Talkgroup-flag data model + resolve_flags() wiring: ~1 day.
|
||||
- Minimal EMS-classification toggle (reuse ai-flags PUT pattern): ~1-2 days.
|
||||
- Redacted-copy storage split + Firestore rules change + regex/heuristic redactor + alerter.py snippet redaction + audit of all direct Firestore listeners in frontend: ~4-6 days -- this is the long pole, because section 4 means it is new code, not a wire-up of an existing field.
|
||||
#48 does not block this. #43 comment 1 is explicit: the 200-call accuracy measurement "can no longer decide whether names are published, because they are suppressed regardless. It remains a Gate B condition for other reasons." Sequence independently.
|
||||
@@ -19,6 +19,14 @@ services:
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_certs:/mosquitto/certs
|
||||
|
||||
# c2-core takes ALL of its configuration from ./drb-c2-core/.env — there is
|
||||
# deliberately no `environment:` block here. An entry in that block wins over
|
||||
# env_file, so listing a key here (e.g. AGENT_SERVICE_KEY=${AGENT_SERVICE_KEY})
|
||||
# would let an unset top-level .env silently blank out a value the owner had
|
||||
# correctly pasted into drb-c2-core/.env. New settings go in
|
||||
# drb-c2-core/.env.example and, for the VM, in
|
||||
# infra/ansible/roles/deploy/templates/c2-core.env.j2 + vault.yml.
|
||||
# AGENT_SERVICE_KEY (server-26#64) is configured that way.
|
||||
c2-core:
|
||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||
build: ./drb-c2-core
|
||||
|
||||
@@ -37,3 +37,15 @@ EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||
# Generate with: openssl rand -hex 32
|
||||
ENROLLMENT_TOKEN=
|
||||
|
||||
# Shared key the Discord bot presents to reach C2 without Firebase.
|
||||
# Generate with: openssl rand -hex 32
|
||||
SERVICE_KEY=
|
||||
|
||||
# Agent/automation key for the unattended work session's headless routes
|
||||
# (GET/PUT /admin/features). DELIBERATELY a different value from SERVICE_KEY —
|
||||
# reusing the bot's key would make both principals indistinguishable in
|
||||
# audit_log, which is the whole point of server-26#64. Leave blank to keep the
|
||||
# agent path closed; the routes still take a Firebase admin token either way.
|
||||
# Generate with: openssl rand -hex 32
|
||||
AGENT_SERVICE_KEY=
|
||||
|
||||
@@ -61,6 +61,15 @@ class Settings(BaseSettings):
|
||||
# against the talkgroup's own anchor instead of stuffing every road in town
|
||||
# into the prompt, so cost scales with location nouns rather than call volume.
|
||||
place_verification_enabled: bool = True
|
||||
# Raw transcript text in alert payloads (server-26#85). Default CLOSED.
|
||||
# Board minutes #42 suppress person names on every surface until E&O is
|
||||
# bound, and an alert webhook is the least recoverable surface there is:
|
||||
# once the text is in a Discord channel we do not own it, cannot unsend
|
||||
# it, and cannot audit who read it. This switch is the operator-level
|
||||
# gate and is deliberately NOT reachable from the app -- the per-org
|
||||
# opt-in alone would let an org owner self-serve their way to somebody
|
||||
# else's PII. Both gates must be open before any snippet leaves.
|
||||
alert_transcript_snippet_enabled: bool = False
|
||||
place_verify_max_per_call: int = 3
|
||||
# How close a candidate has to sound before it may rewrite a transcript.
|
||||
# Below this, Places Text Search will confidently hand back the nearest
|
||||
@@ -132,6 +141,21 @@ class Settings(BaseSettings):
|
||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||
service_key: Optional[str] = None
|
||||
|
||||
# Automation/agent service key — the unattended work-session agent's own
|
||||
# credential for the headless routes it needs (currently GET/PUT
|
||||
# /admin/features).
|
||||
#
|
||||
# DELIBERATELY SEPARATE from service_key above, not a second consumer of
|
||||
# it. service_key is the Discord bot's, and it is handed to a process that
|
||||
# relays radio traffic to a chat server; sharing it here would make "the
|
||||
# bot" and "the agent" the same principal in every log line and audit
|
||||
# entry, so a global AI-cost flag flip could never be attributed to whoever
|
||||
# actually made it. Two keys, two identities (server-26#64 item 1).
|
||||
#
|
||||
# Unset means the agent path is simply closed — the routes still accept a
|
||||
# Firebase admin token. Generate with: openssl rand -hex 32
|
||||
agent_service_key: Optional[str] = None
|
||||
|
||||
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
|
||||
# Not a per-node secret — see routers/enrollment.py for why a leaked copy
|
||||
# of this alone can't steal an already-approved node's key.
|
||||
|
||||
@@ -6,11 +6,15 @@ talkgroup ID, tags, and transcript. On a match:
|
||||
1. Creates an AlertEvent document in Firestore.
|
||||
2. Optionally POSTs a Discord webhook message if the rule has one configured.
|
||||
|
||||
Raw transcript text is withheld from both by default -- see _snippet_allowed
|
||||
and server-26#85.
|
||||
|
||||
Never raises — failures are logged as warnings so the pipeline always completes.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -47,13 +51,17 @@ async def check_and_dispatch(
|
||||
logger.warning(f"Alerter: could not load rules: {e}")
|
||||
return
|
||||
|
||||
# Loop-invariant: every rule here belongs to the same org, so the opt-in is
|
||||
# resolved once rather than per match.
|
||||
snippet_allowed = await _snippet_allowed(org_id)
|
||||
|
||||
for rule in rules:
|
||||
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
|
||||
if not matched_keywords:
|
||||
continue
|
||||
|
||||
alert_id = str(uuid.uuid4())
|
||||
snippet = _snippet(transcript)
|
||||
snippet = _snippet(transcript) if snippet_allowed else None
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
event = {
|
||||
"alert_id": alert_id,
|
||||
@@ -85,6 +93,43 @@ async def check_and_dispatch(
|
||||
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
|
||||
|
||||
|
||||
async def _snippet_allowed(org_id: Optional[str]) -> bool:
|
||||
"""
|
||||
Whether raw transcript text may be attached to an alert (server-26#85).
|
||||
|
||||
Two gates, both of which must be open:
|
||||
|
||||
1. ``settings.alert_transcript_snippet_enabled`` -- the operator switch,
|
||||
default False, set from the environment and unreachable from the app.
|
||||
2. ``alert_snippet_opt_in`` on the org document -- the customer's own
|
||||
explicit, contractual opt-in.
|
||||
|
||||
Gate 1 exists because gate 2 alone is not a real control: the frontend
|
||||
reads and (per the Firestore rules, not ``auth.py``) can write org state
|
||||
directly from the browser, so an org owner could otherwise opt themselves
|
||||
into receiving person names lifted from live public-safety traffic. Board
|
||||
minutes #42 suppress names on every surface until E&O is bound.
|
||||
|
||||
Fails CLOSED on any error, and on a call with no org (a pre-tenancy node
|
||||
that has not been backfilled), because the cost of wrongly withholding a
|
||||
snippet is a less informative alert and the cost of wrongly emitting one
|
||||
is unrecallable disclosure to a third party.
|
||||
"""
|
||||
if not settings.alert_transcript_snippet_enabled:
|
||||
return False
|
||||
if not org_id:
|
||||
return False
|
||||
try:
|
||||
org = await fstore.doc_get("organizations", org_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Alerter: could not read snippet opt-in for org={org_id}, "
|
||||
f"withholding transcript: {e}"
|
||||
)
|
||||
return False
|
||||
return bool((org or {}).get("alert_snippet_opt_in"))
|
||||
|
||||
|
||||
def _match_rule(
|
||||
rule: dict,
|
||||
talkgroup_id: Optional[int],
|
||||
|
||||
@@ -220,6 +220,74 @@ async def require_service_key_or_admin(
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automation / agent principal
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity written into audit_log when the agent key is what authenticated a
|
||||
# request. A Firebase admin gets their own uid/email instead, so the two are
|
||||
# always distinguishable after the fact — which is the point.
|
||||
AGENT_PRINCIPAL_UID = "agent-service"
|
||||
AGENT_PRINCIPAL_EMAIL = "agent-service@drb.internal"
|
||||
|
||||
|
||||
async def require_agent_key_or_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept either the agent service key or a Firebase admin token.
|
||||
|
||||
Deliberately does NOT accept ``settings.service_key``. That key belongs to
|
||||
the Discord bot, and honouring it here would collapse two principals into
|
||||
one unattributable identity in every log line and audit entry — the exact
|
||||
thing server-26#64 exists to end. The bot has no business flipping
|
||||
platform-wide AI flags either way.
|
||||
|
||||
Exists so the unattended runbook can flip AI flags over HTTP instead of
|
||||
SSHing into the container and writing ``config/ai_features`` with the admin
|
||||
SDK, which needs a full container shell to move a cost switch.
|
||||
|
||||
The ``settings.agent_service_key and ...`` guard is load-bearing, not
|
||||
stylistic: ``secrets.compare_digest("", "")`` is a MATCH, so any form of
|
||||
``compare_digest(token, settings.agent_service_key or "")`` would turn a
|
||||
deployment that never configured the key into one that accepts an empty
|
||||
credential. Check the key is configured first and never substitute a
|
||||
placeholder. (``require_service_key`` states the same intent by raising
|
||||
503 when unset; both are correct, this one just stays open to admins.)
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.agent_service_key and secrets.compare_digest(token, settings.agent_service_key):
|
||||
return {
|
||||
"service": True,
|
||||
"principal": "agent",
|
||||
"uid": AGENT_PRINCIPAL_UID,
|
||||
"email": AGENT_PRINCIPAL_EMAIL,
|
||||
}
|
||||
try:
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
def describe_actor(principal: dict) -> tuple[str, str]:
|
||||
"""Return ``(actor_uid, actor_email)`` for an audit entry.
|
||||
|
||||
Works for any credential shape the dependencies above produce, so an audit
|
||||
call site never has to switch on principal type itself.
|
||||
"""
|
||||
if principal.get("principal") == "agent":
|
||||
return AGENT_PRINCIPAL_UID, AGENT_PRINCIPAL_EMAIL
|
||||
if principal.get("service"):
|
||||
return "service", "service@drb.internal"
|
||||
if principal.get("node"):
|
||||
node_id = principal.get("node_id") or "unknown"
|
||||
return f"node:{node_id}", ""
|
||||
return principal.get("uid") or "unknown", principal.get("email") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory sliding-window rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -63,18 +63,137 @@ async def get_flags() -> dict[str, bool]:
|
||||
return dict(_cache)
|
||||
|
||||
|
||||
async def set_flags(updates: dict[str, bool]) -> dict[str, bool]:
|
||||
"""Write flag updates to Firestore and invalidate the cache."""
|
||||
global _cache, _cache_ts
|
||||
async def _cascade_to_systems(clean: dict[str, bool]) -> tuple[list[dict], list[dict]]:
|
||||
"""Clear per-system ``ai_flags`` overrides for the keys just set globally.
|
||||
|
||||
Returns ``(changes, errors)``.
|
||||
|
||||
Why clearing rather than overwriting with the new value: an override that
|
||||
stays present, merely agreeing with the global switch for now, defeats the
|
||||
NEXT flip exactly the same way. Removing it makes the system inherit, which
|
||||
is the same semantics the human-facing route already offers
|
||||
(``PUT /systems/{id}/ai-flags`` with null → "clear override, inherit
|
||||
global").
|
||||
|
||||
Systems are discovered by scanning for documents that actually carry an
|
||||
``ai_flags`` map — never a hardcoded id list. Two systems carry overrides
|
||||
today; a third added tomorrow would silently defeat a global shutoff if
|
||||
this were pinned to the current pair.
|
||||
"""
|
||||
changes: list[dict] = []
|
||||
errors: list[dict] = []
|
||||
|
||||
systems = await fstore.collection_list("systems")
|
||||
for system in systems:
|
||||
sid = system.get("system_id")
|
||||
ai_flags = system.get("ai_flags")
|
||||
# Only documents that actually carry the map. A system with no
|
||||
# overrides already inherits, so there is nothing to cascade to.
|
||||
if not sid or not isinstance(ai_flags, dict) or not ai_flags:
|
||||
continue
|
||||
removed = {k: ai_flags[k] for k in clean if k in ai_flags}
|
||||
if not removed:
|
||||
continue
|
||||
remaining = {k: v for k, v in ai_flags.items() if k not in clean}
|
||||
try:
|
||||
await fstore.doc_update("systems", sid, {"ai_flags": remaining})
|
||||
except Exception as e:
|
||||
# Report rather than swallow: a half-applied cascade is the exact
|
||||
# failure mode this helper exists to prevent, so it must be visible
|
||||
# in the log and the audit entry.
|
||||
logger.error(f"Feature flags: cascade to system '{sid}' failed ({e})")
|
||||
errors.append({"system_id": sid, "error": str(e)})
|
||||
continue
|
||||
changes.append({
|
||||
"system_id": sid,
|
||||
"cleared_overrides": removed,
|
||||
"now_inherits": {k: clean[k] for k in removed},
|
||||
})
|
||||
|
||||
return changes, errors
|
||||
|
||||
|
||||
async def set_flags(
|
||||
updates: dict[str, bool],
|
||||
actor: tuple[str, str] | None = None,
|
||||
cascade: bool = False,
|
||||
) -> dict[str, bool]:
|
||||
"""Write flag updates to Firestore, invalidate the cache, and audit it.
|
||||
|
||||
``actor`` is ``(actor_uid, actor_email)`` — see auth.describe_actor. It is
|
||||
optional so existing callers keep working; an unattributed flip is logged
|
||||
as "unknown" rather than not logged at all.
|
||||
|
||||
``cascade`` also clears the matching per-system ``ai_flags`` overrides, so
|
||||
one call is a total flip. Defaults to False deliberately — see the route's
|
||||
comment in routers/admin.py.
|
||||
|
||||
Returns the resulting global flags dict, unchanged in shape: the admin UI
|
||||
(drb-frontend/lib/c2api.ts setFeatureFlags) types the response as
|
||||
Record<string, boolean>, so cascade/audit detail goes to the log and the
|
||||
audit entry rather than into this payload.
|
||||
"""
|
||||
global _cache_ts
|
||||
|
||||
clean = {k: bool(v) for k, v in updates.items() if k in _DEFAULTS}
|
||||
if not clean:
|
||||
raise ValueError(f"No recognised flag keys in update: {list(updates)}")
|
||||
|
||||
# Force a fresh read for the "before" side of the audit entry: the TTL
|
||||
# cache can be up to _TTL seconds stale, and a wrong previous value in an
|
||||
# audit log is worse than none.
|
||||
_cache_ts = 0.0
|
||||
before = await get_flags()
|
||||
|
||||
await fstore.doc_set(_COLLECTION, _DOC_ID, clean)
|
||||
_cache_ts = 0.0 # force re-read on next get_flags()
|
||||
logger.info(f"Feature flags updated: {clean}")
|
||||
return await get_flags()
|
||||
|
||||
cascaded: list[dict] = []
|
||||
cascade_errors: list[dict] = []
|
||||
if cascade:
|
||||
cascaded, cascade_errors = await _cascade_to_systems(clean)
|
||||
logger.info(
|
||||
f"Feature flags: cascaded {list(clean)} to {len(cascaded)} system(s), "
|
||||
f"{len(cascade_errors)} error(s)"
|
||||
)
|
||||
|
||||
after = await get_flags()
|
||||
|
||||
# The audit entry is a record OF the write, never a precondition for it.
|
||||
# audit_log lives in the same Firestore that just accepted the flag write,
|
||||
# so a failure here is nearly always transient — losing the flip (or 500ing
|
||||
# a route that already succeeded, which invites a retry that flips it back)
|
||||
# would be a far worse outcome than an unrecorded flip that is still in the
|
||||
# service log above.
|
||||
try:
|
||||
# Deferred import: app.internal.audit pulls in firestore, and this
|
||||
# module is imported from router module scope.
|
||||
from app.internal import audit
|
||||
actor_uid, actor_email = actor or ("unknown", "")
|
||||
changed = {
|
||||
k: {"from": before.get(k), "to": after.get(k)}
|
||||
for k in clean
|
||||
if before.get(k) != after.get(k)
|
||||
}
|
||||
await audit.write_audit(
|
||||
actor_uid=actor_uid,
|
||||
actor_email=actor_email,
|
||||
action="feature_flags.update",
|
||||
details={
|
||||
"requested": clean,
|
||||
"changed": changed,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"cascade": cascade,
|
||||
"cascaded_systems": cascaded,
|
||||
"cascade_errors": cascade_errors,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Feature flags: audit write failed ({e}) — flag change stands")
|
||||
|
||||
return after
|
||||
|
||||
|
||||
async def resolve_flags(system_id: str | None):
|
||||
|
||||
@@ -668,10 +668,18 @@ async def correlate_call(
|
||||
vehicles: Optional[list[str]] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Link call_id to an existing incident or create a new one.
|
||||
Thin wrapper: builds context → runs rules decision → commits.
|
||||
|
||||
``embedding`` and ``severity`` are the SCENE's own values (server-26#80/#95).
|
||||
Callers that re-correlate a whole call rather than a scene — the
|
||||
recorrelation sweep — pass the call doc's stored values explicitly; they are
|
||||
no longer read from the doc inside _build_context.
|
||||
"""
|
||||
ctx = await _build_context(
|
||||
call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units,
|
||||
@@ -679,6 +687,7 @@ async def correlate_call(
|
||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
reassignment=reassignment, create_if_new=create_if_new,
|
||||
embedding=embedding, severity=severity, transcript=transcript,
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
return await _apply_and_log(decision, ctx)
|
||||
@@ -700,6 +709,9 @@ async def preview_correlation(
|
||||
vehicles: Optional[list[str]] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Run the rules engine and return the decision WITHOUT committing to Firestore.
|
||||
@@ -720,6 +732,7 @@ async def preview_correlation(
|
||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
reassignment=reassignment, create_if_new=create_if_new,
|
||||
embedding=embedding, severity=severity, transcript=transcript,
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
return {"decision": decision, "ctx": ctx}
|
||||
@@ -752,6 +765,9 @@ async def _build_context(
|
||||
location: Optional[str],
|
||||
reassignment: bool,
|
||||
create_if_new: bool,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> dict:
|
||||
now = reference_time or datetime.now(timezone.utc)
|
||||
window = timedelta(hours=settings.correlation_window_hours)
|
||||
@@ -777,11 +793,27 @@ async def _build_context(
|
||||
all_active = _drop_capped(all_active, now)
|
||||
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
||||
|
||||
call_embedding = call_doc.get("embedding")
|
||||
# embedding and severity come from the SCENE being correlated, not the call
|
||||
# doc — server-26#80 / #95. intelligence.py writes only the primary scene's
|
||||
# embedding and severity to calls/{id}, so reading them back here handed
|
||||
# every non-primary scene the primary scene's semantic vector and severity
|
||||
# rung: a scene about a different event scored against the wrong incident on
|
||||
# the embedding path (:1166/:1205/:1533) and could inherit a minor/moderate/
|
||||
# major severity it never had, clearing the creation gate on borrowed
|
||||
# weight. Same failure and same fix as the #87 coords leak directly below —
|
||||
# a scene that passes none has none, and is judged thin on its own signal.
|
||||
call_embedding = embedding
|
||||
call_units = units if units is not None else (call_doc.get("units") or [])
|
||||
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||
call_severity = call_doc.get("severity") or "routine"
|
||||
call_severity = severity or "routine"
|
||||
# The transcript the LLM correlation tier reasons over. Prefer the SCENE's
|
||||
# own words (server-26#102) — passed by upload.py's scene loop — and fall
|
||||
# back to the call doc only when no scene text was supplied (the
|
||||
# recorrelation sweep, and single-scene calls where the two are identical).
|
||||
# Without this, every non-primary scene of a multi-scene call was judged by
|
||||
# the LLM against a transcript containing the OTHER scenes.
|
||||
scene_transcript = transcript or call_doc.get("transcript_corrected") or call_doc.get("transcript")
|
||||
# A string that is not a place is not a location anywhere downstream — not
|
||||
# in the fit tests, not in the thin-call test, not in the LLM prompt, and
|
||||
# not on the incident. Its coordinates go with it: coords are geocoded
|
||||
@@ -789,7 +821,14 @@ async def _build_context(
|
||||
location = clean_location(location)
|
||||
if location is None:
|
||||
location_coords = None
|
||||
coords = location_coords or call_doc.get("location_coords")
|
||||
# NOT `location_coords or call_doc.get("location_coords")` — server-26#87.
|
||||
# A radio call can be split into several scenes, and only the primary
|
||||
# scene's geocode is written to the call doc. Falling back to it here
|
||||
# would hand every non-primary scene the primary scene's pin, fabricating
|
||||
# location_proximity (the strongest accept signal) for a scene that has
|
||||
# no location of its own and driving over-merges. If a scene passes no
|
||||
# coords, it has none — it is judged thin and must win on its own signal.
|
||||
coords = location_coords
|
||||
is_thin_call = _is_thin_call(
|
||||
call_units, call_vehicles, coords, tags, location, call_severity, reassignment
|
||||
)
|
||||
@@ -797,6 +836,7 @@ async def _build_context(
|
||||
return {
|
||||
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
||||
"call_doc": call_doc, "call_embedding": call_embedding,
|
||||
"scene_transcript": scene_transcript,
|
||||
"call_units": call_units, "call_vehicles": call_vehicles,
|
||||
"call_cleared": call_cleared, "call_severity": call_severity,
|
||||
"coords": coords, "is_thin_call": is_thin_call, "now": now,
|
||||
|
||||
@@ -24,7 +24,16 @@ from app.internal.incident_correlator import clean_location, location_is_unit
|
||||
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
|
||||
|
||||
SCENE DETECTION:
|
||||
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Detect whether this recording contains ONE scene (all transmissions relate to a single event) or MULTIPLE scenes (clearly distinct dispatch conversations with different units being assigned, different locations, different event types). Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
||||
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Your default is ONE scene. Return MULTIPLE scenes ONLY when the recording clearly contains two or more SEPARATE EVENTS — different incidents at different places, with no shared units, no shared subject, and no conversational thread connecting them.
|
||||
|
||||
These do NOT make a new scene — keep them in the same scene:
|
||||
- a different unit or speaker joining the same event
|
||||
- a follow-up transmission about the same job (records check, case number, tow/mileage, a unit clearing, an ETA, a location correction)
|
||||
- the same subject or location being discussed again minutes later
|
||||
- an administrative or status exchange that follows an event on the same channel
|
||||
If you are unsure whether two exchanges are one event or two, treat them as ONE.
|
||||
|
||||
Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
||||
|
||||
Always respond with the scenes array, even for a single scene.
|
||||
|
||||
@@ -163,7 +172,7 @@ async def extract_scenes(
|
||||
|
||||
Each scene dict contains:
|
||||
tags, incident_type, location, location_coords, resolved,
|
||||
severity, vehicles, units, transcript_corrected,
|
||||
severity, vehicles, units, transcript, transcript_corrected,
|
||||
segment_indices, embedding
|
||||
|
||||
Side-effect: updates calls/{call_id} in Firestore with merged tags,
|
||||
@@ -328,6 +337,10 @@ async def extract_scenes(
|
||||
)
|
||||
embedding = await asyncio.to_thread(_sync_embed, scene_text)
|
||||
|
||||
scene_transcript = _scene_transcript_text(
|
||||
transcript, segments, segment_indices, transcript_corrected
|
||||
)
|
||||
|
||||
processed.append({
|
||||
"tags": tags,
|
||||
"incident_type": incident_type,
|
||||
@@ -339,6 +352,7 @@ async def extract_scenes(
|
||||
"severity": severity,
|
||||
"resolved": resolved,
|
||||
"reassignment": reassignment,
|
||||
"transcript": scene_transcript,
|
||||
"transcript_corrected": transcript_corrected,
|
||||
"segment_indices": segment_indices,
|
||||
"embedding": embedding,
|
||||
@@ -562,11 +576,49 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
|
||||
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
|
||||
"""Format transcript as numbered transmissions if segments are available."""
|
||||
if segments and len(segments) > 1:
|
||||
lines = [f"{i+1}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
|
||||
# 0-based labels, matching the prompt's "0-based indices into the
|
||||
# numbered transmissions" — the model echoes these back as
|
||||
# `segment_indices`, which _build_scene_embed_text and the per-scene
|
||||
# `transcript` (server-26#102) then slice with directly.
|
||||
lines = [f"{i}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
|
||||
return f"Transmissions ({len(segments)}):\n" + "\n".join(lines)
|
||||
return f"Transcript:\n{transcript}"
|
||||
|
||||
|
||||
def _scene_transcript_text(
|
||||
transcript: str,
|
||||
segments: Optional[list[dict]],
|
||||
segment_indices: Optional[list[int]],
|
||||
transcript_corrected: Optional[str],
|
||||
) -> str:
|
||||
"""
|
||||
This scene's own words, unprefixed — the segments it owns, joined.
|
||||
|
||||
server-26#102: the correlator's LLM tier reads this per scene instead of
|
||||
the call doc's whole-call transcript, so on a multi-scene call scene N is
|
||||
no longer judged against scenes 1..N-1's text.
|
||||
|
||||
Never returns "". Anything that would leave the slice empty — no
|
||||
`segment_indices` (a single-segment call is never numbered by
|
||||
`_build_transcript_block`), or indices that are out of range / not ints —
|
||||
falls back to the whole-call transcript, which for a single-scene call is
|
||||
the same text and for a mis-sliced multi-scene call is at least this
|
||||
call's own words. `_sync_extract`'s prompt documents 0-based indices and
|
||||
`_build_transcript_block` numbers to match, so no base normalisation here.
|
||||
"""
|
||||
if transcript_corrected:
|
||||
return transcript_corrected
|
||||
if segments and segment_indices:
|
||||
joined = " ".join(
|
||||
segments[i]["text"]
|
||||
for i in segment_indices
|
||||
if isinstance(i, int) and 0 <= i < len(segments)
|
||||
)
|
||||
if joined:
|
||||
return joined
|
||||
return transcript
|
||||
|
||||
|
||||
def _build_scene_embed_text(
|
||||
transcript: str,
|
||||
segments: Optional[list[dict]],
|
||||
|
||||
@@ -61,7 +61,13 @@ def _inc_summary(inc: dict, now: datetime) -> str:
|
||||
def _call_block(ctx: dict) -> str:
|
||||
lines = []
|
||||
call_doc = ctx["call_doc"]
|
||||
transcript = call_doc.get("transcript_corrected") or call_doc.get("transcript")
|
||||
# The SCENE's own transcript, resolved in _build_context (server-26#102).
|
||||
# Falls back to the call doc for a ctx built without a scene (tests, sweep).
|
||||
transcript = (
|
||||
ctx.get("scene_transcript")
|
||||
or call_doc.get("transcript_corrected")
|
||||
or call_doc.get("transcript")
|
||||
)
|
||||
if transcript:
|
||||
lines.append(f"Transcript: {transcript[:700]}")
|
||||
if ctx["tags"]:
|
||||
|
||||
@@ -90,6 +90,11 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
return False
|
||||
|
||||
# All data needed for correlation was stored by the first-pass extraction.
|
||||
# embedding/severity are no longer read from the call doc inside
|
||||
# _build_context (server-26#80/#95) — the sweep re-links a whole call, not a
|
||||
# scene, so it passes the call doc's stored (primary-scene) values here. It
|
||||
# is link-only (create_if_new=False), so a borrowed severity cannot open a
|
||||
# new incident off this path.
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
call_id = call_id,
|
||||
node_id = call.get("node_id", ""),
|
||||
@@ -101,6 +106,9 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
location = call.get("location"),
|
||||
location_coords= call.get("location_coords"),
|
||||
cleared_units = call.get("cleared_units") or [],
|
||||
embedding = call.get("embedding"),
|
||||
severity = call.get("severity"),
|
||||
transcript = call.get("transcript_corrected") or call.get("transcript"),
|
||||
reference_time = started_at, # anchor window to when the call happened
|
||||
create_if_new = False, # never create — link-only
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.auth import require_admin_token, require_agent_key_or_admin, describe_actor
|
||||
from app.internal.feature_flags import get_flags, set_flags
|
||||
from app.internal import firestore as fstore
|
||||
from app.config import settings
|
||||
@@ -25,20 +25,54 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/features")
|
||||
async def get_feature_flags(_=Depends(require_admin_token)):
|
||||
async def get_feature_flags(_=Depends(require_agent_key_or_admin)):
|
||||
"""
|
||||
Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) —
|
||||
was previously any authenticated user via require_firebase_token, which
|
||||
handed platform-wide AI configuration state to every signed-in viewer
|
||||
regardless of org.
|
||||
|
||||
Also reachable with the agent service key (server-26#64) so the unattended
|
||||
runbook can read the switch over HTTP instead of shelling into the
|
||||
container. Note this is require_agent_key_or_admin, NOT the Discord bot's
|
||||
service key — see internal/auth.py.
|
||||
"""
|
||||
return await get_flags()
|
||||
|
||||
|
||||
@router.put("/features")
|
||||
async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
|
||||
"""Update one or more AI feature flags. Admin only."""
|
||||
return await set_flags(body)
|
||||
async def update_feature_flags(
|
||||
body: dict,
|
||||
cascade: bool = Query(
|
||||
False,
|
||||
description=(
|
||||
"Also clear per-system ai_flags overrides for the keys being set, "
|
||||
"so the flip applies to every radio system."
|
||||
),
|
||||
),
|
||||
principal: dict = Depends(require_agent_key_or_admin),
|
||||
):
|
||||
"""Update one or more AI feature flags. Admin or agent service key.
|
||||
|
||||
``cascade`` defaults to **False**, deliberately.
|
||||
|
||||
The tempting default is True: feature_flags.resolve_flags lets a
|
||||
system-level False beat a global True, so turning AI back ON globally can
|
||||
half-apply and leave a system dark, and cascade-by-default would make every
|
||||
flip total. That reasoning holds only if per-system ai_flags are set
|
||||
exclusively by hand. They are not — PUT /systems/{system_id}/ai-flags
|
||||
(routers/systems.py) is a real admin route and drb-frontend's AiFlagsPanel
|
||||
(app/systems/page.tsx) is a real toggle in the UI. So an override is a
|
||||
deliberate operator decision that is visible in the interface, and
|
||||
cascading by default would silently erase it on the next unrelated global
|
||||
flip, with the operator's own UI still showing what they set until reload.
|
||||
|
||||
Silently destroying operator intent is the worse failure, so the caller
|
||||
says when it means "everywhere": the runbook passes cascade=true on the
|
||||
shutoff, and the admin UI (which does not pass it) keeps its per-system
|
||||
overrides.
|
||||
"""
|
||||
return await set_flags(body, actor=describe_actor(principal), cascade=cascade)
|
||||
|
||||
|
||||
@router.get("/debug/correlation")
|
||||
|
||||
@@ -97,7 +97,7 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
|
||||
async def summarize_incident(
|
||||
incident_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
decoded: dict = Depends(require_service_or_firebase_token),
|
||||
decoded: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Immediately run the summarizer for a specific incident."""
|
||||
from app.internal.summarizer import _summarize_incident
|
||||
|
||||
@@ -114,6 +114,9 @@ async def _correlate_with_consensus(
|
||||
vehicles: Optional[list] = None,
|
||||
cleared_units: Optional[list] = None,
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
||||
@@ -131,6 +134,7 @@ async def _correlate_with_consensus(
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||
cleared_units=cleared_units, reassignment=reassignment,
|
||||
embedding=embedding, severity=severity, transcript=transcript,
|
||||
)
|
||||
ctx = preview["ctx"]
|
||||
rules_decision = preview["decision"]
|
||||
@@ -221,6 +225,9 @@ async def _run_extraction_pipeline(
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
transcript=scene.get("transcript"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
@@ -336,6 +343,9 @@ async def _run_intelligence_pipeline(
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
transcript=scene.get("transcript"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
server-26#64 — a headless, attributable, total AI-flag flip.
|
||||
|
||||
Three things are held here:
|
||||
|
||||
* ``require_agent_key_or_admin`` is a DISTINCT principal. It takes the agent
|
||||
service key or a Firebase admin token and refuses the Discord bot's
|
||||
``service_key``, so an audit entry can name who flipped the switch.
|
||||
* ``set_flags`` writes an ``audit_log`` entry carrying before/after values,
|
||||
and an audit failure can neither lose the flag write nor 500 the route.
|
||||
* ``cascade=True`` clears per-system ``ai_flags`` overrides for the keys
|
||||
being set, so a flip cannot half-apply — discovered by scanning for
|
||||
documents that carry the map, never a hardcoded system-id list.
|
||||
|
||||
The dependency is exercised directly rather than through TestClient: these are
|
||||
assertions about the credential check, and routing them through the ASGI stack
|
||||
would only add ways for the test to pass for the wrong reason.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import auth, feature_flags
|
||||
from app.routers import admin
|
||||
|
||||
AGENT_KEY = "agent-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
BOT_KEY = "bot-key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
|
||||
def _creds(token: str) -> HTTPAuthorizationCredentials:
|
||||
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keys(monkeypatch):
|
||||
"""Both keys configured and different — the production shape."""
|
||||
monkeypatch.setattr(settings, "agent_service_key", AGENT_KEY, raising=False)
|
||||
monkeypatch.setattr(settings, "service_key", BOT_KEY, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_flag_cache():
|
||||
"""feature_flags keeps module-level cache state; don't leak it across tests."""
|
||||
feature_flags._cache = {}
|
||||
feature_flags._cache_ts = 0.0
|
||||
yield
|
||||
feature_flags._cache = {}
|
||||
feature_flags._cache_ts = 0.0
|
||||
|
||||
|
||||
# ── Item 1: the credential ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_key_is_accepted_and_identifies_itself(keys):
|
||||
principal = await auth.require_agent_key_or_admin(_creds(AGENT_KEY))
|
||||
assert principal["principal"] == "agent"
|
||||
# The caller must be able to tell the agent from a human admin, or the
|
||||
# audit entry in item 3 cannot name the actor.
|
||||
assert auth.describe_actor(principal) == (
|
||||
auth.AGENT_PRINCIPAL_UID, auth.AGENT_PRINCIPAL_EMAIL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_service_key_is_rejected(keys):
|
||||
"""The whole point of a second key: the bot's key must not open this door.
|
||||
|
||||
It falls through to the Firebase branch and fails there, so the bot gets a
|
||||
401 rather than an unattributable flag flip.
|
||||
"""
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds(BOT_KEY))
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unset_agent_key_cannot_be_bypassed(monkeypatch):
|
||||
"""An unconfigured key must match nothing — especially not an empty string.
|
||||
|
||||
``secrets.compare_digest("", "")`` is a match, so the guard has to be on
|
||||
the key being configured, not on a ``or ""`` fallback.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "agent_service_key", None, raising=False)
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
|
||||
for token in ("", " ", "None", "null", AGENT_KEY):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds(token))
|
||||
assert exc.value.status_code == 401, token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_agent_key_cannot_be_bypassed(monkeypatch):
|
||||
"""Same guarantee for a key set to "" by an empty env var."""
|
||||
monkeypatch.setattr(settings, "agent_service_key", "", raising=False)
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds(""))
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firebase_admin_token_still_works(keys):
|
||||
decoded = {"uid": "u-1", "email": "admin@example.com", "role": "admin"}
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", return_value=decoded):
|
||||
principal = await auth.require_agent_key_or_admin(_creds("firebase-id-token"))
|
||||
assert principal == decoded
|
||||
assert auth.describe_actor(principal) == ("u-1", "admin@example.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_firebase_token_is_forbidden(keys):
|
||||
decoded = {"uid": "u-2", "email": "viewer@example.com", "role": "viewer"}
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", return_value=decoded):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds("firebase-id-token"))
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_credentials_is_401(keys):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(None)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_features_routes_use_the_agent_dependency_and_others_do_not():
|
||||
"""Guards the wiring: only /admin/features moved off require_admin_token."""
|
||||
def deps(path, method):
|
||||
for r in admin.router.routes:
|
||||
if r.path == path and method in r.methods:
|
||||
return {d.call for d in r.dependant.dependencies}
|
||||
raise AssertionError(f"no route {method} {path}")
|
||||
|
||||
assert auth.require_agent_key_or_admin in deps("/admin/features", "GET")
|
||||
assert auth.require_agent_key_or_admin in deps("/admin/features", "PUT")
|
||||
assert auth.require_admin_token in deps("/admin/audit", "GET")
|
||||
assert auth.require_admin_token in deps("/admin/debug/correlation", "GET")
|
||||
|
||||
|
||||
# ── Items 3 and 4: set_flags audits, and cascades on request ──────────────────
|
||||
|
||||
def _fstore_mock(stored: dict, systems: list[dict], updates_sink: list):
|
||||
"""A Firestore stand-in for feature_flags: one config doc, N system docs."""
|
||||
mock = AsyncMock()
|
||||
|
||||
async def doc_get(collection, doc_id):
|
||||
return dict(stored) if collection == "config" else None
|
||||
|
||||
async def doc_set(collection, doc_id, data, merge=True):
|
||||
stored.update(data)
|
||||
|
||||
async def collection_list(collection, **filters):
|
||||
return systems if collection == "systems" else []
|
||||
|
||||
async def doc_update(collection, doc_id, data):
|
||||
updates_sink.append((collection, doc_id, data))
|
||||
|
||||
mock.doc_get = AsyncMock(side_effect=doc_get)
|
||||
mock.doc_set = AsyncMock(side_effect=doc_set)
|
||||
mock.collection_list = AsyncMock(side_effect=collection_list)
|
||||
mock.doc_update = AsyncMock(side_effect=doc_update)
|
||||
return mock
|
||||
|
||||
|
||||
def _systems():
|
||||
return [
|
||||
# Two systems carry overrides today; the ids are irrelevant to the
|
||||
# helper and must stay that way.
|
||||
{"system_id": "sys-a", "ai_flags": {"stt_enabled": False, "correlation_enabled": False}},
|
||||
{"system_id": "sys-b", "ai_flags": {"stt_enabled": False}},
|
||||
# Carries the map but not the key being flipped — must be left alone.
|
||||
{"system_id": "sys-c", "ai_flags": {"summaries_enabled": False}},
|
||||
# No overrides at all: already inherits, nothing to cascade to.
|
||||
{"system_id": "sys-d"},
|
||||
{"system_id": "sys-e", "ai_flags": {}},
|
||||
]
|
||||
|
||||
|
||||
async def _run_set_flags(updates, *, stored=None, systems=None, cascade=False, actor=None,
|
||||
audit_side_effect=None):
|
||||
stored = stored if stored is not None else {"stt_enabled": True, "correlation_enabled": True}
|
||||
systems = systems if systems is not None else _systems()
|
||||
updates_sink: list = []
|
||||
audit_mock = AsyncMock(side_effect=audit_side_effect)
|
||||
with patch.object(feature_flags, "fstore", _fstore_mock(stored, systems, updates_sink)), \
|
||||
patch("app.internal.audit.write_audit", new=audit_mock):
|
||||
result = await feature_flags.set_flags(updates, actor=actor, cascade=cascade)
|
||||
return result, stored, updates_sink, audit_mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_flags_is_backward_compatible_without_actor_or_cascade():
|
||||
"""Existing call shape — set_flags({...}) — must keep working."""
|
||||
result, stored, updates_sink, audit_mock = await _run_set_flags({"stt_enabled": False})
|
||||
assert result["stt_enabled"] is False
|
||||
assert stored["stt_enabled"] is False
|
||||
assert updates_sink == [] # no cascade unless asked
|
||||
assert audit_mock.await_count == 1 # but still audited
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_records_before_and_after_and_the_actor():
|
||||
_, _, _, audit_mock = await _run_set_flags(
|
||||
{"stt_enabled": False},
|
||||
actor=(auth.AGENT_PRINCIPAL_UID, auth.AGENT_PRINCIPAL_EMAIL),
|
||||
)
|
||||
kwargs = audit_mock.await_args.kwargs
|
||||
assert kwargs["action"] == "feature_flags.update"
|
||||
assert kwargs["actor_uid"] == auth.AGENT_PRINCIPAL_UID
|
||||
assert kwargs["actor_email"] == auth.AGENT_PRINCIPAL_EMAIL
|
||||
details = kwargs["details"]
|
||||
assert details["changed"]["stt_enabled"] == {"from": True, "to": False}
|
||||
assert details["before"]["stt_enabled"] is True
|
||||
assert details["after"]["stt_enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_failure_neither_loses_the_write_nor_raises():
|
||||
"""audit_log is a record OF the write, never a precondition for it."""
|
||||
result, stored, _, audit_mock = await _run_set_flags(
|
||||
{"stt_enabled": False},
|
||||
audit_side_effect=RuntimeError("firestore down"),
|
||||
)
|
||||
assert audit_mock.await_count == 1
|
||||
assert stored["stt_enabled"] is False # flag write survived
|
||||
assert result["stt_enabled"] is False # and the route returns normally
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_clears_matching_system_overrides_at_both_levels():
|
||||
result, stored, updates_sink, audit_mock = await _run_set_flags(
|
||||
{"stt_enabled": True}, cascade=True,
|
||||
)
|
||||
# Global level.
|
||||
assert stored["stt_enabled"] is True
|
||||
assert result["stt_enabled"] is True
|
||||
# System level: only the two documents whose ai_flags carry stt_enabled.
|
||||
written = {sid: data["ai_flags"] for _, sid, data in updates_sink}
|
||||
assert set(written) == {"sys-a", "sys-b"}
|
||||
# The flipped key is removed so the system inherits; unrelated overrides stay.
|
||||
assert written["sys-a"] == {"correlation_enabled": False}
|
||||
assert written["sys-b"] == {}
|
||||
# And the cascade is recorded, per system, in the audit entry.
|
||||
cascaded = audit_mock.await_args.kwargs["details"]["cascaded_systems"]
|
||||
assert {c["system_id"] for c in cascaded} == {"sys-a", "sys-b"}
|
||||
assert cascaded[0]["cleared_overrides"] == {"stt_enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_finds_systems_by_shape_not_by_hardcoded_id():
|
||||
"""A newly added system carrying an override must not defeat a flip."""
|
||||
systems = _systems() + [{"system_id": "sys-new", "ai_flags": {"stt_enabled": False}}]
|
||||
_, _, updates_sink, _ = await _run_set_flags(
|
||||
{"stt_enabled": True}, systems=systems, cascade=True,
|
||||
)
|
||||
assert "sys-new" in {sid for _, sid, _ in updates_sink}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_off_leaves_every_system_override_intact():
|
||||
"""The default path must not silently erase a deliberate per-system value."""
|
||||
_, _, updates_sink, _ = await _run_set_flags({"stt_enabled": True}, cascade=False)
|
||||
assert updates_sink == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_error_on_one_system_does_not_stop_the_others():
|
||||
systems = _systems()
|
||||
stored = {"stt_enabled": True, "correlation_enabled": True}
|
||||
updates_sink: list = []
|
||||
fs = _fstore_mock(stored, systems, updates_sink)
|
||||
real_update = fs.doc_update.side_effect
|
||||
|
||||
async def flaky(collection, doc_id, data):
|
||||
if doc_id == "sys-a":
|
||||
raise RuntimeError("write conflict")
|
||||
return await real_update(collection, doc_id, data)
|
||||
|
||||
fs.doc_update = AsyncMock(side_effect=flaky)
|
||||
audit_mock = AsyncMock()
|
||||
with patch.object(feature_flags, "fstore", fs), \
|
||||
patch("app.internal.audit.write_audit", new=audit_mock):
|
||||
await feature_flags.set_flags({"stt_enabled": True}, cascade=True)
|
||||
|
||||
assert [sid for _, sid, _ in updates_sink] == ["sys-b"]
|
||||
details = audit_mock.await_args.kwargs["details"]
|
||||
assert [e["system_id"] for e in details["cascade_errors"]] == ["sys-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecognised_keys_still_raise():
|
||||
with pytest.raises(ValueError):
|
||||
await _run_set_flags({"not_a_flag": True})
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Alert payload redaction — server-26#85.
|
||||
|
||||
Board minutes #42 suppress person names on every surface until E&O is bound.
|
||||
A Discord webhook is the least recoverable surface the system has: once the
|
||||
text is in a channel we do not own it, cannot unsend it, and cannot audit who
|
||||
read it. These tests pin the default-closed behaviour so it cannot regress
|
||||
quietly the way it shipped.
|
||||
|
||||
The transcript below deliberately contains a person name; every assertion is
|
||||
"this string did not leave the process", not "some flag was set".
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import alerter
|
||||
|
||||
|
||||
TRANSCRIPT = "Units respond, subject identified as Michael Brennan, 42 Elm Street"
|
||||
ORG = "org-1"
|
||||
RULE = {
|
||||
"rule_id": "r1",
|
||||
"name": "Structure fire",
|
||||
"enabled": True,
|
||||
"keywords": ["respond"],
|
||||
"discord_webhook": "https://discord.example/webhook",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch):
|
||||
"""Capture what alerter would write to Firestore and POST outbound."""
|
||||
saved: list[dict] = []
|
||||
posted: list[dict] = []
|
||||
|
||||
async def _doc_set(collection, doc_id, data, merge=False):
|
||||
saved.append(data)
|
||||
|
||||
async def _post(url, json=None, **kwargs):
|
||||
posted.append(json or {})
|
||||
|
||||
class _R:
|
||||
status_code = 204
|
||||
return _R()
|
||||
|
||||
monkeypatch.setattr(alerter.fstore, "doc_set", _doc_set)
|
||||
monkeypatch.setattr(
|
||||
alerter.fstore, "collection_list", AsyncMock(return_value=[dict(RULE)])
|
||||
)
|
||||
return saved, posted, _post
|
||||
|
||||
|
||||
async def _run(captured, org_doc):
|
||||
saved, posted, _post = captured
|
||||
with patch.object(
|
||||
alerter.fstore,
|
||||
"doc_get",
|
||||
AsyncMock(side_effect=lambda c, i: {"org_id": ORG} if c == "calls" else org_doc),
|
||||
):
|
||||
client = AsyncMock()
|
||||
client.post = _post
|
||||
with patch("httpx.AsyncClient") as ac:
|
||||
ac.return_value.__aenter__.return_value = client
|
||||
await alerter.check_and_dispatch(
|
||||
call_id="c1",
|
||||
node_id="n1",
|
||||
talkgroup_id=1,
|
||||
talkgroup_name="Fire Dispatch",
|
||||
tags=[],
|
||||
transcript=TRANSCRIPT,
|
||||
)
|
||||
return saved, posted
|
||||
|
||||
|
||||
def _blob(payloads) -> str:
|
||||
return " ".join(str(p) for p in payloads)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_carries_no_transcript_by_default(captured):
|
||||
"""The shipped default must not put raw transcript text on the wire."""
|
||||
saved, posted = await _run(captured, {})
|
||||
|
||||
assert posted, "the webhook should still fire — alerting is not disabled, only the text is"
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert "Elm Street" not in _blob(posted)
|
||||
# The alert is still useful: it names the rule and the talkgroup.
|
||||
assert "Structure fire" in _blob(posted)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_event_stores_no_transcript_by_default(captured):
|
||||
"""Firestore is a surface too — the frontend reads it directly."""
|
||||
saved, _ = await _run(captured, {})
|
||||
|
||||
assert saved, "the alert event should still be recorded"
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
assert "Michael Brennan" not in _blob(saved)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_opt_in_alone_does_not_open_the_gate(captured):
|
||||
"""
|
||||
An org owner writing their own org document must not be able to opt
|
||||
themselves into receiving somebody else's PII. The operator switch is
|
||||
the control; the org flag is only consent.
|
||||
"""
|
||||
assert settings.alert_transcript_snippet_enabled is False
|
||||
saved, posted = await _run(captured, {"alert_snippet_opt_in": True})
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert "Michael Brennan" not in _blob(saved)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_gates_open_emits_the_snippet(monkeypatch, captured):
|
||||
"""The opt-in path still works, so this is a gate and not a deletion."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted = await _run(captured, {"alert_snippet_opt_in": True})
|
||||
|
||||
assert "Michael Brennan" in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_switch_alone_does_not_open_the_gate(monkeypatch, captured):
|
||||
"""Consent is required as well as capability."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted = await _run(captured, {})
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_org_fails_closed(monkeypatch, captured):
|
||||
"""A Firestore error must withhold the transcript, not default to sending it."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted, _post = captured
|
||||
|
||||
async def _doc_get(collection, doc_id):
|
||||
if collection == "calls":
|
||||
return {"org_id": ORG}
|
||||
raise RuntimeError("firestore unavailable")
|
||||
|
||||
with patch.object(alerter.fstore, "doc_get", _doc_get):
|
||||
client = AsyncMock()
|
||||
client.post = _post
|
||||
with patch("httpx.AsyncClient") as ac:
|
||||
ac.return_value.__aenter__.return_value = client
|
||||
await alerter.check_and_dispatch(
|
||||
call_id="c1", node_id="n1", talkgroup_id=1,
|
||||
talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT,
|
||||
)
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_tenancy_call_with_no_org_fails_closed(monkeypatch, captured):
|
||||
"""A call with no org_id has nobody who could have consented to anything."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted, _post = captured
|
||||
|
||||
with patch.object(alerter.fstore, "doc_get", AsyncMock(return_value={})):
|
||||
client = AsyncMock()
|
||||
client.post = _post
|
||||
with patch("httpx.AsyncClient") as ac:
|
||||
ac.return_value.__aenter__.return_value = client
|
||||
await alerter.check_and_dispatch(
|
||||
call_id="c1", node_id="n1", talkgroup_id=1,
|
||||
talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT,
|
||||
)
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
@@ -229,6 +229,114 @@ async def test_a_bare_number_never_reaches_the_correlator():
|
||||
assert ctx["location_coords"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin():
|
||||
"""
|
||||
server-26#87. One call can be split into several scenes, and only the
|
||||
primary scene's geocode is written to the call doc. A non-primary scene
|
||||
that passes no location of its own must not inherit that pin — doing so
|
||||
fabricates location_proximity, the strongest accept signal, for a scene
|
||||
that has none, and drives it into the primary scene's incident.
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(
|
||||
return_value={"location_coords": GRASSLANDS}
|
||||
)
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
)
|
||||
assert ctx["coords"] is None
|
||||
assert ctx["is_thin_call"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scene_does_not_inherit_the_call_docs_embedding_or_severity():
|
||||
"""
|
||||
server-26#80 / #95. Same shape as the #87 coords leak above:
|
||||
intelligence.py writes only the PRIMARY scene's embedding and severity to
|
||||
calls/{id}. A non-primary scene being correlated must be judged on its own
|
||||
embedding (or none) and its own severity — not the call doc's — or a scene
|
||||
about a different event scores against the wrong incident on the embedding
|
||||
path and can inherit a minor/moderate/major rung it never had, clearing the
|
||||
creation gate on borrowed weight.
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(
|
||||
return_value={"embedding": [0.1] * 1536, "severity": "major"}
|
||||
)
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
embedding=None, severity=None,
|
||||
)
|
||||
assert ctx["call_embedding"] is None
|
||||
assert ctx["call_severity"] == "routine"
|
||||
assert ctx["is_thin_call"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
|
||||
"""The other half of #80/#95: the scene's own values are what land in ctx."""
|
||||
scene_vec = [0.9] * 1536
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(
|
||||
return_value={"embedding": [0.1] * 1536, "severity": "routine"}
|
||||
)
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
embedding=scene_vec, severity="major",
|
||||
)
|
||||
assert ctx["call_embedding"] == scene_vec
|
||||
assert ctx["call_severity"] == "major"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_llm_tier_reads_the_scene_transcript_not_the_whole_call():
|
||||
"""
|
||||
server-26#102. intelligence.py writes only the primary scene's corrected
|
||||
text to calls/{id}. _call_block (the LLM correlation prompt) must reason
|
||||
over the SCENE being correlated, not a whole-call transcript that also
|
||||
contains the other scenes. _build_context threads the scene's text in;
|
||||
with no scene text it falls back to the call doc (sweep / single-scene).
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(return_value={
|
||||
"transcript": "scene one about a fire. scene two about a traffic stop.",
|
||||
})
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
scene = await _build_context(
|
||||
call_id="call-1", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
transcript="scene two about a traffic stop.",
|
||||
)
|
||||
fallback = await _build_context(
|
||||
call_id="call-1", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
)
|
||||
assert scene["scene_transcript"] == "scene two about a traffic stop."
|
||||
assert fallback["scene_transcript"] == "scene one about a fire. scene two about a traffic stop."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
||||
inc = await _create(tags=["flames"], location="49", coords=None,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
server-26#81 — any signed-in viewer could trigger OpenAI summary spend.
|
||||
|
||||
``POST /incidents/{incident_id}/summarize`` was gated by
|
||||
``require_service_or_firebase_token``, which accepts ANY authenticated
|
||||
Firebase user (including role "viewer"), not just admins. Hitting the route
|
||||
spends OpenAI credits via the background summarizer task. The call-side
|
||||
equivalent (``PATCH /calls/{id}/transcript``) was already moved to
|
||||
``require_admin_token``; the incident side was not moved with it.
|
||||
|
||||
Following the wiring-test convention in test_admin_feature_flags.py
|
||||
(``test_features_routes_use_the_agent_dependency_and_others_do_not``): assert
|
||||
against the route's actual dependant.dependencies rather than round-tripping
|
||||
through TestClient, so this pins the credential wiring itself and would fail
|
||||
immediately if someone reverts the dependency back to the weak one.
|
||||
"""
|
||||
from app.internal import auth
|
||||
from app.routers import incidents
|
||||
|
||||
|
||||
def _deps(path: str, method: str) -> set:
|
||||
for r in incidents.router.routes:
|
||||
if r.path == path and method in r.methods:
|
||||
return {d.call for d in r.dependant.dependencies}
|
||||
raise AssertionError(f"no route {method} {path}")
|
||||
|
||||
|
||||
def test_summarize_incident_requires_admin_not_any_firebase_user():
|
||||
deps = _deps("/incidents/{incident_id}/summarize", "POST")
|
||||
assert auth.require_admin_token in deps
|
||||
assert auth.require_service_or_firebase_token not in deps
|
||||
|
||||
|
||||
def test_read_only_incident_routes_still_accept_any_signed_in_user():
|
||||
"""Guards against an overcorrection: reads are not spend, they stay open
|
||||
to any authenticated viewer."""
|
||||
assert auth.require_service_or_firebase_token in _deps("/incidents", "GET")
|
||||
assert auth.require_service_or_firebase_token in _deps("/incidents/{incident_id}", "GET")
|
||||
|
||||
|
||||
def test_other_mutating_incident_routes_are_still_admin_only():
|
||||
"""Unchanged by this fix, but pinned so a future edit can't quietly
|
||||
loosen them while touching this file."""
|
||||
for path, method in [
|
||||
("/incidents/summarize", "POST"),
|
||||
("/incidents", "POST"),
|
||||
("/incidents/{incident_id}", "PUT"),
|
||||
("/incidents/{incident_id}", "DELETE"),
|
||||
("/incidents/{incident_id}/calls/{call_id}", "POST"),
|
||||
("/incidents/{incident_id}/calls/{call_id}", "DELETE"),
|
||||
]:
|
||||
assert auth.require_admin_token in _deps(path, method), f"{method} {path}"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
server-26#102 — a scene is correlated on its OWN transcript, not the whole call.
|
||||
|
||||
_scene_transcript_text slices the segments a scene owns. It must never return
|
||||
"" (an empty slice would let incident_correlator._build_context fall back to
|
||||
the call doc's whole-call transcript, re-opening the leak in exactly the case
|
||||
— bad indices — where it matters).
|
||||
"""
|
||||
from app.internal.intelligence import _scene_transcript_text
|
||||
|
||||
SEGS = [
|
||||
{"text": "structure fire, 12 Main"},
|
||||
{"text": "engine 4 responding"},
|
||||
{"text": "traffic stop, plate ABC"},
|
||||
{"text": "one occupant"},
|
||||
]
|
||||
WHOLE = "structure fire, 12 Main engine 4 responding traffic stop, plate ABC one occupant"
|
||||
|
||||
|
||||
def test_scene_owns_a_subset_of_segments():
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [0, 1], None) == "structure fire, 12 Main engine 4 responding"
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [2, 3], None) == "traffic stop, plate ABC one occupant"
|
||||
|
||||
|
||||
def test_corrected_text_wins_when_present():
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [0], "cleaned up text") == "cleaned up text"
|
||||
|
||||
|
||||
def test_no_segment_indices_falls_back_to_whole_call():
|
||||
# single-segment calls are never numbered by _build_transcript_block → null indices
|
||||
assert _scene_transcript_text(WHOLE, SEGS, None, None) == WHOLE
|
||||
assert _scene_transcript_text(WHOLE, None, [0, 1], None) == WHOLE
|
||||
|
||||
|
||||
def test_out_of_range_or_nonint_indices_fall_back_never_empty():
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [9, 10], None) == WHOLE # all out of range
|
||||
assert _scene_transcript_text(WHOLE, SEGS, ["1", "2"], None) == WHOLE # 1-based strings, rejected
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [-1], None) == WHOLE # negative
|
||||
# partial validity: keep what's in range
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [3, 99], None) == "one occupant"
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useAlerts } from "@/lib/useAlerts";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { AlertRule } from "@/lib/types";
|
||||
|
||||
@@ -228,6 +229,13 @@ export default function AlertsPage() {
|
||||
) : alerts.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
||||
and the keyword match that fired the alert was made against it. */}
|
||||
<MachineOutputNotice
|
||||
variant="inline"
|
||||
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
||||
/>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
@@ -280,6 +288,7 @@ export default function AlertsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
type LinkFilter = "any" | "orphan" | "linked";
|
||||
type TranscriptFilter = "any" | "yes" | "no";
|
||||
@@ -348,6 +349,11 @@ export default function ArchivePage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Gate A / A2 (server-26#46) — every row expands to a transcript. */}
|
||||
<MachineOutputNotice
|
||||
detail="transcripts and the incident links derived from them are automated output and may contain errors, including misheard names, addresses and unit numbers. Check the recording before acting on them."
|
||||
/>
|
||||
|
||||
{error && <ErrorBanner message={`Couldn't load calls: ${error}`} />}
|
||||
|
||||
{loading && calls.length === 0 ? (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
const FAQS: { q: string; a: string }[] = [
|
||||
const FAQS: { q: string; a: ReactNode }[] = [
|
||||
{
|
||||
q: "What hardware do I need to run a node?",
|
||||
a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.",
|
||||
@@ -15,7 +18,14 @@ const FAQS: { q: string; a: string }[] = [
|
||||
},
|
||||
{
|
||||
q: "Does DRB do the transcription and AI work itself, or is that a separate cost?",
|
||||
a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.",
|
||||
a: (
|
||||
<>
|
||||
Transcription and incident correlation run automatically on every recorded call and are
|
||||
included — they are not billed as an add-on.
|
||||
{/* Gate A / A2 (server-26#46) — qualified on the same screen as the claim. */}
|
||||
<MachineOutputNotice className="mt-3 not-italic" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Can I listen to live radio traffic without opening the dashboard?",
|
||||
@@ -30,8 +40,22 @@ const FAQS: { q: string; a: string }[] = [
|
||||
a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.",
|
||||
},
|
||||
{
|
||||
// Gate A / A1 (server-26#46): plan-tiered retention windows are an unbuilt
|
||||
// entitlement — there is no TTL and no deletion sweep anywhere in the
|
||||
// product (server-26#44). The claim is marked unbuilt inline, on this
|
||||
// screen, rather than quietly dropped.
|
||||
q: "How long is call and incident history kept?",
|
||||
a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.",
|
||||
a: (
|
||||
<>
|
||||
<UnbuiltMarker>Retention limits — not yet available</UnbuiltMarker>
|
||||
<p className="mt-2">
|
||||
Today nothing is deleted automatically: calls, recordings and incidents stay searchable and
|
||||
linked to their incidents for as long as your account is open. Per-plan retention windows and
|
||||
automatic deletion are not built yet, so we make no commitment about how long anything is kept
|
||||
or when it goes away. If you need data removed, ask us and we will remove it by hand.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Is DMR supported?",
|
||||
@@ -66,7 +90,7 @@ export default function FaqPage() {
|
||||
{FAQS.map((item, i) => {
|
||||
const open = openIndex === i;
|
||||
return (
|
||||
<div key={item.q}>
|
||||
<div key={i}>
|
||||
<button
|
||||
onClick={() => setOpenIndex(open ? null : i)}
|
||||
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
|
||||
@@ -76,7 +100,7 @@ export default function FaqPage() {
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
{open && (
|
||||
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p>
|
||||
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
const SECTIONS = [
|
||||
const SECTIONS: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
body: string;
|
||||
points: string[];
|
||||
/** Section describes AI pipeline output — render the Gate A / A2 qualifier. */
|
||||
qualify?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
eyebrow: "Correlation",
|
||||
title: "Calls become incidents",
|
||||
@@ -13,6 +21,7 @@ const SECTIONS = [
|
||||
"Distance, timing, shared units, and talkgroup signals all feed the match",
|
||||
"Every call keeps its correlation debug trail for admins to audit",
|
||||
],
|
||||
qualify: true,
|
||||
},
|
||||
{
|
||||
eyebrow: "AI pipeline",
|
||||
@@ -24,6 +33,7 @@ const SECTIONS = [
|
||||
"Scene & entity extraction feeds the correlator and the incident summary",
|
||||
"AI-generated incident summaries, regenerable on demand",
|
||||
],
|
||||
qualify: true,
|
||||
},
|
||||
{
|
||||
eyebrow: "Situational awareness",
|
||||
@@ -89,6 +99,9 @@ export default function FeaturesPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Gate A / A2 (server-26#46) — the sections that describe the AI
|
||||
pipeline carry the same qualifier the product surfaces do. */}
|
||||
{s.qualify && <MachineOutputNotice className="mt-5" />}
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||
import { SeverityMark } from "@/components/marks/SeverityMark";
|
||||
import { isKnownSeverity } from "@/lib/severity";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
@@ -165,7 +166,7 @@ export default function IncidentDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Summary — first, in prose. Not a tab. */}
|
||||
<div>
|
||||
<div className="space-y-2.5">
|
||||
{incident.summary ? (
|
||||
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
|
||||
) : (
|
||||
@@ -178,6 +179,10 @@ export default function IncidentDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* Gate A / A2 (server-26#46): the summary, the title, the location,
|
||||
the units and the vehicles below are ALL pipeline output, so the
|
||||
notice sits on this screen with them — not on a policy page. */}
|
||||
<MachineOutputNotice />
|
||||
</div>
|
||||
|
||||
{/* On scene / Cleared */}
|
||||
@@ -225,6 +230,8 @@ export default function IncidentDetailPage() {
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
|
||||
Calls ({calls.length})
|
||||
</p>
|
||||
{/* Gate A / A2 — the spine renders transcripts. */}
|
||||
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
|
||||
{callsLoading ? (
|
||||
<p className="text-ink-muted text-sm">Loading…</p>
|
||||
) : calls.length === 0 ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
||||
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||
@@ -219,6 +220,10 @@ export default function IncidentsPage() {
|
||||
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
||||
/>
|
||||
|
||||
{/* Gate A / A2 (server-26#46) — every row's title, location and unit
|
||||
chips are pipeline output, so the notice rides with the list. */}
|
||||
<MachineOutputNotice variant="inline" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCalls } from "@/lib/useCalls";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
@@ -335,6 +336,8 @@ export default function NodeDetailPage() {
|
||||
{/* Recent calls */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
|
||||
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
||||
{nodeCalls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p>
|
||||
) : (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
@@ -68,6 +69,19 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate A / A1 (server-26#46). The plan cards below render taglines that claim
|
||||
* entitlements with no backend behind them. Those claims get marked unbuilt
|
||||
* inline, on this screen, next to the plan that makes them. This is a labelling
|
||||
* change only — it does not build any of these, and it must never grow into a
|
||||
* price or a checkout path (Gate B still bars charging anyone).
|
||||
*/
|
||||
const UNBUILT_CLAIMS: Partial<Record<PlanId, string[]>> = {
|
||||
free: ["Retention window"],
|
||||
pro: ["Retention window"],
|
||||
enterprise: ["Custom retention", "SSO / SAML", "Uptime SLA", "Data residency"],
|
||||
};
|
||||
|
||||
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
|
||||
paid: "success",
|
||||
open: "warning",
|
||||
@@ -169,6 +183,13 @@ export default function BillingSettingsPage() {
|
||||
>
|
||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
||||
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
||||
<UnbuiltMarker key={claim}>{claim} — not yet available</UnbuiltMarker>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-white text-lg font-bold font-mono mt-3">
|
||||
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
|
||||
</p>
|
||||
|
||||
@@ -39,6 +39,30 @@ function EnrollmentTokensPanel() {
|
||||
const [label, setLabel] = useState("");
|
||||
const [minting, setMinting] = useState(false);
|
||||
const [justMinted, setJustMinted] = useState<string | null>(null);
|
||||
const [cmdCopied, setCmdCopied] = useState(false);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
// The label the operator typed for the token that was just minted — used as
|
||||
// the node id in the install command below. Captured on mint because `label`
|
||||
// itself is cleared afterward.
|
||||
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
|
||||
|
||||
// The paste-ready one-shot install command for a fresh Pi. The node id comes
|
||||
// from the label just entered (spaces → dashes; install.sh requires
|
||||
// [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
|
||||
// placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
|
||||
// of the api host (install.sh header) — a DNS assumption the operator checks.
|
||||
const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, "");
|
||||
const mqttBroker = (() => {
|
||||
try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; }
|
||||
catch { return "mqtt.example.net"; }
|
||||
})();
|
||||
const nodeIdForCmd =
|
||||
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
|
||||
const installCmd = justMinted
|
||||
? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\
|
||||
| sudo bash -s -- --token ${justMinted} --node-id ${nodeIdForCmd} \\
|
||||
--c2-url ${c2Url} --mqtt-broker ${mqttBroker}`
|
||||
: "";
|
||||
|
||||
const load = useCallback(() => {
|
||||
c2api.listEnrollmentTokens()
|
||||
@@ -57,6 +81,7 @@ function EnrollmentTokensPanel() {
|
||||
try {
|
||||
const result = await c2api.mintEnrollmentToken(label.trim());
|
||||
setJustMinted(result.token);
|
||||
setMintedLabel(label.trim());
|
||||
setLabel("");
|
||||
load();
|
||||
} catch (err) {
|
||||
@@ -87,11 +112,43 @@ function EnrollmentTokensPanel() {
|
||||
<p className="text-xs text-indigo-200 font-mono mb-1">
|
||||
New token — copy it now, it won't be shown again:
|
||||
</p>
|
||||
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJustMinted(null)}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
|
||||
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
||||
setTokenCopied(true); setTimeout(() => setTokenCopied(false), 2000);
|
||||
})}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
|
||||
>
|
||||
{tokenCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
|
||||
…or run this on a fresh Pi{" "}
|
||||
{nodeIdForCmd === "node-XXX"
|
||||
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
|
||||
: <>(check the broker host)</>}:
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
||||
setCmdCopied(true); setTimeout(() => setCmdCopied(false), 2000);
|
||||
})}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
|
||||
>
|
||||
{cmdCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setJustMinted(null); setMintedLabel(null); }}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -105,7 +162,7 @@ function EnrollmentTokensPanel() {
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Label, e.g. 'node-003 field kit'"
|
||||
placeholder="Node ID, e.g. node-003"
|
||||
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import type {
|
||||
AreaContext,
|
||||
LocalKnowledgeEntry,
|
||||
@@ -1223,7 +1224,11 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
||||
<p className="text-gray-600 italic">No audio</p>
|
||||
)}
|
||||
{transcript && (
|
||||
<>
|
||||
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
|
||||
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
||||
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import L from "leaflet";
|
||||
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
|
||||
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
@@ -351,6 +352,8 @@ function FanIncidentLayer({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
|
||||
<MachineOutputNotice variant="popup" />
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
@@ -402,6 +405,9 @@ function IncidentPathLayer({
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||
View incident →
|
||||
</a>
|
||||
{/* Gate A / A2 (server-26#46) — the stop location and its
|
||||
ordering come from the transcript, not from GPS. */}
|
||||
<MachineOutputNotice variant="popup" />
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
@@ -657,7 +663,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
{incidents.length > 0 && (
|
||||
<>
|
||||
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
|
||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto">
|
||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5">
|
||||
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
||||
unit counts are pipeline output. Pinned above the scroll area
|
||||
so it cannot be scrolled off the screen it qualifies. */}
|
||||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0">
|
||||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto">
|
||||
{incidents.map((inc) => {
|
||||
const color = severityColor(inc.severity);
|
||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||
@@ -712,6 +725,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: bottom drawer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
||||
@@ -724,6 +738,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
</button>
|
||||
{drawerOpen && (
|
||||
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
{/* Gate A / A2 (server-26#46) */}
|
||||
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
||||
{incidents.map((inc) => {
|
||||
const color = severityColor(inc.severity);
|
||||
const label = (
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Gate A, condition A2 (board minutes #42 / #79, tracked at server-26#46).
|
||||
*
|
||||
* Transcripts, incident summaries, titles, locations and extracted entities are
|
||||
* all produced by the AI pipeline (transcription -> scene/entity extraction ->
|
||||
* correlation -> summary). None of it is reviewed by a human before a reader
|
||||
* sees it, and entity-name accuracy has never been measured (server-26#48).
|
||||
*
|
||||
* Gate A therefore requires that machine-generated content is labelled as
|
||||
* machine-generated and unverified ON THE SAME SCREEN as the content itself —
|
||||
* a note on another page does not satisfy the condition. This is the single
|
||||
* element that does that; render it beside every AI-derived surface.
|
||||
*
|
||||
* Copy rules (do not "improve" these away):
|
||||
* - the words "machine-generated" and "unverified" must both appear
|
||||
* - it must not promise accuracy
|
||||
* - it must not name a model or a vendor
|
||||
* - it must not state or imply a price
|
||||
*
|
||||
* Variants exist only because the surfaces differ in space, not because the
|
||||
* claim differs. All three say the same thing.
|
||||
* block — full-width strip above/below a body of AI output (default)
|
||||
* inline — one compact line, for dense list headers and table captions
|
||||
* popup — for a Leaflet popup, which is stock-white in BOTH themes, so it
|
||||
* uses fixed grays instead of the ink/surface tokens
|
||||
*/
|
||||
|
||||
type Variant = "block" | "inline" | "popup";
|
||||
|
||||
function InfoGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6.5" />
|
||||
<path d="M8 7.25v4" />
|
||||
<path d="M8 4.75h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const LEAD = "Machine-generated and unverified";
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
/** Overrides the trailing sentence. The lead ("Machine-generated and unverified") is fixed. */
|
||||
detail?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MachineOutputNotice({ variant = "block", detail, className }: Props) {
|
||||
const body =
|
||||
detail ??
|
||||
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
|
||||
const shortBody = detail ?? "automated output, may contain errors.";
|
||||
|
||||
if (variant === "popup") {
|
||||
// Leaflet popups render on a white wrapper regardless of theme, so this
|
||||
// deliberately does not use the ink/surface tokens.
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{LEAD} — {shortBody}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className={["flex items-center gap-1.5 text-xs text-ink-muted", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
<InfoGlyph className="shrink-0" />
|
||||
<span>
|
||||
<span className="text-ink-2 font-medium">{LEAD}</span> — {shortBody}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
className={[
|
||||
"flex items-start gap-2 rounded-lg border border-line bg-raised/60 px-3 py-2",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<InfoGlyph className="mt-0.5 shrink-0 text-ink-muted" />
|
||||
<p className="text-xs leading-relaxed text-ink-muted">
|
||||
<span className="text-ink-2 font-medium">{LEAD}</span> — {body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Gate A, condition A1 (board minutes #42, tracked at server-26#46).
|
||||
*
|
||||
* Gate A blocks putting an unbuilt entitlement claim in front of a reader
|
||||
* without saying, inline and on the same screen, that it is not built. Known
|
||||
* unbuilt claims today:
|
||||
* - retention windows (7 / 90 / 365 days) — no TTL and no deletion sweep
|
||||
* exists anywhere in the product (server-26#44, DEFERRED.md)
|
||||
* - Enterprise SSO / SAML — no backend at all
|
||||
* - uptime SLA — none offered or measured
|
||||
* - custom data residency — no backend at all
|
||||
*
|
||||
* This marks the claim. It does NOT build the feature, and nothing here may
|
||||
* grow into a price or a checkout path (Gate B still bars charging anyone).
|
||||
*/
|
||||
export function UnbuiltMarker({
|
||||
children = "Not yet available",
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center whitespace-nowrap rounded-full border border-line-strong",
|
||||
"px-1.5 py-0.5 align-middle text-[10px] font-medium uppercase tracking-wide text-ink-muted",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,14 @@ GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||
SERVICE_KEY={{ vault_service_key }}
|
||||
ENROLLMENT_TOKEN={{ vault_enrollment_token }}
|
||||
|
||||
# Agent/automation key for the unattended work session's headless routes
|
||||
# (GET/PUT /admin/features). MUST NOT equal vault_service_key: that one is the
|
||||
# Discord bot's, and one shared value would make the bot and the agent the same
|
||||
# unattributable principal in audit_log (server-26#64). default('') so a vault
|
||||
# that predates this key still templates instead of failing the play; blank
|
||||
# just leaves the agent path closed.
|
||||
AGENT_SERVICE_KEY={{ vault_agent_service_key | default('') }}
|
||||
|
||||
# Bare domain, not app.<domain>: the frontend is served on {{ domain }} itself
|
||||
# (see Caddyfile.j2 — only api. and the bare name have DNS records). This said
|
||||
# app.{{ domain }} while the browser origin was https://{{ domain }}, so every
|
||||
|
||||
@@ -22,7 +22,11 @@ vault_mqtt_c2_pass: "CHANGE_ME"
|
||||
vault_mqtt_dynsec_admin_pass: "CHANGE_ME" # openssl rand -hex 32 — must be >=12 chars, plugin-enforced minimum
|
||||
|
||||
# ── C2 Core ───────────────────────────────────────────────────────────────────
|
||||
vault_service_key: "" # openssl rand -hex 32
|
||||
vault_service_key: "" # openssl rand -hex 32 — the Discord bot's key
|
||||
# The work-session agent's own key for GET/PUT /admin/features. Generate a
|
||||
# SEPARATE value — never a copy of vault_service_key, or a flag flip cannot be
|
||||
# attributed to the agent vs the bot (server-26#64).
|
||||
vault_agent_service_key: "" # openssl rand -hex 32
|
||||
vault_enrollment_token: "" # openssl rand -hex 32 — fleet-wide, shared by every node's POST /nodes/enroll
|
||||
vault_openai_api_key: ""
|
||||
vault_google_maps_api_key: ""
|
||||
|
||||
Reference in New Issue
Block a user