chore: Initialize modern python project with uv, docs, PWA, and test tools (Chapter 9.0)
This commit is contained in:
commit
02dc1717be
|
|
@ -0,0 +1,14 @@
|
|||
__pycache__/
|
||||
.env
|
||||
*.gguf
|
||||
*.bin
|
||||
*.pt
|
||||
models/
|
||||
whisper.db
|
||||
data/audio/
|
||||
data/samples/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
*.whl
|
||||
*.log
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
# HUTAMS — 지능형 무전 관제 플랫폼 아키텍처 문서
|
||||
|
||||
> **H**eavy-rail **U**nified **T**raffic **A**nalysis & **M**onitoring **S**ystem
|
||||
> LTE-R 기반 무전 통신을 실시간으로 수집·전사·분석하는 AI 관제 플랫폼
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [프로젝트 개요](#1-프로젝트-개요)
|
||||
2. [전체 파이프라인 다이어그램](#2-전체-파이프라인-다이어그램)
|
||||
3. [디렉토리 구조](#3-디렉토리-구조)
|
||||
4. [핵심 파이프라인 상세](#4-핵심-파이프라인-상세)
|
||||
5. [API 엔드포인트 스펙](#5-api-엔드포인트-스펙)
|
||||
6. [환경변수(.env) 설정법](#6-환경변수env-설정법)
|
||||
7. [서버 구동 & 운영 가이드](#7-서버-구동--운영-가이드)
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| **언어** | Python 3.11+ |
|
||||
| **웹 프레임워크** | FastAPI 0.115 + Uvicorn |
|
||||
| **STT 엔진** | faster-whisper (`large-v3-turbo`, INT8 양자화) |
|
||||
| **LLM** | Qwen3 0.8B GGUF (llama-cpp-python, GPU 가속) |
|
||||
| **DB** | SQLite (whisper.db via SQLAlchemy) |
|
||||
| **프론트엔드** | Vanilla JS + Bootstrap 5 |
|
||||
| **실시간 통신** | WebSocket (FastAPI native) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 파이프라인 다이어그램
|
||||
|
||||
```
|
||||
[ 오디오 소스 ]
|
||||
│ Line-in / 마이크 (mic 모드)
|
||||
│ 로컬 wav 파일 (mock 모드)
|
||||
↓
|
||||
[ RadioListener / MockAudioListener ] ← 백그라운드 데몬 스레드
|
||||
│ VAD (RMS 임계치 기반 음성구간 감지)
|
||||
│ 침묵 1.8초 지속 → 녹음 종료 → 임시 .wav 생성
|
||||
↓
|
||||
[ app/services/stt_service.py :: WhisperSTTService ]
|
||||
│ faster-whisper → full_text + segments[] 생성
|
||||
│ domain_dict.post_process_correction() (RapidFuzz 교정)
|
||||
│ HARDCODED_FIXES (신호질로→신호 진로 등)
|
||||
│ guess_speaker (룰 정규식 → LLM 체이닝 fallback)
|
||||
↓
|
||||
[ app/services/llm_service.py :: LocalLLMService ]
|
||||
│ Qwen3 0.8B GGUF
|
||||
│ 제목 / 요약 / 키워드 / 긴급도 추출
|
||||
│ _parse_response() — <think> 태그 제거 후 정규식 파싱
|
||||
↓
|
||||
[ SQLite (whisper.db) ]
|
||||
│ TranscriptionRecord — LLM 메타 포함
|
||||
│ TranscriptionSegment — 화자 뱃지 포함
|
||||
↓
|
||||
[ WebSocket ws_manager.broadcast() ]
|
||||
↓
|
||||
[ 브라우저 (index.html) ]
|
||||
├── Live Mode: 타자기 효과 즉시 렌더링
|
||||
└── Test Mode: 업로드 → REST POST → 동일 렌더링
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 디렉토리 구조
|
||||
|
||||
```
|
||||
Wisper/
|
||||
├── app/
|
||||
│ ├── core/
|
||||
│ │ ├── config.py # Pydantic Settings (환경변수 관리)
|
||||
│ │ ├── dictionary.py # 철도 도메인 사전 + 하드코딩 교정
|
||||
│ │ └── exceptions.py # 도메인 예외 클래스
|
||||
│ ├── db/
|
||||
│ │ ├── database.py # SQLAlchemy 엔진 + SessionLocal
|
||||
│ │ └── models.py # ORM 모델 (TranscriptionRecord, TranscriptionSegment)
|
||||
│ ├── models/
|
||||
│ │ ├── stt.py # STTRequest / STTResponse / STTSegment Pydantic 모델
|
||||
│ │ └── record.py # RecordListResponse 등 API 응답 모델
|
||||
│ ├── routers/
|
||||
│ │ └── records.py # GET /api/v1/records 라우터
|
||||
│ ├── services/
|
||||
│ │ ├── audio_listener.py # RadioListener (실제 마이크 VAD 감청)
|
||||
│ │ ├── audio_parser.py # pydub 포맷 변환 + 무음 제거
|
||||
│ │ ├── llm_service.py # LocalLLMService (Qwen3 GGUF 래퍼)
|
||||
│ │ ├── mock_audio_listener.py # MockAudioListener (파일 스트리밍 시뮬레이터)
|
||||
│ │ └── stt_service.py # WhisperSTTService + guess_speaker()
|
||||
│ ├── static/
|
||||
│ │ └── audio/ # 영구 저장된 무전 오디오 파일 (/static/audio/{filename})
|
||||
│ ├── templates/
|
||||
│ │ └── index.html # 대시보드 SPA (Bootstrap 5 + Vanilla JS)
|
||||
│ ├── main.py # FastAPI 앱 진입점 / lifespan / WS 매니저
|
||||
│ └── .env # 로컬 환경변수 오버라이드
|
||||
├── whisper.db # SQLite 데이터베이스
|
||||
└── ARCHITECTURE.md # 이 문서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 파이프라인 상세
|
||||
|
||||
### 4.1 VAD (음성 활동 감지)
|
||||
|
||||
| 파라미터 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `THRESHOLD` | 600 | RMS 볼륨 임계치 (0~32767 범위) |
|
||||
| `SILENCE_LIMIT` | 1.8s | 침묵 지속 시간 초과 시 발화 종료 판정 |
|
||||
| `PRE_ROLL_SECS` | 0.3s | 발화 시작 직전 버퍼 (첫 음절 잘림 방지) |
|
||||
| `RECORD_TIMEOUT` | 30s | 단일 녹음 최대 시간 (무한 루프 방지) |
|
||||
|
||||
### 4.2 화자 분리 (Speaker Diarization)
|
||||
|
||||
1차: **룰 기반 정규식** (`guess_speaker`)
|
||||
- "전철 OO" 패턴 → 관제
|
||||
- 숫자 + "열차" 패턴 → 열차
|
||||
- 짧은 응답어 + Gap ≥ 1.5s → 교차 할당 휴리스틱
|
||||
|
||||
2차: **LLM 체이닝 Fallback** (`guess_speaker_with_llm`)
|
||||
- 룰에서 "미상" 반환 시 Qwen3 0.8B 추론 (max_tokens=256)
|
||||
- `<think>` 블록 제거 후 "관제" / "열차" 키워드 파싱
|
||||
|
||||
### 4.3 LLM 메타데이터 추출
|
||||
|
||||
```
|
||||
system: "철도 관제 무전 분석 시스템. 지정된 포맷으로만 답변."
|
||||
user: 1-Shot 예시 → 실제 무전 텍스트
|
||||
format: 제목: ... / 요약: ... / 키워드: ... / 긴급도: 긴급|일반
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 엔드포인트 스펙
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | 대시보드 HTML |
|
||||
| `POST` | `/api/v1/transcribe` | 오디오 파일 업로드 → STT + LLM 분석 |
|
||||
| `GET` | `/api/v1/records` | 이력 목록 조회 (`?limit=50&skip=0&keyword=검색어`) |
|
||||
| `GET` | `/static/audio/{filename}` | 저장된 무전 오디오 스트리밍 |
|
||||
| `WS` | `/api/v1/ws/live` | 실시간 감청 결과 WebSocket 구독 |
|
||||
|
||||
### POST /api/v1/transcribe 요청 형식
|
||||
|
||||
```
|
||||
Content-Type: multipart/form-data
|
||||
Fields:
|
||||
audio : UploadFile (m4a, mp3, wav 등)
|
||||
language : str (기본: "ko")
|
||||
base_datetime: ISO8601 str (선택, 예: 2026-03-07T09:00:00+09:00)
|
||||
```
|
||||
|
||||
### WebSocket 수신 메시지 포맷
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stt_result",
|
||||
"text": "좌천 하선 궤도 검측차 신호 진로 확인...",
|
||||
"title": "좌천 하선 신호 진로 확인",
|
||||
"summary": "궤도 검측차가 신호 진로를 확인하고 통과함.",
|
||||
"keywords": "좌천 하선, 검측차, 신호, 통과",
|
||||
"urgency": "일반",
|
||||
"language": "ko",
|
||||
"segments": [
|
||||
{
|
||||
"start_sec": 8.11,
|
||||
"end_sec": 41.12,
|
||||
"text": "좌천 하선 있는 궤도 검측차는...",
|
||||
"speaker": "관제"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 환경변수(.env) 설정법
|
||||
|
||||
`app/.env` 파일(또는 프로젝트 루트 `.env`)에 아래 항목을 설정:
|
||||
|
||||
```ini
|
||||
# ─── Whisper ─────────────────────────────────────
|
||||
WHISPER_MODEL_NAME=large-v3-turbo
|
||||
|
||||
# ─── LLM ─────────────────────────────────────────
|
||||
LLM_ENABLED=true
|
||||
LLM_MODEL_PATH=./Qwen3.5-0.8B-Q4_K_M.gguf
|
||||
# LLM_ENABLED=false 로 설정 시 LLM 분석 전체 스킵
|
||||
|
||||
# ─── 실시간 감청 소스 ─────────────────────────────
|
||||
# "mock" : 로컬 파일 시뮬레이션 (개발/테스트용)
|
||||
# "mic" : 서버 PC 실제 마이크/Line-in (상용 운영용)
|
||||
AUDIO_SOURCE=mock
|
||||
MOCK_AUDIO_PATH=./sample1.m4a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 서버 구동 & 운영 가이드
|
||||
|
||||
### 개발 서버 기동
|
||||
|
||||
```powershell
|
||||
# 프로젝트 루트에서
|
||||
.\.venv\Scripts\uvicorn.exe app.main:app --host 127.0.0.1 --port 28000
|
||||
```
|
||||
|
||||
### Mock 시뮬레이션 모드 확인 절차
|
||||
|
||||
1. `.env`에서 `AUDIO_SOURCE=mock` 설정 (기본값)
|
||||
2. `MOCK_AUDIO_PATH=./sample1.m4a` 경로 확인
|
||||
3. 서버 기동 후 `http://127.0.0.1:28000` 접속
|
||||
4. 우측 상단 **Live Mode** 토글 ON
|
||||
5. 서버 로그에서 `🧪 [Mock] 발화 감지`, `📡 WebSocket broadcast` 확인
|
||||
6. 브라우저 우측 화면에 타자기 효과로 무전 내용이 실시간 렌더링됨
|
||||
|
||||
### 실제 마이크 감청 전환 방법
|
||||
|
||||
```ini
|
||||
# app/.env
|
||||
AUDIO_SOURCE=mic
|
||||
```
|
||||
|
||||
재기동 시 `🎙️ RadioListener(실마이크) 기동` 로그 확인 후,
|
||||
Line-in 단자에 무전 수신기 연결 → Live Mode 토글 ON으로 즉시 감청 시작.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# HUTAMS (AI 기반 전동차 운용관리 플랫폼 - 무전 STT 시스템)
|
||||
|
||||
본 프로젝트는 현장 철도 무전(LTE-R) 음성을 실시간으로 수집하고, Edge 단에서 AI(Faster-Whisper + 0.8B 온디바이스 LLM + RapidFuzz 결합)를 기반으로 고속 문맥 분석과 긴급 상황을 식별하는 STT 및 분석 시스템입니다.
|
||||
|
||||
## 핵심 기동 방법 (Vibe Coding with uv)
|
||||
|
||||
1. **사전 준비**: `uv` 설치 및 파이썬 패키지 세팅
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **서버 실행**:
|
||||
`run_server.ps1` 또는 아래 구문을 사용합니다.
|
||||
```bash
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 28000
|
||||
```
|
||||
|
||||
3. **테스트 도구**:
|
||||
현장 오디오 입력을 위해 USB 사운드카드로 Line-in을 받을 때 쓰는 샘플 스크립트입니다.
|
||||
```bash
|
||||
# 디바이스 목록 확인
|
||||
uv run python tools/record_sample.py --list-devices
|
||||
# 특정 인덱스로 녹음
|
||||
uv run python tools/record_sample.py --device 2 --duration 10
|
||||
```
|
||||
|
||||
4. **문서 구조**:
|
||||
- `docs/project_spec.md`: 모듈 기능 구조와 파이프라인 정비
|
||||
- `docs/api_contract.md`: REST API 및 WebSocket 명세서
|
||||
- `docs/issue.md`: 작업 현황과 롤링 이슈
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "HUTAMS STT Service"
|
||||
|
||||
# CPU/내장그래픽 구동을 위해 한국어 인식률이 극대화된 모델 지정
|
||||
WHISPER_MODEL_NAME: str = "large-v3-turbo"
|
||||
|
||||
# 로컬 LLM GGUF 모델 파일 경로 (.env에서 오버라이드 가능)
|
||||
LLM_MODEL_PATH: str = ""
|
||||
LLM_ENABLED: bool = False # False면 LLM 분석 스킵 (모델 미설정 시 안전 기본값)
|
||||
|
||||
# ─── 실시간 감청 모드 설정 ────────────────────────────────────────────────
|
||||
# "mock" : 로컬 wav 파일을 실시간처럼 스트리밍 (개발/테스트용)
|
||||
# "mic" : 서버 PC에 연결된 실제 마이크/Line-in 장치 감청 (상용 운영용)
|
||||
AUDIO_SOURCE: str = "mock"
|
||||
|
||||
# AUDIO_SOURCE="mock" 일 때 사용할 wav 파일 경로
|
||||
# ※ Whisper 최적 입력 포맷: 16kHz, 모노, 16-bit PCM
|
||||
MOCK_AUDIO_PATH: str = "./sample1.m4a"
|
||||
|
||||
# 지식 연동 제공자 ("mock", "csv", 또는 "rag")
|
||||
CONTEXT_PROVIDER: str = "csv"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
settings = Settings()
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import json
|
||||
import os
|
||||
import csv
|
||||
import logging
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
RAILWAY_TERMS_LIST: list[str] = []
|
||||
RAILWAY_TERMS_DICT: dict[str, list[dict]] = {}
|
||||
|
||||
def load_terms_from_csv(file_path: str):
|
||||
"""
|
||||
[Chapter 6.1] 대규모 도메인 사전(CSV) 안전 적재
|
||||
동음이의어(중복 키)를 허용하기 위해 List 형태로 Value를 저장하며,
|
||||
RapidFuzz 일괄 대조를 위한 중복 제거 리스트를 함께 생성합니다.
|
||||
"""
|
||||
global RAILWAY_TERMS_LIST, RAILWAY_TERMS_DICT
|
||||
RAILWAY_TERMS_LIST.clear()
|
||||
RAILWAY_TERMS_DICT.clear()
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"CSV 사전을 찾을 수 없습니다: {file_path}")
|
||||
|
||||
# 인코딩 Fallback: utf-8-sig 우선, 실패 시 cp949
|
||||
try:
|
||||
with open(file_path, mode="r", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
except UnicodeDecodeError:
|
||||
logger.warning(f"UTF-8-SIG 디코딩 실패. CP949로 재시도합니다: {file_path}")
|
||||
with open(file_path, mode="r", encoding="cp949") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
|
||||
unique_terms = set()
|
||||
count = 0
|
||||
|
||||
for row in rows:
|
||||
keyword = row.get("용어명", "").strip()
|
||||
desc = row.get("내용", "").strip()
|
||||
category = row.get("관련분야", "").strip()
|
||||
|
||||
if not keyword:
|
||||
continue
|
||||
|
||||
unique_terms.add(keyword)
|
||||
if keyword not in RAILWAY_TERMS_DICT:
|
||||
RAILWAY_TERMS_DICT[keyword] = []
|
||||
|
||||
RAILWAY_TERMS_DICT[keyword].append({
|
||||
"desc": desc,
|
||||
"category": category
|
||||
})
|
||||
count += 1
|
||||
|
||||
RAILWAY_TERMS_LIST.extend(list(unique_terms))
|
||||
logger.info(f"✅ 대규모 사전 로드 완료: 총 {count}행 처리, 매칭 대상 {len(RAILWAY_TERMS_LIST)}단어 (파일: {file_path})")
|
||||
|
||||
|
||||
# ── 화자 분류용 호출 부호 외부 정의 ──────────────────────────────────────────
|
||||
# speaker_classifier.py가 이 목록을 임포트하여 사용합니다.
|
||||
# 현장 호출 부호가 추가될 때 여기만 수정하면 전체 파이프라인에 반영됩니다.
|
||||
|
||||
CALLSIGNS_CONTROL: list[str] = [
|
||||
# ── 부산 도시철도 관제 호출 코드 ──
|
||||
"전철 보안", "전철 범일", "전철 호포", "전철 신평", "전철 관제", "전철 통제",
|
||||
"전철보안", "전철범일", "전철호포", "전철신평",
|
||||
# 관제사 특유 어구
|
||||
"진로 확인 부탁", "통과 허가", "신호진로 확인",
|
||||
]
|
||||
|
||||
CALLSIGNS_TRAIN: list[str] = [
|
||||
# ── 차량/열차 유형 ──
|
||||
"모터카", "전기 모터카", "신호 모터카", "검측차", "검축차", "궤도 검측차",
|
||||
# 열차 발화 특유 어구
|
||||
"출발 합니다", "출발하겠습니다", "통과 하겠습니다", "통과하겠습니다",
|
||||
"확인하고 통과", "신호 확인 후 통과",
|
||||
]
|
||||
|
||||
class DomainDictionary(BaseModel):
|
||||
stations: list[str] = [
|
||||
"다대포해수욕장", "다대포항", "낫개", "신장림", "장림", "동매", "신평", "하단", "당리", "사하", "괴정",
|
||||
"대티", "서대신", "동대신", "토성", "자갈치", "남포", "중앙", "노포", "범어사", "남산", "두실", "구서", "장전",
|
||||
"부산대", "온천장", "명륜", "동래", "교대", "연산", "시청", "양정", "부전", "서면", "범내골", "범일", "좌천", "부산진",
|
||||
"초량", "부산역"
|
||||
]
|
||||
railway_terms: list[str] = [
|
||||
"모터카", "분기기", "신호기", "궤도", "검축차", "하선", "상선", "입고", "출고", "무전", "수신", "양호",
|
||||
"신호 모터카", "전기 모터카", "궤도 검측차"
|
||||
]
|
||||
HARDCODED_FIXES: dict[str, str] = {
|
||||
"신호질로": "신호 진로",
|
||||
"멀티플": "멀티플 타이탬퍼"
|
||||
}
|
||||
|
||||
def get_prompt(self) -> str:
|
||||
"""STT 모델에 주입할 initial_prompt 문자열을 반환합니다."""
|
||||
return " ".join(self.stations + self.railway_terms)
|
||||
def post_process_correction(self, text: str, threshold: float = 85.0) -> str:
|
||||
"""
|
||||
[Chapter 6.1 최적화 알고리즘]
|
||||
RapidFuzz를 사용하여 문장 내 특정 어절들을 사전 단어와 비교 후
|
||||
오타로 판단되면 철도 전문 용어로 자동 교정합니다. (띄어쓰기 기준으로 토큰화)
|
||||
"""
|
||||
from rapidfuzz import process, fuzz
|
||||
# RAILWAY_TERMS_LIST가 비어있으면 기본 하드코딩을 쓰고, 있으면 합침
|
||||
all_terms = self.stations + self.railway_terms
|
||||
if RAILWAY_TERMS_LIST:
|
||||
all_terms.extend(RAILWAY_TERMS_LIST)
|
||||
|
||||
# 1. 하드코딩 교정 (단순 매칭/replace)
|
||||
for bad_word, good_word in self.HARDCODED_FIXES.items():
|
||||
text = text.replace(bad_word, good_word)
|
||||
|
||||
words = text.split()
|
||||
corrected_words = []
|
||||
|
||||
# 유효한 최적화를 위해 중복 제거
|
||||
unique_terms = list(set(all_terms))
|
||||
|
||||
for word in words:
|
||||
# 특수 기호 경계 지우기 (단순화)
|
||||
clean_word = "".join(c for c in word if c.isalnum())
|
||||
best_match = None
|
||||
|
||||
# 짧은 단어 무시 (길이 2 이상만 대조, CPU 최적화)
|
||||
if len(clean_word) >= 2 and unique_terms:
|
||||
match_result = process.extractOne(
|
||||
clean_word,
|
||||
unique_terms,
|
||||
scorer=fuzz.WRatio,
|
||||
score_cutoff=threshold
|
||||
)
|
||||
if match_result:
|
||||
best_match = match_result[0]
|
||||
|
||||
if best_match:
|
||||
corrected_words.append(best_match)
|
||||
else:
|
||||
corrected_words.append(word)
|
||||
|
||||
# 3. 추가 보정 (선택사항)
|
||||
for i, cw in enumerate(corrected_words):
|
||||
if "다대포" in cw and not cw.endswith("역") and not "해수욕" in cw:
|
||||
corrected_words[i] = cw + "역"
|
||||
|
||||
return " ".join(corrected_words)
|
||||
|
||||
# 전역 인스턴스
|
||||
domain_dict = DomainDictionary()
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
class STTError(Exception):
|
||||
"""STT 변환 중 발생하는 기본 예외"""
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
class AudioFileNotFoundError(STTError):
|
||||
"""오디오 파일을 찾을 수 없을 때 발생하는 예외"""
|
||||
def __init__(self, path: str):
|
||||
super().__init__(f"오디오 파일을 찾을 수 없습니다: {path}")
|
||||
|
||||
class ModelNotFoundError(STTError):
|
||||
"""Whisper 모델 바인딩이나 모델 파일을 찾을 수 없을 때 발생하는 예외"""
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
app/core/logging_config.py
|
||||
---------------------------
|
||||
HUTAMS 표준 로깅 설정 모듈.
|
||||
Uvicorn 로거에 통합하여 모든 서비스 레이어에서
|
||||
타임스탬프·레벨·모듈명이 포함된 상세 로그를 출력한다.
|
||||
"""
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
|
||||
# ── 로그 핸들러 & 포매터 설정 ──────────────────────────────────────────────────
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
# 개발용: 컬러 없이 시간·레벨·모듈·메시지 풀 포맷
|
||||
"detailed": {
|
||||
"format": "%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d — %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
# Uvicorn 접근 로그용 (기존 형식 유지)
|
||||
"access": {
|
||||
"()": "uvicorn.logging.AccessFormatter",
|
||||
"fmt": '%(levelprefix)s %(client_addr)s — "%(request_line)s" %(status_code)s',
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
# 터미널 출력 (UTF-8 강제 인코딩 — Windows cp949 이모지 크래시 방지)
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "detailed",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
# 파일 로그: 최대 10MB, 최대 5개 롤링
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "detailed",
|
||||
"filename": "hutams.log",
|
||||
"maxBytes": 10 * 1024 * 1024, # 10 MB
|
||||
"backupCount": 5,
|
||||
"encoding": "utf-8",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
# HUTAMS 자체 서비스 로거 — DEBUG부터 파일·콘솔 모두 출력
|
||||
"uvicorn.error": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""FastAPI 앱 시작 시 1회 호출하여 로깅 설정을 적용한다."""
|
||||
# Windows: stdout을 UTF-8로 재설정하여 이모지 인코딩 에러 방지
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
SQLAlchemy 엔진 및 세션 설정 모듈.
|
||||
프로젝트 루트 디렉토리에 whisper.db SQLite 파일을 생성합니다.
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
# 프로젝트 루트 기준 DB 경로
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATABASE_URL = f"sqlite:///{os.path.join(BASE_DIR, 'whisper.db')}"
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}, # SQLite + 멀티스레드 허용
|
||||
echo=False # SQL 쿼리 로깅: 필요 시 True로 변경
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
def get_db():
|
||||
"""FastAPI Depends용 DB 세션 제공 함수 (컨텍스트 매니저)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def init_db():
|
||||
"""모든 테이블을 생성 (최초 1회 실행)"""
|
||||
# models.py를 임포트하여 Base 메타데이터에 테이블 등록
|
||||
from app.db import models # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
SQLAlchemy ORM 모델 정의 모듈.
|
||||
TranscriptionRecord (1) : (N) TranscriptionSegment 관계를 구성합니다.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class TranscriptionRecord(Base):
|
||||
"""STT 변환 1건 전체 기록 테이블"""
|
||||
__tablename__ = "transcription_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
filename = Column(String(512), nullable=False, comment="원본 업로드 파일명")
|
||||
full_text = Column(Text, nullable=True, comment="전체 변환 텍스트")
|
||||
language = Column(String(16), nullable=True, comment="인식된 언어 코드")
|
||||
base_datetime = Column(DateTime(timezone=True), nullable=True, comment="녹음 시작 절대 시간(KST)")
|
||||
processing_time_sec = Column(Float, nullable=True, comment="STT 처리 소요 시간 (초)")
|
||||
audio_duration_sec = Column(Float, nullable=True, comment="오디오 총 길이 (초)")
|
||||
peak_memory_mb = Column(Float, nullable=True, comment="최대 메모리 점유율 (MB)")
|
||||
process_speed_x = Column(Float, nullable=True, comment="처리 속도 배수")
|
||||
# ── LLM 분석 결과 컬럼 ───────────────────────────────────────────────────
|
||||
title = Column(String(256), nullable=True, comment="LLM 생성 제목")
|
||||
summary = Column(Text, nullable=True, comment="LLM 생성 1줄 요약")
|
||||
keywords = Column(String(512), nullable=True, comment="LLM 생성 키워드 (쉼표 구분)")
|
||||
urgency = Column(String(16), nullable=True, comment="LLM 판단 긴급도 (긴급/일반)")
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
comment="레코드 생성 시각 (UTC)"
|
||||
)
|
||||
|
||||
# 1:N 관계
|
||||
segments = relationship(
|
||||
"TranscriptionSegment",
|
||||
back_populates="record",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select"
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionSegment(Base):
|
||||
"""STT 변환 세그먼트(발화 단위) 테이블"""
|
||||
__tablename__ = "transcription_segments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
record_id = Column(Integer, ForeignKey("transcription_records.id", ondelete="CASCADE"), nullable=False)
|
||||
start_sec = Column(Float, nullable=False, comment="발화 시작 시간 (초)")
|
||||
end_sec = Column(Float, nullable=False, comment="발화 종료 시간 (초)")
|
||||
text = Column(Text, nullable=True, comment="교정된 발화 텍스트")
|
||||
speaker = Column(String(32), nullable=True, comment="발화자 식별 결과")
|
||||
absolute_start_time = Column(String(64), nullable=True, comment="ISO 8601 절대 시작 시간")
|
||||
absolute_end_time = Column(String(64), nullable=True, comment="ISO 8601 절대 종료 시간")
|
||||
audio_path = Column(String(512), nullable=True, comment="Opus 압축본 로컬 경로")
|
||||
is_reviewed = Column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="수동 검토 완료 여부 (불명 세그먼트 후처리 추적용)"
|
||||
)
|
||||
|
||||
# N:1 역참조
|
||||
record = relationship("TranscriptionRecord", back_populates="segments")
|
||||
|
|
@ -0,0 +1,564 @@
|
|||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Depends, UploadFile, File, Form, HTTPException, BackgroundTasks, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import shutil
|
||||
import tempfile
|
||||
import os
|
||||
import asyncio
|
||||
import imageio_ffmpeg
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
# ── 로깅 최우선 초기화 (Windows UTF-8 stdout, 파일 롤링 포함) ──────────────
|
||||
from app.core.logging_config import setup_logging
|
||||
setup_logging()
|
||||
|
||||
os.environ["PATH"] += os.pathsep + os.path.dirname(imageio_ffmpeg.get_ffmpeg_exe())
|
||||
|
||||
from pydub import AudioSegment
|
||||
AudioSegment.converter = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import STTError, ModelNotFoundError, AudioFileNotFoundError
|
||||
from app.models.stt import STTRequest, STTResponse
|
||||
from app.services.stt_service import WhisperSTTService
|
||||
from app.services.audio_listener import RadioListener
|
||||
from app.db.database import init_db, SessionLocal
|
||||
from app.db.models import TranscriptionRecord, TranscriptionSegment
|
||||
from app.routers import records as records_router
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# ─── 전역 감청 인스턴스 (선언만, lifespan에서 초기화)
|
||||
_radio_listener: RadioListener | None = None
|
||||
|
||||
# ─── LLM 백그라운드 워커 큐 ─────────────────────────────────────────────────
|
||||
# STT 완료 직후 즉시 큐에 넣고 응답을 반환 → 워커가 비동기로 화자 재분류
|
||||
# asyncio.Queue는 서버 시작 후 event loop가 준비된 뒤 생성해야 하므로
|
||||
# lifespan 내부에서 초기화됩니다.
|
||||
llm_task_queue: asyncio.Queue | None = None
|
||||
|
||||
# ─── 앱 수명주기: DB 초기화 + RadioListener 구동 ─────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global _radio_listener, llm_task_queue
|
||||
|
||||
# (1) DB 초기화
|
||||
logger.info("HUTAMS STT Service 시작 - DB 초기화 중...")
|
||||
init_db()
|
||||
logger.info("DB 초기화 완료 (whisper.db)")
|
||||
|
||||
# (2) 대규모 도메인 사전(CSV) 로드 (Lifespan 기동 중 안전 장치)
|
||||
try:
|
||||
from app.core.dictionary import load_terms_from_csv
|
||||
load_terms_from_csv("bs_20240810.csv")
|
||||
except Exception as e:
|
||||
logger.warning(f"대규모 용어 사전 적재 실패 (서버 기동 유지): {e}")
|
||||
|
||||
# (3) LLM 워커 큐 초기화 (event loop가 준비된 뒤)
|
||||
llm_task_queue = asyncio.Queue()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# [생존형 LLM 백그라운드 워커]
|
||||
# while True 루프 바깥 예외는 워커 자체를 죽이는데,
|
||||
# while True 안쪽 try/except 가 모든 개별 작업 예외를 흡수하므로
|
||||
# 특정 세그먼트 LLM 실패가 워커를 절대로 죽이지 않는다.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
async def llm_worker_loop():
|
||||
logger.info("[LLM Worker] 생존형 백그라운드 워커 시작 (대화 쓰레드 판별/화자 분류)")
|
||||
while True:
|
||||
task = None
|
||||
try:
|
||||
task = await llm_task_queue.get()
|
||||
record_id: int = task.get("record_id")
|
||||
seg_idx: int = task.get("seg_idx", 0)
|
||||
text: str = task.get("text", "")
|
||||
context: str = task.get("context", "")
|
||||
|
||||
# ── 1. 첫 번째 DB 세션: 필요한 데이터만 미리 조회 (LLM 중 블로킹 방지) ──
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from sqlalchemy import select as _select, asc as _asc, desc as _desc, func as _func
|
||||
target_seg = db.scalars(
|
||||
_select(TranscriptionSegment)
|
||||
.where(TranscriptionSegment.record_id == record_id)
|
||||
.order_by(_asc(TranscriptionSegment.id))
|
||||
.offset(seg_idx).limit(1)
|
||||
).first()
|
||||
|
||||
if not target_seg:
|
||||
logger.debug(f"[LLM Worker] 스킵: record={record_id}[{seg_idx}] 미존재")
|
||||
continue
|
||||
|
||||
t_seg_id = target_seg.id
|
||||
t_text = target_seg.text or ""
|
||||
t_speaker = target_seg.speaker
|
||||
t_start_iso = target_seg.absolute_start_time
|
||||
t_rec_id = target_seg.record_id
|
||||
|
||||
prev_seg = db.scalars(
|
||||
_select(TranscriptionSegment)
|
||||
.where(TranscriptionSegment.id < t_seg_id)
|
||||
.order_by(_desc(TranscriptionSegment.id))
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
p_seg_id = prev_seg.id if prev_seg else None
|
||||
p_text = prev_seg.text or "" if prev_seg else ""
|
||||
p_end_iso = prev_seg.absolute_end_time if prev_seg else None
|
||||
p_rec_id = prev_seg.record_id if prev_seg else -1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── 2. LLM 추론 구간 (DB 연결 해제 상태, 비동기 스레드 풀) ──
|
||||
from app.services.speaker_classifier import classify_speaker
|
||||
from app.services.llm_service import llm_service
|
||||
|
||||
# a) 화자 판별
|
||||
speaker_result = await asyncio.get_event_loop().run_in_executor(
|
||||
None, classify_speaker, text, context
|
||||
)
|
||||
|
||||
# b) 쓰레드(맥락) 판별 (30초 하드 컷오프 포함)
|
||||
is_continuation = False
|
||||
if p_seg_id is not None:
|
||||
def _parse_ts(iso_str):
|
||||
if not iso_str: return 0.0
|
||||
try:
|
||||
# Python 3.11에서는 Z 접미사가 포함된 ISO 8601도 파싱되지만,
|
||||
# 안전을 위해 replace 활용
|
||||
return datetime.fromisoformat(iso_str.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
gap = _parse_ts(t_start_iso) - _parse_ts(p_end_iso)
|
||||
if gap >= 30.0:
|
||||
logger.info(f"[LLM Thread] 30초 이상 Gap ({gap:.1f}s) → 무조건 단절(False)")
|
||||
is_continuation = False
|
||||
else:
|
||||
is_continuation = await asyncio.get_event_loop().run_in_executor(
|
||||
None, llm_service.check_thread_continuation, p_text, t_text
|
||||
)
|
||||
logger.info(f"[LLM Thread] LLM 이어짐 판별: {is_continuation} (gap={gap:.1f}s)")
|
||||
|
||||
# ── 3. 두 번째 DB 세션: 결과 갱신 및 상태 반영 ──
|
||||
db = SessionLocal()
|
||||
try:
|
||||
target_seg = db.get(TranscriptionSegment, t_seg_id)
|
||||
if not target_seg: continue
|
||||
|
||||
if target_seg.speaker in ("불명", None, ""):
|
||||
target_seg.speaker = speaker_result
|
||||
|
||||
action = "new"
|
||||
updated_record_id = target_seg.record_id
|
||||
|
||||
if is_continuation:
|
||||
# 이전 대화로 병합 (Append)
|
||||
target_seg.record_id = p_rec_id
|
||||
parent_record = db.get(TranscriptionRecord, p_rec_id)
|
||||
if parent_record:
|
||||
parent_record.full_text = f"{parent_record.full_text} {t_text}".strip()
|
||||
updated_record_id = p_rec_id
|
||||
action = "append"
|
||||
|
||||
# 이전 Record가 완전히 비게 되었다면 껍데기 삭제 (Clean-up)
|
||||
db.flush()
|
||||
remaining = db.scalar(
|
||||
_select(_func.count(TranscriptionSegment.id))
|
||||
.where(TranscriptionSegment.record_id == t_rec_id)
|
||||
)
|
||||
if remaining == 0:
|
||||
old_record = db.get(TranscriptionRecord, t_rec_id)
|
||||
if old_record:
|
||||
db.delete(old_record)
|
||||
else:
|
||||
# 독립 대화 (New)
|
||||
# 이미 _save_to_db가 독립 레코드로 만들었지만,
|
||||
# 한 STTResponse(같은 파일) 내에서 맥락이 단절된 경우엔 강제로 분리한다.
|
||||
if target_seg.record_id == p_rec_id:
|
||||
target_rec = db.get(TranscriptionRecord, t_rec_id)
|
||||
new_record = TranscriptionRecord(
|
||||
filename=target_rec.filename if target_rec else "thread_split",
|
||||
full_text=t_text,
|
||||
language="ko",
|
||||
base_datetime=datetime.now(),
|
||||
processing_time_sec=0, audio_duration_sec=0, peak_memory_mb=0, process_speed_x=0
|
||||
)
|
||||
db.add(new_record)
|
||||
db.flush()
|
||||
target_seg.record_id = new_record.id
|
||||
updated_record_id = new_record.id
|
||||
|
||||
db.commit()
|
||||
|
||||
# ── 4. WebSocket Broadcast: 업데이트 내역 전송 ──
|
||||
await ws_manager.broadcast({
|
||||
"type": "thread_updated",
|
||||
"data": {
|
||||
"record_id": updated_record_id,
|
||||
"action": action,
|
||||
"segment_id": t_seg_id,
|
||||
"speaker": speaker_result
|
||||
}
|
||||
})
|
||||
# ── 5. 키워드 추출 및 지식 연결 (Context Provider) ──
|
||||
from app.services.context_service import context_service
|
||||
keywords = await asyncio.get_event_loop().run_in_executor(
|
||||
None, llm_service.extract_keywords, t_text
|
||||
)
|
||||
|
||||
if keywords:
|
||||
logger.info(f"[LLM Worker] 키워드 추출 성공: {keywords}")
|
||||
contexts = await asyncio.get_event_loop().run_in_executor(
|
||||
None, context_service.get_extended_context, keywords
|
||||
)
|
||||
if contexts:
|
||||
logger.info(f"[LLM Worker] 지식 발견: {len(contexts)}건")
|
||||
await ws_manager.broadcast({
|
||||
"type": "context_discovered",
|
||||
"data": {
|
||||
"record_id": updated_record_id,
|
||||
"contexts": contexts
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[LLM Worker] 작업 처리 중 예외 발생 (생존 방어): {exc!r}")
|
||||
finally:
|
||||
if task is not None:
|
||||
llm_task_queue.task_done()
|
||||
|
||||
# [Chapter 8.0] 심각도 기반 차등 보관 태스크 (24시간 주기 실행)
|
||||
async def audio_cleanup_loop():
|
||||
logger.info("[Cleanup Worker] 차등 삭제 스케줄러 기동 (3일 초과 일반 오디오 삭제)")
|
||||
while True:
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_time = datetime.now() - timedelta(days=3)
|
||||
|
||||
target_segments = db.query(TranscriptionSegment).join(TranscriptionRecord).filter(
|
||||
TranscriptionRecord.created_at < cutoff_time,
|
||||
TranscriptionRecord.urgency != "긴급",
|
||||
TranscriptionSegment.audio_path.isnot(None)
|
||||
).all()
|
||||
|
||||
for seg in target_segments:
|
||||
if seg.audio_path and os.path.exists(seg.audio_path):
|
||||
os.remove(seg.audio_path)
|
||||
logger.info(f"[Cleanup] 파일 삭제됨: {seg.audio_path}")
|
||||
seg.audio_path = None
|
||||
|
||||
if target_segments:
|
||||
db.commit()
|
||||
logger.info(f"[Cleanup] {len(target_segments)}개 오래된 일반 오디오 경로 DB NULL 처리 완료")
|
||||
except Exception as e:
|
||||
logger.error(f"[Cleanup] 스케줄러 오류: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
await asyncio.sleep(24 * 3600) # 24시간마다 실행
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[Cleanup] 스케줄러 외부 오류: {e}")
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
# 워커 태스크 기동 (FastAPI lifespan 내에서 asyncio Task로 등록)
|
||||
worker_task = asyncio.create_task(llm_worker_loop())
|
||||
cleanup_task = asyncio.create_task(audio_cleanup_loop())
|
||||
logger.info("[LLM Worker & Cleanup] asyncio Task 등록 완료")
|
||||
|
||||
# (3) 실시간 on_segment 콜백 정의
|
||||
async def on_segment(wav_path: str):
|
||||
"""RadioListener 가 wav 파일을 완성하면 호출되는 비동기 콜백."""
|
||||
try:
|
||||
svc = WhisperSTTService()
|
||||
req = STTRequest(
|
||||
audio_file_path=wav_path,
|
||||
language="ko",
|
||||
base_datetime=datetime.now()
|
||||
)
|
||||
resp = svc.transcribe(req)
|
||||
|
||||
# DB 저장: 모든 세그먼트 speaker="불명" 으로 즉시 저장
|
||||
saved_filename = os.path.basename(wav_path)
|
||||
record_id = _save_to_db_sync(saved_filename, resp, None)
|
||||
|
||||
# WebSocket broadcast (STT 완료 즉시, LLM 기다리지 않음)
|
||||
broadcast_data = resp.model_dump()
|
||||
broadcast_data["type"] = "stt_result"
|
||||
broadcast_data["record_id"] = record_id # 워커(Thread Check) 결과 매핑용
|
||||
|
||||
# DB insert된 segment들의 id도 프론트엔드로 전달
|
||||
# (_save_to_db_sync가 삽입한 target_seg.id를 알 수 없으나,
|
||||
# 여기서는 단순화를 위해 STTResponse만 보냄. 프론트에서 segment 인덱스를 활용하거나 생략)
|
||||
await ws_manager.broadcast(broadcast_data)
|
||||
logger.info(f"WebSocket broadcast 완료 ({len(ws_manager.active_connections)}개 클라이언트)")
|
||||
|
||||
# ── LLM 화자 재분류 작업을 큐에 넣기 (put_nowait: 블로킹 없음) ──
|
||||
if llm_task_queue is not None and record_id is not None:
|
||||
text_parts = [seg.text or "" for seg in resp.segments]
|
||||
for i, seg_data in enumerate(resp.segments):
|
||||
context = " ".join(text_parts[max(0, i-2):i])
|
||||
# seg_id를 얻기 위해 DB에서 record_id로 조회 (lazy)
|
||||
llm_task_queue.put_nowait({
|
||||
"seg_id": None, # 번호 모름 → 워커에서 record_id로 탐색
|
||||
"record_id": record_id,
|
||||
"seg_idx": i,
|
||||
"text": seg_data.text or "",
|
||||
"context": context,
|
||||
})
|
||||
logger.debug(f"[on_segment] LLM 큐 등록: {len(resp.segments)}건")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"on_segment 파이프라인 오류: {e}")
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# (4) 감청 모듈 분기 기동 (AUDIO_SOURCE 설정에 따라)
|
||||
loop = asyncio.get_event_loop()
|
||||
source = settings.AUDIO_SOURCE.strip().lower()
|
||||
|
||||
if source == "mock":
|
||||
from app.services.mock_audio_listener import MockAudioListener
|
||||
mock_path = settings.MOCK_AUDIO_PATH
|
||||
logger.info(f"AUDIO_SOURCE=mock → MockAudioListener 기동 ({mock_path})")
|
||||
_radio_listener = MockAudioListener(
|
||||
on_segment=on_segment, loop=loop, wav_path=mock_path, loop_playback=True
|
||||
)
|
||||
else:
|
||||
logger.info("AUDIO_SOURCE=mic → RadioListener(실마이크) 기동")
|
||||
_radio_listener = RadioListener(on_segment=on_segment, loop=loop)
|
||||
|
||||
_radio_listener.start()
|
||||
|
||||
yield # ← 서버 서비스 제공 구간
|
||||
|
||||
# (5) 종료 시 안전 해제
|
||||
if _radio_listener:
|
||||
_radio_listener.stop()
|
||||
worker_task.cancel() # 워커 태스크 취소
|
||||
cleanup_task.cancel() # 정리 태스크 취소
|
||||
logger.info("HUTAMS STT Service 종료")
|
||||
|
||||
# FastAPI Application 객체 생성
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
description="현업 LTE-R 무전 감청 및 실시간 분석용 STT API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# DI를 위한 의존성 주입 객체
|
||||
def get_stt_service() -> WhisperSTTService:
|
||||
return WhisperSTTService()
|
||||
|
||||
# 정적 파일 및 템플릿 설정
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
templates = Jinja2Templates(directory=os.path.join(_HERE, "templates"))
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(_HERE, "static")), name="static")
|
||||
|
||||
# 라우터 등록
|
||||
app.include_router(records_router.router)
|
||||
|
||||
# ─── 대시보드 페이지 ───────────────────────────────────────────────────────────
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def dashboard(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
def _save_to_db(filename: str, response: STTResponse, base_datetime: Optional[datetime]) -> Optional[int]:
|
||||
"""
|
||||
STT 결과를 SQLite DB에 Insert.
|
||||
반환: 저장된 record.id (실패 시 None) — on_segment 에서 LLM 큐 등록에 활용.
|
||||
세그먼트 speaker 는 즉시 '불명' 으로 저장하고, 워커가 나중에 업데이트.
|
||||
"""
|
||||
db = None
|
||||
try:
|
||||
from app.services.llm_service import llm_service
|
||||
meta = llm_service.generate_metadata(response.text)
|
||||
|
||||
db = SessionLocal()
|
||||
record = TranscriptionRecord(
|
||||
filename=filename,
|
||||
full_text=response.text,
|
||||
language=response.language,
|
||||
base_datetime=base_datetime,
|
||||
processing_time_sec=response.processing_time_sec,
|
||||
audio_duration_sec=response.audio_duration_sec,
|
||||
peak_memory_mb=response.peak_memory_mb,
|
||||
process_speed_x=response.process_speed_x,
|
||||
title=meta.title or None,
|
||||
summary=meta.summary or None,
|
||||
keywords=meta.keywords or None,
|
||||
urgency=meta.urgency or None,
|
||||
)
|
||||
db.add(record)
|
||||
db.flush() # record.id 확보
|
||||
|
||||
for seg in response.segments:
|
||||
db.add(TranscriptionSegment(
|
||||
record_id=record.id,
|
||||
start_sec=seg.start_sec,
|
||||
end_sec=seg.end_sec,
|
||||
text=seg.text,
|
||||
speaker="불명", # 즉시 저장 기본값 — 워커가 나중에 업데이트
|
||||
absolute_start_time=seg.absolute_start_time,
|
||||
absolute_end_time=seg.absolute_end_time,
|
||||
audio_path=seg.audio_path,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
|
||||
# [Chapter 8.0] 생성된 세그먼트 ID를 응답 객체에 주입하여 프론트엔드 오디오 재생 뱃지와 매핑
|
||||
for idx, seg in enumerate(response.segments):
|
||||
if hasattr(TranscriptionSegment, 'id'):
|
||||
# SQLAlchemy에 의해 방금 부여된 ID
|
||||
db_seg = db.query(TranscriptionSegment).filter_by(record_id=record.id).order_by(TranscriptionSegment.id).offset(idx).first()
|
||||
if db_seg:
|
||||
# Pydantic 모델에 여분 필드(extra)로 id 주입
|
||||
seg.id = db_seg.id
|
||||
|
||||
record_id = record.id
|
||||
logger.info(f"DB 저장 완료 (record_id={record_id}, filename={filename})")
|
||||
return record_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DB 저장 실패 (Soft-Fail): {e}")
|
||||
try:
|
||||
if db:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
if db:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 동기 별칭
|
||||
_save_to_db_sync = _save_to_db
|
||||
|
||||
|
||||
|
||||
@app.post("/api/v1/transcribe", response_model=STTResponse, summary="오디오 파일 STT 변환")
|
||||
async def transcribe_audio_file(
|
||||
background_tasks: BackgroundTasks,
|
||||
audio: UploadFile = File(..., description="분석할 무전 오디오 파일 (m4a, mp3, mp4, wav 등)"),
|
||||
language: str = Form("ko", description="오디오 언어 코드 (기본: ko)"),
|
||||
base_datetime: Optional[datetime] = Form(None, description="녹음 시작 절대 시간 (ISO 8601, 예: 2024-03-06T12:00:00+09:00)"),
|
||||
stt_service: WhisperSTTService = Depends(get_stt_service)
|
||||
):
|
||||
"""
|
||||
클라이언트로부터 오디오 파일을 업로드 받아 STT 엔진에 돌린 후,
|
||||
Pydantic Model 통일 규격의 결과값 반환. 처리 후 결과는 DB에도 자동 적재됨.
|
||||
"""
|
||||
tmp_path = ""
|
||||
saved_filename = ""
|
||||
try:
|
||||
import time
|
||||
# 1. 파일을 고유한 이름으로 static/audio 폴더에 영구 저장 (timestamp 활용)
|
||||
safe_name = audio.filename.replace(" ", "_")
|
||||
timestamp = int(time.time())
|
||||
saved_filename = f"{timestamp}_{safe_name}"
|
||||
save_dir = os.path.join(_HERE, "static", "audio")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
tmp_path = os.path.join(save_dir, saved_filename)
|
||||
with open(tmp_path, "wb") as buffer:
|
||||
shutil.copyfileobj(audio.file, buffer)
|
||||
|
||||
# 2. Controller -> Model 데이터 생성
|
||||
request_dto = STTRequest(
|
||||
audio_file_path=tmp_path,
|
||||
language=language,
|
||||
base_datetime=base_datetime
|
||||
)
|
||||
|
||||
# 3. 비즈니스 로직(Service) 호출
|
||||
response_dto = stt_service.transcribe(request_dto)
|
||||
|
||||
# 4. 백그라운드로 DB 저장 (Soft-Fail: DB 에러가 응답을 막지 않음)
|
||||
# 이제 임시 파일명이 아닌 실제 저장된 파일명을 DB에 저장합니다.
|
||||
background_tasks.add_task(_save_to_db, saved_filename, response_dto, base_datetime)
|
||||
|
||||
return response_dto
|
||||
|
||||
except ModelNotFoundError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
except AudioFileNotFoundError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except STTError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
# finally 블록에서 os.remove(tmp_path)를 호출하던 부분을 제거하여
|
||||
# 오디오 파일이 static 폴더에 보존되도록 합니다.
|
||||
|
||||
# ─── WebSocket 매니저 및 라우터 (실시간 감청용) ───────────────────────────────
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ws_manager = ConnectionManager()
|
||||
|
||||
# 마지막으로 broadcast된 데이터를 캐시 (뒤늦게 연결한 클라이언트에게 즉시 전송)
|
||||
_last_broadcast: dict | None = None
|
||||
|
||||
@app.websocket("/api/v1/ws/live")
|
||||
async def websocket_live_endpoint(websocket: WebSocket):
|
||||
global _last_broadcast
|
||||
await ws_manager.connect(websocket)
|
||||
try:
|
||||
# 연결 성공 알림
|
||||
await websocket.send_json({"type": "info", "message": "WebSocket Connected."})
|
||||
|
||||
# Late-join 지원: 연결 즉시 DB에서 최근 레코드 1건을 push
|
||||
# (사용자가 Live Mode를 켰을 때 화면이 공백으로 보이는 문제 해결)
|
||||
try:
|
||||
db = SessionLocal()
|
||||
from sqlalchemy.orm import joinedload as _jl
|
||||
latest = db.query(TranscriptionRecord).options(
|
||||
_jl(TranscriptionRecord.segments)
|
||||
).order_by(TranscriptionRecord.id.desc()).first()
|
||||
if latest:
|
||||
from app.models.record import RecordListResponse
|
||||
payload = RecordListResponse.model_validate(latest).model_dump(mode="json")
|
||||
payload["type"] = "stt_result"
|
||||
payload["late_join"] = True
|
||||
await websocket.send_json(payload)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Late-join push 실패 (무시): {e}")
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect(websocket)
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"""
|
||||
STT 이력 조회 라우터 모듈.
|
||||
GET /api/v1/records — 필터링·페이징·N+1 방지 Eager Loading 적용
|
||||
GET /api/v1/segments/daily — 일자별 세그먼트 채팅뷰 API (Cursor 페이지네이션)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import select, func, desc, asc
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import TranscriptionRecord, TranscriptionSegment
|
||||
from app.models.record import RecordListResponse, RecordPageResponse, DailySegmentResponse, DailySegmentPage
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["기록 조회"])
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/records",
|
||||
response_model=RecordPageResponse,
|
||||
summary="STT 변환 이력 목록 조회",
|
||||
description=(
|
||||
"저장된 STT 변환 기록을 최신순으로 조회합니다. "
|
||||
"keyword·start_date·end_date 파라미터로 필터링하며 "
|
||||
"skip/limit으로 페이징할 수 있습니다."
|
||||
)
|
||||
)
|
||||
def get_records(
|
||||
skip: int = Query(default=0, ge=0, description="조회 시작 오프셋"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="한 페이지 최대 건수 (최대 200)"),
|
||||
keyword: Optional[str] = Query(default=None, description="full_text 에 포함된 검색 키워드"),
|
||||
start_date: Optional[datetime] = Query(default=None, description="base_datetime 시작 (ISO 8601)"),
|
||||
end_date: Optional[datetime] = Query(default=None, description="base_datetime 종료 (ISO 8601)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
N+1 방지: joinedload(TranscriptionRecord.segments) 로
|
||||
단 1번의 JOIN 쿼리로 Record + Segment를 모두 가져옵니다.
|
||||
"""
|
||||
filters = []
|
||||
if keyword:
|
||||
filters.append(TranscriptionRecord.full_text.like(f"%{keyword}%"))
|
||||
if start_date:
|
||||
filters.append(TranscriptionRecord.base_datetime >= start_date)
|
||||
if end_date:
|
||||
filters.append(TranscriptionRecord.base_datetime <= end_date)
|
||||
|
||||
count_stmt = select(func.count(TranscriptionRecord.id)).where(*filters)
|
||||
total: int = db.scalar(count_stmt) or 0
|
||||
|
||||
data_stmt = (
|
||||
select(TranscriptionRecord)
|
||||
.where(*filters)
|
||||
.options(joinedload(TranscriptionRecord.segments))
|
||||
.order_by(desc(TranscriptionRecord.id))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
records = db.scalars(data_stmt).unique().all()
|
||||
logger.debug(f"[Records API] total={total}, returned={len(records)}, keyword={keyword!r}")
|
||||
|
||||
return RecordPageResponse(
|
||||
total=total, skip=skip, limit=limit,
|
||||
records=[RecordListResponse.model_validate(r) for r in records]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/segments/daily",
|
||||
response_model=DailySegmentPage,
|
||||
summary="일자별 전체 세그먼트 조회 (Cursor 페이지네이션)",
|
||||
description=(
|
||||
"특정 날짜(date=YYYY-MM-DD)에 저장된 TranscriptionSegment를 "
|
||||
"id 오름차순으로 반환합니다. "
|
||||
"cursor(마지막 id) + limit 파라미터로 무한 스크롤을 지원합니다. "
|
||||
"COUNT(*) 없이 인덱스만 사용하므로 대용량에서도 빠릅니다."
|
||||
)
|
||||
)
|
||||
def get_segments_daily(
|
||||
date: str = Query(..., description="조회 날짜 (YYYY-MM-DD, 예: 2026-03-07)"),
|
||||
cursor: Optional[int] = Query(default=None, description="마지막으로 받은 segment id (첫 요청 시 생략)"),
|
||||
limit: int = Query(default=100, ge=1, le=500, description="한 번에 받을 세그먼트 수 (기본 100)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cursor 기반 페이지네이션 설계:
|
||||
- cursor=None: 첫 페이지 (id가 가장 작은 것부터)
|
||||
- cursor=N: id > N 조건으로 다음 페이지 (인덱스 스캔, COUNT(*) 없음)
|
||||
- limit+1 개를 가져와 has_more 판단 후 마지막 1건 제거
|
||||
"""
|
||||
try:
|
||||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="날짜 형식 오류: YYYY-MM-DD 형식으로 입력하세요.")
|
||||
|
||||
logger.debug(f"[Daily API] date={target_date}, cursor={cursor}, limit={limit}")
|
||||
|
||||
# 해당 날짜 record_id 서브쿼리 (created_at 기준)
|
||||
record_ids = list(db.scalars(
|
||||
select(TranscriptionRecord.id)
|
||||
.where(func.date(TranscriptionRecord.created_at) == target_date)
|
||||
).all())
|
||||
|
||||
logger.debug(f"[Daily API] 해당 record_ids={record_ids}")
|
||||
|
||||
if not record_ids:
|
||||
return DailySegmentPage(items=[], next_cursor=None, has_more=False)
|
||||
|
||||
# Cursor 조건 구성 (id > cursor 로 인덱스 활용)
|
||||
seg_filters = [TranscriptionSegment.record_id.in_(record_ids)]
|
||||
if cursor is not None:
|
||||
seg_filters.append(TranscriptionSegment.id > cursor)
|
||||
|
||||
# limit+1 조회 → has_more 판단
|
||||
seg_query = (
|
||||
select(TranscriptionSegment)
|
||||
.where(*seg_filters)
|
||||
.order_by(asc(TranscriptionSegment.id))
|
||||
.limit(limit + 1)
|
||||
)
|
||||
raw_segs = db.scalars(seg_query).all()
|
||||
|
||||
has_more = len(raw_segs) > limit
|
||||
segs = raw_segs[:limit]
|
||||
next_cursor = segs[-1].id if has_more and segs else None
|
||||
|
||||
logger.debug(f"[Daily API] returned={len(segs)}, has_more={has_more}, next_cursor={next_cursor}")
|
||||
|
||||
return DailySegmentPage(
|
||||
items=[DailySegmentResponse.model_validate(s) for s in segs],
|
||||
next_cursor=next_cursor,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
import os
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
@router.get(
|
||||
"/segments/{segment_id}/audio",
|
||||
summary="[Chapter 8.0] 세그먼트별 압축 오디오 재생 (보안 스트리밍 API)",
|
||||
description="DB에서 opus 오디오 경로를 읽어 반환합니다. 향후 인증 로직(Depends) 연동을 지원합니다."
|
||||
)
|
||||
def get_segment_audio(
|
||||
segment_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
seg = db.query(TranscriptionSegment).filter(TranscriptionSegment.id == segment_id).first()
|
||||
if not seg or not seg.audio_path or not os.path.exists(seg.audio_path):
|
||||
raise HTTPException(status_code=404, detail="오디오 파일을 찾을 수 없거나 삭제되었습니다.")
|
||||
|
||||
return FileResponse(seg.audio_path, media_type="audio/ogg")
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import re
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
def get_critical_keywords() -> list[str]:
|
||||
default_kw = ["탈선", "화재", "사상", "구호", "단전", "충돌"]
|
||||
# project_root/data/critical_keywords.txt
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
txt_path = os.path.join(base_dir, "data", "critical_keywords.txt")
|
||||
|
||||
try:
|
||||
if os.path.exists(txt_path):
|
||||
with open(txt_path, "r", encoding="utf-8") as f:
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
if lines:
|
||||
return lines
|
||||
except Exception as e:
|
||||
logger.warning(f"[Analyzer] 긴급 키워드 파일 읽기 실패, 기본값 사용: {e}")
|
||||
|
||||
return default_kw
|
||||
|
||||
CRITICAL_KEYWORDS = get_critical_keywords()
|
||||
|
||||
def check_urgency(text: str) -> str:
|
||||
"""텍스트 내 긴급 키워드가 포함되어 있는지 확인합니다."""
|
||||
for kw in CRITICAL_KEYWORDS:
|
||||
if kw in text:
|
||||
return "긴급"
|
||||
return "일반"
|
||||
|
||||
KOR_NUM_MAP: dict[str, str] = {
|
||||
"영": "0", "공": "0",
|
||||
"일": "1", "이": "2", "삼": "3", "사": "4", "오": "5",
|
||||
"육": "6", "칠": "7", "팔": "8", "구": "9"
|
||||
}
|
||||
|
||||
def extract_train_number(text: str) -> Optional[str]:
|
||||
"""
|
||||
STT 텍스트에서 열차 번호("K1234", "3042", "삼공사이", "삼천사십이")를 추출합니다.
|
||||
"""
|
||||
# 1. 일반 영어+숫자 조합 (K1234, 1234 등)
|
||||
m1 = re.search(r'([Kk케이]?\s*\d{3,4})', text)
|
||||
if m1:
|
||||
num_str = m1.group(1).replace(" ", "").replace("케이", "K").upper()
|
||||
if num_str.startswith("K") or len(num_str) >= 3:
|
||||
return num_str
|
||||
|
||||
# 2. 한글 숫자로 읽는 방식 매칭 ("삼공사이", "삼천사십이", "케이삼천사백")
|
||||
m2 = re.search(r'([Kk케이]?\s*[영공일이삼사오육칠팔구천백십]{2,10})', text)
|
||||
if m2:
|
||||
raw = m2.group(1).replace(" ", "").replace("케이", "K").upper()
|
||||
prefix = "K" if raw.startswith("K") else ""
|
||||
if prefix:
|
||||
raw = raw[1:]
|
||||
|
||||
has_units = any(c in raw for c in ["천", "백", "십"])
|
||||
res = 0
|
||||
|
||||
if not has_units:
|
||||
# "삼공사이" 방식
|
||||
digits = "".join(KOR_NUM_MAP.get(c, "") for c in raw)
|
||||
if len(digits) >= 3:
|
||||
return f"{prefix}{digits}"
|
||||
else:
|
||||
# "삼천사십이" 방식
|
||||
curr = 0
|
||||
for ch in raw:
|
||||
if ch in KOR_NUM_MAP:
|
||||
curr = int(KOR_NUM_MAP[ch])
|
||||
elif ch == "천":
|
||||
res += (curr if curr != 0 else 1) * 1000
|
||||
curr = 0
|
||||
elif ch == "백":
|
||||
res += (curr if curr != 0 else 1) * 100
|
||||
curr = 0
|
||||
elif ch == "십":
|
||||
res += (curr if curr != 0 else 1) * 10
|
||||
curr = 0
|
||||
res += curr
|
||||
if res >= 100: # 최소 3자리의 숫자값 보장
|
||||
return f"{prefix}{res}"
|
||||
|
||||
return None
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
"""
|
||||
app/services/audio_listener.py
|
||||
-------------------------------
|
||||
서버의 Line-in(또는 기본 마이크)를 24/7 감청하다가,
|
||||
VAD(음성 구간 감지)로 무전 발화를 포착 → 임시 .wav 파일 저장
|
||||
→ WhisperSTT 파이프라인 → LLM 메타데이터 추출 → WebSocket broadcast
|
||||
를 한 사이클로 처리하는 백그라운드 데몬 스레드 모듈.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# ─── 기본 설정값 (환경에 따라 .env 또는 생성자 파라미터로 변경 가능) ───
|
||||
SAMPLE_RATE = 16000 # Whisper 권장 샘플링 레이트 (16kHz)
|
||||
CHANNELS = 1 # 모노
|
||||
CHUNK = 1024 # 한 번에 읽을 프레임 수
|
||||
FORMAT = None # pyaudio.paInt16 — 아래 init 시 할당
|
||||
THRESHOLD = 600 # RMS 볼륨 임계치 (0~32767 범위에서 경험값)
|
||||
# 조용한 환경 → 500~800 / 시끄러운 환경 → 1000~1500
|
||||
SILENCE_LIMIT = 1.8 # 이 초(sec) 이상 침묵이 지속되면 발화 종료 판정
|
||||
MIN_SILENCE = 0.3 # 첫 발화 후 최소 이 초는 녹음 유지
|
||||
RECORD_TIMEOUT = 30.0 # 단일 무전 최대 녹음 시간 (무한 루프 방지)
|
||||
PRE_ROLL_SECS = 0.3 # 발화 시작 0.3초 전 버퍼 — 첫 음절 잘림 방지
|
||||
|
||||
|
||||
class RadioListener:
|
||||
"""
|
||||
백그라운드 스레드에서 Line-in을 24/7 감청하는 VAD 감청기.
|
||||
|
||||
사용법:
|
||||
listener = RadioListener(on_segment=mycallback, loop=asyncio_loop)
|
||||
listener.start()
|
||||
...
|
||||
listener.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, on_segment, loop: asyncio.AbstractEventLoop,
|
||||
threshold: int = THRESHOLD,
|
||||
silence_limit: float = SILENCE_LIMIT,
|
||||
device_index: int | None = None):
|
||||
"""
|
||||
:param on_segment: 녹음 완료된 .wav 파일 경로(str)를 받는 async 콜백
|
||||
:param loop: FastAPI 메인 이벤트 루프
|
||||
:param threshold: RMS 볼륨 임계치
|
||||
:param silence_limit: 침묵 지속 시간(초) 임계치
|
||||
:param device_index: PyAudio 입력 장치 인덱스 (None = 시스템 기본값)
|
||||
"""
|
||||
self.on_segment = on_segment
|
||||
self.loop = loop
|
||||
self.threshold = threshold
|
||||
self.silence_limit = silence_limit
|
||||
self.device_index = device_index
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._pa = None # PyAudio 인스턴스
|
||||
|
||||
# ── 공개 인터페이스 ──────────────────────────────────────────────────────
|
||||
def start(self):
|
||||
"""백그라운드 데몬 스레드로 감청 루프 시작."""
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
||||
self._thread.name = "RadioListenerThread"
|
||||
self._thread.start()
|
||||
logger.info("🎙️ RadioListener 백그라운드 스레드 시작")
|
||||
|
||||
def stop(self):
|
||||
"""감청 루프를 안전하게 종료하고 자원을 해제."""
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5.0)
|
||||
if self._pa:
|
||||
try:
|
||||
self._pa.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("🔇 RadioListener 종료 완료")
|
||||
|
||||
# ── 내부 로직 ──────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _rms(chunk_bytes: bytes) -> float:
|
||||
"""16-bit PCM 바이트 배열로부터 RMS 볼륨을 계산."""
|
||||
import struct
|
||||
shorts = struct.unpack(f"{len(chunk_bytes) // 2}h", chunk_bytes)
|
||||
mean_sq = sum(s * s for s in shorts) / len(shorts)
|
||||
return math.sqrt(mean_sq)
|
||||
|
||||
def _listen_loop(self):
|
||||
"""메인 감청 스레드 루프. 오디오 장치 부재 시 warning 후 종료."""
|
||||
try:
|
||||
import pyaudio
|
||||
global FORMAT
|
||||
FORMAT = pyaudio.paInt16
|
||||
|
||||
self._pa = pyaudio.PyAudio()
|
||||
|
||||
# 입력 장치 검증
|
||||
device_count = self._pa.get_device_count()
|
||||
if device_count == 0:
|
||||
raise RuntimeError("오디오 입력 장치를 찾을 수 없습니다.")
|
||||
|
||||
# 스트림 오픈
|
||||
stream = self._pa.open(
|
||||
format=FORMAT,
|
||||
channels=CHANNELS,
|
||||
rate=SAMPLE_RATE,
|
||||
input=True,
|
||||
input_device_index=self.device_index,
|
||||
frames_per_buffer=CHUNK,
|
||||
)
|
||||
logger.info(f"🔊 오디오 스트림 오픈 완료 (SR={SAMPLE_RATE}Hz, CH={CHANNELS}, CHUNK={CHUNK})")
|
||||
|
||||
# pre-roll 버퍼: 발화 시작 직전 청크를 미리 채워 두어 첫 음절 잘림 방지
|
||||
pre_roll_size = int((PRE_ROLL_SECS * SAMPLE_RATE) / CHUNK)
|
||||
pre_roll: list[bytes] = []
|
||||
|
||||
# VAD 상태
|
||||
is_recording = False
|
||||
frames: list[bytes] = []
|
||||
silence_chunks = 0
|
||||
record_chunks = 0
|
||||
silence_limit_chunks = int((self.silence_limit * SAMPLE_RATE) / CHUNK)
|
||||
|
||||
logger.info(f"⏳ 무전 감청 대기 중... (임계치 RMS={self.threshold}, 침묵판정={self.silence_limit}s)")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
chunk = stream.read(CHUNK, exception_on_overflow=False)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
rms = self._rms(chunk)
|
||||
|
||||
if not is_recording:
|
||||
# ─ 대기 상태: 임계치 초과 시 녹음 시작 ─
|
||||
pre_roll.append(chunk)
|
||||
if len(pre_roll) > pre_roll_size:
|
||||
pre_roll.pop(0)
|
||||
|
||||
if rms > self.threshold:
|
||||
logger.info(f"🎤 발화 감지 (RMS={rms:.0f}) — 녹음 시작")
|
||||
is_recording = True
|
||||
silence_chunks = 0
|
||||
record_chunks = 0
|
||||
frames = list(pre_roll) # pre-roll 삽입
|
||||
frames.append(chunk)
|
||||
else:
|
||||
# ─ 녹음 상태 ─
|
||||
frames.append(chunk)
|
||||
record_chunks += 1
|
||||
|
||||
if rms < self.threshold:
|
||||
silence_chunks += 1
|
||||
else:
|
||||
silence_chunks = 0 # 다시 소리가 나면 침묵 카운터 리셋
|
||||
|
||||
# 침묵 초과 → 발화 종료
|
||||
if silence_chunks >= silence_limit_chunks:
|
||||
logger.info(f"🔚 발화 종료 (총 {record_chunks * CHUNK / SAMPLE_RATE:.1f}s)")
|
||||
is_recording = False
|
||||
self._dispatch(frames)
|
||||
frames = []
|
||||
|
||||
# 최대 녹음 시간 초과 → 강제 종료
|
||||
elif record_chunks * CHUNK / SAMPLE_RATE > RECORD_TIMEOUT:
|
||||
logger.warning(f"⚠️ 최대 녹음 시간({RECORD_TIMEOUT}s) 초과 — 강제 분할")
|
||||
is_recording = False
|
||||
self._dispatch(frames)
|
||||
frames = []
|
||||
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
|
||||
except ImportError:
|
||||
logger.warning("⚠️ pyaudio 미설치 — 감청 모듈 비활성화됨. `uv pip install pyaudio`로 설치할 것.")
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"⚠️ 오디오 장치 오류 — 감청 모듈 비활성화 ({e})")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 감청 루프 예외 발생 — 모듈 비활성화 ({e})")
|
||||
|
||||
def _dispatch(self, frames: list[bytes]):
|
||||
"""녹음된 프레임을 .wav 파일로 저장하고 비동기 STT 파이프라인으로 전달."""
|
||||
if not frames:
|
||||
return
|
||||
|
||||
# 임시 wav 파일 저장
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix="_live.wav", dir=tempfile.gettempdir()
|
||||
)
|
||||
tmp_path = tmp.name
|
||||
tmp.close()
|
||||
|
||||
import pyaudio
|
||||
with wave.open(tmp_path, "wb") as wf:
|
||||
wf.setnchannels(CHANNELS)
|
||||
wf.setsampwidth(self._pa.get_sample_size(pyaudio.paInt16))
|
||||
wf.setframerate(SAMPLE_RATE)
|
||||
wf.writeframes(b"".join(frames))
|
||||
|
||||
logger.info(f"💾 임시 wav 저장 완료 → {tmp_path}")
|
||||
|
||||
# asyncio 메인 루프로 코루틴 스케줄링 (thread-safe)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.on_segment(tmp_path),
|
||||
self.loop
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ wav 저장 또는 디스패치 실패: {e}")
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
from app.core.exceptions import AudioFileNotFoundError, STTError
|
||||
|
||||
class AudioParser:
|
||||
"""
|
||||
모든 형식(m4a, mp3, mp4 등)의 오디오/비디오 파일을
|
||||
whisper.cpp가 요구하는 16kHz 16-bit Mono WAV 포맷으로 변환하는 파서 클래스.
|
||||
"""
|
||||
@staticmethod
|
||||
def preprocess_audio(file_path: str, sample_rate: int = 16000) -> str:
|
||||
if not os.path.exists(file_path):
|
||||
raise AudioFileNotFoundError(file_path)
|
||||
|
||||
try:
|
||||
# 임시 파일로 wav 저장 경로 설정
|
||||
temp_dir = tempfile.gettempdir()
|
||||
filename = os.path.basename(file_path)
|
||||
base_name, _ = os.path.splitext(filename)
|
||||
wav_path = os.path.join(temp_dir, f"{base_name}_16kHz.wav")
|
||||
|
||||
# 파이썬 번들 ffmpeg.exe 사용
|
||||
import imageio_ffmpeg
|
||||
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
|
||||
# 16kHz, 1채널(Mono), wav 형태로 강제 변환
|
||||
command = [
|
||||
ffmpeg_exe,
|
||||
"-y", # 이미 있으면 덮어쓰기
|
||||
"-i", file_path, # 원본 파일
|
||||
"-ac", "1", # 채널 수를 1 (Mono)
|
||||
"-ar", str(sample_rate), # 16kHz
|
||||
wav_path
|
||||
]
|
||||
|
||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
return wav_path
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
err_msg = e.stderr.decode("utf-8", errors="ignore")
|
||||
raise STTError(f"오디오 파일 변환 실패 ({file_path}): ffmpeg 오류 - {err_msg}")
|
||||
except Exception as e:
|
||||
raise STTError(f"오디오 파일 처리 중 예기치 않은 오류 발생: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def cleanup(file_path: str):
|
||||
"""사용한 임시 wav 파일을 삭제하는 유틸"""
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"""
|
||||
유연한 지식(Context) 제공자 패턴 모듈.
|
||||
LLM Worker가 추출한 키워드를 받아 관련 지식 DB(또는 Mock)에서
|
||||
추가 컨텍스트 딕셔너리를 반환합니다. 향후 RAG 서버로 코드 수정 없이 확장할 수 있습니다.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class ContextProvider(ABC):
|
||||
"""지식 제공자 추상 클래스"""
|
||||
@abstractmethod
|
||||
def get_extended_context(self, keywords: List[str]) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
|
||||
class MockContextProvider(ContextProvider):
|
||||
"""개발 및 데모를 위한 하드코딩된 Mock 지식 제공자"""
|
||||
def __init__(self):
|
||||
# 데모용 지식 딕셔너리
|
||||
self._mock_db = {
|
||||
"검측차": {
|
||||
"title": "검측차 (Inspection Train)",
|
||||
"desc": "선로, 전차선, 신호 등의 상태를 주행하며 실시간으로 점검하는 특수 목적 차량. 주로 야간 작업에 투입됨."
|
||||
},
|
||||
"다대포": {
|
||||
"title": "다대포 해수욕장역 (Dadaepo Beach)",
|
||||
"desc": "부산 도시철도 1호선의 종착역. 회차선 및 차량 유치선 존재."
|
||||
},
|
||||
"tcms": {
|
||||
"title": "TCMS (열차종합제어장치)",
|
||||
"desc": "Train Control and Monitoring System. 열차의 각종 주요 기기를 제어하고 실시간 상태를 감시, 진단하는 시스템."
|
||||
},
|
||||
"관제": {
|
||||
"title": "관제 센터 (Control Center)",
|
||||
"desc": "전체 철도 시스템의 운행 상황을 실시간 모니터링하고 통제, 지시를 내리는 종합 사령실."
|
||||
}
|
||||
}
|
||||
|
||||
def get_extended_context(self, keywords: List[str]) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
found_keys = set()
|
||||
|
||||
for kw in keywords:
|
||||
kw_lower = kw.lower()
|
||||
for key, val in self._mock_db.items():
|
||||
if key in found_keys:
|
||||
continue
|
||||
# 키워드가 DB 키에 포함되거나, DB 키가 키워드에 포함된 경우 매칭
|
||||
if key.lower() in kw_lower or kw_lower in key.lower():
|
||||
results.append({
|
||||
"keyword": key,
|
||||
"title": val["title"],
|
||||
"desc": val["desc"]
|
||||
})
|
||||
found_keys.add(key)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class CSVContextProvider(ContextProvider):
|
||||
"""
|
||||
[Chapter 6.1]
|
||||
대규모 도메인 사전(CSV) 기반의 지식 제공자. O(1) 검색 최적화 및
|
||||
동음이의어(리스트 Value) 중복 처리를 허용합니다.
|
||||
"""
|
||||
def get_extended_context(self, keywords: List[str]) -> List[Dict[str, Any]]:
|
||||
from app.core.dictionary import RAILWAY_TERMS_DICT
|
||||
results = []
|
||||
found_keys = set()
|
||||
|
||||
for kw in keywords:
|
||||
kw_clean = kw.strip()
|
||||
if not kw_clean: continue
|
||||
|
||||
entries = RAILWAY_TERMS_DICT.get(kw_clean)
|
||||
if entries:
|
||||
for entry in entries:
|
||||
# 동음이의어 방지용 해시 (keyword+desc)
|
||||
unique_hash = f"{kw_clean}_{entry['desc'][:20]}"
|
||||
if unique_hash not in found_keys:
|
||||
results.append({
|
||||
"keyword": entry.get("category", "일반 용어"), # Frontend tag
|
||||
"title": kw_clean, # Frontend subtitle
|
||||
"desc": entry.get("desc", "") # Frontend text
|
||||
})
|
||||
found_keys.add(unique_hash)
|
||||
|
||||
return results
|
||||
|
||||
def get_context_service() -> ContextProvider:
|
||||
"""설정된 CONTEXT_PROVIDER 환경 변수에 따라 적절한 구현체를 반환하는 팩토리 함수"""
|
||||
provider_type = getattr(settings, "CONTEXT_PROVIDER", "mock").strip().lower()
|
||||
|
||||
if provider_type == "mock":
|
||||
return MockContextProvider()
|
||||
elif provider_type == "csv":
|
||||
return CSVContextProvider()
|
||||
# elif provider_type == "rag":
|
||||
# return RAGContextProvider() # 향후 구축 시 연결
|
||||
else:
|
||||
logger.warning(f"[Context Service] 알 수 없는 프로바이더 '{provider_type}', Mock Provider로 폴백합니다.")
|
||||
return MockContextProvider()
|
||||
|
||||
# 전역 싱글톤 인스턴스 (워커에서 Import 용)
|
||||
context_service = get_context_service()
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
"""
|
||||
로컬 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()
|
||||
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
"""
|
||||
app/services/mock_audio_listener.py
|
||||
-------------------------------------
|
||||
물리적 마이크(Line-in)가 없는 서버 환경에서 실시간 감청 파이프라인을
|
||||
완벽하게 테스트하기 위한 시뮬레이터 모듈.
|
||||
|
||||
실제 .wav 파일을 CHUNK 단위로 읽어 실제 재생 속도만큼 딜레이를 주며,
|
||||
RadioListener와 동일한 VAD(음성 감지) 로직으로 on_segment 콜백을 트리거한다.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# ─── 기본 설정값 (RadioListener와 동일한 상수 체계) ───────────────────────────
|
||||
SAMPLE_RATE = 16000 # 처리 샘플링 레이트
|
||||
CHANNELS = 1 # 모노
|
||||
CHUNK = 1024 # 한 번에 읽을 프레임 수
|
||||
THRESHOLD = 600 # RMS 볼륨 임계치
|
||||
SILENCE_LIMIT = 1.8 # 침묵 지속 시간(초) 임계치
|
||||
PRE_ROLL_SECS = 0.3 # 발화 시작 직전 버퍼 (첫 음절 잘림 방지)
|
||||
|
||||
|
||||
class MockAudioListener:
|
||||
"""
|
||||
로컬 .wav 파일을 실시간 감청처럼 스트리밍하는 시뮬레이터.
|
||||
|
||||
RadioListener와 동일한 인터페이스(start / stop, on_segment 콜백)를 가지므로
|
||||
main.py에서 완전히 대체(drop-in replacement)하여 사용 가능하다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
on_segment : callable (async)
|
||||
완성된 wav 임시 파일 경로(str)를 인자로 받는 비동기 콜백.
|
||||
loop : asyncio.AbstractEventLoop
|
||||
FastAPI 메인 이벤트 루프.
|
||||
wav_path : str
|
||||
시뮬레이션 소스로 사용할 .wav 파일의 경로.
|
||||
threshold : int, optional
|
||||
RMS 음량 임계치 (기본값 600).
|
||||
silence_limit : float, optional
|
||||
침묵 종료 판정까지의 시간(초) (기본값 1.8).
|
||||
loop_playback : bool, optional
|
||||
파일 재생이 끝났을 때 반복 재생 여부 (기본값 True).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_segment,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
wav_path: str,
|
||||
threshold: int = THRESHOLD,
|
||||
silence_limit: float = SILENCE_LIMIT,
|
||||
loop_playback: bool = True,
|
||||
):
|
||||
if not os.path.exists(wav_path):
|
||||
raise FileNotFoundError(f"Mock 오디오 파일을 찾을 수 없습니다: {wav_path}")
|
||||
|
||||
self.on_segment = on_segment
|
||||
self.loop = loop
|
||||
self.wav_path = wav_path
|
||||
self.threshold = threshold
|
||||
self.silence_limit = silence_limit
|
||||
self.loop_playback = loop_playback
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
# ── 공개 인터페이스 ──────────────────────────────────────────────────────
|
||||
def start(self):
|
||||
"""백그라운드 데몬 스레드로 시뮬레이션 루프 시작."""
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._mock_loop, daemon=True)
|
||||
self._thread.name = "MockAudioListenerThread"
|
||||
self._thread.start()
|
||||
logger.info(f"🧪 MockAudioListener 시작 → {self.wav_path}")
|
||||
|
||||
def stop(self):
|
||||
"""시뮬레이션 루프를 안전하게 종료."""
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5.0)
|
||||
logger.info("🧪 MockAudioListener 종료")
|
||||
|
||||
# ── 내부 로직 ──────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _rms(chunk_bytes: bytes) -> float:
|
||||
"""16-bit PCM 바이트 배열로부터 RMS 볼륨을 계산.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
chunk_bytes : bytes
|
||||
PCM 16-bit little-endian 바이트 배열.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
RMS 볼륨 값 (0 ~ 32767 범위).
|
||||
"""
|
||||
shorts = struct.unpack(f"{len(chunk_bytes) // 2}h", chunk_bytes)
|
||||
mean_sq = sum(s * s for s in shorts) / max(len(shorts), 1)
|
||||
return math.sqrt(mean_sq)
|
||||
|
||||
def _mock_loop(self):
|
||||
"""시뮬레이션 메인 루프: wav → 청크 단위 스트리밍 + VAD."""
|
||||
chunk_duration = CHUNK / SAMPLE_RATE # 청크 한 개의 실제 재생 시간(초)
|
||||
|
||||
# wav가 아닌 파일 포맷 최초 1회 자동 변환 (m4a, mp3 등 → 16kHz 모노 wav)
|
||||
target_path = self.wav_path
|
||||
tmp_converted = None
|
||||
if not self.wav_path.lower().endswith(".wav"):
|
||||
try:
|
||||
import subprocess
|
||||
import imageio_ffmpeg as _iff
|
||||
ffmpeg_exe = _iff.get_ffmpeg_exe()
|
||||
tmp_conv = tempfile.NamedTemporaryFile(delete=False, suffix="_mock_conv.wav")
|
||||
tmp_conv.close()
|
||||
# ffmpeg 로 직접 16kHz 모노 PCM wav 변환
|
||||
subprocess.run(
|
||||
[ffmpeg_exe, "-y", "-i", self.wav_path,
|
||||
"-ar", str(SAMPLE_RATE), "-ac", "1", "-acodec", "pcm_s16le",
|
||||
tmp_conv.name],
|
||||
check=True, capture_output=True
|
||||
)
|
||||
target_path = tmp_conv.name
|
||||
tmp_converted = tmp_conv.name
|
||||
logger.info(f"🔄 [Mock] 포맷 변환 완료 (ffmpeg): {self.wav_path} → {target_path}")
|
||||
except Exception as conv_err:
|
||||
logger.error(f"❌ [Mock] 파일 변환 실패: {conv_err}")
|
||||
return # 변환 실패 시 루프 진입 없이 종료
|
||||
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
with wave.open(target_path, "rb") as wf:
|
||||
actual_sr = wf.getframerate()
|
||||
actual_ch = wf.getnchannels()
|
||||
actual_sw = wf.getsampwidth()
|
||||
|
||||
logger.info(
|
||||
f"▶ 시뮬레이션 재생 시작 | "
|
||||
f"SR={actual_sr}Hz CH={actual_ch} SW={actual_sw}byte | "
|
||||
f"파일={os.path.basename(self.wav_path)}"
|
||||
)
|
||||
|
||||
# VAD 상태 초기화
|
||||
pre_roll_size = int((PRE_ROLL_SECS * actual_sr) / CHUNK)
|
||||
pre_roll: list[bytes] = []
|
||||
is_recording = False
|
||||
frames: list[bytes] = []
|
||||
silence_chunks = 0
|
||||
silence_limit_chunks = int((self.silence_limit * actual_sr) / CHUNK)
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
raw = wf.readframes(CHUNK)
|
||||
if not raw:
|
||||
break # 파일 끝
|
||||
|
||||
# ─ 실제 재생 속도 흉내 ─
|
||||
time.sleep(chunk_duration)
|
||||
|
||||
rms = self._rms(raw)
|
||||
|
||||
if not is_recording:
|
||||
pre_roll.append(raw)
|
||||
if len(pre_roll) > pre_roll_size:
|
||||
pre_roll.pop(0)
|
||||
|
||||
if rms > self.threshold:
|
||||
logger.info(f"[Mock] VAD: speech start (RMS={rms:.0f})")
|
||||
is_recording = True
|
||||
silence_chunks = 0
|
||||
frames = list(pre_roll)
|
||||
frames.append(raw)
|
||||
else:
|
||||
frames.append(raw)
|
||||
if rms < self.threshold:
|
||||
silence_chunks += 1
|
||||
else:
|
||||
silence_chunks = 0
|
||||
|
||||
if silence_chunks >= silence_limit_chunks:
|
||||
logger.info(f"[Mock] VAD: speech end ({len(frames) * CHUNK / actual_sr:.1f}s)")
|
||||
is_recording = False
|
||||
self._dispatch(frames, actual_sr, actual_ch, actual_sw)
|
||||
frames = []
|
||||
|
||||
if is_recording and frames:
|
||||
logger.info("[Mock] VAD: EOF reached, dispatching last segment")
|
||||
self._dispatch(frames, actual_sr, actual_ch, actual_sw)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [Mock] 루프 오류: {e}")
|
||||
break
|
||||
|
||||
if self.loop_playback and not self._stop_event.is_set():
|
||||
logger.info("[Mock] playback done - waiting 10s before repeat")
|
||||
time.sleep(10.0)
|
||||
else:
|
||||
logger.info("✅ [Mock] 단일 재생 완료 — 시뮬레이터 종료")
|
||||
break
|
||||
finally:
|
||||
# 변환된 임시 파일 정리
|
||||
if tmp_converted and os.path.exists(tmp_converted):
|
||||
try:
|
||||
os.remove(tmp_converted)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _dispatch(self, frames: list[bytes], sr: int, ch: int, sw: int):
|
||||
"""녹음된 프레임을 임시 .wav로 저장하고 비동기 STT 파이프라인으로 전달.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frames : list[bytes]
|
||||
PCM 프레임 버퍼 목록.
|
||||
sr : int
|
||||
샘플링 레이트.
|
||||
ch : int
|
||||
채널 수.
|
||||
sw : int
|
||||
샘플 폭(byte).
|
||||
"""
|
||||
if not frames:
|
||||
return
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix="_mock.wav", dir=tempfile.gettempdir()
|
||||
)
|
||||
tmp_path = tmp.name
|
||||
tmp.close()
|
||||
|
||||
with wave.open(tmp_path, "wb") as wf:
|
||||
wf.setnchannels(ch)
|
||||
wf.setsampwidth(sw)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(b"".join(frames))
|
||||
|
||||
logger.info(f"💾 [Mock] 임시 wav 저장 → {tmp_path}")
|
||||
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.on_segment(tmp_path),
|
||||
self.loop
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [Mock] dispatch 실패: {e}")
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"""
|
||||
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()
|
||||
|
||||
# <think>...</think> 블록 제거 (Qwen3 계열)
|
||||
raw = re.sub(r"<think>.*?</think>", "", 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 "불명"
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import os
|
||||
import time
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import AudioFileNotFoundError, ModelNotFoundError, STTError
|
||||
from app.models.stt import STTRequest, STTResponse
|
||||
from app.services.audio_parser import AudioParser
|
||||
from app.services.speaker_classifier import classify_speaker
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except ImportError:
|
||||
WhisperModel = None
|
||||
|
||||
|
||||
|
||||
class WhisperSTTService:
|
||||
"""
|
||||
faster-whisper를 이용하여 오디오를 텍스트로 변환하는 서비스 클래스.
|
||||
CPU / 내장그래픽 최적화를 위해 int8 양자화 모델을 기반으로 동작.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.model_name = settings.WHISPER_MODEL_NAME
|
||||
self._model = None
|
||||
|
||||
def _load_model(self):
|
||||
"""지연 로딩(Lazy Loading) 방식으로 모델을 초기화함."""
|
||||
if WhisperModel is None:
|
||||
raise ModelNotFoundError(
|
||||
"faster-whisper 모듈이 설치되어 있지 않습니다. "
|
||||
"'pip install faster-whisper'를 확인하세요."
|
||||
)
|
||||
|
||||
if self._model is None:
|
||||
try:
|
||||
# CPU 타겟, int8 양자화로 모델 객체 생성. (최초 시도시 모델 자동 다운로드됨)
|
||||
self._model = WhisperModel(self.model_name, device="cpu", compute_type="int8")
|
||||
except Exception as e:
|
||||
raise ModelNotFoundError(f"Whisper 모델 로드 실패 ({self.model_name}): {str(e)}")
|
||||
|
||||
def transcribe(self, request: STTRequest) -> STTResponse:
|
||||
"""
|
||||
[Pydantic 모델 기반 입출력]
|
||||
오디오 파일을 STT 엔진에 전달하고 변환 결과 텍스트를 반환함.
|
||||
"""
|
||||
if not os.path.exists(request.audio_file_path):
|
||||
raise AudioFileNotFoundError(request.audio_file_path)
|
||||
|
||||
import tracemalloc
|
||||
tracemalloc.start()
|
||||
|
||||
load_start = time.time()
|
||||
self._load_model()
|
||||
load_time = time.time() - load_start
|
||||
|
||||
start_time = time.time()
|
||||
processed_wav = None
|
||||
|
||||
try:
|
||||
# 1. 오디오 포맷 전처리 (m4a, mp3 -> 16kHz mono wav 변환)
|
||||
processed_wav = AudioParser.preprocess_audio(request.audio_file_path)
|
||||
|
||||
# 오디오 길이(duration) 측정
|
||||
# pydub를 사용하여 길이를 구하거나 wave 파일 헤더를 읽음 (간단히 pydub 사용)
|
||||
from pydub import AudioSegment
|
||||
audio_segment = AudioSegment.from_file(processed_wav)
|
||||
audio_duration_sec = len(audio_segment) / 1000.0
|
||||
|
||||
# 사전 데이터 모듈을 통한 철도/지하철 용어 프롬프트 주입
|
||||
from app.core.dictionary import domain_dict
|
||||
stations_prompt = domain_dict.get_prompt()
|
||||
|
||||
# 2. faster-whisper API 호출
|
||||
segments, info = self._model.transcribe(
|
||||
processed_wav,
|
||||
beam_size=5,
|
||||
initial_prompt=stations_prompt,
|
||||
vad_filter=True, # VAD(단말기 감지)를 켜서 빈 구간 스킵 및 속도 향상
|
||||
word_timestamps=False,
|
||||
condition_on_previous_text=False # 무전 특성상 이전 맥락 환각 방지
|
||||
)
|
||||
|
||||
# 3. generator를 순회하며 텍스트 추출 및 세그먼트 매핑
|
||||
from app.models.stt import STTSegment
|
||||
from datetime import timedelta
|
||||
|
||||
stt_segments = []
|
||||
final_text_parts = []
|
||||
|
||||
# 화자 분리 추적 변수
|
||||
current_speaker = "미상"
|
||||
prev_end_sec = 0.0
|
||||
|
||||
for segment in segments:
|
||||
raw_seg_text = segment.text.strip()
|
||||
if not raw_seg_text:
|
||||
continue
|
||||
|
||||
# 개별 세그먼트에 후처리 필터 적용 (RapidFuzz 교정기)
|
||||
corrected_seg_text = domain_dict.post_process_correction(raw_seg_text, threshold=88.0).strip()
|
||||
|
||||
# 빈 문자열로 교정된 경우 스킵
|
||||
if not corrected_seg_text:
|
||||
continue
|
||||
|
||||
final_text_parts.append(corrected_seg_text)
|
||||
|
||||
# 절대 시간 연산 (타임존 방어: naive datetime → KST 강제 적용)
|
||||
abs_start_time = None
|
||||
abs_end_time = None
|
||||
if request.base_datetime:
|
||||
try:
|
||||
from datetime import timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
kst = ZoneInfo("Asia/Seoul")
|
||||
|
||||
base = request.base_datetime
|
||||
# naive datetime이면 KST로 강제 localize
|
||||
if base.tzinfo is None:
|
||||
base = base.replace(tzinfo=kst)
|
||||
|
||||
abs_start_time = (base + timedelta(seconds=segment.start)).isoformat()
|
||||
abs_end_time = (base + timedelta(seconds=segment.end)).isoformat()
|
||||
except Exception:
|
||||
pass # 안전을 위해 예외시 None으로 둠
|
||||
|
||||
# ── 화자 분류: speaker_classifier (휴리스틱 → LLM JSON) ──
|
||||
context_chunk = " ".join(final_text_parts[-3:-1]) if len(final_text_parts) > 1 else ""
|
||||
current_speaker = classify_speaker(corrected_seg_text, context_chunk)
|
||||
logger.debug(f"[STT] 세그먼트 화자: '{corrected_seg_text[:30]}' → {current_speaker}")
|
||||
|
||||
prev_end_sec = segment.end
|
||||
|
||||
# [Chapter 8.0] 단일 세그먼트 Opus 압축 인코딩
|
||||
import subprocess
|
||||
import uuid
|
||||
os.makedirs("data/audio", exist_ok=True)
|
||||
audio_path = f"data/audio/seg_{int(time.time())}_{uuid.uuid4().hex[:6]}.ogg"
|
||||
try:
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", processed_wav,
|
||||
"-ss", str(segment.start), "-to", str(segment.end),
|
||||
"-c:a", "libopus", "-b:a", "16k", audio_path
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"ffmpeg Opus 인코딩 실패: {e}")
|
||||
audio_path = None
|
||||
|
||||
stt_segments.append(
|
||||
STTSegment(
|
||||
start_sec=round(segment.start, 2),
|
||||
end_sec=round(segment.end, 2),
|
||||
text=corrected_seg_text,
|
||||
speaker=current_speaker,
|
||||
absolute_start_time=abs_start_time,
|
||||
absolute_end_time=abs_end_time,
|
||||
audio_path=audio_path
|
||||
)
|
||||
)
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
# 메모리 프로파일링 완료
|
||||
current_mem, peak_mem = tracemalloc.get_traced_memory()
|
||||
tracemalloc.stop()
|
||||
|
||||
# 4. Pydantic Response 반환
|
||||
from app.services.analyzer import check_urgency, extract_train_number
|
||||
final_text = " ".join(final_text_parts)
|
||||
urgency = check_urgency(final_text)
|
||||
train_number = extract_train_number(final_text)
|
||||
|
||||
return STTResponse(
|
||||
text=final_text,
|
||||
language=info.language if info else request.language,
|
||||
segments=stt_segments,
|
||||
processing_time_sec=round(processing_time, 2),
|
||||
load_time_sec=round(load_time, 2),
|
||||
audio_duration_sec=round(audio_duration_sec, 2),
|
||||
peak_memory_mb=round(peak_mem / 1024 / 1024, 2),
|
||||
process_speed_x=round(audio_duration_sec / processing_time, 2) if processing_time > 0 else 0,
|
||||
urgency=urgency,
|
||||
train_number=train_number
|
||||
)
|
||||
|
||||
except STTError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise STTError(f"STT 변환 실패: {str(e)}")
|
||||
finally:
|
||||
if processed_wav and processed_wav != request.audio_file_path:
|
||||
AudioParser.cleanup(processed_wav)
|
||||
Binary file not shown.
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "HUTAMS 관제",
|
||||
"short_name": "HUTAMS",
|
||||
"description": "LTE-R 기반 지능형 철도관제 대시보드",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0ea5e9",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/static/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
|||
@echo off
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
|
||||
cd d:\py_train\Wisper
|
||||
set VIRTUAL_ENV=d:\py_train\Wisper\.venv
|
||||
set PATH=d:\py_train\Wisper\.venv\Scripts;%PATH%
|
||||
uv pip install whisper-cpp-python
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@echo off
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
|
||||
cd /d d:\py_train\Wisper
|
||||
set VIRTUAL_ENV=d:\py_train\Wisper\.venv
|
||||
set PATH=d:\py_train\Wisper\.venv\Scripts;%PATH%
|
||||
pip install --no-cache-dir setuptools wheel scikit-build cmake ninja cffi
|
||||
pip install --no-cache-dir --no-build-isolation whisper-cpp-python
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
# 📜 [Python 프로젝트 범용 기본 헌법] v1.5
|
||||
|
||||
## 서문 (Preamble)
|
||||
|
||||
이 문서는 본 프로젝트의 AI 에이전트와 개발자가 준수해야 할 **절대적인 가이드라인**이다.
|
||||
우리는 복잡성을 거부하고 본질에 집중하며, 가장 효율적이고 직관적인 방식으로 Python 기반의 소프트웨어를 구축한다.
|
||||
이 헌법은 **제1원칙 사고**, **MVC 패턴**, **견고한 엔지니어링**을 기반으로 하며, 모든 소통은 **한국어**를 원칙으로 한다.
|
||||
|
||||
---
|
||||
|
||||
## 제1조: 사고방식과 접근 태도 (Mindset & Approach)
|
||||
|
||||
### 1.1 제1원칙 사고 (First Principles Thinking)
|
||||
|
||||
- **타협 없는 근본주의:** "원래 이렇게 해왔다"는 관성을 거부한다. 물리적, 논리적으로 불가능한 것이 아니라면 모든 제약 조건을 의심하고 바닥부터 다시 생각한다.
|
||||
- **복잡성은 죄악이다:** 코드는 바보가 봐도 이해할 수 있을 만큼 단순해야 한다. 과도한 엔지니어링(Over-engineering)을 엄격히 금지한다.
|
||||
|
||||
### 1.2 바이브 코딩 & 완결성 (Vibe Coding & Completeness)
|
||||
|
||||
- **몰입과 속도:** 완벽함보다 '작동함'과 '개발자의 몰입(Flow)'을 중시한다.
|
||||
- **직관적인 흐름:** 코드는 시처럼 읽혀야 한다. 논리의 흐름이 뚝뚝 끊기지 않고 자연스럽게 연결되도록 작성한다.
|
||||
- **흐름의 유지 (Flow State):**
|
||||
- AI는 코드를 제안할 때 **"작동 가능한 최소 단위(MVP)"**를 먼저 제시한다.
|
||||
- 완벽한 예외 처리보다는 핵심 기능의 구현을 우선하고, 이후에 리팩토링(Refactoring)한다.
|
||||
- 사용자의 의도가 모호할 때는 멈추지 말고 가장 합리적인 방향으로 먼저 작성한 뒤, **"이렇게 구현했는데 의도와 맞나요?"**라고 확인한다.
|
||||
- **생략 없는 구현 (No Loose Ends):**
|
||||
- **`TODO`, `pass`, "추후 추가 예정" 금지:** 현재 구현해야 할 기능이라면 100% 동작하게 만든다. 지금 필요 없다면(YAGNI) 아예 작성하지 않는다. 어중간한 채워넣기(Placeholder)는 허용하지 않는다.
|
||||
- **블랙박스 신뢰성:** 명세가 확실하다면, 사용자가 내부 코드를 한 줄도 읽지 않고도 믿고 쓸 수 있을 만큼 **완벽하게** 구현해야 한다. 사용자가 코드를 검증하게 만드는 것은 AI의 직무유기다.
|
||||
- **에러 대응 (Debug Attitude):**
|
||||
- 에러가 발생하면 당황하지 않고 **스택 트레이스(Stack Trace)**를 분석한다.
|
||||
- 수정 제안 시: "이게 문제인 것 같습니다"가 아니라, **"이 부분을 이렇게 수정하면 해결됩니다"**라고 확정적인 코드를 제공한다.
|
||||
---
|
||||
|
||||
## 제2조: 아키텍처 및 구조 (Architecture)
|
||||
|
||||
### 2.1 철저한 MVC 분리
|
||||
|
||||
모든 프로젝트는 명확한 **역할 분담(Separation of Concerns)**을 따른다.
|
||||
|
||||
| 역할 | 비유 | 설명 |
|
||||
|---|---|---|
|
||||
| **Model** (데이터와 로직) | 🧠 뇌 | 데이터 구조(Pydantic), 비즈니스 로직, DB 상호작용. **UI를 전혀 몰라야 함.** |
|
||||
| **View** (사용자 인터페이스) | 😊 얼굴 | 데이터를 시각적으로 표현하는 것**만** 담당한다. **비즈니스 로직을 포함해서는 안 된다.** (Console, Web, GUI 모두 해당) |
|
||||
| **Controller** (중재자) | 🔗 신경망 | 사용자의 입력을 받아 Model을 갱신하고 View에 전달. 흐름 제어. |
|
||||
|
||||
|
||||
### 2.2 디렉토리 구조 (Standard Layout)
|
||||
|
||||
`core` 패키지를 도입하여 설정과 예외를 중앙 관리한다.
|
||||
|
||||
```
|
||||
project_root/
|
||||
├── app/
|
||||
│ ├── core/ # [심장] 프로젝트의 핵심 설정 및 표준
|
||||
│ │ ├── config.py # 환경변수, 상수 관리 (하드코딩 금지)
|
||||
│ │ └── exceptions.py # 커스텀 예외 정의 (여기 없는 에러 금지)
|
||||
│ ├── models/ # [뇌] Pydantic 모델, DB 스키마, 비즈니스 로직
|
||||
│ ├── views/ # [얼굴] UI/UX (Console, Web, GUI)
|
||||
│ ├── controllers/ # [신경망] 제어 로직
|
||||
│ └── utils/ # [도구] 로깅, 공통 헬퍼 함수
|
||||
├── tests/ # 테스트 코드
|
||||
├── docs/ # 문서 (한글 작성)
|
||||
└── main.py # 진입점
|
||||
```
|
||||
|
||||
### 2.3 모듈화와 단일 책임 (SRP)
|
||||
|
||||
- 하나의 함수/클래스는 오직 **하나의 목적**만 수행한다.
|
||||
- 모듈 크기는 **500라인**을 넘으면 강제로 분리한다.
|
||||
|
||||
### 2.4 데이터 모델링 (Data Modeling)
|
||||
|
||||
- **Pydantic 도입:** 데이터 검증과 관리는 `dict`나 튜플 대신 반드시 **Pydantic Model**을 사용한다.
|
||||
- 데이터의 유효성은 입력 시점에 엄격하게 검증하여, 로직 내부에서는 오염된 데이터가 돌아다니지 않도록 한다.
|
||||
|
||||
---
|
||||
|
||||
## 제3조: 언어 및 소통 프로토콜 (Language Protocol)
|
||||
|
||||
### 3.1 한국어 전용 원칙 (Korean Only)
|
||||
|
||||
- **모든 대화 및 문서:** AI와의 대화, 코드에 대한 설명, 주석(Comment), 문서(Docstring), 리드미(README) 등 모든 텍스트 기반 정보는 **반드시 한국어(한글)**로 작성한다.
|
||||
- **`print()` 문, 로그 메시지, 사용자 UI 텍스트**는 무조건 한국어로 작성한다.
|
||||
- **주석**은 개발자가 나중에 읽었을 때 **1초 만에 이해**할 수 있도록 친절한 한국어 구어체를 사용한다.
|
||||
- ❌ (Bad) `# Check user validity based on timestamp.`
|
||||
- ✅ (Good) `# 타임스탬프를 확인해서 유효한 사용자인지 검사함.`
|
||||
- **예외:** 프로그래밍 언어 문법(키워드), 라이브러리 함수명 등 코드 자체는 영어를 사용하되, 변수명은 의미가 명확한 영어 단어를 사용한다.
|
||||
|
||||
### 3.2 명확성과 존중
|
||||
|
||||
- AI는 개발자에게 모호한 답변을 하지 않는다. "그럴 수도 있습니다" 대신, **근거에 기반한 명확한 솔루션**을 제시한다.
|
||||
- 코드 설명 시, 복잡한 전문 용어보다는 **직관적인 비유와 쉬운 한국어 표현**을 우선한다.
|
||||
- AI와의 대화, 주석, 문서, 리드미 등 모든 텍스트는 **한국어**로 작성한다.
|
||||
- **예외:** 코드 문법(키워드)은 영어, 변수명은 명확한 영어 단어 사용.
|
||||
|
||||
---
|
||||
|
||||
## 제4조: 엔지니어링 표준 (Engineering Standards)
|
||||
|
||||
### 4.1 Pythonic Style
|
||||
|
||||
- 타입 힌트(Type Hinting)는 **선택이 아닌 필수**다.
|
||||
- 리스트 컴프리헨션 등을 적절히 활용하되, 가독성을 해치지 않는다.
|
||||
|
||||
### 4.2 설정 관리 (Configuration)
|
||||
|
||||
- **하드코딩 절대 금지:** API 키, DB 주소, 타임아웃 시간 등 모든 설정값은 코드에 박아넣지 않는다.
|
||||
- 반드시 `app/core/config.py` (Pydantic BaseSettings 등 활용)를 경유하여 호출한다.
|
||||
|
||||
### 4.2 네이밍 컨벤션 (Naming)
|
||||
|
||||
| 대상 | 규칙 | 예시 |
|
||||
|---|---|---|
|
||||
| 변수 / 함수 | `snake_case` | `user_name`, `get_data` |
|
||||
| 클래스 | `PascalCase` | `UserHandler` |
|
||||
| 상수 | `UPPER_CASE` | `MAX_RETRY` |
|
||||
|
||||
### 4.4 에러 처리의 표준화 (Exception Handling)
|
||||
- 에러를 침묵시키지 않는다(**`pass` 금지**).
|
||||
- **표준 예외 사용:** AI가 임의로 `Exception`을 던지지 않는다. 반드시 `app/core/exceptions.py`에 정의된 커스텀 예외(예: `[Domain]Error`, `[Action]Error`)를 사용한다.
|
||||
- **침묵 금지:** `try-except pass`는 해고 사유다. 모든 에러는 로그를 남기거나 사용자에게 알기 쉽게(한국어로) 전파되어야 한다.
|
||||
|
||||
### 4.5 유지보수성 최적화 (Optimized for Maintenance)
|
||||
|
||||
- **주석(Comments):** 단순히 코드를 번역하지 말고 **"왜(Why)"**를 기록한다. 개발자가 1년 뒤에 봐도 1초 만에 이해할 수 있어야 한다.
|
||||
- **로깅(Logging):** 디버깅을 위해 로거(Logger)를 적극 활용한다. `print()` 대신 로거를 사용하며, 로그 레벨(INFO, ERROR)을 명확히 구분한다.
|
||||
|
||||
---
|
||||
### 4.6 오케스트레이션 및 서비스 생애주기 (Lifecycle & Orchestration)
|
||||
- **API 중앙화:** 다수의 라우터는 개별적으로 `main.py`에 등록하지 않으며, 반드시 `api_v1.py` 등 중앙 라우터에 `include_router`로 묶어서 퍼사드(Facade) 형태로 진입점을 단일화한다.
|
||||
- **의존성 중앙화:** 서비스 인스턴스, DB 세션 등의 주입(DI) 함수는 `dependencies.py` 한 곳에서 통합 관리하여 추후 인증/권한 로직 결합에 대비한다.
|
||||
- **서비스 생애주기 (1 Request = 1 Instance):** 모든 서비스 객체는 FastAPI의 `Depends()`를 통해 주입받으며, HTTP 요청 1건당 생성되고 응답 시 소멸하는 무상태(Stateless)를 엄격히 유지한다.
|
||||
- **복합 트랜잭션 (Orchestrator/Facade 패턴):** 여러 도메인의 CRUD가 혼합된 복잡한 비즈니스 로직(예: 인수인계)은 라우터에서 개별 호출하지 않고, 해당 도메인을 아우르는 단일 Facade Service(예: `HandoverService`)를 구축하여 **단일 DB 세션(Session) 하에서 원자적(Atomic)으로 묶어 롤백(Rollback)을 보장**해야 한다.
|
||||
|
||||
## 제5조: AI 에이전트의 행동 강령 (AI Persona)
|
||||
|
||||
- **수동적 도구가 아닌 파트너:** 개발자의 지시가 제1원칙에 위배되거나 비효율적일 경우, AI는 이를 지적하고 더 나은 대안(Better Alternative)을 **제시해야 할 의무**가 있다.
|
||||
- **전체 그림 파악:** 단순히 시키는 것만 하지 않는다. 이 코드가 프로젝트 전체(1인 유니콘 기업)에서 어떤 역할을 하는지 이해하고, 그 수준에 걸맞은 퀄리티로 작성한다.
|
||||
|
||||
### 5.1 상세 작업 지침 (Detailed Work Guidelines)
|
||||
|
||||
1. **Context Awareness (맥락 인식):** 작업을 시작하기 전에 반드시 전체 파일 구조를 인덱싱하고, 관련 파일(`models.py`, `services/` 등)의 내용을 먼저 읽은 뒤 제안한다.
|
||||
2. **Sample First (데이터 우선):** 파싱이나 데이터 처리 로직 수정 시, `data/samples/`에 있는 실제 데이터 규격을 최우선으로 참고한다.
|
||||
3. **Test-Driven Modification (테스트 주도):**
|
||||
- 기능을 추가하거나 수정할 때, 해당 기능을 검증할 수 있는 테스트 스크립트(`tests/`)가 있는지 확인한다.
|
||||
- 테스트가 없다면 테스트 코드를 먼저 작성하거나 제안하고, 수정 후에는 반드시 테스트 통과 여부를 리포트한다.
|
||||
4. **Step-by-Step Thinking (단계적 사고):** 복잡한 로직 수정 시 바로 코드를 짜지 말고, `docs/project_spec.md`에 의거한 작업 순서를 먼저 브리핑하고 사용자의 승인을 얻은 뒤 코딩을 시작한다.
|
||||
|
||||
### 5.2 로드맵 정합성 (Roadmap Consistency)
|
||||
|
||||
- **아이디어 기록:** 새로운 아이디어나 추가 기능은 코드에 바로 넣지 않고 `docs/roadmap.md`에 먼저 기록하여 전체 아키텍처와의 정합성을 검토한다.
|
||||
|
||||
### 5.3 파일 분리 원칙 (File Splitting)
|
||||
|
||||
- **의존성 주입(DI) 준수:** 파일을 분리할 때는 단순히 코드를 자르는 것이 아니라, **의존성 주입(Dependency Injection)** 원칙을 철저히 지켜야 한다.
|
||||
- **분리 기준:** 단일 파일이 600라인을 초과하거나, 특정 기능(예: DB 처리, API 통신)이 너무 비대해졌을 때 분리를 제안한다.
|
||||
- **구현 방식:**
|
||||
- 분리된 모듈은 생성자(Constructor)나 메서드 인자를 통해 필요한 객체(Dependency)를 주입받아야 한다.
|
||||
- 전역 변수나 싱글톤(Singleton) 남용을 지양하고, 명시적인 의존성 관리를 통해 테스트 용이성과 코드의 유연성을 확보해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 제6조: 문서화 및 프로젝트 관리 (Documentation & Management)
|
||||
|
||||
### 6.1 Living Documents (살아있는 문서)
|
||||
|
||||
모든 프로젝트는 아래 3종의 문서를 `docs/` 폴더에 유지하며, 기능 수정 시 코딩보다 문서 업데이트를 우선한다.
|
||||
|
||||
| 문서명 | 설명 |
|
||||
|---|---|
|
||||
| `project_spec.md` | 프로젝트의 목적, 핵심 기능, 비즈니스 로직 정의 |
|
||||
| `api_contract.md` | 모듈/함수 간 입출력 규격 및 Pydantic 모델 매핑 정보 |
|
||||
| `issue.md` | 현재 발견된 버그, 해결해야 할 과제, 작업 이력 기록 |
|
||||
|
||||
### 6.2 AI의 문서 업데이트 의무 (Documentation Duty)
|
||||
|
||||
- **상태 동기화:** AI는 작업을 완료할 때마다 `issue.md`의 진행 상태를 갱신하고, 변경된 인터페이스가 있다면 `api_contract.md`를 즉시 수정한다.
|
||||
- **선 문서화, 후 코딩:** 사용자가 "A 기능 추가해줘"라고 하면, AI는 관련 문서(spec, contract)를 먼저 수정하여 제안한 뒤 코딩을 시작한다.
|
||||
|
||||
|
||||
## 제7조: Git (GitHub/Gitea) PR 및 Issue 연동 규칙
|
||||
|
||||
### 7.1 브랜치 전략 (Branching)
|
||||
- `main`: 운영(`prod`) 환경 배포용
|
||||
- `develop`: 개발(`dev`) 환경 배포용
|
||||
- `feature/[#이슈번호]-[작업명]`: 개별 기능 개발용 (예: `feature/#12-handover-api`)
|
||||
|
||||
### 7.2 Issue 동기화 및 PR (Pull Request) 작성
|
||||
- **로컬-원격 동기화:** `docs/issue.md`에 기록하는 작업 내역은 원격 Issue 번호와 1:1 매핑되어야 한다.
|
||||
- **PR 필수 포함 내용:** AI 제안 시 1) 해결 Issue 번호(`Closes #12`), 2) `api_contract.md` 변경 요약, 3) 테스트 통과 여부를 반드시 포함한다.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# WebSocket Events
|
||||
|
||||
모든 실시간 통신은 `/api/v1/ws/live` 엔드포인트에서 이뤄집니다.
|
||||
|
||||
1. **`stt_result`**: 오디오 변환 완료 즉시 발생.
|
||||
- Payload: Pydantic `STTResponse` JSON
|
||||
- 긴급도(`urgency`), 열번(`train_number`), 세그먼트 데이터 포함.
|
||||
|
||||
2. **`thread_updated`**: 병목이 걸리는 백그라운드 LLM 연산 처리 이후 보내지는 후행 이벤트.
|
||||
- Payload: `{"action": "append|new", "record_id": num, "segment_id": num, "speaker": str}`
|
||||
|
||||
3. **`context_discovered`**: 관제 전문 지식이 포함되어 있을 경우 딕셔너리 정보 반환.
|
||||
- Payload: `contexts` 배열 (키워드, 제목, 설명글 등)
|
||||
|
||||
# REST API
|
||||
|
||||
1. **`GET /api/v1/segments/{segment_id}/audio`**: Opus 압축 오디오 재생 스트리밍
|
||||
2. **`GET /api/v1/records`**: STT 변환 이력 목록 조회
|
||||
3. **`GET /api/v1/segments/daily`**: 일자별 세그먼트 채팅뷰 커서 페이징 조회
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# HUTAMS 관제 이슈 노트
|
||||
|
||||
## 1. 해결됨
|
||||
- **UI Jitter 방지**: WebSocket 기반 실시간 렌더링 시, LLM 응답과의 충돌로 인한 블록 잔소리 문제 완전히 개선. (js `_sttResultQueue` 타임아웃 1500ms debounce merge).
|
||||
- **Rule-Based Urgent State 추출**: 빠른 속도로 지연을 획기적으로 낮춰 LLM이 백단에서 도는 중에 UI가 이미 반응을 하도록. K1234 및 삼천사십이와 조합된 텍스트 필터 지원 완료.
|
||||
- **PWA 연동**: manifest 스펙 구현됨 (앱 아이콘은 차후 보강).
|
||||
|
||||
## 2. 향후 과제
|
||||
- 현장 무전기 Line-in 테스트를 위해 오디오 장비 검증 및 마이크 설정 확인. (Record Sample Script 적용완료).
|
||||
- 보안 API 인증 의존성 점검 (JWT/Depends 주입 시나리오 검토).
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# STT API 및 모델 로드 가이드
|
||||
|
||||
이 프로젝트는 현재 파이썬 환경의 **faster-whisper**를 기반으로 작동합니다. (기존 whisper.cpp의 C++ 빌드 의존성을 제거하고, 윈도우 환경에서의 완벽한 호환성을 확보했습니다.)
|
||||
|
||||
## 1. 모델 준비 및 로딩 방식
|
||||
|
||||
### 1-1. 작동 원리
|
||||
`faster-whisper`는 **CTranslate2** 엔진을 내부적으로 사용하여 Whisper 모델의 속도와 메모리를 극한으로 최적화한 구현체입니다.
|
||||
|
||||
- **모델 파일 다운로드 불필요:** 기존 C++ 포맷(.bin)을 수동으로 받을 필요가 없습니다.
|
||||
- 파이썬 코드가 실행될 때 `settings.WHISPER_MODEL_NAME` (예: `"base"`, `"large-v3-turbo"`) 문자열을 통해 HuggingFace에서 자동으로 최적화된 모델 캐시를 로컬 폴더로 다운로드합니다.
|
||||
- **CPU 환경 최적화**: 코드 내에서 `device="cpu"` 및 `compute_type="int8"` 가 지정되어 있어 무거운 GPU 세팅 없이도 가볍고 쾌적하게 동작합니다.
|
||||
|
||||
### 1-2. 모델 변경
|
||||
더 높은 정확도를 원한다면 `app/core/config.py` 에서 모델 이름을 수정하기만 하면 됩니다.
|
||||
- `"base"`: 처리 속도 우선시
|
||||
- `"small"`: 밸런스형
|
||||
- `"large-v3-turbo"`: 한국어 등 다국어 처리 시 극강의 정확도
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI 아키텍처 도입 안내
|
||||
실시간 무전 수신과 대시보드 웹 UI 연동을 위해 `FastAPI` 기반의 백엔드를 구축했습니다. 시스템은 다음과 같이 변경됩니다.
|
||||
|
||||
- **RESTful API Endpoint 구성**: 다른 시스템이나 프론트엔드에서 `/api/v1/transcribe` 로 HTTP POST 요청만 보내면 바로 결과를 받을 수 있습니다.
|
||||
- **Pydantic 연동 최적화**: `STTResponse` 객체가 FastAPI의 Swagger UI와 100% 자동 매핑됩니다.
|
||||
- **비동기 확장성**: 현재는 동기식 파일 분석으로 작성되었으나, FastAPI의 `async/await` 구조 덕분에 향후 WebSocket 실시간 스트리밍 분석 등으로 즉시 확장 가능합니다.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# HUTAMS STT 파이프라인
|
||||
|
||||
이 시스템은 현장 관제를 위한 초저지연 오디오 인식 및 지식 결합 파이프라인을 구축하고 있습니다.
|
||||
기반 아키텍처: FastAPI + SQLite + Faster-Whisper + On-device LLM + WebSocket.
|
||||
|
||||
## 1. 단계별 실행 흐름
|
||||
|
||||
1. **음성 포착 (VAD)**
|
||||
MockAudioListener 또는 RadioListener(실마이크) 백그라운드 스레드가 임계치를 넘는 음성만을 분리하여 임시 `wav` 파일로 만들어 저장합니다.
|
||||
해당 파일 완성 직후 `on_segment` 콜백이 트리거되어 STT Service에 넘깁니다.
|
||||
|
||||
2. **음성 처리 및 Opus 인코딩 (STT Service)**
|
||||
Faster-Whisper V3 Turbo가 텍스트를 인식합니다.
|
||||
음성은 고효율(16k libopus) `.ogg` 로 백업되어 보관(`data/audio/`)됩니다.
|
||||
1차 보정: Rapidfuzz를 사용해 고속으로 4,000건의 철도 용어 사전(`dictionary.py`) 대조 작업을 거친 뒤 철자를 교정합니다.
|
||||
|
||||
3. **Rule-based 긴급 상황 식별 (Analyzer)**
|
||||
교정된 문장에서 룰 기반 긴급 키워드 필터링과 다국어(한국어 단위)가 섞인 열차 번호("삼천사십이")를 정규식으로 직접 추출합니다.
|
||||
이 룰 판별은 LLM에 들어가는 병목을 우회하여 즉시(Socket Event) Pydantic 모델로 주입됩니다.
|
||||
|
||||
4. **Background Data Integration (LLM Worker)**
|
||||
STT의 큐가 넘어간 다음 0.8B Qwen 로컬 모델(`llm_service.py`)이 다음과 같은 두 가지 행동을 백그라운드로 실행합니다:
|
||||
- `thread_updated`: 이전 대화와의 이어지는 맥락인지 화자를 식별.
|
||||
- `context_discovered`: 관련 철도 전문 사전 키워드가 있는지 추출해 우측 Context 패널에 띄웁니다.
|
||||
|
||||
5. **클린업 스케줄러 (lifespan)**
|
||||
FastAPI 서비스 생명주기에 결합되어, 매 24시간마다 3일 이상 지난 일반 오디오(긴급 아님) 라인들을 삭제하며 디스크를 관리합니다.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# 🚀 바이브 코딩 프롬프트 마스터 가이드 (Human Only)
|
||||
|
||||
이 가이드는 **[Python 프로젝트 범용 기본 헌법 v1.5+]** 체계 아래에서 AI 에이전트를 완벽하게 조종하기 위한 개발자 전용 지침서입니다. AI에게 명령을 내리기 전, 상황에 맞는 프롬프트를 복사하여 사용하십시오.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 초기화 (Setup)
|
||||
새로운 프로젝트를 시작하거나 AI에게 전체 맥락을 주입할 때 사용합니다.
|
||||
|
||||
### 📜 헌법 및 컨텍스트 주입
|
||||
> "루트의 `AI_CONTEXT.md`(또는 `gemini.md`)를 정독해. 이 프로젝트의 모든 사고방식과 코딩 스타일은 이 헌법을 절대적으로 따른다. 준비됐으면 핵심 원칙 3가지를 요약해서 보고해."
|
||||
|
||||
### 📂 표준 구조 및 문서 생성
|
||||
> "우리 헌법 제2조의 표준 레이아웃에 따라 폴더 구조를 생성해. 그리고 `docs/` 폴더에 `project_spec.md`, `api_contract.md`, `issue.md`, `roadmap.md` 4종 문서의 초안을 범용적인 골격으로 생성해줘."
|
||||
|
||||
---
|
||||
|
||||
## 2. 기능 설계 및 로드맵 관리 (Design & Planning)
|
||||
코드 작성 전, 설계의 정합성을 맞추는 단계입니다. (헌법 제6조 준수)
|
||||
|
||||
### 💡 아이디어 제안 및 로드맵 기록
|
||||
> "새로운 아이디어가 있어: [아이디어 내용]. 헌법 제5.2항에 따라 바로 코딩하지 말고 `docs/roadmap.md`에 먼저 기록해줘. 그리고 현재 아키텍처에서 이 기능이 미칠 영향도를 분석해봐."
|
||||
|
||||
### 📝 상세 사양 및 인터페이스 정의
|
||||
> "`docs/roadmap.md`의 [기능명]을 구현할 거야. 먼저 `docs/project_spec.md`에 상세 로직과 예외 케이스를 정의하고, `docs/api_contract.md`에 모듈 간 데이터 규격을 Pydantic 모델 기반으로 설계해줘. **내 승인 전까지 코딩은 금지한다.**"
|
||||
|
||||
---
|
||||
|
||||
## 3. 실제 구현 (Implementation)
|
||||
설계가 확정된 후, '완결성' 있는 코드를 생산하게 만드는 단계입니다.
|
||||
|
||||
### 🧠 데이터 모델링 (Pydantic)
|
||||
> "`docs/api_contract.md`에 정의된 스키마를 바탕으로 `app/models/`에 Pydantic 모델을 구현해. 타입 힌트는 필수이며, 데이터 유효성 검사 로직을 포함해줘. 주석은 헌법 제3조에 따라 친절한 한국어로 작성해."
|
||||
|
||||
### 🛠 모듈 구현 (No Loose Ends)
|
||||
> "정의된 사양에 따라 [기능명]의 핵심 로직을 `app/services/`에 구현해. 헌법 1.2항에 의거해 `TODO`, `pass` 등 임시 코드는 절대 사용하지 말고, 단독으로 실행 가능한 완벽한 형태로 작성해줘."
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 및 품질 관리 (Testing & QA)
|
||||
AI가 만든 코드가 진짜 작동하는지 확인하게 합니다.
|
||||
|
||||
### 🧪 테스트 코드 우선 작성 (TDD)
|
||||
> "기능 구현에 앞서, 이를 검증할 수 있는 단위 테스트를 `tests/` 폴더에 작성해. [특정 조건]일 때 성공하고 [에러 조건]일 때 예외를 던지는지 확인해야 해."
|
||||
|
||||
### ✅ 결과 검증 및 리포트
|
||||
> "방금 작성한 코드가 테스트를 통과하는지 실행해보고 결과를 리포트해. 만약 실패한다면 헌법 제1.2항의 '에러 대응' 원칙에 따라 확정적인 수정안을 제시해."
|
||||
|
||||
---
|
||||
|
||||
## 5. 유지보수 및 이슈 관리 (Maintenance)
|
||||
작업을 마무리하고 기록을 남기는 단계입니다.
|
||||
|
||||
### 🔄 문서 업데이트 및 동기화
|
||||
> "작업이 완료되었으니 헌법 제6.3항에 따라 `docs/issue.md`의 작업 상태를 갱신하고, 변경된 인터페이스가 있다면 `docs/api_contract.md`에 반영해줘. 이번 작업으로 해결된 버그나 남은 과제도 기록해."
|
||||
|
||||
---
|
||||
|
||||
## 💡 개발자를 위한 바이브 코딩 팁 (Tips)
|
||||
|
||||
1. **AI의 '직무유기' 방지:** AI가 "이 부분은 직접 구현하세요"라고 하면, 즉시 **헌법 제1.2항(생략 없는 구현)**을 상기시키며 다시 요구하세요.
|
||||
2. **한국어 원칙 고수:** 로그 메시지나 주석이 영어로 나온다면 **헌법 제3조**를 근거로 즉시 수정을 지시하세요. 나중에 사용자님이 코드를 읽을 때의 피로도가 달라집니다.
|
||||
3. **샘플 데이터의 힘:** 복잡한 로직일수록 `data/samples/`에 예시 데이터를 던져주고 "이걸 보고 판단해"라고 하는 것이 수천 줄의 설명보다 정확합니다.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# 🚀 Project Roadmap
|
||||
|
||||
## Phase 1: MVP (Minimum Viable Product)
|
||||
- [ ] 프로젝트의 본질적인 가치를 증명하는 핵심 기능 구현
|
||||
|
||||
## Phase 2: 안정화 및 고도화
|
||||
- [ ] 성능 최적화, 테스트 코드 확충, 예외 처리 보강
|
||||
|
||||
## Phase 3: 기능 확장 및 연동
|
||||
- [ ] 외부 API 연동, 웹 UI, 모바일 UI, 멀티플랫폼 강화
|
||||
|
||||
## 💡 아이디어 보관함 (Icebox)
|
||||
- [나중에 구현하고 싶은 창의적인 생각들을 자유롭게 기록]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# 🚀 AI Agent Bootloader: [Project Name]
|
||||
|
||||
당신은 이 프로젝트의 **수석 파이썬 개발자(Senior Python Developer)**입니다.
|
||||
사용자는 **시스템 아키텍트(System Architect)**이며, 당신은 아래의 '절대 원칙'을 준수하며 코드를 구현합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🛑 작업 시작 전 필수 체크리스트 (MUST)
|
||||
어떠한 코드 수정이나 제안을 하기 전에, 반드시 다음 순서로 파일을 읽고 분석 결과를 요약 보고하십시오.
|
||||
|
||||
1. `docs/ai_context.md`: 시스템 아키텍처 및 코딩 헌법 숙지
|
||||
2. `docs/project_spec.md`: 현재 작업의 요구사항 확인
|
||||
3. `docs/api_contract.md`: 데이터 모델(Pydantic) 및 규격 확인
|
||||
4. `docs/issue.md`: 현재 해결해야 할 문제 및 진행 상황 파악
|
||||
|
||||
**위 단계를 누락하고 코드를 작성하는 것은 시스템 설계를 파괴하는 행위로 간주됩니다.**
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 개발 원칙 (Core Rules)
|
||||
- **Strict MVC:** 로직(Model), 흐름(Controller), UI(View)를 철저히 분리하십시오.
|
||||
- **Pydantic Only:** 모든 데이터 교환은 Pydantic 모델을 사용하며, 딕셔너리 직접 전달을 금지합니다.
|
||||
- **No Incomplete Code:** 핵심 경로에 `TODO`, `pass`, 임시 코드를 남기지 마십시오.
|
||||
- **Context Protection:** 단일 파일 600라인 초과 시 반드시 모듈 분리를 제안하십시오.
|
||||
|
||||
---
|
||||
|
||||
## 💬 소통 규칙
|
||||
- 모든 주석, 로그 메시지, 문서는 **한국어**로 작성합니다.
|
||||
- 코드와 변수명은 **영어**를 사용합니다.
|
||||
- 수정 사항이 발생하면 반드시 `docs/issue.md`와 관련 문서를 먼저 업데이트한 후 코드를 수정하십시오.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
DB 스키마 마이그레이션.
|
||||
- transcription_records: LLM 메타데이터 컬럼 추가 (title, summary, keywords, urgency)
|
||||
- transcription_segments: speaker 컬럼 추가 (VARCHAR(10), 기본값 '불명')
|
||||
"""
|
||||
from sqlalchemy import create_engine, text, inspect
|
||||
|
||||
e = create_engine("sqlite:///whisper.db")
|
||||
insp = inspect(e)
|
||||
|
||||
# ── 1. transcription_records 테이블 ───────────────────────────────────────────
|
||||
existing_rec = [c["name"] for c in insp.get_columns("transcription_records")]
|
||||
print("[ transcription_records ] 현재 컬럼:", existing_rec)
|
||||
|
||||
record_cols = [
|
||||
("title", "VARCHAR(256)"),
|
||||
("summary", "TEXT"),
|
||||
("keywords", "VARCHAR(512)"),
|
||||
("urgency", "VARCHAR(16)"),
|
||||
]
|
||||
|
||||
with e.connect() as conn:
|
||||
for col, typ in record_cols:
|
||||
if col not in existing_rec:
|
||||
conn.execute(text(f"ALTER TABLE transcription_records ADD COLUMN {col} {typ}"))
|
||||
print(f" ✅ 추가됨: {col}")
|
||||
else:
|
||||
print(f" ⏭️ 이미 존재: {col}")
|
||||
conn.commit()
|
||||
|
||||
# ── 2. transcription_segments 테이블 ─────────────────────────────────────────
|
||||
existing_seg = [c["name"] for c in insp.get_columns("transcription_segments")]
|
||||
print("\n[ transcription_segments ] 현재 컬럼:", existing_seg)
|
||||
|
||||
segment_cols = [
|
||||
("speaker", "VARCHAR(10) DEFAULT '불명'"),
|
||||
("is_reviewed", "BOOLEAN DEFAULT 0 NOT NULL"), # False=0, 수동 검토 완료 여부
|
||||
("audio_path", "VARCHAR(512)"), # [Chapter 8.0] Opus 압축본 오디오 경로
|
||||
]
|
||||
|
||||
with e.connect() as conn:
|
||||
for col, typ in segment_cols:
|
||||
if col not in existing_seg:
|
||||
conn.execute(text(f"ALTER TABLE transcription_segments ADD COLUMN {col} {typ}"))
|
||||
print(f" ✅ 추가됨: {col}")
|
||||
else:
|
||||
print(f" ⏭️ 이미 존재: {col}")
|
||||
conn.commit()
|
||||
|
||||
print("\n마이그레이션 완료")
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# open_dashboard.ps1 — HUTAMS 프론트엔드 대시보드 열기 스크립트
|
||||
# 사용법: .\open_dashboard.ps1
|
||||
# 참고: 백엔드 서버(run_server.ps1)가 먼저 실행 중이어야 합니다.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
$serverUrl = "http://localhost:28000"
|
||||
$ws_url = "ws://localhost:28000/api/v1/ws/live"
|
||||
|
||||
# 서버 응답 확인
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $serverUrl -TimeoutSec 3 -ErrorAction Stop
|
||||
Write-Host "서버 응답 확인: $($response.StatusCode) OK" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "서버가 응답하지 않습니다. run_server.ps1을 먼저 실행하세요."
|
||||
Write-Host "5초 후 브라우저를 열겠습니다..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=================================" -ForegroundColor Cyan
|
||||
Write-Host " HUTAMS 관제 대시보드" -ForegroundColor Cyan
|
||||
Write-Host " URL : $serverUrl" -ForegroundColor White
|
||||
Write-Host " WS : $ws_url" -ForegroundColor White
|
||||
Write-Host "=================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Live Mode 사용법:" -ForegroundColor Yellow
|
||||
Write-Host " 1. 브라우저에서 대시보드 접속"
|
||||
Write-Host " 2. 우측 상단 [Live Mode] 토글 ON"
|
||||
Write-Host " 3. Mock 모드: sample1.m4a 파일이 실시간처럼 스트리밍됨"
|
||||
Write-Host " 4. 무전 내용이 우측 패널에 타자기 효과로 표시됨"
|
||||
Write-Host ""
|
||||
|
||||
# 기본 브라우저로 열기
|
||||
Start-Process $serverUrl
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
[project]
|
||||
name = "hutams"
|
||||
version = "0.1.0"
|
||||
description = "HUTAMS STT Service for LTE-R"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn>=0.23.0",
|
||||
"faster-whisper",
|
||||
"rapidfuzz",
|
||||
"sqlalchemy",
|
||||
"pydantic",
|
||||
"pydub",
|
||||
"python-multipart",
|
||||
"jinja2",
|
||||
"websockets"
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
"ruff"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
pytest>=7.4.0
|
||||
pydub>=0.25.1
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
faster-whisper>=1.0.0
|
||||
python-multipart>=0.0.9
|
||||
imageio-ffmpeg>=0.5.0
|
||||
rapidfuzz>=3.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# run_server.ps1 — HUTAMS 백엔드 서버 기동 스크립트
|
||||
# 사용법: .\run_server.ps1
|
||||
# 접속: http://localhost:28000 또는 http://127.0.0.1:28000
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 1. 기존에 실행 중인 uvicorn 프로세스 안전 종료
|
||||
taskkill /IM uvicorn.exe /F 2>$null
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
# 2. 가상환경이 있는지 확인
|
||||
if (-not (Test-Path ".\.venv\Scripts\uvicorn.exe")) {
|
||||
Write-Error "venv가 없습니다. 'uv venv && uv pip install -r requirements.txt'를 먼저 실행하세요."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 3. uvicorn 서버 기동
|
||||
# --host 0.0.0.0: localhost / 127.0.0.1 모두 수용 (WebSocket 정상 동작)
|
||||
# --port 28000: 기본 8000이 다른 서비스와 충돌하므로 28000 사용
|
||||
# --reload: 소스 변경 시 자동 재시작 (개발 환경 전용, 상용 제거 권장)
|
||||
Write-Host "HUTAMS 서버 기동 중... http://localhost:28000" -ForegroundColor Cyan
|
||||
.\.venv\Scripts\uvicorn.exe app.main:app --host 0.0.0.0 --port 28000 --reload
|
||||
Binary file not shown.
|
|
@ -0,0 +1,43 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# test_mock.ps1 — HUTAMS MOCK 오디오 시뮬레이터 테스트 스크립트
|
||||
# 사용법: .\test_mock.ps1
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host "=================================" -ForegroundColor Cyan
|
||||
Write-Host " HUTAMS MOCK Test Mode" -ForegroundColor Cyan
|
||||
Write-Host "=================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Enforcing AUDIO_SOURCE=mock in .env file and starting server."
|
||||
Write-Host ""
|
||||
|
||||
$envFile = ".\app\.env"
|
||||
|
||||
if (-not (Test-Path $envFile)) {
|
||||
Write-Host "Creating new .env file." -ForegroundColor Yellow
|
||||
"LLM_ENABLED=true`nLLM_MODEL_PATH=./Qwen3.5-0.8B-Q4_K_M.gguf`nAUDIO_SOURCE=mock`nMOCK_AUDIO_PATH=./sample1.m4a" | Out-File -Encoding UTF8 $envFile
|
||||
}
|
||||
else {
|
||||
$content = Get-Content $envFile
|
||||
$newContent = @()
|
||||
$found = $false
|
||||
foreach ($line in $content) {
|
||||
if ($line -match "^AUDIO_SOURCE=") {
|
||||
$newContent += "AUDIO_SOURCE=mock"
|
||||
$found = $true
|
||||
}
|
||||
else {
|
||||
$newContent += $line
|
||||
}
|
||||
}
|
||||
if (-not $found) {
|
||||
$newContent += "AUDIO_SOURCE=mock"
|
||||
$newContent += "MOCK_AUDIO_PATH=./sample1.m4a"
|
||||
}
|
||||
$newContent | Out-File -Encoding ASCII $envFile -Force
|
||||
Write-Host ".env configuration applied." -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "Restarting server..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
.\run_server.ps1
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
import pytest
|
||||
from app.models.stt import STTRequest, STTResponse
|
||||
from app.core.exceptions import AudioFileNotFoundError, ModelNotFoundError
|
||||
from app.services.stt_service import WhisperSTTService
|
||||
|
||||
class MockSegment:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
class MockInfo:
|
||||
def __init__(self, language):
|
||||
self.language = language
|
||||
|
||||
class MockWhisperModel:
|
||||
"""faster-whisper 엔진을 흉내내는 테스트용 Mock 클래스"""
|
||||
def __init__(self, model_size_or_path, device="cpu", compute_type="int8"):
|
||||
self.model_size_or_path = model_size_or_path
|
||||
|
||||
def transcribe(self, audio_path, beam_size=5):
|
||||
# 의도된 테스트 출력 결과 (무전 기록)
|
||||
segments = [MockSegment("열차 번호 1234, 기지 입고 확인했습니다. 수신 양호합니다.")]
|
||||
info = MockInfo(language="ko")
|
||||
return segments, info
|
||||
|
||||
def test_pydantic_schema_validation():
|
||||
"""Pydantic 모델이 오염된 데이터를 차단하고 정상 생성되는지 테스트"""
|
||||
req = STTRequest(audio_file_path="dummy.wav")
|
||||
assert req.audio_file_path == "dummy.wav"
|
||||
assert req.language == "ko" # Default 값 확인
|
||||
|
||||
def test_stt_transcription_success(monkeypatch, tmp_path):
|
||||
"""STT 전체 프로세스가 정상적으로 처리되는지 확인하는 테스트"""
|
||||
|
||||
# 1. 테스트용 더미 오디오
|
||||
dummy_audio = tmp_path / "test_audio.wav"
|
||||
dummy_audio.touch()
|
||||
|
||||
# 2. 서비스 인스턴스 초기화
|
||||
service = WhisperSTTService()
|
||||
service.model_name = "test_base"
|
||||
|
||||
# 3. WhisperModel 및 AudioParser Mock 객체로 대체(Patch)
|
||||
monkeypatch.setattr("app.services.stt_service.WhisperModel", MockWhisperModel)
|
||||
|
||||
# AudioParser는 변환 대신 그대로 리턴하게 모킹
|
||||
class MockAudioParser:
|
||||
@staticmethod
|
||||
def preprocess_audio(file_path, sample_rate=16000):
|
||||
return file_path
|
||||
@staticmethod
|
||||
def cleanup(file_path):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("app.services.stt_service.AudioParser", MockAudioParser)
|
||||
|
||||
# 4. Request 생성 및 실행
|
||||
request = STTRequest(audio_file_path=str(dummy_audio))
|
||||
response = service.transcribe(request)
|
||||
|
||||
# 5. Response 검증 (Pydantic 모델인지, 텍스트가 정상인지 검사)
|
||||
assert isinstance(response, STTResponse)
|
||||
assert "열차 번호 1234" in response.text
|
||||
assert response.language == "ko"
|
||||
assert response.processing_time_sec is not None
|
||||
|
||||
def test_stt_audio_file_not_found():
|
||||
"""존재하지 않는 오디오 파일 요청 시 커스텀 예외 발생 테스트"""
|
||||
service = WhisperSTTService()
|
||||
request = STTRequest(audio_file_path="invalid_path.wav")
|
||||
|
||||
with pytest.raises(AudioFileNotFoundError) as exc_info:
|
||||
service.transcribe(request)
|
||||
|
||||
assert "오디오 파일을 찾을 수 없습니다" in str(exc_info.value)
|
||||
|
||||
def test_stt_model_file_not_found(monkeypatch, tmp_path):
|
||||
"""Whisper 모델 로딩 실패 시 발생하는 예외 테스트"""
|
||||
dummy_audio = tmp_path / "test_audio.wav"
|
||||
dummy_audio.touch()
|
||||
|
||||
# AudioParser 모킹
|
||||
class MockAudioParser:
|
||||
@staticmethod
|
||||
def preprocess_audio(file_path, sample_rate=16000):
|
||||
return file_path
|
||||
@staticmethod
|
||||
def cleanup(file_path):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("app.services.stt_service.AudioParser", MockAudioParser)
|
||||
|
||||
# 모델 로딩을 강제로 실패하게 하는 모킹
|
||||
def mock_init(*args, **kwargs):
|
||||
raise ValueError("Simulated model loading error")
|
||||
|
||||
monkeypatch.setattr("app.services.stt_service.WhisperModel", mock_init)
|
||||
|
||||
service = WhisperSTTService()
|
||||
service.model_name = "invalid_model_path_or_name"
|
||||
|
||||
request = STTRequest(audio_file_path=str(dummy_audio))
|
||||
|
||||
with pytest.raises(ModelNotFoundError) as exc_info:
|
||||
service.transcribe(request)
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
from scipy.io.wavfile import write
|
||||
except ImportError:
|
||||
print("오류: sounddevice, numpy, scipy 패키지가 필요합니다.")
|
||||
print("uv pip install sounddevice numpy scipy 를 실행해주세요.")
|
||||
sys.exit(1)
|
||||
|
||||
def list_devices():
|
||||
"""시스템에 연결된 오디오 입력 디바이스의 목록을 출력합니다."""
|
||||
print("=== 시스템 오디오 디바이스 목록 ===")
|
||||
print(sd.query_devices())
|
||||
print("================================")
|
||||
print("녹음에 사용할 입력 디바이스의 인덱스 번호를 확인하세요.")
|
||||
|
||||
def record_audio(device_index, duration=None, samplerate=16000):
|
||||
"""
|
||||
지정된 디바이스 인덱스에서 오디오를 캡처하여 WAV 파일로 저장합니다.
|
||||
디바이스는 주로 USB 사운드카드 (Line-In)를 타겟팅합니다.
|
||||
"""
|
||||
try:
|
||||
device_info = sd.query_devices(device_index, 'input')
|
||||
except Exception as e:
|
||||
print(f"디바이스 초기화 오류: {e}")
|
||||
return
|
||||
|
||||
print(f"[{device_info['name']}] 디바이스에서 녹음을 시작합니다.")
|
||||
os.makedirs("data/samples", exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"data/samples/sample_{timestamp}.wav"
|
||||
|
||||
if duration:
|
||||
print(f"{duration}초 동안 녹음합니다...")
|
||||
recording = sd.rec(int(duration * samplerate), samplerate=samplerate, channels=1, device=device_index, dtype='int16')
|
||||
sd.wait()
|
||||
write(filename, samplerate, recording)
|
||||
print(f"저장 완료: {filename}")
|
||||
else:
|
||||
print("무제한 녹음 모드입니다. 종료하려면 Ctrl+C 키를 누르세요.")
|
||||
audio_data = []
|
||||
|
||||
def callback(indata, frames, time, status):
|
||||
if status:
|
||||
print(status, file=sys.stderr)
|
||||
audio_data.append(indata.copy())
|
||||
|
||||
try:
|
||||
with sd.InputStream(samplerate=samplerate, channels=1, device=device_index, dtype='int16', callback=callback):
|
||||
while True:
|
||||
sd.sleep(1000)
|
||||
except KeyboardInterrupt:
|
||||
print("\n사용자에 의해 녹음이 중단되었습니다. 파일을 저장합니다...")
|
||||
if audio_data:
|
||||
recording = np.concatenate(audio_data, axis=0)
|
||||
write(filename, samplerate, recording)
|
||||
print(f"저장 완료: {filename}")
|
||||
else:
|
||||
print("저장할 오디오 데이터가 없습니다.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="HUTAMS 현장 무전기 Line-in 녹음 툴")
|
||||
parser.add_argument("--list-devices", action="store_true", help="시스템에 연결된 모든 디바이스 목록 출력")
|
||||
parser.add_argument("--device", type=int, help="녹음에 사용할 오디오 디바이스 인덱스", default=None)
|
||||
parser.add_argument("--duration", type=int, help="녹음 길이 (초 단위, 생략시 무제한)", default=None)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_devices:
|
||||
list_devices()
|
||||
elif args.device is not None:
|
||||
record_audio(args.device, args.duration)
|
||||
else:
|
||||
print("사용법: --device [인덱스] 를 지정하거나 --list-devices 로 디바이스 번호를 확인하세요.")
|
||||
list_devices()
|
||||
Loading…
Reference in New Issue