From 5125f82d4a01859238563f218b5bc3a6f273254c Mon Sep 17 00:00:00 2001 From: hyungi Date: Fri, 15 May 2026 03:13:43 +0000 Subject: [PATCH] feat(study): Mac mini derived-worker (PR-MacMini-Derived-Worker-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPU = RAG context provider, Mac mini = LLM 가공 공장. GPU 측 변경: - app/api/internal_study.py: GET /internal/study/explanation-context/{qid} Bearer auth, gather_explanation_context + _render_envelope_prompt 재호출. 204=evidence missing, 410=deleted/ready. - app/workers/study_queue_consumer.py: settings.study_explanation_enabled false 시 explanation 분기 skip (status/attempts 미변경, pending 유지 → Mac mini 흡수). - app/core/config.py: study_explanation_enabled + internal_worker_token 2 setting. - app/main.py: internal_study_router include (prefix /internal/study). - docker-compose.yml: fastapi ports → 100.110.63.63:8000 Tailscale bind, STUDY_EXPLANATION_ENABLED + INTERNAL_WORKER_TOKEN env 추가. Mac mini 측: ~/derived-worker/ (별도 push 0, 어제 작성). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/internal_study.py | 75 +++++++++++++++++++++++++++++ app/core/config.py | 11 +++++ app/main.py | 2 + app/workers/study_queue_consumer.py | 6 +++ docker-compose.yml | 7 ++- 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 app/api/internal_study.py diff --git a/app/api/internal_study.py b/app/api/internal_study.py new file mode 100644 index 0000000..2ae3cda --- /dev/null +++ b/app/api/internal_study.py @@ -0,0 +1,75 @@ +"""PR-MacMini-Derived-Worker-1 internal endpoint. + +Mac mini derived-worker 가 study explanation 가공을 위해 호출. +GPU = RAG context provider (LLM generation X), Mac mini = LLM 가공 공장. +Bearer token 보호 (settings.internal_worker_token). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from core.config import settings +from core.database import async_session +from models.study_question import StudyQuestion +from services.study.explanation_rag import gather_explanation_context, render_evidence_block +from workers.study_explanation_worker import _render_envelope_prompt + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _verify_token(authorization: str | None = Header(default=None)) -> None: + if not settings.internal_worker_token: + raise HTTPException(status_code=503, detail="internal_worker_token not configured") + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="missing Bearer token") + token = authorization[7:].strip() + if token != settings.internal_worker_token: + raise HTTPException(status_code=403, detail="invalid token") + + +async def _session() -> AsyncSession: + async with async_session() as s: + yield s + + +@router.get("/explanation-context/{question_id}") +async def get_explanation_context( + question_id: int = Path(..., ge=1), + _auth: None = Depends(_verify_token), + session: AsyncSession = Depends(_session), +): + question = await session.get(StudyQuestion, question_id) + if question is None or question.deleted_at is not None: + raise HTTPException(status_code=410, detail="question deleted or missing") + if question.ai_explanation_status == "ready": + raise HTTPException(status_code=410, detail="explanation already ready") + + ctx = await gather_explanation_context(session, question.user_id, question) + docs_count = len(ctx.documents) + qs_count = len(ctx.questions) + if docs_count == 0 and qs_count == 0: + return Response(status_code=204) + + doc_block = render_evidence_block(ctx.documents) + q_block = render_evidence_block(ctx.questions) + rendered_prompt = _render_envelope_prompt(question, doc_block, q_block) + + logger.info( + "internal_study_context qid=%s docs=%s questions=%s prompt_len=%s", + question_id, docs_count, qs_count, len(rendered_prompt), + ) + + return { + "question_id": question.id, + "question_correct_choice": question.correct_choice, + "rendered_prompt": rendered_prompt, + "evidence_summary": { + "documents_count": docs_count, + "questions_count": qs_count, + }, + } diff --git a/app/core/config.py b/app/core/config.py index 935130e..08f20ed 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -101,11 +101,20 @@ class Settings(BaseModel): # 업로드 한도 (authoritative policy) upload: UploadConfig = UploadConfig() + # PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini + # GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거. + study_explanation_enabled: bool = True + + # internal endpoint Bearer token (Mac mini derived-worker 호출용) + internal_worker_token: str = "" + def load_settings() -> Settings: """config.yaml + 환경변수에서 설정 로딩""" # 환경변수 (docker-compose에서 주입) database_url = os.getenv("DATABASE_URL", "") + study_explanation_enabled = os.getenv("STUDY_EXPLANATION_ENABLED", "true").lower() in ("1", "true", "yes") + internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "") jwt_secret = os.getenv("JWT_SECRET", "") totp_secret = os.getenv("TOTP_SECRET", "") eval_runner_token = os.getenv("EVAL_RUNNER_TOKEN", "") @@ -186,6 +195,8 @@ def load_settings() -> Settings: taxonomy=taxonomy, document_types=document_types, upload=upload_cfg, + study_explanation_enabled=study_explanation_enabled, + internal_worker_token=internal_worker_token, ) diff --git a/app/main.py b/app/main.py index c0cf4b3..499fdbc 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy import func, select, text from api.audio import router as audio_router +from api.internal_study import router as internal_study_router from api.auth import router as auth_router from api.briefing import router as briefing_router from api.config import router as config_router @@ -143,6 +144,7 @@ app.include_router(news_router, prefix="/api/news", tags=["news"]) app.include_router(digest_router, prefix="/api/digest", tags=["digest"]) app.include_router(briefing_router, prefix="/api/briefing", tags=["briefing"]) app.include_router(audio_router, prefix="/api/audio", tags=["audio"]) +app.include_router(internal_study_router, prefix="/internal/study", tags=["internal-study"]) app.include_router(video_router, prefix="/api/video", tags=["video"]) app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"]) app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"]) diff --git a/app/workers/study_queue_consumer.py b/app/workers/study_queue_consumer.py index 6b565e7..dfcef93 100644 --- a/app/workers/study_queue_consumer.py +++ b/app/workers/study_queue_consumer.py @@ -16,6 +16,7 @@ from sqlalchemy import select, update from sqlalchemy.exc import SQLAlchemyError from core.database import async_session +from core.config import settings from core.utils import setup_logger from models.study_question_job import StudyQuestionJob from workers.study_explanation_worker import run_explanation_job @@ -80,6 +81,11 @@ async def consume_study_queue() -> None: continue # 다른 cycle 에서 이미 처리 if job.kind == "explanation": + if not settings.study_explanation_enabled: + # PR-MacMini-Derived-Worker-1: explanation owner = Mac mini. + # status/attempts 변경하지 않고 pending 그대로 유지 → Mac mini worker 가 흡수. + logger.info("skip explanation owner=macmini job_id=%s qid=%s", job.id, job.study_question_id) + continue await run_explanation_job(s, job) elif job.kind == "session_summary": # Phase 4-B 미구현 — 즉시 skipped 처리 (lost in queue 방지) diff --git a/docker-compose.yml b/docker-compose.yml index 14af257..cb48adf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: POSTGRES_USER: pkm POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "127.0.0.1:15432:5432" + - "100.110.63.63:15432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U pkm"] interval: 5s @@ -173,7 +173,7 @@ services: fastapi: build: ./app ports: - - "127.0.0.1:8000:8000" + - "100.110.63.63:8000:8000" volumes: - ${NAS_NFS_PATH:-/mnt/nas/Document_Server}:/documents - ./config.yaml:/app/config.yaml:ro @@ -200,6 +200,9 @@ services: - STT_ENDPOINT=http://stt-service:3300 # KGS Code 등 외부 학습 자료 추가 스캔 경로 (host .env 에서 주입). 빈 값이면 비활성. - ADDITIONAL_WATCH_TARGETS=${ADDITIONAL_WATCH_TARGETS:-} + # PR-MacMini-Derived-Worker-1 + - STUDY_EXPLANATION_ENABLED=${STUDY_EXPLANATION_ENABLED:-true} + - INTERNAL_WORKER_TOKEN=${INTERNAL_WORKER_TOKEN} # Voice Memo PoC v1 — bot 계정 한정 long-expiry access token. default false → 일반 운영 영향 0. # 활성화: host .env 에 VOICE_MEMO_BOT_TOKEN_ENABLED=true. plan: rosy-launching-otter.md - VOICE_MEMO_BOT_TOKEN_ENABLED=${VOICE_MEMO_BOT_TOKEN_ENABLED:-false} -- 2.52.0