""" 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 "불명"