feat(canonical): restore GPU STT owner and extend KGS watch paths

D9 Track B revised (2026-05-08):

1) STT owner GPU 정식 복귀:
   - docker-compose.yml: stt-service profiles:[legacy] 제거 → 상시 활성
   - fastapi STT_ENDPOINT = http://stt-service:3300 (compose 내부 DNS)
   - 정책: Mac mini = Gemma 26B 전용 우선이므로 STT/Whisper 는 호출량 무관
     GPU 서버 소유. 이전 "Mac mini 이전본" 주석은 trace 오인 기반.

2) KGS Code 등 외부 학습 자료 추가 스캔 경로:
   - ADDITIONAL_WATCH_TARGETS env (쉼표 구분, PKM 상대경로)
   - app/core/config.py: additional_watch_targets list 설정 추가
   - app/workers/file_watcher.py: 추가 watch path 처리
   - app/workers/classify_worker.py: KGS Code 분류 분기 (가스기사 학습 자료)
   - 모두 expected_category=library 처리 (md/pdf/docx 만)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-10 05:18:41 +00:00
parent c1b22d8833
commit aca2f0d62c
4 changed files with 134 additions and 13 deletions
+11
View File
@@ -88,6 +88,12 @@ class Settings(BaseModel):
# NFS 경유 별도 마운트된 Roon 라이브러리. # NFS 경유 별도 마운트된 Roon 라이브러리.
roon_library_path: str = "" roon_library_path: str = ""
# KGS Code 등 외부 작성 마크다운 자료 추가 스캔 경로 (PKM 상대 경로, 쉼표 구분).
# env: ADDITIONAL_WATCH_TARGETS=Knowledge/Industrial_Safety/가스기사/KGS_Code,...
# 모두 expected_category="library" 로 처리 (md/pdf/docx 등 문서 확장자만 수락).
# Inbox/Recordings/Videos 기본 스캔 외에 추가만 허용.
additional_watch_targets: list[str] = []
# 분류 체계 # 분류 체계
taxonomy: dict = {} taxonomy: dict = {}
document_types: list[str] = [] document_types: list[str] = []
@@ -108,6 +114,10 @@ def load_settings() -> Settings:
stt_endpoint = os.getenv("STT_ENDPOINT", "http://stt-service:3300") stt_endpoint = os.getenv("STT_ENDPOINT", "http://stt-service:3300")
roon_library_path = os.getenv("ROON_LIBRARY_PATH", "") roon_library_path = os.getenv("ROON_LIBRARY_PATH", "")
# ADDITIONAL_WATCH_TARGETS — 쉼표 구분 (공백 제거)
awt_raw = os.getenv("ADDITIONAL_WATCH_TARGETS", "")
additional_watch_targets = [p.strip() for p in awt_raw.split(",") if p.strip()]
# config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트 # config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트
config_path = Path("/app/config.yaml") config_path = Path("/app/config.yaml")
if not config_path.exists(): if not config_path.exists():
@@ -172,6 +182,7 @@ def load_settings() -> Settings:
ocr_endpoint=ocr_endpoint, ocr_endpoint=ocr_endpoint,
stt_endpoint=stt_endpoint, stt_endpoint=stt_endpoint,
roon_library_path=roon_library_path, roon_library_path=roon_library_path,
additional_watch_targets=additional_watch_targets,
taxonomy=taxonomy, taxonomy=taxonomy,
document_types=document_types, document_types=document_types,
upload=upload_cfg, upload=upload_cfg,
+96 -4
View File
@@ -21,9 +21,12 @@ PR-B B-1 tier triage (신규, 4B gemma Ollama):
from __future__ import annotations from __future__ import annotations
import json import json
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
import yaml
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import text as sql_text from sqlalchemy import text as sql_text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -301,8 +304,42 @@ def _distill(triage_out: TriageOutput, limit: int = 2000) -> str:
return "\n".join(parts)[:limit] return "\n".join(parts)[:limit]
# ───────────────────── frontmatter 파싱 (옵션 C) ──────────────────────
# YAML frontmatter (--- ... ---) + body 분리. body 가 없거나 frontmatter 가 형식 오류여도 안전하게 fallback.
_FM_PATTERN = re.compile("^---\\s*\\n(.*?)\\n---\\s*\\n?(.*)$", re.DOTALL)
def _parse_frontmatter(extracted_text: str) -> tuple[dict, str]:
"""extracted_text 시작에 YAML frontmatter 가 있으면 (frontmatter_dict, body) 반환.
없으면 ({}, extracted_text). YAML 파싱 실패 시도 ({}, extracted_text) 로 안전 fallback.
"""
if not extracted_text or not extracted_text.startswith("---"):
return {}, extracted_text
m = _FM_PATTERN.match(extracted_text)
if not m:
return {}, extracted_text
fm_text, body = m.group(1), m.group(2)
try:
fm = yaml.safe_load(fm_text)
if not isinstance(fm, dict):
return {}, extracted_text
return fm, body
except yaml.YAMLError:
return {}, extracted_text
# frontmatter 우선 인식: code/section/source_pdf/source_pages/source_basis/verified_level/verification_pending
# 등 원문 추적 메타데이터는 LLM 이 절대 덮어쓰지 못하게 차단.
_FRONTMATTER_PRESERVED_KEYS = {
"code", "section", "source_pdf", "source_pages", "source_basis",
"verified_level", "verification_pending", "source_type", "kgs_code",
}
# ───────────────────────── main process ──────────────────────────────── # ───────────────────────── main process ────────────────────────────────
async def process(document_id: int, session: AsyncSession) -> None: async def process(document_id: int, session: AsyncSession) -> None:
"""문서 분류 + 요약 + tier triage. """문서 분류 + 요약 + tier triage.
@@ -334,6 +371,59 @@ async def process(document_id: int, session: AsyncSession) -> None:
if not doc.extracted_text: if not doc.extracted_text:
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음") raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
# ─── 옵션 C: markdown frontmatter 우선 인식 ───────────────────────────
# KGS Code 등 외부 작성 마크다운은 frontmatter 에 정확한 메타가 있다.
# title / tags / ai_summary / ai_domain 은 frontmatter 에 있으면 그대로 사용,
# 없는 필드만 LLM 호출. code/section/source_pages/verified_level 등 원문
# 추적 메타는 documents.md_frontmatter JSONB 에 보존하고 LLM 이 덮어쓰지 못하게 한다.
fm, body = _parse_frontmatter(doc.extracted_text)
if fm:
# frontmatter 전체를 md_frontmatter JSONB 에 저장 (원문 추적용 single source)
doc.md_frontmatter = fm
# 우선 반영 (LLM 보다 신뢰도 높음, frontmatter 가 authoritative)
if fm.get("title"):
doc.title = str(fm["title"])
fm_tags = fm.get("tags")
if isinstance(fm_tags, list) and fm_tags:
# ai_tags 에 frontmatter 태그 우선 적재 (LLM 이 추가만 가능)
doc.ai_tags = [str(t) for t in fm_tags]
if fm.get("ai_domain"):
doc.ai_domain = str(fm["ai_domain"])
parts = doc.ai_domain.split("/")
if len(parts) > 1 and not doc.ai_sub_group:
doc.ai_sub_group = parts[1]
if fm.get("ai_sub_group"):
doc.ai_sub_group = str(fm["ai_sub_group"])
if fm.get("document_type"):
doc.document_type = str(fm["document_type"])
if fm.get("ai_summary"):
doc.ai_summary = str(fm["ai_summary"])
if fm.get("importance") in ("high", "medium", "low"):
doc.importance = fm["importance"]
# 핵심 메타 (title + ai_domain + ai_summary) 가 모두 frontmatter 로 채워졌으면
# LLM classify/summarize 스킵. tier triage 도 스킵 (frontmatter 가 더 정확).
# frontmatter 미커버 필드는 그대로 두어 향후 필요 시 manual UI 채움.
if doc.title and doc.ai_domain and doc.ai_summary:
if not doc.ai_confidence:
doc.ai_confidence = 1.0 # frontmatter 는 사람이 작성한 단정값
doc.ai_processed_at = datetime.now(timezone.utc)
doc.ai_model_version = "frontmatter@manual"
await session.commit()
logger.info(f"doc {document_id}: frontmatter 옵션 C → classify/summarize/triage 전부 skip")
return
# 일부만 frontmatter 에 있을 때는 LLM 으로 미설정 필드 보완. 단 _FRONTMATTER_PRESERVED_KEYS
# 는 이미 md_frontmatter 에 있으므로 LLM 이 ai_domain/document_type 등에 영향 못 준다.
logger.info(f"doc {document_id}: frontmatter 부분 인식 → LLM 으로 미설정 필드 보완")
client = AIClient() client = AIClient()
try: try:
# ─── 1. Legacy classify (primary 26B) ─── # ─── 1. Legacy classify (primary 26B) ───
@@ -344,17 +434,19 @@ async def process(document_id: int, session: AsyncSession) -> None:
if not parsed: if not parsed:
raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}") raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}")
# domain 검증 # domain 검증 (frontmatter 가 이미 채웠으면 LLM 결과 무시)
domain = _validate_domain(parsed.get("domain", "")) domain = _validate_domain(parsed.get("domain", ""))
doc.ai_domain = domain if not doc.ai_domain:
doc.ai_domain = domain
# sub_group은 domain 경로에서 추출 (호환성) # sub_group은 domain 경로에서 추출 (호환성)
parts = domain.split("/") parts = domain.split("/")
doc.ai_sub_group = parts[1] if len(parts) > 1 else "" doc.ai_sub_group = parts[1] if len(parts) > 1 else ""
# document_type 검증 # document_type 검증 (frontmatter 가 이미 채웠으면 LLM 결과 무시)
doc_type = parsed.get("document_type", "") doc_type = parsed.get("document_type", "")
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note" if not doc.document_type:
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
# confidence # confidence
confidence = parsed.get("confidence", 0.5) confidence = parsed.get("confidence", 0.5)
+19 -1
View File
@@ -31,6 +31,9 @@ AUDIO_EXTS = {".mp3", ".m4a", ".opus", ".wav", ".flac", ".ogg"}
VIDEO_DIRECT_EXTS = {".mp4", ".webm"} # 브라우저 direct play VIDEO_DIRECT_EXTS = {".mp4", ".webm"} # 브라우저 direct play
VIDEO_QUARANTINE_EXTS = {".mov", ".mkv", ".avi"} # 변환 필요, 보관만 VIDEO_QUARANTINE_EXTS = {".mov", ".mkv", ".avi"} # 변환 필요, 보관만
# library (외부 작성 학습 자료) 폴더 — md/pdf/docx 등 문서 확장자만 수락
LIBRARY_DOC_EXTS = {".md", ".pdf", ".docx", ".doc", ".txt", ".rtf", ".html", ".odt"}
# 스캔 대상: (하위경로, 예상 category) — None 은 문서함(카테고리 미지정) # 스캔 대상: (하위경로, 예상 category) — None 은 문서함(카테고리 미지정)
SCAN_TARGETS: list[tuple[str, str | None]] = [ SCAN_TARGETS: list[tuple[str, str | None]] = [
("Inbox", None), ("Inbox", None),
@@ -77,6 +80,15 @@ def _route_media(path: Path, expected_category: str | None) -> tuple[str | None,
return ("video", True, None) return ("video", True, None)
return (None, False, None) # 기타 → skip return (None, False, None) # 기타 → skip
if expected_category == "library":
# 외부 작성 학습 자료 (KGS Code, 시행규칙 등). 문서 확장자만 수락.
# frontmatter 해석은 classify_worker (옵션 C) 가 담당. file_watcher 는 라우팅만.
if ext in LIBRARY_DOC_EXTS:
return ("library", False, "extract")
if ext in AUDIO_EXTS or ext in VIDEO_DIRECT_EXTS or ext in VIDEO_QUARANTINE_EXTS:
return (None, False, None) # audio/video 잘못 들어오면 skip
return (None, False, None) # 기타 알 수 없는 확장자 skip
# Inbox: 문서 파이프 (기존). audio/video 확장자가 실수로 여기 들어오면 skip. # Inbox: 문서 파이프 (기존). audio/video 확장자가 실수로 여기 들어오면 skip.
if ext in AUDIO_EXTS or ext in VIDEO_DIRECT_EXTS or ext in VIDEO_QUARANTINE_EXTS: if ext in AUDIO_EXTS or ext in VIDEO_DIRECT_EXTS or ext in VIDEO_QUARANTINE_EXTS:
return (None, False, None) return (None, False, None)
@@ -92,8 +104,14 @@ async def watch_inbox():
new_count = 0 new_count = 0
changed_count = 0 changed_count = 0
# 동적 스캔 대상 합성: 기본 (Inbox/Recordings/Videos) + env 로 확장된 library 경로
# settings.additional_watch_targets 는 PKM 상대 경로 리스트 (예: "Knowledge/Industrial_Safety/가스기사/KGS_Code")
targets = list(SCAN_TARGETS)
for extra_path in settings.additional_watch_targets:
targets.append((extra_path, "library"))
async with async_session() as session: async with async_session() as session:
for sub, expected_category in SCAN_TARGETS: for sub, expected_category in targets:
scan_root = pkm_root / sub scan_root = pkm_root / sub
if not scan_root.exists(): if not scan_root.exists():
continue continue
+8 -8
View File
@@ -83,11 +83,10 @@ services:
restart: unless-stopped restart: unless-stopped
stt-service: stt-service:
# 2026-04-24: STT 가 Mac mini (faster-whisper, 192.168.1.122:8804 / 100.76.254.116:8804) # 2026-05-08 (D9 Track B revised): GPU is canonical STT owner.
# 로 이전됨. GPU 에서 컨테이너는 더 이상 기동하지 않는다. 복원이 필요하면 # 정책: Mac mini = Gemma 26B 전용 우선이므로 STT/Whisper 는 호출량 무관 GPU 서버 소유.
# `docker compose --profile legacy up -d stt-service` 로 legacy 프로파일 활성화. # 이전 "Mac mini 이전본" 주석은 trace 오인 기반이었고 본 revised 결정으로 폐기.
# fastapi 의 STT_ENDPOINT 도 Mac mini 주소를 가리킴 (아래 environment 참고). # fastapi 의 STT_ENDPOINT 는 `http://stt-service:3300` (compose 내부 DNS) 사용.
profiles: [legacy]
build: ./services/stt build: ./services/stt
expose: expose:
- "3300" - "3300"
@@ -191,9 +190,10 @@ services:
- OCR_ENDPOINT=http://ocr-service:3200 - OCR_ENDPOINT=http://ocr-service:3200
- MARKER_ENDPOINT=http://marker-service:3300 - MARKER_ENDPOINT=http://marker-service:3300
- MARKER_CONTAINER_PATH_PREFIX=/documents - MARKER_CONTAINER_PATH_PREFIX=/documents
# 2026-04-24 STT Mac mini 이전: 기본값 100.76.254.116:8804 (Tailscale), 필요 시 # 2026-05-08 (D9 Track B revised): GPU stt-service 정식 승격, 내부 DNS 사용.
# MAC_MINI_HOST env 로 192.168.1.122 등 LAN IP 주입. - STT_ENDPOINT=http://stt-service:3300
- STT_ENDPOINT=http://${MAC_MINI_HOST:-100.76.254.116}:8804 # KGS Code 등 외부 학습 자료 추가 스캔 경로 (host .env 에서 주입). 빈 값이면 비활성.
- ADDITIONAL_WATCH_TARGETS=${ADDITIONAL_WATCH_TARGETS:-}
restart: unless-stopped restart: unless-stopped
frontend: frontend: