feat(saju-lab): interpret/prompt + schema — 12항목 + 궁합 SYSTEM_PROMPT (8 tests)

This commit is contained in:
2026-05-25 20:21:07 +09:00
parent cad65dc869
commit f995f8739f
3 changed files with 278 additions and 0 deletions

View File

@@ -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 인용 포함.
"""

View File

@@ -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, ""

View File

@@ -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