Compare commits
29 Commits
20691b5057
...
8123f758a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8123f758a8 | |||
| 8ec3abb800 | |||
| 6d752acbe1 | |||
| f995f8739f | |||
| cad65dc869 | |||
| f4f518fc80 | |||
| db1f69c7a5 | |||
| ebfade655a | |||
| 234ccfe857 | |||
| 3f0b7bcd74 | |||
| f91a74237b | |||
| 95243a7f1f | |||
| 07b5c32f2f | |||
| 4ddcd75453 | |||
| 018459db88 | |||
| 42182014f0 | |||
| 03edfb04aa | |||
| 8b0c12b595 | |||
| e52e47fe3b | |||
| 8d25a1467a | |||
| 901d3535ee | |||
| 91caddb4b2 | |||
| abdfcbb144 | |||
| a94c73b134 | |||
| 387d2465b0 | |||
| 4073370e1b | |||
| 1775f7dd2d | |||
| 677d05fc31 | |||
| d87ad2421d |
@@ -38,9 +38,3 @@ LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
|
||||
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
|
||||
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
|
||||
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))
|
||||
|
||||
# Tarot Lab
|
||||
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "60"))
|
||||
|
||||
@@ -795,115 +795,3 @@ def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
# --- tarot_readings CRUD ---
|
||||
|
||||
def save_tarot_reading(data: Dict[str, Any]) -> int:
|
||||
interp = data.get("interpretation_json") or {}
|
||||
summary = interp.get("summary", "") if isinstance(interp, dict) else ""
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO tarot_readings
|
||||
(spread_type, category, question, cards, interpretation_json,
|
||||
summary, model, tokens_in, tokens_out, cost_usd, confidence)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
data["spread_type"],
|
||||
data.get("category"),
|
||||
data.get("question"),
|
||||
json.dumps(data.get("cards") or [], ensure_ascii=False),
|
||||
json.dumps(interp, ensure_ascii=False) if interp else None,
|
||||
summary,
|
||||
data.get("model"),
|
||||
data.get("tokens_in"),
|
||||
data.get("tokens_out"),
|
||||
data.get("cost_usd"),
|
||||
data.get("confidence"),
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone()
|
||||
return _tarot_row_to_dict(r) if r else None
|
||||
|
||||
|
||||
def list_tarot_readings(
|
||||
page: int = 1, size: int = 20,
|
||||
favorite: Optional[bool] = None,
|
||||
spread_type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wheres, params = [], []
|
||||
if favorite is not None:
|
||||
wheres.append("favorite=?")
|
||||
params.append(1 if favorite else 0)
|
||||
if spread_type:
|
||||
wheres.append("spread_type=?")
|
||||
params.append(spread_type)
|
||||
if category:
|
||||
wheres.append("category=?")
|
||||
params.append(category)
|
||||
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
offset = (page - 1) * size
|
||||
with _conn() as conn:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params
|
||||
).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
params + [size, offset],
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [_tarot_row_to_dict(r) for r in rows],
|
||||
"page": page, "size": size, "total": int(total),
|
||||
}
|
||||
|
||||
|
||||
def update_tarot_reading(reading_id: int, **kwargs) -> None:
|
||||
sets, vals = [], []
|
||||
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||
sets.append("favorite=?")
|
||||
vals.append(1 if kwargs["favorite"] else 0)
|
||||
if "note" in kwargs and kwargs["note"] is not None:
|
||||
sets.append("note=?")
|
||||
vals.append(kwargs["note"])
|
||||
if not sets:
|
||||
return
|
||||
vals.append(reading_id)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals)
|
||||
|
||||
|
||||
def delete_tarot_reading(reading_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,))
|
||||
|
||||
|
||||
def _tarot_row_to_dict(r) -> Dict[str, Any]:
|
||||
try:
|
||||
interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None
|
||||
except (ValueError, TypeError):
|
||||
interp = None
|
||||
try:
|
||||
cards = json.loads(r["cards"]) if r["cards"] else []
|
||||
except (ValueError, TypeError):
|
||||
cards = []
|
||||
return {
|
||||
"id": r["id"],
|
||||
"created_at": r["created_at"],
|
||||
"spread_type": r["spread_type"],
|
||||
"category": r["category"],
|
||||
"question": r["question"],
|
||||
"cards": cards,
|
||||
"interpretation_json": interp,
|
||||
"summary": r["summary"],
|
||||
"model": r["model"],
|
||||
"tokens_in": r["tokens_in"],
|
||||
"tokens_out": r["tokens_out"],
|
||||
"cost_usd": r["cost_usd"],
|
||||
"confidence": r["confidence"],
|
||||
"favorite": int(r["favorite"]),
|
||||
"note": r["note"],
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
|
||||
from .scheduler import init_scheduler
|
||||
from . import telegram_bot
|
||||
from .routers import notify as notify_router
|
||||
from .routers import tarot as tarot_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(notify_router.router)
|
||||
app.include_router(tarot_router.router)
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
|
||||
@@ -33,46 +33,3 @@ class ComposeCommand(BaseModel):
|
||||
style: Optional[str] = None
|
||||
model: Optional[str] = "V4"
|
||||
instrumental: Optional[bool] = False
|
||||
|
||||
|
||||
class TarotCardDraw(BaseModel):
|
||||
position: str
|
||||
card_id: str
|
||||
reversed: bool = False
|
||||
|
||||
|
||||
class TarotInterpretRequest(BaseModel):
|
||||
spread_type: Literal["one_card", "three_card"]
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
cards: List[TarotCardDraw]
|
||||
cards_reference: str = Field(..., min_length=1)
|
||||
context_meta: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TarotInterpretResponse(BaseModel):
|
||||
interpretation_json: dict
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
latency_ms: int
|
||||
reroll_count: int = 0
|
||||
|
||||
|
||||
class TarotSaveRequest(BaseModel):
|
||||
spread_type: Literal["one_card", "three_card"]
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
cards: List[TarotCardDraw]
|
||||
interpretation_json: dict
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
confidence: Optional[str] = None
|
||||
|
||||
|
||||
class TarotPatchRequest(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인."""
|
||||
81
agent-office/scripts/migrate_tarot_to_lab.py
Normal file
81
agent-office/scripts/migrate_tarot_to_lab.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""1회성 마이그레이션 — agent_office.db.tarot_readings → tarot.db.tarot_readings.
|
||||
|
||||
멱등성: 이미 존재하는 id는 SKIP.
|
||||
|
||||
실행:
|
||||
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
||||
|
||||
또는 호스트에서 직접:
|
||||
AGENT_OFFICE_DB=/path/to/agent_office.db TAROT_DB=/path/to/tarot.db \\
|
||||
python scripts/migrate_tarot_to_lab.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
|
||||
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
spread_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
question TEXT,
|
||||
cards TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
summary TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
cost_usd REAL,
|
||||
confidence TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def migrate() -> int:
|
||||
"""이관된 row 수 반환."""
|
||||
src = sqlite3.connect(SRC)
|
||||
src.row_factory = sqlite3.Row
|
||||
dst = sqlite3.connect(DST)
|
||||
dst.execute("PRAGMA journal_mode=WAL")
|
||||
dst.executescript(SCHEMA)
|
||||
|
||||
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
|
||||
if not rows:
|
||||
src.close(); dst.close()
|
||||
return 0
|
||||
|
||||
all_cols = list(rows[0].keys())
|
||||
|
||||
moved = 0
|
||||
for r in rows:
|
||||
exists = dst.execute("SELECT 1 FROM tarot_readings WHERE id=?", (r["id"],)).fetchone()
|
||||
if exists:
|
||||
continue
|
||||
# NULL 값은 INSERT에서 제외 → 목적지 스키마의 DEFAULT가 적용되도록 함
|
||||
# (예: created_at이 NULL이면 strftime() 기본값 사용)
|
||||
cols = [c for c in all_cols if r[c] is not None]
|
||||
placeholders = ",".join("?" * len(cols))
|
||||
cols_str = ",".join(cols)
|
||||
dst.execute(
|
||||
f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})",
|
||||
tuple(r[c] for c in cols),
|
||||
)
|
||||
moved += 1
|
||||
dst.commit()
|
||||
src.close(); dst.close()
|
||||
return moved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
moved = migrate()
|
||||
total = sqlite3.connect(SRC).execute("SELECT COUNT(*) FROM tarot_readings").fetchone()[0]
|
||||
print(f"migrated {moved} / {total} rows from {SRC} to {DST}")
|
||||
sys.exit(0)
|
||||
72
agent-office/tests/test_migrate_tarot.py
Normal file
72
agent-office/tests/test_migrate_tarot.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""migrate_tarot_to_lab.py 단위 테스트 — 멱등성 + 데이터 보존."""
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_db(tmp_path):
|
||||
p = tmp_path / "agent_office.db"
|
||||
conn = sqlite3.connect(str(p))
|
||||
conn.execute("""
|
||||
CREATE TABLE tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT, spread_type TEXT, category TEXT, question TEXT,
|
||||
cards TEXT, interpretation_json TEXT, summary TEXT, model TEXT,
|
||||
tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL,
|
||||
confidence TEXT, favorite INTEGER, note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO tarot_readings (id, spread_type, category, cards, model, favorite)
|
||||
VALUES (1, 'three_card', '연애', '[]', 'm', 0),
|
||||
(2, 'one_card', '재물', '[]', 'm', 1)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return str(p)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dst_db(tmp_path):
|
||||
return str(tmp_path / "tarot.db")
|
||||
|
||||
|
||||
def _import_migrate(src, dst, monkeypatch):
|
||||
monkeypatch.setenv("AGENT_OFFICE_DB", src)
|
||||
monkeypatch.setenv("TAROT_DB", dst)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
import migrate_tarot_to_lab as m
|
||||
import importlib
|
||||
importlib.reload(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_first_run_copies_all_rows(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
moved = m.migrate()
|
||||
assert moved == 2
|
||||
conn = sqlite3.connect(dst_db)
|
||||
rows = conn.execute("SELECT id, spread_type, category FROM tarot_readings ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows == [(1, "three_card", "연애"), (2, "one_card", "재물")]
|
||||
|
||||
|
||||
def test_idempotent_second_run(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
moved2 = m.migrate()
|
||||
assert moved2 == 0
|
||||
|
||||
|
||||
def test_partial_migration(src_db, dst_db, monkeypatch):
|
||||
"""dst에 id=1만 있는 상태에서 다시 돌리면 id=2만 옮김."""
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
conn = sqlite3.connect(dst_db)
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
moved = m.migrate()
|
||||
assert moved == 1
|
||||
@@ -1,113 +0,0 @@
|
||||
import json
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from app.tarot import pipeline as p
|
||||
from app.models import TarotInterpretRequest
|
||||
|
||||
|
||||
def _valid_response_text():
|
||||
return json.dumps({
|
||||
"summary": "S",
|
||||
"cards": [
|
||||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "i", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||
"interpretation": "i", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||
"interpretation": "i", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||
],
|
||||
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}],
|
||||
"advice": "A", "warning": None, "confidence": "medium",
|
||||
})
|
||||
|
||||
|
||||
def _claude_resp(text, in_tok=100, out_tok=200):
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||
}
|
||||
|
||||
|
||||
def _req():
|
||||
return TarotInterpretRequest(
|
||||
spread_type="three_card",
|
||||
category="연애",
|
||||
question="Q",
|
||||
cards=[
|
||||
{"position": "과거", "card_id": "the-fool", "reversed": False},
|
||||
{"position": "현재", "card_id": "the-lovers", "reversed": True},
|
||||
{"position": "미래", "card_id": "ten-of-cups", "reversed": False},
|
||||
],
|
||||
cards_reference="REFERENCE",
|
||||
context_meta={"major_minor_ratio": "2:1"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_happy_path(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text())))
|
||||
out = await p.interpret(_req())
|
||||
assert out["interpretation_json"]["confidence"] == "medium"
|
||||
assert out["tokens_in"] == 100
|
||||
assert out["tokens_out"] == 200
|
||||
assert out["reroll_count"] == 0
|
||||
assert out["cost_usd"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_codeblock_strip(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
wrapped = "```json\n" + _valid_response_text() + "\n```"
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped)))
|
||||
out = await p.interpret(_req())
|
||||
assert out["interpretation_json"]["summary"] == "S"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_reroll_on_validation_fail(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
bad = json.loads(_valid_response_text())
|
||||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||
bad_text = json.dumps(bad)
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
route = mock.post("/v1/messages")
|
||||
route.side_effect = [
|
||||
Response(200, json=_claude_resp(bad_text)),
|
||||
Response(200, json=_claude_resp(_valid_response_text())),
|
||||
]
|
||||
out = await p.interpret(_req())
|
||||
assert out["reroll_count"] == 1
|
||||
assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_raises_when_both_attempts_fail(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
bad = json.loads(_valid_response_text())
|
||||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||
bad_text = json.dumps(bad)
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text)))
|
||||
with pytest.raises(p.TarotError):
|
||||
await p.interpret(_req())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_raises_when_api_key_missing(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "")
|
||||
with pytest.raises(p.TarotError):
|
||||
await p.interpret(_req())
|
||||
|
||||
|
||||
def test_calc_cost():
|
||||
assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0)
|
||||
assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0)
|
||||
assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)
|
||||
@@ -1,86 +0,0 @@
|
||||
import json
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
db_file = tmp_path / "test_routes.db"
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
from app.main import app
|
||||
yield app
|
||||
|
||||
|
||||
def test_interpret_calls_pipeline(monkeypatch, fresh_db):
|
||||
async def fake_interpret(req):
|
||||
return {
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "high"},
|
||||
"model": "claude-sonnet-4-6", "tokens_in": 100, "tokens_out": 200,
|
||||
"cost_usd": 0.005, "latency_ms": 1234, "reroll_count": 0,
|
||||
}
|
||||
from app.tarot import pipeline
|
||||
monkeypatch.setattr(pipeline, "interpret", fake_interpret)
|
||||
client = TestClient(fresh_db)
|
||||
r = client.post("/api/agent-office/tarot/interpret", json={
|
||||
"spread_type": "one_card",
|
||||
"category": "일반",
|
||||
"question": "Q",
|
||||
"cards": [{"position": "오늘", "card_id": "the-fool", "reversed": False}],
|
||||
"cards_reference": "REF",
|
||||
"context_meta": {},
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["interpretation_json"]["confidence"] == "high"
|
||||
|
||||
|
||||
def test_save_and_list(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
save = client.post("/api/agent-office/tarot/readings", json={
|
||||
"spread_type": "three_card", "category": "연애", "question": "Q",
|
||||
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||
"model": "claude-sonnet-4-6", "tokens_in": 1, "tokens_out": 2, "cost_usd": 0.01,
|
||||
"confidence": "medium",
|
||||
})
|
||||
assert save.status_code == 200, save.text
|
||||
rid = save.json()["id"]
|
||||
lst = client.get("/api/agent-office/tarot/readings?page=1&size=10")
|
||||
assert lst.json()["total"] == 1
|
||||
assert lst.json()["items"][0]["id"] == rid
|
||||
|
||||
|
||||
def test_patch_favorite(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
save = client.post("/api/agent-office/tarot/readings", json={
|
||||
"spread_type": "one_card", "cards": [],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||
})
|
||||
rid = save.json()["id"]
|
||||
p = client.patch(f"/api/agent-office/tarot/readings/{rid}", json={"favorite": True})
|
||||
assert p.status_code == 200
|
||||
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||
assert g.json()["favorite"] == 1
|
||||
|
||||
|
||||
def test_delete(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
save = client.post("/api/agent-office/tarot/readings", json={
|
||||
"spread_type": "one_card", "cards": [],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||
})
|
||||
rid = save.json()["id"]
|
||||
d = client.delete(f"/api/agent-office/tarot/readings/{rid}")
|
||||
assert d.status_code == 200
|
||||
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
def test_get_missing_reading_404(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
r = client.get("/api/agent-office/tarot/readings/99999")
|
||||
assert r.status_code == 404
|
||||
@@ -1,75 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from app.tarot.schema import validate_interpretation
|
||||
|
||||
|
||||
def _valid_three():
|
||||
return {
|
||||
"summary": "S",
|
||||
"cards": [
|
||||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||
],
|
||||
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "..."}],
|
||||
"advice": "A",
|
||||
"warning": None,
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
|
||||
def test_valid_three_card_passes():
|
||||
ok, msg = validate_interpretation(_valid_three(), "three_card")
|
||||
assert ok, msg
|
||||
|
||||
|
||||
def test_missing_evidence_fails():
|
||||
bad = _valid_three()
|
||||
del bad["cards"][0]["evidence"]
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
assert "evidence" in msg
|
||||
|
||||
|
||||
def test_empty_card_meaning_used_fails():
|
||||
bad = _valid_three()
|
||||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
assert "card_meaning_used" in msg
|
||||
|
||||
|
||||
def test_three_card_requires_interactions():
|
||||
bad = _valid_three()
|
||||
bad["interactions"] = []
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
assert "interactions" in msg
|
||||
|
||||
|
||||
def test_one_card_accepts_empty_interactions():
|
||||
one = {
|
||||
"summary": "S",
|
||||
"cards": [{"position": "오늘", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}],
|
||||
"interactions": [],
|
||||
"advice": "A",
|
||||
"warning": None,
|
||||
"confidence": "high",
|
||||
}
|
||||
ok, msg = validate_interpretation(one, "one_card")
|
||||
assert ok, msg
|
||||
|
||||
|
||||
def test_invalid_confidence_fails():
|
||||
bad = _valid_three()
|
||||
bad["confidence"] = "very high"
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
@@ -227,6 +227,54 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
tarot-lab:
|
||||
build:
|
||||
context: ./tarot-lab
|
||||
container_name: tarot-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18250:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
|
||||
- TAROT_COST_INPUT_PER_M=${TAROT_COST_INPUT_PER_M:-3.0}
|
||||
- TAROT_COST_OUTPUT_PER_M=${TAROT_COST_OUTPUT_PER_M:-15.0}
|
||||
- TAROT_TIMEOUT_SEC=${TAROT_TIMEOUT_SEC:-180}
|
||||
- TAROT_DATA_PATH=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/tarot:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
saju-lab:
|
||||
build:
|
||||
context: ./saju-lab
|
||||
container_name: saju-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18300:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- SAJU_MODEL=${SAJU_MODEL:-claude-sonnet-4-6}
|
||||
- SAJU_COST_INPUT_PER_M=${SAJU_COST_INPUT_PER_M:-3.0}
|
||||
- SAJU_COST_OUTPUT_PER_M=${SAJU_COST_OUTPUT_PER_M:-15.0}
|
||||
- SAJU_TIMEOUT_SEC=${SAJU_TIMEOUT_SEC:-240}
|
||||
- SAJU_DATA_PATH=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/saju:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
personal:
|
||||
build:
|
||||
context: ./personal
|
||||
|
||||
3274
docs/superpowers/plans/2026-05-25-saju-tarot-lab-migration.md
Normal file
3274
docs/superpowers/plans/2026-05-25-saju-tarot-lab-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,670 @@
|
||||
# saju-lab 신설 + tarot-lab 분리 — 마이그레이션 설계
|
||||
|
||||
**작성일**: 2026-05-25
|
||||
**상태**: Spec (구현 plan 작성 전)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
1. **saju-lab 신설**: 별도 디렉토리에 있던 `saju-web` (Next.js + Supabase + OpenAI) 프로젝트를 web-backend 모노레포의 한 lab 서비스로 마이그레이션. Python FastAPI + Claude + SQLite 패턴으로 단순화.
|
||||
2. **tarot-lab 분리**: 현재 `agent-office` 컨테이너 내부 모듈로 들어 있는 tarot 기능을 독립 컨테이너로 분리. agent-office가 가벼워지고 tarot은 자체 라이프사이클을 가짐.
|
||||
|
||||
두 작업이 같은 패턴(독립 lab 컨테이너 신설)을 공유하므로 하나의 spec에 담아 순차 구현.
|
||||
|
||||
---
|
||||
|
||||
## 2. 배경
|
||||
|
||||
### 2-1. saju-web 현황
|
||||
- 위치: `C:\Users\jaeoh\Desktop\workspace\saju-web`
|
||||
- 스택: Next.js 16, TypeScript, Supabase(OAuth+DB), OpenAI gpt-4o, PortOne 결제, Kakao 공유
|
||||
- 기능 4종: 사주분석(10토큰), 궁합(15토큰), 토정비결(5토큰), 오늘의 운세
|
||||
- 핵심 자산: `lib/saju-calculator.ts`, `lib/ai-interpretation.ts`, `lib/daeun-calculator.ts`, `lib/solar-terms.ts` (계산 엔진 ~1500줄)
|
||||
- 현재 사용 중이 아님. 자산 보존 + 패턴 일치화를 위한 마이그레이션
|
||||
|
||||
### 2-2. tarot-lab 현황
|
||||
- 위치: `agent-office/app/tarot/` (모듈), `agent-office/app/routers/tarot.py`
|
||||
- DB: `agent_office.db`의 `tarot_readings` 테이블
|
||||
- API: `/api/agent-office/tarot/*` 6개 endpoint (interpret, save, list, get, patch, delete)
|
||||
- 21개 단위 테스트 존재
|
||||
- 문제: agent-office가 점점 비대해짐 (텔레그램·로또·주식·청약·유튜브·타로 모두 한 컨테이너에). tarot은 독립 도메인이라 분리가 자연스러움
|
||||
|
||||
### 2-3. 다른 lab 패턴 (참조 기준)
|
||||
`insta-lab`, `music-lab`, `realestate-lab`은 모두 동일 패턴:
|
||||
```
|
||||
<lab>/
|
||||
├── Dockerfile (python:3.12-slim)
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── tests/
|
||||
└── app/
|
||||
├── main.py (FastAPI)
|
||||
├── config.py
|
||||
├── db.py (SQLite)
|
||||
└── <도메인 모듈들>
|
||||
```
|
||||
- 인증 없음 (개인 NAS 서비스)
|
||||
- nginx가 `/api/<name>/`로 라우팅
|
||||
- docker-compose의 한 항목으로 등록
|
||||
- Gitea Webhook → deployer가 rsync + docker compose up -d --build
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 결정 사항
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 백엔드 언어 | Python FastAPI (saju 계산 엔진은 TypeScript → Python 포팅) |
|
||||
| AI 모델 | Claude Sonnet 4.6 (`claude-sonnet-4-6`) + prompt-caching beta. tarot과 일관 |
|
||||
| DB | SQLite 로컬 (saju-lab은 `saju.db`, tarot-lab은 `tarot.db`) |
|
||||
| 인증 | 없음 (다른 lab 패턴 일치). saju-web의 Supabase/PortOne/Kakao 제거 |
|
||||
| saju-lab v1 기능 | 사주 분석 + 궁합 + 사주 결과 내 세운(歲運) (오늘의 운세는 세운으로 통합). 토정비결은 v2 |
|
||||
| tarot DB 마이그레이션 | 1회성 복사 스크립트 (agent_office.db → tarot.db), cutover 후 agent-office tarot 모듈 완전 제거 |
|
||||
| saju-lab UI | 시안 기반 신규 (시안 추후 제공, Phase 2 마지막 단계) |
|
||||
| API prefix | `/api/saju/*`, `/api/tarot/*` (완전 이전) — `/api/agent-office/tarot/*`는 제거 |
|
||||
| 포트 (내부) | tarot-lab 18250, saju-lab 18300 |
|
||||
| 진행 순서 | Phase 1 tarot 분리 → Phase 2 saju 신설 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 디렉토리 구조
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── tarot-lab/ # [신설]
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── pytest.ini
|
||||
│ ├── tests/
|
||||
│ │ ├── test_db.py # agent-office/tests/test_tarot_db.py 이관
|
||||
│ │ ├── test_schema.py
|
||||
│ │ ├── test_pipeline.py
|
||||
│ │ └── test_routes.py # 6 endpoint (interpret + readings CRUD 5)
|
||||
│ └── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app + /api/tarot/* 라우터 6개
|
||||
│ ├── config.py # TAROT_MODEL, TAROT_COST_*, ANTHROPIC_API_KEY, TAROT_TIMEOUT_SEC
|
||||
│ ├── db.py # tarot.db: 5 CRUD + _tarot_row_to_dict
|
||||
│ ├── models.py # Pydantic 모델 5개 (TarotCardDraw, TarotInterpretRequest, TarotInterpretResponse, TarotSaveRequest, TarotPatchRequest)
|
||||
│ ├── pipeline.py # Claude 호출 + reroll 1회
|
||||
│ ├── prompt.py # SYSTEM_PROMPT + build_user_message
|
||||
│ └── schema.py # validate_interpretation
|
||||
│
|
||||
├── saju-lab/ # [신설]
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt # fastapi, httpx, anthropic, pydantic, sxtwl(절기/음력)
|
||||
│ ├── pytest.ini
|
||||
│ ├── tests/
|
||||
│ │ ├── fixtures/
|
||||
│ │ │ └── reference_saju.json # Node.js 원본에서 추출한 입력→출력 쌍
|
||||
│ │ ├── test_core.py # 천간/지지/십성/십이운성/calculate_saju
|
||||
│ │ ├── test_solar_terms.py # 24절기
|
||||
│ │ ├── test_lunar.py # 음력 변환
|
||||
│ │ ├── test_analysis.py # 오행/신강신약/용신/세운
|
||||
│ │ ├── test_daeun.py
|
||||
│ │ ├── test_shinsal.py # 신살/공망/지장간
|
||||
│ │ ├── test_compatibility.py # 궁합 점수
|
||||
│ │ ├── test_pipeline.py # Claude mock + reroll
|
||||
│ │ ├── test_compat_pipeline.py
|
||||
│ │ ├── test_schema.py
|
||||
│ │ ├── test_db.py
|
||||
│ │ └── test_routes.py
|
||||
│ └── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app
|
||||
│ ├── config.py # SAJU_MODEL, SAJU_COST_*, ANTHROPIC_API_KEY, SAJU_TIMEOUT_SEC
|
||||
│ ├── db.py # saju.db: saju_records, compat_records 테이블 + CRUD
|
||||
│ ├── models.py # SajuRequest, CompatRequest, etc.
|
||||
│ ├── calculator/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── constants.py # HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS, HIDDEN_STEMS, TEN_GODS, TWELVE_FORTUNES
|
||||
│ │ ├── core.py # get_year_ganzi, get_month_ganzi, get_day_ganzi, get_hour_ganzi, get_ten_god, get_twelve_fortune, calculate_saju
|
||||
│ │ ├── solar_terms.py # get_solar_term_date, get_current_solar_term, get_solar_term_month_branch, get_days_to_next_solar_term — sxtwl 사용
|
||||
│ │ ├── lunar.py # solar_to_lunar, lunar_to_solar
|
||||
│ │ ├── shinsal.py # get_hidden_stems, get_all_hidden_stems, analyze_branch_interactions, calculate_shinsal, calculate_gongmang
|
||||
│ │ ├── analysis.py # calculate_detailed_element_balance, calculate_element_score, analyze_day_master_strength, estimate_yongshin, calculate_seun, perform_full_analysis
|
||||
│ │ ├── daeun.py # calculate_daeun, get_current_daeun, get_daeun_description
|
||||
│ │ └── compatibility.py # calculate_compatibility (오행 상생/상극 + 지지 합/충 점수화)
|
||||
│ ├── interpret/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── pipeline.py # Claude 호출 + reroll (tarot 패턴)
|
||||
│ │ ├── compat_pipeline.py
|
||||
│ │ ├── prompt.py # 사주 12항목 SYSTEM_PROMPT (Claude용 재작성, evidence-based)
|
||||
│ │ ├── compat_prompt.py # 궁합 SYSTEM_PROMPT
|
||||
│ │ └── schema.py # validate_saju_interpretation, validate_compat_interpretation
|
||||
│ └── routers/
|
||||
│ ├── __init__.py
|
||||
│ ├── saju.py # POST /api/saju/interpret, /readings CRUD, /current-fortune
|
||||
│ └── compat.py # POST /api/saju/compat/interpret, /readings CRUD
|
||||
│
|
||||
├── agent-office/ # [수정]
|
||||
│ ├── app/
|
||||
│ │ ├── tarot/ # [제거]
|
||||
│ │ ├── routers/tarot.py # [제거]
|
||||
│ │ ├── models.py # Tarot* 5개 제거
|
||||
│ │ ├── db.py # tarot_readings 관련 CRUD 5개 + _tarot_row_to_dict + CREATE TABLE 제거
|
||||
│ │ └── main.py # include_router(tarot_router.router) 줄 제거
|
||||
│ ├── tests/ # test_tarot_*.py 4개 제거
|
||||
│ └── scripts/
|
||||
│ └── migrate_tarot_to_lab.py # [신설] 1회성 마이그레이션
|
||||
│
|
||||
├── docker-compose.yml # [수정] tarot-lab, saju-lab 추가
|
||||
├── nginx/default.conf # [수정] /api/tarot/ → tarot-lab, /api/saju/ → saju-lab, /api/agent-office/tarot/ 제거
|
||||
├── scripts/
|
||||
│ ├── deploy-nas.sh # [수정] CONTAINERS 배열에 saju-lab, tarot-lab 추가
|
||||
│ └── deploy.sh # [수정] 5위치 (CLAUDE.md memory의 "배포 스크립트 동기화" 항목 참조)
|
||||
└── docs/superpowers/specs/
|
||||
└── 2026-05-25-saju-tarot-lab-migration-design.md # 본 문서
|
||||
```
|
||||
|
||||
**프론트엔드 (`web-ui/`)** — Phase 1·2 양쪽 변경:
|
||||
```
|
||||
web-ui/
|
||||
├── src/
|
||||
│ ├── api.js # [Phase 1 수정] tarot helpers 6개 URL prefix 변경 + [Phase 2 추가] saju/compat helpers
|
||||
│ ├── routes.jsx # [Phase 2 수정] /saju, /saju/result, /saju/compatibility, /saju/compatibility/result 라우트
|
||||
│ ├── components/Icons.jsx # [Phase 2 수정] IconSaju 추가
|
||||
│ └── pages/
|
||||
│ ├── tarot/ # [Phase 1] URL prefix만 변경, 그 외 변경 없음
|
||||
│ └── saju/ # [Phase 2 신설, 시안 받은 후]
|
||||
│ ├── Saju.jsx
|
||||
│ ├── SajuForm.jsx
|
||||
│ ├── SajuResult.jsx
|
||||
│ ├── Compatibility.jsx
|
||||
│ ├── CompatibilityForm.jsx
|
||||
│ ├── CompatibilityResult.jsx
|
||||
│ ├── data/
|
||||
│ │ ├── constants.js # 천간/지지/오행 상수 (UI 표시용)
|
||||
│ │ └── interpretations.js
|
||||
│ ├── hooks/
|
||||
│ │ ├── useSajuForm.js
|
||||
│ │ └── useSajuInterpretation.js
|
||||
│ └── components/
|
||||
│ ├── SajuBoard.jsx # 4기둥 시각화
|
||||
│ ├── ElementChart.jsx# 오행 차트
|
||||
│ ├── DaeunTimeline.jsx
|
||||
│ └── InterpretationPanel.jsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1: tarot-lab 분리
|
||||
|
||||
### 5-1. 신규 tarot-lab 컨테이너 생성
|
||||
|
||||
**파일 단순 복사 + 모듈 평탄화:**
|
||||
- `agent-office/app/tarot/__init__.py` → `tarot-lab/app/__init__.py` (간단화)
|
||||
- `agent-office/app/tarot/prompt.py` → `tarot-lab/app/prompt.py`
|
||||
- `agent-office/app/tarot/pipeline.py` → `tarot-lab/app/pipeline.py` (import 경로 수정: `..config` → `.config`, `..models` → `.models`)
|
||||
- `agent-office/app/tarot/schema.py` → `tarot-lab/app/schema.py`
|
||||
|
||||
**추출 파일:**
|
||||
- `tarot-lab/app/config.py`: agent-office의 config.py에서 TAROT_* 환경변수만 추출
|
||||
```python
|
||||
import os
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "180"))
|
||||
TAROT_DATA_PATH = os.getenv("TAROT_DATA_PATH", "/app/data")
|
||||
TAROT_DB_PATH = os.path.join(TAROT_DATA_PATH, "tarot.db")
|
||||
```
|
||||
- `tarot-lab/app/models.py`: agent-office models.py에서 Tarot* 5개만 추출
|
||||
- `tarot-lab/app/db.py`:
|
||||
- tarot_readings CREATE TABLE + WAL 활성화
|
||||
- 5 CRUD (save/get/list/update/delete) + `_tarot_row_to_dict`
|
||||
- DB 경로는 `TAROT_DB_PATH` (volume mount된 `/app/data/tarot.db`)
|
||||
- `tarot-lab/app/main.py`:
|
||||
```python
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from .models import (...)
|
||||
from . import pipeline, db as db_module
|
||||
|
||||
app = FastAPI(title="tarot-lab")
|
||||
|
||||
@app.on_event("startup")
|
||||
def _init_db():
|
||||
db_module.init_db()
|
||||
|
||||
# /api/tarot/* 5 endpoints (routers/tarot.py 코드 그대로)
|
||||
```
|
||||
|
||||
**Dockerfile (insta-lab 패턴):**
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
**requirements.txt:**
|
||||
```
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
httpx==0.27.2
|
||||
pydantic==2.9.2
|
||||
```
|
||||
|
||||
### 5-2. 테스트 이관
|
||||
- `agent-office/tests/test_tarot_*.py` 4개 파일 → `tarot-lab/tests/test_*.py`
|
||||
- import 경로 수정 (`from app.tarot.pipeline` → `from app.pipeline`)
|
||||
- pytest.ini 추가 (`testpaths = tests`, `pythonpath = .`)
|
||||
- 모두 통과 확인 (21 tests)
|
||||
|
||||
### 5-3. DB 마이그레이션 스크립트
|
||||
|
||||
`agent-office/scripts/migrate_tarot_to_lab.py`:
|
||||
```python
|
||||
"""1회성 — agent_office.db의 tarot_readings를 tarot.db로 복사.
|
||||
멱등성: 이미 존재하는 id는 SKIP.
|
||||
실행: docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
|
||||
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
|
||||
|
||||
def migrate():
|
||||
src = sqlite3.connect(SRC)
|
||||
dst = sqlite3.connect(DST)
|
||||
dst.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
... (agent-office 스키마 그대로)
|
||||
)
|
||||
""")
|
||||
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
|
||||
cols = [c[0] for c in src.execute("SELECT * FROM tarot_readings LIMIT 1").description]
|
||||
placeholders = ",".join("?" * len(cols))
|
||||
cols_str = ",".join(cols)
|
||||
moved = 0
|
||||
for r in rows:
|
||||
cur = dst.execute(f"SELECT 1 FROM tarot_readings WHERE id = ?", (r[0],))
|
||||
if cur.fetchone() is None:
|
||||
dst.execute(f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})", r)
|
||||
moved += 1
|
||||
dst.commit()
|
||||
print(f"migrated {moved} / {len(rows)} rows")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
```
|
||||
|
||||
**볼륨 공유 전략**: tarot-lab의 `/app/data`를 agent-office의 `/app/data`와 같은 NAS 호스트 디렉토리에 마운트. tarot.db는 신규 파일이라 별도 마운트 가능.
|
||||
|
||||
### 5-4. docker-compose / nginx / deploy 갱신
|
||||
|
||||
**docker-compose.yml**에 추가:
|
||||
```yaml
|
||||
tarot-lab:
|
||||
build: ./tarot-lab
|
||||
container_name: tarot-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18250:8000"
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
|
||||
- TAROT_DATA_PATH=/app/data
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||
```
|
||||
|
||||
**nginx/default.conf**에 추가, 기존 `/api/agent-office/tarot/`은 제거:
|
||||
```nginx
|
||||
location /api/tarot/ {
|
||||
proxy_pass http://tarot-lab:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
```
|
||||
|
||||
**deploy 스크립트 5위치** (memory의 "배포 스크립트 동기화" 참조):
|
||||
- `scripts/deploy-nas.sh`의 CONTAINERS 배열
|
||||
- `scripts/deploy.sh`의 SERVICES, DIRS 배열
|
||||
- 컨테이너 목록 하드코딩된 모든 위치에 `tarot-lab` 추가 (Phase 1) / `saju-lab` 추가 (Phase 2)
|
||||
|
||||
### 5-5. agent-office cutover
|
||||
|
||||
마이그레이션 + 데이터 검증 후:
|
||||
- `agent-office/app/tarot/` 디렉토리 통째로 제거
|
||||
- `agent-office/app/routers/tarot.py` 제거
|
||||
- `agent-office/app/main.py`에서 tarot router import + include_router 줄 제거
|
||||
- `agent-office/app/models.py`에서 `TarotCardDraw`, `TarotInterpretRequest`, `TarotInterpretResponse`, `TarotSaveRequest`, `TarotPatchRequest` 제거
|
||||
- `agent-office/app/db.py`에서 `save_tarot_reading`, `get_tarot_reading`, `list_tarot_readings`, `update_tarot_reading`, `delete_tarot_reading`, `_tarot_row_to_dict` 제거
|
||||
- `agent-office/app/db.py`의 CREATE TABLE에서 `tarot_readings` 줄 제거 (또는 idempotent 유지: 기존 DB 호환 위해 CREATE IF NOT EXISTS는 유지하되 코드 경로 제거)
|
||||
- `agent-office/tests/test_tarot_*.py` 4개 제거
|
||||
- agent-office pytest 통과 확인
|
||||
|
||||
### 5-6. web-ui api.js URL 변경
|
||||
|
||||
`web-ui/src/api.js`의 tarot helpers 6개:
|
||||
- `tarotInterpret`: `/api/agent-office/tarot/interpret` → `/api/tarot/interpret`
|
||||
- `tarotSaveReading`: `/api/agent-office/tarot/readings` → `/api/tarot/readings`
|
||||
- `tarotListReadings`: 동일 변환
|
||||
- `tarotGetReading`: 동일 변환
|
||||
- `tarotPatchReading`: 동일 변환
|
||||
- `tarotDeleteReading`: 동일 변환
|
||||
|
||||
Phase 1 검증: `npm run dev` → http://127.0.0.1:3007/tarot → 3장 리딩 1회 e2e 동작 확인.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2: saju-lab 신설
|
||||
|
||||
### 6-1. 계산 엔진 포팅 (TypeScript → Python)
|
||||
|
||||
**핵심 위험**: 계산 엔진은 ~1500줄 TypeScript로 매년 검증된 코드. Python으로 옮기면서 미세한 버그가 들어가면 모든 사주 해석이 잘못됨.
|
||||
|
||||
**대응 전략 — Reference Output 비교 테스트**:
|
||||
1. saju-web의 `lib/saju-calculator.ts` 코드를 Node.js로 직접 실행 (`node -e "..."`)
|
||||
2. 알려진 입력 30~50쌍에 대해 `calculateSaju(year, month, day, hour, gender)` + `performFullAnalysis(saju, currentYear)` + `calculateDaeun(...)` 호출 결과를 JSON 파일로 저장
|
||||
3. `tests/fixtures/reference_saju.json` 형식:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"input": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male"},
|
||||
"expected": {
|
||||
"saju": {...},
|
||||
"analysis": {...},
|
||||
"daeun": [...]
|
||||
}
|
||||
},
|
||||
... (50개)
|
||||
]
|
||||
```
|
||||
4. Python 포팅 후 pytest로 매 입력 → expected와 1:1 비교 (`assert deep_equal(actual, expected)`)
|
||||
|
||||
**포팅 순서** (의존성 그래프):
|
||||
1. `calculator/constants.py` — 모든 상수 (천간 10·지지 12·오행 5·십성·십이운성·지장간·신살)
|
||||
2. `calculator/solar_terms.py` — `sxtwl` Python 라이브러리 사용 (24절기 + 음력)
|
||||
3. `calculator/lunar.py` — `sxtwl` 음력↔양력 변환
|
||||
4. `calculator/core.py` — `get_year_ganzi`, `get_month_ganzi` (절기 기반), `get_day_ganzi`, `get_hour_ganzi`, `get_ten_god`, `get_twelve_fortune`, `calculate_saju`
|
||||
5. `calculator/shinsal.py` — 지장간(`get_hidden_stems`, `get_all_hidden_stems`), 지지 상호작용(`analyze_branch_interactions`), 신살(`calculate_shinsal`), 공망(`calculate_gongmang`)
|
||||
6. `calculator/analysis.py` — 오행 점수(`calculate_detailed_element_balance`, `calculate_element_score`), 신강신약(`analyze_day_master_strength`), 용신(`estimate_yongshin`), 세운(`calculate_seun`), 종합(`perform_full_analysis`)
|
||||
7. `calculator/daeun.py` — `calculate_daeun`, `get_current_daeun`, `get_daeun_description`
|
||||
8. `calculator/compatibility.py` — 두 사주의 오행 매칭 + 지지 합/충 점수화 → 0~100 점수
|
||||
|
||||
각 단계마다 reference test 통과를 게이트로.
|
||||
|
||||
### 6-2. Claude 프롬프트 (tarot 패턴 재활용)
|
||||
|
||||
**`interpret/prompt.py`** — 사주 12항목 해석:
|
||||
- 시스템 프롬프트: "당신은 한국 전통 사주명리학 전문가다. 다음 사주 + 분석 결과를 보고, JSON 스키마로 12항목 해석을 작성하라. 각 항목은 evidence 필드를 포함해 어떤 사주 요소에서 결론을 도출했는지 명시하라."
|
||||
- 12항목: 타고난 기질 / 오행 밸런스 / 지지 상호작용 / 신살 영향 / 재물운 / 직업 적성 / 애정운 / 건강운 / 현재 대운 / 올해 세운 / 인생 황금기 / 종합 조언
|
||||
- JSON 응답 스키마:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "key": "기질", "title": "...", "content": "...", "evidence": {"saju_element": "...", "reasoning": "..."} },
|
||||
...
|
||||
],
|
||||
"summary": "...",
|
||||
"advice": "...",
|
||||
"warning": "...",
|
||||
"confidence": "high|medium|low"
|
||||
}
|
||||
```
|
||||
- `cache_control: ephemeral`을 system 블록에 적용
|
||||
|
||||
**`interpret/compat_prompt.py`** — 궁합 해석:
|
||||
- 두 사주 + 궁합 점수 + 오행 상생/상극 분석 → JSON 응답
|
||||
- evidence: 어떤 지지 합/충에서 점수가 나왔는지 명시
|
||||
|
||||
**`interpret/schema.py`** — validate 함수:
|
||||
- `validate_saju_interpretation(parsed)`: items 12개 존재 / 각 evidence 채워졌는지 / confidence 값 검증
|
||||
- `validate_compat_interpretation(parsed)`: 마찬가지
|
||||
|
||||
**`interpret/pipeline.py`** — Claude 호출 (tarot pipeline.py 거의 그대로 복사 + 사주용 prompt/schema 사용):
|
||||
- max_tokens 2400 (12항목 + 종합이라 더 길음)
|
||||
- reroll 1회
|
||||
- latency_ms / tokens 로깅
|
||||
|
||||
### 6-3. DB 스키마
|
||||
|
||||
`saju-lab/app/db.py`:
|
||||
```python
|
||||
SAJU_DB_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS saju_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
birth_year INTEGER NOT NULL,
|
||||
birth_month INTEGER NOT NULL,
|
||||
birth_day INTEGER NOT NULL,
|
||||
birth_hour INTEGER,
|
||||
gender TEXT NOT NULL,
|
||||
calendar_type TEXT DEFAULT 'solar',
|
||||
saju_data JSON NOT NULL,
|
||||
analysis_data JSON NOT NULL,
|
||||
daeun_data JSON NOT NULL,
|
||||
interpretation_json JSON,
|
||||
model TEXT,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
reroll_count INTEGER DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
memo TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compat_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_a JSON NOT NULL,
|
||||
person_b JSON NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
breakdown JSON NOT NULL,
|
||||
interpretation_json JSON,
|
||||
model TEXT,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
memo TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
```
|
||||
|
||||
CRUD 함수: `save_saju_record`, `get_saju_record`, `list_saju_records`, `update_saju_record`, `delete_saju_record` + compat 5개.
|
||||
|
||||
### 6-4. API 엔드포인트
|
||||
|
||||
**`routers/saju.py`**:
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/saju/interpret` | 입력 → 계산 + AI 해석 + DB 저장. 응답에 saju/analysis/daeun/interpretation/reading_id 포함 |
|
||||
| GET | `/api/saju/readings` | 페이지네이션 목록 (page, size, favorite) |
|
||||
| GET | `/api/saju/readings/{id}` | 상세 조회 |
|
||||
| PATCH | `/api/saju/readings/{id}` | favorite, memo 수정 |
|
||||
| DELETE | `/api/saju/readings/{id}` | 삭제 |
|
||||
| GET | `/api/saju/current-fortune?reading_id={id}` | 저장된 사주 기반 오늘의 세운 (실시간 계산, AI 호출 없음) |
|
||||
|
||||
**`routers/compat.py`**:
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/saju/compat/interpret` | 두 사람 입력 → 두 사주 계산 + 궁합 점수 + AI 해석 + DB 저장 |
|
||||
| GET | `/api/saju/compat/readings` | 목록 |
|
||||
| GET | `/api/saju/compat/readings/{id}` | 상세 |
|
||||
| PATCH | `/api/saju/compat/readings/{id}` | favorite, memo |
|
||||
| DELETE | `/api/saju/compat/readings/{id}` | 삭제 |
|
||||
|
||||
### 6-5. docker-compose / nginx 등록
|
||||
|
||||
**docker-compose.yml**에 saju-lab 항목 추가 (tarot-lab과 동일 패턴, 포트 18300).
|
||||
|
||||
**nginx/default.conf**에 추가:
|
||||
```nginx
|
||||
location /api/saju/ {
|
||||
proxy_pass http://saju-lab:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
```
|
||||
|
||||
deploy 스크립트 5위치에 `saju-lab` 추가.
|
||||
|
||||
### 6-6. web-ui /saju 페이지
|
||||
|
||||
**시안 추후 제공** (사용자 확인). 시안 받은 후 tarot 페이지 패턴 따라 구현:
|
||||
- 입력 폼: 생년월일 + 시간 + 성별 + 양력/음력 (양력 default)
|
||||
- 결과 페이지: 사주판 시각화 + 오행 차트 + 대운 타임라인 + AI 12항목 아코디언
|
||||
- 궁합: 두 사람 입력 폼 + 결과 카드
|
||||
- 인사이트 패널 (tarot의 InterpretationPanel.jsx 패턴 차용)
|
||||
|
||||
`api.js`에 helpers 추가:
|
||||
- `sajuInterpret`, `sajuListReadings`, `sajuGetReading`, `sajuPatchReading`, `sajuDeleteReading`, `sajuCurrentFortune`
|
||||
- `compatInterpret`, `compatListReadings`, `compatGetReading`, `compatPatchReading`, `compatDeleteReading`
|
||||
|
||||
`routes.jsx`에 라우트 추가:
|
||||
- `/saju` (입력), `/saju/result` (사주 결과), `/saju/compatibility` (입력), `/saju/compatibility/result` (궁합 결과)
|
||||
|
||||
`components/Icons.jsx`에 `IconSaju` 추가.
|
||||
|
||||
---
|
||||
|
||||
## 7. 데이터 흐름
|
||||
|
||||
### tarot-lab (Phase 1)
|
||||
```
|
||||
[web-ui /tarot/reading]
|
||||
↓ POST /api/tarot/interpret { cards, question, category, spread_type }
|
||||
[nginx /api/tarot/ → tarot-lab:8000]
|
||||
↓ pipeline.interpret() → Claude API
|
||||
↓ validate + reroll
|
||||
[tarot-lab]
|
||||
↓ POST /api/tarot/readings { ... save body }
|
||||
↓ db.save_tarot_reading() → tarot.db INSERT
|
||||
← { id, created_at }
|
||||
```
|
||||
|
||||
### saju-lab (Phase 2)
|
||||
```
|
||||
[web-ui /saju/result]
|
||||
↓ POST /api/saju/interpret { year, month, day, hour, gender, calendarType }
|
||||
[nginx /api/saju/ → saju-lab:8000]
|
||||
↓ calculator.calculate_saju() → SajuData
|
||||
↓ calculator.perform_full_analysis() → SajuAnalysis
|
||||
↓ calculator.calculate_daeun() → DaeunPillar[]
|
||||
↓ interpret.pipeline.interpret() → Claude API
|
||||
↓ validate + reroll
|
||||
↓ db.save_saju_record() → saju.db INSERT
|
||||
← { saju, analysis, daeun, interpretation, reading_id, cost_usd, latency_ms }
|
||||
[web-ui]
|
||||
```
|
||||
|
||||
### saju-lab 궁합
|
||||
```
|
||||
[web-ui /saju/compatibility/result]
|
||||
↓ POST /api/saju/compat/interpret { person_a: {...}, person_b: {...} }
|
||||
[saju-lab]
|
||||
↓ calculate_saju(person_a) + calculate_saju(person_b)
|
||||
↓ compatibility.calculate_compatibility(saju_a, saju_b) → { score, breakdown }
|
||||
↓ interpret.compat_pipeline.interpret() → Claude API
|
||||
↓ db.save_compat_record()
|
||||
← { saju_a, saju_b, score, breakdown, interpretation, reading_id }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리
|
||||
|
||||
| 시나리오 | 처리 |
|
||||
|---------|------|
|
||||
| Claude API HTTP error | `TarotError` / `SajuError` raise → FastAPI 500 |
|
||||
| Claude JSON 파싱 실패 | `_extract_json` codeblock 스트립 + 첫 `{` / 마지막 `}` 추출. 실패 시 reroll |
|
||||
| validate 실패 (필수 필드 누락) | reroll 1회. 그래도 실패 시 `_Error("검증 실패")` raise → 500 |
|
||||
| 계산 엔진 입력 오류 (잘못된 날짜 등) | Pydantic validation → 422 |
|
||||
| DB 락 | sqlite WAL 모드. 짧은 retry 없이 raise (드물게 발생) |
|
||||
| 마이그레이션 스크립트 중복 실행 | `INSERT OR IGNORE` 패턴 / 멱등 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
### tarot-lab
|
||||
- 기존 21 tests 이관 + import 경로 수정 후 100% 통과
|
||||
|
||||
### saju-lab — 계산 엔진
|
||||
- **Reference output 비교가 핵심**. 30~50개 입력 → JSON 저장 → Python 결과와 deep_equal 비교
|
||||
- 각 모듈 단위 테스트 (constants, solar_terms, lunar, core, shinsal, analysis, daeun, compatibility)
|
||||
- 회귀 방지: 추가 입력 케이스 발견 시 fixtures에 추가
|
||||
|
||||
### saju-lab — Claude 파이프라인
|
||||
- httpx mock (respx 또는 monkeypatch) 사용 (tarot 패턴 그대로)
|
||||
- validate / reroll / JSON 파싱 폴백 / cost 계산 검증
|
||||
|
||||
### saju-lab — 라우터
|
||||
- TestClient 기반 e2e (FastAPI 표준)
|
||||
- DB tmp_path fixture
|
||||
|
||||
### 통합 검증 (Phase 1, Phase 2 끝)
|
||||
- `npm run dev` + http://127.0.0.1:3007/tarot에서 리딩 1회 (Phase 1)
|
||||
- 같은 곳에서 /saju에서 사주 + 궁합 1회씩 (Phase 2, 시안 적용 후)
|
||||
|
||||
---
|
||||
|
||||
## 10. 환경변수 정리
|
||||
|
||||
**tarot-lab 신규 환경변수** (docker-compose env):
|
||||
- `ANTHROPIC_API_KEY` (필수)
|
||||
- `TAROT_MODEL` (기본 `claude-sonnet-4-6`)
|
||||
- `TAROT_COST_INPUT_PER_M` (기본 3.0)
|
||||
- `TAROT_COST_OUTPUT_PER_M` (기본 15.0)
|
||||
- `TAROT_TIMEOUT_SEC` (기본 180)
|
||||
- `TAROT_DATA_PATH` (기본 `/app/data`)
|
||||
|
||||
**saju-lab 신규 환경변수**:
|
||||
- `ANTHROPIC_API_KEY` (필수)
|
||||
- `SAJU_MODEL` (기본 `claude-sonnet-4-6`)
|
||||
- `SAJU_COST_INPUT_PER_M`, `SAJU_COST_OUTPUT_PER_M`
|
||||
- `SAJU_TIMEOUT_SEC`
|
||||
- `SAJU_DATA_PATH`
|
||||
|
||||
---
|
||||
|
||||
## 11. 마이그레이션 위험 + 완화
|
||||
|
||||
| 위험 | 영향 | 완화 |
|
||||
|------|------|------|
|
||||
| TS→Python 포팅 미세 차이 (예: 절기 일자 1일 차이) | 모든 사주 결과 변형 | Reference output 비교 테스트 50건 + sxtwl로 절기 동일 알고리즘 사용 |
|
||||
| tarot.db 마이그레이션 중 데이터 손실 | 사용자 리딩 이력 손실 | 멱등 스크립트 + 검증 후 cutover. agent-office의 원본 데이터는 cutover 후에도 30일 유지 (테이블만 DROP 안 함) |
|
||||
| 두 컨테이너 추가로 NAS 메모리 압박 | 다른 서비스 OOM | python:3.12-slim 기반 ~150MB. 18GB RAM 여유 충분 |
|
||||
| API prefix 변경 missed 위치 (web-ui에서 일부 호출만 변경) | 일부 페이지 404 | grep 검색 (`/api/agent-office/tarot`) 후 일괄 변경 |
|
||||
| nginx restart 누락 | 라우팅 안 됨 | docker compose up -d --build → nginx 컨테이너 재시작 자동 (deployer 패턴) |
|
||||
| saju-web 코드 사라짐 (참조 못 하게 됨) | 검증 어려움 | saju-web 디렉토리는 그대로 유지 (포팅 끝나도 archive로 보존) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 향후 (v2, 본 spec 밖)
|
||||
|
||||
- 토정비결 (12개월 운세) — saju-lab v2에서 추가
|
||||
- 정밀 음력 + 윤달 처리 검증
|
||||
- 자동 마이그레이션 스크립트의 ON DELETE CASCADE 검토 (이력 정합성)
|
||||
- agent-office의 tarot 관련 텔레그램 명령이 있다면 그것도 saju-lab에 추가할지 검토
|
||||
- saju-lab UI 디자인 시안 확정 후 별도 짧은 plan으로 진행
|
||||
|
||||
---
|
||||
|
||||
## 13. 참고 자료
|
||||
|
||||
- saju-web/PROJECT_OVERVIEW.md — 마이그레이션 원본 명세
|
||||
- web-backend/CLAUDE.md — lab 서비스 패턴 참조
|
||||
- agent-office/app/tarot/, agent-office/app/routers/tarot.py — Phase 1 이관 원본
|
||||
- web-backend/insta-lab/, music-lab/, realestate-lab/ — Dockerfile + 디렉토리 구조 참조 패턴
|
||||
- sxtwl (Python 만세력 라이브러리) — solarlunar 대체
|
||||
- docs/superpowers/specs/2026-05-23-tarot-lab-design.md — 본 작업의 직전 spec (tarot-lab 원본 설계)
|
||||
@@ -344,6 +344,36 @@ server {
|
||||
proxy_pass http://$packs_backend$request_uri;
|
||||
}
|
||||
|
||||
# tarot-lab API (agent-office에서 분리)
|
||||
location /api/tarot/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $tarot_backend tarot-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_pass http://$tarot_backend$request_uri;
|
||||
}
|
||||
|
||||
# saju-lab API
|
||||
location /api/saju/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $saju_backend saju-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_pass http://$saju_backend$request_uri;
|
||||
}
|
||||
|
||||
# agent-office API + WebSocket
|
||||
location /api/agent-office/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
5
saju-lab/.dockerignore
Normal file
5
saju-lab/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
data/
|
||||
tests/
|
||||
12
saju-lab/Dockerfile
Normal file
12
saju-lab/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
saju-lab/app/__init__.py
Normal file
0
saju-lab/app/__init__.py
Normal file
0
saju-lab/app/calculator/__init__.py
Normal file
0
saju-lab/app/calculator/__init__.py
Normal file
446
saju-lab/app/calculator/analysis.py
Normal file
446
saju-lab/app/calculator/analysis.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""사주 종합 분석 — 오행/신강신약/용신/세운/perform_full_analysis.
|
||||
|
||||
saju-web/lib/ai-interpretation.ts 와 1:1 매핑.
|
||||
|
||||
핵심 함수:
|
||||
- calculate_detailed_element_balance: 가중치 오행 점수 (천간 1.0, 본기 1.0, 중기 0.5, 여기 0.3)
|
||||
- calculate_element_score: 비율 (%)
|
||||
- analyze_day_master_strength: 신강/신약/중화 + score(정수) + reasons(텍스트 리스트)
|
||||
- estimate_yongshin: 용신/희신/기신 + explanation
|
||||
- calculate_seun: 올해(주어진 year) 세운 + 일주/년월시 충/합 매핑
|
||||
- perform_full_analysis: 위 모두 + branch_interactions + shinsal + gongmang + hidden_stems
|
||||
|
||||
출력 키는 snake_case. pillar 값은 한글(`세운`/`년주`/`월주`/`일주`/`시주`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
FIVE_ELEMENTS,
|
||||
FIVE_ELEMENTS_KR,
|
||||
HIDDEN_STEMS,
|
||||
)
|
||||
from .shinsal import (
|
||||
get_hidden_stems,
|
||||
get_all_hidden_stems,
|
||||
analyze_branch_interactions,
|
||||
calculate_shinsal,
|
||||
calculate_gongmang,
|
||||
)
|
||||
from .core import get_year_ganzi
|
||||
|
||||
|
||||
# ─── 상생/상극 맵 (TS PRODUCE_MAP / OVERCOME_MAP 동일) ─────────────
|
||||
_PRODUCE_MAP: dict[str, str] = {
|
||||
"木": "火", "火": "土", "土": "金", "金": "水", "水": "木",
|
||||
}
|
||||
|
||||
_OVERCOME_MAP: dict[str, str] = {
|
||||
"木": "土", "火": "金", "土": "水", "金": "木", "水": "火",
|
||||
}
|
||||
|
||||
|
||||
def _get_producing_element(elem: str) -> str:
|
||||
"""`elem`을 생하는 오행 (= 인성). TS getProducingElement 동일."""
|
||||
for k, v in _PRODUCE_MAP.items():
|
||||
if v == elem:
|
||||
return k
|
||||
return ""
|
||||
|
||||
|
||||
def _get_overcoming_me(elem: str) -> str:
|
||||
"""`elem`을 극하는 오행 (= 관살). TS getOvercomingMe 동일."""
|
||||
for k, v in _OVERCOME_MAP.items():
|
||||
if v == elem:
|
||||
return k
|
||||
return ""
|
||||
|
||||
|
||||
def _js_round(x: float) -> int:
|
||||
"""JavaScript Math.round 호환 — half-away-from-zero (양수에서는 half-up).
|
||||
|
||||
Python의 round()는 banker's rounding(half-to-even)을 쓰므로 호환을 위해 우회.
|
||||
오행 점수는 항상 ≥ 0 이므로 floor(x + 0.5) 로 충분.
|
||||
"""
|
||||
if x >= 0:
|
||||
return math.floor(x + 0.5)
|
||||
return -math.floor(-x + 0.5)
|
||||
|
||||
|
||||
# ─── 오행 밸런스 ──────────────────────────────────────────────────
|
||||
def calculate_detailed_element_balance(saju: dict) -> dict:
|
||||
"""가중치 적용 오행 점수.
|
||||
|
||||
- 천간 stem: 1.0 per pillar
|
||||
- 지지 hidden_stems: 본기 1.0, 중기 0.5, 여기 0.3
|
||||
- 결과는 소수점 둘째 자리로 반올림 (TS Math.round(x*100)/100).
|
||||
"""
|
||||
balance: dict[str, float] = {"木": 0.0, "火": 0.0, "土": 0.0, "金": 0.0, "水": 0.0}
|
||||
|
||||
# 천간
|
||||
stems = [saju["year"]["stem"], saju["month"]["stem"], saju["day"]["stem"]]
|
||||
if saju.get("hour") is not None:
|
||||
stems.append(saju["hour"]["stem"])
|
||||
|
||||
for stem in stems:
|
||||
elem = FIVE_ELEMENTS.get(stem)
|
||||
if elem in balance:
|
||||
balance[elem] += 1.0
|
||||
|
||||
# 지지 지장간
|
||||
branches = [saju["year"]["branch"], saju["month"]["branch"], saju["day"]["branch"]]
|
||||
if saju.get("hour") is not None:
|
||||
branches.append(saju["hour"]["branch"])
|
||||
|
||||
weights = [1.0, 0.5, 0.3]
|
||||
for branch in branches:
|
||||
hidden = get_hidden_stems(branch)
|
||||
for i, hs in enumerate(hidden):
|
||||
elem = FIVE_ELEMENTS.get(hs)
|
||||
if elem in balance:
|
||||
w = weights[i] if i < len(weights) else 0.3
|
||||
balance[elem] += w
|
||||
|
||||
# 소수점 둘째 자리 반올림 (TS Math.round(x*100)/100)
|
||||
for k in list(balance.keys()):
|
||||
balance[k] = _js_round(balance[k] * 100) / 100
|
||||
|
||||
return balance
|
||||
|
||||
|
||||
def calculate_element_score(saju: dict) -> dict:
|
||||
"""오행 비율 (%). TS: Math.round((value / total) * 100)."""
|
||||
balance = calculate_detailed_element_balance(saju)
|
||||
total = sum(balance.values())
|
||||
|
||||
scores: dict[str, int] = {}
|
||||
for elem, value in balance.items():
|
||||
if total > 0:
|
||||
scores[elem] = _js_round((value / total) * 100)
|
||||
else:
|
||||
scores[elem] = 0
|
||||
return scores
|
||||
|
||||
|
||||
# ─── 신강/신약 분석 ───────────────────────────────────────────────
|
||||
def analyze_day_master_strength(saju: dict) -> dict:
|
||||
"""신강/신약/중화 판단 — TS analyzeDayMasterStrength 와 1:1 동일.
|
||||
|
||||
score 가산/감산 규칙:
|
||||
1. 월령 득령
|
||||
- 월지 본기 == 일간 오행: +3 ("월령 득령: 월지 X(Y)의 본기가 일간과 같은 ...")
|
||||
- 월지 본기 == 인성: +2 ("월령 득령: 월지 X(Y)의 본기가 일간을 생하는 ...")
|
||||
- 기타: -2 ("월령 실령: 월지 X(Y)의 본기가 일간을 돕지 않음")
|
||||
2. 통근 (지지 지장간 중 일간/인성 오행 포함된 지지 개수)
|
||||
- >=3: +2 / >=2: +1 / 그 외: -1
|
||||
3. 투출 (년주/월주/시주 천간 중 일간/인성 오행 개수, 일주 제외)
|
||||
- >=2: +2 / ==1: +1 / 0: -1
|
||||
4. 오행 비율 (조력 > 약화 * 1.3 또는 반대)
|
||||
- +1 / -1
|
||||
|
||||
결과: score >= 3 → 신강, score <= -2 → 신약, else 중화.
|
||||
"""
|
||||
day_stem = saju["day_stem"]
|
||||
day_element = FIVE_ELEMENTS[day_stem]
|
||||
producing_element = _get_producing_element(day_element)
|
||||
reasons: list[str] = []
|
||||
score = 0
|
||||
|
||||
# 1. 월령 득령
|
||||
month_branch = saju["month"]["branch"]
|
||||
month_branch_kr = saju["month"]["branch_kr"]
|
||||
month_hidden = get_hidden_stems(month_branch)
|
||||
month_main_element = FIVE_ELEMENTS[month_hidden[0]]
|
||||
|
||||
if month_main_element == day_element:
|
||||
score += 3
|
||||
reasons.append(
|
||||
f"월령 득령: 월지 {month_branch_kr}({month_branch})의 본기가 일간과 같은 "
|
||||
f"{FIVE_ELEMENTS_KR[day_element]}으로 강한 힘을 받음"
|
||||
)
|
||||
elif month_main_element == producing_element:
|
||||
score += 2
|
||||
reasons.append(
|
||||
f"월령 득령: 월지 {month_branch_kr}({month_branch})의 본기가 일간을 생하는 "
|
||||
f"{FIVE_ELEMENTS_KR[producing_element]}으로 힘을 받음"
|
||||
)
|
||||
else:
|
||||
score -= 2
|
||||
reasons.append(
|
||||
f"월령 실령: 월지 {month_branch_kr}({month_branch})의 본기가 일간을 돕지 않음"
|
||||
)
|
||||
|
||||
# 2. 통근 (4주 지지 모두 검사)
|
||||
all_branches = [saju["year"]["branch"], saju["month"]["branch"], saju["day"]["branch"]]
|
||||
if saju.get("hour") is not None:
|
||||
all_branches.append(saju["hour"]["branch"])
|
||||
|
||||
root_count = 0
|
||||
for branch in all_branches:
|
||||
hidden = get_hidden_stems(branch)
|
||||
for h in hidden:
|
||||
h_elem = FIVE_ELEMENTS.get(h)
|
||||
if h_elem == day_element or h_elem == producing_element:
|
||||
root_count += 1
|
||||
break
|
||||
|
||||
if root_count >= 3:
|
||||
score += 2
|
||||
reasons.append(f"통근 강함: {root_count}개 지지에서 일간의 뿌리를 찾음")
|
||||
elif root_count >= 2:
|
||||
score += 1
|
||||
reasons.append(f"통근 보통: {root_count}개 지지에서 일간의 뿌리를 찾음")
|
||||
else:
|
||||
score -= 1
|
||||
reasons.append(f"통근 약함: {root_count}개 지지에서만 일간의 뿌리를 찾음")
|
||||
|
||||
# 3. 투출 (년주/월주 천간 + 시주 천간, 일주 제외)
|
||||
all_stems = [saju["year"]["stem"], saju["month"]["stem"]]
|
||||
if saju.get("hour") is not None:
|
||||
all_stems.append(saju["hour"]["stem"])
|
||||
|
||||
helping_stem_count = 0
|
||||
for stem in all_stems:
|
||||
stem_elem = FIVE_ELEMENTS.get(stem)
|
||||
if stem_elem == day_element or stem_elem == producing_element:
|
||||
helping_stem_count += 1
|
||||
|
||||
if helping_stem_count >= 2:
|
||||
score += 2
|
||||
reasons.append(
|
||||
f"투출 강함: 천간에 비겁/인성이 {helping_stem_count}개 있어 일간을 도움"
|
||||
)
|
||||
elif helping_stem_count == 1:
|
||||
score += 1
|
||||
reasons.append("투출 보통: 천간에 비겁/인성이 1개 있음")
|
||||
else:
|
||||
score -= 1
|
||||
reasons.append("투출 없음: 천간에 일간을 돕는 비겁/인성이 없음")
|
||||
|
||||
# 4. 오행 비율 (조력 vs 약화)
|
||||
balance = calculate_detailed_element_balance(saju)
|
||||
helping_score = balance.get(day_element, 0.0) + balance.get(producing_element, 0.0)
|
||||
draining_score = sum(
|
||||
v for k, v in balance.items()
|
||||
if k != day_element and k != producing_element
|
||||
)
|
||||
|
||||
if helping_score > draining_score * 1.3:
|
||||
score += 1
|
||||
reasons.append(
|
||||
f"오행 비율: 비겁+인성({helping_score:.1f}) > 식상+재관({draining_score:.1f}) "
|
||||
f"→ 일간 세력 우세"
|
||||
)
|
||||
elif draining_score > helping_score * 1.3:
|
||||
score -= 1
|
||||
reasons.append(
|
||||
f"오행 비율: 식상+재관({draining_score:.1f}) > 비겁+인성({helping_score:.1f}) "
|
||||
f"→ 일간 세력 열세"
|
||||
)
|
||||
|
||||
if score >= 3:
|
||||
result = "신강"
|
||||
elif score <= -2:
|
||||
result = "신약"
|
||||
else:
|
||||
result = "중화"
|
||||
|
||||
return {"result": result, "score": score, "reasons": reasons}
|
||||
|
||||
|
||||
# ─── 용신 추정 ────────────────────────────────────────────────────
|
||||
def estimate_yongshin(saju: dict, strength: dict) -> dict:
|
||||
"""용신/희신/기신 추정 — TS estimateYongShin 와 1:1 동일.
|
||||
|
||||
신강:
|
||||
용신/희신 = 식상/재성/관살 중 balance 가장 낮은 2개 (점수 오름차순)
|
||||
기신 = 일간 오행 (비겁)
|
||||
신약:
|
||||
용신/희신 = 인성/비겁 중 balance 낮은 2개
|
||||
기신 = 관살
|
||||
중화:
|
||||
용신/희신 = 모든 오행 중 balance 낮은 2개
|
||||
기신 = balance 가장 높은 오행
|
||||
"""
|
||||
day_element = FIVE_ELEMENTS[saju["day_stem"]]
|
||||
balance = calculate_detailed_element_balance(saju)
|
||||
|
||||
producing_me = _get_producing_element(day_element) # 인성
|
||||
my_product = _PRODUCE_MAP[day_element] # 식상
|
||||
my_overcome = _OVERCOME_MAP[day_element] # 재성
|
||||
overcome_me = _get_overcoming_me(day_element) # 관살
|
||||
|
||||
def kr(e: str) -> str:
|
||||
return FIVE_ELEMENTS_KR.get(e, e)
|
||||
|
||||
result = strength["result"]
|
||||
|
||||
if result == "신강":
|
||||
candidates = [
|
||||
{"elem": my_product, "score": balance.get(my_product, 0.0), "name": "식상"},
|
||||
{"elem": my_overcome, "score": balance.get(my_overcome, 0.0), "name": "재성"},
|
||||
{"elem": overcome_me, "score": balance.get(overcome_me, 0.0), "name": "관살"},
|
||||
]
|
||||
candidates.sort(key=lambda x: x["score"])
|
||||
yong = candidates[0]
|
||||
hee = candidates[1]
|
||||
|
||||
return {
|
||||
"yong_shin": yong["elem"], "yong_shin_kr": kr(yong["elem"]),
|
||||
"hee_shin": hee["elem"], "hee_shin_kr": kr(hee["elem"]),
|
||||
"gi_shin": day_element, "gi_shin_kr": kr(day_element),
|
||||
"explanation": (
|
||||
f"신강한 사주로 일간의 힘이 넘치므로 {yong['name']}({kr(yong['elem'])}) "
|
||||
f"기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. "
|
||||
f"{hee['name']}({kr(hee['elem'])})이 희신으로 보조합니다."
|
||||
),
|
||||
}
|
||||
elif result == "신약":
|
||||
candidates = [
|
||||
{"elem": producing_me, "score": balance.get(producing_me, 0.0), "name": "인성"},
|
||||
{"elem": day_element, "score": balance.get(day_element, 0.0), "name": "비겁"},
|
||||
]
|
||||
candidates.sort(key=lambda x: x["score"])
|
||||
yong = candidates[0]
|
||||
hee = candidates[1]
|
||||
|
||||
return {
|
||||
"yong_shin": yong["elem"], "yong_shin_kr": kr(yong["elem"]),
|
||||
"hee_shin": hee["elem"], "hee_shin_kr": kr(hee["elem"]),
|
||||
"gi_shin": overcome_me, "gi_shin_kr": kr(overcome_me),
|
||||
"explanation": (
|
||||
f"신약한 사주로 일간의 힘이 부족하므로 {yong['name']}({kr(yong['elem'])}) "
|
||||
f"기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. "
|
||||
f"{hee['name']}({kr(hee['elem'])})이 희신으로 보조합니다."
|
||||
),
|
||||
}
|
||||
else:
|
||||
# 중화 — Object.entries 순서 (TS): 木, 火, 土, 金, 水
|
||||
entries = [(e, balance[e]) for e in ["木", "火", "土", "金", "水"]]
|
||||
# Array.prototype.sort 는 안정 정렬(Node v12+). Python sort 도 안정 정렬이므로 동일.
|
||||
entries.sort(key=lambda x: x[1])
|
||||
yong = entries[0]
|
||||
hee = entries[1]
|
||||
gi = entries[-1]
|
||||
|
||||
return {
|
||||
"yong_shin": yong[0], "yong_shin_kr": kr(yong[0]),
|
||||
"hee_shin": hee[0], "hee_shin_kr": kr(hee[0]),
|
||||
"gi_shin": gi[0], "gi_shin_kr": kr(gi[0]),
|
||||
"explanation": (
|
||||
f"중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. "
|
||||
f"가장 부족한 {kr(yong[0])}({yong[0]}) 기운을 보충하면 더욱 좋아집니다."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── 세운 ─────────────────────────────────────────────────────────
|
||||
_SEUN_CHUNG_PAIRS: list[tuple[str, str]] = [
|
||||
("子", "午"), ("丑", "未"), ("寅", "申"),
|
||||
("卯", "酉"), ("辰", "戌"), ("巳", "亥"),
|
||||
]
|
||||
|
||||
_SEUN_YUKAP_PAIRS: list[tuple[str, str, str]] = [
|
||||
("子", "丑", "土"), ("寅", "亥", "木"), ("卯", "戌", "火"),
|
||||
("辰", "酉", "金"), ("巳", "申", "水"), ("午", "未", "火"),
|
||||
]
|
||||
|
||||
|
||||
def calculate_seun(year: int, saju: dict) -> dict:
|
||||
"""올해(주어진 year) 세운 정보 + 일주/년월시 지지와의 충/합 매핑.
|
||||
|
||||
TS calculateSeun 와 1:1 동일. interactions 는 충(沖) 먼저 -> 합(合) 순서.
|
||||
"""
|
||||
ganzi = get_year_ganzi(year)
|
||||
element = FIVE_ELEMENTS[ganzi["stem"]]
|
||||
element_kr = FIVE_ELEMENTS_KR[element]
|
||||
|
||||
seun_branch = ganzi["branch"]
|
||||
seun_branch_kr = ganzi["branch_kr"]
|
||||
|
||||
pillar_branches: list[dict] = [
|
||||
{"branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"], "pillar": "년주"},
|
||||
{"branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"], "pillar": "월주"},
|
||||
{"branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"], "pillar": "일주"},
|
||||
]
|
||||
if saju.get("hour") is not None:
|
||||
pillar_branches.append({
|
||||
"branch": saju["hour"]["branch"],
|
||||
"branch_kr": saju["hour"]["branch_kr"],
|
||||
"pillar": "시주",
|
||||
})
|
||||
|
||||
interactions: list[dict] = []
|
||||
|
||||
# 충 (沖)
|
||||
for a, b in _SEUN_CHUNG_PAIRS:
|
||||
for pb in pillar_branches:
|
||||
if (seun_branch == a and pb["branch"] == b) or (seun_branch == b and pb["branch"] == a):
|
||||
interactions.append({
|
||||
"type": "충(沖)",
|
||||
"branches": [seun_branch, pb["branch"]],
|
||||
"branches_kr": [seun_branch_kr, pb["branch_kr"]],
|
||||
"pillars": ["세운", pb["pillar"]],
|
||||
"description": (
|
||||
f"세운 {seun_branch_kr}와 {pb['pillar']} {pb['branch_kr']}가 충 "
|
||||
f"→ 해당 영역에 변동과 변화가 예상됨."
|
||||
),
|
||||
})
|
||||
|
||||
# 합 (六合)
|
||||
for a, b, elem in _SEUN_YUKAP_PAIRS:
|
||||
for pb in pillar_branches:
|
||||
if (seun_branch == a and pb["branch"] == b) or (seun_branch == b and pb["branch"] == a):
|
||||
interactions.append({
|
||||
"type": "합(合)",
|
||||
"branches": [seun_branch, pb["branch"]],
|
||||
"branches_kr": [seun_branch_kr, pb["branch_kr"]],
|
||||
"pillars": ["세운", pb["pillar"]],
|
||||
"description": (
|
||||
f"세운 {seun_branch_kr}와 {pb['pillar']} {pb['branch_kr']}가 합 "
|
||||
f"→ 해당 영역에 조화와 좋은 인연이 기대됨."
|
||||
),
|
||||
"result_element": elem,
|
||||
})
|
||||
|
||||
return {
|
||||
"stem": ganzi["stem"],
|
||||
"branch": ganzi["branch"],
|
||||
"stem_kr": ganzi["stem_kr"],
|
||||
"branch_kr": ganzi["branch_kr"],
|
||||
"element": element,
|
||||
"element_kr": element_kr,
|
||||
"year": year,
|
||||
"interactions": interactions,
|
||||
}
|
||||
|
||||
|
||||
# ─── 통합 분석 ────────────────────────────────────────────────────
|
||||
def perform_full_analysis(saju: dict, current_year: int) -> dict:
|
||||
"""모든 분석 통합 — TS performFullAnalysis 와 1:1 동일.
|
||||
|
||||
반환 키는 snake_case.
|
||||
"""
|
||||
element_balance = calculate_detailed_element_balance(saju)
|
||||
element_scores = calculate_element_score(saju)
|
||||
day_master_strength = analyze_day_master_strength(saju)
|
||||
yong_shin = estimate_yongshin(saju, day_master_strength)
|
||||
branch_interactions = analyze_branch_interactions(saju)
|
||||
shinsal = calculate_shinsal(saju)
|
||||
gongmang = calculate_gongmang(saju["day_stem"], saju["day"]["branch"])
|
||||
seun = calculate_seun(current_year, saju)
|
||||
hidden_stems = get_all_hidden_stems(saju)
|
||||
|
||||
return {
|
||||
"element_balance": element_balance,
|
||||
"element_scores": element_scores,
|
||||
"day_master_strength": day_master_strength,
|
||||
"yong_shin": yong_shin,
|
||||
"branch_interactions": branch_interactions,
|
||||
"shinsal": shinsal,
|
||||
"gongmang": gongmang,
|
||||
"seun": seun,
|
||||
"hidden_stems": hidden_stems,
|
||||
}
|
||||
170
saju-lab/app/calculator/compatibility.py
Normal file
170
saju-lab/app/calculator/compatibility.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""사주 궁합 — 두 사주 일주(일간 오행 + 일지) 매칭 → 점수 + breakdown.
|
||||
|
||||
saju-web/app/compatibility/result/page.tsx의 `calculateCompatibility()` 로직과 1:1 매핑.
|
||||
|
||||
알고리즘 (saju-web TS):
|
||||
score = 50 (base)
|
||||
# 일간 오행 관계
|
||||
if element1 == element2: score += 10 # same
|
||||
elif relation in (produce, produced): score += 25 # 상생
|
||||
else: score -= 10 # 상극 (overcome/overcomed)
|
||||
# 일지 관계
|
||||
if 六合 (sixHarmony): score += 20
|
||||
if 三合 (threeHarmony): score += 15
|
||||
if 沖 (conflict): score -= 20
|
||||
score = clamp(score, 0, 100)
|
||||
|
||||
breakdown은 saju-lab 자체 확장 — 각 카테고리별 (score_delta, description) 제공.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .constants import FIVE_ELEMENTS
|
||||
|
||||
|
||||
# ─── 오행 상생/상극 맵 (saju-web과 동일) ──────────────────────────────
|
||||
_PRODUCE_MAP = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
_OVERCOME_MAP = {"木": "土", "火": "金", "土": "水", "金": "木", "水": "火"}
|
||||
|
||||
|
||||
# ─── 지지 6합/6충 (saju-web과 동일) ───────────────────────────────────
|
||||
_SIX_HARMONY: dict[str, str] = {
|
||||
"子": "丑", "丑": "子",
|
||||
"寅": "亥", "亥": "寅",
|
||||
"卯": "戌", "戌": "卯",
|
||||
"辰": "酉", "酉": "辰",
|
||||
"巳": "申", "申": "巳",
|
||||
"午": "未", "未": "午",
|
||||
}
|
||||
|
||||
# 삼합 그룹 (saju-web threeHarmonyGroups)
|
||||
_THREE_HARMONY_GROUPS = [
|
||||
{"申", "子", "辰"},
|
||||
{"寅", "午", "戌"},
|
||||
{"亥", "卯", "未"},
|
||||
{"巳", "酉", "丑"},
|
||||
]
|
||||
|
||||
_CONFLICT: dict[str, str] = {
|
||||
"子": "午", "午": "子",
|
||||
"丑": "未", "未": "丑",
|
||||
"寅": "申", "申": "寅",
|
||||
"卯": "酉", "酉": "卯",
|
||||
"辰": "戌", "戌": "辰",
|
||||
"巳": "亥", "亥": "巳",
|
||||
}
|
||||
|
||||
|
||||
# ─── 헬퍼 ─────────────────────────────────────────────────────────
|
||||
def _get_element_relation(el1: str, el2: str) -> str:
|
||||
"""오행 관계 판별 — 'same' | 'produce' | 'produced' | 'overcome' | 'overcomed'.
|
||||
|
||||
saju-web getElementRelation()과 1:1 매핑.
|
||||
"""
|
||||
if el1 == el2:
|
||||
return "same"
|
||||
if _PRODUCE_MAP.get(el1) == el2:
|
||||
return "produce" # el1 → el2 (el1이 el2를 생함)
|
||||
if _PRODUCE_MAP.get(el2) == el1:
|
||||
return "produced" # el2 → el1 (el1이 el2로부터 생을 받음)
|
||||
if _OVERCOME_MAP.get(el1) == el2:
|
||||
return "overcome" # el1 → el2 (el1이 el2를 극함)
|
||||
return "overcomed" # el2 → el1 (el1이 el2로부터 극을 받음)
|
||||
|
||||
|
||||
def _get_branch_relation(b1: str, b2: str) -> dict:
|
||||
"""지지 관계 — {six_harmony, three_harmony, conflict}.
|
||||
|
||||
saju-web getBranchRelation()과 1:1 매핑.
|
||||
"""
|
||||
is_three = any(b1 in g and b2 in g and b1 != b2 for g in _THREE_HARMONY_GROUPS)
|
||||
return {
|
||||
"six_harmony": _SIX_HARMONY.get(b1) == b2,
|
||||
"three_harmony": is_three,
|
||||
"conflict": _CONFLICT.get(b1) == b2,
|
||||
}
|
||||
|
||||
|
||||
def _day_master_element_delta(el1: str, el2: str) -> tuple[int, str, str]:
|
||||
"""일간 오행 매칭 delta + relation tag + description."""
|
||||
relation = _get_element_relation(el1, el2)
|
||||
if relation == "same":
|
||||
return 10, "same", f"일간 동일 오행 ({el1}={el2}) — 안정적이나 자극은 약함"
|
||||
if relation in ("produce", "produced"):
|
||||
return 25, relation, f"일간 상생 관계 ({el1}↔{el2}) — 서로 도움"
|
||||
# overcome / overcomed
|
||||
return -10, relation, f"일간 상극 관계 ({el1}↔{el2}) — 갈등 가능"
|
||||
|
||||
|
||||
def _branch_interaction_delta(b1: str, b2: str) -> tuple[int, dict, str]:
|
||||
"""일지 6합/3합/충 delta + flags + description."""
|
||||
rel = _get_branch_relation(b1, b2)
|
||||
delta = 0
|
||||
parts: list[str] = []
|
||||
if rel["six_harmony"]:
|
||||
delta += 20
|
||||
parts.append(f"일지 6합 ({b1}+{b2})")
|
||||
if rel["three_harmony"]:
|
||||
delta += 15
|
||||
parts.append(f"일지 3합 ({b1}+{b2})")
|
||||
if rel["conflict"]:
|
||||
delta -= 20
|
||||
parts.append(f"일지 충 ({b1}↔{b2})")
|
||||
if not parts:
|
||||
parts.append(f"일지 중립 ({b1}, {b2})")
|
||||
return delta, rel, " · ".join(parts)
|
||||
|
||||
|
||||
# ─── 메인 ─────────────────────────────────────────────────────────
|
||||
def calculate_compatibility(saju_a: Dict[str, Any], saju_b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""두 사주 궁합 점수 (0~100) + breakdown.
|
||||
|
||||
Args:
|
||||
saju_a: calculate_saju() 결과 — `saju["day"]["stem"|"branch"|"element"]` 사용
|
||||
saju_b: 두 번째 사주
|
||||
|
||||
Returns:
|
||||
{
|
||||
"score": int (0~100),
|
||||
"breakdown": {
|
||||
"day_master_element": {"score": int, "relation": str, "description": str},
|
||||
"branch_interaction": {"score": int, "flags": dict, "description": str},
|
||||
"base": 50,
|
||||
}
|
||||
}
|
||||
"""
|
||||
day_a = saju_a["day"]
|
||||
day_b = saju_b["day"]
|
||||
|
||||
el1 = day_a.get("element") or FIVE_ELEMENTS[day_a["stem"]]
|
||||
el2 = day_b.get("element") or FIVE_ELEMENTS[day_b["stem"]]
|
||||
b1 = day_a["branch"]
|
||||
b2 = day_b["branch"]
|
||||
|
||||
elem_delta, elem_relation, elem_desc = _day_master_element_delta(el1, el2)
|
||||
branch_delta, branch_flags, branch_desc = _branch_interaction_delta(b1, b2)
|
||||
|
||||
raw = 50 + elem_delta + branch_delta
|
||||
score = max(0, min(100, raw))
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"breakdown": {
|
||||
"base": 50,
|
||||
"day_master_element": {
|
||||
"score": elem_delta,
|
||||
"relation": elem_relation,
|
||||
"element_a": el1,
|
||||
"element_b": el2,
|
||||
"description": elem_desc,
|
||||
},
|
||||
"branch_interaction": {
|
||||
"score": branch_delta,
|
||||
"flags": branch_flags,
|
||||
"branch_a": b1,
|
||||
"branch_b": b2,
|
||||
"description": branch_desc,
|
||||
},
|
||||
},
|
||||
}
|
||||
66
saju-lab/app/calculator/constants.py
Normal file
66
saju-lab/app/calculator/constants.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""사주 계산 상수 — saju-web/lib/saju-calculator.ts 의 상수와 1:1 매핑."""
|
||||
|
||||
HEAVENLY_STEMS = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]
|
||||
HEAVENLY_STEMS_KR = ["갑", "을", "병", "정", "무", "기", "경", "신", "임", "계"]
|
||||
|
||||
EARTHLY_BRANCHES = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
|
||||
EARTHLY_BRANCHES_KR = ["자", "축", "인", "묘", "진", "사", "오", "미", "신", "유", "술", "해"]
|
||||
|
||||
FIVE_ELEMENTS = {
|
||||
"甲": "木", "乙": "木",
|
||||
"丙": "火", "丁": "火",
|
||||
"戊": "土", "己": "土",
|
||||
"庚": "金", "辛": "金",
|
||||
"壬": "水", "癸": "水",
|
||||
"寅": "木", "卯": "木",
|
||||
"巳": "火", "午": "火",
|
||||
"辰": "土", "戌": "土", "丑": "土", "未": "土",
|
||||
"申": "金", "酉": "金",
|
||||
"子": "水", "亥": "水",
|
||||
}
|
||||
|
||||
FIVE_ELEMENTS_KR = {
|
||||
"木": "목", "火": "화", "土": "토", "金": "금", "水": "수",
|
||||
}
|
||||
|
||||
IS_YANG_STEM = {
|
||||
"甲": True, "乙": False,
|
||||
"丙": True, "丁": False,
|
||||
"戊": True, "己": False,
|
||||
"庚": True, "辛": False,
|
||||
"壬": True, "癸": False,
|
||||
}
|
||||
|
||||
IS_YANG_BRANCH = {
|
||||
"子": True, "丑": False,
|
||||
"寅": True, "卯": False,
|
||||
"辰": True, "巳": False,
|
||||
"午": True, "未": False,
|
||||
"申": True, "酉": False,
|
||||
"戌": True, "亥": False,
|
||||
}
|
||||
|
||||
# 지장간: { 지지: [본기, 중기, 여기] (없으면 생략) }
|
||||
HIDDEN_STEMS = {
|
||||
"子": ["癸"],
|
||||
"丑": ["己", "癸", "辛"],
|
||||
"寅": ["甲", "丙", "戊"],
|
||||
"卯": ["乙"],
|
||||
"辰": ["戊", "乙", "癸"],
|
||||
"巳": ["丙", "庚", "戊"],
|
||||
"午": ["丁", "己"],
|
||||
"未": ["己", "丁", "乙"],
|
||||
"申": ["庚", "壬", "戊"],
|
||||
"酉": ["辛"],
|
||||
"戌": ["戊", "辛", "丁"],
|
||||
"亥": ["壬", "甲"],
|
||||
}
|
||||
|
||||
# 본기, 중기, 여기 가중치
|
||||
HIDDEN_STEM_WEIGHTS = [1.0, 0.5, 0.3]
|
||||
|
||||
# 상생 사이클 (목→화→토→금→수→목)
|
||||
SHENG_CYCLE = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
|
||||
# 상극 사이클 (목→토→수→화→금→목)
|
||||
KE_CYCLE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
||||
261
saju-lab/app/calculator/core.py
Normal file
261
saju-lab/app/calculator/core.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""사주 핵심 계산 — 60갑자 + 십성 + 십이운성 + calculate_saju.
|
||||
|
||||
saju-web/lib/saju-calculator.ts 의 알고리즘과 1:1 매핑.
|
||||
|
||||
핵심 결정 사항:
|
||||
- 년주: 1900-01-01 = 庚子년(stem=6, branch=0) 기준 단순 차분 (TS 동일)
|
||||
- 월주: 月支 = 절기 기반 (sxtwl), 月干 = (yearStemIndex * 2 + branchIndex) % 10 (TS 동일)
|
||||
- 일주: 1900-01-01 = 丙寅일(stem=2, branch=2) 기준 그레고리안 일수 차분 (TS 동일, sxtwl 미사용)
|
||||
- 시주: branchIndex = hour-to-branch 매핑, stemIndex = (dayStemIndex * 2 + branchIndex) % 10
|
||||
- 십성: 일간/대상 오행 관계 + isYang 플래그 (호출자가 음양 동일 여부 결정)
|
||||
- 십이운성: 일간×지지 12 갑자 하드코딩 매핑 (건록, '임관' 아님)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
HEAVENLY_STEMS,
|
||||
HEAVENLY_STEMS_KR,
|
||||
EARTHLY_BRANCHES,
|
||||
EARTHLY_BRANCHES_KR,
|
||||
FIVE_ELEMENTS,
|
||||
)
|
||||
from .solar_terms import get_solar_term_month_branch
|
||||
|
||||
|
||||
# ─── 기준일 (TS saju-calculator.ts와 동일) ────────────────────────────
|
||||
BASE_YEAR = 1900
|
||||
BASE_YEAR_STEM = 6 # 庚
|
||||
BASE_YEAR_BRANCH = 0 # 子
|
||||
BASE_DAY_STEM = 2 # 丙
|
||||
BASE_DAY_BRANCH = 2 # 寅
|
||||
_BASE_DATE = _date(1900, 1, 1)
|
||||
|
||||
|
||||
# ─── 십이운성 ─────────────────────────────────────────────────────
|
||||
TWELVE_FORTUNES = [
|
||||
"장생", "목욕", "관대", "건록", "제왕", "쇠",
|
||||
"병", "사", "묘", "절", "태", "양",
|
||||
]
|
||||
|
||||
|
||||
# 일간 × 지지 → TWELVE_FORTUNES 인덱스
|
||||
# saju-web TS getTwelveFortune() 함수의 hardcoded map과 1:1 일치
|
||||
_FORTUNE_MAP: dict[str, dict[str, int]] = {
|
||||
"甲": {"亥": 11, "子": 0, "丑": 1, "寅": 2, "卯": 3, "辰": 4, "巳": 5, "午": 6, "未": 7, "申": 8, "酉": 9, "戌": 10},
|
||||
"乙": {"午": 11, "未": 0, "申": 1, "酉": 2, "戌": 3, "亥": 4, "子": 5, "丑": 6, "寅": 7, "卯": 8, "辰": 9, "巳": 10},
|
||||
"丙": {"寅": 11, "卯": 0, "辰": 1, "巳": 2, "午": 3, "未": 4, "申": 5, "酉": 6, "戌": 7, "亥": 8, "子": 9, "丑": 10},
|
||||
"丁": {"酉": 11, "戌": 0, "亥": 1, "子": 2, "丑": 3, "寅": 4, "卯": 5, "辰": 6, "巳": 7, "午": 8, "未": 9, "申": 10},
|
||||
"戊": {"寅": 11, "卯": 0, "辰": 1, "巳": 2, "午": 3, "未": 4, "申": 5, "酉": 6, "戌": 7, "亥": 8, "子": 9, "丑": 10},
|
||||
"己": {"酉": 11, "戌": 0, "亥": 1, "子": 2, "丑": 3, "寅": 4, "卯": 5, "辰": 6, "巳": 7, "午": 8, "未": 9, "申": 10},
|
||||
"庚": {"巳": 11, "午": 0, "未": 1, "申": 2, "酉": 3, "戌": 4, "亥": 5, "子": 6, "丑": 7, "寅": 8, "卯": 9, "辰": 10},
|
||||
"辛": {"子": 11, "丑": 0, "寅": 1, "卯": 2, "辰": 3, "巳": 4, "午": 5, "未": 6, "申": 7, "酉": 8, "戌": 9, "亥": 10},
|
||||
"壬": {"申": 11, "酉": 0, "戌": 1, "亥": 2, "子": 3, "丑": 4, "寅": 5, "卯": 6, "辰": 7, "巳": 8, "午": 9, "未": 10},
|
||||
"癸": {"卯": 11, "辰": 0, "巳": 1, "午": 2, "未": 3, "申": 4, "酉": 5, "戌": 6, "亥": 7, "子": 8, "丑": 9, "寅": 10},
|
||||
}
|
||||
|
||||
|
||||
# ─── 오행 상생/상극 (TS getTenGod 내부 매핑과 동일) ───────────────────
|
||||
_PRODUCE_MAP = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
_OVERCOME_MAP = {"木": "土", "火": "金", "土": "水", "金": "木", "水": "火"}
|
||||
|
||||
|
||||
# ─── 헬퍼 ─────────────────────────────────────────────────────────
|
||||
def _make_pillar(stem_idx: int, branch_idx: int) -> dict:
|
||||
"""천간/지지 인덱스 → pillar 기본 필드 (element 포함)."""
|
||||
stem = HEAVENLY_STEMS[stem_idx]
|
||||
branch = EARTHLY_BRANCHES[branch_idx]
|
||||
return {
|
||||
"stem": stem,
|
||||
"branch": branch,
|
||||
"stem_kr": HEAVENLY_STEMS_KR[stem_idx],
|
||||
"branch_kr": EARTHLY_BRANCHES_KR[branch_idx],
|
||||
"element": FIVE_ELEMENTS[stem],
|
||||
}
|
||||
|
||||
|
||||
# ─── 60갑자 ───────────────────────────────────────────────────────
|
||||
def get_year_ganzi(year: int) -> dict:
|
||||
"""년주 — 1900년 庚子 기준 단순 차분 (TS getYearGanzi 동일)."""
|
||||
year_diff = year - BASE_YEAR
|
||||
stem_idx = (BASE_YEAR_STEM + year_diff) % 10
|
||||
branch_idx = (BASE_YEAR_BRANCH + year_diff) % 12
|
||||
# 음수 보정
|
||||
if stem_idx < 0:
|
||||
stem_idx += 10
|
||||
if branch_idx < 0:
|
||||
branch_idx += 12
|
||||
return _make_pillar(stem_idx, branch_idx)
|
||||
|
||||
|
||||
def get_month_ganzi(year: int, month: int, day: int) -> dict:
|
||||
"""월주 — 月支 = 절기 기반, 月干 = (yearStemIdx*2 + branchIdx) % 10.
|
||||
|
||||
TS getMonthGanzi와 동일.
|
||||
"""
|
||||
branch_idx = get_solar_term_month_branch(year, month, day)
|
||||
year_stem = get_year_ganzi(year)["stem"]
|
||||
year_stem_idx = HEAVENLY_STEMS.index(year_stem)
|
||||
stem_idx = (year_stem_idx * 2 + branch_idx) % 10
|
||||
return _make_pillar(stem_idx, branch_idx)
|
||||
|
||||
|
||||
def get_day_ganzi(year: int, month: int, day: int) -> dict:
|
||||
"""일주 — 1900-01-01(丙寅) 기준 그레고리안 일수 차분 (TS getDayGanzi 동일).
|
||||
|
||||
sxtwl을 사용하지 않음 — TS와 1:1 일치를 위해 단순 일수 차분.
|
||||
|
||||
⚠️ TS는 JavaScript `new Date(1900, 0, 1).getTime()`을 사용하는데, Asia/Seoul
|
||||
timezone에서 1900-01-01 자정은 LMT(+8:27:52) 보정 때문에 UTC 기준으로
|
||||
1899-12-31T15:32:08Z에 해당. 반면 후대 날짜는 KST(+9:00) 기준이라
|
||||
Math.floor((target - base) / DAY)는 정확한 일수보다 1 작게 계산됨
|
||||
(모든 케이스가 균일하게 -1). 본 함수는 그 결과를 재현하기 위해 days_diff -= 1.
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
days_diff = (target - _BASE_DATE).days - 1 # TS Asia/Seoul LMT artifact 보정
|
||||
stem_idx = (BASE_DAY_STEM + days_diff) % 10
|
||||
branch_idx = (BASE_DAY_BRANCH + days_diff) % 12
|
||||
if stem_idx < 0:
|
||||
stem_idx += 10
|
||||
if branch_idx < 0:
|
||||
branch_idx += 12
|
||||
return _make_pillar(stem_idx, branch_idx)
|
||||
|
||||
|
||||
def _hour_to_branch_idx(hour: int) -> int:
|
||||
"""시간 → 시지 인덱스 (TS getHourGanzi 내부 분기 동일).
|
||||
|
||||
23~01 = 子(0), 01~03 = 丑(1), 03~05 = 寅(2), ..., 21~23 = 亥(11)
|
||||
"""
|
||||
if hour >= 23 or hour < 1:
|
||||
return 0
|
||||
if 1 <= hour < 3:
|
||||
return 1
|
||||
if 3 <= hour < 5:
|
||||
return 2
|
||||
if 5 <= hour < 7:
|
||||
return 3
|
||||
if 7 <= hour < 9:
|
||||
return 4
|
||||
if 9 <= hour < 11:
|
||||
return 5
|
||||
if 11 <= hour < 13:
|
||||
return 6
|
||||
if 13 <= hour < 15:
|
||||
return 7
|
||||
if 15 <= hour < 17:
|
||||
return 8
|
||||
if 17 <= hour < 19:
|
||||
return 9
|
||||
if 19 <= hour < 21:
|
||||
return 10
|
||||
return 11 # 21~23
|
||||
|
||||
|
||||
def get_hour_ganzi(day_stem: str, hour: int) -> dict:
|
||||
"""시주 — branchIdx = hour-to-branch, stemIdx = (dayStemIdx*2 + branchIdx) % 10.
|
||||
|
||||
TS getHourGanzi와 동일 (五鼠遁訣 정통 공식이 아닌 단순 *2 패턴).
|
||||
"""
|
||||
branch_idx = _hour_to_branch_idx(hour)
|
||||
day_stem_idx = HEAVENLY_STEMS.index(day_stem)
|
||||
stem_idx = (day_stem_idx * 2 + branch_idx) % 10
|
||||
return _make_pillar(stem_idx, branch_idx)
|
||||
|
||||
|
||||
# ─── 십성 ─────────────────────────────────────────────────────────
|
||||
def get_ten_god(day_stem: str, target_stem: str, is_yang: bool) -> str:
|
||||
"""십성 — 일간/대상 오행 관계 + is_yang (TS getTenGod 동일).
|
||||
|
||||
is_yang은 호출자가 '대상이 일간과 동일한 음양인가?'로 미리 계산해서 전달.
|
||||
같은 음양 → 비견/식신/편재/편관/편인
|
||||
다른 음양 → 겁재/상관/정재/정관/정인
|
||||
"""
|
||||
day_element = FIVE_ELEMENTS[day_stem]
|
||||
target_element = FIVE_ELEMENTS[target_stem]
|
||||
|
||||
if day_element == target_element:
|
||||
return "비견" if is_yang else "겁재"
|
||||
|
||||
if _PRODUCE_MAP[day_element] == target_element:
|
||||
return "식신" if is_yang else "상관"
|
||||
|
||||
if _OVERCOME_MAP[day_element] == target_element:
|
||||
return "편재" if is_yang else "정재"
|
||||
|
||||
if _OVERCOME_MAP[target_element] == day_element:
|
||||
return "편관" if is_yang else "정관"
|
||||
|
||||
if _PRODUCE_MAP[target_element] == day_element:
|
||||
return "편인" if is_yang else "정인"
|
||||
|
||||
return "비견"
|
||||
|
||||
|
||||
# ─── 십이운성 ─────────────────────────────────────────────────────
|
||||
def get_twelve_fortune(day_stem: str, branch: str) -> str:
|
||||
"""십이운성 — 일간×지지 하드코딩 매핑 (TS getTwelveFortune 동일)."""
|
||||
idx = _FORTUNE_MAP.get(day_stem, {}).get(branch, 0)
|
||||
return TWELVE_FORTUNES[idx]
|
||||
|
||||
|
||||
# ─── 사주 통합 계산 ────────────────────────────────────────────────
|
||||
def calculate_saju(
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: Optional[int],
|
||||
gender: str,
|
||||
) -> dict:
|
||||
"""사주팔자 전체 계산 — TS calculateSaju와 1:1 일치.
|
||||
|
||||
반환 키는 snake_case (year, month, day, hour, day_stem, birth_date, gender).
|
||||
"""
|
||||
year_pillar = get_year_ganzi(year)
|
||||
month_pillar = get_month_ganzi(year, month, day)
|
||||
day_pillar = get_day_ganzi(year, month, day)
|
||||
|
||||
day_stem = day_pillar["stem"]
|
||||
day_stem_idx = HEAVENLY_STEMS.index(day_stem)
|
||||
is_day_yang = (day_stem_idx % 2) == 0
|
||||
|
||||
def _ten_god_for(stem: str) -> str:
|
||||
target_idx = HEAVENLY_STEMS.index(stem)
|
||||
target_yang = (target_idx % 2) == 0
|
||||
# is_yang 인자는 '대상이 일간과 같은 음양인가?'
|
||||
return get_ten_god(day_stem, stem, target_yang == is_day_yang)
|
||||
|
||||
# 년주 — ten_god + fortune 부착
|
||||
year_pillar["ten_god"] = _ten_god_for(year_pillar["stem"])
|
||||
year_pillar["fortune"] = get_twelve_fortune(day_stem, year_pillar["branch"])
|
||||
|
||||
# 월주
|
||||
month_pillar["ten_god"] = _ten_god_for(month_pillar["stem"])
|
||||
month_pillar["fortune"] = get_twelve_fortune(day_stem, month_pillar["branch"])
|
||||
|
||||
# 일주 — ten_god='일간'
|
||||
day_pillar["ten_god"] = "일간"
|
||||
day_pillar["fortune"] = get_twelve_fortune(day_stem, day_pillar["branch"])
|
||||
|
||||
birth_date: dict = {"year": year, "month": month, "day": day}
|
||||
|
||||
result: dict = {
|
||||
"year": year_pillar,
|
||||
"month": month_pillar,
|
||||
"day": day_pillar,
|
||||
"day_stem": day_stem,
|
||||
"birth_date": birth_date,
|
||||
"gender": gender,
|
||||
}
|
||||
|
||||
# 시주
|
||||
if hour is not None:
|
||||
hour_pillar = get_hour_ganzi(day_stem, hour)
|
||||
hour_pillar["ten_god"] = _ten_god_for(hour_pillar["stem"])
|
||||
hour_pillar["fortune"] = get_twelve_fortune(day_stem, hour_pillar["branch"])
|
||||
result["hour"] = hour_pillar
|
||||
birth_date["hour"] = hour
|
||||
|
||||
return result
|
||||
277
saju-lab/app/calculator/daeun.py
Normal file
277
saju-lab/app/calculator/daeun.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""대운(大運) 계산 — saju-web/lib/daeun-calculator.ts 와 1:1 매핑.
|
||||
|
||||
핵심 알고리즘:
|
||||
1. 방향 결정 (양남음녀=순행, 음남양녀=역행)
|
||||
- 양/음은 년간 인덱스 % 2 == 0 여부 (TS: yearStemIndex = (year - 1900 + 6) % 10)
|
||||
2. 시작 나이 계산 (절기 기준)
|
||||
- 순행: 다음 절기까지 일수 / 3 (floor)
|
||||
- 역행: 이전 절기부터 일수 / 3 (TS는 Math.ceil 사용)
|
||||
- clamp [1, 10]
|
||||
3. 8개 대운 — 시작 나이부터 10년 단위, 월주에서 ±1씩 60갑자 진행
|
||||
|
||||
⚠️ TS `getCurrentSolarTerm`의 미묘한 동작을 재현:
|
||||
- TS는 saju-web 인덱스 i=23(대한)부터 i=0(입춘) 순서로 내려가며,
|
||||
`getSolarTermDate(year, i)` 의 날짜와 비교하여 첫 번째 매치를 반환.
|
||||
- 대한(i=23)은 1월 20일경이라 같은 해의 대부분의 날짜가 매치 → currentTerm=23이
|
||||
대다수.
|
||||
- 매치 못 찾으면 폴백 23 반환 → 입춘 이전 (1월 초·중순) 또한 23으로 처리됨.
|
||||
- 결과적으로 forward 의 days 가 매우 크게 잡혀 startAge=10으로 cap 되는 게 보통.
|
||||
- 본 구현은 sxtwl 정확 날짜로 동일한 비교 로직을 수행 → TS 결과 정확 재현.
|
||||
|
||||
⚠️ saju-web 인덱스 vs sxtwl JieQi 인덱스:
|
||||
- saju-web 0=입춘 → sxtwl 3=立春
|
||||
- 변환: sxtwl_idx = (web_idx + 3) % 24
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
HEAVENLY_STEMS,
|
||||
HEAVENLY_STEMS_KR,
|
||||
EARTHLY_BRANCHES,
|
||||
EARTHLY_BRANCHES_KR,
|
||||
)
|
||||
from .solar_terms import _all_jieqi_dates
|
||||
|
||||
|
||||
# saju-web 절기 인덱스 → sxtwl JieQi 인덱스
|
||||
# 0=입춘, 1=우수, 2=경칩, ..., 22=소한, 23=대한 (saju-web)
|
||||
# 3=立春, 4=雨水, 5=驚蟄, ..., 1=小寒, 2=大寒 (sxtwl)
|
||||
def _web_to_sxtwl_idx(web_idx: int) -> int:
|
||||
return (web_idx + 3) % 24
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _saju_web_solar_term_date(year: int, web_idx: int) -> _date:
|
||||
"""saju-web 인덱스 기준 `year` 의 절기 날짜 반환 (정확 날짜).
|
||||
|
||||
TS `getSolarTermDate(year, termIndex)` 의 1:1 대응.
|
||||
sxtwl 으로 정확한 천문학적 날짜를 사용 (TS의 search-range 근사보다 정확).
|
||||
|
||||
`year` 는 양력 그레고리안 연도. 예를 들어 大寒(saju-web 23)의 1985년 날짜는
|
||||
1985-01-20이며, sxtwl 에서는 getJieQiByYear(1984) 의 결과에 idx=2로 포함됨.
|
||||
"""
|
||||
sxtwl_idx = _web_to_sxtwl_idx(web_idx)
|
||||
# 해당 sxtwl_idx 의 절기 중 그레고리안 연도가 일치하는 날짜 검색.
|
||||
# sxtwl 의 getJieQiByYear(y) 는 立春 y년부터 그 다음 立春 직전(=大寒 y+1년)까지 포함.
|
||||
for src_year in (year - 1, year, year + 1):
|
||||
for d, idx in _all_jieqi_dates(src_year):
|
||||
if idx == sxtwl_idx and d.year == year:
|
||||
return d
|
||||
# 폴백 — 정상 동작 시 도달 불가
|
||||
base_month = [
|
||||
2, 2, 3, 3, 4, 4,
|
||||
5, 5, 6, 6, 7, 7,
|
||||
8, 8, 9, 9, 10, 10,
|
||||
11, 11, 12, 12, 1, 1,
|
||||
]
|
||||
base_day = [
|
||||
4, 19, 5, 20, 4, 20,
|
||||
5, 21, 6, 21, 7, 23,
|
||||
7, 23, 8, 23, 8, 23,
|
||||
7, 22, 7, 22, 5, 20,
|
||||
]
|
||||
return _date(year, base_month[web_idx], base_day[web_idx])
|
||||
|
||||
|
||||
def _get_current_solar_term_web(year: int, month: int, day: int) -> int:
|
||||
"""TS getCurrentSolarTerm 의 1:1 재현.
|
||||
|
||||
saju-web 절기 인덱스 (0=입춘 ... 23=대한) 중 birth date 이상인 가장 큰 i 반환.
|
||||
탐색 순서: i=23 → i=0. i=23 (대한, 1월 20일경) 매치 시 즉시 반환되므로
|
||||
대부분의 날짜가 23을 반환. 매치 못 찾으면 폴백 23.
|
||||
|
||||
TS의 특수 처리: i >= 22 (소한, 대한) 이고 birthMonth >= 2 면 termYear=year,
|
||||
그 외 i >= 22 는 termYear = year - 1. 이는 1월 출생자가 전년도 대한·소한 기준이 됨을 의미.
|
||||
본 함수는 i >= 22 일 때 TS와 동일한 termYear 조정 후 비교.
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
|
||||
for i in range(23, -1, -1):
|
||||
# 기본은 birthYear 와 동일 연도의 절기 날짜
|
||||
term_date = _saju_web_solar_term_date(year, i)
|
||||
term_year = year
|
||||
term_month = term_date.month
|
||||
term_day = term_date.day
|
||||
|
||||
# 대한(23) / 소한(22) 특수 처리 — birthMonth < 2 (1월)면 전년도 절기
|
||||
if i >= 22 and month < 2:
|
||||
term_year = year - 1
|
||||
shifted = _saju_web_solar_term_date(term_year, i)
|
||||
term_month = shifted.month
|
||||
term_day = shifted.day
|
||||
|
||||
try:
|
||||
term = _date(term_year, term_month, term_day)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if target >= term:
|
||||
return i
|
||||
|
||||
# 입춘 이전 (1월 초) — TS는 23 반환
|
||||
return 23
|
||||
|
||||
|
||||
def _calculate_daeun_start_age(
|
||||
year: int, month: int, day: int, gender: str, is_yang_year: bool
|
||||
) -> int:
|
||||
"""대운 시작 나이 정밀 계산 (절기 기준).
|
||||
|
||||
TS calculateDaeunStartAge 와 1:1 매핑.
|
||||
- 양남(陽男) / 음녀(陰女) → 순행: 다음 절기까지 일수
|
||||
- 음남(陰男) / 양녀(陽女) → 역행: 이전 절기부터 일수
|
||||
- startAge = floor(days / 3), clamp [1, 10]
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
current_term = _get_current_solar_term_web(year, month, day)
|
||||
|
||||
if (gender == "male" and is_yang_year) or (gender == "female" and not is_yang_year):
|
||||
# 순행 — 다음 절기까지
|
||||
next_term_index = (current_term + 1) % 24
|
||||
next_year = year + 1 if current_term == 23 else year
|
||||
next_date = _saju_web_solar_term_date(next_year, next_term_index)
|
||||
diff = next_date - target
|
||||
# TS: Math.ceil(diffTime / DAY) — date 차분은 정수이므로 그대로
|
||||
days = diff.days
|
||||
else:
|
||||
# 역행 — 이전 절기부터
|
||||
term_date = _saju_web_solar_term_date(year, current_term)
|
||||
term_year = year
|
||||
term_month = term_date.month
|
||||
# TS: 대한, 소한 처리 — i >= 22 일 때
|
||||
if current_term >= 22 and month >= 2:
|
||||
term_year = year
|
||||
elif current_term >= 22:
|
||||
term_year = year - 1
|
||||
term_date = _saju_web_solar_term_date(term_year, current_term)
|
||||
try:
|
||||
term_dt = _date(term_year, term_month, term_date.day)
|
||||
except ValueError:
|
||||
term_dt = term_date
|
||||
diff = target - term_dt
|
||||
days = diff.days
|
||||
|
||||
start_age = days // 3 # Math.floor
|
||||
|
||||
# 최소 1세, 최대 10세
|
||||
return max(1, min(10, start_age))
|
||||
|
||||
|
||||
def calculate_daeun(
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
gender: str,
|
||||
month_stem: str,
|
||||
month_branch: str,
|
||||
) -> list[dict]:
|
||||
"""8개 대운 계산.
|
||||
|
||||
Args:
|
||||
year, month, day: 양력 생년월일
|
||||
gender: "male" | "female"
|
||||
month_stem, month_branch: 월주 천간/지지 (사주 계산 후 전달)
|
||||
|
||||
Returns:
|
||||
8개 DaeunPillar dict (snake_case 키):
|
||||
- age: 시작 나이
|
||||
- start_year: 시작 년도
|
||||
- end_year: 끝 년도 (start_year + 9)
|
||||
- stem, branch: 천간/지지
|
||||
- stem_kr, branch_kr: 한글
|
||||
"""
|
||||
try:
|
||||
month_stem_idx = HEAVENLY_STEMS.index(month_stem)
|
||||
month_branch_idx = EARTHLY_BRANCHES.index(month_branch)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
# 양남음녀(陽男陰女)=순행, 음남양녀(陰男陽女)=역행
|
||||
# TS: yearStemIndex = (year - 1900 + 6) % 10 → isYangYear = (idx % 2 == 0)
|
||||
year_stem_idx = (year - 1900 + 6) % 10
|
||||
if year_stem_idx < 0:
|
||||
year_stem_idx += 10
|
||||
is_yang_year = (year_stem_idx % 2) == 0
|
||||
|
||||
if gender == "male":
|
||||
is_forward = is_yang_year
|
||||
else:
|
||||
is_forward = not is_yang_year
|
||||
|
||||
start_age = _calculate_daeun_start_age(year, month, day, gender, is_yang_year)
|
||||
|
||||
daeun_list: list[dict] = []
|
||||
for i in range(8):
|
||||
age = start_age + (i * 10)
|
||||
start_year = year + age
|
||||
end_year = start_year + 9
|
||||
|
||||
if is_forward:
|
||||
stem_idx = (month_stem_idx + i + 1) % 10
|
||||
branch_idx = (month_branch_idx + i + 1) % 12
|
||||
else:
|
||||
stem_idx = (month_stem_idx - i - 1 + 100) % 10
|
||||
branch_idx = (month_branch_idx - i - 1 + 120) % 12
|
||||
|
||||
daeun_list.append({
|
||||
"age": age,
|
||||
"start_year": start_year,
|
||||
"end_year": end_year,
|
||||
"stem": HEAVENLY_STEMS[stem_idx],
|
||||
"branch": EARTHLY_BRANCHES[branch_idx],
|
||||
"stem_kr": HEAVENLY_STEMS_KR[stem_idx],
|
||||
"branch_kr": EARTHLY_BRANCHES_KR[branch_idx],
|
||||
})
|
||||
|
||||
return daeun_list
|
||||
|
||||
|
||||
def get_current_daeun(daeun_list: list[dict], current_year: int) -> Optional[dict]:
|
||||
"""현재 연도에 해당하는 대운 반환 (없으면 None)."""
|
||||
for d in daeun_list:
|
||||
if d["start_year"] <= current_year <= d["end_year"]:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def get_daeun_description(daeun: dict, day_stem: str) -> str:
|
||||
"""대운 해석 — TS getDaeunDescription 와 1:1 매핑.
|
||||
|
||||
daeun dict 키는 snake_case (age, stem, branch, stem_kr, branch_kr).
|
||||
"""
|
||||
age = daeun["age"]
|
||||
stem = daeun["stem"]
|
||||
branch = daeun["branch"]
|
||||
stem_kr = daeun["stem_kr"]
|
||||
branch_kr = daeun["branch_kr"]
|
||||
ganzi = f"{stem}{branch}"
|
||||
|
||||
description = (
|
||||
f"{age}세부터 {age + 9}세까지의 10년은 {stem_kr}{branch_kr}({ganzi}) 대운입니다. "
|
||||
)
|
||||
|
||||
# 대운 천간 인덱스 (음양 판정용)
|
||||
try:
|
||||
stem_idx = HEAVENLY_STEMS.index(stem)
|
||||
except ValueError:
|
||||
stem_idx = 0
|
||||
|
||||
if age < 20:
|
||||
description += "청소년기로 학업과 기초를 다지는 시기입니다. "
|
||||
elif age < 40:
|
||||
description += "성장과 발전의 시기로 사회활동이 왕성한 때입니다. "
|
||||
elif age < 60:
|
||||
description += "안정과 성숙의 시기로 경험이 쌓이는 때입니다. "
|
||||
else:
|
||||
description += "원숙한 시기로 인생의 지혜를 나누는 때입니다. "
|
||||
|
||||
if stem_idx % 2 == 0:
|
||||
description += "적극적이고 외향적인 활동이 유리합니다."
|
||||
else:
|
||||
description += "차분하고 내실을 다지는 것이 좋습니다."
|
||||
|
||||
return description
|
||||
23
saju-lab/app/calculator/lunar.py
Normal file
23
saju-lab/app/calculator/lunar.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""음력↔양력 변환 — sxtwl 기반."""
|
||||
import sxtwl
|
||||
|
||||
|
||||
def solar_to_lunar(year: int, month: int, day: int) -> dict:
|
||||
"""양력 → 음력 변환. {'year', 'month', 'day', 'is_leap'} 반환."""
|
||||
day_obj = sxtwl.fromSolar(year, month, day)
|
||||
return {
|
||||
"year": day_obj.getLunarYear(),
|
||||
"month": day_obj.getLunarMonth(),
|
||||
"day": day_obj.getLunarDay(),
|
||||
"is_leap": bool(day_obj.isLunarLeap()),
|
||||
}
|
||||
|
||||
|
||||
def lunar_to_solar(year: int, month: int, day: int, is_leap: bool = False) -> tuple[int, int, int]:
|
||||
"""음력 → 양력 변환. (year, month, day) tuple 반환."""
|
||||
day_obj = sxtwl.fromLunar(year, month, day, is_leap)
|
||||
return (
|
||||
day_obj.getSolarYear(),
|
||||
day_obj.getSolarMonth(),
|
||||
day_obj.getSolarDay(),
|
||||
)
|
||||
482
saju-lab/app/calculator/shinsal.py
Normal file
482
saju-lab/app/calculator/shinsal.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""신살 + 지장간 + 공망 + 지지 상호작용.
|
||||
|
||||
saju-web/lib/saju-calculator.ts 의 다음 함수와 1:1 매핑:
|
||||
- getHiddenStems / getAllHiddenStems (지장간)
|
||||
- analyzeBranchInteractions (육합/삼합/방합/충/형/자형/파/해)
|
||||
- calculateShinsal (역마/도화/화개/천을귀인/문창귀인/천덕귀인)
|
||||
- calculateGongmang (공망)
|
||||
|
||||
입력 saju dict는 core.calculate_saju 의 snake_case 출력 (year/month/day/hour pillar
|
||||
각각 stem, branch, stem_kr, branch_kr, element, ten_god, fortune. day_stem 별도).
|
||||
출력 키는 snake_case (branch_kr, branches_kr, result_element 등). 단, `pillar` 값은
|
||||
TS와 동일하게 한글(`년주`/`월주`/`일주`/`시주`)을 사용.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
HEAVENLY_STEMS,
|
||||
HEAVENLY_STEMS_KR,
|
||||
EARTHLY_BRANCHES,
|
||||
EARTHLY_BRANCHES_KR,
|
||||
FIVE_ELEMENTS,
|
||||
HIDDEN_STEMS,
|
||||
)
|
||||
|
||||
|
||||
# ─── 지장간 ────────────────────────────────────────────────────────
|
||||
def get_hidden_stems(branch: str) -> list[str]:
|
||||
"""지지의 지장간(숨은 천간) — [본기, 중기, 여기]."""
|
||||
return list(HIDDEN_STEMS.get(branch, []))
|
||||
|
||||
|
||||
def get_all_hidden_stems(saju: dict) -> list[dict]:
|
||||
"""4주 전체의 지장간 정보 반환.
|
||||
|
||||
출력 구조:
|
||||
[{
|
||||
"pillar": "년주",
|
||||
"branch": "午",
|
||||
"branch_kr": "오",
|
||||
"stems": [
|
||||
{"stem": "丁", "stem_kr": "정", "element": "火", "role": "정기(본기)"},
|
||||
{"stem": "己", "stem_kr": "기", "element": "土", "role": "중기"},
|
||||
],
|
||||
}, ...]
|
||||
"""
|
||||
pillars: list[dict] = [
|
||||
{"pillar": "년주", "branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"]},
|
||||
{"pillar": "월주", "branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"]},
|
||||
{"pillar": "일주", "branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"]},
|
||||
]
|
||||
if saju.get("hour") is not None:
|
||||
pillars.append({
|
||||
"pillar": "시주",
|
||||
"branch": saju["hour"]["branch"],
|
||||
"branch_kr": saju["hour"]["branch_kr"],
|
||||
})
|
||||
|
||||
result: list[dict] = []
|
||||
for p in pillars:
|
||||
hidden = get_hidden_stems(p["branch"])
|
||||
stems = []
|
||||
for idx, stem in enumerate(hidden):
|
||||
stem_index = HEAVENLY_STEMS.index(stem)
|
||||
if idx == 0:
|
||||
role = "정기(본기)"
|
||||
elif idx == 1:
|
||||
role = "중기"
|
||||
else:
|
||||
role = "여기"
|
||||
stems.append({
|
||||
"stem": stem,
|
||||
"stem_kr": HEAVENLY_STEMS_KR[stem_index],
|
||||
"element": FIVE_ELEMENTS[stem],
|
||||
"role": role,
|
||||
})
|
||||
result.append({
|
||||
"pillar": p["pillar"],
|
||||
"branch": p["branch"],
|
||||
"branch_kr": p["branch_kr"],
|
||||
"stems": stems,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ─── 지지 상호작용 ──────────────────────────────────────────────────
|
||||
# 육합 (六合): 子丑(土), 寅亥(木), 卯戌(火), 辰酉(金), 巳申(水), 午未(火)
|
||||
_YUKAP_PAIRS: list[tuple[str, str, str]] = [
|
||||
("子", "丑", "土"), ("寅", "亥", "木"), ("卯", "戌", "火"),
|
||||
("辰", "酉", "金"), ("巳", "申", "水"), ("午", "未", "火"),
|
||||
]
|
||||
|
||||
# 삼합 (三合)
|
||||
_SAMHAP_GROUPS: list[tuple[str, str, str, str]] = [
|
||||
("申", "子", "辰", "水"), ("亥", "卯", "未", "木"),
|
||||
("寅", "午", "戌", "火"), ("巳", "酉", "丑", "金"),
|
||||
]
|
||||
|
||||
# 방합 (方合)
|
||||
_BANGHAP_GROUPS: list[tuple[str, str, str, str]] = [
|
||||
("寅", "卯", "辰", "木"), ("巳", "午", "未", "火"),
|
||||
("申", "酉", "戌", "金"), ("亥", "子", "丑", "水"),
|
||||
]
|
||||
|
||||
# 충 (沖)
|
||||
_CHUNG_PAIRS: list[tuple[str, str]] = [
|
||||
("子", "午"), ("丑", "未"), ("寅", "申"),
|
||||
("卯", "酉"), ("辰", "戌"), ("巳", "亥"),
|
||||
]
|
||||
|
||||
# 형 (刑)
|
||||
_HYUNG_GROUPS: list[dict] = [
|
||||
{"branches": ["寅", "巳", "申"], "name": "무은지형(無恩之刑)"},
|
||||
{"branches": ["丑", "戌", "未"], "name": "지세지형(恃勢之刑)"},
|
||||
{"branches": ["子", "卯"], "name": "무례지형(無禮之刑)"},
|
||||
]
|
||||
_JAHYUNG_BRANCHES = ["辰", "午", "酉", "亥"]
|
||||
|
||||
# 파 (破)
|
||||
_PA_PAIRS: list[tuple[str, str]] = [
|
||||
("子", "酉"), ("丑", "辰"), ("寅", "亥"),
|
||||
("卯", "午"), ("巳", "申"), ("未", "戌"),
|
||||
]
|
||||
|
||||
# 해 (害)
|
||||
_HAE_PAIRS: list[tuple[str, str]] = [
|
||||
("子", "未"), ("丑", "午"), ("寅", "巳"),
|
||||
("卯", "辰"), ("申", "亥"), ("酉", "戌"),
|
||||
]
|
||||
|
||||
_ELEMENT_NAMES_KR: dict[str, str] = {
|
||||
"木": "목", "火": "화", "土": "토", "金": "금", "水": "수",
|
||||
}
|
||||
|
||||
|
||||
def _collect_pillar_branches(saju: dict) -> list[dict]:
|
||||
"""4주 지지 리스트 — TS analyzeBranchInteractions 내부 변수와 동일 순서."""
|
||||
out: list[dict] = [
|
||||
{"branch": saju["year"]["branch"], "pillar": "년주", "branch_kr": saju["year"]["branch_kr"]},
|
||||
{"branch": saju["month"]["branch"], "pillar": "월주", "branch_kr": saju["month"]["branch_kr"]},
|
||||
{"branch": saju["day"]["branch"], "pillar": "일주", "branch_kr": saju["day"]["branch_kr"]},
|
||||
]
|
||||
if saju.get("hour") is not None:
|
||||
out.append({
|
||||
"branch": saju["hour"]["branch"],
|
||||
"pillar": "시주",
|
||||
"branch_kr": saju["hour"]["branch_kr"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def analyze_branch_interactions(saju: dict) -> list[dict]:
|
||||
"""지지 상호작용 분석 (육합/삼합/반삼합/방합/충/형/자형/파/해).
|
||||
|
||||
TS analyzeBranchInteractions 와 1:1 동일. branches는 첫 번째 발견 인덱스만 본다
|
||||
(중복 지지가 있을 때 TS의 indexOf 동작과 동일).
|
||||
"""
|
||||
interactions: list[dict] = []
|
||||
pillar_branches = _collect_pillar_branches(saju)
|
||||
branches = [pb["branch"] for pb in pillar_branches]
|
||||
|
||||
# 육합 (六合)
|
||||
for a, b, elem in _YUKAP_PAIRS:
|
||||
if a in branches and b in branches:
|
||||
ia = branches.index(a)
|
||||
ib = branches.index(b)
|
||||
interactions.append({
|
||||
"type": "육합(六合)",
|
||||
"branches": [a, b],
|
||||
"branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]],
|
||||
"pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]],
|
||||
"description": (
|
||||
f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 육합 "
|
||||
f"→ {_ELEMENT_NAMES_KR[elem]}({elem}) 기운 생성. 조화와 화합의 관계."
|
||||
),
|
||||
"result_element": elem,
|
||||
})
|
||||
|
||||
# 삼합 (三合) / 반삼합 (半三合)
|
||||
for a, b, c, elem in _SAMHAP_GROUPS:
|
||||
found = [x for x in (a, b, c) if x in branches]
|
||||
if len(found) >= 2:
|
||||
found_pillars = [pillar_branches[branches.index(x)] for x in found]
|
||||
is_complete = len(found) == 3
|
||||
type_kr = "삼합(三合)" if is_complete else "반삼합(半三合)"
|
||||
kr_str = "".join(p["branch_kr"] for p in found_pillars)
|
||||
samhap_label = "삼합" if is_complete else "반삼합"
|
||||
tail = "강력한 합의 기운." if is_complete else "삼합의 기운이 부분적으로 작용."
|
||||
interactions.append({
|
||||
"type": type_kr,
|
||||
"branches": found,
|
||||
"branches_kr": [p["branch_kr"] for p in found_pillars],
|
||||
"pillars": [p["pillar"] for p in found_pillars],
|
||||
"description": (
|
||||
f"{kr_str} {samhap_label} → {_ELEMENT_NAMES_KR[elem]}({elem})국. {tail}"
|
||||
),
|
||||
"result_element": elem,
|
||||
})
|
||||
|
||||
# 방합 (方合)
|
||||
for a, b, c, elem in _BANGHAP_GROUPS:
|
||||
found = [x for x in (a, b, c) if x in branches]
|
||||
if len(found) == 3:
|
||||
found_pillars = [pillar_branches[branches.index(x)] for x in found]
|
||||
kr_str = "".join(p["branch_kr"] for p in found_pillars)
|
||||
interactions.append({
|
||||
"type": "방합(方合)",
|
||||
"branches": found,
|
||||
"branches_kr": [p["branch_kr"] for p in found_pillars],
|
||||
"pillars": [p["pillar"] for p in found_pillars],
|
||||
"description": (
|
||||
f"{kr_str} 방합 → {_ELEMENT_NAMES_KR[elem]}({elem}) 방국. 매우 강한 오행 기운."
|
||||
),
|
||||
"result_element": elem,
|
||||
})
|
||||
|
||||
# 충 (沖)
|
||||
for a, b in _CHUNG_PAIRS:
|
||||
if a in branches and b in branches:
|
||||
ia = branches.index(a)
|
||||
ib = branches.index(b)
|
||||
interactions.append({
|
||||
"type": "충(沖)",
|
||||
"branches": [a, b],
|
||||
"branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]],
|
||||
"pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]],
|
||||
"description": (
|
||||
f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 충 "
|
||||
f"→ 변동, 갈등, 변화의 에너지. "
|
||||
f"{pillar_branches[ia]['pillar']}와 {pillar_branches[ib]['pillar']} 사이의 긴장 관계."
|
||||
),
|
||||
})
|
||||
|
||||
# 형 (刑)
|
||||
for group in _HYUNG_GROUPS:
|
||||
found = [x for x in group["branches"] if x in branches]
|
||||
if len(found) >= 2:
|
||||
found_pillars = [pillar_branches[branches.index(x)] for x in found]
|
||||
kr_str = "".join(p["branch_kr"] for p in found_pillars)
|
||||
interactions.append({
|
||||
"type": "형(刑)",
|
||||
"branches": found,
|
||||
"branches_kr": [p["branch_kr"] for p in found_pillars],
|
||||
"pillars": [p["pillar"] for p in found_pillars],
|
||||
"description": f"{kr_str} {group['name']} → 시련과 갈등의 기운. 주의가 필요한 관계.",
|
||||
})
|
||||
|
||||
# 자형 (自刑)
|
||||
for jb in _JAHYUNG_BRANCHES:
|
||||
count = branches.count(jb)
|
||||
if count >= 2:
|
||||
br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(jb)]
|
||||
interactions.append({
|
||||
"type": "자형(自刑)",
|
||||
"branches": [jb, jb],
|
||||
"branches_kr": [br_kr, br_kr],
|
||||
"pillars": [p["pillar"] for p in pillar_branches if p["branch"] == jb],
|
||||
"description": f"{br_kr}{br_kr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.",
|
||||
})
|
||||
|
||||
# 파 (破)
|
||||
for a, b in _PA_PAIRS:
|
||||
if a in branches and b in branches:
|
||||
ia = branches.index(a)
|
||||
ib = branches.index(b)
|
||||
interactions.append({
|
||||
"type": "파(破)",
|
||||
"branches": [a, b],
|
||||
"branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]],
|
||||
"pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]],
|
||||
"description": (
|
||||
f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 파 "
|
||||
f"→ 관계의 균열, 계획의 차질 가능성."
|
||||
),
|
||||
})
|
||||
|
||||
# 해 (害)
|
||||
for a, b in _HAE_PAIRS:
|
||||
if a in branches and b in branches:
|
||||
ia = branches.index(a)
|
||||
ib = branches.index(b)
|
||||
interactions.append({
|
||||
"type": "해(害)",
|
||||
"branches": [a, b],
|
||||
"branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]],
|
||||
"pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]],
|
||||
"description": (
|
||||
f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 해 "
|
||||
f"→ 은근한 방해, 원망의 기운."
|
||||
),
|
||||
})
|
||||
|
||||
return interactions
|
||||
|
||||
|
||||
# ─── 신살 ──────────────────────────────────────────────────────────
|
||||
# 일지 삼합국 기준 신살 매핑
|
||||
_SAMHAP_GROUP_MAP: dict[str, str] = {
|
||||
"申": "申子辰", "子": "申子辰", "辰": "申子辰",
|
||||
"寅": "寅午戌", "午": "寅午戌", "戌": "寅午戌",
|
||||
"巳": "巳酉丑", "酉": "巳酉丑", "丑": "巳酉丑",
|
||||
"亥": "亥卯未", "卯": "亥卯未", "未": "亥卯未",
|
||||
}
|
||||
_YEOKMA_MAP: dict[str, str] = {
|
||||
"申子辰": "寅", "寅午戌": "申", "巳酉丑": "亥", "亥卯未": "巳",
|
||||
}
|
||||
_DOHWA_MAP: dict[str, str] = {
|
||||
"申子辰": "酉", "寅午戌": "卯", "巳酉丑": "午", "亥卯未": "子",
|
||||
}
|
||||
_HWAGAE_MAP: dict[str, str] = {
|
||||
"申子辰": "辰", "寅午戌": "戌", "巳酉丑": "丑", "亥卯未": "未",
|
||||
}
|
||||
|
||||
# 천을귀인 (天乙貴人) — 일간 기준
|
||||
_CHEONUL_MAP: dict[str, list[str]] = {
|
||||
"甲": ["丑", "未"], "乙": ["子", "申"], "丙": ["亥", "酉"], "丁": ["亥", "酉"],
|
||||
"戊": ["丑", "未"], "己": ["子", "申"], "庚": ["丑", "未"], "辛": ["寅", "午"],
|
||||
"壬": ["卯", "巳"], "癸": ["卯", "巳"],
|
||||
}
|
||||
|
||||
# 문창귀인 (文昌貴人) — 일간 기준
|
||||
_MUNCHANG_MAP: dict[str, str] = {
|
||||
"甲": "巳", "乙": "午", "丙": "申", "丁": "酉",
|
||||
"戊": "申", "己": "酉", "庚": "亥", "辛": "子",
|
||||
"壬": "寅", "癸": "卯",
|
||||
}
|
||||
|
||||
# 천덕귀인 (天德貴人) — 월지 기준 천간
|
||||
_CHEONDUK_MAP: dict[str, str] = {
|
||||
"寅": "丁", "卯": "申", "辰": "壬", "巳": "辛",
|
||||
"午": "亥", "未": "甲", "申": "癸", "酉": "寅",
|
||||
"戌": "丙", "亥": "乙", "子": "巳", "丑": "庚",
|
||||
}
|
||||
|
||||
|
||||
def calculate_shinsal(saju: dict) -> list[dict]:
|
||||
"""신살 계산 (역마/도화/화개/천을귀인/문창귀인/천덕귀인).
|
||||
|
||||
TS calculateShinsal 와 1:1 동일.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
day_branch = saju["day"]["branch"]
|
||||
day_stem = saju["day_stem"]
|
||||
month_branch = saju["month"]["branch"]
|
||||
|
||||
pillar_branches: list[dict] = [
|
||||
{"branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"], "pillar": "년주"},
|
||||
{"branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"], "pillar": "월주"},
|
||||
{"branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"], "pillar": "일주"},
|
||||
]
|
||||
if saju.get("hour") is not None:
|
||||
pillar_branches.append({
|
||||
"branch": saju["hour"]["branch"],
|
||||
"branch_kr": saju["hour"]["branch_kr"],
|
||||
"pillar": "시주",
|
||||
})
|
||||
|
||||
group = _SAMHAP_GROUP_MAP.get(day_branch)
|
||||
|
||||
if group:
|
||||
# 역마살
|
||||
yeokma = _YEOKMA_MAP[group]
|
||||
for pb in pillar_branches:
|
||||
if pb["branch"] == yeokma and pb["pillar"] != "일주":
|
||||
result.append({
|
||||
"name": "역마살",
|
||||
"name_hanja": "驛馬殺",
|
||||
"branch": yeokma,
|
||||
"branch_kr": pb["branch_kr"],
|
||||
"pillar": pb["pillar"],
|
||||
"description": "이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.",
|
||||
})
|
||||
|
||||
# 도화살
|
||||
dohwa = _DOHWA_MAP[group]
|
||||
for pb in pillar_branches:
|
||||
if pb["branch"] == dohwa and pb["pillar"] != "일주":
|
||||
result.append({
|
||||
"name": "도화살",
|
||||
"name_hanja": "桃花殺",
|
||||
"branch": dohwa,
|
||||
"branch_kr": pb["branch_kr"],
|
||||
"pillar": pb["pillar"],
|
||||
"description": "매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.",
|
||||
})
|
||||
|
||||
# 화개살
|
||||
hwagae = _HWAGAE_MAP[group]
|
||||
for pb in pillar_branches:
|
||||
if pb["branch"] == hwagae and pb["pillar"] != "일주":
|
||||
result.append({
|
||||
"name": "화개살",
|
||||
"name_hanja": "華蓋殺",
|
||||
"branch": hwagae,
|
||||
"branch_kr": pb["branch_kr"],
|
||||
"pillar": pb["pillar"],
|
||||
"description": "학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.",
|
||||
})
|
||||
|
||||
# 천을귀인 — 일간 기준
|
||||
cheonul_branches = _CHEONUL_MAP.get(day_stem, [])
|
||||
for pb in pillar_branches:
|
||||
if pb["branch"] in cheonul_branches and pb["pillar"] != "일주":
|
||||
result.append({
|
||||
"name": "천을귀인",
|
||||
"name_hanja": "天乙貴人",
|
||||
"branch": pb["branch"],
|
||||
"branch_kr": pb["branch_kr"],
|
||||
"pillar": pb["pillar"],
|
||||
"description": "위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.",
|
||||
})
|
||||
|
||||
# 문창귀인 — 일간 기준
|
||||
munchang_branch = _MUNCHANG_MAP.get(day_stem)
|
||||
if munchang_branch:
|
||||
for pb in pillar_branches:
|
||||
if pb["branch"] == munchang_branch and pb["pillar"] != "일주":
|
||||
result.append({
|
||||
"name": "문창귀인",
|
||||
"name_hanja": "文昌貴人",
|
||||
"branch": pb["branch"],
|
||||
"branch_kr": pb["branch_kr"],
|
||||
"pillar": pb["pillar"],
|
||||
"description": "학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.",
|
||||
})
|
||||
|
||||
# 천덕귀인 — 월지 기준 천간이 4주 천간에 있으면
|
||||
cheonduk_stem = _CHEONDUK_MAP.get(month_branch)
|
||||
if cheonduk_stem:
|
||||
all_stems: list[dict] = [
|
||||
{"stem": saju["year"]["stem"], "pillar": "년주"},
|
||||
{"stem": saju["day"]["stem"], "pillar": "일주"},
|
||||
]
|
||||
if saju.get("hour") is not None:
|
||||
all_stems.append({"stem": saju["hour"]["stem"], "pillar": "시주"})
|
||||
for ps in all_stems:
|
||||
if ps["stem"] == cheonduk_stem:
|
||||
br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(month_branch)]
|
||||
result.append({
|
||||
"name": "천덕귀인",
|
||||
"name_hanja": "天德貴人",
|
||||
"branch": month_branch,
|
||||
"branch_kr": br_kr,
|
||||
"pillar": ps["pillar"],
|
||||
"description": "하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── 공망 ──────────────────────────────────────────────────────────
|
||||
def calculate_gongmang(day_stem: str, day_branch: str) -> dict:
|
||||
"""공망(空亡) — 일주의 旬(60갑자 10단위)에서 빠지는 2개 지지.
|
||||
|
||||
TS calculateGongmang 와 1:1 동일.
|
||||
"""
|
||||
stem_idx = HEAVENLY_STEMS.index(day_stem)
|
||||
branch_idx = EARTHLY_BRANCHES.index(day_branch)
|
||||
|
||||
# 60갑자에서 해당 旬의 시작점 = 천간이 甲인 지점
|
||||
# 旬의 시작 지지 인덱스 = (branchIdx - stemIdx + 120) % 12
|
||||
start_branch_idx = (branch_idx - stem_idx + 120) % 12
|
||||
|
||||
# 공망 = 旬 시작 + 10, +11
|
||||
gongmang1_idx = (start_branch_idx + 10) % 12
|
||||
gongmang2_idx = (start_branch_idx + 11) % 12
|
||||
|
||||
branch1 = EARTHLY_BRANCHES[gongmang1_idx]
|
||||
branch2 = EARTHLY_BRANCHES[gongmang2_idx]
|
||||
branch_kr1 = EARTHLY_BRANCHES_KR[gongmang1_idx]
|
||||
branch_kr2 = EARTHLY_BRANCHES_KR[gongmang2_idx]
|
||||
|
||||
return {
|
||||
"branches": [branch1, branch2],
|
||||
"branches_kr": [branch_kr1, branch_kr2],
|
||||
"description": (
|
||||
f"{branch_kr1}({branch1})·{branch_kr2}({branch2}) 공망 "
|
||||
f"→ 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. "
|
||||
f"오히려 초월적 능력이 될 수 있음."
|
||||
),
|
||||
}
|
||||
130
saju-lab/app/calculator/solar_terms.py
Normal file
130
saju-lab/app/calculator/solar_terms.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""24절기 + sxtwl 기반 月支 계산.
|
||||
|
||||
sxtwl(寿星天文历) 2.x의 JieQi index 순서:
|
||||
0=冬至, 1=小寒, 2=大寒, 3=立春, 4=雨水, 5=驚蟄,
|
||||
6=春分, 7=清明, 8=穀雨, 9=立夏, 10=小滿, 11=芒種,
|
||||
12=夏至, 13=小暑, 14=大暑, 15=立秋, 16=處暑, 17=白露,
|
||||
18=秋分, 19=寒露, 20=霜降, 21=立冬, 22=小雪, 23=大雪
|
||||
|
||||
(冬至부터 15° 간격으로 황경 증가 — sxtwl.cpp 의 `xn % 24` 계산식 기준)
|
||||
|
||||
saju에서 月支는 12개의 '절'(節, 홀수 황경 인덱스에 해당)을 기준으로 12구간으로 나눈다:
|
||||
立春(3) → 寅(2)
|
||||
驚蟄(5) → 卯(3)
|
||||
清明(7) → 辰(4)
|
||||
立夏(9) → 巳(5)
|
||||
芒種(11) → 午(6)
|
||||
小暑(13) → 未(7)
|
||||
立秋(15) → 申(8)
|
||||
白露(17) → 酉(9)
|
||||
寒露(19) → 戌(10)
|
||||
立冬(21) → 亥(11)
|
||||
大雪(23) → 子(0)
|
||||
小寒(1) → 丑(1)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from functools import lru_cache
|
||||
|
||||
import sxtwl
|
||||
|
||||
|
||||
# sxtwl JieQi index → 月支 인덱스 (子=0, 丑=1, 寅=2, ..., 亥=11)
|
||||
# 12 '節' 만 매핑 (홀수 인덱스), '氣' (짝수 인덱스)는 月支 경계가 아님 → 직전 절의 月支 유지
|
||||
JIEQI_TO_BRANCH: dict[int, int] = {
|
||||
3: 2, # 立春 → 寅
|
||||
5: 3, # 驚蟄 → 卯
|
||||
7: 4, # 清明 → 辰
|
||||
9: 5, # 立夏 → 巳
|
||||
11: 6, # 芒種 → 午
|
||||
13: 7, # 小暑 → 未
|
||||
15: 8, # 立秋 → 申
|
||||
17: 9, # 白露 → 酉
|
||||
19: 10, # 寒露 → 戌
|
||||
21: 11, # 立冬 → 亥
|
||||
23: 0, # 大雪 → 子
|
||||
1: 1, # 小寒 → 丑
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _all_jieqi_dates(year: int) -> tuple[tuple[_date, int], ...]:
|
||||
"""주어진 연도의 24절기 (date, jieqi_index) 튜플 리스트.
|
||||
|
||||
sxtwl.getJieQiByYear(year) → list[JieQiInfo(jd, jqIndex)] 사용.
|
||||
sxtwl.JD2DD(jd) → Time(Y, M, D, ...)로 양력 변환.
|
||||
|
||||
@lru_cache로 같은 연도 반복 호출 시 sxtwl 재호출 방지.
|
||||
"""
|
||||
result: list[tuple[_date, int]] = []
|
||||
jq_list = sxtwl.getJieQiByYear(year)
|
||||
for info in jq_list:
|
||||
t = sxtwl.JD2DD(info.jd)
|
||||
try:
|
||||
d = _date(int(t.Y), int(t.M), int(t.D))
|
||||
except (ValueError, OverflowError):
|
||||
continue
|
||||
result.append((d, int(info.jqIndex)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def get_current_solar_term(year: int, month: int, day: int) -> int:
|
||||
"""현재 날짜에 적용되는 절기 인덱스 (0~23, sxtwl 기준) 반환.
|
||||
|
||||
입력 날짜 이전(또는 같은) 가장 가까운 절기의 인덱스를 반환.
|
||||
절기가 아직 들어오지 않은 경우(연초 등) ±1년치를 함께 탐색.
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
candidates: list[tuple[_date, int]] = []
|
||||
for y in (year - 1, year, year + 1):
|
||||
candidates.extend(_all_jieqi_dates(y))
|
||||
candidates.sort()
|
||||
|
||||
last_idx = 0 # 폴백
|
||||
for d, idx in candidates:
|
||||
if d <= target:
|
||||
last_idx = idx
|
||||
else:
|
||||
break
|
||||
return last_idx
|
||||
|
||||
|
||||
def get_solar_term_month_branch(year: int, month: int, day: int) -> int:
|
||||
"""현재 날짜의 月支 인덱스 (0~11, 子=0...亥=11). 절(節) 기준 12구간.
|
||||
|
||||
12개 '절'만 추출해, 입력 날짜 이전 가장 가까운 절의 月支를 반환.
|
||||
立春 이전이면 직전 해 小寒(=丑) 또는 大雪(=子) 기준.
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
jie_dates: list[tuple[_date, int]] = []
|
||||
for y in (year - 1, year, year + 1):
|
||||
for d, qi_idx in _all_jieqi_dates(y):
|
||||
if qi_idx in JIEQI_TO_BRANCH:
|
||||
jie_dates.append((d, JIEQI_TO_BRANCH[qi_idx]))
|
||||
jie_dates.sort()
|
||||
|
||||
last_branch = 1 # 立春 이전 폴백 — 丑(축)
|
||||
for d, branch_idx in jie_dates:
|
||||
if d <= target:
|
||||
last_branch = branch_idx
|
||||
else:
|
||||
break
|
||||
return last_branch
|
||||
|
||||
|
||||
def get_days_to_next_solar_term(year: int, month: int, day: int) -> int:
|
||||
"""다음 절기까지 남은 일수 (대운 계산용).
|
||||
|
||||
24절기 모두 대상. 입력 날짜 이후 가장 가까운 절기까지의 일 수 반환.
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
all_qi: list[tuple[_date, int]] = []
|
||||
for y in (year, year + 1):
|
||||
all_qi.extend(_all_jieqi_dates(y))
|
||||
all_qi.sort()
|
||||
|
||||
for d, _idx in all_qi:
|
||||
if d > target:
|
||||
return (d - target).days
|
||||
return 30 # 폴백 (이론상 도달 불가)
|
||||
16
saju-lab/app/config.py
Normal file
16
saju-lab/app/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""saju-lab 환경변수."""
|
||||
import os
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
SAJU_MODEL = os.getenv("SAJU_MODEL", "claude-sonnet-4-6")
|
||||
SAJU_COST_INPUT_PER_M = float(os.getenv("SAJU_COST_INPUT_PER_M", "3.0"))
|
||||
SAJU_COST_OUTPUT_PER_M = float(os.getenv("SAJU_COST_OUTPUT_PER_M", "15.0"))
|
||||
SAJU_TIMEOUT_SEC = int(os.getenv("SAJU_TIMEOUT_SEC", "240"))
|
||||
|
||||
SAJU_DATA_PATH = os.getenv("SAJU_DATA_PATH", "/app/data")
|
||||
DB_PATH = os.path.join(SAJU_DATA_PATH, "saju.db")
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS",
|
||||
"http://localhost:3007,http://localhost:8080",
|
||||
)
|
||||
238
saju-lab/app/db.py
Normal file
238
saju-lab/app/db.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""saju.db SQLite — saju_records + compat_records CRUD."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS saju_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
birth_year INTEGER NOT NULL,
|
||||
birth_month INTEGER NOT NULL,
|
||||
birth_day INTEGER NOT NULL,
|
||||
birth_hour INTEGER,
|
||||
gender TEXT NOT NULL,
|
||||
calendar_type TEXT DEFAULT 'solar',
|
||||
saju_data TEXT NOT NULL,
|
||||
analysis_data TEXT NOT NULL,
|
||||
daeun_data TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
reroll_count INTEGER DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_saju_created
|
||||
ON saju_records(created_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS compat_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_a TEXT NOT NULL,
|
||||
person_b TEXT NOT NULL,
|
||||
saju_a TEXT NOT NULL,
|
||||
saju_b TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
breakdown TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
reroll_count INTEGER DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
# saju_records CRUD
|
||||
def save_saju_record(data: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO saju_records
|
||||
(birth_year, birth_month, birth_day, birth_hour, gender, calendar_type,
|
||||
saju_data, analysis_data, daeun_data, interpretation_json,
|
||||
model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
data["birth_year"], data["birth_month"], data["birth_day"],
|
||||
data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"),
|
||||
json.dumps(data["saju_data"], ensure_ascii=False),
|
||||
json.dumps(data["analysis_data"], ensure_ascii=False),
|
||||
json.dumps(data["daeun_data"], ensure_ascii=False),
|
||||
json.dumps(data.get("interpretation_json"), ensure_ascii=False) if data.get("interpretation_json") else None,
|
||||
data.get("model"),
|
||||
data.get("tokens_in", 0), data.get("tokens_out", 0),
|
||||
data.get("cost_usd", 0.0), data.get("latency_ms", 0),
|
||||
data.get("reroll_count", 0),
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_saju_record(record_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM saju_records WHERE id=?", (record_id,)).fetchone()
|
||||
return _saju_row_to_dict(r) if r else None
|
||||
|
||||
|
||||
def list_saju_records(page: int = 1, size: int = 20, favorite: Optional[bool] = None) -> Dict[str, Any]:
|
||||
wheres, params = [], []
|
||||
if favorite is not None:
|
||||
wheres.append("favorite=?")
|
||||
params.append(1 if favorite else 0)
|
||||
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
offset = (page - 1) * size
|
||||
with _conn() as conn:
|
||||
total = conn.execute(f"SELECT COUNT(*) c FROM saju_records {where_sql}", params).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM saju_records {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
params + [size, offset],
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [_saju_row_to_dict(r) for r in rows],
|
||||
"page": page, "size": size, "total": int(total),
|
||||
}
|
||||
|
||||
|
||||
def update_saju_record(record_id: int, **kwargs) -> None:
|
||||
sets, vals = [], []
|
||||
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||
sets.append("favorite=?"); vals.append(1 if kwargs["favorite"] else 0)
|
||||
if "memo" in kwargs and kwargs["memo"] is not None:
|
||||
sets.append("memo=?"); vals.append(kwargs["memo"])
|
||||
if not sets:
|
||||
return
|
||||
vals.append(record_id)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE saju_records SET {','.join(sets)} WHERE id=?", vals)
|
||||
|
||||
|
||||
def delete_saju_record(record_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM saju_records WHERE id=?", (record_id,))
|
||||
|
||||
|
||||
def _saju_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"created_at": r["created_at"],
|
||||
"birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"],
|
||||
"birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"],
|
||||
"saju_data": json.loads(r["saju_data"]) if r["saju_data"] else None,
|
||||
"analysis_data": json.loads(r["analysis_data"]) if r["analysis_data"] else None,
|
||||
"daeun_data": json.loads(r["daeun_data"]) if r["daeun_data"] else None,
|
||||
"interpretation_json": json.loads(r["interpretation_json"]) if r["interpretation_json"] else None,
|
||||
"model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
|
||||
"cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
|
||||
"favorite": int(r["favorite"]), "memo": r["memo"],
|
||||
}
|
||||
|
||||
|
||||
# compat_records CRUD
|
||||
def save_compat_record(data: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO compat_records
|
||||
(person_a, person_b, saju_a, saju_b, score, breakdown,
|
||||
interpretation_json, model, tokens_in, tokens_out,
|
||||
cost_usd, latency_ms, reroll_count)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
json.dumps(data["person_a"], ensure_ascii=False),
|
||||
json.dumps(data["person_b"], ensure_ascii=False),
|
||||
json.dumps(data["saju_a"], ensure_ascii=False),
|
||||
json.dumps(data["saju_b"], ensure_ascii=False),
|
||||
data["score"],
|
||||
json.dumps(data["breakdown"], ensure_ascii=False),
|
||||
json.dumps(data.get("interpretation_json"), ensure_ascii=False) if data.get("interpretation_json") else None,
|
||||
data.get("model"),
|
||||
data.get("tokens_in", 0), data.get("tokens_out", 0),
|
||||
data.get("cost_usd", 0.0), data.get("latency_ms", 0),
|
||||
data.get("reroll_count", 0),
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_compat_record(record_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM compat_records WHERE id=?", (record_id,)).fetchone()
|
||||
return _compat_row_to_dict(r) if r else None
|
||||
|
||||
|
||||
def list_compat_records(page: int = 1, size: int = 20, favorite: Optional[bool] = None) -> Dict[str, Any]:
|
||||
wheres, params = [], []
|
||||
if favorite is not None:
|
||||
wheres.append("favorite=?"); params.append(1 if favorite else 0)
|
||||
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
offset = (page - 1) * size
|
||||
with _conn() as conn:
|
||||
total = conn.execute(f"SELECT COUNT(*) c FROM compat_records {where_sql}", params).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM compat_records {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
params + [size, offset],
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [_compat_row_to_dict(r) for r in rows],
|
||||
"page": page, "size": size, "total": int(total),
|
||||
}
|
||||
|
||||
|
||||
def update_compat_record(record_id: int, **kwargs) -> None:
|
||||
sets, vals = [], []
|
||||
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||
sets.append("favorite=?"); vals.append(1 if kwargs["favorite"] else 0)
|
||||
if "memo" in kwargs and kwargs["memo"] is not None:
|
||||
sets.append("memo=?"); vals.append(kwargs["memo"])
|
||||
if not sets:
|
||||
return
|
||||
vals.append(record_id)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE compat_records SET {','.join(sets)} WHERE id=?", vals)
|
||||
|
||||
|
||||
def delete_compat_record(record_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM compat_records WHERE id=?", (record_id,))
|
||||
|
||||
|
||||
def _compat_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"created_at": r["created_at"],
|
||||
"person_a": json.loads(r["person_a"]),
|
||||
"person_b": json.loads(r["person_b"]),
|
||||
"saju_a": json.loads(r["saju_a"]),
|
||||
"saju_b": json.loads(r["saju_b"]),
|
||||
"score": r["score"],
|
||||
"breakdown": json.loads(r["breakdown"]),
|
||||
"interpretation_json": json.loads(r["interpretation_json"]) if r["interpretation_json"] else None,
|
||||
"model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
|
||||
"cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
|
||||
"favorite": int(r["favorite"]), "memo": r["memo"],
|
||||
}
|
||||
0
saju-lab/app/interpret/__init__.py
Normal file
0
saju-lab/app/interpret/__init__.py
Normal file
145
saju-lab/app/interpret/pipeline.py
Normal file
145
saju-lab/app/interpret/pipeline.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""사주 + 궁합 해석 파이프라인 — Claude Sonnet 호출 + reroll 1회.
|
||||
|
||||
tarot-lab/app/pipeline.py 패턴 재활용.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import (
|
||||
ANTHROPIC_API_KEY,
|
||||
SAJU_MODEL,
|
||||
SAJU_COST_INPUT_PER_M,
|
||||
SAJU_COST_OUTPUT_PER_M,
|
||||
SAJU_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger("saju-lab.pipeline")
|
||||
|
||||
from .prompt import SAJU_SYSTEM_PROMPT, COMPAT_SYSTEM_PROMPT, build_saju_user_message, build_compat_user_message
|
||||
from .schema import validate_saju_interpretation, validate_compat_interpretation
|
||||
|
||||
|
||||
API_URL = "https://api.anthropic.com/v1/messages"
|
||||
|
||||
|
||||
class SajuError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def calc_cost(tokens_in: int, tokens_out: int) -> float:
|
||||
return (
|
||||
tokens_in / 1_000_000 * SAJU_COST_INPUT_PER_M
|
||||
+ tokens_out / 1_000_000 * SAJU_COST_OUTPUT_PER_M
|
||||
)
|
||||
|
||||
|
||||
def _strip_codeblock(text: str) -> str:
|
||||
t = text.strip()
|
||||
if t.startswith("```"):
|
||||
t = t.strip("`")
|
||||
if t.startswith("json"):
|
||||
t = t[4:]
|
||||
t = t.strip()
|
||||
return t
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict:
|
||||
cleaned = _strip_codeblock(raw)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
start, end = cleaned.find("{"), cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(cleaned[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def _call_claude(system_prompt: str, user_text: str, feedback: str = "") -> tuple[dict, dict, str]:
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise SajuError("ANTHROPIC_API_KEY missing")
|
||||
if feedback:
|
||||
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마(시스템 지침)로 다시 응답.\n\n{user_text}"
|
||||
payload = {
|
||||
"model": SAJU_MODEL,
|
||||
"max_tokens": 2400,
|
||||
"system": [{"type": "text", "text": system_prompt,
|
||||
"cache_control": {"type": "ephemeral"}}],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||
}
|
||||
headers = {
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
started = time.monotonic()
|
||||
async with httpx.AsyncClient(timeout=SAJU_TIMEOUT_SEC) as client:
|
||||
r = await client.post(API_URL, headers=headers, json=payload)
|
||||
r.raise_for_status()
|
||||
resp = r.json()
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
raw_text = "".join(
|
||||
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||
)
|
||||
usage = resp.get("usage", {}) or {}
|
||||
tokens_in = int(usage.get("input_tokens", 0) or 0)
|
||||
tokens_out = int(usage.get("output_tokens", 0) or 0)
|
||||
logger.info("saju claude call: latency=%dms, in=%d, out=%d", latency_ms, tokens_in, tokens_out)
|
||||
parsed = _extract_json(raw_text)
|
||||
meta = {"tokens_in": tokens_in, "tokens_out": tokens_out, "latency_ms": latency_ms}
|
||||
return parsed, meta, raw_text
|
||||
|
||||
|
||||
async def _interpret_loop(system_prompt: str, user_text: str, validate_fn) -> Dict[str, Any]:
|
||||
total_in, total_out, total_latency = 0, 0, 0
|
||||
last_error = ""
|
||||
for attempt in range(2):
|
||||
try:
|
||||
parsed, meta, _raw = await _call_claude(system_prompt, user_text, feedback=last_error)
|
||||
except httpx.HTTPError as e:
|
||||
raise SajuError(f"Claude HTTP error: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
last_error = f"JSON 파싱 실패: {e}"
|
||||
continue
|
||||
total_in += meta["tokens_in"]
|
||||
total_out += meta["tokens_out"]
|
||||
total_latency += meta["latency_ms"]
|
||||
|
||||
ok, err = validate_fn(parsed)
|
||||
if ok:
|
||||
return {
|
||||
"interpretation_json": parsed,
|
||||
"model": SAJU_MODEL,
|
||||
"tokens_in": total_in,
|
||||
"tokens_out": total_out,
|
||||
"cost_usd": calc_cost(total_in, total_out),
|
||||
"latency_ms": total_latency,
|
||||
"reroll_count": attempt,
|
||||
}
|
||||
last_error = err
|
||||
|
||||
raise SajuError(f"검증 실패 (reroll 2회): {last_error}")
|
||||
|
||||
|
||||
async def interpret_saju(saju: dict, analysis: dict, daeun: list, current_year: int) -> Dict[str, Any]:
|
||||
user_text = build_saju_user_message(saju, analysis, daeun, current_year)
|
||||
return await _interpret_loop(SAJU_SYSTEM_PROMPT, user_text, validate_saju_interpretation)
|
||||
|
||||
|
||||
async def interpret_compat(
|
||||
saju_a: dict, saju_b: dict,
|
||||
analysis_a: dict, analysis_b: dict,
|
||||
score: int, breakdown: dict,
|
||||
) -> Dict[str, Any]:
|
||||
user_text = build_compat_user_message(
|
||||
saju_a, saju_b, analysis_a, analysis_b, score, breakdown,
|
||||
)
|
||||
return await _interpret_loop(COMPAT_SYSTEM_PROMPT, user_text, validate_compat_interpretation)
|
||||
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, ""
|
||||
33
saju-lab/app/main.py
Normal file
33
saju-lab/app/main.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""saju-lab FastAPI app."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS
|
||||
from .routers import saju, compat
|
||||
from . import db as db_module
|
||||
|
||||
|
||||
app = FastAPI(title="saju-lab")
|
||||
|
||||
_origins = [o.strip() for o in CORS_ALLOW_ORIGINS.split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _init():
|
||||
db_module.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
app.include_router(saju.router)
|
||||
app.include_router(compat.router)
|
||||
63
saju-lab/app/models.py
Normal file
63
saju-lab/app/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""saju-lab Pydantic 모델."""
|
||||
from typing import List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --- Input ---
|
||||
|
||||
class SajuInterpretRequest(BaseModel):
|
||||
year: int = Field(..., ge=1900, le=2100)
|
||||
month: int = Field(..., ge=1, le=12)
|
||||
day: int = Field(..., ge=1, le=31)
|
||||
hour: Optional[int] = Field(None, ge=0, le=23)
|
||||
gender: Literal["male", "female"]
|
||||
calendar_type: Literal["solar", "lunar"] = "solar"
|
||||
is_leap_month: bool = False
|
||||
|
||||
|
||||
class CompatInterpretRequest(BaseModel):
|
||||
person_a: SajuInterpretRequest
|
||||
person_b: SajuInterpretRequest
|
||||
|
||||
|
||||
# --- Response ---
|
||||
|
||||
class SajuInterpretResponse(BaseModel):
|
||||
saju: dict
|
||||
analysis: dict
|
||||
daeun: List[dict]
|
||||
interpretation_json: dict
|
||||
reading_id: int
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
latency_ms: int
|
||||
reroll_count: int = 0
|
||||
|
||||
|
||||
class CompatInterpretResponse(BaseModel):
|
||||
saju_a: dict
|
||||
saju_b: dict
|
||||
score: int
|
||||
breakdown: dict
|
||||
interpretation_json: dict
|
||||
reading_id: int
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
latency_ms: int
|
||||
reroll_count: int = 0
|
||||
|
||||
|
||||
# --- CRUD ---
|
||||
|
||||
class SajuPatchRequest(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class CompatPatchRequest(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
memo: Optional[str] = None
|
||||
0
saju-lab/app/routers/__init__.py
Normal file
0
saju-lab/app/routers/__init__.py
Normal file
107
saju-lab/app/routers/compat.py
Normal file
107
saju-lab/app/routers/compat.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""compat API — /api/saju/compat/* 5 endpoints."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from ..models import (
|
||||
CompatInterpretRequest,
|
||||
CompatInterpretResponse,
|
||||
CompatPatchRequest,
|
||||
)
|
||||
from ..interpret import pipeline
|
||||
from ..calculator.core import calculate_saju
|
||||
from ..calculator.analysis import perform_full_analysis
|
||||
from ..calculator.compatibility import calculate_compatibility
|
||||
from ..calculator.lunar import lunar_to_solar
|
||||
from .. import db as db_module
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/saju/compat")
|
||||
|
||||
|
||||
def _calc_one(p) -> tuple[dict, dict]:
|
||||
"""한 사람의 입력 → (saju, analysis)."""
|
||||
if p.calendar_type == "lunar":
|
||||
sy, sm, sd = lunar_to_solar(p.year, p.month, p.day, p.is_leap_month)
|
||||
else:
|
||||
sy, sm, sd = p.year, p.month, p.day
|
||||
saju = calculate_saju(sy, sm, sd, p.hour, p.gender)
|
||||
analysis = perform_full_analysis(saju, 2026)
|
||||
return saju, analysis
|
||||
|
||||
|
||||
@router.post("/interpret", response_model=CompatInterpretResponse)
|
||||
async def interpret_compat_endpoint(req: CompatInterpretRequest):
|
||||
try:
|
||||
saju_a, analysis_a = _calc_one(req.person_a)
|
||||
saju_b, analysis_b = _calc_one(req.person_b)
|
||||
compat = calculate_compatibility(saju_a, saju_b)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"계산 실패: {e}")
|
||||
|
||||
try:
|
||||
interp_result = await pipeline.interpret_compat(
|
||||
saju_a, saju_b, analysis_a, analysis_b,
|
||||
compat["score"], compat["breakdown"],
|
||||
)
|
||||
except pipeline.SajuError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
rid = db_module.save_compat_record({
|
||||
"person_a": req.person_a.model_dump(),
|
||||
"person_b": req.person_b.model_dump(),
|
||||
"saju_a": saju_a, "saju_b": saju_b,
|
||||
"score": compat["score"],
|
||||
"breakdown": compat["breakdown"],
|
||||
"interpretation_json": interp_result["interpretation_json"],
|
||||
"model": interp_result["model"],
|
||||
"tokens_in": interp_result["tokens_in"],
|
||||
"tokens_out": interp_result["tokens_out"],
|
||||
"cost_usd": interp_result["cost_usd"],
|
||||
"latency_ms": interp_result["latency_ms"],
|
||||
"reroll_count": interp_result["reroll_count"],
|
||||
})
|
||||
|
||||
return {
|
||||
"saju_a": saju_a, "saju_b": saju_b,
|
||||
"score": compat["score"],
|
||||
"breakdown": compat["breakdown"],
|
||||
"interpretation_json": interp_result["interpretation_json"],
|
||||
"reading_id": rid,
|
||||
"model": interp_result["model"],
|
||||
"tokens_in": interp_result["tokens_in"],
|
||||
"tokens_out": interp_result["tokens_out"],
|
||||
"cost_usd": interp_result["cost_usd"],
|
||||
"latency_ms": interp_result["latency_ms"],
|
||||
"reroll_count": interp_result["reroll_count"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/readings")
|
||||
async def list_readings(page: int = 1, size: int = 20, favorite: Optional[bool] = None):
|
||||
return db_module.list_compat_records(page=page, size=size, favorite=favorite)
|
||||
|
||||
|
||||
@router.get("/readings/{reading_id}")
|
||||
async def get_reading(reading_id: int):
|
||||
row = db_module.get_compat_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/readings/{reading_id}")
|
||||
async def patch_reading(reading_id: int, req: CompatPatchRequest):
|
||||
row = db_module.get_compat_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
db_module.update_compat_record(reading_id, **req.model_dump(exclude_none=True))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/readings/{reading_id}")
|
||||
async def delete_reading(reading_id: int):
|
||||
row = db_module.get_compat_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
db_module.delete_compat_record(reading_id)
|
||||
return {"ok": True}
|
||||
122
saju-lab/app/routers/saju.py
Normal file
122
saju-lab/app/routers/saju.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""saju API — /api/saju/* 6 endpoints."""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
|
||||
from ..models import (
|
||||
SajuInterpretRequest,
|
||||
SajuInterpretResponse,
|
||||
SajuPatchRequest,
|
||||
)
|
||||
from ..interpret import pipeline
|
||||
from ..calculator.core import calculate_saju
|
||||
from ..calculator.analysis import perform_full_analysis
|
||||
from ..calculator.daeun import calculate_daeun
|
||||
from ..calculator.lunar import lunar_to_solar
|
||||
from ..config import SAJU_MODEL
|
||||
from .. import db as db_module
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/saju")
|
||||
|
||||
|
||||
@router.post("/interpret", response_model=SajuInterpretResponse)
|
||||
async def interpret_saju_endpoint(req: SajuInterpretRequest):
|
||||
"""사주 입력 → 계산 + AI 해석 + DB 저장."""
|
||||
# 음력 입력 시 양력 변환
|
||||
if req.calendar_type == "lunar":
|
||||
sy, sm, sd = lunar_to_solar(req.year, req.month, req.day, req.is_leap_month)
|
||||
else:
|
||||
sy, sm, sd = req.year, req.month, req.day
|
||||
|
||||
try:
|
||||
saju = calculate_saju(sy, sm, sd, req.hour, req.gender)
|
||||
analysis = perform_full_analysis(saju, 2026)
|
||||
daeun = calculate_daeun(sy, sm, sd, req.gender, saju["month"]["stem"], saju["month"]["branch"])
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"계산 실패: {e}")
|
||||
|
||||
try:
|
||||
interp_result = await pipeline.interpret_saju(saju, analysis, daeun, 2026)
|
||||
except pipeline.SajuError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
# DB 저장
|
||||
rid = db_module.save_saju_record({
|
||||
"birth_year": req.year, "birth_month": req.month, "birth_day": req.day,
|
||||
"birth_hour": req.hour, "gender": req.gender,
|
||||
"calendar_type": req.calendar_type,
|
||||
"saju_data": saju,
|
||||
"analysis_data": analysis,
|
||||
"daeun_data": daeun,
|
||||
"interpretation_json": interp_result["interpretation_json"],
|
||||
"model": interp_result["model"],
|
||||
"tokens_in": interp_result["tokens_in"],
|
||||
"tokens_out": interp_result["tokens_out"],
|
||||
"cost_usd": interp_result["cost_usd"],
|
||||
"latency_ms": interp_result["latency_ms"],
|
||||
"reroll_count": interp_result["reroll_count"],
|
||||
})
|
||||
|
||||
return {
|
||||
"saju": saju,
|
||||
"analysis": analysis,
|
||||
"daeun": daeun,
|
||||
"interpretation_json": interp_result["interpretation_json"],
|
||||
"reading_id": rid,
|
||||
"model": interp_result["model"],
|
||||
"tokens_in": interp_result["tokens_in"],
|
||||
"tokens_out": interp_result["tokens_out"],
|
||||
"cost_usd": interp_result["cost_usd"],
|
||||
"latency_ms": interp_result["latency_ms"],
|
||||
"reroll_count": interp_result["reroll_count"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/readings")
|
||||
async def list_readings(
|
||||
page: int = 1, size: int = 20,
|
||||
favorite: Optional[bool] = None,
|
||||
):
|
||||
return db_module.list_saju_records(page=page, size=size, favorite=favorite)
|
||||
|
||||
|
||||
@router.get("/readings/{reading_id}")
|
||||
async def get_reading(reading_id: int):
|
||||
row = db_module.get_saju_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/readings/{reading_id}")
|
||||
async def patch_reading(reading_id: int, req: SajuPatchRequest):
|
||||
row = db_module.get_saju_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
db_module.update_saju_record(reading_id, **req.model_dump(exclude_none=True))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/readings/{reading_id}")
|
||||
async def delete_reading(reading_id: int):
|
||||
row = db_module.get_saju_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
db_module.delete_saju_record(reading_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/current-fortune")
|
||||
async def current_fortune(reading_id: int = Query(...)):
|
||||
"""저장된 사주의 오늘 세운 (실시간 계산, AI 호출 없음)."""
|
||||
row = db_module.get_saju_record(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
|
||||
from datetime import datetime
|
||||
saju = row["saju_data"]
|
||||
current_year = datetime.now().year
|
||||
|
||||
from ..calculator.analysis import calculate_seun
|
||||
seun = calculate_seun(current_year, saju)
|
||||
return {"reading_id": reading_id, "year": current_year, "seun": seun}
|
||||
3
saju-lab/pytest.ini
Normal file
3
saju-lab/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
pythonpath = .
|
||||
8
saju-lab/requirements.txt
Normal file
8
saju-lab/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx>=0.27
|
||||
pydantic>=2.9
|
||||
sxtwl>=2.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
respx>=0.21
|
||||
0
saju-lab/tests/__init__.py
Normal file
0
saju-lab/tests/__init__.py
Normal file
0
saju-lab/tests/fixtures/__init__.py
vendored
Normal file
0
saju-lab/tests/fixtures/__init__.py
vendored
Normal file
58
saju-lab/tests/fixtures/generate_reference.ts
vendored
Normal file
58
saju-lab/tests/fixtures/generate_reference.ts
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
// saju-lab/tests/fixtures/generate_reference.ts
|
||||
// Usage (run from saju-web/ so solarlunar resolves):
|
||||
// cd C:/Users/jaeoh/Desktop/workspace/saju-web
|
||||
// npx -y tsx ../web-backend/saju-lab/tests/fixtures/generate_reference.ts > ../web-backend/saju-lab/tests/fixtures/reference_saju.json
|
||||
import { calculateSaju } from '../../../../saju-web/lib/saju-calculator';
|
||||
import { performFullAnalysis } from '../../../../saju-web/lib/ai-interpretation';
|
||||
import { calculateDaeun } from '../../../../saju-web/lib/daeun-calculator';
|
||||
|
||||
const CASES = [
|
||||
{ year: 1990, month: 5, day: 15, hour: 14, gender: 'male' as const },
|
||||
{ year: 1985, month: 1, day: 1, hour: 0, gender: 'female' as const },
|
||||
{ year: 2000, month: 2, day: 29, hour: 12, gender: 'male' as const },
|
||||
{ year: 1995, month: 2, day: 3, hour: 23, gender: 'female' as const },
|
||||
{ year: 1995, month: 2, day: 4, hour: 13, gender: 'male' as const },
|
||||
{ year: 1995, month: 2, day: 5, hour: 5, gender: 'female' as const },
|
||||
{ year: 1980, month: 6, day: 6, hour: 6, gender: 'male' as const },
|
||||
{ year: 1975, month: 11, day: 11, hour: 11, gender: 'female' as const },
|
||||
{ year: 2010, month: 12, day: 31, hour: 23, gender: 'male' as const },
|
||||
{ year: 1960, month: 4, day: 8, hour: 16, gender: 'female' as const },
|
||||
{ year: 1972, month: 7, day: 24, hour: 9, gender: 'male' as const },
|
||||
{ year: 1968, month: 10, day: 15, hour: 21, gender: 'female' as const },
|
||||
{ year: 1955, month: 3, day: 3, hour: 7, gender: 'male' as const },
|
||||
{ year: 1992, month: 8, day: 8, hour: 18, gender: 'female' as const },
|
||||
{ year: 1988, month: 9, day: 9, hour: 4, gender: 'male' as const },
|
||||
{ year: 1999, month: 12, day: 22, hour: 22, gender: 'female' as const },
|
||||
{ year: 2005, month: 6, day: 22, hour: 14, gender: 'male' as const },
|
||||
{ year: 2015, month: 3, day: 21, hour: 12, gender: 'female' as const },
|
||||
{ year: 2020, month: 9, day: 23, hour: 12, gender: 'male' as const },
|
||||
{ year: 1945, month: 8, day: 15, hour: 12, gender: 'male' as const },
|
||||
{ year: 1950, month: 6, day: 25, hour: 4, gender: 'male' as const },
|
||||
{ year: 1977, month: 7, day: 7, hour: 7, gender: 'female' as const },
|
||||
{ year: 1983, month: 11, day: 23, hour: 13, gender: 'male' as const },
|
||||
{ year: 1991, month: 4, day: 14, hour: 19, gender: 'female' as const },
|
||||
{ year: 1996, month: 5, day: 5, hour: 5, gender: 'male' as const },
|
||||
{ year: 2003, month: 10, day: 10, hour: 10, gender: 'female' as const },
|
||||
{ year: 2008, month: 8, day: 8, hour: 8, gender: 'male' as const },
|
||||
{ year: 2012, month: 12, day: 12, hour: 12, gender: 'female' as const },
|
||||
{ year: 1965, month: 1, day: 20, hour: 23, gender: 'male' as const },
|
||||
{ year: 1973, month: 7, day: 4, hour: 17, gender: 'female' as const },
|
||||
];
|
||||
|
||||
const CURRENT_YEAR = 2026;
|
||||
|
||||
const out = CASES.map((input) => {
|
||||
const saju = calculateSaju(input.year, input.month, input.day, input.hour, input.gender);
|
||||
const analysis = performFullAnalysis(saju, CURRENT_YEAR);
|
||||
const daeun = calculateDaeun(
|
||||
input.year,
|
||||
input.month,
|
||||
input.day,
|
||||
input.gender,
|
||||
saju.month.stem,
|
||||
saju.month.branch,
|
||||
);
|
||||
return { input, expected: { saju, analysis, daeun } };
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
10506
saju-lab/tests/fixtures/reference_saju.json
vendored
Normal file
10506
saju-lab/tests/fixtures/reference_saju.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
182
saju-lab/tests/test_analysis.py
Normal file
182
saju-lab/tests/test_analysis.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""analysis.py — 오행 점수/신강신약/용신/세운/perform_full_analysis 검증.
|
||||
|
||||
fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator.core import calculate_saju
|
||||
from app.calculator.analysis import (
|
||||
calculate_detailed_element_balance,
|
||||
calculate_element_score,
|
||||
analyze_day_master_strength,
|
||||
estimate_yongshin,
|
||||
calculate_seun,
|
||||
perform_full_analysis,
|
||||
)
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch.isupper():
|
||||
out.append("_" + ch.lower())
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize(d):
|
||||
"""TS camelCase → Python snake_case (deep). 값은 변경하지 않음."""
|
||||
if isinstance(d, dict):
|
||||
return {_camel_to_snake(k): _normalize(v) for k, v in d.items()}
|
||||
if isinstance(d, list):
|
||||
return [_normalize(x) for x in d]
|
||||
return d
|
||||
|
||||
|
||||
def _canonical(item):
|
||||
"""dict/list 비교용 — 안정 정렬을 위한 JSON 직렬화."""
|
||||
return json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
# ─── 1. element_balance reference 매칭 ───────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_element_balance_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected = case["expected"]["analysis"]["elementBalance"]
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = calculate_detailed_element_balance(saju)
|
||||
|
||||
for elem in ["木", "火", "土", "金", "水"]:
|
||||
assert abs(actual[elem] - expected[elem]) < 0.01, (
|
||||
f"{inp}: element_balance.{elem} differ: {actual[elem]} vs {expected[elem]}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 2. element_scores reference 매칭 ────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_element_score_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected = case["expected"]["analysis"]["elementScores"]
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = calculate_element_score(saju)
|
||||
|
||||
for elem, expected_pct in expected.items():
|
||||
actual_pct = actual.get(elem, 0)
|
||||
assert actual_pct == expected_pct, (
|
||||
f"{inp}: element_scores.{elem}: {actual_pct} vs {expected_pct}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 3. day_master_strength reference 매칭 ───────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_day_master_strength_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected = case["expected"]["analysis"]["dayMasterStrength"]
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = analyze_day_master_strength(saju)
|
||||
|
||||
assert actual["result"] == expected["result"], (
|
||||
f"{inp}: result mismatch: {actual['result']} vs {expected['result']}"
|
||||
)
|
||||
assert actual["score"] == expected["score"], (
|
||||
f"{inp}: score mismatch: {actual['score']} vs {expected['score']}"
|
||||
)
|
||||
assert actual["reasons"] == expected["reasons"], (
|
||||
f"{inp}: reasons mismatch:\n actual={actual['reasons']}\n expected={expected['reasons']}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 4. yong_shin reference 매칭 ─────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_yong_shin_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"]["yongShin"]
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
strength = analyze_day_master_strength(saju)
|
||||
actual = estimate_yongshin(saju, strength)
|
||||
|
||||
assert actual["yong_shin"] == expected["yong_shin"]
|
||||
assert actual["yong_shin_kr"] == expected["yong_shin_kr"]
|
||||
assert actual["hee_shin"] == expected["hee_shin"]
|
||||
assert actual["hee_shin_kr"] == expected["hee_shin_kr"]
|
||||
assert actual["gi_shin"] == expected["gi_shin"]
|
||||
assert actual["gi_shin_kr"] == expected["gi_shin_kr"]
|
||||
assert actual["explanation"] == expected["explanation"]
|
||||
|
||||
|
||||
# ─── 5. seun reference 매칭 ─────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_seun_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"]["seun"]
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = calculate_seun(2026, saju)
|
||||
|
||||
assert actual["stem"] == expected["stem"]
|
||||
assert actual["branch"] == expected["branch"]
|
||||
assert actual["stem_kr"] == expected["stem_kr"]
|
||||
assert actual["branch_kr"] == expected["branch_kr"]
|
||||
assert actual["element"] == expected["element"]
|
||||
assert actual["element_kr"] == expected["element_kr"]
|
||||
assert actual["year"] == expected["year"]
|
||||
# interactions — 순서 무관 비교
|
||||
actual_iter = actual.get("interactions", [])
|
||||
expected_iter = expected.get("interactions", [])
|
||||
assert sorted(map(_canonical, actual_iter)) == sorted(map(_canonical, expected_iter)), (
|
||||
f"{inp}: seun interactions mismatch:\n actual={actual_iter}\n expected={expected_iter}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 6. perform_full_analysis 통합 ───────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_perform_full_analysis_keys(case):
|
||||
"""perform_full_analysis 가 모든 expected 키를 반환하는지 확인."""
|
||||
inp = case["input"]
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = perform_full_analysis(saju, 2026)
|
||||
|
||||
# 통합 결과에 필수 키 모두 존재
|
||||
for key in [
|
||||
"element_balance", "element_scores", "day_master_strength", "yong_shin",
|
||||
"branch_interactions", "shinsal", "gongmang", "seun", "hidden_stems",
|
||||
]:
|
||||
assert key in actual, f"{inp}: missing key {key}"
|
||||
174
saju-lab/tests/test_compatibility.py
Normal file
174
saju-lab/tests/test_compatibility.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""compatibility.py — 두 사주 궁합 점수 + breakdown 검증.
|
||||
|
||||
saju-web/app/compatibility/result/page.tsx의 calculateCompatibility() 로직과 1:1 매핑.
|
||||
알고리즘:
|
||||
score = 50 (base)
|
||||
+ (same: +10 / produce|produced: +25 / overcome|overcomed: -10)
|
||||
+ (six_harmony: +20)
|
||||
+ (three_harmony: +15)
|
||||
+ (conflict: -20)
|
||||
clamp(0, 100)
|
||||
"""
|
||||
from app.calculator.core import calculate_saju
|
||||
from app.calculator.compatibility import (
|
||||
calculate_compatibility,
|
||||
_get_element_relation,
|
||||
_get_branch_relation,
|
||||
)
|
||||
|
||||
|
||||
# ─── 단위 헬퍼 테스트 ─────────────────────────────────────────────
|
||||
def test_element_relation_same():
|
||||
assert _get_element_relation("金", "金") == "same"
|
||||
|
||||
|
||||
def test_element_relation_produce():
|
||||
# 木 → 火 (목생화): el1 produces el2
|
||||
assert _get_element_relation("木", "火") == "produce"
|
||||
|
||||
|
||||
def test_element_relation_produced():
|
||||
# 火 ← 木: el1 is produced by el2
|
||||
assert _get_element_relation("火", "木") == "produced"
|
||||
|
||||
|
||||
def test_element_relation_overcome():
|
||||
# 金 → 木 (금극목): el1 overcomes el2
|
||||
assert _get_element_relation("金", "木") == "overcome"
|
||||
|
||||
|
||||
def test_element_relation_overcomed():
|
||||
# 木 ← 金: el1 is overcomed by el2
|
||||
assert _get_element_relation("木", "金") == "overcomed"
|
||||
|
||||
|
||||
def test_branch_six_harmony():
|
||||
# 寅亥 6합
|
||||
rel = _get_branch_relation("寅", "亥")
|
||||
assert rel["six_harmony"] is True
|
||||
assert rel["conflict"] is False
|
||||
|
||||
|
||||
def test_branch_three_harmony():
|
||||
# 亥卯未 3합 — 亥+卯
|
||||
rel = _get_branch_relation("亥", "卯")
|
||||
assert rel["three_harmony"] is True
|
||||
|
||||
|
||||
def test_branch_conflict():
|
||||
# 子午 충
|
||||
rel = _get_branch_relation("子", "午")
|
||||
assert rel["conflict"] is True
|
||||
assert rel["six_harmony"] is False
|
||||
|
||||
|
||||
def test_branch_same_not_three_harmony():
|
||||
# 같은 지지는 3합 아님 (서로 다른 지지여야 합)
|
||||
rel = _get_branch_relation("未", "未")
|
||||
assert rel["three_harmony"] is False
|
||||
assert rel["six_harmony"] is False
|
||||
assert rel["conflict"] is False
|
||||
|
||||
|
||||
# ─── 통합: calculate_compatibility ────────────────────────────────
|
||||
def test_score_range_0_to_100():
|
||||
"""모든 결과가 0~100 범위 + breakdown 키 존재."""
|
||||
a = calculate_saju(1990, 5, 15, 14, "male")
|
||||
b = calculate_saju(1992, 8, 8, 18, "female")
|
||||
result = calculate_compatibility(a, b)
|
||||
assert 0 <= result["score"] <= 100
|
||||
assert "breakdown" in result
|
||||
bd = result["breakdown"]
|
||||
assert "day_master_element" in bd
|
||||
assert "branch_interaction" in bd
|
||||
assert "base" in bd
|
||||
assert bd["base"] == 50
|
||||
|
||||
|
||||
def test_same_saju_same_element_no_branch_interaction():
|
||||
"""1990-5-15 male+female (둘 다 辛未/金) — same element, 같은 일지(중립) → 50+10+0 = 60."""
|
||||
a = calculate_saju(1990, 5, 15, 14, "male")
|
||||
b = calculate_saju(1990, 5, 15, 14, "female")
|
||||
result = calculate_compatibility(a, b)
|
||||
assert result["score"] == 60
|
||||
assert result["breakdown"]["day_master_element"]["relation"] == "same"
|
||||
assert result["breakdown"]["day_master_element"]["score"] == 10
|
||||
assert result["breakdown"]["branch_interaction"]["score"] == 0
|
||||
|
||||
|
||||
def test_six_harmony_plus_produce_high_score():
|
||||
"""1985-1-1 (辛卯/金) + 1989-7-10 (壬戌/水)
|
||||
→ 金生水 (produce) +25, 卯戌 6합 +20 → 50+25+20 = 95.
|
||||
"""
|
||||
a = calculate_saju(1985, 1, 1, 12, "male")
|
||||
b = calculate_saju(1989, 7, 10, 12, "male")
|
||||
result = calculate_compatibility(a, b)
|
||||
assert result["score"] == 95
|
||||
bd = result["breakdown"]
|
||||
assert bd["day_master_element"]["relation"] == "produce"
|
||||
assert bd["day_master_element"]["score"] == 25
|
||||
assert bd["branch_interaction"]["flags"]["six_harmony"] is True
|
||||
assert bd["branch_interaction"]["score"] == 20
|
||||
|
||||
|
||||
def test_conflict_plus_overcome_low_score():
|
||||
"""1988-12-1 (辛巳/金) + 1992-3-21 (丁亥/火)
|
||||
→ 火→金 (overcomed) -10, 巳亥 충 -20 → 50-10-20 = 20.
|
||||
"""
|
||||
a = calculate_saju(1988, 12, 1, 8, "male")
|
||||
b = calculate_saju(1992, 3, 21, 12, "female")
|
||||
result = calculate_compatibility(a, b)
|
||||
assert result["score"] == 20
|
||||
bd = result["breakdown"]
|
||||
assert bd["day_master_element"]["relation"] in ("overcome", "overcomed")
|
||||
assert bd["day_master_element"]["score"] == -10
|
||||
assert bd["branch_interaction"]["flags"]["conflict"] is True
|
||||
assert bd["branch_interaction"]["score"] == -20
|
||||
|
||||
|
||||
def test_three_harmony_plus_overcome():
|
||||
"""1990-5-15 (辛未/金) + 1992-3-21 (丁亥/火)
|
||||
→ 火→金 (overcomed) -10, 亥未 3합 +15 → 50-10+15 = 55.
|
||||
"""
|
||||
a = calculate_saju(1990, 5, 15, 14, "male")
|
||||
b = calculate_saju(1992, 3, 21, 12, "female")
|
||||
result = calculate_compatibility(a, b)
|
||||
assert result["score"] == 55
|
||||
bd = result["breakdown"]
|
||||
assert bd["day_master_element"]["score"] == -10
|
||||
assert bd["branch_interaction"]["flags"]["three_harmony"] is True
|
||||
assert bd["branch_interaction"]["score"] == 15
|
||||
|
||||
|
||||
def test_score_clamp_at_zero():
|
||||
"""극단 케이스: 상극(-10) + 충(-20) = 50-30 = 20. clamp 동작 검증을 위한 sanity."""
|
||||
a = calculate_saju(1988, 12, 1, 8, "male")
|
||||
b = calculate_saju(1992, 3, 21, 12, "female")
|
||||
result = calculate_compatibility(a, b)
|
||||
assert result["score"] >= 0
|
||||
assert result["score"] <= 100
|
||||
|
||||
|
||||
def test_breakdown_descriptions_non_empty():
|
||||
"""breakdown의 description 필드가 비어있지 않음."""
|
||||
a = calculate_saju(1990, 5, 15, 14, "male")
|
||||
b = calculate_saju(1995, 8, 20, 12, "female")
|
||||
result = calculate_compatibility(a, b)
|
||||
bd = result["breakdown"]
|
||||
assert isinstance(bd["day_master_element"]["description"], str)
|
||||
assert len(bd["day_master_element"]["description"]) > 0
|
||||
assert isinstance(bd["branch_interaction"]["description"], str)
|
||||
assert len(bd["branch_interaction"]["description"]) > 0
|
||||
|
||||
|
||||
def test_symmetry_score_only():
|
||||
"""A vs B와 B vs A의 score는 동일 (saju-web과 동일하게 대칭).
|
||||
|
||||
note: relation 태그(produce vs produced)는 비대칭일 수 있으나
|
||||
delta 점수는 동일하므로 최종 score는 항상 같다.
|
||||
"""
|
||||
a = calculate_saju(1985, 1, 1, 12, "male")
|
||||
b = calculate_saju(1989, 7, 10, 12, "male")
|
||||
r_ab = calculate_compatibility(a, b)
|
||||
r_ba = calculate_compatibility(b, a)
|
||||
assert r_ab["score"] == r_ba["score"]
|
||||
34
saju-lab/tests/test_constants.py
Normal file
34
saju-lab/tests/test_constants.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from app.calculator import constants
|
||||
|
||||
|
||||
def test_heavenly_stems_10():
|
||||
assert len(constants.HEAVENLY_STEMS) == 10
|
||||
assert constants.HEAVENLY_STEMS[0] == "甲"
|
||||
assert constants.HEAVENLY_STEMS[9] == "癸"
|
||||
|
||||
|
||||
def test_earthly_branches_12():
|
||||
assert len(constants.EARTHLY_BRANCHES) == 12
|
||||
assert constants.EARTHLY_BRANCHES[0] == "子"
|
||||
assert constants.EARTHLY_BRANCHES[11] == "亥"
|
||||
|
||||
|
||||
def test_five_elements_mapping():
|
||||
assert constants.FIVE_ELEMENTS["甲"] == "木"
|
||||
assert constants.FIVE_ELEMENTS["丁"] == "火"
|
||||
assert constants.FIVE_ELEMENTS["亥"] == "水"
|
||||
assert constants.FIVE_ELEMENTS["申"] == "金"
|
||||
assert constants.FIVE_ELEMENTS["未"] == "土"
|
||||
|
||||
|
||||
def test_hidden_stems():
|
||||
assert constants.HIDDEN_STEMS["子"] == ["癸"]
|
||||
assert constants.HIDDEN_STEMS["丑"] == ["己", "癸", "辛"]
|
||||
assert constants.HIDDEN_STEMS["寅"] == ["甲", "丙", "戊"]
|
||||
|
||||
|
||||
def test_yang_yin_stems():
|
||||
assert constants.IS_YANG_STEM["甲"] is True
|
||||
assert constants.IS_YANG_STEM["乙"] is False
|
||||
assert constants.IS_YANG_STEM["庚"] is True
|
||||
assert constants.IS_YANG_STEM["辛"] is False
|
||||
78
saju-lab/tests/test_core.py
Normal file
78
saju-lab/tests/test_core.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""calculator/core.py — saju-web TS 엔진과 1:1 매칭 검증.
|
||||
|
||||
fixtures/reference_saju.json 의 30 case (TS calculateSaju 결과)와 Python 구현 일치 여부 검증.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator.core import calculate_saju
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch.isupper():
|
||||
out.append("_" + ch.lower())
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize(d):
|
||||
"""TS camelCase → Python snake_case 변환 (deep)."""
|
||||
if isinstance(d, dict):
|
||||
return {_camel_to_snake(k): _normalize(v) for k, v in d.items()}
|
||||
if isinstance(d, list):
|
||||
return [_normalize(x) for x in d]
|
||||
return d
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}-{c['input'].get('hour')}-{c['input']['gender']}",
|
||||
)
|
||||
def test_calculate_saju_matches_reference(case):
|
||||
inp = case["input"]
|
||||
expected = _normalize(case["expected"]["saju"])
|
||||
|
||||
actual = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"],
|
||||
inp.get("hour"), inp["gender"],
|
||||
)
|
||||
|
||||
# 4기둥 비교
|
||||
for pillar in ["year", "month", "day", "hour"]:
|
||||
if expected.get(pillar) is None:
|
||||
assert actual.get(pillar) is None, f"{pillar} should be None"
|
||||
continue
|
||||
if pillar == "hour" and inp.get("hour") is None:
|
||||
assert actual.get(pillar) is None
|
||||
continue
|
||||
ep = expected[pillar]
|
||||
ap = actual[pillar]
|
||||
for field in ["stem", "branch", "stem_kr", "branch_kr", "element", "ten_god", "fortune"]:
|
||||
assert ap[field] == ep[field], (
|
||||
f"{pillar}.{field}: actual={ap[field]} expected={ep[field]} "
|
||||
f"(input={inp})"
|
||||
)
|
||||
|
||||
# day_stem
|
||||
assert actual["day_stem"] == expected["day_stem"]
|
||||
|
||||
# gender
|
||||
assert actual["gender"] == expected["gender"]
|
||||
|
||||
# birth_date
|
||||
bd = actual["birth_date"]
|
||||
assert bd["year"] == inp["year"]
|
||||
assert bd["month"] == inp["month"]
|
||||
assert bd["day"] == inp["day"]
|
||||
if inp.get("hour") is not None:
|
||||
assert bd.get("hour") == inp["hour"]
|
||||
107
saju-lab/tests/test_daeun.py
Normal file
107
saju-lab/tests/test_daeun.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""daeun.py — 대운 8개 계산 검증.
|
||||
|
||||
fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator.core import calculate_saju
|
||||
from app.calculator.daeun import calculate_daeun, get_current_daeun, get_daeun_description
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch.isupper():
|
||||
out.append("_" + ch.lower())
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize(d):
|
||||
"""TS camelCase → Python snake_case (deep)."""
|
||||
if isinstance(d, dict):
|
||||
return {_camel_to_snake(k): _normalize(v) for k, v in d.items()}
|
||||
if isinstance(d, list):
|
||||
return [_normalize(x) for x in d]
|
||||
return d
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}-{c['input']['gender']}",
|
||||
)
|
||||
def test_calculate_daeun_matches_reference(case):
|
||||
inp = case["input"]
|
||||
expected = [_normalize(x) for x in case["expected"]["daeun"]]
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = calculate_daeun(
|
||||
inp["year"],
|
||||
inp["month"],
|
||||
inp["day"],
|
||||
inp["gender"],
|
||||
saju["month"]["stem"],
|
||||
saju["month"]["branch"],
|
||||
)
|
||||
|
||||
assert len(actual) == len(expected), (
|
||||
f"len mismatch for {inp}: {len(actual)} vs {len(expected)}"
|
||||
)
|
||||
|
||||
for i, (a, e) in enumerate(zip(actual, expected)):
|
||||
for key in ("stem", "branch", "stem_kr", "branch_kr"):
|
||||
assert a.get(key) == e.get(key), (
|
||||
f"{inp} daeun[{i}].{key}: actual={a.get(key)} expected={e.get(key)}"
|
||||
)
|
||||
# age + start_year/end_year
|
||||
assert a.get("age") == e.get("age"), (
|
||||
f"{inp} daeun[{i}].age: {a.get('age')} vs {e.get('age')}"
|
||||
)
|
||||
assert a.get("start_year") == e.get("start_year"), (
|
||||
f"{inp} daeun[{i}].start_year: {a.get('start_year')} vs {e.get('start_year')}"
|
||||
)
|
||||
assert a.get("end_year") == e.get("end_year"), (
|
||||
f"{inp} daeun[{i}].end_year: {a.get('end_year')} vs {e.get('end_year')}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_current_daeun_returns_match():
|
||||
daeun_list = [
|
||||
{"age": 10, "start_year": 2000, "end_year": 2009},
|
||||
{"age": 20, "start_year": 2010, "end_year": 2019},
|
||||
{"age": 30, "start_year": 2020, "end_year": 2029},
|
||||
]
|
||||
assert get_current_daeun(daeun_list, 2005) == daeun_list[0]
|
||||
assert get_current_daeun(daeun_list, 2015) == daeun_list[1]
|
||||
assert get_current_daeun(daeun_list, 2025) == daeun_list[2]
|
||||
assert get_current_daeun(daeun_list, 1999) is None
|
||||
assert get_current_daeun(daeun_list, 2030) is None
|
||||
|
||||
|
||||
def test_get_daeun_description_returns_string():
|
||||
daeun = {
|
||||
"age": 10,
|
||||
"start_year": 2000,
|
||||
"end_year": 2009,
|
||||
"stem": "壬",
|
||||
"branch": "午",
|
||||
"stem_kr": "임",
|
||||
"branch_kr": "오",
|
||||
}
|
||||
desc = get_daeun_description(daeun, "辛")
|
||||
assert isinstance(desc, str)
|
||||
assert "임오" in desc
|
||||
assert "壬午" in desc
|
||||
assert "10세" in desc
|
||||
assert "19세" in desc
|
||||
142
saju-lab/tests/test_db.py
Normal file
142
saju-lab/tests/test_db.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import pytest
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
db_file = tmp_path / "test_saju.db"
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
yield
|
||||
try:
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
# --- saju_records 5 tests ---
|
||||
|
||||
def test_saju_save_and_get():
|
||||
rid = db_module.save_saju_record({
|
||||
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": 14,
|
||||
"gender": "male", "calendar_type": "solar",
|
||||
"saju_data": {"day_stem": "辛"},
|
||||
"analysis_data": {"element_balance": {"金": 3.0}},
|
||||
"daeun_data": [{"age": 10}],
|
||||
"interpretation_json": {"items": []},
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
||||
})
|
||||
assert rid > 0
|
||||
row = db_module.get_saju_record(rid)
|
||||
assert row["birth_year"] == 1990
|
||||
assert row["saju_data"]["day_stem"] == "辛"
|
||||
|
||||
|
||||
def test_saju_list_with_favorite():
|
||||
for f in [0, 1, 0]:
|
||||
db_module.save_saju_record({
|
||||
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
|
||||
"gender": "male",
|
||||
"saju_data": {}, "analysis_data": {}, "daeun_data": [],
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0,
|
||||
})
|
||||
# set favorites
|
||||
rows = db_module.list_saju_records()["items"]
|
||||
db_module.update_saju_record(rows[0]["id"], favorite=True)
|
||||
res = db_module.list_saju_records(favorite=True)
|
||||
assert res["total"] == 1
|
||||
|
||||
|
||||
def test_saju_update_favorite_and_memo():
|
||||
rid = db_module.save_saju_record({
|
||||
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
|
||||
"gender": "male",
|
||||
"saju_data": {}, "analysis_data": {}, "daeun_data": [],
|
||||
"model": "x",
|
||||
})
|
||||
db_module.update_saju_record(rid, favorite=True, memo="좋은 사주")
|
||||
row = db_module.get_saju_record(rid)
|
||||
assert row["favorite"] == 1
|
||||
assert row["memo"] == "좋은 사주"
|
||||
|
||||
|
||||
def test_saju_delete():
|
||||
rid = db_module.save_saju_record({
|
||||
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
|
||||
"gender": "male",
|
||||
"saju_data": {}, "analysis_data": {}, "daeun_data": [],
|
||||
"model": "x",
|
||||
})
|
||||
db_module.delete_saju_record(rid)
|
||||
assert db_module.get_saju_record(rid) is None
|
||||
|
||||
|
||||
def test_saju_get_nonexistent():
|
||||
assert db_module.get_saju_record(9999) is None
|
||||
|
||||
|
||||
# --- compat_records 5 tests ---
|
||||
|
||||
def test_compat_save_and_get():
|
||||
rid = db_module.save_compat_record({
|
||||
"person_a": {"year": 1990},
|
||||
"person_b": {"year": 1992},
|
||||
"saju_a": {"day_stem": "辛"},
|
||||
"saju_b": {"day_stem": "丁"},
|
||||
"score": 85,
|
||||
"breakdown": {"day_master_element": {"score": 25}},
|
||||
"interpretation_json": {"summary": "좋음"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 200, "tokens_out": 300, "cost_usd": 0.01,
|
||||
})
|
||||
assert rid > 0
|
||||
row = db_module.get_compat_record(rid)
|
||||
assert row["score"] == 85
|
||||
assert row["breakdown"]["day_master_element"]["score"] == 25
|
||||
|
||||
|
||||
def test_compat_list_with_favorite():
|
||||
for _ in range(3):
|
||||
db_module.save_compat_record({
|
||||
"person_a": {}, "person_b": {},
|
||||
"saju_a": {}, "saju_b": {},
|
||||
"score": 50,
|
||||
"breakdown": {},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0,
|
||||
})
|
||||
rows = db_module.list_compat_records()["items"]
|
||||
db_module.update_compat_record(rows[0]["id"], favorite=True)
|
||||
res = db_module.list_compat_records(favorite=True)
|
||||
assert res["total"] == 1
|
||||
|
||||
|
||||
def test_compat_update_favorite_and_memo():
|
||||
rid = db_module.save_compat_record({
|
||||
"person_a": {}, "person_b": {},
|
||||
"saju_a": {}, "saju_b": {},
|
||||
"score": 70,
|
||||
"breakdown": {},
|
||||
"model": "x",
|
||||
})
|
||||
db_module.update_compat_record(rid, favorite=True, memo="궁합 좋음")
|
||||
row = db_module.get_compat_record(rid)
|
||||
assert row["favorite"] == 1
|
||||
assert row["memo"] == "궁합 좋음"
|
||||
|
||||
|
||||
def test_compat_delete():
|
||||
rid = db_module.save_compat_record({
|
||||
"person_a": {}, "person_b": {},
|
||||
"saju_a": {}, "saju_b": {},
|
||||
"score": 50,
|
||||
"breakdown": {},
|
||||
"model": "x",
|
||||
})
|
||||
db_module.delete_compat_record(rid)
|
||||
assert db_module.get_compat_record(rid) is None
|
||||
|
||||
|
||||
def test_compat_get_nonexistent():
|
||||
assert db_module.get_compat_record(9999) is None
|
||||
34
saju-lab/tests/test_lunar.py
Normal file
34
saju-lab/tests/test_lunar.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""음력↔양력 변환 테스트 (sxtwl 기반)."""
|
||||
from app.calculator import lunar
|
||||
|
||||
|
||||
def test_solar_to_lunar_known_date():
|
||||
# 2024년 추석 = 양력 2024-09-17 = 음력 2024-08-15
|
||||
result = lunar.solar_to_lunar(2024, 9, 17)
|
||||
assert result["year"] == 2024
|
||||
assert result["month"] == 8
|
||||
assert result["day"] == 15
|
||||
assert result["is_leap"] is False
|
||||
|
||||
|
||||
def test_lunar_to_solar_known_date():
|
||||
# 음력 2024-08-15 → 양력 2024-09-17
|
||||
result = lunar.lunar_to_solar(2024, 8, 15, is_leap=False)
|
||||
assert result == (2024, 9, 17)
|
||||
|
||||
|
||||
def test_solar_to_lunar_new_years():
|
||||
# 양력 2024-01-01 → 음력 2023-11-20 (음력 새해는 보통 1~2월)
|
||||
result = lunar.solar_to_lunar(2024, 1, 1)
|
||||
assert result["year"] in (2023, 2024)
|
||||
assert result["month"] in range(1, 13)
|
||||
|
||||
|
||||
def test_roundtrip():
|
||||
# 양력 → 음력 → 양력 = 원본
|
||||
solar_input = (1990, 5, 15)
|
||||
lunar_mid = lunar.solar_to_lunar(*solar_input)
|
||||
solar_back = lunar.lunar_to_solar(
|
||||
lunar_mid["year"], lunar_mid["month"], lunar_mid["day"], lunar_mid["is_leap"]
|
||||
)
|
||||
assert solar_back == solar_input
|
||||
154
saju-lab/tests/test_pipeline.py
Normal file
154
saju-lab/tests/test_pipeline.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import json
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from app.interpret import pipeline
|
||||
from app.interpret.pipeline import SajuError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_key(monkeypatch):
|
||||
monkeypatch.setattr(pipeline, "ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
|
||||
SAJU_ITEM_KEYS = [
|
||||
"기질", "오행밸런스", "지지상호작용", "신살영향",
|
||||
"재물운", "직업적성", "애정운", "건강운",
|
||||
"현재대운", "올해세운", "인생황금기", "종합조언",
|
||||
]
|
||||
|
||||
|
||||
def _valid_saju_response():
|
||||
items = []
|
||||
for k in SAJU_ITEM_KEYS:
|
||||
items.append({
|
||||
"key": k, "title": "...", "content": "...",
|
||||
"evidence": {"saju_element": "...", "reasoning": "..."}
|
||||
})
|
||||
return {
|
||||
"items": items,
|
||||
"summary": "...",
|
||||
"advice": "...",
|
||||
"warning": None,
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
|
||||
def _valid_compat_response():
|
||||
return {
|
||||
"summary": "...",
|
||||
"strengths": [
|
||||
{"title": "오행 상생", "explanation": "...", "evidence": "..."},
|
||||
{"title": "...", "explanation": "...", "evidence": "..."},
|
||||
],
|
||||
"challenges": [
|
||||
{"title": "...", "explanation": "...", "evidence": "..."},
|
||||
{"title": "...", "explanation": "...", "evidence": "..."},
|
||||
],
|
||||
"advice": "...",
|
||||
"warning": None,
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
|
||||
def _claude_envelope(text: str, in_tok=200, out_tok=400):
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||
}
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_saju_interpret_success():
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(json.dumps(_valid_saju_response())))
|
||||
)
|
||||
result = await pipeline.interpret_saju(
|
||||
saju={"day_stem": "辛"},
|
||||
analysis={"element_balance": {"金": 3.0}},
|
||||
daeun=[{"age": 10}],
|
||||
current_year=2026,
|
||||
)
|
||||
assert result["reroll_count"] == 0
|
||||
assert result["tokens_in"] == 200
|
||||
assert result["cost_usd"] > 0
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_saju_codeblock_stripped():
|
||||
text = "```json\n" + json.dumps(_valid_saju_response()) + "\n```"
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(text))
|
||||
)
|
||||
result = await pipeline.interpret_saju(saju={}, analysis={}, daeun=[], current_year=2026)
|
||||
assert "interpretation_json" in result
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_saju_reroll_then_success():
|
||||
valid = json.dumps(_valid_saju_response())
|
||||
invalid = json.dumps({"items": [], "summary": "...", "advice": "", "confidence": "medium"})
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
side_effect=[
|
||||
httpx.Response(200, json=_claude_envelope(invalid)),
|
||||
httpx.Response(200, json=_claude_envelope(valid)),
|
||||
]
|
||||
)
|
||||
result = await pipeline.interpret_saju(saju={}, analysis={}, daeun=[], current_year=2026)
|
||||
assert result["reroll_count"] == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_saju_reroll_fail_raises():
|
||||
invalid = json.dumps({"items": [], "summary": "...", "advice": "", "confidence": "medium"})
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(invalid))
|
||||
)
|
||||
with pytest.raises(SajuError):
|
||||
await pipeline.interpret_saju(saju={}, analysis={}, daeun=[], current_year=2026)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_saju_http_error():
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(500, text="boom")
|
||||
)
|
||||
with pytest.raises(SajuError):
|
||||
await pipeline.interpret_saju(saju={}, analysis={}, daeun=[], current_year=2026)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_compat_interpret_success():
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(json.dumps(_valid_compat_response())))
|
||||
)
|
||||
result = await pipeline.interpret_compat(
|
||||
saju_a={"day_stem": "辛"}, saju_b={"day_stem": "丁"},
|
||||
analysis_a={}, analysis_b={},
|
||||
score=85, breakdown={"day_master_element": {"score": 25}},
|
||||
)
|
||||
assert result["reroll_count"] == 0
|
||||
assert "interpretation_json" in result
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_compat_reroll_then_success():
|
||||
valid = json.dumps(_valid_compat_response())
|
||||
invalid = json.dumps({"summary": "...", "strengths": [], "challenges": [], "advice": "", "confidence": "high"})
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
side_effect=[
|
||||
httpx.Response(200, json=_claude_envelope(invalid)),
|
||||
httpx.Response(200, json=_claude_envelope(valid)),
|
||||
]
|
||||
)
|
||||
result = await pipeline.interpret_compat(
|
||||
saju_a={}, saju_b={}, analysis_a={}, analysis_b={},
|
||||
score=50, breakdown={},
|
||||
)
|
||||
assert result["reroll_count"] == 1
|
||||
|
||||
|
||||
def test_calc_cost():
|
||||
cost = pipeline.calc_cost(1_000_000, 1_000_000)
|
||||
assert cost == pipeline.SAJU_COST_INPUT_PER_M + pipeline.SAJU_COST_OUTPUT_PER_M
|
||||
220
saju-lab/tests/test_routes.py
Normal file
220
saju-lab/tests/test_routes.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
db_file = tmp_path / "test_saju.db"
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
yield
|
||||
try:
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def _interpret_result(interp_json=None):
|
||||
if interp_json is None:
|
||||
interp_json = {"items": [], "summary": "...", "advice": "...", "warning": None, "confidence": "medium"}
|
||||
return {
|
||||
"interpretation_json": interp_json,
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 200, "tokens_out": 400, "cost_usd": 0.01,
|
||||
"latency_ms": 1200, "reroll_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_health():
|
||||
with TestClient(app) as c:
|
||||
r = c.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_saju_interpret_endpoint(monkeypatch):
|
||||
"""saju interpret이 pipeline mock으로 동작."""
|
||||
async def fake_interpret(*args, **kwargs):
|
||||
return _interpret_result()
|
||||
|
||||
# interpret_saju를 mock
|
||||
from app.routers import saju as saju_router
|
||||
monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)
|
||||
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/saju/interpret", json={
|
||||
"year": 1990, "month": 5, "day": 15, "hour": 14,
|
||||
"gender": "male", "calendar_type": "solar"
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert "saju" in data
|
||||
assert "analysis" in data
|
||||
assert "daeun" in data
|
||||
assert "reading_id" in data
|
||||
assert data["reading_id"] > 0
|
||||
|
||||
|
||||
def test_saju_list_get_cycle(monkeypatch):
|
||||
async def fake_interpret(*args, **kwargs):
|
||||
return _interpret_result()
|
||||
from app.routers import saju as saju_router
|
||||
monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)
|
||||
|
||||
with TestClient(app) as c:
|
||||
# save
|
||||
rid = c.post("/api/saju/interpret", json={
|
||||
"year": 1990, "month": 5, "day": 15, "hour": 14,
|
||||
"gender": "male", "calendar_type": "solar"
|
||||
}).json()["reading_id"]
|
||||
# list
|
||||
assert c.get("/api/saju/readings").json()["total"] == 1
|
||||
# get
|
||||
r = c.get(f"/api/saju/readings/{rid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["birth_year"] == 1990
|
||||
|
||||
|
||||
def test_saju_patch_and_delete(monkeypatch):
|
||||
async def fake_interpret(*args, **kwargs):
|
||||
return _interpret_result()
|
||||
from app.routers import saju as saju_router
|
||||
monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)
|
||||
|
||||
with TestClient(app) as c:
|
||||
rid = c.post("/api/saju/interpret", json={
|
||||
"year": 1990, "month": 5, "day": 15, "hour": 14,
|
||||
"gender": "male", "calendar_type": "solar"
|
||||
}).json()["reading_id"]
|
||||
|
||||
r = c.patch(f"/api/saju/readings/{rid}", json={"favorite": True, "memo": "메모"})
|
||||
assert r.status_code == 200
|
||||
row = c.get(f"/api/saju/readings/{rid}").json()
|
||||
assert row["favorite"] == 1
|
||||
assert row["memo"] == "메모"
|
||||
|
||||
r = c.delete(f"/api/saju/readings/{rid}")
|
||||
assert r.status_code == 200
|
||||
assert c.get(f"/api/saju/readings/{rid}").status_code == 404
|
||||
|
||||
|
||||
def test_saju_get_404():
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/saju/readings/9999").status_code == 404
|
||||
|
||||
|
||||
def test_saju_current_fortune(monkeypatch):
|
||||
async def fake_interpret(*args, **kwargs):
|
||||
return _interpret_result()
|
||||
from app.routers import saju as saju_router
|
||||
monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)
|
||||
|
||||
with TestClient(app) as c:
|
||||
rid = c.post("/api/saju/interpret", json={
|
||||
"year": 1990, "month": 5, "day": 15, "hour": 14,
|
||||
"gender": "male", "calendar_type": "solar"
|
||||
}).json()["reading_id"]
|
||||
r = c.get(f"/api/saju/current-fortune?reading_id={rid}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "seun" in data
|
||||
assert data["seun"]["stem"]
|
||||
|
||||
|
||||
def test_compat_interpret(monkeypatch):
|
||||
async def fake_compat(*args, **kwargs):
|
||||
return {
|
||||
"interpretation_json": {"summary": "...", "strengths": [
|
||||
{"title": "A", "explanation": "B", "evidence": "C"},
|
||||
{"title": "A2", "explanation": "B2", "evidence": "C2"},
|
||||
], "challenges": [
|
||||
{"title": "D", "explanation": "E", "evidence": "F"},
|
||||
{"title": "D2", "explanation": "E2", "evidence": "F2"},
|
||||
], "advice": "...", "warning": None, "confidence": "high"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 300, "tokens_out": 500, "cost_usd": 0.015,
|
||||
"latency_ms": 1500, "reroll_count": 0,
|
||||
}
|
||||
from app.routers import compat as compat_router
|
||||
monkeypatch.setattr(compat_router.pipeline, "interpret_compat", fake_compat)
|
||||
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/saju/compat/interpret", json={
|
||||
"person_a": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male", "calendar_type": "solar"},
|
||||
"person_b": {"year": 1992, "month": 8, "day": 8, "hour": 18, "gender": "female", "calendar_type": "solar"},
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert "score" in data
|
||||
assert "saju_a" in data
|
||||
assert "saju_b" in data
|
||||
|
||||
|
||||
def test_compat_list_get_cycle(monkeypatch):
|
||||
async def fake_compat(*args, **kwargs):
|
||||
return {
|
||||
"interpretation_json": {"summary": "...", "strengths": [
|
||||
{"title": "a", "explanation": "b", "evidence": "c"},
|
||||
{"title": "a", "explanation": "b", "evidence": "c"},
|
||||
], "challenges": [
|
||||
{"title": "d", "explanation": "e", "evidence": "f"},
|
||||
{"title": "d", "explanation": "e", "evidence": "f"},
|
||||
], "advice": "", "warning": None, "confidence": "medium"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0,
|
||||
"latency_ms": 0, "reroll_count": 0,
|
||||
}
|
||||
from app.routers import compat as compat_router
|
||||
monkeypatch.setattr(compat_router.pipeline, "interpret_compat", fake_compat)
|
||||
|
||||
with TestClient(app) as c:
|
||||
rid = c.post("/api/saju/compat/interpret", json={
|
||||
"person_a": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male", "calendar_type": "solar"},
|
||||
"person_b": {"year": 1992, "month": 8, "day": 8, "hour": 18, "gender": "female", "calendar_type": "solar"},
|
||||
}).json()["reading_id"]
|
||||
|
||||
assert c.get("/api/saju/compat/readings").json()["total"] == 1
|
||||
r = c.get(f"/api/saju/compat/readings/{rid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["score"] >= 0
|
||||
|
||||
|
||||
def test_compat_patch_and_delete(monkeypatch):
|
||||
async def fake_compat(*args, **kwargs):
|
||||
return {
|
||||
"interpretation_json": {"summary": "...", "strengths": [
|
||||
{"title": "a", "explanation": "b", "evidence": "c"},
|
||||
{"title": "a", "explanation": "b", "evidence": "c"},
|
||||
], "challenges": [
|
||||
{"title": "d", "explanation": "e", "evidence": "f"},
|
||||
{"title": "d", "explanation": "e", "evidence": "f"},
|
||||
], "advice": "", "warning": None, "confidence": "medium"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0,
|
||||
"latency_ms": 0, "reroll_count": 0,
|
||||
}
|
||||
from app.routers import compat as compat_router
|
||||
monkeypatch.setattr(compat_router.pipeline, "interpret_compat", fake_compat)
|
||||
|
||||
with TestClient(app) as c:
|
||||
rid = c.post("/api/saju/compat/interpret", json={
|
||||
"person_a": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male", "calendar_type": "solar"},
|
||||
"person_b": {"year": 1992, "month": 8, "day": 8, "hour": 18, "gender": "female", "calendar_type": "solar"},
|
||||
}).json()["reading_id"]
|
||||
|
||||
r = c.patch(f"/api/saju/compat/readings/{rid}", json={"favorite": True, "memo": "좋은 궁합"})
|
||||
assert r.status_code == 200
|
||||
row = c.get(f"/api/saju/compat/readings/{rid}").json()
|
||||
assert row["favorite"] == 1
|
||||
|
||||
r = c.delete(f"/api/saju/compat/readings/{rid}")
|
||||
assert r.status_code == 200
|
||||
assert c.get(f"/api/saju/compat/readings/{rid}").status_code == 404
|
||||
|
||||
|
||||
def test_compat_get_404():
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/saju/compat/readings/9999").status_code == 404
|
||||
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
|
||||
156
saju-lab/tests/test_shinsal.py
Normal file
156
saju-lab/tests/test_shinsal.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""shinsal.py — 지장간/신살/공망/지지 상호작용 검증.
|
||||
|
||||
fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator.core import calculate_saju
|
||||
from app.calculator.shinsal import (
|
||||
get_hidden_stems,
|
||||
get_all_hidden_stems,
|
||||
analyze_branch_interactions,
|
||||
calculate_shinsal,
|
||||
calculate_gongmang,
|
||||
)
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch.isupper():
|
||||
out.append("_" + ch.lower())
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize(d):
|
||||
"""TS camelCase → Python snake_case (deep). 값은 변경하지 않음."""
|
||||
if isinstance(d, dict):
|
||||
return {_camel_to_snake(k): _normalize(v) for k, v in d.items()}
|
||||
if isinstance(d, list):
|
||||
return [_normalize(x) for x in d]
|
||||
return d
|
||||
|
||||
|
||||
def _canonical(item):
|
||||
"""dict/list 비교용 — 안정 정렬을 위한 JSON 직렬화."""
|
||||
return json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
# ─── 1. 지장간 단순 테스트 ────────────────────────────────────────────
|
||||
def test_hidden_stems_basic():
|
||||
assert get_hidden_stems("子") == ["癸"]
|
||||
assert get_hidden_stems("丑") == ["己", "癸", "辛"]
|
||||
assert get_hidden_stems("寅") == ["甲", "丙", "戊"]
|
||||
assert get_hidden_stems("卯") == ["乙"]
|
||||
assert get_hidden_stems("辰") == ["戊", "乙", "癸"]
|
||||
assert get_hidden_stems("巳") == ["丙", "庚", "戊"]
|
||||
assert get_hidden_stems("午") == ["丁", "己"]
|
||||
assert get_hidden_stems("未") == ["己", "丁", "乙"]
|
||||
assert get_hidden_stems("申") == ["庚", "壬", "戊"]
|
||||
assert get_hidden_stems("酉") == ["辛"]
|
||||
assert get_hidden_stems("戌") == ["戊", "辛", "丁"]
|
||||
assert get_hidden_stems("亥") == ["壬", "甲"]
|
||||
|
||||
|
||||
# ─── 2. hiddenStems reference 매칭 ───────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_hidden_stems_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("hiddenStems")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no hiddenStems in expected")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = get_all_hidden_stems(saju)
|
||||
assert actual == expected, f"mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
|
||||
|
||||
# ─── 3. branchInteractions reference 매칭 ────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_branch_interactions_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("branchInteractions")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no branchInteractions")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = analyze_branch_interactions(saju)
|
||||
# 순서가 다를 수 있으므로 정렬 후 비교
|
||||
assert sorted(map(_canonical, actual)) == sorted(map(_canonical, expected)), (
|
||||
f"mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 4. shinsal reference 매칭 ───────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_shinsal_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("shinsal")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no shinsal")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = calculate_shinsal(saju)
|
||||
assert sorted(map(_canonical, actual)) == sorted(map(_canonical, expected)), (
|
||||
f"mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 5. gongmang reference 매칭 ──────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_gongmang_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("gongmang")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no gongmang")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = calculate_gongmang(saju["day_stem"], saju["day"]["branch"])
|
||||
|
||||
# branches / branches_kr / description 전체 비교
|
||||
assert actual.get("branches") == expected.get("branches"), (
|
||||
f"branches mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
assert actual.get("branches_kr") == expected.get("branches_kr"), (
|
||||
f"branches_kr mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
assert actual.get("description") == expected.get("description"), (
|
||||
f"description mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
116
saju-lab/tests/test_solar_terms.py
Normal file
116
saju-lab/tests/test_solar_terms.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""solar_terms.py — sxtwl 기반 24절기 + 月支 매핑 테스트.
|
||||
|
||||
Reference fixture(`reference_saju.json`)는 saju-web의 solarlunar gzMonth를 직접 사용해
|
||||
재생성되었다 (이전 버전은 `getCurrentSolarTerm` 루프 버그로 모든 케이스가 月支='丑'으로 떨어졌음).
|
||||
본 테스트는
|
||||
- 절기 boundary (입춘 day) 동작 검증
|
||||
- sxtwl JieQi index 순서 가정 검증
|
||||
- 月支 자체 일관성(여러 날짜에서 기대되는 月支 산출) 검증
|
||||
- Reference fixture 30 케이스 sxtwl ↔ solarlunar 일치 검증
|
||||
네 가지로 구성한다.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator import solar_terms as st
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
BRANCHES = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
|
||||
|
||||
|
||||
def test_jieqi_index_order_lichun_is_3():
|
||||
"""sxtwl JieQi index에서 立春은 3(冬至=0 기준 황경 +45°).
|
||||
|
||||
1995-02-04는 입춘 당일. sxtwl이 그 날짜에 jqIndex=3을 반환해야 함.
|
||||
"""
|
||||
import sxtwl
|
||||
|
||||
qi_list = sxtwl.getJieQiByYear(1995)
|
||||
# 1995-02-04 ±1일 안에 jqIndex=3인 항목이 있어야 함
|
||||
found = False
|
||||
for info in qi_list:
|
||||
t = sxtwl.JD2DD(info.jd)
|
||||
if int(info.jqIndex) == 3 and int(t.Y) == 1995 and int(t.M) == 2 and 3 <= int(t.D) <= 5:
|
||||
found = True
|
||||
break
|
||||
assert found, "1995년 立春(jqIndex=3) 절기가 2/3~2/5 사이에서 발견되지 않음"
|
||||
|
||||
|
||||
def test_get_current_solar_term_ipchun_boundary():
|
||||
"""1995-02-04는 입춘 당일."""
|
||||
idx_4 = st.get_current_solar_term(1995, 2, 4)
|
||||
idx_5 = st.get_current_solar_term(1995, 2, 5)
|
||||
idx_3 = st.get_current_solar_term(1995, 2, 3)
|
||||
# 2/4와 2/5는 같은 절기 인덱스 (立春=3)
|
||||
assert idx_4 == idx_5
|
||||
# 2/3은 다른 인덱스 (立春 이전 — 大寒=2)
|
||||
assert idx_4 != idx_3
|
||||
|
||||
|
||||
def test_get_solar_term_month_branch_ipchun_day():
|
||||
"""1995-02-04는 입춘 → 月支 = 寅(인덱스 2)."""
|
||||
branch_idx = st.get_solar_term_month_branch(1995, 2, 4)
|
||||
assert BRANCHES[branch_idx] == "寅"
|
||||
|
||||
|
||||
def test_get_solar_term_month_branch_before_ipchun():
|
||||
"""1995-02-03은 입춘 이전 → 月支 = 丑(인덱스 1)."""
|
||||
branch_idx = st.get_solar_term_month_branch(1995, 2, 3)
|
||||
assert BRANCHES[branch_idx] == "丑"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"year,month,day,expected_branch",
|
||||
[
|
||||
# 표준 절기 경계 기준 月支 검증 (saju 일반 상식)
|
||||
(1990, 5, 15, "巳"), # 立夏(5/6) ~ 芒種(6/6) 사이 → 巳월
|
||||
(1990, 6, 6, "午"), # 芒種 당일 → 午월
|
||||
(1980, 6, 6, "午"), # 芒種 ~ 小暑 → 午월
|
||||
(1995, 2, 5, "寅"), # 立春 다음날 → 寅
|
||||
(1995, 2, 3, "丑"), # 立春 이전 → 丑
|
||||
(2000, 2, 29, "寅"), # 立春 이후 → 寅
|
||||
(2010, 12, 31, "子"), # 大雪 이후 → 子
|
||||
(1985, 1, 1, "子"), # 1/1은 小寒(1/5) 이전 → 子
|
||||
(1985, 1, 20, "丑"), # 小寒 이후 → 丑
|
||||
(1972, 7, 24, "未"), # 小暑 ~ 立秋 사이 → 未
|
||||
(1992, 8, 8, "申"), # 立秋 ~ 白露 → 申
|
||||
(1988, 9, 9, "酉"), # 白露 ~ 寒露 → 酉
|
||||
(2005, 6, 22, "午"), # 芒種 ~ 小暑 → 午
|
||||
(2020, 9, 23, "酉"), # 白露 ~ 寒露 → 酉
|
||||
],
|
||||
)
|
||||
def test_month_branch_standard_cases(year, month, day, expected_branch):
|
||||
"""일반 사주 상식 기준 月支 검증 — sxtwl 절기와 일치해야 함."""
|
||||
branch_idx = st.get_solar_term_month_branch(year, month, day)
|
||||
actual = BRANCHES[branch_idx]
|
||||
assert actual == expected_branch, (
|
||||
f"{year}-{month:02d}-{day:02d}: expected {expected_branch}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_days_to_next_solar_term_positive():
|
||||
"""다음 절기까지 일수는 항상 양수, 16일 이내."""
|
||||
# 立春(2/4) 다음날 → 雨水(2/19)까지 약 15일
|
||||
days = st.get_days_to_next_solar_term(1995, 2, 5)
|
||||
assert 1 <= days <= 16, f"days = {days} (expected 1~16)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_month_branch_matches_reference(case):
|
||||
"""Reference fixture의 month branch(solarlunar gzMonth 기준)와 sxtwl 계산 일치."""
|
||||
inp = case["input"]
|
||||
expected_branch = case["expected"]["saju"]["month"]["branch"]
|
||||
branch_idx = st.get_solar_term_month_branch(inp["year"], inp["month"], inp["day"])
|
||||
actual_branch = BRANCHES[branch_idx]
|
||||
assert actual_branch == expected_branch, (
|
||||
f"mismatch for {inp}: got {actual_branch}, expected {expected_branch}"
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab nginx scripts"
|
||||
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab nginx scripts"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
@@ -15,15 +15,15 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
||||
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab frontend"
|
||||
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab frontend"
|
||||
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
|
||||
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab image-lab frontend"
|
||||
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab image-lab tarot-lab saju-lab frontend"
|
||||
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
|
||||
INFRA_SERVICES="redis"
|
||||
# 헬스체크 대상
|
||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab redis"
|
||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab redis"
|
||||
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
||||
DATA_DIRS="music stock insta realestate agent-office personal video image"
|
||||
DATA_DIRS="music stock insta realestate agent-office personal video image tarot saju"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
5
tarot-lab/.dockerignore
Normal file
5
tarot-lab/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
data/
|
||||
tests/
|
||||
12
tarot-lab/Dockerfile
Normal file
12
tarot-lab/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
tarot-lab/app/__init__.py
Normal file
0
tarot-lab/app/__init__.py
Normal file
16
tarot-lab/app/config.py
Normal file
16
tarot-lab/app/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""tarot-lab 환경변수."""
|
||||
import os
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "180"))
|
||||
|
||||
TAROT_DATA_PATH = os.getenv("TAROT_DATA_PATH", "/app/data")
|
||||
DB_PATH = os.path.join(TAROT_DATA_PATH, "tarot.db")
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS",
|
||||
"http://localhost:3007,http://localhost:8080",
|
||||
)
|
||||
159
tarot-lab/app/db.py
Normal file
159
tarot-lab/app/db.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""tarot.db SQLite — 5 CRUD + _tarot_row_to_dict + init_db."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
spread_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
question TEXT,
|
||||
cards TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
summary TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
cost_usd REAL,
|
||||
confidence TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tarot_created
|
||||
ON tarot_readings(created_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
||||
ON tarot_readings(favorite, created_at DESC)
|
||||
""")
|
||||
|
||||
|
||||
def save_tarot_reading(data: Dict[str, Any]) -> int:
|
||||
interp = data.get("interpretation_json") or {}
|
||||
summary = interp.get("summary", "") if isinstance(interp, dict) else ""
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO tarot_readings
|
||||
(spread_type, category, question, cards, interpretation_json,
|
||||
summary, model, tokens_in, tokens_out, cost_usd, confidence)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
data["spread_type"],
|
||||
data.get("category"),
|
||||
data.get("question"),
|
||||
json.dumps(data.get("cards") or [], ensure_ascii=False),
|
||||
json.dumps(interp, ensure_ascii=False) if interp else None,
|
||||
summary,
|
||||
data.get("model"),
|
||||
data.get("tokens_in"),
|
||||
data.get("tokens_out"),
|
||||
data.get("cost_usd"),
|
||||
data.get("confidence"),
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone()
|
||||
return _tarot_row_to_dict(r) if r else None
|
||||
|
||||
|
||||
def list_tarot_readings(
|
||||
page: int = 1, size: int = 20,
|
||||
favorite: Optional[bool] = None,
|
||||
spread_type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wheres, params = [], []
|
||||
if favorite is not None:
|
||||
wheres.append("favorite=?")
|
||||
params.append(1 if favorite else 0)
|
||||
if spread_type:
|
||||
wheres.append("spread_type=?")
|
||||
params.append(spread_type)
|
||||
if category:
|
||||
wheres.append("category=?")
|
||||
params.append(category)
|
||||
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
offset = (page - 1) * size
|
||||
with _conn() as conn:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params
|
||||
).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
params + [size, offset],
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [_tarot_row_to_dict(r) for r in rows],
|
||||
"page": page, "size": size, "total": int(total),
|
||||
}
|
||||
|
||||
|
||||
def update_tarot_reading(reading_id: int, **kwargs) -> None:
|
||||
sets, vals = [], []
|
||||
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||
sets.append("favorite=?")
|
||||
vals.append(1 if kwargs["favorite"] else 0)
|
||||
if "note" in kwargs and kwargs["note"] is not None:
|
||||
sets.append("note=?")
|
||||
vals.append(kwargs["note"])
|
||||
if not sets:
|
||||
return
|
||||
vals.append(reading_id)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals)
|
||||
|
||||
|
||||
def delete_tarot_reading(reading_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,))
|
||||
|
||||
|
||||
def _tarot_row_to_dict(r) -> Dict[str, Any]:
|
||||
try:
|
||||
interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None
|
||||
except (ValueError, TypeError):
|
||||
interp = None
|
||||
try:
|
||||
cards = json.loads(r["cards"]) if r["cards"] else []
|
||||
except (ValueError, TypeError):
|
||||
cards = []
|
||||
return {
|
||||
"id": r["id"],
|
||||
"created_at": r["created_at"],
|
||||
"spread_type": r["spread_type"],
|
||||
"category": r["category"],
|
||||
"question": r["question"],
|
||||
"cards": cards,
|
||||
"interpretation_json": interp,
|
||||
"summary": r["summary"],
|
||||
"model": r["model"],
|
||||
"tokens_in": r["tokens_in"],
|
||||
"tokens_out": r["tokens_out"],
|
||||
"cost_usd": r["cost_usd"],
|
||||
"confidence": r["confidence"],
|
||||
"favorite": int(r["favorite"]),
|
||||
"note": r["note"],
|
||||
}
|
||||
@@ -1,20 +1,40 @@
|
||||
"""Tarot Lab 엔드포인트 — interpret + readings CRUD."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
"""tarot-lab FastAPI app — /api/tarot/* 6 endpoints."""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from ..models import (
|
||||
from .config import CORS_ALLOW_ORIGINS
|
||||
from .models import (
|
||||
TarotInterpretRequest,
|
||||
TarotInterpretResponse,
|
||||
TarotSaveRequest,
|
||||
TarotPatchRequest,
|
||||
)
|
||||
from ..tarot import pipeline
|
||||
from .. import db as db_module
|
||||
from . import pipeline, db as db_module
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/agent-office/tarot")
|
||||
app = FastAPI(title="tarot-lab")
|
||||
|
||||
_origins = [o.strip() for o in CORS_ALLOW_ORIGINS.split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/interpret", response_model=TarotInterpretResponse)
|
||||
@app.on_event("startup")
|
||||
def _init():
|
||||
db_module.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/tarot/interpret", response_model=TarotInterpretResponse)
|
||||
async def interpret_endpoint(req: TarotInterpretRequest):
|
||||
try:
|
||||
result = await pipeline.interpret(req)
|
||||
@@ -23,14 +43,14 @@ async def interpret_endpoint(req: TarotInterpretRequest):
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/readings")
|
||||
@app.post("/api/tarot/readings")
|
||||
async def save_reading(req: TarotSaveRequest):
|
||||
rid = db_module.save_tarot_reading(req.model_dump())
|
||||
row = db_module.get_tarot_reading(rid)
|
||||
return {"id": rid, "created_at": row["created_at"]}
|
||||
|
||||
|
||||
@router.get("/readings")
|
||||
@app.get("/api/tarot/readings")
|
||||
async def list_readings(
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
@@ -44,7 +64,7 @@ async def list_readings(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/readings/{reading_id}")
|
||||
@app.get("/api/tarot/readings/{reading_id}")
|
||||
async def get_reading(reading_id: int):
|
||||
row = db_module.get_tarot_reading(reading_id)
|
||||
if not row:
|
||||
@@ -52,7 +72,7 @@ async def get_reading(reading_id: int):
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/readings/{reading_id}")
|
||||
@app.patch("/api/tarot/readings/{reading_id}")
|
||||
async def patch_reading(reading_id: int, req: TarotPatchRequest):
|
||||
row = db_module.get_tarot_reading(reading_id)
|
||||
if not row:
|
||||
@@ -61,7 +81,7 @@ async def patch_reading(reading_id: int, req: TarotPatchRequest):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/readings/{reading_id}")
|
||||
@app.delete("/api/tarot/readings/{reading_id}")
|
||||
async def delete_reading(reading_id: int):
|
||||
row = db_module.get_tarot_reading(reading_id)
|
||||
if not row:
|
||||
47
tarot-lab/app/models.py
Normal file
47
tarot-lab/app/models.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tarot Pydantic 모델 — agent-office models.py에서 추출."""
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TarotCardDraw(BaseModel):
|
||||
position: str
|
||||
card_id: str
|
||||
reversed: bool = False
|
||||
|
||||
|
||||
class TarotInterpretRequest(BaseModel):
|
||||
spread_type: Literal["one_card", "three_card"]
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
cards: List[TarotCardDraw]
|
||||
cards_reference: str = Field(..., min_length=1)
|
||||
context_meta: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TarotInterpretResponse(BaseModel):
|
||||
interpretation_json: dict
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
latency_ms: int
|
||||
reroll_count: int = 0
|
||||
|
||||
|
||||
class TarotSaveRequest(BaseModel):
|
||||
spread_type: Literal["one_card", "three_card"]
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
cards: List[TarotCardDraw]
|
||||
interpretation_json: dict
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
confidence: Optional[str] = None
|
||||
|
||||
|
||||
class TarotPatchRequest(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
note: Optional[str] = None
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import (
|
||||
from .config import (
|
||||
ANTHROPIC_API_KEY,
|
||||
TAROT_MODEL,
|
||||
TAROT_COST_INPUT_PER_M,
|
||||
@@ -15,8 +15,8 @@ from ..config import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger("agent-office.tarot")
|
||||
from ..models import TarotInterpretRequest
|
||||
logger = logging.getLogger("tarot-lab.pipeline")
|
||||
from .models import TarotInterpretRequest
|
||||
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||
from .schema import validate_interpretation
|
||||
|
||||
3
tarot-lab/pytest.ini
Normal file
3
tarot-lab/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
pythonpath = .
|
||||
7
tarot-lab/requirements.txt
Normal file
7
tarot-lab/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx>=0.27
|
||||
pydantic>=2.9
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
respx>=0.21
|
||||
0
tarot-lab/tests/__init__.py
Normal file
0
tarot-lab/tests/__init__.py
Normal file
@@ -1,7 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
@@ -13,11 +10,14 @@ def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
yield
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
try:
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
except PermissionError:
|
||||
pass # Windows SQLite WAL 잠금
|
||||
|
||||
|
||||
def test_save_and_get_tarot_reading():
|
||||
def test_save_and_get():
|
||||
rid = db_module.save_tarot_reading({
|
||||
"spread_type": "three_card",
|
||||
"category": "연애",
|
||||
@@ -36,7 +36,7 @@ def test_save_and_get_tarot_reading():
|
||||
assert row["favorite"] == 0
|
||||
|
||||
|
||||
def test_list_tarot_readings_filters_and_pagination():
|
||||
def test_list_with_filters():
|
||||
for cat in ["연애", "연애", "재물"]:
|
||||
db_module.save_tarot_reading({
|
||||
"spread_type": "three_card", "category": cat, "question": "Q",
|
||||
@@ -48,23 +48,23 @@ def test_list_tarot_readings_filters_and_pagination():
|
||||
assert all(r["category"] == "연애" for r in res["items"])
|
||||
|
||||
|
||||
def test_update_tarot_reading_favorite_and_note():
|
||||
def test_update_favorite_and_note():
|
||||
rid = db_module.save_tarot_reading({
|
||||
"spread_type": "one_card", "category": None, "question": None,
|
||||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||
"cards": [], "interpretation_json": None,
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": None,
|
||||
})
|
||||
db_module.update_tarot_reading(rid, favorite=True, note="기억하고 싶음")
|
||||
db_module.update_tarot_reading(rid, favorite=True, note="좋아요")
|
||||
row = db_module.get_tarot_reading(rid)
|
||||
assert row["favorite"] == 1
|
||||
assert row["note"] == "기억하고 싶음"
|
||||
assert row["note"] == "좋아요"
|
||||
|
||||
|
||||
def test_delete_tarot_reading():
|
||||
def test_delete():
|
||||
rid = db_module.save_tarot_reading({
|
||||
"spread_type": "one_card", "category": None, "question": None,
|
||||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||
"cards": [], "interpretation_json": None,
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": None,
|
||||
})
|
||||
db_module.delete_tarot_reading(rid)
|
||||
assert db_module.get_tarot_reading(rid) is None
|
||||
114
tarot-lab/tests/test_pipeline.py
Normal file
114
tarot-lab/tests/test_pipeline.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from app import pipeline
|
||||
from app.models import TarotInterpretRequest, TarotCardDraw
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_key(monkeypatch):
|
||||
monkeypatch.setattr(pipeline, "ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
|
||||
def _req():
|
||||
return TarotInterpretRequest(
|
||||
spread_type="three_card",
|
||||
category="연애",
|
||||
question="Q",
|
||||
cards=[
|
||||
TarotCardDraw(position="과거", card_id="the-fool", reversed=False),
|
||||
TarotCardDraw(position="현재", card_id="the-magician", reversed=False),
|
||||
TarotCardDraw(position="미래", card_id="the-empress", reversed=True),
|
||||
],
|
||||
cards_reference="...",
|
||||
)
|
||||
|
||||
|
||||
def _valid_response_json():
|
||||
return {
|
||||
"summary": "흐름이 있음",
|
||||
"cards": [
|
||||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "...", "advice": "...",
|
||||
"evidence": {"card_meaning_used": "...", "position_logic": "...", "category_lens": "..."}},
|
||||
{"position": "현재", "card": "the-magician", "reversed": False,
|
||||
"interpretation": "...", "advice": "...",
|
||||
"evidence": {"card_meaning_used": "...", "position_logic": "...", "category_lens": "..."}},
|
||||
{"position": "미래", "card": "the-empress", "reversed": True,
|
||||
"interpretation": "...", "advice": "...",
|
||||
"evidence": {"card_meaning_used": "...", "position_logic": "...", "category_lens": "..."}},
|
||||
],
|
||||
"interactions": [{"type": "synergy", "between": ["the-fool", "the-magician"], "explanation": "..."}],
|
||||
"advice": "...",
|
||||
"warning": None,
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
|
||||
def _claude_envelope(text: str, in_tok=100, out_tok=200):
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||
}
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_interpret_success():
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(json.dumps(_valid_response_json())))
|
||||
)
|
||||
result = await pipeline.interpret(_req())
|
||||
assert result["reroll_count"] == 0
|
||||
assert result["model"] == pipeline.TAROT_MODEL
|
||||
assert result["tokens_in"] == 100
|
||||
assert result["cost_usd"] > 0
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_interpret_codeblock_stripped():
|
||||
text = "```json\n" + json.dumps(_valid_response_json()) + "\n```"
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(text))
|
||||
)
|
||||
result = await pipeline.interpret(_req())
|
||||
assert "interpretation_json" in result
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_interpret_reroll_then_success():
|
||||
valid = json.dumps(_valid_response_json())
|
||||
invalid = json.dumps({"summary": "...", "cards": [], "interactions": [], "advice": "", "confidence": "medium"})
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
side_effect=[
|
||||
httpx.Response(200, json=_claude_envelope(invalid)),
|
||||
httpx.Response(200, json=_claude_envelope(valid)),
|
||||
]
|
||||
)
|
||||
result = await pipeline.interpret(_req())
|
||||
assert result["reroll_count"] == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_interpret_reroll_fail_raises():
|
||||
invalid = json.dumps({"summary": "...", "cards": [], "interactions": [], "advice": "", "confidence": "medium"})
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=_claude_envelope(invalid))
|
||||
)
|
||||
with pytest.raises(pipeline.TarotError):
|
||||
await pipeline.interpret(_req())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_interpret_http_error():
|
||||
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(500, text="boom")
|
||||
)
|
||||
with pytest.raises(pipeline.TarotError):
|
||||
await pipeline.interpret(_req())
|
||||
|
||||
|
||||
def test_calc_cost():
|
||||
cost = pipeline.calc_cost(1_000_000, 1_000_000)
|
||||
assert cost == pipeline.TAROT_COST_INPUT_PER_M + pipeline.TAROT_COST_OUTPUT_PER_M
|
||||
71
tarot-lab/tests/test_routes.py
Normal file
71
tarot-lab/tests/test_routes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
db_file = tmp_path / "test_tarot.db"
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
yield
|
||||
try:
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def _save_payload():
|
||||
return {
|
||||
"spread_type": "three_card",
|
||||
"category": "연애",
|
||||
"question": "Q",
|
||||
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
|
||||
def test_health():
|
||||
with TestClient(app) as c:
|
||||
r = c.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_save_list_get_cycle():
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/tarot/readings", json=_save_payload())
|
||||
assert r.status_code == 200
|
||||
rid = r.json()["id"]
|
||||
r = c.get("/api/tarot/readings")
|
||||
assert r.json()["total"] == 1
|
||||
r = c.get(f"/api/tarot/readings/{rid}")
|
||||
assert r.json()["category"] == "연애"
|
||||
|
||||
|
||||
def test_patch_favorite_and_note():
|
||||
with TestClient(app) as c:
|
||||
rid = c.post("/api/tarot/readings", json=_save_payload()).json()["id"]
|
||||
r = c.patch(f"/api/tarot/readings/{rid}", json={"favorite": True, "note": "n"})
|
||||
assert r.status_code == 200
|
||||
row = c.get(f"/api/tarot/readings/{rid}").json()
|
||||
assert row["favorite"] == 1
|
||||
assert row["note"] == "n"
|
||||
|
||||
|
||||
def test_delete():
|
||||
with TestClient(app) as c:
|
||||
rid = c.post("/api/tarot/readings", json=_save_payload()).json()["id"]
|
||||
assert c.delete(f"/api/tarot/readings/{rid}").status_code == 200
|
||||
assert c.get(f"/api/tarot/readings/{rid}").status_code == 404
|
||||
|
||||
|
||||
def test_get_404():
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/tarot/readings/9999").status_code == 404
|
||||
59
tarot-lab/tests/test_schema.py
Normal file
59
tarot-lab/tests/test_schema.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user