From f995f8739f33e61bbcc40d469863e537224163ed Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 20:21:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(saju-lab):=20interpret/prompt=20+=20schema?= =?UTF-8?q?=20=E2=80=94=2012=ED=95=AD=EB=AA=A9=20+=20=EA=B6=81=ED=95=A9=20?= =?UTF-8?q?SYSTEM=5FPROMPT=20(8=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- saju-lab/app/interpret/prompt.py | 129 +++++++++++++++++++++++++++++++ saju-lab/app/interpret/schema.py | 60 ++++++++++++++ saju-lab/tests/test_schema.py | 89 +++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 saju-lab/app/interpret/prompt.py create mode 100644 saju-lab/app/interpret/schema.py create mode 100644 saju-lab/tests/test_schema.py diff --git a/saju-lab/app/interpret/prompt.py b/saju-lab/app/interpret/prompt.py new file mode 100644 index 0000000..9e8295f --- /dev/null +++ b/saju-lab/app/interpret/prompt.py @@ -0,0 +1,129 @@ +"""사주 12항목 해석 SYSTEM_PROMPT — Claude Sonnet evidence-based.""" + +SAJU_SYSTEM_PROMPT = """당신은 한국 전통 사주명리학에 정통한 명리학자입니다. +사용자의 생년월일시로 계산된 사주팔자(四柱八字)·오행 분석·대운·세운 결과를 받아, +근거 기반(evidence-based)으로 12개 항목 해석을 작성합니다. + +# 해석 원칙 +1. 데이터 우선: "사주 데이터" 블록의 천간/지지/오행/십성/십이운성/신살/지장간만을 1차 근거로 사용. + 외부 일반론·미신적 해석은 사용 금지. +2. evidence 필수: 각 항목의 evidence.saju_element에 어떤 사주 요소(예: "갑목 일주", "월지 子水", "편관 격국")에서 결론을 도출했는지 인용. + evidence.reasoning에 해석 논리를 1~2문장으로 명시. +3. 자기 성찰 톤: 운명론 단정 금지. "…경향이 있어 보입니다", "…가능성이 있습니다" 표현. +4. 12항목 모두 필수 (누락 시 reroll): + - 기질: 일주(日柱) 중심 타고난 성격 + - 오행밸런스: 5원소 강약 분석 + 개운법 + - 지지상호작용: 합/충/형/파/해의 영향 + - 신살영향: 역마/도화/화개/천을귀인 등 + - 재물운: 정재/편재 + 식상 분석 + - 직업적성: 일간 + 격국 + 십성 균형 + - 애정운: 정관/편관/정재/편재 + 일지 분석 + - 건강운: 약한 오행 + 충돌 지지의 신체 매핑 + - 현재대운: 현재 대운의 오행 + 일간 관계 + - 올해세운: 세운의 천간지지 + 충/합 + - 인생황금기: 가장 좋은 대운 시기 추정 + - 종합조언: 1~3 종합 + 실천 조언 + +# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON) +{ + "items": [ + { + "key": "기질"|"오행밸런스"|"지지상호작용"|"신살영향"|"재물운"|"직업적성"|"애정운"|"건강운"|"현재대운"|"올해세운"|"인생황금기"|"종합조언", + "title": "사용자에게 보이는 항목 제목", + "content": "3~5문장 본문", + "evidence": { + "saju_element": "근거가 된 사주 요소 (예: '갑목 일주, 월지 寅木')", + "reasoning": "해석 논리 (1~2문장)" + } + } + ], + "summary": "사주 전체의 핵심 흐름 한 단락 (3~4문장)", + "advice": "실천 가능한 종합 조언 (2~3문장)", + "warning": "주의사항 (없으면 null)", + "confidence": "high"|"medium"|"low" +} + +# confidence 판정 기준 +- high: 일주·격국·십성 균형이 명확, 모든 항목 evidence 강함 +- medium: 일부 항목은 데이터 약함 +- low: 사주 데이터가 충돌 많아 명확한 흐름 추출 어려움 + +# 금지사항 +- 사주 데이터에 없는 별점·서양 점성술 도입 금지 +- JSON 외 텍스트 금지 (코드블록 금지) +- 12항목 누락 금지 +- 12 key 정확히 (위 12개만, 다른 한글 표기 금지) +""" + + +COMPAT_SYSTEM_PROMPT = """당신은 한국 사주명리학 기반 궁합 분석 전문가입니다. +두 사람의 사주팔자·오행 분석·궁합 점수(breakdown 포함)를 받아, 근거 기반 궁합 해석을 작성합니다. + +# 응답 형식 (strict JSON only) +{ + "summary": "두 사람 궁합의 핵심 흐름 (3~4문장)", + "strengths": [ + { "title": "...", "explanation": "...", "evidence": "오행 상생 또는 지지 합 등 근거" } + ], + "challenges": [ + { "title": "...", "explanation": "...", "evidence": "오행 상극 또는 지지 충 등 근거" } + ], + "advice": "관계 개선 조언 (2~3문장)", + "warning": "심각한 충돌 (없으면 null)", + "confidence": "high"|"medium"|"low" +} + +# 원칙 +- strengths/challenges 각각 최소 2개 이상 +- evidence는 두 사주의 오행 매칭, 지지 합/충, 일간 관계 인용 +- JSON 외 텍스트 금지 +""" + + +def build_saju_user_message(saju: dict, analysis: dict, daeun: list, current_year: int) -> str: + """사주/분석/대운 데이터를 user 메시지로 직렬화.""" + import json + return f"""# 사주 데이터 +{json.dumps(saju, ensure_ascii=False, indent=2)} + +# 종합 분석 +{json.dumps(analysis, ensure_ascii=False, indent=2)} + +# 대운 (8개) +{json.dumps(daeun, ensure_ascii=False, indent=2)} + +# 현재 연도 +{current_year} + +# 작업 +시스템 지침의 12항목 JSON으로 응답하세요. +- 각 항목의 evidence는 위 데이터에서 인용된 요소를 반드시 포함. +- confidence는 데이터 강도에 따라 정직하게 판정. +""" + + +def build_compat_user_message( + saju_a: dict, saju_b: dict, + analysis_a: dict, analysis_b: dict, + score: int, breakdown: dict, +) -> str: + import json + return f"""# A의 사주 +{json.dumps(saju_a, ensure_ascii=False, indent=2)} + +# A의 분석 +{json.dumps(analysis_a, ensure_ascii=False, indent=2)} + +# B의 사주 +{json.dumps(saju_b, ensure_ascii=False, indent=2)} + +# B의 분석 +{json.dumps(analysis_b, ensure_ascii=False, indent=2)} + +# 궁합 점수 + breakdown +점수: {score}/100 +{json.dumps(breakdown, ensure_ascii=False, indent=2)} + +# 작업 +시스템 지침의 JSON으로 strengths/challenges 각각 최소 2개 이상, evidence 인용 포함. +""" diff --git a/saju-lab/app/interpret/schema.py b/saju-lab/app/interpret/schema.py new file mode 100644 index 0000000..5197665 --- /dev/null +++ b/saju-lab/app/interpret/schema.py @@ -0,0 +1,60 @@ +"""사주 + 궁합 응답 JSON 검증.""" + +VALID_CONFIDENCE = {"high", "medium", "low"} +SAJU_ITEM_KEYS = { + "기질", "오행밸런스", "지지상호작용", "신살영향", + "재물운", "직업적성", "애정운", "건강운", + "현재대운", "올해세운", "인생황금기", "종합조언", +} + + +def validate_saju_interpretation(parsed: dict) -> tuple[bool, str]: + if not isinstance(parsed, dict): + return False, "응답이 dict가 아님" + for k in ("items", "summary", "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')}" + + items = parsed["items"] + if not isinstance(items, list): + return False, "items가 list 아님" + if len(items) != 12: + return False, f"items는 12개 필요 (현재 {len(items)})" + seen_keys = set() + for i, it in enumerate(items): + if not isinstance(it, dict): + return False, f"items[{i}] dict 아님" + for k in ("key", "title", "content", "evidence"): + if k not in it: + return False, f"items[{i}].{k} 누락" + if it["key"] not in SAJU_ITEM_KEYS: + return False, f"items[{i}].key 비정상: {it['key']}" + if it["key"] in seen_keys: + return False, f"items[{i}].key 중복: {it['key']}" + seen_keys.add(it["key"]) + ev = it["evidence"] + if not isinstance(ev, dict) or "saju_element" not in ev or "reasoning" not in ev: + return False, f"items[{i}].evidence 형식 오류" + if not ev.get("saju_element", "").strip() or not ev.get("reasoning", "").strip(): + return False, f"items[{i}].evidence 빈 문자열" + return True, "" + + +def validate_compat_interpretation(parsed: dict) -> tuple[bool, str]: + if not isinstance(parsed, dict): + return False, "응답이 dict가 아님" + for k in ("summary", "strengths", "challenges", "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')}" + for k in ("strengths", "challenges"): + v = parsed[k] + if not isinstance(v, list) or not v: + return False, f"{k}는 비어있지 않은 list 필요" + for i, item in enumerate(v): + if not isinstance(item, dict) or "title" not in item or "explanation" not in item or "evidence" not in item: + return False, f"{k}[{i}] 형식 오류" + return True, "" diff --git a/saju-lab/tests/test_schema.py b/saju-lab/tests/test_schema.py new file mode 100644 index 0000000..37b04a7 --- /dev/null +++ b/saju-lab/tests/test_schema.py @@ -0,0 +1,89 @@ +from app.interpret.schema import validate_saju_interpretation, validate_compat_interpretation + + +def _valid_saju_item(key="기질"): + return { + "key": key, "title": "타고난 기질", + "content": "본문 3~5문장", + "evidence": {"saju_element": "갑목 일주", "reasoning": "..."} + } + + +SAJU_ITEM_KEYS = [ + "기질", "오행밸런스", "지지상호작용", "신살영향", + "재물운", "직업적성", "애정운", "건강운", + "현재대운", "올해세운", "인생황금기", "종합조언", +] + + +def _valid_saju_payload(): + return { + "items": [_valid_saju_item(k) for k in SAJU_ITEM_KEYS], + "summary": "...", + "advice": "...", + "warning": None, + "confidence": "medium", + } + + +def test_valid_saju(): + ok, _ = validate_saju_interpretation(_valid_saju_payload()) + assert ok is True + + +def test_saju_missing_items(): + p = _valid_saju_payload(); del p["items"] + ok, err = validate_saju_interpretation(p) + assert not ok and "items" in err + + +def test_saju_wrong_item_count(): + p = _valid_saju_payload(); p["items"] = p["items"][:5] + ok, err = validate_saju_interpretation(p) + assert not ok and ("12" in err or "items" in err) + + +def test_saju_missing_evidence(): + p = _valid_saju_payload(); del p["items"][0]["evidence"] + ok, err = validate_saju_interpretation(p) + assert not ok and "evidence" in err + + +def test_saju_invalid_confidence(): + p = _valid_saju_payload(); p["confidence"] = "extreme" + ok, err = validate_saju_interpretation(p) + assert not ok and "confidence" in err + + +def _valid_compat_payload(): + return { + "summary": "두 사주 궁합 핵심", + "strengths": [ + {"title": "오행 상생", "explanation": "...", "evidence": "甲木 → 丁火"}, + {"title": "일지 합", "explanation": "...", "evidence": "申/巳 합"}, + ], + "challenges": [ + {"title": "...", "explanation": "...", "evidence": "..."}, + {"title": "...", "explanation": "...", "evidence": "..."}, + ], + "advice": "...", + "warning": None, + "confidence": "high", + } + + +def test_valid_compat(): + ok, _ = validate_compat_interpretation(_valid_compat_payload()) + assert ok is True + + +def test_compat_missing_strengths(): + p = _valid_compat_payload(); del p["strengths"] + ok, err = validate_compat_interpretation(p) + assert not ok and "strengths" in err + + +def test_compat_invalid_confidence(): + p = _valid_compat_payload(); p["confidence"] = "absolute" + ok, err = validate_compat_interpretation(p) + assert not ok and "confidence" in err