301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""
|
||
로컬 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()
|
||
|