트리거 판정 v1 — is_final 도착 기반 (B-1)

발화 → “지금 검색을 발동할 것인가”. TriggerPort 를 구현한다.

왜 3주차 항목을 당겨왔나

장민석 님 파이프라인의 남은 501 두 개가 검색·트리거였다(“w2-naive-rag 가 붙어야 내 파이프라인의 501 두 개가 풀린다”). 검색은 w2-naive-rag 에서 풀렸고, 트리거만 남으면 server/ 담당이 파이프라인을 끝까지 흘려볼 수 없다. 규칙 계산뿐이라 크기가 작아 당겼다.

판정 규칙

셋 다 만족할 때만 발동한다.

조건
is_final 이다 interim 은 20초 발화에 199건(V4 실측). 매번 발동하면 검색이 초당 수십 번 돈다
고객 발화다 문서가 필요한 시점은 고객이 질문을 끝냈을 때다. 상담원이 말하는 중에 화면을 바꾸면 방해다
내용이 있다 빈 문자열로 검색하면 의미 없는 상위 문서가 뜬다

자체 침묵 타이머를 만들지 않고 STT 의 엔드포인팅 판단을 그대로 쓴다(2026-08-25 팀 컨펌, 정성윤 제안). 침묵 임계값을 재려면 발화 간 침묵 길이가 필요한데 보유 데이터로는 잴 수 없었다.

⚠ 발동 시각(at_ms)은 지금 모형값이다

TranscriptEvent이벤트 도착 시각이 없다. utterance_end_ms(발화가 끝난 시각)만 있다. 그래서 발동 시각을 “발화 종료 + STT 최종 결과 지연(V4 실측 346ms)” 으로 놓았다.

그 결과 이 구현으로 낸 지연 분포는 상수 하나로 수렴한다 — p50 = p95 = 346, 적절 발동률 1.0. 숫자는 나오지만 측정이 아니다.

그래서 이렇게 갈랐다:

경로 꽂는가
server/main.py (요청) 발동 여부(fire)는 진짜 판정이고, 이게 있어야 파이프라인이 흐른다
scripts/run_eval.py (평가) 꽂으면 가짜 1.0 이 리포트에 남는다. 측정할 수 없는 것을 측정한 것처럼 쓰지 않는다(절대 원칙 10)

고칠 방법: 게이트웨이가 도착 시각을 실어 보내고 포트가 그걸 받게 한다. 계약 변경이라 server/ 와 합의가 필요하다 — 미결 항목에 올렸다. 그때까지의 통로로 IsFinalTrigger(now_ms=...) 를 열어 뒀다.

완료 조건

장민석 님께 — 배선 방법

server/ai/ 를 import 할 수 없지만(계약 2), 합성 루트 main.py 는 그 경계 밖입니다. sys.pathai/apps 를 올리고 두 줄이면 됩니다.

# server/main.py
AI = Path(__file__).resolve().parent.parent / "ai"
sys.path[:0] = [str(AI / "apps"), str(AI)]   # 앞은 retrieval·training, 뒤는 provider.py

from provider import build_retrieval_provider, build_trigger_provider
from hub.dependencies.retrieval_provider import get_retrieval_port
from hub.dependencies.trigger_provider import get_trigger_port

app.dependency_overrides[get_trigger_port] = build_trigger_provider()
SPOKES.append("trigger")

if settings.elasticsearch_configured:
    app.dependency_overrides[get_retrieval_port] = build_retrieval_provider(
        settings.elasticsearch_url, api_key=settings.elasticsearch_api_key
    )
    SPOKES.append("retrieval")

배선을 제가 하지 않은 이유: server/main.pyserver/ 소관입니다(decisions/012). 꽂기 쉬운 상태까지만 만들어 두는 게 경계에 맞다고 봤습니다 — 원하시면 제가 해도 됩니다.