From a94c73b13489159326a4df9bd8259b889206c141 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 18:26:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(tarot-lab):=20prompt.py=20+=20schema.py=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=20+=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=206=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- tarot-lab/app/prompt.py | 108 +++++++++++++++++++++++++++++++++ tarot-lab/app/schema.py | 36 +++++++++++ tarot-lab/tests/test_schema.py | 59 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 tarot-lab/app/prompt.py create mode 100644 tarot-lab/app/schema.py create mode 100644 tarot-lab/tests/test_schema.py diff --git a/tarot-lab/app/prompt.py b/tarot-lab/app/prompt.py new file mode 100644 index 0000000..720528e --- /dev/null +++ b/tarot-lab/app/prompt.py @@ -0,0 +1,108 @@ +"""Tarot 프롬프트 — SYSTEM + build_user_message.""" + +SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다. +사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다. + +# 해석 원칙 +1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용. + 외부 변형 의미·다른 덱 해석은 사용하지 않음. +2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록. +3. 카드 간 상호작용 분석 (3장 스프레드): + - 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름 + - 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환 +4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현. +5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함. +6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영. + +# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON) +{ + "summary": "전체 흐름 한 단락 (3~4문장)", + "cards": [ + { + "position": "<위치 라벨>", + "card": "", + "reversed": , + "interpretation": "3~4문장", + "evidence": { + "card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징", + "position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)", + "category_lens": "카테고리 관점에서 부각되는 면 (1문장)" + }, + "advice": "1문장" + } + ], + "interactions": [ + { "type": "synergy"|"conflict"|"transition", + "between": ["", ""], + "explanation": "1~2문장" } + ], + "advice": "2문장. interactions를 1개 이상 참조할 것.", + "warning": "역방향·충돌 경계 (없으면 null)", + "confidence": "high"|"medium"|"low" +} + +# confidence 판정 기준 +- high: 3장 모두 한 방향 서사 또는 명확한 전환 +- medium: 2장 일관, 1장 별도 신호 +- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움 + +# 금지사항 +- 참고 카드 정보에 없는 상징 도입 금지 +- 역방향 카드를 정방향처럼 다루지 말 것 +- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시 +- JSON 외 텍스트 금지 +""" + + +SPREAD_NAMES = { + "one_card": "오늘의 카드", + "three_card": "3장 스프레드 (과거·현재·미래)", +} + + +def build_user_message( + question: str, + category: str, + spread_type: str, + cards_reference: str, + context_meta: dict, + spread_count: int, +) -> str: + q = question or "(질문 없음)" + cat = category or "일반" + spread_name = SPREAD_NAMES.get(spread_type, spread_type) + + meta_lines = [] + if context_meta: + if "major_minor_ratio" in context_meta: + meta_lines.append(f"- 메이저:마이너 비율: {context_meta['major_minor_ratio']}") + if "element_distribution" in context_meta: + ed = context_meta["element_distribution"] + meta_lines.append( + f"- 원소 분포: 공기 {ed.get('air',0)}, 물 {ed.get('water',0)}, 불 {ed.get('fire',0)}, 흙 {ed.get('earth',0)}" + ) + if "orientation_flow" in context_meta: + meta_lines.append(f"- 정역 흐름: {context_meta['orientation_flow']}") + meta_block = "\n".join(meta_lines) if meta_lines else "(추가 컨텍스트 없음)" + + return f"""# 질문 +{q} + +# 카테고리 +{cat} + +# 스프레드 +{spread_name} ({spread_count}장) + +# 뽑힌 카드와 참고 카드 정보 +{cards_reference} + +## 추가 컨텍스트 +{meta_block} + +# 작업 +위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요. +- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용. +- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출 (1장 스프레드면 빈 배열 허용). +- confidence는 카드 흐름의 일관성에 따라 정직하게 판정. +""" diff --git a/tarot-lab/app/schema.py b/tarot-lab/app/schema.py new file mode 100644 index 0000000..4f13331 --- /dev/null +++ b/tarot-lab/app/schema.py @@ -0,0 +1,36 @@ +"""Tarot 응답 스키마 검증 — 누락·빈 필드 reroll 트리거.""" + +VALID_CONFIDENCE = {"high", "medium", "low"} + + +def validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]: + if not isinstance(parsed, dict): + return False, "응답이 dict가 아님" + for k in ("summary", "cards", "interactions", "advice", "confidence"): + if k not in parsed: + return False, f"필수 필드 누락: {k}" + if parsed.get("confidence") not in VALID_CONFIDENCE: + return False, f"confidence 값 비정상: {parsed.get('confidence')}" + cards = parsed.get("cards") + if not isinstance(cards, list) or not cards: + return False, "cards가 빈 배열" + for i, c in enumerate(cards): + if not isinstance(c, dict): + return False, f"cards[{i}] dict 아님" + for k in ("position", "card", "reversed", "interpretation", "advice", "evidence"): + if k not in c: + return False, f"cards[{i}].{k} 누락" + ev = c["evidence"] + if not isinstance(ev, dict): + return False, f"cards[{i}].evidence dict 아님" + for k in ("card_meaning_used", "position_logic", "category_lens"): + if k not in ev: + return False, f"cards[{i}].evidence.{k} 누락" + if not isinstance(ev[k], str) or not ev[k].strip(): + return False, f"cards[{i}].evidence.{k} 빈 문자열" + interactions = parsed.get("interactions") + if not isinstance(interactions, list): + return False, "interactions가 list 아님" + if spread_type == "three_card" and len(interactions) == 0: + return False, "three_card는 interactions 1개 이상 필요" + return True, "" diff --git a/tarot-lab/tests/test_schema.py b/tarot-lab/tests/test_schema.py new file mode 100644 index 0000000..0ad7b64 --- /dev/null +++ b/tarot-lab/tests/test_schema.py @@ -0,0 +1,59 @@ +from app.schema import validate_interpretation + + +def _valid_card(): + return { + "position": "과거", "card": "the-fool", "reversed": False, + "interpretation": "...", "advice": "...", + "evidence": { + "card_meaning_used": "새 시작", + "position_logic": "...", + "category_lens": "...", + }, + } + + +def _valid_payload(): + return { + "summary": "...", + "cards": [_valid_card()], + "interactions": [{"type": "synergy", "between": ["a", "b"], "explanation": "..."}], + "advice": "...", + "confidence": "medium", + } + + +def test_valid_three_card(): + ok, _ = validate_interpretation(_valid_payload(), "three_card") + assert ok is True + + +def test_missing_summary(): + p = _valid_payload(); del p["summary"] + ok, err = validate_interpretation(p, "three_card") + assert not ok and "summary" in err + + +def test_invalid_confidence(): + p = _valid_payload(); p["confidence"] = "extreme" + ok, err = validate_interpretation(p, "three_card") + assert not ok and "confidence" in err + + +def test_three_card_empty_interactions(): + p = _valid_payload(); p["interactions"] = [] + ok, err = validate_interpretation(p, "three_card") + assert not ok and "interactions" in err + + +def test_one_card_empty_interactions_ok(): + p = _valid_payload(); p["interactions"] = [] + ok, _ = validate_interpretation(p, "one_card") + assert ok is True + + +def test_card_evidence_missing_field(): + p = _valid_payload() + del p["cards"][0]["evidence"]["category_lens"] + ok, err = validate_interpretation(p, "three_card") + assert not ok and "category_lens" in err