"""
app/services/speaker_classifier.py
-------------------------------------
LLM 기반 단일 분류 화자 판별 서비스.
입력: 무전 텍스트 1줄 (str)
출력: Literal["관제", "열차", "불명"] 중 하나
[설계 원칙]
- 1단계: dictionary.py의 CALLSIGNS_CONTROL/TRAIN 목록으로 빠른 문자열 매칭
- 2단계: 단 1번의 LLM 호출로 JSON {"speaker":"관제"|"열차"|"불명"} 수신
- 3단계: 파싱 실패·LLM 미활성·예외 → "불명" 반환 (절대 크래시 없음)
"""
from __future__ import annotations
import re
import logging
from typing import Literal
from app.core.dictionary import CALLSIGNS_CONTROL, CALLSIGNS_TRAIN
logger = logging.getLogger("uvicorn.error")
SpeakerLabel = Literal["관제", "열차", "불명"]
# ── 시스템 프롬프트: 반드시 JSON 단답만 허용 ─────────────────────────────────
_SYS_PROMPT = (
"당신은 철도 무전 화자 분류기입니다. "
"입력된 무전 문장이 '관제사(관제)'의 발언인지 '기관사/차량(열차)'의 발언인지 판별하세요. "
"반드시 JSON 형태로만 답변하세요: "
'{"speaker": "관제"} 또는 {"speaker": "열차"} 또는 {"speaker": "불명"} '
"다른 텍스트, 설명, 생각 과정은 절대 포함하지 마세요. /no_think"
)
def _heuristic(text: str) -> SpeakerLabel | None:
"""
dictionary.py의 호출 부호 목록으로 빠른 1차 분류.
확실하지 않으면 None 반환 → 2단계 LLM 호출.
"""
for sign in CALLSIGNS_CONTROL:
if sign in text:
logger.debug(f"[SPKCLS] 관제 휴리스틱 히트: '{sign}'")
return "관제"
for sign in CALLSIGNS_TRAIN:
if sign in text:
logger.debug(f"[SPKCLS] 열차 휴리스틱 히트: '{sign}'")
return "열차"
# 정규식 추가 패턴: "XXX열차" (3~4자리 숫자 + 열차)
if re.search(r"\d{3,4}\s*열차", text):
return "열차"
return None
def classify_speaker(text: str, context: str = "") -> SpeakerLabel:
"""
무전 텍스트 1줄을 받아 화자를 분류한다.
1단계: 휴리스틱 (dictionary 호출 부호 + 정규식)
2단계: LLM JSON 단답 추론 (max_tokens=32, temperature=0.0)
3단계: 파싱 실패 시 "불명" 반환 — 절대 크래시 없음
Args:
text: 분류 대상 무전 문장
context: 직전 맥락 문장 (선택, LLM 정확도 향상)
Returns:
"관제" | "열차" | "불명"
"""
if not text or not text.strip():
return "불명"
# ── 1단계: 빠른 휴리스틱 ──────────────────────────────────────────────────
result = _heuristic(text)
if result is not None:
return result
# ── 2단계: LLM JSON 단답 추론 ────────────────────────────────────────────
try:
from app.services.llm_service import llm_service
model = llm_service._model
if model is None:
logger.debug("[SPKCLS] LLM 미활성 → 불명 반환")
return "불명"
ctx_line = f"맥락: {context}\n" if context.strip() else ""
user_msg = f"{ctx_line}무전: {text.strip()}"
prompt = (
f"<|im_start|>system\n{_SYS_PROMPT}<|im_end|>\n"
f"<|im_start|>user\n{user_msg}<|im_end|>\n"
f"<|im_start|>assistant\n"
)
out = model(
prompt,
max_tokens=32, # JSON 한 줄이므로 최소 토큰
temperature=0.0, # 결정론적 출력
stop=["<|im_end|>", "<|im_start|>", "\n\n"],
echo=False,
)
raw: str = out["choices"][0]["text"].strip()
# ... 블록 제거 (Qwen3 계열)
raw = re.sub(r".*?", "", raw, flags=re.DOTALL).strip()
logger.debug(f"[SPKCLS] LLM raw: {raw!r}")
# JSON 파싱: {"speaker": "관제"} 형태
m = re.search(r'\{[^{}]*"speaker"\s*:\s*"([^"]+)"[^{}]*\}', raw)
if m:
label = m.group(1).strip()
if label in ("관제", "열차", "불명"):
logger.debug(f"[SPKCLS] LLM 판별: '{text[:30]}' → {label}")
return label # type: ignore[return-value]
# 직접 단어 포함 Fallback
if "관제" in raw:
return "관제"
if "열차" in raw:
return "열차"
logger.warning(f"[SPKCLS] JSON 파싱 실패 → 불명 반환. raw={raw!r}")
return "불명"
except Exception as e:
logger.warning(f"[SPKCLS] LLM 호출 예외 → 불명 반환: {e}")
return "불명"