Make "AI is off" true, and stop the transcript PATCH from destroying calls
Build & Deploy / Build & push images (push) Successful in 4m2s
Build & Deploy / Deploy to VM (push) Successful in 1m53s
Build & Deploy / Report a failed deploy (push) Skipped

config/ai_features was not the switch it was documented to be. Three paths
spent money with it off, and one path read it wrong, so per-system opt-outs
did not opt anything out.

- Correlation in the ingest pipeline tested the raw global flag instead of the
  per-system resolution. With a system opted out, extraction was skipped but
  the no-scenes fallback still correlated the call with empty tags, taking the
  thin/recency path and attaching it to whatever incident was most recent on
  that system. The opt-out did not disable correlation, it disabled good
  correlation and left the worst kind running. (#75)

- Transcript correction ran on every transcribed call gated only by an env var,
  spending Gemini tokens and a Places lookup per proposed location. An
  "STT-only" window was never STT-only and its cost could not be attributed.
  Now behind transcript_correction_enabled. (#76)

- _run_extraction_pipeline and the vocabulary learner, both reachable from
  PATCH /calls/{id}/transcript, checked no flags at all. (#76, #81)

The flag resolver now lives in feature_flags.resolve_flags() rather than as a
local helper in upload.py. Three copies of that logic is how #75 happened.

PATCH /calls/{id}/transcript now refuses with 409 when correlation is off.
That route wipes tags, severity, location, units, embedding and unlinks the
call from every incident before queueing re-extraction. Gating extraction
alone would have made it destructive-only in the standing flags-off
configuration: the call left blank and orphaned forever, with the route still
answering 200. The wipe and the rebuild are one transaction in intent, so it
refuses before the first write.

Also: the summarizer's stale-incident sweep is no longer behind
summaries_enabled. It is pure Firestore with no model call in it, and gating
it meant nothing auto-resolved while AI was off - so every incident stayed
active forever and the candidate set every correlation reads kept growing.

transcript_correction_enabled is documented as NOT a pure cost lever. The
corrector is also the noise gate that sets not_speech; with it off, recogniser
noise reaches extraction as a real transcript, comes back thin, and
auto-attaches. Never open an evaluation window with correction off and
correlation on.

14 tests added covering flag precedence, both pipeline paths, the 409, the
correction gate and the summarizer no-op. Suite: 264 passed.

Refs #75, #76, #81, #45.
This commit is contained in:
Logan Cusano
2026-08-27 02:49:09 -04:00
parent 5fc4e2c57b
commit d18e4f0743
7 changed files with 411 additions and 62 deletions
+17 -1
View File
@@ -229,10 +229,26 @@ async def patch_transcript(
_: dict = Depends(require_admin_token),
):
"""Overwrite a call's transcript and re-run intelligence extraction."""
from app.internal.feature_flags import resolve_flags
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
# This route is destructive before it is constructive: it wipes the call's
# tags, severity, location, units and embedding and unlinks it from every
# incident, on the promise that re-extraction will rebuild all of it. With
# correlation off that promise cannot be kept, and the call would be left
# permanently blank and orphaned while the route still answered 200.
# Refuse before the first write rather than half-run (server-26#76).
_, flag = await resolve_flags(call.get("system_id"))
if not flag("correlation_enabled"):
raise HTTPException(
409,
"Correlation is disabled, so the re-extraction this correction depends on "
"cannot run. The transcript was not changed. Enable correlation and retry.",
)
# Save user correction as transcript_corrected; leave original transcript intact.
# Clear stale intelligence fields so re-extraction runs fresh.
await fstore.doc_set("calls", call_id, {
@@ -271,7 +287,7 @@ async def patch_transcript(
# Learn from the correction: diff original → corrected and add new tokens to vocabulary
system_id = call.get("system_id")
original_text = call.get("transcript_corrected") or call.get("transcript") or ""
if system_id and original_text:
if system_id and original_text and flag("vocabulary_learning_enabled"):
from app.internal.vocabulary_learner import learn_from_correction
await learn_from_correction(system_id, original_text, body.transcript)