844a5e0204
- internal_study._verify_token: != 비교는 첫 불일치 단락으로 prefix 길이 timing side-channel(RAG 정답 endpoint 보호 토큰) → hmac.compare_digest(search.py 정본 일치). - memos tag 필터: f-string 으로 사용자 tag 를 JSON 배열 리터럴에 직접 삽입 → tag 안 "/] 가 JSON 깨 500 + 필터 변형. func.jsonb_build_array(tag) 바인드 파라미터로. 검증: py_compile 통과. R7 나머지(get_live_document·paper-holder deleted_at·delete_file purge 마커+retention sweep·fetch-page·save-content)는 이어서. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""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 hmac
|
|
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()
|
|
# 상수시간 비교 (R7) — 일반 != 는 첫 불일치에서 단락돼 prefix 길이로 바이트 추정 가능한
|
|
# timing side-channel. 이 토큰이 RAG 정답 포함 endpoint 를 보호하므로 compare_digest 로
|
|
# 통일(search.py 정본과 일치).
|
|
if not hmac.compare_digest(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,
|
|
},
|
|
}
|