63457e6afc
주제(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>
76 lines
3.2 KiB
Python
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,
|
|
}
|