HUTAMS_AUDIO/app/services/llm_service.py

301 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
로컬 LLM (llama-cpp-python) 서비스 모듈.
STT 변환 텍스트를 받아 제목, 요약, 키워드, 긴급도를 추출합니다.
[설정 방법]
1. pip install llama-cpp-python
2. .env 파일에 다음 설정 추가:
LLM_ENABLED=true
LLM_MODEL_PATH=D:/models/Qwen2.5-0.5B-Instruct-Q8_0.gguf
3. GGUF 모델 다운로드:
https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF
"""
import re
import logging
from typing import Optional
from app.core.config import settings
logger = logging.getLogger("uvicorn.error")
# LLM 라이브러리 조건부 임포트 (미설치 시 graceful 처리)
try:
from llama_cpp import Llama
_LLAMA_AVAILABLE = True
except ImportError:
Llama = None
_LLAMA_AVAILABLE = False
# LLM 분석 결과 반환 구조
class LLMMetadata:
def __init__(self, title: str = "", summary: str = "", keywords: str = "", urgency: str = "일반"):
self.title = title
self.summary = summary
self.keywords = keywords
self.urgency = urgency
# 프롬프트 템플릿 — Qwen3 계열의 think 태그를 끄는 /no_think 지시어 포함
_SYSTEM_PROMPT = "당신은 철도 관제 무전 분석 시스템입니다. 주어진 무전에서 지정된 포맷으로만 답변하세요. /no_think"
_USER_PROMPT_TEMPLATE = """다음은 철도 무전 내용입니다. 예시처럼 정확히 4줄로 요약하세요. 추가 설명이나 인사말은 절대 금지합니다.
[예시]
무전 내용: "신호질로 확인하고 통과하겠습니다 다대포해수욕장 분기기 확인."
제목: 다대포 분기기 통과
요약: 다대포해수욕장 분기기의 신호를 확인하고 통과함.
키워드: 다대포해수욕장,분기기,신호,통과
긴급도: 일반
[실제 분석 대상]
무전 내용: "{text}"
"""
class LocalLLMService:
"""
llama-cpp-python 기반 로컬 LLM 서비스.
LLM_ENABLED=false 이거나 모델 파일이 없으면 즉시 기본값을 반환합니다.
"""
_instance: Optional["LocalLLMService"] = None
_model = None
@classmethod
def get_instance(cls) -> "LocalLLMService":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
if not settings.LLM_ENABLED:
logger.info(" LLM_ENABLED=false — LLM 분석이 비활성화 상태입니다.")
return
if not _LLAMA_AVAILABLE:
logger.warning("⚠️ llama-cpp-python이 설치되어 있지 않습니다. 'pip install llama-cpp-python'")
return
if not settings.LLM_MODEL_PATH:
logger.warning("⚠️ LLM_MODEL_PATH가 설정되어 있지 않습니다. .env 파일을 확인하세요.")
return
try:
logger.info(f"🔄 LLM 모델 로딩 중 (GPU 우선): {settings.LLM_MODEL_PATH}")
self._model = Llama(
model_path=settings.LLM_MODEL_PATH,
n_ctx=2048,
n_threads=4,
n_gpu_layers=-1, # 전체 레이어를 GPU(CUDA)에 적재
verbose=False,
)
logger.info("✅ LLM 모델 로딩 완료 (GPU 가속)")
except Exception as gpu_err:
logger.warning(f"⚠️ GPU 초기화 실패 → CPU Fallback 시도: {gpu_err}")
try:
self._model = Llama(
model_path=settings.LLM_MODEL_PATH,
n_ctx=2048,
n_threads=4,
n_gpu_layers=0, # CPU only fallback
verbose=False,
)
logger.info("✅ LLM 모델 로딩 완료 (CPU 모드)")
except Exception as cpu_err:
logger.error(f"❌ LLM 모델 로딩 최종 실패: {cpu_err}")
self._model = None
def _parse_response(self, raw: str) -> LLMMetadata:
"""
LLM이 뱉어낸 텍스트를 정규식으로 안전하게 파싱합니다.
Qwen3의 <think>...</think> 태그를 먼저 제거한 뒤 파싱합니다.
파싱 실패 시 각 필드에 안전한 기본값을 사용합니다 (Fallback).
"""
# Qwen3 think 블록 제거
clean = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
def extract(pattern: str, default: str = "") -> str:
# 한글/영문 콜론 모두 허용, 줄 단위 탐색
m = re.search(pattern, clean, re.MULTILINE)
return m.group(1).strip() if m else default
title = extract(r"^제목\s*[:]\s*(.+)$", default="무전 기록")
summary = extract(r"^요약\s*[:]\s*(.+)$", default="")
keywords = extract(r"^키워드\s*[:]\s*(.+)$", default="")
urgency_raw = extract(r"^긴급도\s*[:]\s*(.+)$", default="일반")
urgency = "긴급" if "긴급" in urgency_raw else "일반"
logger.info(f"📝 LLM 파싱 결과 — 제목:{title} | 긴급도:{urgency} | 키워드:{keywords[:40] if keywords else '없음'}")
return LLMMetadata(title=title, summary=summary, keywords=keywords, urgency=urgency)
def generate_metadata(self, text: str) -> LLMMetadata:
"""
STT 텍스트를 LLM에 넣어 제목/요약/키워드/긴급도를 추출합니다.
LLM 비활성화 상태이거나 오류 발생 시 안전한 기본값을 반환합니다.
"""
if self._model is None:
return LLMMetadata() # 비활성화 또는 미설정: 기본값 반환
prompt = f"<|im_start|>system\n{_SYSTEM_PROMPT}<|im_end|>\n<|im_start|>user\n{_USER_PROMPT_TEMPLATE.format(text=text[:1200])}<|im_end|>\n<|im_start|>assistant\n제목:"
try:
output = self._model(
prompt,
max_tokens=256,
temperature=0.1, # 낮은 temperature로 일관된 포맷 출력 유도
stop=["<|im_end|>", "<|im_start|>"],
echo=False,
)
# 시작 문자열(제목:)을 수동으로 붙여줌 (echo=False이므로 출력에 미포함됨)
raw_text = "제목:" + output["choices"][0]["text"]
logger.info(f"✅ LLM 분석 완료 (raw 출력 {len(raw_text)}자)")
return self._parse_response(raw_text)
except Exception as e:
logger.error(f"⚠️ LLM 생성 실패 (Fallback 사용): {e}")
return LLMMetadata()
def guess_speaker_with_llm(self, context_text: str, current_text: str) -> str:
"""
문맥과 현재 문장을 비교하여 발화자가 관제인지 열차(모터카)인지 추론합니다.
가벼운 단답 추론 (관제 / 열차 / 미상)만 수행합니다.
"""
if self._model is None:
return "미상"
# 문맥이 없으면 '-'로 표시
ctx = context_text if context_text.strip() else "-"
# 간단한 1-Shot 지시문
spk_prompt = (
f"<|im_start|>system\n"
f"주어진 철도 무전의 화자를 '관제' 또는 '열차' 중 하나로만 답변하세요.<|im_end|>\n"
f"<|im_start|>user\n"
f"맥락: {ctx}\n무전: {current_text}\n결과:<|im_end|>\n"
f"<|im_start|>assistant\n"
f"화자:"
)
try:
out = self._model(
spk_prompt,
max_tokens=256,
temperature=0.1,
stop=["<|im_end|>", "<|im_start|>"],
echo=False,
)
raw_ans = out["choices"][0]["text"]
# Qwen3 think 블록 (혹은 생각 과정) 제거 후 실제 응답만 추출
import re
ans = re.sub(r"<think>.*?</think>", "", raw_ans, flags=re.DOTALL).strip()
logger.info(f"🎤 LLM 화자 판별 (raw): {len(raw_ans)}자 | Context: '{ctx}' | Current: '{current_text}' | Ans: '{ans}'")
# 안전하게 파싱
if "관제" in ans: return "관제"
if "열차" in ans or "모터카" in ans or "검측차" in ans or "검축차" in ans: return "열차"
return "미상"
except Exception:
return "미상"
def check_thread_continuation(self, context_text: str, current_text: str) -> bool:
"""
[Chapter 5.4] 대화 쓰레드(맥락) 연결 판별.
이전 무전(context_text)과 현재 무전(current_text)이 이어지는 상황인지 판별.
반환: True (이어짐) / False (새로운 호출)
실패 시 False가 아닌 True를 반환(Fallback)하여 불필요한 파편화를 방지.
"""
if self._model is None:
return True
# 이전 문맥이 아예 없으면 무조건 새로운 대화(False)
if not context_text or not context_text.strip():
return False
sys_prompt = (
"당신은 철도 무전 맥락 분석기입니다. "
"'이전 무전''현재 무전'이 같은 상황에서 이어지는 대화인지 판별하세요. "
"이어지면 {\"is_continuation\": true}, 새로운 호출이면 {\"is_continuation\": false} 로만 대답하세요. /no_think"
)
user_msg = f"이전 무전: {context_text.strip()}\n현재 무전: {current_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"
)
try:
out = self._model(
prompt,
max_tokens=32,
temperature=0.0,
stop=["<|im_end|>", "<|im_start|>", "\n\n"],
echo=False,
)
raw: str = out["choices"][0]["text"].strip()
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
logger.debug(f"[LLM Thread] raw 응답: {raw!r}")
import json
# JSON 파싱 정규식 시도
m = re.search(r'\{[^{}]*"is_continuation"\s*:\s*(true|false|True|False)[^{}]*\}', raw, re.IGNORECASE)
if m:
val = m.group(1).lower()
return val == "true"
# Fallback 텍스트 검사
if "true" in raw.lower(): return True
if "false" in raw.lower(): return False
logger.warning(f"⚠️ [LLM Thread] JSON 파싱 실패. Fallback=True 유지. raw={raw!r}")
return True
except Exception as e:
logger.warning(f"⚠️ [LLM Thread] LLM 호출 실패. Fallback=True 유지. {e}")
return True
def extract_keywords(self, text: str) -> list[str]:
"""
[Chapter 6.0] 지식 연동을 위한 키워드 추출기.
철도 무전에서 핵심 명사(역명, 열차 이름, 철도 장비 등)만 리스트로 반환합니다.
"""
if self._model is None:
return []
if not text or not text.strip():
return []
sys_prompt = (
"당신은 철도 무전 키워드 추출기입니다. "
"주어진 무전 내용 중에서 철도 장비, 역명, 지명, 열차 번호 등 가장 핵심적인 명사만 추출하세요. "
"반드시 JSON 형태로 답변하세요: {\"keywords\": [\"키워드1\", \"키워드2\"]} /no_think"
)
prompt = (
f"<|im_start|>system\n{sys_prompt}<|im_end|>\n"
f"<|im_start|>user\n{text.strip()}<|im_end|>\n"
f"<|im_start|>assistant\n"
)
try:
out = self._model(
prompt, max_tokens=64, temperature=0.1, stop=["<|im_end|>", "<|im_start|>"], echo=False
)
raw = out["choices"][0]["text"].strip()
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
import json
m = re.search(r'\{[^{}]*"keywords"\s*:\s*\[([^\]]*)\][^{}]*\}', raw, re.IGNORECASE)
if m:
kw_str = m.group(1)
keywords = [k.strip().strip('"\'') for k in kw_str.split(',') if k.strip().strip('"\'')]
return keywords
logger.warning(f"[LLM Keyword] 추출 실패 (JSON 파싱). raw= {raw}")
return []
except Exception as e:
logger.warning(f"[LLM Keyword] 추출 실패: {e}")
return []
# 싱글톤 인스턴스
llm_service = LocalLLMService.get_instance()