Files
hyungi_document_server/app/services/study/publish_projection.py
T
hyungi 63457e6afc feat(publish): S-1 pub_topics 발행 — projection+저작훅+백필 (study→viewer)
주제(study_topic) 메타를 발행 레이어에 실어 viewer 가 주제/회차 단위 퀴즈를
구성하게 한다(현재 topic 이름 미발행이라 불가). plan study-viewer-port S-1.
- publish_projection: KIND_TOPIC + project_topic(topic_id·name·exam_round_size).
  회차는 미발행 = viewer 가 pub_content(study_question) 의 exam_name/exam_round 로
  파생(추가 발행 불요). topic_id = project_question.topic_id 와 동일 DS 식별자라
  viewer 문항→주제 상관 키(pub_id 는 opaque 라 상관 키 아님).
- publish_enqueue: enqueue_topic_publish + backfill_publish_topics(bounded page,
  deleted_at IS NULL). 멱등 = 워커 (payload_hash, deleted) 디둡.
- study_topics 저작훅(전부 study_publish_enabled 게이트): create(flush→enqueue→
  commit) / update(재투영, payload 무변경은 디둡이 rev 안 올림=churn 0) /
  delete(tombstone, raw DELETE 금지·워커 경유).
- scripts/backfill_publish_topics.py: 기존 주제 1회 outbox 적재(overflow 가드).

워커·/published/feed 는 kind-generic(무변경, 실측). flag on 환경 배포 시 주제 발행
시작 → S-3 viewer 수용(generic upsert·kind-filtered read) 선행 전제, 게이트 PASS 됨.
백필 실행·배포순서 cutover 는 deploy 게이트(소프트락)라 본 슬라이스 미포함.
py_compile PASS · project_topic payload 단위검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:48:08 +09:00

76 lines
3.2 KiB
Python

"""발행 projection — 소스 행을 render-ready payload + 안정 해시로 변환 (순수 함수).
뷰어가 보는 '단일 진실'은 이 payload 까지 (DS 내부 실험 스키마는 계약 뒤 격리).
kind 별 projector. payload_hash = 정렬된 JSON 의 sha256 = (payload_hash, deleted) 디둡 키.
★주의(plan study-to-viewer-slice1 r2): 과목/시험메타를 per-question payload 에 인라인 —
bulk subject rename 시 N행 churn. 정규화(과목=별 kind subject ref)는 churn 최적화 후속(P0-1b),
읽기 정합엔 무영향. 지금은 인라인(상관관계 단순)으로 두고 후속 PR 에서 분리.
SCHEMA_VERSION = 엔벨로프 버전. payload 모양 진화 시 bump + 뷰어 range 수용(P0-2).
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
SCHEMA_VERSION = 1
KIND_QUESTION = "study_question"
KIND_EXPLANATION = "study_explanation"
KIND_TOPIC = "study_topic"
def payload_hash(payload: dict[str, Any]) -> str:
"""정렬 JSON 의 sha256 — (payload_hash, deleted) 디둡 키. 키 순서/공백 비의존."""
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def project_question(q: Any) -> dict[str, Any]:
"""study_question → 발행 payload. 정답 포함(개인 학습툴, plan Q2). 이미지는 ref 만(P0-4, 후속)."""
return {
"topic_id": q.study_topic_id,
"question_text": q.question_text,
"choices": [q.choice_1, q.choice_2, q.choice_3, q.choice_4],
"correct_choice": q.correct_choice,
"subject": q.subject,
"scope": q.scope,
"exam_name": q.exam_name,
"exam_round": q.exam_round,
"exam_question_number": q.exam_question_number,
"explanation": q.explanation, # 수동 해설(있으면). AI 해설은 별 kind.
}
def project_explanation(q: Any) -> dict[str, Any] | None:
"""study_question 의 AI 해설 → 별 발행 kind. ready 일 때만(없으면 None=발행 안 함).
재조우 표시용 선발행. 신규 오답은 4-A 워커가 ~90s 후 ready→재발행(P2-3 결선, P0-1b).
"""
if getattr(q, "ai_explanation_status", None) != "ready" or not getattr(q, "ai_explanation", None):
return None
gen = getattr(q, "ai_explanation_generated_at", None)
return {
"question_source_id": q.id,
"explanation_md": q.ai_explanation,
"model": getattr(q, "ai_explanation_model", None),
"generated_at": gen.isoformat() if gen else None,
}
def project_topic(t: Any) -> dict[str, Any]:
"""study_topic → 발행 payload (S-1, plan study-viewer-port).
topic 메타만 신규 발행 — viewer 가 주제 단위 퀴즈를 만들 최소 정보.
회차 목록은 발행 안 함 = viewer 가 pub_content(study_question) 의 exam_name/exam_round 로
파생(추가 발행 불요, plan S-1 결정). topic_id 는 project_question 의 topic_id(=study_topic_id)
와 동일 DS 식별자라 viewer 가 문항→주제 상관에 사용(pub_id 는 opaque 라 상관 키 아님).
"""
return {
"topic_id": t.id,
"name": t.name,
"exam_round_size": t.exam_round_size,
}