From aca2f0d62c19408eff3a4fe89de18b7a35687d17 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sun, 10 May 2026 05:18:41 +0000 Subject: [PATCH] feat(canonical): restore GPU STT owner and extend KGS watch paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/core/config.py | 11 ++++ app/workers/classify_worker.py | 100 +++++++++++++++++++++++++++++++-- app/workers/file_watcher.py | 20 ++++++- docker-compose.yml | 16 +++--- 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 45f90d0..935130e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -88,6 +88,12 @@ class Settings(BaseModel): # NFS 경유 별도 마운트된 Roon 라이브러리. 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 = {} document_types: list[str] = [] @@ -108,6 +114,10 @@ def load_settings() -> Settings: stt_endpoint = os.getenv("STT_ENDPOINT", "http://stt-service:3300") 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_path = Path("/app/config.yaml") if not config_path.exists(): @@ -172,6 +182,7 @@ def load_settings() -> Settings: ocr_endpoint=ocr_endpoint, stt_endpoint=stt_endpoint, roon_library_path=roon_library_path, + additional_watch_targets=additional_watch_targets, taxonomy=taxonomy, document_types=document_types, upload=upload_cfg, diff --git a/app/workers/classify_worker.py b/app/workers/classify_worker.py index 3ceb57a..3e46f3c 100644 --- a/app/workers/classify_worker.py +++ b/app/workers/classify_worker.py @@ -21,9 +21,12 @@ PR-B B-1 tier triage (신규, 4B gemma Ollama): from __future__ import annotations import json +import re import time from datetime import datetime, timezone +import yaml + from pydantic import BaseModel, Field, ValidationError from sqlalchemy import text as sql_text from sqlalchemy.ext.asyncio import AsyncSession @@ -301,8 +304,42 @@ def _distill(triage_out: TriageOutput, limit: int = 2000) -> str: 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 ──────────────────────────────── + async def process(document_id: int, session: AsyncSession) -> None: """문서 분류 + 요약 + tier triage. @@ -334,6 +371,59 @@ async def process(document_id: int, session: AsyncSession) -> None: if not doc.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() try: # ─── 1. Legacy classify (primary 26B) ─── @@ -344,17 +434,19 @@ async def process(document_id: int, session: AsyncSession) -> None: if not parsed: raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}") - # domain 검증 + # domain 검증 (frontmatter 가 이미 채웠으면 LLM 결과 무시) domain = _validate_domain(parsed.get("domain", "")) - doc.ai_domain = domain + if not doc.ai_domain: + doc.ai_domain = domain # sub_group은 domain 경로에서 추출 (호환성) parts = domain.split("/") doc.ai_sub_group = parts[1] if len(parts) > 1 else "" - # document_type 검증 + # document_type 검증 (frontmatter 가 이미 채웠으면 LLM 결과 무시) 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 = parsed.get("confidence", 0.5) diff --git a/app/workers/file_watcher.py b/app/workers/file_watcher.py index 15eaeed..cbaa74d 100644 --- a/app/workers/file_watcher.py +++ b/app/workers/file_watcher.py @@ -31,6 +31,9 @@ AUDIO_EXTS = {".mp3", ".m4a", ".opus", ".wav", ".flac", ".ogg"} VIDEO_DIRECT_EXTS = {".mp4", ".webm"} # 브라우저 direct play VIDEO_QUARANTINE_EXTS = {".mov", ".mkv", ".avi"} # 변환 필요, 보관만 +# library (외부 작성 학습 자료) 폴더 — md/pdf/docx 등 문서 확장자만 수락 +LIBRARY_DOC_EXTS = {".md", ".pdf", ".docx", ".doc", ".txt", ".rtf", ".html", ".odt"} + # 스캔 대상: (하위경로, 예상 category) — None 은 문서함(카테고리 미지정) SCAN_TARGETS: list[tuple[str, str | None]] = [ ("Inbox", None), @@ -77,6 +80,15 @@ def _route_media(path: Path, expected_category: str | None) -> tuple[str | None, return ("video", True, None) 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. if ext in AUDIO_EXTS or ext in VIDEO_DIRECT_EXTS or ext in VIDEO_QUARANTINE_EXTS: return (None, False, None) @@ -92,8 +104,14 @@ async def watch_inbox(): new_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: - for sub, expected_category in SCAN_TARGETS: + for sub, expected_category in targets: scan_root = pkm_root / sub if not scan_root.exists(): continue diff --git a/docker-compose.yml b/docker-compose.yml index d719ce0..1313a43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,11 +83,10 @@ services: restart: unless-stopped stt-service: - # 2026-04-24: STT 가 Mac mini (faster-whisper, 192.168.1.122:8804 / 100.76.254.116:8804) - # 로 이전됨. GPU 에서 컨테이너는 더 이상 기동하지 않는다. 복원이 필요하면 - # `docker compose --profile legacy up -d stt-service` 로 legacy 프로파일 활성화. - # fastapi 의 STT_ENDPOINT 도 Mac mini 주소를 가리킴 (아래 environment 참고). - profiles: [legacy] + # 2026-05-08 (D9 Track B revised): GPU is canonical STT owner. + # 정책: Mac mini = Gemma 26B 전용 우선이므로 STT/Whisper 는 호출량 무관 GPU 서버 소유. + # 이전 "Mac mini 이전본" 주석은 trace 오인 기반이었고 본 revised 결정으로 폐기. + # fastapi 의 STT_ENDPOINT 는 `http://stt-service:3300` (compose 내부 DNS) 사용. build: ./services/stt expose: - "3300" @@ -191,9 +190,10 @@ services: - OCR_ENDPOINT=http://ocr-service:3200 - MARKER_ENDPOINT=http://marker-service:3300 - MARKER_CONTAINER_PATH_PREFIX=/documents - # 2026-04-24 STT Mac mini 이전: 기본값 100.76.254.116:8804 (Tailscale), 필요 시 - # MAC_MINI_HOST env 로 192.168.1.122 등 LAN IP 주입. - - STT_ENDPOINT=http://${MAC_MINI_HOST:-100.76.254.116}:8804 + # 2026-05-08 (D9 Track B revised): GPU stt-service 정식 승격, 내부 DNS 사용. + - STT_ENDPOINT=http://stt-service:3300 + # KGS Code 등 외부 학습 자료 추가 스캔 경로 (host .env 에서 주입). 빈 값이면 비활성. + - ADDITIONAL_WATCH_TARGETS=${ADDITIONAL_WATCH_TARGETS:-} restart: unless-stopped frontend: