feat(saju-lab): interpret/prompt + schema — 12항목 + 궁합 SYSTEM_PROMPT (8 tests)
This commit is contained in:
129
saju-lab/app/interpret/prompt.py
Normal file
129
saju-lab/app/interpret/prompt.py
Normal 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 인용 포함.
|
||||||
|
"""
|
||||||
60
saju-lab/app/interpret/schema.py
Normal file
60
saju-lab/app/interpret/schema.py
Normal 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, ""
|
||||||
89
saju-lab/tests/test_schema.py
Normal file
89
saju-lab/tests/test_schema.py
Normal 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
|
||||||
Reference in New Issue
Block a user