Spaces:
Running
Running
feat: 애플톤 UI 전면 적용 · 사주 만세력(자체 엔진+JGOS UI) · TeXray AI글판별 흡수
Browse files- app.py +52 -0
- requirements.txt +23 -20
- saju_manseryeok.py +1600 -0
- static/index.html +850 -639
app.py
CHANGED
|
@@ -3627,3 +3627,55 @@ async def demo_file(filename: str):
|
|
| 3627 |
|
| 3628 |
return FileResponse(fp, media_type=mt, filename=filename)
|
| 3629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3627 |
|
| 3628 |
return FileResponse(fp, media_type=mt, filename=filename)
|
| 3629 |
|
| 3630 |
+
|
| 3631 |
+
# ===== 🔮 사주 만세력 (LifeOS 정밀 엔진) =====
|
| 3632 |
+
try:
|
| 3633 |
+
from saju_manseryeok import SajuManseryeok as _Saju
|
| 3634 |
+
_SAJU_OK = True
|
| 3635 |
+
except Exception as _e: # 의존성 미설치 시에도 나머지 서비스는 살아야 한다
|
| 3636 |
+
_SAJU_OK = False
|
| 3637 |
+
_SAJU_ERR = str(_e)
|
| 3638 |
+
|
| 3639 |
+
@app.post("/api/saju")
|
| 3640 |
+
async def api_saju(payload: dict = Body(...)):
|
| 3641 |
+
if not _SAJU_OK:
|
| 3642 |
+
raise HTTPException(503, f"사주 엔진을 불러오지 못했습니다: {_SAJU_ERR}")
|
| 3643 |
+
try:
|
| 3644 |
+
y = int(payload["year"]); m = int(payload["month"]); d = int(payload["day"])
|
| 3645 |
+
except Exception:
|
| 3646 |
+
raise HTTPException(400, "생년월일을 확인해 주세요.")
|
| 3647 |
+
if not (1900 <= y <= 2100 and 1 <= m <= 12 and 1 <= d <= 31):
|
| 3648 |
+
raise HTTPException(400, "1900~2100년 사이의 날짜만 계산할 수 있습니다.")
|
| 3649 |
+
hour = payload.get("hour")
|
| 3650 |
+
unknown_hour = hour in (None, "", -1, "-1")
|
| 3651 |
+
hh = 12 if unknown_hour else max(0, min(23, int(hour)))
|
| 3652 |
+
gender = "female" if str(payload.get("gender","male")).startswith(("f","여")) else "male"
|
| 3653 |
+
is_lunar = bool(payload.get("is_lunar"))
|
| 3654 |
+
try:
|
| 3655 |
+
s = _Saju(y, m, d, hour=hh, minute=int(payload.get("minute") or 0),
|
| 3656 |
+
is_lunar=is_lunar, is_leap_month=bool(payload.get("is_leap_month")),
|
| 3657 |
+
gender=gender)
|
| 3658 |
+
pil = s.saju_pillars
|
| 3659 |
+
out = {
|
| 3660 |
+
"pillars": pil,
|
| 3661 |
+
"saju_text": s.saju_text,
|
| 3662 |
+
"saju_hanja": s.saju_text_hanja,
|
| 3663 |
+
"day_master": s.day_master,
|
| 3664 |
+
"day_master_element": s.day_master_element,
|
| 3665 |
+
"day_master_yinyang": s.day_master_yinyang,
|
| 3666 |
+
"animal": s.animal_sign,
|
| 3667 |
+
"oheng": s.oheng_balance,
|
| 3668 |
+
"oheng_pct": s.oheng_percentages,
|
| 3669 |
+
"dominant": s.dominant_element,
|
| 3670 |
+
"weak": s.weak_element,
|
| 3671 |
+
"unknown_hour": unknown_hour,
|
| 3672 |
+
}
|
| 3673 |
+
for name, fn in (("sipseong","get_sipseong_table"),("twelve_stages","get_twelve_stages"),
|
| 3674 |
+
("jijanggan","get_jijanggan_table")):
|
| 3675 |
+
try: out[name] = getattr(s, fn)()
|
| 3676 |
+
except Exception: out[name] = None
|
| 3677 |
+
return JSONResponse(out)
|
| 3678 |
+
except HTTPException:
|
| 3679 |
+
raise
|
| 3680 |
+
except Exception as e:
|
| 3681 |
+
raise HTTPException(500, f"사주 계산 중 문제가 생겼습니다: {e}")
|
requirements.txt
CHANGED
|
@@ -1,20 +1,23 @@
|
|
| 1 |
-
fastapi==0.115.0
|
| 2 |
-
uvicorn[standard]==0.32.0
|
| 3 |
-
httpx==0.27.2
|
| 4 |
-
openai==1.54.0
|
| 5 |
-
pydantic==2.9.2
|
| 6 |
-
python-multipart==0.0.12
|
| 7 |
-
# file extraction
|
| 8 |
-
pymupdf==1.24.10
|
| 9 |
-
python-docx==1.1.2
|
| 10 |
-
python-pptx==1.0.2
|
| 11 |
-
openpyxl==3.1.5
|
| 12 |
-
pandas==2.2.3
|
| 13 |
-
pillow==11.0.0
|
| 14 |
-
xlrd==2.0.1
|
| 15 |
-
huggingface_hub==0.26.2
|
| 16 |
-
# 행정 AX 탭
|
| 17 |
-
lxml>=5.0
|
| 18 |
-
pdfplumber>=0.10
|
| 19 |
-
chardet>=5.0
|
| 20 |
-
reportlab>=4.0
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.32.0
|
| 3 |
+
httpx==0.27.2
|
| 4 |
+
openai==1.54.0
|
| 5 |
+
pydantic==2.9.2
|
| 6 |
+
python-multipart==0.0.12
|
| 7 |
+
# file extraction
|
| 8 |
+
pymupdf==1.24.10
|
| 9 |
+
python-docx==1.1.2
|
| 10 |
+
python-pptx==1.0.2
|
| 11 |
+
openpyxl==3.1.5
|
| 12 |
+
pandas==2.2.3
|
| 13 |
+
pillow==11.0.0
|
| 14 |
+
xlrd==2.0.1
|
| 15 |
+
huggingface_hub==0.26.2
|
| 16 |
+
# 행정 AX 탭
|
| 17 |
+
lxml>=5.0
|
| 18 |
+
pdfplumber>=0.10
|
| 19 |
+
chardet>=5.0
|
| 20 |
+
reportlab>=4.0
|
| 21 |
+
# 사주 만세력 (LifeOS 엔진)
|
| 22 |
+
korean-lunar-calendar>=0.3.1
|
| 23 |
+
pymeeus>=0.5.12
|
saju_manseryeok.py
ADDED
|
@@ -0,0 +1,1600 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
사주 만세력 (四柱 萬歲曆) — Korean Four Pillars of Destiny Calculation Engine
|
| 4 |
+
==============================================================================
|
| 5 |
+
|
| 6 |
+
Accurate manseryeok calculation using:
|
| 7 |
+
- pymeeus : Precise solar term (절기) dates via Sun ecliptic longitude
|
| 8 |
+
- korean_lunar_calendar : Solar <-> Lunar date conversion
|
| 9 |
+
|
| 10 |
+
Core rules:
|
| 11 |
+
1. Year pillar changes at 입춘 (立春, lon 315°), NOT Jan 1
|
| 12 |
+
2. Month pillar uses 절기 boundaries, NOT calendar months
|
| 13 |
+
3. Day pillar uses Julian Day Number, NOT approximate formulas
|
| 14 |
+
4. Hour pillar handles 23:00 자시 as next day's 자시
|
| 15 |
+
|
| 16 |
+
Author: VIDraft / LifeOS
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import math
|
| 22 |
+
from datetime import datetime, timedelta
|
| 23 |
+
from functools import lru_cache
|
| 24 |
+
from typing import Dict, List, Optional, Tuple
|
| 25 |
+
|
| 26 |
+
from korean_lunar_calendar import KoreanLunarCalendar
|
| 27 |
+
from pymeeus.Epoch import Epoch
|
| 28 |
+
from pymeeus.Sun import Sun
|
| 29 |
+
|
| 30 |
+
# =============================================================================
|
| 31 |
+
# 1. CONSTANTS
|
| 32 |
+
# =============================================================================
|
| 33 |
+
|
| 34 |
+
# 10 Heavenly Stems (천간)
|
| 35 |
+
CHEONGAN = ["갑", "을", "병", "정", "무", "기", "경", "신", "임", "계"]
|
| 36 |
+
CHEONGAN_HANJA = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]
|
| 37 |
+
|
| 38 |
+
# 12 Earthly Branches (지지)
|
| 39 |
+
JIJI = ["자", "축", "인", "묘", "진", "사", "오", "미", "신", "유", "술", "해"]
|
| 40 |
+
JIJI_HANJA = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
|
| 41 |
+
JIJI_ANIMALS = [
|
| 42 |
+
"쥐", "소", "호랑이", "토끼", "용", "뱀",
|
| 43 |
+
"말", "양", "원숭이", "닭", "개", "돼지",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
# 60 갑자 (sexagenary cycle)
|
| 47 |
+
GANJI_60 = []
|
| 48 |
+
for i in range(60):
|
| 49 |
+
GANJI_60.append(CHEONGAN[i % 10] + JIJI[i % 12])
|
| 50 |
+
|
| 51 |
+
GANJI_60_HANJA = []
|
| 52 |
+
for i in range(60):
|
| 53 |
+
GANJI_60_HANJA.append(CHEONGAN_HANJA[i % 10] + JIJI_HANJA[i % 12])
|
| 54 |
+
|
| 55 |
+
# Five Elements mapping (오행)
|
| 56 |
+
OHENG_MAP = {
|
| 57 |
+
# Stems
|
| 58 |
+
"갑": "목", "을": "목",
|
| 59 |
+
"병": "화", "정": "화",
|
| 60 |
+
"무": "토", "기": "토",
|
| 61 |
+
"경": "금", "신": "금",
|
| 62 |
+
"임": "수", "계": "수",
|
| 63 |
+
# Branches
|
| 64 |
+
"자": "수", "축": "토",
|
| 65 |
+
"인": "목", "묘": "목",
|
| 66 |
+
"진": "토", "사": "화",
|
| 67 |
+
"오": "화", "미": "토",
|
| 68 |
+
"신": "금", "유": "금",
|
| 69 |
+
"술": "토", "해": "수",
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# Element names
|
| 73 |
+
OHENG_NAMES = {"목": "木", "화": "火", "토": "土", "금": "金", "수": "水"}
|
| 74 |
+
OHENG_ENGLISH = {"목": "wood", "화": "fire", "토": "earth", "금": "metal", "수": "water"}
|
| 75 |
+
|
| 76 |
+
# Yin/Yang for stems (음양): even index = yang, odd index = yin
|
| 77 |
+
STEM_YINYANG = ["양", "음", "양", "음", "양", "음", "양", "음", "양", "음"]
|
| 78 |
+
BRANCH_YINYANG = ["양", "음", "양", "음", "양", "음", "양", "음", "양", "음", "양", "음"]
|
| 79 |
+
|
| 80 |
+
# =============================================================================
|
| 81 |
+
# 2. SOLAR TERMS (절기) — Using pymeeus
|
| 82 |
+
# =============================================================================
|
| 83 |
+
|
| 84 |
+
# 24 solar terms: (name_kr, name_hanja, ecliptic_longitude)
|
| 85 |
+
# The 12 "절" (jol) terms that determine saju months are marked with is_jol=True
|
| 86 |
+
SOLAR_TERMS_24 = [
|
| 87 |
+
# name_kr, name_hanja, longitude, is_jol, saju_month (if jol)
|
| 88 |
+
("소한", "小寒", 285, True, 12), # Month 12 (축월)
|
| 89 |
+
("대한", "大寒", 300, False, None),
|
| 90 |
+
("입춘", "立春", 315, True, 1), # Month 1 (인월) — YEAR CHANGES HERE
|
| 91 |
+
("우수", "雨水", 330, False, None),
|
| 92 |
+
("경칩", "驚蟄", 345, True, 2), # Month 2 (묘월)
|
| 93 |
+
("춘분", "春分", 0, False, None),
|
| 94 |
+
("청명", "清明", 15, True, 3), # Month 3 (진월)
|
| 95 |
+
("곡우", "穀雨", 30, False, None),
|
| 96 |
+
("입하", "立夏", 45, True, 4), # Month 4 (사월)
|
| 97 |
+
("소만", "小滿", 60, False, None),
|
| 98 |
+
("망종", "芒種", 75, True, 5), # Month 5 (오월)
|
| 99 |
+
("하지", "夏至", 90, False, None),
|
| 100 |
+
("소서", "小暑", 105, True, 6), # Month 6 (미월)
|
| 101 |
+
("대서", "大暑", 120, False, None),
|
| 102 |
+
("입추", "立秋", 135, True, 7), # Month 7 (신월)
|
| 103 |
+
("처서", "處暑", 150, False, None),
|
| 104 |
+
("백로", "白露", 165, True, 8), # Month 8 (유월)
|
| 105 |
+
("추분", "秋分", 180, False, None),
|
| 106 |
+
("한로", "寒露", 195, True, 9), # Month 9 (술월)
|
| 107 |
+
("상강", "霜降", 210, False, None),
|
| 108 |
+
("입동", "立冬", 225, True, 10), # Month 10 (해월)
|
| 109 |
+
("소설", "小雪", 240, False, None),
|
| 110 |
+
("대설", "大雪", 255, True, 11), # Month 11 (자월)
|
| 111 |
+
("동지", "冬至", 270, False, None),
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
# Just the 12 절 terms for month boundary calculation
|
| 115 |
+
JOL_TERMS = [t for t in SOLAR_TERMS_24 if t[3]]
|
| 116 |
+
|
| 117 |
+
# Saju month -> branch index mapping:
|
| 118 |
+
# Month 1 (인월, starts at 입춘) -> branch 2 (인)
|
| 119 |
+
# Month 2 (묘월, starts at 경칩) -> branch 3 (묘)
|
| 120 |
+
# ...
|
| 121 |
+
# Month 11 (자월, starts at 대설) -> branch 0 (자)
|
| 122 |
+
# Month 12 (축월, starts at 소한) -> branch 1 (축)
|
| 123 |
+
SAJU_MONTH_TO_BRANCH = {
|
| 124 |
+
1: 2, # 인(寅)
|
| 125 |
+
2: 3, # 묘(卯)
|
| 126 |
+
3: 4, # 진(辰)
|
| 127 |
+
4: 5, # 사(巳)
|
| 128 |
+
5: 6, # 오(午)
|
| 129 |
+
6: 7, # 미(未)
|
| 130 |
+
7: 8, # 신(申)
|
| 131 |
+
8: 9, # 유(酉)
|
| 132 |
+
9: 10, # 술(戌)
|
| 133 |
+
10: 11, # 해(亥)
|
| 134 |
+
11: 0, # 자(子)
|
| 135 |
+
12: 1, # 축(丑)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _sun_apparent_longitude(epoch_or_jde) -> float:
|
| 140 |
+
"""Calculate the Sun's apparent ecliptic longitude.
|
| 141 |
+
|
| 142 |
+
Uses pymeeus Sun.apparent_geocentric_position for high precision
|
| 143 |
+
(~0.01 degree, sufficient for solar term determination within minutes).
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
epoch_or_jde: A pymeeus Epoch object or a JDE float.
|
| 147 |
+
"""
|
| 148 |
+
if isinstance(epoch_or_jde, (int, float)):
|
| 149 |
+
epoch_or_jde = Epoch(epoch_or_jde)
|
| 150 |
+
# apparent_geocentric_position returns (longitude_Angle, latitude_Angle, radius)
|
| 151 |
+
lon_angle, _, _ = Sun.apparent_geocentric_position(epoch_or_jde)
|
| 152 |
+
lon = float(lon_angle) % 360.0
|
| 153 |
+
return lon
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _epoch_from_datetime(dt: datetime) -> Epoch:
|
| 157 |
+
"""Convert a Python datetime to a pymeeus Epoch."""
|
| 158 |
+
d = dt.day + dt.hour / 24.0 + dt.minute / 1440.0 + dt.second / 86400.0
|
| 159 |
+
return Epoch(dt.year, dt.month, d)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _jde_from_datetime(dt: datetime) -> float:
|
| 163 |
+
"""Convert a Python datetime to Julian Ephemeris Day."""
|
| 164 |
+
return _epoch_from_datetime(dt).jde()
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _datetime_from_jde(jde: float) -> datetime:
|
| 168 |
+
"""Convert a Julian Ephemeris Day to a Python datetime (UTC)."""
|
| 169 |
+
epoch = Epoch(jde)
|
| 170 |
+
y, m, d = epoch.get_date()
|
| 171 |
+
# d is a float with fractional day
|
| 172 |
+
day_int = int(d)
|
| 173 |
+
frac = d - day_int
|
| 174 |
+
hours = frac * 24.0
|
| 175 |
+
hour_int = int(hours)
|
| 176 |
+
mins = (hours - hour_int) * 60.0
|
| 177 |
+
min_int = int(mins)
|
| 178 |
+
secs = (mins - min_int) * 60.0
|
| 179 |
+
sec_int = int(round(secs))
|
| 180 |
+
if sec_int >= 60:
|
| 181 |
+
sec_int = 0
|
| 182 |
+
min_int += 1
|
| 183 |
+
if min_int >= 60:
|
| 184 |
+
min_int = 0
|
| 185 |
+
hour_int += 1
|
| 186 |
+
if hour_int >= 24:
|
| 187 |
+
hour_int = 0
|
| 188 |
+
day_int += 1
|
| 189 |
+
try:
|
| 190 |
+
return datetime(int(y), int(m), day_int, hour_int, min_int, sec_int)
|
| 191 |
+
except ValueError:
|
| 192 |
+
# Handle month overflow etc.
|
| 193 |
+
base = datetime(int(y), int(m), 1) + timedelta(days=day_int - 1,
|
| 194 |
+
hours=hour_int,
|
| 195 |
+
minutes=min_int,
|
| 196 |
+
seconds=sec_int)
|
| 197 |
+
return base
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@lru_cache(maxsize=512)
|
| 201 |
+
def get_solar_term_date(year: int, target_longitude: float) -> datetime:
|
| 202 |
+
"""Find the exact datetime when the Sun reaches a specific ecliptic longitude.
|
| 203 |
+
|
| 204 |
+
Uses Newton-Raphson iteration on the Sun's apparent longitude.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
year: The Gregorian year (approximate) in which to search.
|
| 208 |
+
target_longitude: The ecliptic longitude in degrees [0, 360).
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
datetime (UTC) when the Sun reaches the target longitude.
|
| 212 |
+
"""
|
| 213 |
+
# Estimate initial JDE based on longitude
|
| 214 |
+
# The Sun is at ~280° on Jan 1. Each degree takes ~1.0145 days.
|
| 215 |
+
# Spring equinox (0°) is ~March 20.
|
| 216 |
+
|
| 217 |
+
# Better estimation: days from March equinox
|
| 218 |
+
# 0° -> ~March 20 (day ~79)
|
| 219 |
+
# Each degree ~ 365.25/360 ~ 1.01458 days
|
| 220 |
+
|
| 221 |
+
if target_longitude >= 0 and target_longitude < 280:
|
| 222 |
+
# After spring equinox and before ~Jan 1
|
| 223 |
+
est_day = 79 + target_longitude * (365.25 / 360.0)
|
| 224 |
+
else:
|
| 225 |
+
# Before spring equinox (280-360 -> corresponds to Jan-Mar)
|
| 226 |
+
est_day = 79 + (target_longitude - 360) * (365.25 / 360.0)
|
| 227 |
+
|
| 228 |
+
est_dt = datetime(year, 1, 1) + timedelta(days=est_day - 1)
|
| 229 |
+
jde = _jde_from_datetime(est_dt)
|
| 230 |
+
|
| 231 |
+
# Newton-Raphson iteration
|
| 232 |
+
for _ in range(50):
|
| 233 |
+
current_lon = _sun_apparent_longitude(jde)
|
| 234 |
+
|
| 235 |
+
# Calculate difference, handling the 0°/360° boundary
|
| 236 |
+
diff = target_longitude - current_lon
|
| 237 |
+
if diff > 180:
|
| 238 |
+
diff -= 360
|
| 239 |
+
elif diff < -180:
|
| 240 |
+
diff += 360
|
| 241 |
+
|
| 242 |
+
# Convergence check: ~0.0001° ≈ 8.6 seconds of time
|
| 243 |
+
if abs(diff) < 0.0001:
|
| 244 |
+
break
|
| 245 |
+
|
| 246 |
+
# Sun moves ~360°/365.25 ≈ 0.9856° per day
|
| 247 |
+
jde += diff / 0.9856
|
| 248 |
+
|
| 249 |
+
result = _datetime_from_jde(jde)
|
| 250 |
+
|
| 251 |
+
# Apply approximate UT-KST offset (+9 hours) for Korean standard time
|
| 252 |
+
result_kst = result + timedelta(hours=9)
|
| 253 |
+
return result_kst
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_all_jol_dates(year: int) -> List[Tuple[str, str, int, datetime, int]]:
|
| 257 |
+
"""Get the dates of all 12 절 terms for a given year.
|
| 258 |
+
|
| 259 |
+
Returns list of (name_kr, name_hanja, longitude, datetime_kst, saju_month).
|
| 260 |
+
The list covers from 소한 of the given year through 소한 of the next year.
|
| 261 |
+
"""
|
| 262 |
+
results = []
|
| 263 |
+
for name_kr, name_hanja, lon, is_jol, saju_month in JOL_TERMS:
|
| 264 |
+
if saju_month is None:
|
| 265 |
+
continue
|
| 266 |
+
# 소한(285°) and 대설(255°) occur at end of solar year
|
| 267 |
+
# 입춘(315°) through 소한 next cycle
|
| 268 |
+
search_year = year
|
| 269 |
+
# For terms after winter solstice (소한 285°, 입춘 315°, etc.)
|
| 270 |
+
# that actually occur in late year or early next year
|
| 271 |
+
if lon >= 285: # 소한, ���춘
|
| 272 |
+
# 소한 is early January, 입춘 is early February
|
| 273 |
+
# These happen in the calendar year = year
|
| 274 |
+
pass
|
| 275 |
+
dt = get_solar_term_date(search_year, lon)
|
| 276 |
+
results.append((name_kr, name_hanja, lon, dt, saju_month))
|
| 277 |
+
|
| 278 |
+
return results
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def get_ipchun_date(year: int) -> datetime:
|
| 282 |
+
"""Get the exact 입춘 (立春, longitude 315°) date for a given year."""
|
| 283 |
+
return get_solar_term_date(year, 315.0)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# =============================================================================
|
| 287 |
+
# 3. JULIAN DAY NUMBER
|
| 288 |
+
# =============================================================================
|
| 289 |
+
|
| 290 |
+
def julian_day_number(year: int, month: int, day: int) -> float:
|
| 291 |
+
"""Calculate Julian Day Number for a Gregorian calendar date.
|
| 292 |
+
|
| 293 |
+
Standard formula from Meeus "Astronomical Algorithms" ch.7.
|
| 294 |
+
"""
|
| 295 |
+
y = year
|
| 296 |
+
m = month
|
| 297 |
+
if m <= 2:
|
| 298 |
+
y -= 1
|
| 299 |
+
m += 12
|
| 300 |
+
A = y // 100
|
| 301 |
+
B = 2 - A + A // 4
|
| 302 |
+
jd = int(365.25 * (y + 4716)) + int(30.6001 * (m + 1)) + day + B - 1524.5
|
| 303 |
+
return jd
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# =============================================================================
|
| 307 |
+
# 4. LUNAR-SOLAR CONVERSION
|
| 308 |
+
# =============================================================================
|
| 309 |
+
|
| 310 |
+
def lunar_to_solar(year: int, month: int, day: int, is_leap_month: bool = False) -> Tuple[int, int, int]:
|
| 311 |
+
"""Convert a Korean lunar date to solar (Gregorian) date.
|
| 312 |
+
|
| 313 |
+
Returns (solar_year, solar_month, solar_day).
|
| 314 |
+
"""
|
| 315 |
+
cal = KoreanLunarCalendar()
|
| 316 |
+
cal.setLunarDate(year, month, day, is_leap_month)
|
| 317 |
+
solar_iso = cal.SolarIsoFormat() # "YYYY-MM-DD"
|
| 318 |
+
parts = solar_iso.split("-")
|
| 319 |
+
return int(parts[0]), int(parts[1]), int(parts[2])
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def solar_to_lunar(year: int, month: int, day: int) -> Tuple[int, int, int, bool]:
|
| 323 |
+
"""Convert a solar (Gregorian) date to Korean lunar date.
|
| 324 |
+
|
| 325 |
+
Returns (lunar_year, lunar_month, lunar_day, is_leap_month).
|
| 326 |
+
"""
|
| 327 |
+
cal = KoreanLunarCalendar()
|
| 328 |
+
cal.setSolarDate(year, month, day)
|
| 329 |
+
lunar_iso = cal.LunarIsoFormat() # "YYYY-MM-DD" (leap month has special notation)
|
| 330 |
+
ly = cal.lunarYear
|
| 331 |
+
lm = cal.lunarMonth
|
| 332 |
+
ld = cal.lunarDay
|
| 333 |
+
is_leap = cal.isIntercalation
|
| 334 |
+
return ly, lm, ld, is_leap
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
# =============================================================================
|
| 338 |
+
# 5. YEAR PILLAR (년주)
|
| 339 |
+
# =============================================================================
|
| 340 |
+
|
| 341 |
+
def calc_year_pillar(solar_year: int, solar_month: int, solar_day: int,
|
| 342 |
+
solar_hour: int = 0, solar_minute: int = 0) -> Tuple[int, int]:
|
| 343 |
+
"""Calculate the year pillar (년주).
|
| 344 |
+
|
| 345 |
+
The saju year changes at 입춘 (立春), NOT January 1st.
|
| 346 |
+
If the date is before 입춘 of the given year, use the previous year.
|
| 347 |
+
|
| 348 |
+
Returns (stem_idx, branch_idx) where:
|
| 349 |
+
stem_idx is 0-9 index into CHEONGAN
|
| 350 |
+
branch_idx is 0-11 index into JIJI
|
| 351 |
+
"""
|
| 352 |
+
ipchun = get_ipchun_date(solar_year)
|
| 353 |
+
birth_dt = datetime(solar_year, solar_month, solar_day, solar_hour, solar_minute)
|
| 354 |
+
|
| 355 |
+
if birth_dt < ipchun:
|
| 356 |
+
adjusted_year = solar_year - 1
|
| 357 |
+
else:
|
| 358 |
+
adjusted_year = solar_year
|
| 359 |
+
|
| 360 |
+
# Standard formula: 甲子 year = 4 CE (year 4 in the cycle)
|
| 361 |
+
stem_idx = (adjusted_year - 4) % 10
|
| 362 |
+
branch_idx = (adjusted_year - 4) % 12
|
| 363 |
+
|
| 364 |
+
return stem_idx, branch_idx
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# =============================================================================
|
| 368 |
+
# 6. MONTH PILLAR (월주)
|
| 369 |
+
# =============================================================================
|
| 370 |
+
|
| 371 |
+
# 오호결원법 (Five Tiger Starting Method)
|
| 372 |
+
# Maps year stem (mod 5) to the starting stem for 인월 (Month 1)
|
| 373 |
+
OHOGYEOL_START = {
|
| 374 |
+
0: 2, # 갑(0)/기(5) year -> 병인 (stem 2 = 병)
|
| 375 |
+
1: 4, # 을(1)/경(6) year -> 무인 (stem 4 = 무)
|
| 376 |
+
2: 6, # 병(2)/신(7) year -> 경인 (stem 6 = 경)
|
| 377 |
+
3: 8, # 정(3)/임(8) year -> 임인 (stem 8 = 임)
|
| 378 |
+
4: 0, # 무(4)/계(9) year -> 갑인 (stem 0 = 갑)
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _get_jol_boundaries_for_date(solar_year: int, solar_month: int, solar_day: int) -> Tuple[int, int]:
|
| 383 |
+
"""Determine which 절기 month a date falls into and the year context.
|
| 384 |
+
|
| 385 |
+
Returns (saju_month, saju_year_for_month) where:
|
| 386 |
+
saju_month is 1-12 (1=인월 at 입춘, 12=축월 at 소한)
|
| 387 |
+
saju_year_for_month is the adjusted saju year for month stem calc
|
| 388 |
+
"""
|
| 389 |
+
birth_dt = datetime(solar_year, solar_month, solar_day)
|
| 390 |
+
|
| 391 |
+
# We need to check jol dates from the previous year's 소한 through
|
| 392 |
+
# the current year's 대설, and possibly next year's 소한.
|
| 393 |
+
|
| 394 |
+
# Build a complete ordered list of jol boundaries
|
| 395 |
+
boundaries = []
|
| 396 |
+
|
| 397 |
+
# Get 소한 and 대설 from the previous year (for dates in early January)
|
| 398 |
+
for name_kr, name_hanja, lon, is_jol, saju_month in JOL_TERMS:
|
| 399 |
+
search_year = solar_year - 1 if lon >= 255 else solar_year # Rough heuristic
|
| 400 |
+
# More precise: get all jol for solar_year-1 and solar_year
|
| 401 |
+
|
| 402 |
+
# Better approach: get jol dates for both years and sort
|
| 403 |
+
all_boundaries = []
|
| 404 |
+
|
| 405 |
+
for check_year in [solar_year - 1, solar_year, solar_year + 1]:
|
| 406 |
+
for name_kr, name_hanja, lon, is_jol, saju_month in JOL_TERMS:
|
| 407 |
+
dt = get_solar_term_date(check_year, lon)
|
| 408 |
+
all_boundaries.append((dt, saju_month, name_kr, check_year))
|
| 409 |
+
|
| 410 |
+
# Sort by date
|
| 411 |
+
all_boundaries.sort(key=lambda x: x[0])
|
| 412 |
+
|
| 413 |
+
# Remove duplicates that are too close (same term computed from different years)
|
| 414 |
+
filtered = [all_boundaries[0]]
|
| 415 |
+
for i in range(1, len(all_boundaries)):
|
| 416 |
+
if abs((all_boundaries[i][0] - filtered[-1][0]).total_seconds()) > 86400 * 15:
|
| 417 |
+
filtered.append(all_boundaries[i])
|
| 418 |
+
all_boundaries = filtered
|
| 419 |
+
|
| 420 |
+
# Find which interval the birth date falls into
|
| 421 |
+
saju_month = 12 # default: before first 소한 = 축월
|
| 422 |
+
for i in range(len(all_boundaries) - 1, -1, -1):
|
| 423 |
+
if birth_dt >= datetime(all_boundaries[i][0].year, all_boundaries[i][0].month,
|
| 424 |
+
all_boundaries[i][0].day):
|
| 425 |
+
saju_month = all_boundaries[i][1]
|
| 426 |
+
break
|
| 427 |
+
|
| 428 |
+
return saju_month
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def calc_month_pillar(solar_year: int, solar_month: int, solar_day: int,
|
| 432 |
+
year_stem_idx: int) -> Tuple[int, int]:
|
| 433 |
+
"""Calculate the month pillar (월주).
|
| 434 |
+
|
| 435 |
+
Uses 절기 boundaries for the month and 오호결원법 for the stem.
|
| 436 |
+
|
| 437 |
+
Args:
|
| 438 |
+
solar_year, solar_month, solar_day: Solar date
|
| 439 |
+
year_stem_idx: The stem index (0-9) of the year pillar
|
| 440 |
+
|
| 441 |
+
Returns (stem_idx, branch_idx)
|
| 442 |
+
"""
|
| 443 |
+
saju_month = _get_jol_boundaries_for_date(solar_year, solar_month, solar_day)
|
| 444 |
+
|
| 445 |
+
# Branch: saju_month -> branch index
|
| 446 |
+
branch_idx = SAJU_MONTH_TO_BRANCH[saju_month]
|
| 447 |
+
|
| 448 |
+
# Stem: 오호결원법
|
| 449 |
+
# The starting stem for 인월 (month 1) is determined by year stem % 5
|
| 450 |
+
stem_start = OHOGYEOL_START[year_stem_idx % 5]
|
| 451 |
+
|
| 452 |
+
# Month 1 (인월) gets stem_start.
|
| 453 |
+
# Month 2 (묘월) gets stem_start + 1, etc.
|
| 454 |
+
# month_offset = saju_month - 1 (since month 1 is the base)
|
| 455 |
+
month_offset = saju_month - 1
|
| 456 |
+
stem_idx = (stem_start + month_offset) % 10
|
| 457 |
+
|
| 458 |
+
return stem_idx, branch_idx
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
# =============================================================================
|
| 462 |
+
# 7. DAY PILLAR (일주)
|
| 463 |
+
# =============================================================================
|
| 464 |
+
|
| 465 |
+
# Reference: 2000년 1월 7일 (양력) = 甲子 (갑자일, pillar index 0)
|
| 466 |
+
# JDN for 2000-01-07 = 2451551.5 (at noon)
|
| 467 |
+
_REF_JDN = 2451550.5 # 2000-01-06 at midnight -> 갑자 day starts
|
| 468 |
+
# Actually, let's use a verified reference:
|
| 469 |
+
# 2000-01-07 is 갑자일. JDN(2000,1,7) = 2451551.5
|
| 470 |
+
# The 60-cycle index for this day is 0 (갑자)
|
| 471 |
+
# So: cycle_index = (JDN - 2451551) % 60... but JDN is at noon
|
| 472 |
+
# Let's use integer JDN at noon for simplicity
|
| 473 |
+
_REF_JIAZI_JDN = 2451551 # JDN at noon for 2000-01-07 = 甲子
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def calc_day_pillar(solar_year: int, solar_month: int, solar_day: int,
|
| 477 |
+
solar_hour: int = 0) -> Tuple[int, int]:
|
| 478 |
+
"""Calculate the day pillar (일주).
|
| 479 |
+
|
| 480 |
+
Uses Julian Day Number for precise calculation.
|
| 481 |
+
|
| 482 |
+
IMPORTANT: If the hour is 23:00 or later (자시 of next day),
|
| 483 |
+
the day pillar advances to the next day.
|
| 484 |
+
|
| 485 |
+
Returns (stem_idx, branch_idx)
|
| 486 |
+
"""
|
| 487 |
+
# Adjust for 야자시 (23:00+ is next day's 자시)
|
| 488 |
+
y, m, d = solar_year, solar_month, solar_day
|
| 489 |
+
if solar_hour >= 23:
|
| 490 |
+
# Advance to next day
|
| 491 |
+
next_day = datetime(y, m, d) + timedelta(days=1)
|
| 492 |
+
y, m, d = next_day.year, next_day.month, next_day.day
|
| 493 |
+
|
| 494 |
+
jdn = int(julian_day_number(y, m, d) + 0.5) # Round to integer JDN
|
| 495 |
+
|
| 496 |
+
# Calculate 60-cycle index
|
| 497 |
+
cycle_idx = (jdn - _REF_JIAZI_JDN) % 60
|
| 498 |
+
|
| 499 |
+
stem_idx = cycle_idx % 10
|
| 500 |
+
branch_idx = cycle_idx % 12
|
| 501 |
+
|
| 502 |
+
return stem_idx, branch_idx
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
# =============================================================================
|
| 506 |
+
# 8. HOUR PILLAR (시주)
|
| 507 |
+
# =============================================================================
|
| 508 |
+
|
| 509 |
+
# 시진 (時辰): 2-hour periods mapped to branches
|
| 510 |
+
# 23:00-00:59 -> 자(0)
|
| 511 |
+
# 01:00-02:59 -> 축(1)
|
| 512 |
+
# 03:00-04:59 -> 인(2)
|
| 513 |
+
# 05:00-06:59 -> 묘(3)
|
| 514 |
+
# 07:00-08:59 -> 진(4)
|
| 515 |
+
# 09:00-10:59 -> 사(5)
|
| 516 |
+
# 11:00-12:59 -> 오(6)
|
| 517 |
+
# 13:00-14:59 -> 미(7)
|
| 518 |
+
# 15:00-16:59 -> 신(8)
|
| 519 |
+
# 17:00-18:59 -> 유(9)
|
| 520 |
+
# 19:00-20:59 -> 술(10)
|
| 521 |
+
# 21:00-22:59 -> 해(11)
|
| 522 |
+
|
| 523 |
+
# 오자결원법 (Five Rat Starting Method)
|
| 524 |
+
# Maps day stem (mod 5) to the starting stem for 자시
|
| 525 |
+
OJAGYEOL_START = {
|
| 526 |
+
0: 0, # 갑(0)/기(5) day -> 갑자시 (stem 0 = 갑)
|
| 527 |
+
1: 2, # 을(1)/경(6) day -> 병자시 (stem 2 = 병)
|
| 528 |
+
2: 4, # 병(2)/신(7) day -> 무자시 (stem 4 = 무)
|
| 529 |
+
3: 6, # 정(3)/임(8) day -> 경자시 (stem 6 = 경)
|
| 530 |
+
4: 8, # 무(4)/계(9) day -> 임자시 (stem 8 = 임)
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def calc_hour_pillar(solar_hour: int, day_stem_idx: int) -> Tuple[int, int]:
|
| 535 |
+
"""Calculate the hour pillar (시주).
|
| 536 |
+
|
| 537 |
+
Args:
|
| 538 |
+
solar_hour: Hour (0-23) of birth
|
| 539 |
+
day_stem_idx: The stem index (0-9) of the day pillar
|
| 540 |
+
NOTE: If hour >= 23, this should be the NEXT day's stem
|
| 541 |
+
|
| 542 |
+
Returns (stem_idx, branch_idx)
|
| 543 |
+
"""
|
| 544 |
+
# Map hour to branch index (시진)
|
| 545 |
+
hour_branch_idx = ((solar_hour + 1) // 2) % 12
|
| 546 |
+
|
| 547 |
+
# Stem: 오자결원법
|
| 548 |
+
stem_start = OJAGYEOL_START[day_stem_idx % 5]
|
| 549 |
+
hour_stem_idx = (stem_start + hour_branch_idx) % 10
|
| 550 |
+
|
| 551 |
+
return hour_stem_idx, hour_branch_idx
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
# =============================================================================
|
| 555 |
+
# 9. FIVE ELEMENTS ANALYSIS (오행 분석)
|
| 556 |
+
# =============================================================================
|
| 557 |
+
|
| 558 |
+
def calc_oheng_balance(pillars: List[Tuple[int, int]]) -> Tuple[Dict[str, int], Dict[str, int]]:
|
| 559 |
+
"""Calculate the five elements balance across all pillars.
|
| 560 |
+
|
| 561 |
+
Args:
|
| 562 |
+
pillars: List of (stem_idx, branch_idx) for year/month/day/hour
|
| 563 |
+
|
| 564 |
+
Returns:
|
| 565 |
+
(raw_counts, percentages) — both are dicts keyed by element name (목/화/토/금/수)
|
| 566 |
+
"""
|
| 567 |
+
balance = {"목": 0, "화": 0, "토": 0, "금": 0, "수": 0}
|
| 568 |
+
|
| 569 |
+
for stem_idx, branch_idx in pillars:
|
| 570 |
+
stem_element = OHENG_MAP[CHEONGAN[stem_idx]]
|
| 571 |
+
branch_element = OHENG_MAP[JIJI[branch_idx]]
|
| 572 |
+
balance[stem_element] += 1
|
| 573 |
+
balance[branch_element] += 1
|
| 574 |
+
|
| 575 |
+
total = sum(balance.values())
|
| 576 |
+
if total == 0:
|
| 577 |
+
percentages = {k: 0 for k in balance}
|
| 578 |
+
else:
|
| 579 |
+
percentages = {k: round(v / total * 100) for k, v in balance.items()}
|
| 580 |
+
|
| 581 |
+
return balance, percentages
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def get_dominant_element(balance: Dict[str, int]) -> str:
|
| 585 |
+
"""Get the most dominant element."""
|
| 586 |
+
return max(balance, key=balance.get)
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def get_weak_element(balance: Dict[str, int]) -> str:
|
| 590 |
+
"""Get the weakest element."""
|
| 591 |
+
return min(balance, key=balance.get)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
# =============================================================================
|
| 595 |
+
# 10. 십성 (10 Gods / Ten Stars)
|
| 596 |
+
# =============================================================================
|
| 597 |
+
|
| 598 |
+
SIPSEONG_NAMES = [
|
| 599 |
+
"비견", "겁재", "식신", "상관", "편재",
|
| 600 |
+
"정재", "편관", "정관", "편인", "정인",
|
| 601 |
+
]
|
| 602 |
+
|
| 603 |
+
SIPSEONG_HANJA = [
|
| 604 |
+
"比肩", "劫財", "食神", "傷官", "偏財",
|
| 605 |
+
"正財", "偏官", "正官", "偏印", "正印",
|
| 606 |
+
]
|
| 607 |
+
|
| 608 |
+
# Element interaction table for 십성:
|
| 609 |
+
# Based on the day master's element:
|
| 610 |
+
# same element -> 비견(same yin/yang) or 겁재(different)
|
| 611 |
+
# I produce -> 식신(same yin/yang) or 상관(different)
|
| 612 |
+
# I overcome -> 편재(same yin/yang) or 정재(different)
|
| 613 |
+
# overcomes me -> 편관(same yin/yang) or 정관(different)
|
| 614 |
+
# produces me -> 편인(same yin/yang) or 정인(different)
|
| 615 |
+
|
| 616 |
+
# Production cycle: 목->화->토->금->수->목
|
| 617 |
+
# Overcoming cycle: 목->토->수->화->금->목
|
| 618 |
+
|
| 619 |
+
PRODUCTION_CYCLE = {"목": "화", "화": "토", "토": "금", "금": "수", "수": "목"}
|
| 620 |
+
OVERCOME_CYCLE = {"목": "토", "토": "수", "수": "화", "화": "금", "금": "목"}
|
| 621 |
+
PRODUCED_BY = {v: k for k, v in PRODUCTION_CYCLE.items()}
|
| 622 |
+
OVERCOME_BY = {v: k for k, v in OVERCOME_CYCLE.items()}
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def calc_sipseong(day_stem_idx: int, other_stem_idx: int) -> str:
|
| 626 |
+
"""Calculate the 십성 (10 Gods) relationship.
|
| 627 |
+
|
| 628 |
+
The relationship is determined by:
|
| 629 |
+
1. The five-element interaction between day stem and other stem
|
| 630 |
+
2. Whether they share the same yin/yang polarity
|
| 631 |
+
|
| 632 |
+
Args:
|
| 633 |
+
day_stem_idx: The day master stem index (0-9)
|
| 634 |
+
other_stem_idx: The other stem index (0-9)
|
| 635 |
+
|
| 636 |
+
Returns:
|
| 637 |
+
Name of the 십성 relationship (Korean)
|
| 638 |
+
"""
|
| 639 |
+
# Direct index-based approach: the 10 gods cycle exactly with stem difference
|
| 640 |
+
diff = (other_stem_idx - day_stem_idx) % 10
|
| 641 |
+
return SIPSEONG_NAMES[diff]
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def calc_sipseong_for_branch(day_stem_idx: int, branch_idx: int) -> str:
|
| 645 |
+
"""Calculate 십성 for a branch (using the branch's hidden stem / 지장간).
|
| 646 |
+
|
| 647 |
+
Uses the primary hidden stem of each branch.
|
| 648 |
+
"""
|
| 649 |
+
# Primary hidden stems for each branch (주기 / 본기)
|
| 650 |
+
BRANCH_PRIMARY_STEM = {
|
| 651 |
+
0: 9, # 자 -> 계(9)
|
| 652 |
+
1: 5, # 축 -> 기(5)
|
| 653 |
+
2: 0, # 인 -> 갑(0)
|
| 654 |
+
3: 1, # 묘 -> 을(1)
|
| 655 |
+
4: 4, # 진 -> 무(4)
|
| 656 |
+
5: 2, # 사 -> 병(2)
|
| 657 |
+
6: 3, # 오 -> 정(3)
|
| 658 |
+
7: 5, # 미 -> 기(5)
|
| 659 |
+
8: 6, # 신 -> 경(6)
|
| 660 |
+
9: 7, # 유 -> 신(7)
|
| 661 |
+
10: 4, # 술 -> 무(4)
|
| 662 |
+
11: 8, # 해 -> 임(8)
|
| 663 |
+
}
|
| 664 |
+
hidden_stem = BRANCH_PRIMARY_STEM[branch_idx]
|
| 665 |
+
return calc_sipseong(day_stem_idx, hidden_stem)
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
# =============================================================================
|
| 669 |
+
# 11. 지장간 (Hidden Stems in Branches)
|
| 670 |
+
# =============================================================================
|
| 671 |
+
|
| 672 |
+
# Each branch contains 1-3 hidden stems: (stem_idx, proportion_days)
|
| 673 |
+
# Order: 여기(余氣), 중기(中氣), 정기(正氣/本氣)
|
| 674 |
+
JIJANGGAN = {
|
| 675 |
+
0: [(9, 30)], # 자: 계(30)
|
| 676 |
+
1: [(9, 9), (7, 3), (5, 18)], # 축: 계9, 신3, 기18
|
| 677 |
+
2: [(4, 7), (2, 7), (0, 16)], # 인: 무7, 병7, 갑16
|
| 678 |
+
3: [(1, 30)], # 묘: 을(30) -- sometimes (0, 10), (1, 20)
|
| 679 |
+
4: [(1, 9), (9, 3), (4, 18)], # 진: 을9, 계3, 무18
|
| 680 |
+
5: [(4, 7), (6, 7), (2, 16)], # 사: 무7, 경7, 병16
|
| 681 |
+
6: [(2, 11), (5, 9), (3, 10)], # 오: 병11, 기9, 정10 -- some say (3,30)
|
| 682 |
+
7: [(3, 9), (1, 3), (5, 18)], # 미: 정9, 을3, 기18
|
| 683 |
+
8: [(4, 7), (8, 7), (6, 16)], # 신: 무7, 임7, 경16 -- some say 기 not 무
|
| 684 |
+
9: [(7, 30)], # 유: 신(30)
|
| 685 |
+
10: [(7, 9), (3, 3), (4, 18)], # 술: 신9, 정3, 무18
|
| 686 |
+
11: [(4, 7), (0, 5), (8, 18)], # 해: 무7, 갑5, 임18 -- some say 무5, 갑7
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def get_jijanggan(branch_idx: int) -> List[Tuple[str, str, int]]:
|
| 691 |
+
"""Get the hidden stems for a branch.
|
| 692 |
+
|
| 693 |
+
Returns list of (stem_hangul, stem_hanja, days).
|
| 694 |
+
"""
|
| 695 |
+
result = []
|
| 696 |
+
for stem_idx, days in JIJANGGAN[branch_idx]:
|
| 697 |
+
result.append((CHEONGAN[stem_idx], CHEONGAN_HANJA[stem_idx], days))
|
| 698 |
+
return result
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
# =============================================================================
|
| 702 |
+
# 12. 12운성 (Twelve Lifecycle Stages)
|
| 703 |
+
# =============================================================================
|
| 704 |
+
|
| 705 |
+
TWELVE_STAGES = [
|
| 706 |
+
"장생", "목욕", "관대", "건록", "제왕",
|
| 707 |
+
"쇠", "병", "사", "묘", "절", "태", "양",
|
| 708 |
+
]
|
| 709 |
+
|
| 710 |
+
TWELVE_STAGES_HANJA = [
|
| 711 |
+
"長生", "沐浴", "冠帶", "建祿", "帝旺",
|
| 712 |
+
"衰", "病", "死", "墓", "絶", "胎", "養",
|
| 713 |
+
]
|
| 714 |
+
|
| 715 |
+
# Standard 12운성 table:
|
| 716 |
+
# For each stem, the branch where 장생 starts, and direction (forward/backward)
|
| 717 |
+
# Yang stems (갑,병,무,경,임) go FORWARD
|
| 718 |
+
# Yin stems (을,정,기,신,계) go BACKWARD
|
| 719 |
+
#
|
| 720 |
+
# 장생 positions (branch index):
|
| 721 |
+
# 갑(0, 목양) -> 해(11)에서 장생 forward
|
| 722 |
+
# 을(1, 목음) -> 오(6)에서 장생 backward
|
| 723 |
+
# 병(2, 화양) -> 인(2)에서 장생 forward
|
| 724 |
+
# 정(3, 화음) -> 유(9)에서 장생 backward
|
| 725 |
+
# 무(4, 토양) -> 인(2)에서 장생 forward (무 follows 병)
|
| 726 |
+
# 기(5, 토음) -> 유(9)에서 장생 backward (기 follows 정)
|
| 727 |
+
# 경(6, 금양) -> 사(5)에서 장생 forward
|
| 728 |
+
# 신(7, 금음) -> 자(0)에서 장생 backward
|
| 729 |
+
# 임(8, 수양) -> 신(8)에서 장생 forward
|
| 730 |
+
# 계(9, 수음) -> 묘(3)에서 장생 backward
|
| 731 |
+
|
| 732 |
+
_TWELVE_STAGE_JANGSAENG = {
|
| 733 |
+
0: (11, 1), # 갑 -> 해, forward
|
| 734 |
+
1: (6, -1), # 을 -> 오, backward
|
| 735 |
+
2: (2, 1), # 병 -> 인, forward
|
| 736 |
+
3: (9, -1), # 정 -> 유, backward
|
| 737 |
+
4: (2, 1), # 무 -> 인, forward
|
| 738 |
+
5: (9, -1), # 기 -> 유, backward
|
| 739 |
+
6: (5, 1), # 경 -> 사, forward
|
| 740 |
+
7: (0, -1), # 신 -> 자, backward
|
| 741 |
+
8: (8, 1), # 임 -> 신, forward
|
| 742 |
+
9: (3, -1), # 계 -> 묘, backward
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def calc_twelve_stage(day_stem_idx: int, branch_idx: int) -> str:
|
| 747 |
+
"""Calculate the 12운성 for a given day stem against a branch.
|
| 748 |
+
|
| 749 |
+
Args:
|
| 750 |
+
day_stem_idx: The day master stem index (0-9)
|
| 751 |
+
branch_idx: The branch index (0-11) to evaluate
|
| 752 |
+
|
| 753 |
+
Returns:
|
| 754 |
+
Name of the lifecycle stage (Korean)
|
| 755 |
+
"""
|
| 756 |
+
start_branch, direction = _TWELVE_STAGE_JANGSAENG[day_stem_idx]
|
| 757 |
+
|
| 758 |
+
if direction == 1: # Forward (양)
|
| 759 |
+
stage_idx = (branch_idx - start_branch) % 12
|
| 760 |
+
else: # Backward (음)
|
| 761 |
+
stage_idx = (start_branch - branch_idx) % 12
|
| 762 |
+
|
| 763 |
+
return TWELVE_STAGES[stage_idx]
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
# =============================================================================
|
| 767 |
+
# 13. 대운 (Major Fortune Cycles) and 세운 (Annual Fortune)
|
| 768 |
+
# =============================================================================
|
| 769 |
+
|
| 770 |
+
def calc_daeun(gender: str, year_stem_idx: int,
|
| 771 |
+
month_stem_idx: int, month_branch_idx: int,
|
| 772 |
+
birth_year: int, birth_month: int, birth_day: int,
|
| 773 |
+
birth_hour: int = 12) -> List[Dict]:
|
| 774 |
+
"""Calculate 대운 (10-year major fortune cycles).
|
| 775 |
+
|
| 776 |
+
Direction:
|
| 777 |
+
- Male + yang year stem -> forward (순행)
|
| 778 |
+
- Female + yin year stem -> forward (순행)
|
| 779 |
+
- Male + yin year stem -> backward (역행)
|
| 780 |
+
- Female + yang year stem -> backward (역행)
|
| 781 |
+
|
| 782 |
+
Start age: calculated from birth date to the nearest 절기 boundary,
|
| 783 |
+
converted to years (3 days ≈ 1 year in saju tradition).
|
| 784 |
+
|
| 785 |
+
Returns list of dicts: [{age, stem_idx, branch_idx, hangul, hanja}, ...]
|
| 786 |
+
"""
|
| 787 |
+
is_male = gender.lower() in ("male", "남", "남자", "m")
|
| 788 |
+
is_yang_year = (year_stem_idx % 2 == 0)
|
| 789 |
+
|
| 790 |
+
# Determine direction
|
| 791 |
+
if (is_male and is_yang_year) or (not is_male and not is_yang_year):
|
| 792 |
+
forward = True # 순행
|
| 793 |
+
else:
|
| 794 |
+
forward = False # 역행
|
| 795 |
+
|
| 796 |
+
# Calculate start age by finding the nearest 절기 boundary
|
| 797 |
+
birth_dt = datetime(birth_year, birth_month, birth_day, birth_hour)
|
| 798 |
+
|
| 799 |
+
# Get all jol dates for the birth year and adjacent years
|
| 800 |
+
all_jol = []
|
| 801 |
+
for y in [birth_year - 1, birth_year, birth_year + 1]:
|
| 802 |
+
for name_kr, name_hanja, lon, is_jol, saju_month in JOL_TERMS:
|
| 803 |
+
dt = get_solar_term_date(y, lon)
|
| 804 |
+
all_jol.append(dt)
|
| 805 |
+
all_jol.sort()
|
| 806 |
+
|
| 807 |
+
# Find the nearest jol AFTER and BEFORE birth
|
| 808 |
+
next_jol = None
|
| 809 |
+
prev_jol = None
|
| 810 |
+
for dt in all_jol:
|
| 811 |
+
if dt > birth_dt and next_jol is None:
|
| 812 |
+
next_jol = dt
|
| 813 |
+
if dt <= birth_dt:
|
| 814 |
+
prev_jol = dt
|
| 815 |
+
|
| 816 |
+
# Use forward or backward jol
|
| 817 |
+
if forward:
|
| 818 |
+
target_jol = next_jol
|
| 819 |
+
else:
|
| 820 |
+
target_jol = prev_jol
|
| 821 |
+
|
| 822 |
+
if target_jol is None:
|
| 823 |
+
days_diff = 0
|
| 824 |
+
else:
|
| 825 |
+
days_diff = abs((target_jol - birth_dt).total_seconds()) / 86400.0
|
| 826 |
+
|
| 827 |
+
# Convert days to 대운 start age: 3 days = 1 year
|
| 828 |
+
start_age = round(days_diff / 3.0)
|
| 829 |
+
if start_age < 1:
|
| 830 |
+
start_age = 1
|
| 831 |
+
|
| 832 |
+
# Generate 대운 pillars (10 cycles, each lasting 10 years)
|
| 833 |
+
result = []
|
| 834 |
+
|
| 835 |
+
# Starting sexagenary index from the month pillar
|
| 836 |
+
month_60_idx = None
|
| 837 |
+
for i in range(60):
|
| 838 |
+
if i % 10 == month_stem_idx and i % 12 == month_branch_idx:
|
| 839 |
+
month_60_idx = i
|
| 840 |
+
break
|
| 841 |
+
|
| 842 |
+
if month_60_idx is None:
|
| 843 |
+
month_60_idx = 0 # fallback
|
| 844 |
+
|
| 845 |
+
for i in range(1, 13): # Generate 12 대운 periods
|
| 846 |
+
if forward:
|
| 847 |
+
idx = (month_60_idx + i) % 60
|
| 848 |
+
else:
|
| 849 |
+
idx = (month_60_idx - i) % 60
|
| 850 |
+
|
| 851 |
+
s_idx = idx % 10
|
| 852 |
+
b_idx = idx % 12
|
| 853 |
+
age = start_age + (i - 1) * 10
|
| 854 |
+
|
| 855 |
+
result.append({
|
| 856 |
+
"age": age,
|
| 857 |
+
"end_age": age + 9,
|
| 858 |
+
"stem_idx": s_idx,
|
| 859 |
+
"branch_idx": b_idx,
|
| 860 |
+
"hangul": CHEONGAN[s_idx] + JIJI[b_idx],
|
| 861 |
+
"hanja": CHEONGAN_HANJA[s_idx] + JIJI_HANJA[b_idx],
|
| 862 |
+
"element": OHENG_MAP[CHEONGAN[s_idx]],
|
| 863 |
+
})
|
| 864 |
+
|
| 865 |
+
return result
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def calc_seun(target_year: int) -> Tuple[int, int]:
|
| 869 |
+
"""Calculate 세운 (annual fortune) for a given year.
|
| 870 |
+
|
| 871 |
+
Returns (stem_idx, branch_idx) for the year's heavenly stem and earthly branch.
|
| 872 |
+
"""
|
| 873 |
+
stem_idx = (target_year - 4) % 10
|
| 874 |
+
branch_idx = (target_year - 4) % 12
|
| 875 |
+
return stem_idx, branch_idx
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
# =============================================================================
|
| 879 |
+
# 14. ADDITIONAL RELATIONS
|
| 880 |
+
# =============================================================================
|
| 881 |
+
|
| 882 |
+
# 삼합 (Three Harmony)
|
| 883 |
+
SAMHAP = {
|
| 884 |
+
# branch_idx: (partner1, partner2, resulting_element)
|
| 885 |
+
2: (6, 10, "화"), # 인오술 -> 화
|
| 886 |
+
5: (9, 1, "금"), # 사유축 -> 금
|
| 887 |
+
8: (0, 4, "수"), # 신자진 -> 수
|
| 888 |
+
11: (3, 7, "목"), # 해묘미 -> 목
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
# 육합 (Six Harmonies)
|
| 892 |
+
YUKHAP = {
|
| 893 |
+
0: 1, # 자축합 -> 토
|
| 894 |
+
1: 0,
|
| 895 |
+
2: 11, # 인해합 -> 목
|
| 896 |
+
11: 2,
|
| 897 |
+
3: 10, # 묘술합 -> 화
|
| 898 |
+
10: 3,
|
| 899 |
+
4: 9, # 진유합 -> 금
|
| 900 |
+
9: 4,
|
| 901 |
+
5: 8, # 사신합 -> 수
|
| 902 |
+
8: 5,
|
| 903 |
+
6: 7, # 오미합 -> 화(토)
|
| 904 |
+
7: 6,
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
# 충 (Clash/Opposition)
|
| 908 |
+
CHUNG = {
|
| 909 |
+
0: 6, # 자오충
|
| 910 |
+
1: 7, # 축미충
|
| 911 |
+
2: 8, # 인신충
|
| 912 |
+
3: 9, # 묘유충
|
| 913 |
+
4: 10, # 진술충
|
| 914 |
+
5: 11, # 사해충
|
| 915 |
+
6: 0, 7: 1, 8: 2, 9: 3, 10: 4, 11: 5,
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
# 형 (Punishment)
|
| 919 |
+
HYEONG = {
|
| 920 |
+
2: 5, # 인사형 (무례지형)
|
| 921 |
+
5: 8, # 사신형
|
| 922 |
+
8: 2, # 신인형
|
| 923 |
+
1: 10, # 축술형 (무은지형)
|
| 924 |
+
10: 7, # 술미형
|
| 925 |
+
7: 1, # 미축형
|
| 926 |
+
0: 3, # 자묘형 (무례지형)
|
| 927 |
+
3: 0, # 묘자형
|
| 928 |
+
# 자형 (self-punishment)
|
| 929 |
+
4: 4, # 진진 자형
|
| 930 |
+
6: 6, # 오오 자형
|
| 931 |
+
9: 9, # 유유 자형
|
| 932 |
+
11: 11, # 해해 자형
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def check_relations(branch_indices: List[int]) -> Dict[str, List]:
|
| 937 |
+
"""Check for notable relations among the four pillar branches.
|
| 938 |
+
|
| 939 |
+
Returns dict with keys: 'samhap', 'yukhap', 'chung', 'hyeong'
|
| 940 |
+
"""
|
| 941 |
+
relations = {"samhap": [], "yukhap": [], "chung": [], "hyeong": []}
|
| 942 |
+
|
| 943 |
+
n = len(branch_indices)
|
| 944 |
+
|
| 945 |
+
# Check 충 (clashes)
|
| 946 |
+
for i in range(n):
|
| 947 |
+
for j in range(i + 1, n):
|
| 948 |
+
if CHUNG.get(branch_indices[i]) == branch_indices[j]:
|
| 949 |
+
relations["chung"].append(
|
| 950 |
+
(JIJI[branch_indices[i]], JIJI[branch_indices[j]])
|
| 951 |
+
)
|
| 952 |
+
|
| 953 |
+
# Check 육합
|
| 954 |
+
for i in range(n):
|
| 955 |
+
for j in range(i + 1, n):
|
| 956 |
+
if YUKHAP.get(branch_indices[i]) == branch_indices[j]:
|
| 957 |
+
relations["yukhap"].append(
|
| 958 |
+
(JIJI[branch_indices[i]], JIJI[branch_indices[j]])
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
# Check 형
|
| 962 |
+
for i in range(n):
|
| 963 |
+
for j in range(i + 1, n):
|
| 964 |
+
if HYEONG.get(branch_indices[i]) == branch_indices[j]:
|
| 965 |
+
relations["hyeong"].append(
|
| 966 |
+
(JIJI[branch_indices[i]], JIJI[branch_indices[j]])
|
| 967 |
+
)
|
| 968 |
+
|
| 969 |
+
# Check 삼합 (need 3 branches)
|
| 970 |
+
branch_set = set(branch_indices)
|
| 971 |
+
for key, (p1, p2, element) in SAMHAP.items():
|
| 972 |
+
if key in branch_set and p1 in branch_set and p2 in branch_set:
|
| 973 |
+
relations["samhap"].append(
|
| 974 |
+
(JIJI[key], JIJI[p1], JIJI[p2], element)
|
| 975 |
+
)
|
| 976 |
+
|
| 977 |
+
return relations
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
# =============================================================================
|
| 981 |
+
# 15. MAIN CLASS: SajuManseryeok
|
| 982 |
+
# =============================================================================
|
| 983 |
+
|
| 984 |
+
class SajuManseryeok:
|
| 985 |
+
"""Complete Four Pillars (사주팔��) calculation engine.
|
| 986 |
+
|
| 987 |
+
Usage:
|
| 988 |
+
saju = SajuManseryeok(1990, 5, 15, hour=14, gender="male")
|
| 989 |
+
print(saju.saju_text)
|
| 990 |
+
print(saju.oheng_balance)
|
| 991 |
+
"""
|
| 992 |
+
|
| 993 |
+
def __init__(self, year: int, month: int, day: int,
|
| 994 |
+
hour: int = 12, minute: int = 0,
|
| 995 |
+
is_lunar: bool = False, is_leap_month: bool = False,
|
| 996 |
+
gender: str = "male"):
|
| 997 |
+
"""
|
| 998 |
+
Args:
|
| 999 |
+
year, month, day: Birth date
|
| 1000 |
+
hour: Birth hour (0-23). Default 12 (noon) if unknown.
|
| 1001 |
+
minute: Birth minute (0-59).
|
| 1002 |
+
is_lunar: If True, the input date is in Korean lunar calendar
|
| 1003 |
+
is_leap_month: If True and is_lunar, this is a leap month
|
| 1004 |
+
gender: "male"/"남"/"m" or "female"/"여"/"f"
|
| 1005 |
+
"""
|
| 1006 |
+
self.input_year = year
|
| 1007 |
+
self.input_month = month
|
| 1008 |
+
self.input_day = day
|
| 1009 |
+
self.input_hour = hour
|
| 1010 |
+
self.input_minute = minute
|
| 1011 |
+
self.is_lunar = is_lunar
|
| 1012 |
+
self.is_leap_month = is_leap_month
|
| 1013 |
+
self.gender = gender
|
| 1014 |
+
|
| 1015 |
+
# Convert lunar to solar if needed
|
| 1016 |
+
if is_lunar:
|
| 1017 |
+
sy, sm, sd = lunar_to_solar(year, month, day, is_leap_month)
|
| 1018 |
+
self.solar_year = sy
|
| 1019 |
+
self.solar_month = sm
|
| 1020 |
+
self.solar_day = sd
|
| 1021 |
+
self.lunar_year = year
|
| 1022 |
+
self.lunar_month = month
|
| 1023 |
+
self.lunar_day = day
|
| 1024 |
+
self.lunar_is_leap = is_leap_month
|
| 1025 |
+
else:
|
| 1026 |
+
self.solar_year = year
|
| 1027 |
+
self.solar_month = sm if False else month # type trick avoided
|
| 1028 |
+
self.solar_month = month
|
| 1029 |
+
self.solar_day = day
|
| 1030 |
+
ly, lm, ld, ll = solar_to_lunar(year, month, day)
|
| 1031 |
+
self.lunar_year = ly
|
| 1032 |
+
self.lunar_month = lm
|
| 1033 |
+
self.lunar_day = ld
|
| 1034 |
+
self.lunar_is_leap = ll
|
| 1035 |
+
|
| 1036 |
+
# --- Calculate all four pillars ---
|
| 1037 |
+
|
| 1038 |
+
# Year pillar
|
| 1039 |
+
self._year_stem, self._year_branch = calc_year_pillar(
|
| 1040 |
+
self.solar_year, self.solar_month, self.solar_day,
|
| 1041 |
+
hour, minute
|
| 1042 |
+
)
|
| 1043 |
+
|
| 1044 |
+
# Month pillar
|
| 1045 |
+
self._month_stem, self._month_branch = calc_month_pillar(
|
| 1046 |
+
self.solar_year, self.solar_month, self.solar_day,
|
| 1047 |
+
self._year_stem
|
| 1048 |
+
)
|
| 1049 |
+
|
| 1050 |
+
# Day pillar (handles 야자시 internally)
|
| 1051 |
+
self._day_stem, self._day_branch = calc_day_pillar(
|
| 1052 |
+
self.solar_year, self.solar_month, self.solar_day,
|
| 1053 |
+
hour
|
| 1054 |
+
)
|
| 1055 |
+
|
| 1056 |
+
# Hour pillar
|
| 1057 |
+
# For 야자시 (23:00+), the day stem used for hour calculation
|
| 1058 |
+
# must be the NEXT day's day stem
|
| 1059 |
+
self._hour_stem, self._hour_branch = calc_hour_pillar(
|
| 1060 |
+
hour, self._day_stem
|
| 1061 |
+
)
|
| 1062 |
+
|
| 1063 |
+
# Pillars as list
|
| 1064 |
+
self._pillars = [
|
| 1065 |
+
(self._year_stem, self._year_branch),
|
| 1066 |
+
(self._month_stem, self._month_branch),
|
| 1067 |
+
(self._day_stem, self._day_branch),
|
| 1068 |
+
(self._hour_stem, self._hour_branch),
|
| 1069 |
+
]
|
| 1070 |
+
|
| 1071 |
+
# Five elements
|
| 1072 |
+
self._balance, self._percentages = calc_oheng_balance(self._pillars)
|
| 1073 |
+
|
| 1074 |
+
# --- Properties ---
|
| 1075 |
+
|
| 1076 |
+
@property
|
| 1077 |
+
def year_pillar(self) -> Tuple[int, int]:
|
| 1078 |
+
return (self._year_stem, self._year_branch)
|
| 1079 |
+
|
| 1080 |
+
@property
|
| 1081 |
+
def month_pillar(self) -> Tuple[int, int]:
|
| 1082 |
+
return (self._month_stem, self._month_branch)
|
| 1083 |
+
|
| 1084 |
+
@property
|
| 1085 |
+
def day_pillar(self) -> Tuple[int, int]:
|
| 1086 |
+
return (self._day_stem, self._day_branch)
|
| 1087 |
+
|
| 1088 |
+
@property
|
| 1089 |
+
def hour_pillar(self) -> Tuple[int, int]:
|
| 1090 |
+
return (self._hour_stem, self._hour_branch)
|
| 1091 |
+
|
| 1092 |
+
@property
|
| 1093 |
+
def day_master(self) -> str:
|
| 1094 |
+
"""The day stem (일간/일주천간) — the core identity in saju."""
|
| 1095 |
+
return CHEONGAN[self._day_stem]
|
| 1096 |
+
|
| 1097 |
+
@property
|
| 1098 |
+
def day_master_element(self) -> str:
|
| 1099 |
+
"""The five element of the day master."""
|
| 1100 |
+
return OHENG_MAP[self.day_master]
|
| 1101 |
+
|
| 1102 |
+
@property
|
| 1103 |
+
def day_master_yinyang(self) -> str:
|
| 1104 |
+
"""Yin/Yang of the day master."""
|
| 1105 |
+
return STEM_YINYANG[self._day_stem]
|
| 1106 |
+
|
| 1107 |
+
@property
|
| 1108 |
+
def saju_pillars(self) -> Dict:
|
| 1109 |
+
"""Returns structured pillar data matching frontend format."""
|
| 1110 |
+
def _fmt(stem_idx, branch_idx):
|
| 1111 |
+
return {
|
| 1112 |
+
"stem": CHEONGAN[stem_idx],
|
| 1113 |
+
"branch": JIJI[branch_idx],
|
| 1114 |
+
"hangul": CHEONGAN[stem_idx] + JIJI[branch_idx],
|
| 1115 |
+
"hanja": CHEONGAN_HANJA[stem_idx] + JIJI_HANJA[branch_idx],
|
| 1116 |
+
"stem_hanja": CHEONGAN_HANJA[stem_idx],
|
| 1117 |
+
"branch_hanja": JIJI_HANJA[branch_idx],
|
| 1118 |
+
"stem_element": OHENG_MAP[CHEONGAN[stem_idx]],
|
| 1119 |
+
"branch_element": OHENG_MAP[JIJI[branch_idx]],
|
| 1120 |
+
"stem_element_hanja": OHENG_NAMES[OHENG_MAP[CHEONGAN[stem_idx]]],
|
| 1121 |
+
"branch_element_hanja": OHENG_NAMES[OHENG_MAP[JIJI[branch_idx]]],
|
| 1122 |
+
"stem_yinyang": STEM_YINYANG[stem_idx],
|
| 1123 |
+
"branch_yinyang": BRANCH_YINYANG[branch_idx],
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
return {
|
| 1127 |
+
"year": _fmt(self._year_stem, self._year_branch),
|
| 1128 |
+
"month": _fmt(self._month_stem, self._month_branch),
|
| 1129 |
+
"day": _fmt(self._day_stem, self._day_branch),
|
| 1130 |
+
"hour": _fmt(self._hour_stem, self._hour_branch),
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
+
@property
|
| 1134 |
+
def saju_text(self) -> str:
|
| 1135 |
+
"""Compact text representation: 시 일 월 년 (right to left, traditional)."""
|
| 1136 |
+
p = self.saju_pillars
|
| 1137 |
+
return (f"{p['hour']['hangul']} {p['day']['hangul']} "
|
| 1138 |
+
f"{p['month']['hangul']} {p['year']['hangul']}")
|
| 1139 |
+
|
| 1140 |
+
@property
|
| 1141 |
+
def saju_text_hanja(self) -> str:
|
| 1142 |
+
p = self.saju_pillars
|
| 1143 |
+
return (f"{p['hour']['hanja']} {p['day']['hanja']} "
|
| 1144 |
+
f"{p['month']['hanja']} {p['year']['hanja']}")
|
| 1145 |
+
|
| 1146 |
+
@property
|
| 1147 |
+
def animal_sign(self) -> str:
|
| 1148 |
+
"""The zodiac animal for the year branch."""
|
| 1149 |
+
return JIJI_ANIMALS[self._year_branch]
|
| 1150 |
+
|
| 1151 |
+
@property
|
| 1152 |
+
def oheng_balance(self) -> Dict[str, int]:
|
| 1153 |
+
"""Raw element counts."""
|
| 1154 |
+
return dict(self._balance)
|
| 1155 |
+
|
| 1156 |
+
@property
|
| 1157 |
+
def oheng_percentages(self) -> Dict[str, int]:
|
| 1158 |
+
"""Element percentages."""
|
| 1159 |
+
return dict(self._percentages)
|
| 1160 |
+
|
| 1161 |
+
@property
|
| 1162 |
+
def oheng_balance_english(self) -> Dict[str, int]:
|
| 1163 |
+
"""Element percentages with English keys for frontend."""
|
| 1164 |
+
return {OHENG_ENGLISH[k]: v for k, v in self._percentages.items()}
|
| 1165 |
+
|
| 1166 |
+
@property
|
| 1167 |
+
def dominant_element(self) -> str:
|
| 1168 |
+
return get_dominant_element(self._balance)
|
| 1169 |
+
|
| 1170 |
+
@property
|
| 1171 |
+
def weak_element(self) -> str:
|
| 1172 |
+
return get_weak_element(self._balance)
|
| 1173 |
+
|
| 1174 |
+
def get_sipseong_table(self) -> Dict[str, Dict[str, str]]:
|
| 1175 |
+
"""Get 십성 for all stems relative to day master.
|
| 1176 |
+
|
| 1177 |
+
Returns dict keyed by pillar name with stem and branch 십성.
|
| 1178 |
+
"""
|
| 1179 |
+
result = {}
|
| 1180 |
+
pillar_names = ["year", "month", "day", "hour"]
|
| 1181 |
+
|
| 1182 |
+
for name, (s_idx, b_idx) in zip(pillar_names, self._pillars):
|
| 1183 |
+
stem_ss = calc_sipseong(self._day_stem, s_idx) if name != "day" else "일간"
|
| 1184 |
+
branch_ss = calc_sipseong_for_branch(self._day_stem, b_idx)
|
| 1185 |
+
result[name] = {
|
| 1186 |
+
"stem_sipseong": stem_ss,
|
| 1187 |
+
"branch_sipseong": branch_ss,
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
return result
|
| 1191 |
+
|
| 1192 |
+
def get_twelve_stages(self) -> Dict[str, str]:
|
| 1193 |
+
"""Get 12운성 for each pillar's branch relative to day master."""
|
| 1194 |
+
result = {}
|
| 1195 |
+
pillar_names = ["year", "month", "day", "hour"]
|
| 1196 |
+
|
| 1197 |
+
for name, (_, b_idx) in zip(pillar_names, self._pillars):
|
| 1198 |
+
result[name] = calc_twelve_stage(self._day_stem, b_idx)
|
| 1199 |
+
|
| 1200 |
+
return result
|
| 1201 |
+
|
| 1202 |
+
def get_jijanggan_table(self) -> Dict[str, List]:
|
| 1203 |
+
"""Get 지장간 for each pillar's branch."""
|
| 1204 |
+
result = {}
|
| 1205 |
+
pillar_names = ["year", "month", "day", "hour"]
|
| 1206 |
+
|
| 1207 |
+
for name, (_, b_idx) in zip(pillar_names, self._pillars):
|
| 1208 |
+
result[name] = get_jijanggan(b_idx)
|
| 1209 |
+
|
| 1210 |
+
return result
|
| 1211 |
+
|
| 1212 |
+
def get_daeun(self) -> List[Dict]:
|
| 1213 |
+
"""Get 대운 (10-year fortune cycles)."""
|
| 1214 |
+
return calc_daeun(
|
| 1215 |
+
self.gender, self._year_stem,
|
| 1216 |
+
self._month_stem, self._month_branch,
|
| 1217 |
+
self.solar_year, self.solar_month, self.solar_day,
|
| 1218 |
+
self.input_hour
|
| 1219 |
+
)
|
| 1220 |
+
|
| 1221 |
+
def get_seun(self, target_year: int) -> Dict:
|
| 1222 |
+
"""Get 세운 (annual fortune) for a specific year."""
|
| 1223 |
+
s_idx, b_idx = calc_seun(target_year)
|
| 1224 |
+
return {
|
| 1225 |
+
"year": target_year,
|
| 1226 |
+
"stem_idx": s_idx,
|
| 1227 |
+
"branch_idx": b_idx,
|
| 1228 |
+
"hangul": CHEONGAN[s_idx] + JIJI[b_idx],
|
| 1229 |
+
"hanja": CHEONGAN_HANJA[s_idx] + JIJI_HANJA[b_idx],
|
| 1230 |
+
"element": OHENG_MAP[CHEONGAN[s_idx]],
|
| 1231 |
+
"animal": JIJI_ANIMALS[b_idx],
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
def get_relations(self) -> Dict:
|
| 1235 |
+
"""Check for 삼합, 육합, 충, 형 among the four pillars."""
|
| 1236 |
+
branches = [b for _, b in self._pillars]
|
| 1237 |
+
return check_relations(branches)
|
| 1238 |
+
|
| 1239 |
+
def to_dict(self) -> Dict:
|
| 1240 |
+
"""Export everything as a flat dictionary for JSON serialization."""
|
| 1241 |
+
pillars = self.saju_pillars
|
| 1242 |
+
stages = self.get_twelve_stages()
|
| 1243 |
+
sipseong = self.get_sipseong_table()
|
| 1244 |
+
jijanggan = self.get_jijanggan_table()
|
| 1245 |
+
relations = self.get_relations()
|
| 1246 |
+
|
| 1247 |
+
return {
|
| 1248 |
+
"input": {
|
| 1249 |
+
"year": self.input_year,
|
| 1250 |
+
"month": self.input_month,
|
| 1251 |
+
"day": self.input_day,
|
| 1252 |
+
"hour": self.input_hour,
|
| 1253 |
+
"minute": self.input_minute,
|
| 1254 |
+
"is_lunar": self.is_lunar,
|
| 1255 |
+
"gender": self.gender,
|
| 1256 |
+
},
|
| 1257 |
+
"solar_date": {
|
| 1258 |
+
"year": self.solar_year,
|
| 1259 |
+
"month": self.solar_month,
|
| 1260 |
+
"day": self.solar_day,
|
| 1261 |
+
},
|
| 1262 |
+
"lunar_date": {
|
| 1263 |
+
"year": self.lunar_year,
|
| 1264 |
+
"month": self.lunar_month,
|
| 1265 |
+
"day": self.lunar_day,
|
| 1266 |
+
"is_leap": self.lunar_is_leap,
|
| 1267 |
+
},
|
| 1268 |
+
"pillars": pillars,
|
| 1269 |
+
"day_master": {
|
| 1270 |
+
"hangul": self.day_master,
|
| 1271 |
+
"hanja": CHEONGAN_HANJA[self._day_stem],
|
| 1272 |
+
"element": self.day_master_element,
|
| 1273 |
+
"element_hanja": OHENG_NAMES[self.day_master_element],
|
| 1274 |
+
"yinyang": self.day_master_yinyang,
|
| 1275 |
+
},
|
| 1276 |
+
"animal_sign": self.animal_sign,
|
| 1277 |
+
"oheng": {
|
| 1278 |
+
"balance": self.oheng_balance,
|
| 1279 |
+
"percentages": self.oheng_percentages,
|
| 1280 |
+
"percentages_en": self.oheng_balance_english,
|
| 1281 |
+
"dominant": self.dominant_element,
|
| 1282 |
+
"dominant_hanja": OHENG_NAMES[self.dominant_element],
|
| 1283 |
+
"weak": self.weak_element,
|
| 1284 |
+
"weak_hanja": OHENG_NAMES[self.weak_element],
|
| 1285 |
+
},
|
| 1286 |
+
"sipseong": sipseong,
|
| 1287 |
+
"twelve_stages": stages,
|
| 1288 |
+
"jijanggan": jijanggan,
|
| 1289 |
+
"relations": relations,
|
| 1290 |
+
}
|
| 1291 |
+
|
| 1292 |
+
def to_prompt_context(self) -> str:
|
| 1293 |
+
"""Generate a rich context string for LLM interpretation prompts."""
|
| 1294 |
+
p = self.saju_pillars
|
| 1295 |
+
stages = self.get_twelve_stages()
|
| 1296 |
+
sipseong = self.get_sipseong_table()
|
| 1297 |
+
daeun = self.get_daeun()
|
| 1298 |
+
relations = self.get_relations()
|
| 1299 |
+
bal = self.oheng_balance
|
| 1300 |
+
pct = self.oheng_percentages
|
| 1301 |
+
|
| 1302 |
+
lines = []
|
| 1303 |
+
lines.append("=" * 60)
|
| 1304 |
+
lines.append("사주팔자 (四柱八字) 만세력 분석")
|
| 1305 |
+
lines.append("=" * 60)
|
| 1306 |
+
|
| 1307 |
+
# Birth info
|
| 1308 |
+
lines.append(f"\n[생년월일시]")
|
| 1309 |
+
lines.append(f" 양력: {self.solar_year}년 {self.solar_month}월 {self.solar_day}일 {self.input_hour}시")
|
| 1310 |
+
lines.append(f" 음력: {self.lunar_year}년 {self.lunar_month}월 {self.lunar_day}일"
|
| 1311 |
+
f"{'(윤달)' if self.lunar_is_leap else ''}")
|
| 1312 |
+
lines.append(f" 성별: {'남' if self.gender.lower() in ('male','남','남자','m') else '여'}")
|
| 1313 |
+
lines.append(f" 띠: {self.animal_sign}")
|
| 1314 |
+
|
| 1315 |
+
# Four pillars table
|
| 1316 |
+
lines.append(f"\n[사주팔자]")
|
| 1317 |
+
lines.append(f" 시주 일주 월주 년주")
|
| 1318 |
+
lines.append(f" 한글: {p['hour']['hangul']} {p['day']['hangul']} "
|
| 1319 |
+
f"{p['month']['hangul']} {p['year']['hangul']}")
|
| 1320 |
+
lines.append(f" 한자: {p['hour']['hanja']} {p['day']['hanja']} "
|
| 1321 |
+
f"{p['month']['hanja']} {p['year']['hanja']}")
|
| 1322 |
+
|
| 1323 |
+
# Stems line
|
| 1324 |
+
lines.append(f"\n 천간: {p['hour']['stem']}({p['hour']['stem_element']}) "
|
| 1325 |
+
f"{p['day']['stem']}({p['day']['stem_element']}) "
|
| 1326 |
+
f"{p['month']['stem']}({p['month']['stem_element']}) "
|
| 1327 |
+
f"{p['year']['stem']}({p['year']['stem_element']})")
|
| 1328 |
+
lines.append(f" 지지: {p['hour']['branch']}({p['hour']['branch_element']}) "
|
| 1329 |
+
f"{p['day']['branch']}({p['day']['branch_element']}) "
|
| 1330 |
+
f"{p['month']['branch']}({p['month']['branch_element']}) "
|
| 1331 |
+
f"{p['year']['branch']}({p['year']['branch_element']})")
|
| 1332 |
+
|
| 1333 |
+
# Day master
|
| 1334 |
+
lines.append(f"\n[일간 (Day Master)]")
|
| 1335 |
+
lines.append(f" {self.day_master}({CHEONGAN_HANJA[self._day_stem]}) "
|
| 1336 |
+
f"— {self.day_master_element}({OHENG_NAMES[self.day_master_element]}) "
|
| 1337 |
+
f"{self.day_master_yinyang}")
|
| 1338 |
+
|
| 1339 |
+
# 십성
|
| 1340 |
+
lines.append(f"\n[십성 (Ten Gods)]")
|
| 1341 |
+
for name_kr, name_en in [("년주","year"),("월주","month"),("일주","day"),("시주","hour")]:
|
| 1342 |
+
ss = sipseong[name_en]
|
| 1343 |
+
lines.append(f" {name_kr}: 천간={ss['stem_sipseong']}, 지지={ss['branch_sipseong']}")
|
| 1344 |
+
|
| 1345 |
+
# 12운성
|
| 1346 |
+
lines.append(f"\n[12운성 (Twelve Stages)]")
|
| 1347 |
+
for name_kr, name_en in [("년주","year"),("월주","month"),("일주","day"),("시주","hour")]:
|
| 1348 |
+
lines.append(f" {name_kr}: {stages[name_en]}")
|
| 1349 |
+
|
| 1350 |
+
# 오행
|
| 1351 |
+
lines.append(f"\n[오행 분석 (Five Elements)]")
|
| 1352 |
+
for elem in ["목", "화", "토", "금", "수"]:
|
| 1353 |
+
bar = "█" * bal[elem] + "░" * (8 - bal[elem])
|
| 1354 |
+
lines.append(f" {elem}({OHENG_NAMES[elem]}): {bar} {bal[elem]}개 ({pct[elem]}%)")
|
| 1355 |
+
lines.append(f" 강한 오행: {self.dominant_element}({OHENG_NAMES[self.dominant_element]})")
|
| 1356 |
+
lines.append(f" 약한 오행: {self.weak_element}({OHENG_NAMES[self.weak_element]})")
|
| 1357 |
+
|
| 1358 |
+
# Relations
|
| 1359 |
+
lines.append(f"\n[합충형 관계]")
|
| 1360 |
+
if relations["samhap"]:
|
| 1361 |
+
for r in relations["samhap"]:
|
| 1362 |
+
lines.append(f" 삼합: {r[0]}{r[1]}{r[2]} -> {r[3]}")
|
| 1363 |
+
if relations["yukhap"]:
|
| 1364 |
+
for r in relations["yukhap"]:
|
| 1365 |
+
lines.append(f" 육합: {r[0]}{r[1]}")
|
| 1366 |
+
if relations["chung"]:
|
| 1367 |
+
for r in relations["chung"]:
|
| 1368 |
+
lines.append(f" 충: {r[0]}{r[1]}")
|
| 1369 |
+
if relations["hyeong"]:
|
| 1370 |
+
for r in relations["hyeong"]:
|
| 1371 |
+
lines.append(f" 형: {r[0]}{r[1]}")
|
| 1372 |
+
if not any(relations.values()):
|
| 1373 |
+
lines.append(" 특이 관계 없음")
|
| 1374 |
+
|
| 1375 |
+
# 대운
|
| 1376 |
+
lines.append(f"\n[대운 (10년 大運)]")
|
| 1377 |
+
for du in daeun[:8]: # Show first 8
|
| 1378 |
+
lines.append(f" {du['age']:2d}~{du['end_age']:2d}세: "
|
| 1379 |
+
f"{du['hangul']}({du['hanja']}) — {du['element']}")
|
| 1380 |
+
|
| 1381 |
+
lines.append("\n" + "=" * 60)
|
| 1382 |
+
|
| 1383 |
+
return "\n".join(lines)
|
| 1384 |
+
|
| 1385 |
+
def __repr__(self) -> str:
|
| 1386 |
+
p = self.saju_pillars
|
| 1387 |
+
return (f"SajuManseryeok({self.solar_year}-{self.solar_month:02d}-{self.solar_day:02d} "
|
| 1388 |
+
f"{self.input_hour:02d}:{self.input_minute:02d}, "
|
| 1389 |
+
f"{'남' if self.gender.lower() in ('male','남','남자','m') else '여'}) = "
|
| 1390 |
+
f"{p['year']['hangul']} {p['month']['hangul']} "
|
| 1391 |
+
f"{p['day']['hangul']} {p['hour']['hangul']}")
|
| 1392 |
+
|
| 1393 |
+
|
| 1394 |
+
# =============================================================================
|
| 1395 |
+
# 16. CONVENIENCE FUNCTIONS
|
| 1396 |
+
# =============================================================================
|
| 1397 |
+
|
| 1398 |
+
def calculate_saju(year: int, month: int, day: int,
|
| 1399 |
+
hour: int = 12, minute: int = 0,
|
| 1400 |
+
is_lunar: bool = False, is_leap_month: bool = False,
|
| 1401 |
+
gender: str = "male") -> SajuManseryeok:
|
| 1402 |
+
"""Convenience function to create a SajuManseryeok instance."""
|
| 1403 |
+
return SajuManseryeok(year, month, day, hour, minute,
|
| 1404 |
+
is_lunar, is_leap_month, gender)
|
| 1405 |
+
|
| 1406 |
+
|
| 1407 |
+
def quick_saju(year: int, month: int, day: int,
|
| 1408 |
+
hour: int = 12, gender: str = "male") -> str:
|
| 1409 |
+
"""Quick one-line saju result."""
|
| 1410 |
+
s = SajuManseryeok(year, month, day, hour, gender=gender)
|
| 1411 |
+
return repr(s)
|
| 1412 |
+
|
| 1413 |
+
|
| 1414 |
+
# =============================================================================
|
| 1415 |
+
# 17. VALIDATION & TEST
|
| 1416 |
+
# =============================================================================
|
| 1417 |
+
|
| 1418 |
+
if __name__ == "__main__":
|
| 1419 |
+
print("=" * 70)
|
| 1420 |
+
print("사주 만세력 (四柱 萬歲曆) — Validation Tests")
|
| 1421 |
+
print("=" * 70)
|
| 1422 |
+
|
| 1423 |
+
# ---------------------------------------------------------------
|
| 1424 |
+
# Test 1: 입춘 date calculation
|
| 1425 |
+
# ---------------------------------------------------------------
|
| 1426 |
+
print("\n--- Test 1: 입춘 (立春) dates ---")
|
| 1427 |
+
for y in [2024, 2025, 2026]:
|
| 1428 |
+
ip = get_ipchun_date(y)
|
| 1429 |
+
print(f" {y}년 입춘: {ip.strftime('%Y-%m-%d %H:%M:%S')} KST")
|
| 1430 |
+
|
| 1431 |
+
# ---------------------------------------------------------------
|
| 1432 |
+
# Test 2: Reference case — 1985-01-15 양력 08:30 남성
|
| 1433 |
+
# ---------------------------------------------------------------
|
| 1434 |
+
print("\n--- Test 2: 1985-01-15 08:30 남성 ---")
|
| 1435 |
+
print(" Expected: 년주=甲子 (갑자) — Jan 15 is before 입춘 so year is 1984")
|
| 1436 |
+
|
| 1437 |
+
saju = SajuManseryeok(1985, 1, 15, 8, 30, gender="male")
|
| 1438 |
+
p = saju.saju_pillars
|
| 1439 |
+
|
| 1440 |
+
print(f" 년주: {p['year']['hangul']} ({p['year']['hanja']})")
|
| 1441 |
+
print(f" 월주: {p['month']['hangul']} ({p['month']['hanja']})")
|
| 1442 |
+
print(f" 일주: {p['day']['hangul']} ({p['day']['hanja']})")
|
| 1443 |
+
print(f" 시주: {p['hour']['hangul']} ({p['hour']['hanja']})")
|
| 1444 |
+
print(f" 일간: {saju.day_master} ({saju.day_master_element})")
|
| 1445 |
+
print(f" 띠: {saju.animal_sign}")
|
| 1446 |
+
|
| 1447 |
+
assert p['year']['hangul'] == "갑자", f"Year pillar mismatch: got {p['year']['hangul']}"
|
| 1448 |
+
print(" [PASS] Year pillar is 갑자 (甲子) as expected")
|
| 1449 |
+
|
| 1450 |
+
# ---------------------------------------------------------------
|
| 1451 |
+
# Test 3: Day pillar via Julian Day Number
|
| 1452 |
+
# ---------------------------------------------------------------
|
| 1453 |
+
print("\n--- Test 3: Julian Day Number validation ---")
|
| 1454 |
+
# 2000-01-07 should be 甲子 (갑자)
|
| 1455 |
+
jdn = julian_day_number(2000, 1, 7)
|
| 1456 |
+
print(f" JDN(2000-01-07) = {jdn}")
|
| 1457 |
+
s, b = calc_day_pillar(2000, 1, 7)
|
| 1458 |
+
print(f" Day pillar: {CHEONGAN[s]}{JIJI[b]} ({CHEONGAN_HANJA[s]}{JIJI_HANJA[b]})")
|
| 1459 |
+
# 2000-01-07 is known to be 갑자일
|
| 1460 |
+
assert CHEONGAN[s] == "갑" and JIJI[b] == "자", \
|
| 1461 |
+
f"Day pillar for 2000-01-07 should be 갑자, got {CHEONGAN[s]}{JIJI[b]}"
|
| 1462 |
+
print(" [PASS] 2000-01-07 is 갑자 (甲子) day")
|
| 1463 |
+
|
| 1464 |
+
# ---------------------------------------------------------------
|
| 1465 |
+
# Test 4: 오호결원법 (Month stem derivation)
|
| 1466 |
+
# ---------------------------------------------------------------
|
| 1467 |
+
print("\n--- Test 4: 오호결원법 verification ---")
|
| 1468 |
+
# 갑(0) year -> 인월 stem should be 병(2) -> 병인
|
| 1469 |
+
stem_start = OHOGYEOL_START[0 % 5]
|
| 1470 |
+
assert stem_start == 2, f"갑년 인월 stem should be 병(2), got {stem_start}"
|
| 1471 |
+
print(f" 갑년 인월: {CHEONGAN[stem_start]}인 = 병인 [PASS]")
|
| 1472 |
+
|
| 1473 |
+
# 을(1) year -> 인월 stem should be 무(4) -> 무인
|
| 1474 |
+
stem_start = OHOGYEOL_START[1 % 5]
|
| 1475 |
+
assert stem_start == 4
|
| 1476 |
+
print(f" 을년 인월: {CHEONGAN[stem_start]}인 = 무인 [PASS]")
|
| 1477 |
+
|
| 1478 |
+
# ---------------------------------------------------------------
|
| 1479 |
+
# Test 5: 오자결원법 (Hour stem derivation)
|
| 1480 |
+
# ---------------------------------------------------------------
|
| 1481 |
+
print("\n--- Test 5: 오자결원법 verification ---")
|
| 1482 |
+
# 갑(0) day -> 자시 stem should be 갑(0) -> 갑자
|
| 1483 |
+
stem_start = OJAGYEOL_START[0 % 5]
|
| 1484 |
+
assert stem_start == 0
|
| 1485 |
+
print(f" 갑일 자시: {CHEONGAN[stem_start]}자 = 갑자 [PASS]")
|
| 1486 |
+
|
| 1487 |
+
# 을(1) day -> 자시 stem should be 병(2) -> 병자
|
| 1488 |
+
stem_start = OJAGYEOL_START[1 % 5]
|
| 1489 |
+
assert stem_start == 2
|
| 1490 |
+
print(f" 을일 자시: {CHEONGAN[stem_start]}자 = 병자 [PASS]")
|
| 1491 |
+
|
| 1492 |
+
# ---------------------------------------------------------------
|
| 1493 |
+
# Test 6: 야자시 (23:00) handling
|
| 1494 |
+
# ---------------------------------------------------------------
|
| 1495 |
+
print("\n--- Test 6: 야자시 handling ---")
|
| 1496 |
+
# At 23:00, the day pillar should advance to next day
|
| 1497 |
+
dp_22 = calc_day_pillar(2026, 5, 7, 22)
|
| 1498 |
+
dp_23 = calc_day_pillar(2026, 5, 7, 23)
|
| 1499 |
+
dp_next = calc_day_pillar(2026, 5, 8, 0)
|
| 1500 |
+
print(f" 2026-05-07 22시 일주: {CHEONGAN[dp_22[0]]}{JIJI[dp_22[1]]}")
|
| 1501 |
+
print(f" 2026-05-07 23시 일주: {CHEONGAN[dp_23[0]]}{JIJI[dp_23[1]]}")
|
| 1502 |
+
print(f" 2026-05-08 00시 일주: {CHEONGAN[dp_next[0]]}{JIJI[dp_next[1]]}")
|
| 1503 |
+
assert dp_23 == dp_next, "23시 day pillar should match next day's 0시"
|
| 1504 |
+
print(" [PASS] 23시 일주 = 다음날 0시 일주")
|
| 1505 |
+
|
| 1506 |
+
# ---------------------------------------------------------------
|
| 1507 |
+
# Test 7: Full saju with context output
|
| 1508 |
+
# ---------------------------------------------------------------
|
| 1509 |
+
print("\n--- Test 7: Full saju analysis ---")
|
| 1510 |
+
saju2 = SajuManseryeok(1990, 3, 15, 14, 0, gender="male")
|
| 1511 |
+
print(saju2.to_prompt_context())
|
| 1512 |
+
|
| 1513 |
+
# ---------------------------------------------------------------
|
| 1514 |
+
# Test 8: Lunar date conversion
|
| 1515 |
+
# ---------------------------------------------------------------
|
| 1516 |
+
print("\n--- Test 8: Lunar-Solar conversion ---")
|
| 1517 |
+
# Test lunar date (설날 2025 = lunar 2025-01-01 = solar 2025-01-29)
|
| 1518 |
+
try:
|
| 1519 |
+
sy, sm, sd = lunar_to_solar(2025, 1, 1)
|
| 1520 |
+
print(f" 2025년 음력 1월 1일 = 양력 {sy}-{sm:02d}-{sd:02d}")
|
| 1521 |
+
ly, lm, ld, ll = solar_to_lunar(sy, sm, sd)
|
| 1522 |
+
print(f" 역변환: 음력 {ly}-{lm:02d}-{ld:02d} (윤달: {ll})")
|
| 1523 |
+
assert lm == 1 and ld == 1, "Round-trip conversion failed"
|
| 1524 |
+
print(" [PASS] Lunar-Solar round trip")
|
| 1525 |
+
except Exception as e:
|
| 1526 |
+
print(f" [WARN] Lunar conversion: {e}")
|
| 1527 |
+
|
| 1528 |
+
# ---------------------------------------------------------------
|
| 1529 |
+
# Test 9: 12운성 spot check
|
| 1530 |
+
# ---------------------------------------------------------------
|
| 1531 |
+
print("\n--- Test 9: 12운성 spot check ---")
|
| 1532 |
+
# 갑(0) at 해(11) should be 장생
|
| 1533 |
+
stage = calc_twelve_stage(0, 11)
|
| 1534 |
+
print(f" 갑일간 at 해: {stage}")
|
| 1535 |
+
assert stage == "장생", f"갑 at 해 should be 장생, got {stage}"
|
| 1536 |
+
print(" [PASS] 갑 at 해 = 장생")
|
| 1537 |
+
|
| 1538 |
+
# 갑(0) at 자(0) should be 목욕 (해=장생, 자=목욕, 축=관대...)
|
| 1539 |
+
stage = calc_twelve_stage(0, 0)
|
| 1540 |
+
print(f" 갑일간 at 자: {stage}")
|
| 1541 |
+
assert stage == "목욕", f"갑 at 자 should be 목욕, got {stage}"
|
| 1542 |
+
print(" [PASS] 갑 at 자 = 목욕")
|
| 1543 |
+
|
| 1544 |
+
# 갑(0) at 오(6) should be 사
|
| 1545 |
+
stage = calc_twelve_stage(0, 6)
|
| 1546 |
+
print(f" 갑일간 at 오: {stage}")
|
| 1547 |
+
assert stage == "사", f"갑 at 오 should be 사, got {stage}"
|
| 1548 |
+
print(" [PASS] 갑 at 오 = 사")
|
| 1549 |
+
|
| 1550 |
+
# ---------------------------------------------------------------
|
| 1551 |
+
# Test 10: 십성 basic check
|
| 1552 |
+
# ---------------------------------------------------------------
|
| 1553 |
+
print("\n--- Test 10: 십성 basic check ---")
|
| 1554 |
+
# Day stem = 갑(0), same stem -> 비견
|
| 1555 |
+
ss = calc_sipseong(0, 0)
|
| 1556 |
+
assert ss == "비견", f"Same stem should be 비견, got {ss}"
|
| 1557 |
+
print(f" 갑 vs 갑: {ss} [PASS]")
|
| 1558 |
+
|
| 1559 |
+
# Day stem = 갑(0), stem 을(1) -> 겁재
|
| 1560 |
+
ss = calc_sipseong(0, 1)
|
| 1561 |
+
assert ss == "겁재", f"갑 vs 을 should be 겁재, got {ss}"
|
| 1562 |
+
print(f" 갑 vs 을: {ss} [PASS]")
|
| 1563 |
+
|
| 1564 |
+
# ---------------------------------------------------------------
|
| 1565 |
+
# Test 11: Multiple known dates cross-check
|
| 1566 |
+
# ---------------------------------------------------------------
|
| 1567 |
+
print("\n--- Test 11: Known dates ---")
|
| 1568 |
+
|
| 1569 |
+
# 2000-01-01 00:00 -> Year should still be 1999 (before 입춘)
|
| 1570 |
+
s0 = SajuManseryeok(2000, 1, 1, 0, gender="male")
|
| 1571 |
+
p0 = s0.saju_pillars
|
| 1572 |
+
print(f" 2000-01-01: 년주={p0['year']['hangul']}({p0['year']['hanja']})")
|
| 1573 |
+
# 1999 year: (1999-4)%10=5(기), (1999-4)%12=3(묘) -> 기묘
|
| 1574 |
+
assert p0['year']['hangul'] == "기묘", f"Expected 기묘, got {p0['year']['hangul']}"
|
| 1575 |
+
print(" [PASS] 2000-01-01 년주 = 기묘 (1999 year, before 입춘)")
|
| 1576 |
+
|
| 1577 |
+
# 2000-02-05 -> After 입춘 (2000 입춘 ~ Feb 4)
|
| 1578 |
+
s1 = SajuManseryeok(2000, 2, 5, 12, gender="male")
|
| 1579 |
+
p1 = s1.saju_pillars
|
| 1580 |
+
print(f" 2000-02-05: 년주={p1['year']['hangul']}({p1['year']['hanja']})")
|
| 1581 |
+
# 2000 year: (2000-4)%10=6(경), (2000-4)%12=4(진) -> 경진
|
| 1582 |
+
assert p1['year']['hangul'] == "경��", f"Expected 경진, got {p1['year']['hangul']}"
|
| 1583 |
+
print(" [PASS] 2000-02-05 년주 = 경진 (after 입춘)")
|
| 1584 |
+
|
| 1585 |
+
# ---------------------------------------------------------------
|
| 1586 |
+
# Test 12: 대운 generation
|
| 1587 |
+
# ---------------------------------------------------------------
|
| 1588 |
+
print("\n--- Test 12: 대운 generation ---")
|
| 1589 |
+
saju3 = SajuManseryeok(1985, 1, 15, 8, 30, gender="male")
|
| 1590 |
+
daeun = saju3.get_daeun()
|
| 1591 |
+
print(f" 대운 ({len(daeun)}개):")
|
| 1592 |
+
for d in daeun[:6]:
|
| 1593 |
+
print(f" {d['age']:2d}~{d['end_age']:2d}세: {d['hangul']} ({d['hanja']})")
|
| 1594 |
+
|
| 1595 |
+
# ---------------------------------------------------------------
|
| 1596 |
+
# Summary
|
| 1597 |
+
# ---------------------------------------------------------------
|
| 1598 |
+
print("\n" + "=" * 70)
|
| 1599 |
+
print("All validation tests passed successfully!")
|
| 1600 |
+
print("=" * 70)
|
static/index.html
CHANGED
|
@@ -1,639 +1,850 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="ko">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
| 6 |
-
<meta name="theme-color" content="#0052a4">
|
| 7 |
-
<title>[제안 테스트] JGOS · 행정 AI 서비스 특화 모델</title>
|
| 8 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
-
<link rel="stylesheet" href="/static/style.css?v=20260612a">
|
| 11 |
-
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js"></script>
|
| 12 |
-
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.7/dist/purify.min.js"></script>
|
| 13 |
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/github.min.css">
|
| 14 |
-
<script src="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
|
| 15 |
-
<style id="ax-modal-fix">#ax-modal[hidden]{display:none !important;}</style>
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
#
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
</
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
</
|
| 174 |
-
|
| 175 |
-
<
|
| 176 |
-
<
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
<
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
</div>
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
<
|
| 212 |
-
|
| 213 |
-
<
|
| 214 |
-
<
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
<
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
<
|
| 276 |
-
|
| 277 |
-
</
|
| 278 |
-
|
| 279 |
-
<
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
<
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
</
|
| 296 |
-
<
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
</
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
<
|
| 413 |
-
<
|
| 414 |
-
<
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
<
|
| 440 |
-
|
| 441 |
-
<
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
<
|
| 447 |
-
|
| 448 |
-
<
|
| 449 |
-
|
| 450 |
-
<
|
| 451 |
-
/
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
/
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
</
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
<
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
</
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
<
|
| 534 |
-
/
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
document.addEventListener("click",function(e){
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
<script
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
});
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
}
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="ko">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
| 6 |
+
<meta name="theme-color" content="#0052a4">
|
| 7 |
+
<title>[제안 테스트] JGOS · 행정 AI 서비스 특화 모델</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css?v=20260612a">
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js"></script>
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.7/dist/purify.min.js"></script>
|
| 13 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/github.min.css">
|
| 14 |
+
<script src="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
|
| 15 |
+
<style id="ax-modal-fix">#ax-modal[hidden]{display:none !important;}</style>
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
|
| 19 |
+
<style id="jgos-apple-css">
|
| 20 |
+
:root{
|
| 21 |
+
--bg:#F5F5F7; --surface:#FFFFFF; --surface-2:rgba(255,255,255,.72);
|
| 22 |
+
--ink:#1D1D1F; --ink-2:#4B4F55; --ink-3:#86868B; --hair:rgba(0,0,0,.09);
|
| 23 |
+
--blue:#0071E3; --green:#00875A; --amber:#8A6414;
|
| 24 |
+
--r-lg:22px; --r-md:16px;
|
| 25 |
+
--sh:0 1px 2px rgba(0,0,0,.04), 0 12px 32px -20px rgba(0,0,0,.30);
|
| 26 |
+
--sh-hi:0 2px 6px rgba(0,0,0,.06), 0 24px 48px -24px rgba(0,0,0,.34);
|
| 27 |
+
}
|
| 28 |
+
@media (prefers-color-scheme: dark){
|
| 29 |
+
:root{
|
| 30 |
+
--bg:#000000; --surface:#1C1C1E; --surface-2:rgba(28,28,30,.72);
|
| 31 |
+
--ink:#F5F5F7; --ink-2:#C7C9CC; --ink-3:#8E8E93; --hair:rgba(255,255,255,.12);
|
| 32 |
+
--blue:#0A84FF; --green:#30D158; --amber:#FFD60A;
|
| 33 |
+
--sh:0 1px 2px rgba(0,0,0,.5), 0 12px 32px -20px rgba(0,0,0,.9);
|
| 34 |
+
--sh-hi:0 2px 8px rgba(0,0,0,.6), 0 24px 56px -26px rgba(0,0,0,1);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
body,#app,#main{background:var(--bg)!important;color:var(--ink)!important;
|
| 38 |
+
font-family:"Pretendard Variable",Pretendard,-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",system-ui,sans-serif!important;
|
| 39 |
+
-webkit-font-smoothing:antialiased;letter-spacing:-.011em}
|
| 40 |
+
|
| 41 |
+
#topbar{background:var(--surface-2)!important;backdrop-filter:saturate(180%) blur(20px);
|
| 42 |
+
-webkit-backdrop-filter:saturate(180%) blur(20px);
|
| 43 |
+
border-bottom:1px solid var(--hair)!important;box-shadow:none!important}
|
| 44 |
+
.title-main{font-size:17px!important;font-weight:650!important;letter-spacing:-.02em!important;color:var(--ink)!important}
|
| 45 |
+
.title-sub{font-size:12.5px!important;color:var(--ink-3)!important;font-weight:400!important}
|
| 46 |
+
.top-tabs{gap:2px!important}
|
| 47 |
+
.top-tab{background:transparent!important;border:0!important;color:var(--ink-2)!important;
|
| 48 |
+
font-size:13.5px!important;font-weight:500!important;padding:7px 13px!important;border-radius:980px!important;
|
| 49 |
+
transition:background .18s,color .18s}
|
| 50 |
+
.top-tab:hover{background:rgba(127,127,127,.12)!important;color:var(--ink)!important}
|
| 51 |
+
.top-tab.active{background:var(--ink)!important;color:var(--bg)!important;font-weight:600!important}
|
| 52 |
+
#sidebar{background:var(--surface-2)!important;backdrop-filter:blur(20px);border-right:1px solid var(--hair)!important}
|
| 53 |
+
.status-pill{border-radius:980px!important;font-size:11.5px!important;color:var(--ink-3)!important;background:transparent!important;border:1px solid var(--hair)!important}
|
| 54 |
+
|
| 55 |
+
#view-home{padding:0 0 88px;overflow-y:auto;background:var(--bg)}
|
| 56 |
+
.svc-wrap{max-width:1040px;margin:0 auto;padding:0 22px}
|
| 57 |
+
.svc-hero{position:relative;margin:44px 0 40px;padding:64px 48px;border-radius:var(--r-lg);
|
| 58 |
+
background:linear-gradient(158deg,#0A2540 0%,#123A63 45%,#0071E3 130%);color:#fff;cursor:pointer;
|
| 59 |
+
overflow:hidden;box-shadow:var(--sh);transition:transform .3s cubic-bezier(.2,.8,.2,1),box-shadow .3s}
|
| 60 |
+
.svc-hero::after{content:"";position:absolute;inset:auto -18% -62% auto;width:520px;height:520px;border-radius:50%;
|
| 61 |
+
background:radial-gradient(circle,rgba(255,255,255,.16),transparent 66%);pointer-events:none}
|
| 62 |
+
.svc-hero:hover{transform:translateY(-3px);box-shadow:var(--sh-hi)}
|
| 63 |
+
.svc-hero .eyebrow{font-size:12.5px;letter-spacing:.02em;font-weight:600;color:rgba(255,255,255,.72)}
|
| 64 |
+
.svc-hero h2{margin:14px 0 12px;font-size:clamp(32px,5vw,52px);font-weight:700;letter-spacing:-.035em;line-height:1.08}
|
| 65 |
+
.svc-hero p{margin:0;font-size:17px;line-height:1.62;color:rgba(255,255,255,.88);max-width:34em;font-weight:400}
|
| 66 |
+
.svc-hero .go{display:inline-flex;align-items:center;gap:7px;margin-top:26px;background:#fff;color:#0A2540;
|
| 67 |
+
border-radius:980px;padding:12px 24px;font-weight:600;font-size:15px;letter-spacing:-.01em}
|
| 68 |
+
|
| 69 |
+
.svc-group{margin-top:52px}
|
| 70 |
+
.svc-group h3{font-size:26px;font-weight:680;letter-spacing:-.025em;margin:0 0 20px;padding:0;border:0;color:var(--ink)}
|
| 71 |
+
.svc-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(238px,1fr))}
|
| 72 |
+
.svc-card{background:var(--surface);border:0;border-radius:var(--r-md);padding:24px 22px 20px;
|
| 73 |
+
display:flex;flex-direction:column;gap:8px;cursor:pointer;box-shadow:var(--sh);
|
| 74 |
+
transition:transform .28s cubic-bezier(.2,.8,.2,1),box-shadow .28s}
|
| 75 |
+
.svc-card:hover,.svc-card:focus-visible{transform:translateY(-4px);box-shadow:var(--sh-hi);outline:none}
|
| 76 |
+
.svc-card .row{display:flex;align-items:flex-start;gap:9px}
|
| 77 |
+
.svc-card h4{margin:0;font-size:17px;font-weight:620;letter-spacing:-.02em;line-height:1.35;color:var(--ink)}
|
| 78 |
+
.svc-card p{margin:0;font-size:13.5px;line-height:1.62;color:var(--ink-3);font-weight:400}
|
| 79 |
+
.svc-st{flex:none;margin-left:auto;font-size:11px;font-weight:600;padding:3px 9px;border-radius:980px;white-space:nowrap}
|
| 80 |
+
.st-live{background:rgba(0,135,90,.11);color:var(--green)}
|
| 81 |
+
.st-soon{background:rgba(138,100,20,.12);color:var(--amber)}
|
| 82 |
+
.svc-card.soon{cursor:default;box-shadow:none;background:transparent;border:1px solid var(--hair)}
|
| 83 |
+
.svc-card.soon:hover{transform:none;box-shadow:none}
|
| 84 |
+
|
| 85 |
+
#view-saju{padding:0 0 88px;overflow-y:auto;background:var(--bg)}
|
| 86 |
+
.saju-wrap{max-width:880px;margin:0 auto;padding:52px 22px 0}
|
| 87 |
+
.saju-head h2{font-size:clamp(30px,4.4vw,44px);font-weight:700;letter-spacing:-.035em;margin:0 0 10px}
|
| 88 |
+
.saju-head p{margin:0 0 30px;color:var(--ink-3);font-size:16px;line-height:1.6}
|
| 89 |
+
.saju-form{background:var(--surface);border-radius:var(--r-md);padding:22px;box-shadow:var(--sh)}
|
| 90 |
+
.saju-row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end}
|
| 91 |
+
.saju-row label{display:flex;flex-direction:column;gap:7px;font-size:12.5px;font-weight:600;color:var(--ink-3);flex:1 1 150px}
|
| 92 |
+
.saju-row input,.saju-row select{font-family:inherit;font-size:15px;color:var(--ink);background:var(--bg);
|
| 93 |
+
border:1px solid var(--hair);border-radius:11px;padding:11px 13px;font-weight:450;-webkit-appearance:none;appearance:none}
|
| 94 |
+
.saju-row input:focus,.saju-row select:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3.5px rgba(0,113,227,.16)}
|
| 95 |
+
#sj-go{flex:0 0 auto;background:var(--blue);color:#fff;border:0;border-radius:980px;padding:12px 26px;
|
| 96 |
+
font-family:inherit;font-size:15px;font-weight:600;cursor:pointer;letter-spacing:-.01em;transition:opacity .18s}
|
| 97 |
+
#sj-go:hover{opacity:.86}
|
| 98 |
+
#sj-go:disabled{opacity:.45;cursor:default}
|
| 99 |
+
.saju-msg{margin-top:18px;padding:14px 16px;border-radius:12px;background:var(--surface);color:var(--ink-2);font-size:14px;box-shadow:var(--sh)}
|
| 100 |
+
.saju-result{margin-top:26px;display:flex;flex-direction:column;gap:16px}
|
| 101 |
+
.sj-card{background:var(--surface);border-radius:var(--r-md);padding:26px 24px;box-shadow:var(--sh)}
|
| 102 |
+
.sj-card h3{margin:0 0 4px;font-size:13px;font-weight:600;color:var(--ink-3)}
|
| 103 |
+
.sj-pillars{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:16px}
|
| 104 |
+
.sj-p{text-align:center;background:var(--bg);border-radius:13px;padding:18px 8px}
|
| 105 |
+
.sj-p .lab{font-size:11.5px;color:var(--ink-3);font-weight:600}
|
| 106 |
+
.sj-p .han{font-size:clamp(26px,4.6vw,38px);font-weight:600;letter-spacing:-.02em;margin:8px 0 2px;line-height:1.1}
|
| 107 |
+
.sj-p .kor{font-size:13px;color:var(--ink-2)}
|
| 108 |
+
.sj-meta{display:flex;flex-wrap:wrap;gap:9px;margin-top:18px}
|
| 109 |
+
.sj-tag{background:var(--bg);border-radius:980px;padding:7px 14px;font-size:13px;color:var(--ink-2);font-weight:500}
|
| 110 |
+
.sj-tag b{color:var(--ink);font-weight:640}
|
| 111 |
+
.sj-bars{display:flex;flex-direction:column;gap:11px;margin-top:16px}
|
| 112 |
+
.sj-bar{display:grid;grid-template-columns:34px 1fr 46px;align-items:center;gap:12px;font-size:13.5px}
|
| 113 |
+
.sj-bar .n{font-weight:620;color:var(--ink)}
|
| 114 |
+
.sj-bar .t{height:8px;border-radius:980px;background:rgba(127,127,127,.16);overflow:hidden}
|
| 115 |
+
.sj-bar .f{height:100%;border-radius:980px;background:var(--blue);transition:width .7s cubic-bezier(.2,.8,.2,1)}
|
| 116 |
+
.sj-bar .v{text-align:right;color:var(--ink-3);font-variant-numeric:tabular-nums}
|
| 117 |
+
.saju-note{margin:26px 0 0;font-size:12.5px;color:var(--ink-3);text-align:center}
|
| 118 |
+
|
| 119 |
+
#view-building,#view-texray{padding:0;background:var(--bg)}
|
| 120 |
+
#building-frame,#texray-frame{width:100%;height:calc(100vh - 168px);border:0;display:block;background:var(--bg)}
|
| 121 |
+
@media(max-width:720px){.svc-hero{padding:44px 26px}.sj-pillars{grid-template-columns:repeat(2,1fr)}}
|
| 122 |
+
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
| 123 |
+
</style>
|
| 124 |
+
</head>
|
| 125 |
+
<body>
|
| 126 |
+
<div id="proposal-banner" style="background:#b91c1c;color:#fff;text-align:center;padding:7px 12px;font-size:13px;font-weight:600;line-height:1.45;position:relative;z-index:9999;">⚠️ 본 서비스는 <b>제안용 테스트(시범) 버전</b>입니다 — 실제 행정 서비스가 아니며, 응답의 정확성을 보장하지 않습니다.</div>
|
| 127 |
+
<div id="app">
|
| 128 |
+
|
| 129 |
+
<!-- ===== 모바일 햄버거 ===== -->
|
| 130 |
+
<button id="hamburger-btn" aria-label="메뉴 열기">
|
| 131 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 132 |
+
<line x1="3" y1="6" x2="21" y2="6"/>
|
| 133 |
+
<line x1="3" y1="12" x2="21" y2="12"/>
|
| 134 |
+
<line x1="3" y1="18" x2="21" y2="18"/>
|
| 135 |
+
</svg>
|
| 136 |
+
</button>
|
| 137 |
+
|
| 138 |
+
<!-- ===== 사이드바 ===== -->
|
| 139 |
+
<aside id="sidebar">
|
| 140 |
+
<div class="sidebar-head">
|
| 141 |
+
<div class="brand-top">
|
| 142 |
+
<div class="emblem">🏛️</div>
|
| 143 |
+
<div>
|
| 144 |
+
<div class="brand-title">전남광주 통합특별시</div>
|
| 145 |
+
<div class="brand-sub">시민의 AI</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
<button id="new-chat-btn" title="새 대화">+ 새 상담</button>
|
| 149 |
+
</div>
|
| 150 |
+
<div class="sidebar-section-label">최근 상담</div>
|
| 151 |
+
<ul id="chat-list"></ul>
|
| 152 |
+
<div class="sidebar-foot">
|
| 153 |
+
<div class="brand-small">
|
| 154 |
+
<strong>Powered by @비드래프트</strong>
|
| 155 |
+
<small>JGOS v0.1 preview</small>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</aside>
|
| 159 |
+
|
| 160 |
+
<main id="main">
|
| 161 |
+
<!-- ===== 상단 시민포털 헤더 ===== -->
|
| 162 |
+
<header id="topbar">
|
| 163 |
+
<div class="topbar-left">
|
| 164 |
+
<button id="sidebar-toggle" class="mobile-only" aria-label="사이드바">☰</button>
|
| 165 |
+
<div class="title-block">
|
| 166 |
+
<div class="title-main">🏛️ 전남광주 통합특별시 · 시민의 AI</div>
|
| 167 |
+
<div class="title-sub">행정 특화 언어모델 <b>JGOS</b> 탑재 · 행정·법령·관광·민원 안내</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
<nav class="top-tabs">
|
| 171 |
+
<button class="top-tab active" data-view="home">🏠 서비스</button>
|
| 172 |
+
<button class="top-tab" data-view="chat">💬 JGOS 챗봇</button>
|
| 173 |
+
<button class="top-tab" data-view="building">🏗️ 건축 도면</button>
|
| 174 |
+
<button class="top-tab" data-view="office">📑 무료 오피스</button>
|
| 175 |
+
<button class="top-tab" data-view="image">🎨 이미지 생성</button>
|
| 176 |
+
<button class="top-tab" data-view="saju">🔮 사주</button>
|
| 177 |
+
<button class="top-tab" data-view="texray">🔍 AI 글 판별</button>
|
| 178 |
+
<button class="top-tab" data-view="ax">🏛️ 행정 AX(테스트)</button>
|
| 179 |
+
</nav>
|
| 180 |
+
<div class="topbar-right">
|
| 181 |
+
<span class="status-pill" id="status">● 정상</span>
|
| 182 |
+
</div>
|
| 183 |
+
</header>
|
| 184 |
+
|
| 185 |
+
<div id="view-chat">
|
| 186 |
+
<div id="messages-wrap">
|
| 187 |
+
<div id="messages"></div>
|
| 188 |
+
|
| 189 |
+
<!-- ===== 첫 화면 시민포털 (메시지 0건 시 표시) ===== -->
|
| 190 |
+
<div id="portal" class="portal">
|
| 191 |
+
<div class="portal-hero">
|
| 192 |
+
<div class="hero-emblem">🏛️</div>
|
| 193 |
+
<h1 class="hero-title">전남광주 통합특별시민과<br>함께하는 AI <strong>JGOS</strong></h1>
|
| 194 |
+
<p class="hero-sub">행정·민원·법령·관광·교통·문화·통계·산업·농수산 정보를<br>친절하게 안내해 드립니다. 무엇이 궁금하신가요?</p>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- ===== 방안1: 히어로 파일 드롭존 ===== -->
|
| 198 |
+
<button class="hero-dropzone" id="hero-dropzone" type="button">
|
| 199 |
+
<div class="hd-icon">📎</div>
|
| 200 |
+
<div class="hd-main">파일을 끌어다 놓거나 클릭하세요</div>
|
| 201 |
+
<div class="hd-sub">HWP · 이미지 · PDF · 엑셀 무엇이든 올리면 <strong>바로 분석</strong>해 드립니다</div>
|
| 202 |
+
<div class="hd-badge">🇰🇷 타 AI가 못 읽는 <strong>한글 HWP</strong>도 분석하는 sovereign AI</div>
|
| 203 |
+
<!-- 방안2: 지원 형식 아이콘 띠 -->
|
| 204 |
+
<div class="fmt-strip">
|
| 205 |
+
<span class="fmt hwp">HWP</span><span class="fmt hwp">HWPX</span>
|
| 206 |
+
<span class="fmt pdf">PDF</span><span class="fmt doc">DOCX</span>
|
| 207 |
+
<span class="fmt xls">XLSX</span><span class="fmt ppt">PPTX</span>
|
| 208 |
+
<span class="fmt csv">CSV</span><span class="fmt img">이미지</span>
|
| 209 |
+
<span class="fmt more">+10종</span>
|
| 210 |
+
</div>
|
| 211 |
+
</button>
|
| 212 |
+
|
| 213 |
+
<!-- 8개 카테고리 큰 카드 -->
|
| 214 |
+
<div class="cat-grid">
|
| 215 |
+
<button class="cat-card" data-q="민원 처리 절차와 필요한 서류를 알려주세요.">
|
| 216 |
+
<div class="cat-emoji">📝</div>
|
| 217 |
+
<div class="cat-name">민원</div>
|
| 218 |
+
<div class="cat-desc">절차·서류·전화</div>
|
| 219 |
+
</button>
|
| 220 |
+
<button class="cat-card" data-q="전남광주통합특별법의 주요 조항을 알려주세요.">
|
| 221 |
+
<div class="cat-emoji">📜</div>
|
| 222 |
+
<div class="cat-name">법령</div>
|
| 223 |
+
<div class="cat-desc">특별법·조례</div>
|
| 224 |
+
</button>
|
| 225 |
+
<button class="cat-card" data-q="전남광주 통합특별시 관광 명소를 추천해 주세요.">
|
| 226 |
+
<div class="cat-emoji">🗺️</div>
|
| 227 |
+
<div class="cat-name">관광</div>
|
| 228 |
+
<div class="cat-desc">22 시군 + 5구</div>
|
| 229 |
+
</button>
|
| 230 |
+
<button class="cat-card" data-q="전남광주 통합특별시 교통편을 안내해 주세요.">
|
| 231 |
+
<div class="cat-emoji">🚆</div>
|
| 232 |
+
<div class="cat-name">교통</div>
|
| 233 |
+
<div class="cat-desc">KTX·공항·페리</div>
|
| 234 |
+
</button>
|
| 235 |
+
<button class="cat-card" data-q="전남광주 통합특별시의 농수산 특산품을 알려주세요.">
|
| 236 |
+
<div class="cat-emoji">🌾</div>
|
| 237 |
+
<div class="cat-name">농수산</div>
|
| 238 |
+
<div class="cat-desc">GI 특산·수산물</div>
|
| 239 |
+
</button>
|
| 240 |
+
<button class="cat-card" data-q="전남광주 통합특별시의 주요 산업과 경제 규모를 알려주세요.">
|
| 241 |
+
<div class="cat-emoji">🏭</div>
|
| 242 |
+
<div class="cat-name">산업</div>
|
| 243 |
+
<div class="cat-desc">경제·기업</div>
|
| 244 |
+
</button>
|
| 245 |
+
<button class="cat-card" data-q="전남광주 통합특별시의 인구 통계와 지역 현황을 알려주세요.">
|
| 246 |
+
<div class="cat-emoji">📊</div>
|
| 247 |
+
<div class="cat-name">통계</div>
|
| 248 |
+
<div class="cat-desc">인구·경제</div>
|
| 249 |
+
</button>
|
| 250 |
+
<button class="cat-card" data-attach="true">
|
| 251 |
+
<div class="cat-emoji">📎</div>
|
| 252 |
+
<div class="cat-name">첨부문서</div>
|
| 253 |
+
<div class="cat-desc">HWP·PDF·이미지</div>
|
| 254 |
+
</button>
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<!-- 추천 질문 (자주 묻는) -->
|
| 258 |
+
<div class="quick-section">
|
| 259 |
+
<div class="quick-label">자주 묻는 질문</div>
|
| 260 |
+
<div class="quick-grid">
|
| 261 |
+
<button class="quick-btn" data-q="전남광주 통합특별시장과 광주 5개 구청장, 전남 시장·군수 당선인이 각각 누구인가요?">🏛️ 지자체장 당선인</button>
|
| 262 |
+
<button class="quick-btn" data-q="전남광주(광주·전남) 지역구 국회의원 명단을 알려주세요.">🗳️ 지역구 국회의원</button>
|
| 263 |
+
<button class="quick-btn" data-q="주민등록증 재발급 절���와 필요한 서류는?">📇 주민등록증 재발급</button>
|
| 264 |
+
<button class="quick-btn" data-q="전남광주 통합특별시 인구는?">👥 통합특별시 인구</button>
|
| 265 |
+
<button class="quick-btn" data-q="보성 녹차밭 관광 안내 부탁해.">🍃 보성 녹차밭</button>
|
| 266 |
+
<button class="quick-btn" data-q="전남광주통합특별법 제6조 내용은?">📜 특별법 제6조</button>
|
| 267 |
+
<button class="quick-btn" data-q="전남광주 통합특별시청 대표 전화번호와 부서 안내는?">📞 시청 대표번호</button>
|
| 268 |
+
<button class="quick-btn" data-q="여수에서 가볼 만한 곳 3곳만 추천해.">🏖️ 여수 가볼 곳</button>
|
| 269 |
+
<button class="quick-btn" data-q="5·18 민주화운동 기록관 위치와 관람 안내는?">🏛️ 5·18 기록관</button>
|
| 270 |
+
<button class="quick-btn" data-q="2026년 7월 1일 전남광주 통합특별시 출범의 의미와 일정은?">🎌 7·1 출범</button>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<!-- 신뢰 배지 (첫 화면 only — NIA 본문은 composer footer에서 영구 표시) -->
|
| 275 |
+
<div class="portal-foot">
|
| 276 |
+
<div class="trust-line">
|
| 277 |
+
<span class="trust-badge">🔒 sovereign AI</span>
|
| 278 |
+
<span class="trust-badge">📅 기준일 2026-06-01</span>
|
| 279 |
+
<span class="trust-badge">📚 RAG 21,192 청크</span>
|
| 280 |
+
<span class="trust-badge">⚖️ 정식 행정 안내 아님 · 062-120 확인 권장</span>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<!-- ===== 입력 영역 ===== -->
|
| 287 |
+
<footer id="composer">
|
| 288 |
+
<div class="warn-bar" id="warn-bar" hidden>
|
| 289 |
+
✅ 2026년 6월 3일 지방선거 완료 — 민형배 통합특별시장 당선(7월 1일 취임). 단체장·지역구 국회의원 명단 반영 완료.
|
| 290 |
+
</div>
|
| 291 |
+
<div class="attach-row" id="attach-row"></div>
|
| 292 |
+
<div class="input-row">
|
| 293 |
+
<button id="attach-btn" title="파일 첨부 (HWP/HWPX/PDF/DOCX/PPTX/XLSX/XLS/CSV/이미지)" aria-label="파일 첨부">📎</button>
|
| 294 |
+
<button id="web-btn" title="웹검색 — 켜면 네이버·웹에서 최신 정보를 함께 검색합니다" aria-label="웹검색 켜기/끄기" aria-pressed="false">🔍</button>
|
| 295 |
+
<button id="minwon-btn" title="민원 — 켜면 질문을 민원으로 보고 담당 부서·연락처를 찾아드립니다" aria-label="민원 모드 켜기/끄기" aria-pressed="false">📞</button>
|
| 296 |
+
<input type="file" id="file-input" hidden multiple
|
| 297 |
+
accept=".hwp,.hwpx,.pdf,.docx,.pptx,.xlsx,.xls,.csv,.txt,.md,.jpg,.jpeg,.png,.gif,.webp">
|
| 298 |
+
<textarea id="input" rows="1" placeholder="질문을 입력하거나 파일을 첨부하세요 (Shift+Enter 줄바꿈)"></textarea>
|
| 299 |
+
<button id="send-btn" title="전송 (Enter)" aria-label="전송">
|
| 300 |
+
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
| 301 |
+
</button>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<!-- ===== NIA K-AI 1위 출처 문구 (항상 표시 — 모든 화면 하단) ===== -->
|
| 305 |
+
<div class="nia-disclaimer">
|
| 306 |
+
본 모델은 과학기술정보통신부·한국지능정보사회진흥원(NIA) K-AI 리더보드 1위 달성 모델을 기반으로,
|
| 307 |
+
전남광주 통합특별시 관련 공개정보를 추가 학습·최적화한 한국어 특화 모델입니다.
|
| 308 |
+
한국어 처리 능력과 한국 문화·사회·행정·전문 분야에 대한 이해를 바탕으로
|
| 309 |
+
시민 질의에 정확하고 친절하게 응답합니다.
|
| 310 |
+
</div>
|
| 311 |
+
</footer>
|
| 312 |
+
</div><!-- /view-chat -->
|
| 313 |
+
|
| 314 |
+
<!-- ===== 무료 오피스 (HanSoo 엔진 흡수 · same-origin iframe 임베디드) ===== -->
|
| 315 |
+
|
| 316 |
+
<section id="view-home" hidden>
|
| 317 |
+
<div class="svc-wrap">
|
| 318 |
+
|
| 319 |
+
<div class="svc-hero" data-view="chat">
|
| 320 |
+
<div class="eyebrow">가장 먼저 · 무엇이든 물어보세요</div>
|
| 321 |
+
<h2>JGOS 챗봇</h2>
|
| 322 |
+
<p>전입신고부터 재산세 납부 기한까지. 전남광주 통합특별시 행정에 맞춰 만든 우리 시 전용 AI가 24시간 답합니다.</p>
|
| 323 |
+
<span class="go">상담 시작하기 →</span>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<div class="svc-group">
|
| 327 |
+
<h3>지금 바로 쓰는 서비스</h3>
|
| 328 |
+
<div class="svc-grid">
|
| 329 |
+
<div class="svc-card" data-view="building" tabindex="0"><div class="row"><h4>🏗️ 건축 도면 만들기</h4><span class="svc-st st-live">가동</span></div><p>말로 설명하면 평면도와 도면 파일(DXF)을 만들어 드립니다.</p></div>
|
| 330 |
+
<div class="svc-card" data-view="office" tabindex="0"><div class="row"><h4>📑 무료 오피스</h4><span class="svc-st st-live">가동</span></div><p>한글(HWP)·PDF 문서를 설치 없이 열고 고치고 저장합니다.</p></div>
|
| 331 |
+
<div class="svc-card" data-view="image" tabindex="0"><div class="row"><h4>🎨 이미지 만들기</h4><span class="svc-st st-live">가동</span></div><p>가게 홍보물, 마을 안내문에 쓸 그림을 말로 설명해 만듭니다.</p></div>
|
| 332 |
+
<div class="svc-card" data-view="ax" tabindex="0"><div class="row"><h4>🏛️ 엑셀 자동 통합</h4><span class="svc-st st-live">가동</span></div><p>양식이 제각각인 부서 파일을 하나의 표로 합칩니다.</p></div>
|
| 333 |
+
<div class="svc-card" data-view="chat" data-q="이 파일 내용을 정리해 주세요." tabindex="0"><div class="row"><h4>📎 파일 분석</h4><span class="svc-st st-live">가동</span></div><p>고지서·계약서 사진을 올리면 무슨 내용인지 풀어 설명합니다.</p></div>
|
| 334 |
+
<div class="svc-card" data-view="saju" tabindex="0"><div class="row"><h4>🔮 사주 만세력</h4><span class="svc-st st-live">가동</span></div><p>생년월일시로 사주팔자·오행·십성을 정밀 계산합니다. 절기와 윤달까지 반영.</p></div>
|
| 335 |
+
<div class="svc-card" data-view="texray" tabindex="0"><div class="row"><h4>🔍 AI 글 판별</h4><span class="svc-st st-live">가동</span></div><p>제출물이 AI로 쓰였는지, 베낀 곳이 있는지 검사합니다.</p></div>
|
| 336 |
+
<div class="svc-card" data-view="chat" data-q="전남광주 통합특별시 관광 명소와 특산품, 교통편을 안내해 주세요." tabindex="0"><div class="row"><h4>🚄 관광·특산·교통</h4><span class="svc-st st-live">가동</span></div><p>명소, 지리적표시 특산품, KTX·공항·페리를 한자리에서.</p></div>
|
| 337 |
+
<div class="svc-card" data-view="chat" data-q="전남광주통합특별법의 주요 조항을 알려주세요." tabindex="0"><div class="row"><h4>⚖️ 특별법·조례</h4><span class="svc-st st-live">가동</span></div><p>통합특별법과 시 조례 내용을 찾아 설명합니다.</p></div>
|
| 338 |
+
<div class="svc-card" data-view="chat" data-q="어려운 행정 용어를 어르신도 알아듣기 쉬운 말로 풀어서 설명해 주세요." tabindex="0"><div class="row"><h4>🧓 쉬운 말로 듣기</h4><span class="svc-st st-live">가동</span></div><p>어려운 행정 용어를 알아듣기 쉬운 말로 바꿔 안내합니다.</p></div>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
<div class="svc-group">
|
| 343 |
+
<h3>준비 중인 서비스</h3>
|
| 344 |
+
<div class="svc-grid">
|
| 345 |
+
<div class="svc-card soon"><div class="row"><h4>📄 문서 판독</h4><span class="svc-st st-soon">준비 중</span></div><p>손글씨 서식·진단서를 읽어 표로 정리합니다.</p></div>
|
| 346 |
+
<div class="svc-card soon"><div class="row"><h4>🗣️ 안내 아바타</h4><span class="svc-st st-soon">준비 중</span></div><p>사람 얼굴로 말하며 안내합니다. 글보다 말이 편한 분께.</p></div>
|
| 347 |
+
<div class="svc-card soon"><div class="row"><h4>☎️ AI 전화상담</h4><span class="svc-st st-soon">준비 중</span></div><p>인터넷을 안 쓰셔도 전화 한 통이면 됩니다.</p></div>
|
| 348 |
+
<div class="svc-card soon"><div class="row"><h4>🛡️ 어르신 생활반응</h4><span class="svc-st st-soon">준비 중</span></div><p>카메라 없이 집 안 움직임을 살펴 이상이 길어지면 알립니다.</p></div>
|
| 349 |
+
<div class="svc-card soon"><div class="row"><h4>🌾 영농 상담</h4><span class="svc-st st-soon">준비 중</span></div><p>작물 시기, 판로, 지원 사업을 물어보세요.</p></div>
|
| 350 |
+
<div class="svc-card soon"><div class="row"><h4>🏢 조달·나라장터</h4><span class="svc-st st-soon">준비 중</span></div><p>우리 시 기업이 낼 수 있는 공고를 찾아 드립니다.</p></div>
|
| 351 |
+
<div class="svc-card soon"><div class="row"><h4>💡 특허·지식재산</h4><span class="svc-st st-soon">준비 중</span></div><p>아이디어가 이미 나온 것인지 확인하고 출원을 돕습니다.</p></div>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
</div>
|
| 356 |
+
</section>
|
| 357 |
+
|
| 358 |
+
<section id="view-building" hidden>
|
| 359 |
+
<iframe id="building-frame" title="건축 도면 만들기" src="about:blank"></iframe>
|
| 360 |
+
</section>
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
<section id="view-saju" hidden>
|
| 364 |
+
<div class="saju-wrap">
|
| 365 |
+
<div class="saju-head">
|
| 366 |
+
<h2>🔮 사주 만세력</h2>
|
| 367 |
+
<p>절기(입춘)와 윤달을 반영한 정밀 계산입니다. 생년월일만 넣으셔도 됩니다.</p>
|
| 368 |
+
</div>
|
| 369 |
+
<form class="saju-form" id="saju-form">
|
| 370 |
+
<div class="saju-row">
|
| 371 |
+
<label>생년월일<input type="date" id="sj-date" required></label>
|
| 372 |
+
<label>달력<select id="sj-cal"><option value="0">양력</option><option value="1">음력</option></select></label>
|
| 373 |
+
<label>성별<select id="sj-gender"><option value="male">남</option><option value="female">여</option></select></label>
|
| 374 |
+
<label>태어난 시각<select id="sj-hour">
|
| 375 |
+
<option value="-1">모름</option>
|
| 376 |
+
<option value="0">자시 23~01</option><option value="2">축시 01~03</option><option value="4">인시 03~05</option>
|
| 377 |
+
<option value="6">묘시 05~07</option><option value="8">진시 07~09</option><option value="10">사시 09~11</option>
|
| 378 |
+
<option value="12">오시 11~13</option><option value="14">미시 13~15</option><option value="16">신시 15~17</option>
|
| 379 |
+
<option value="18">유��� 17~19</option><option value="20">술시 19~21</option><option value="22">해시 21~23</option>
|
| 380 |
+
</select></label>
|
| 381 |
+
<button type="submit" id="sj-go">사주 보기</button>
|
| 382 |
+
</div>
|
| 383 |
+
</form>
|
| 384 |
+
<div id="sj-msg" class="saju-msg" hidden></div>
|
| 385 |
+
<div id="sj-result" class="saju-result" hidden></div>
|
| 386 |
+
<p class="saju-note">전통 명리학 계산 결과이며, 행정 안내가 아닙니다. 재미로 참고하세요.</p>
|
| 387 |
+
</div>
|
| 388 |
+
</section>
|
| 389 |
+
|
| 390 |
+
<section id="view-texray" hidden>
|
| 391 |
+
<iframe id="texray-frame" title="AI 글 판별" src="about:blank"></iframe>
|
| 392 |
+
</section>
|
| 393 |
+
|
| 394 |
+
<section id="view-office" hidden>
|
| 395 |
+
<iframe id="office-frame" title="무료 오피스 (HWP·DOCX·XLSX·PPTX·PDF)"
|
| 396 |
+
allow="clipboard-read; clipboard-write"></iframe>
|
| 397 |
+
</section>
|
| 398 |
+
|
| 399 |
+
<!-- ===== JGOS-Image (Qwen-Image-2512 한글 이미지 생성) ===== -->
|
| 400 |
+
<section id="view-image" hidden>
|
| 401 |
+
<div class="img-studio">
|
| 402 |
+
<div class="img-head">
|
| 403 |
+
<h2>🎨 이미지 생성 <small>한국형 AI 이미지 · 한국 인물·배경 · 영문 텍스트 · Qwen-Image-2512</small></h2>
|
| 404 |
+
</div>
|
| 405 |
+
<div class="img-examples">
|
| 406 |
+
<button class="img-ex" data-p="전남광주 통합특별시 출범 기념 포스터, 한국 시민들이 함께 모인 장면, 영문 제목 'Better Together', 밝고 희망찬">🎌 출범 포스터</button>
|
| 407 |
+
<button class="img-ex" data-p="보성 녹차밭 관광 포스터, 한국 전통 정취, 영문 문구 'Green Healing Tour', 청량하고 시원한">🍃 관광 홍보</button>
|
| 408 |
+
<button class="img-ex" data-p="민원 안내 인포그래픽, 깔끔한 한국 행정 디자인, 영문 제목 'Civil Service Guide', 아이콘">📋 민원 안내</button>
|
| 409 |
+
<button class="img-ex" data-p="전남 청정 농수산물 홍보, 한국 농촌 배경, 영문 문구 'Fresh Local Food', 풍성한">🌾 특산물</button>
|
| 410 |
+
</div>
|
| 411 |
+
<div class="img-opts">
|
| 412 |
+
<label class="img-opt-label">📐 비율
|
| 413 |
+
<select id="img-ratio">
|
| 414 |
+
<option value="1:1" selected>⬜ 정사각형 1:1</option>
|
| 415 |
+
<option value="3:4">📄 세로 3:4 (포스터)</option>
|
| 416 |
+
<option value="9:16">📱 세로 9:16 (모바일)</option>
|
| 417 |
+
<option value="4:3">🖼️ 가로 4:3</option>
|
| 418 |
+
<option value="16:9">🖥️ 가로 16:9 (배너)</option>
|
| 419 |
+
</select>
|
| 420 |
+
</label>
|
| 421 |
+
</div>
|
| 422 |
+
<div class="img-input-row">
|
| 423 |
+
<textarea id="img-prompt" rows="2" placeholder="원하는 이미지를 설명하세요 (예: 여수 밤바다 야경, 영문 문구 'Visit Yeosu')"></textarea>
|
| 424 |
+
<button id="img-gen-btn">🎨 생성</button>
|
| 425 |
+
</div>
|
| 426 |
+
<div id="img-status" class="img-status" hidden></div>
|
| 427 |
+
<div id="img-result" class="img-result"></div>
|
| 428 |
+
<div class="img-foot">⚖️ AI 생성 이미지 · 공식 행정자료 아님 · 상업적 이용 시 별도 검토</div>
|
| 429 |
+
</div>
|
| 430 |
+
</section>
|
| 431 |
+
|
| 432 |
+
<!-- ===== 🎓 AI 교육 (AI 시민 대학) ===== -->
|
| 433 |
+
<section id="view-edu" hidden>
|
| 434 |
+
<div class="edu-wrap">
|
| 435 |
+
<div class="edu-head">
|
| 436 |
+
<h2>🎓 AI 시민 대학 <small>AI 선생님에게 배우고 바로 실습하세요 · 누구나 무료</small></h2>
|
| 437 |
+
</div>
|
| 438 |
+
<div class="edu-cat-title">📱 JGOS 100% 활용</div>
|
| 439 |
+
<div class="edu-cards">
|
| 440 |
+
<button class="edu-card" data-q="JGOS에서 민원을 어떻게 물어보나요? 처음 쓰는 사람도 알 수 있게 쉽게 알려주세요." data-go="chat">① 민원 물어보기</button>
|
| 441 |
+
<button class="edu-card" data-q="JGOS 무료 오피스로 한글(HWP) 문서를 편집하고 저장하는 법을 알려주세요." data-go="office">② HWP 편집</button>
|
| 442 |
+
<button class="edu-card" data-q="JGOS 이미지 생성 기능 사용법과 좋은 설명(프롬프트) 쓰는 법을 알려주세요." data-go="image">③ 이미지 생성</button>
|
| 443 |
+
<button class="edu-card" data-q="JGOS에 PDF나 사진 파일을 올려서 내용을 분석하는 방법을 알려주세요." data-go="chat">④ 파일 분석</button>
|
| 444 |
+
</div>
|
| 445 |
+
<div class="edu-cat-title">🤖 AI 기본 소양</div>
|
| 446 |
+
<div class="edu-cards">
|
| 447 |
+
<button class="edu-card" data-q="AI가 무엇인지 어르신도 이해하기 쉽게 비유를 들어 설명해주세요.">⑤ AI란 무엇?</button>
|
| 448 |
+
<button class="edu-card" data-q="AI에게 질문을 잘 하는 방법(프롬프트)을 쉬운 예시와 함께 알려주세요.">⑥ 질문 잘하기</button>
|
| 449 |
+
<button class="edu-card" data-q="AI가 만든 가짜 정보(환각)를 구별하고 조심하는 방법을 알려주세요.">⑦ 가짜정보 판별</button>
|
| 450 |
+
<button class="edu-card" data-q="AI를 사용할 때 내 개인정보를 안전하게 지키는 방법을 알려주세요.">⑧ 개인정보 보호</button>
|
| 451 |
+
</div>
|
| 452 |
+
<div class="edu-cat-title">🌱 ���활 맞춤</div>
|
| 453 |
+
<div class="edu-cards">
|
| 454 |
+
<button class="edu-card" data-q="컴퓨터가 익숙하지 않은 어르신을 위해 AI를 처음 사용하는 법을 아주 쉽고 천천히 알려주세요.">⑨ 어르신 첫걸음</button>
|
| 455 |
+
<button class="edu-card" data-q="소상공인이 AI로 가게 홍보물과 안내 문서를 만드는 방법을 알려주세요.">⑩ 소상공인 홍보</button>
|
| 456 |
+
<button class="edu-card" data-q="학생이 AI를 공부와 학습에 올바르고 똑똑하게 활용하는 방법을 알려주세요.">⑪ 학생 학습</button>
|
| 457 |
+
<button class="edu-card" data-q="농어민이 AI로 농수산물 홍보·판매와 영농 정보를 활용하는 방법을 알려주세요.">⑫ 농어민 활용</button>
|
| 458 |
+
</div>
|
| 459 |
+
<div class="edu-teacher">
|
| 460 |
+
<div class="edu-teacher-head">🧑🏫 AI 선생님 — 무엇이든 편하게 물어보세요</div>
|
| 461 |
+
<div id="edu-messages" class="edu-messages"></div>
|
| 462 |
+
<div id="edu-goto" class="edu-goto" hidden></div>
|
| 463 |
+
<div class="edu-input-row">
|
| 464 |
+
<textarea id="edu-input" rows="1" placeholder="예: AI가 뭐예요? / 프롬프트가 뭔가요?"></textarea>
|
| 465 |
+
<button id="edu-send">질문</button>
|
| 466 |
+
</div>
|
| 467 |
+
</div>
|
| 468 |
+
<div class="edu-foot">🎓 전남광주 통합특별시 AI 시민 대학 · 모든 시민 무료 · 위 강의를 누르면 AI 선생님이 설명해 드려요</div>
|
| 469 |
+
</div>
|
| 470 |
+
</section>
|
| 471 |
+
|
| 472 |
+
<!-- ===== 🏛️ 행정 AX 데이터 통합 (테스트 데모) ===== -->
|
| 473 |
+
<section id="view-ax" hidden>
|
| 474 |
+
<div class="ax-container">
|
| 475 |
+
<div class="ax-header">
|
| 476 |
+
<h2>🏛️ 행정 AX 데이터 통합 — 테스트 데모</h2>
|
| 477 |
+
<p class="ax-desc">이종 파일 포맷·서식의 행정 데이터를 자동으로 인식·정규화·통합합니다</p>
|
| 478 |
+
</div>
|
| 479 |
+
|
| 480 |
+
<!-- Demo 1: 이종 Excel 통합 -->
|
| 481 |
+
<div class="ax-demo-card">
|
| 482 |
+
<div class="ax-demo-header">
|
| 483 |
+
<span class="ax-demo-badge">Demo 1</span>
|
| 484 |
+
<h3>📊 이종 Excel 자동 통합</h3>
|
| 485 |
+
<p>27개 지자체가 제각각 다른 서식으로 제출한 Excel을 자동으로 표준화·통합합니다</p>
|
| 486 |
+
</div>
|
| 487 |
+
<div class="ax-demo-files">
|
| 488 |
+
<div class="ax-files-label">📁 예제 파일 (클릭하여 미리보기)</div>
|
| 489 |
+
<div class="ax-file-icons" id="ax-excel-demos"><!-- JS로 채움 --></div>
|
| 490 |
+
</div>
|
| 491 |
+
<div class="ax-upload-zone" id="ax-excel-drop">
|
| 492 |
+
<div class="ax-upload-inner">
|
| 493 |
+
<span class="ax-upload-icon">📤</span>
|
| 494 |
+
<div>Excel/CSV 파일을 여기에 드래그하거나 클릭하세요</div>
|
| 495 |
+
<div class="ax-upload-hint">XLSX, XLS, CSV — 여러 파일 동시 업로드 가능</div>
|
| 496 |
+
</div>
|
| 497 |
+
<input type="file" id="ax-excel-input" hidden multiple accept=".xlsx,.xls,.csv">
|
| 498 |
+
</div>
|
| 499 |
+
<div class="ax-actions">
|
| 500 |
+
<button class="ax-btn ax-btn-primary" id="ax-excel-run" disabled>
|
| 501 |
+
<span>⚡</span> 통합 실행
|
| 502 |
+
</button>
|
| 503 |
+
<span class="ax-file-count" id="ax-excel-count"></span>
|
| 504 |
+
</div>
|
| 505 |
+
<div class="ax-result-area" id="ax-excel-result" hidden></div>
|
| 506 |
+
</div>
|
| 507 |
+
|
| 508 |
+
<!-- Demo 2: 이종 포맷 통합 -->
|
| 509 |
+
<div class="ax-demo-card">
|
| 510 |
+
<div class="ax-demo-header">
|
| 511 |
+
<span class="ax-demo-badge ax-demo-badge-2">Demo 2</span>
|
| 512 |
+
<h3>🗂️ 이종 포맷 자동 통합</h3>
|
| 513 |
+
<p>HWP, XLSX, CSV, PPTX, DOCX, PDF — 7가지 다른 포맷의 데이터를 하나의 표로 통합합니다</p>
|
| 514 |
+
</div>
|
| 515 |
+
<div class="ax-demo-files">
|
| 516 |
+
<div class="ax-files-label">📁 예제 파일 7종 (클릭하여 미리보기)</div>
|
| 517 |
+
<div class="ax-file-icons" id="ax-format-demos"><!-- JS로 채움 --></div>
|
| 518 |
+
</div>
|
| 519 |
+
<div class="ax-upload-zone" id="ax-format-drop">
|
| 520 |
+
<div class="ax-upload-inner">
|
| 521 |
+
<span class="ax-upload-icon">📤</span>
|
| 522 |
+
<div>이종 포맷 파일을 여기에 드래그하거나 클릭하세요</div>
|
| 523 |
+
<div class="ax-upload-hint">HWPX, XLSX, CSV, PPTX, DOCX, PDF — 혼합 업로드 가능</div>
|
| 524 |
+
</div>
|
| 525 |
+
<input type="file" id="ax-format-input" hidden multiple accept=".hwp,.hwpx,.xlsx,.xls,.csv,.docx,.pptx,.pdf">
|
| 526 |
+
</div>
|
| 527 |
+
<div class="ax-actions">
|
| 528 |
+
<button class="ax-btn ax-btn-primary ax-btn-2" id="ax-format-run" disabled>
|
| 529 |
+
<span>⚡</span> 통합 실행
|
| 530 |
+
</button>
|
| 531 |
+
<span class="ax-file-count" id="ax-format-count"></span>
|
| 532 |
+
</div>
|
| 533 |
+
<div class="ax-result-area" id="ax-format-result" hidden></div>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
|
| 537 |
+
<!-- 파일 미리보기 모달 -->
|
| 538 |
+
<div id="ax-modal" class="ax-modal-overlay" hidden>
|
| 539 |
+
<div class="ax-modal-box">
|
| 540 |
+
<div class="ax-modal-header">
|
| 541 |
+
<span id="ax-modal-title">파일 미리보기</span>
|
| 542 |
+
<button class="ax-modal-close" id="ax-modal-close">✕</button>
|
| 543 |
+
</div>
|
| 544 |
+
<div class="ax-modal-body" id="ax-modal-body">
|
| 545 |
+
<div class="ax-loading">⏳ 불러오는 중...</div>
|
| 546 |
+
</div>
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
</section>
|
| 550 |
+
</main>
|
| 551 |
+
|
| 552 |
+
<!-- 모바일 사이드바 백드롭 -->
|
| 553 |
+
<div id="sidebar-backdrop" hidden></div>
|
| 554 |
+
</div>
|
| 555 |
+
|
| 556 |
+
<div id="drag-overlay" hidden>
|
| 557 |
+
<div class="drop-card">
|
| 558 |
+
📂 여기에 파일을 놓으세요
|
| 559 |
+
<small>HWP · HWPX · PDF · DOCX · PPTX · XLSX · CSV · 이미지<br>(ESC 또는 클릭으로 닫기)</small>
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
| 562 |
+
|
| 563 |
+
<script src="/static/jgos_app_v611.js?v=612"></script>
|
| 564 |
+
|
| 565 |
+
<script>
|
| 566 |
+
/* JGOS AX 미리보기 — 인라인 (캐시 우회, v611-INLINE) */
|
| 567 |
+
window.axPreviewDemo = async function(filename, label){
|
| 568 |
+
var modal=document.getElementById("ax-modal");
|
| 569 |
+
var body=document.getElementById("ax-modal-body");
|
| 570 |
+
var title=document.getElementById("ax-modal-title");
|
| 571 |
+
if(!modal||!body)return;
|
| 572 |
+
if(title)title.textContent="\uD83D\uDCC4 "+label+" \u2014 \uBBF8\uB9AC\uBCF4\uAE30";
|
| 573 |
+
body.innerHTML='<div class="ax-loading">\u23F3 \uBD88\uB7EC\uC624\uB294 \uC911... [INLINE]</div>';
|
| 574 |
+
modal.hidden=false;
|
| 575 |
+
try{
|
| 576 |
+
var r=await fetch("/api/demo/preview/"+encodeURIComponent(filename)+"?t="+Date.now());
|
| 577 |
+
if(!r.ok){body.innerHTML="<p style=\'color:#b91c1c\'>\uC11C\uBC84 \uC624\uB958 ("+r.status+")</p>";return;}
|
| 578 |
+
var d=await r.json();
|
| 579 |
+
body.innerHTML=(d.rows>0)?(d.html||"<p>\uBE48 \uACB0\uACFC</p>"):"<p style=\'color:#92400e\'>\u26A0\uFE0F \uD30C\uC2F1 \uC2E4\uD328</p>";
|
| 580 |
+
}catch(e){
|
| 581 |
+
body.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e&&e.message?e.message:e)+"</p>";
|
| 582 |
+
}
|
| 583 |
+
};
|
| 584 |
+
/* 칩 onclick 재바인딩 (외부 js가 이미 그렸어도 인라인 함수로 연결) */
|
| 585 |
+
(function(){
|
| 586 |
+
function rebind(){
|
| 587 |
+
document.querySelectorAll(".ax-file-chip").forEach(function(chip){
|
| 588 |
+
chip.onclick=function(){ window.axPreviewDemo(chip.dataset.demo, chip.dataset.label); };
|
| 589 |
+
});
|
| 590 |
+
}
|
| 591 |
+
if(document.readyState!=="loading")setTimeout(rebind,300);
|
| 592 |
+
else document.addEventListener("DOMContentLoaded",function(){setTimeout(rebind,300);});
|
| 593 |
+
// 탭 전환 후에도 재바인딩
|
| 594 |
+
document.addEventListener("click",function(e){
|
| 595 |
+
var t=e.target.closest&&e.target.closest('[data-view="ax"]');
|
| 596 |
+
if(t)setTimeout(rebind,400);
|
| 597 |
+
});
|
| 598 |
+
})();
|
| 599 |
+
console.log("[JGOS] AX inline preview v611-INLINE loaded");
|
| 600 |
+
</script>
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
<script>
|
| 604 |
+
/* JGOS AX 예제 바로 실행 버튼 — DEMO-RUN-v1 */
|
| 605 |
+
(function(){
|
| 606 |
+
async function runDemoMerge(btn, resultId, demoType){
|
| 607 |
+
var result=document.getElementById(resultId);
|
| 608 |
+
var old=btn.innerHTML; btn.disabled=true; btn.innerHTML="\u23F3 \uD1B5\uD569 \uC911...";
|
| 609 |
+
if(result){result.hidden=false;result.innerHTML='<div class="ax-loading">\u23F3 \uC608\uC81C \uD1B5\uD569 \uC911...</div>';}
|
| 610 |
+
try{
|
| 611 |
+
var r=await fetch("/api/demo/merge/"+demoType+"?t="+Date.now());
|
| 612 |
+
var d=await r.json();
|
| 613 |
+
if(result){
|
| 614 |
+
var src=(d.sources||[]).map(function(s){return '<span class="ax-source-chip">'+(s["\uD3EC\uB9F7"]||"")+' '+s["\uD30C\uC77C"]+' ('+s["\uAC74\uC218"]+'\uAC74)</span>';}).join("");
|
| 615 |
+
result.innerHTML='<div class="ax-source-list">'+src+'</div>'+(d.html||"");
|
| 616 |
+
}
|
| 617 |
+
}catch(e){if(result)result.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e&&e.message?e.message:e)+"</p>";}
|
| 618 |
+
btn.disabled=false; btn.innerHTML=old;
|
| 619 |
+
}
|
| 620 |
+
function addBtn(runBtnId, resultId, demoType){
|
| 621 |
+
var runBtn=document.getElementById(runBtnId);
|
| 622 |
+
if(!runBtn) return;
|
| 623 |
+
if(document.getElementById(runBtnId+"-demo")) return;
|
| 624 |
+
var btn=document.createElement("button");
|
| 625 |
+
btn.id=runBtnId+"-demo";
|
| 626 |
+
btn.className=runBtn.className;
|
| 627 |
+
btn.style.background="#0d7377";
|
| 628 |
+
btn.style.marginLeft="8px";
|
| 629 |
+
btn.innerHTML="\uD83D\uDCC2 \uC608\uC81C\uB85C \uBC14\uB85C \uD1B5\uD569";
|
| 630 |
+
runBtn.parentNode.insertBefore(btn, runBtn.nextSibling);
|
| 631 |
+
btn.onclick=function(){ runDemoMerge(btn, resultId, demoType); };
|
| 632 |
+
}
|
| 633 |
+
function setup(){
|
| 634 |
+
addBtn("ax-excel-run","ax-excel-result","excel");
|
| 635 |
+
addBtn("ax-format-run","ax-format-result","formats");
|
| 636 |
+
}
|
| 637 |
+
if(document.readyState!=="loading") setTimeout(setup,500);
|
| 638 |
+
else document.addEventListener("DOMContentLoaded",function(){setTimeout(setup,500);});
|
| 639 |
+
document.addEventListener("click",function(e){
|
| 640 |
+
var t=e.target.closest&&e.target.closest('[data-view="ax"]');
|
| 641 |
+
if(t) setTimeout(setup,500);
|
| 642 |
+
});
|
| 643 |
+
console.log("[JGOS] AX demo-run buttons v1 loaded");
|
| 644 |
+
})();
|
| 645 |
+
</script>
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
<script>
|
| 649 |
+
/* AX-INTEGRATE-v2: 통합 결과 표시 + XLSX 다운로드 */
|
| 650 |
+
(function(){
|
| 651 |
+
function chip(s){return '<span class="ax-source-chip">'+(s["\uD3EC\uB9F7"]||"")+" "+(s["\uD30C\uC77C"]||"")+" ("+(s["\uAC74\uC218"]||0)+"\uAC74)</span>";}
|
| 652 |
+
window.__axShow=function(resultId,d){
|
| 653 |
+
var result=document.getElementById(resultId);
|
| 654 |
+
if(!result)return;
|
| 655 |
+
result.hidden=false;
|
| 656 |
+
var src=(d.sources||[]).map(chip).join("");
|
| 657 |
+
result.innerHTML='<div class="ax-source-list">'+src+'</div>'+(d.html||"");
|
| 658 |
+
if(d.records&&d.records.length){
|
| 659 |
+
var dl=document.createElement("button");
|
| 660 |
+
dl.style.cssText="background:#1d6f42;color:#fff;margin-top:14px;padding:11px 24px;border:none;border-radius:9px;font-weight:700;cursor:pointer;font-size:0.95rem";
|
| 661 |
+
dl.textContent="\uD83D\uDCE5 \uD1B5\uD569\uBCF8 XLSX \uB2E4\uC6B4\uB85C\uB4DC ("+d.records.length+"\uAC74)";
|
| 662 |
+
dl.onclick=async function(){
|
| 663 |
+
var o=dl.textContent; dl.disabled=true; dl.textContent="\u23F3 \uC0DD\uC131 \uC911...";
|
| 664 |
+
try{
|
| 665 |
+
var rr=await fetch("/api/export/xlsx",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({records:d.records,title:"JGOS \uD589\uC815AX \uD1B5\uD569\uACB0\uACFC"})});
|
| 666 |
+
if(!rr.ok)throw new Error("\uC11C\uBC84 "+rr.status);
|
| 667 |
+
var b=await rr.blob(); var u=URL.createObjectURL(b);
|
| 668 |
+
var a=document.createElement("a"); a.href=u; a.download="JGOS_\uD589\uC815AX_\uD1B5\uD569\uACB0\uACFC.xlsx";
|
| 669 |
+
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(u);
|
| 670 |
+
}catch(e){alert("\uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: "+(e.message||e));}
|
| 671 |
+
dl.disabled=false; dl.textContent=o;
|
| 672 |
+
};
|
| 673 |
+
result.appendChild(dl);
|
| 674 |
+
}
|
| 675 |
+
};
|
| 676 |
+
async function exampleMerge(btn,resultId,type){
|
| 677 |
+
var o=btn.innerHTML; btn.disabled=true; btn.innerHTML="\u23F3 \uD1B5\uD569 \uC911...";
|
| 678 |
+
var result=document.getElementById(resultId);
|
| 679 |
+
if(result){result.hidden=false;result.innerHTML='<div class="ax-loading">\u23F3 \uC608\uC81C \uD1B5\uD569 \uC911...</div>';}
|
| 680 |
+
try{var r=await fetch("/api/demo/merge/"+type+"?t="+Date.now());var d=await r.json();window.__axShow(resultId,d);}
|
| 681 |
+
catch(e){if(result)result.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e.message||e)+"</p>";}
|
| 682 |
+
btn.disabled=false; btn.innerHTML=o;
|
| 683 |
+
}
|
| 684 |
+
async function uploadMerge(btn,inputId,resultId,apiPath){
|
| 685 |
+
var input=document.getElementById(inputId);
|
| 686 |
+
if(!input||!input.files||!input.files.length){alert("\uD30C\uC77C\uC744 \uC120\uD0DD\uD558\uC138\uC694");return;}
|
| 687 |
+
var o=btn.innerHTML; btn.disabled=true; btn.innerHTML="\u23F3 \uD1B5\uD569 \uC911...";
|
| 688 |
+
var result=document.getElementById(resultId);
|
| 689 |
+
if(result){result.hidden=false;result.innerHTML='<div class="ax-loading">\u23F3 \uD1B5\uD569 \uC911...</div>';}
|
| 690 |
+
var fd=new FormData(); for(var i=0;i<input.files.length;i++)fd.append("files",input.files[i]);
|
| 691 |
+
try{var r=await fetch(apiPath,{method:"POST",body:fd});var d=await r.json();window.__axShow(resultId,d);}
|
| 692 |
+
catch(e){if(result)result.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e.message||e)+"</p>";}
|
| 693 |
+
btn.disabled=false; btn.innerHTML=o;
|
| 694 |
+
}
|
| 695 |
+
function setup(){
|
| 696 |
+
var er=document.getElementById("ax-excel-run");
|
| 697 |
+
if(er)er.onclick=function(){uploadMerge(er,"ax-excel-input","ax-excel-result","/api/excel/merge");};
|
| 698 |
+
var fr=document.getElementById("ax-format-run");
|
| 699 |
+
if(fr)fr.onclick=function(){uploadMerge(fr,"ax-format-input","ax-format-result","/api/files/merge-formats");};
|
| 700 |
+
[["ax-excel-run","ax-excel-result","excel"],["ax-format-run","ax-format-result","formats"]].forEach(function(x){
|
| 701 |
+
var run=document.getElementById(x[0]); if(!run)return;
|
| 702 |
+
var id=x[0]+"-demo"; var b=document.getElementById(id);
|
| 703 |
+
if(!b){b=document.createElement("button");b.id=id;b.className=run.className;b.style.background="#0d7377";b.style.marginLeft="8px";b.innerHTML="\uD83D\uDCC2 \uC608\uC81C\uB85C \uBC14\uB85C \uD1B5\uD569";run.parentNode.insertBefore(b,run.nextSibling);}
|
| 704 |
+
b.onclick=function(){exampleMerge(b,x[1],x[2]);};
|
| 705 |
+
});
|
| 706 |
+
}
|
| 707 |
+
if(document.readyState!=="loading")setTimeout(setup,600);
|
| 708 |
+
else document.addEventListener("DOMContentLoaded",function(){setTimeout(setup,600);});
|
| 709 |
+
document.addEventListener("click",function(e){var t=e.target.closest&&e.target.closest('[data-view="ax"]');if(t)setTimeout(setup,600);});
|
| 710 |
+
console.log("[JGOS] AX integrate+download v2 loaded");
|
| 711 |
+
})();
|
| 712 |
+
</script>
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
<script id="jgos-home-js">
|
| 716 |
+
/* 서비스 첫화면 + 건축도면 뷰 — 기존 showView/switchTab 을 감싸서 확장 (원본 JS 무수정) */
|
| 717 |
+
(function(){
|
| 718 |
+
var home=document.getElementById('view-home');
|
| 719 |
+
var build=document.getElementById('view-building');
|
| 720 |
+
var saju=document.getElementById('view-saju');
|
| 721 |
+
var tex=document.getElementById('view-texray');
|
| 722 |
+
var texf=document.getElementById('texray-frame');var texLoaded=false;
|
| 723 |
+
var frame=document.getElementById('building-frame');
|
| 724 |
+
var chat=document.getElementById('view-chat');
|
| 725 |
+
var loaded=false;
|
| 726 |
+
function paint(name){
|
| 727 |
+
home.hidden = name!=='home';
|
| 728 |
+
build.hidden = name!=='building';
|
| 729 |
+
saju.hidden = name!=='saju';
|
| 730 |
+
tex.hidden = name!=='texray';
|
| 731 |
+
if(name==='texray' && !texLoaded){ texf.src='https://ginigen-TeXray.hf.space/'; texLoaded=true; }
|
| 732 |
+
if(name==='building' && !loaded){ frame.src='https://VIDraft-building.hf.space/'; loaded=true; }
|
| 733 |
+
if(name!=='chat'){ if(chat) chat.style.display='none'; }
|
| 734 |
+
}
|
| 735 |
+
function go(name){
|
| 736 |
+
document.querySelectorAll('.top-tab').forEach(function(t){
|
| 737 |
+
t.classList.toggle('active', t.dataset.view===name);
|
| 738 |
+
});
|
| 739 |
+
if(typeof showView==='function') showView(['home','building','saju','texray'].indexOf(name)>=0 ? '__none__' : name);
|
| 740 |
+
paint(name);
|
| 741 |
+
}
|
| 742 |
+
window.jgosGo = go;
|
| 743 |
+
document.querySelectorAll('.top-tab').forEach(function(t){
|
| 744 |
+
t.addEventListener('click', function(){ go(t.dataset.view); });
|
| 745 |
+
});
|
| 746 |
+
document.addEventListener('click', function(e){
|
| 747 |
+
var c=e.target.closest('.svc-card, .svc-hero');
|
| 748 |
+
if(!c || c.classList.contains('soon')) return;
|
| 749 |
+
var v=c.dataset.view; if(!v) return;
|
| 750 |
+
go(v);
|
| 751 |
+
if(v==='chat' && c.dataset.q){
|
| 752 |
+
var box=document.querySelector('#composer-input, #input, textarea');
|
| 753 |
+
if(box){ box.value=c.dataset.q; box.focus(); }
|
| 754 |
+
}
|
| 755 |
+
});
|
| 756 |
+
go('home'); /* 첫 화면 = 서비스 */
|
| 757 |
+
})();
|
| 758 |
+
</script>
|
| 759 |
+
<script id="jgos-saju-js">
|
| 760 |
+
/* 사주 만세력 — JGOS 자체 UI (/api/saju) */
|
| 761 |
+
(function(){
|
| 762 |
+
var form = document.getElementById('saju-form');
|
| 763 |
+
if(!form) return;
|
| 764 |
+
var msg = document.getElementById('sj-msg');
|
| 765 |
+
var box = document.getElementById('sj-result');
|
| 766 |
+
var btn = document.getElementById('sj-go');
|
| 767 |
+
var ELEM = {'목':'木','화':'火','토':'土','금':'金','수':'水'};
|
| 768 |
+
|
| 769 |
+
function say(t){ msg.textContent = t; msg.hidden = !t; }
|
| 770 |
+
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){
|
| 771 |
+
return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
|
| 772 |
+
|
| 773 |
+
function pillar(label, p){
|
| 774 |
+
if(!p) return '';
|
| 775 |
+
var han = esc(p.hanja || ''), kor = esc(p.hangul || '');
|
| 776 |
+
return '<div class="sj-p"><div class="lab">' + label + '</div>' +
|
| 777 |
+
'<div class="han">' + han + '</div><div class="kor">' + kor + '</div></div>';
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
function render(d){
|
| 781 |
+
var p = d.pillars || {};
|
| 782 |
+
var html = '';
|
| 783 |
+
|
| 784 |
+
html += '<div class="sj-card"><h3>사주팔자</h3>' +
|
| 785 |
+
'<div class="sj-pillars">' +
|
| 786 |
+
pillar('시주', p.hour) + pillar('일주', p.day) +
|
| 787 |
+
pillar('월주', p.month) + pillar('년주', p.year) +
|
| 788 |
+
'</div><div class="sj-meta">' +
|
| 789 |
+
'<span class="sj-tag">일간 <b>' + esc(d.day_master) + '</b> · ' + esc(d.day_master_element) + ' ' + esc(d.day_master_yinyang) + '</span>' +
|
| 790 |
+
'<span class="sj-tag">띠 <b>' + esc(d.animal) + '</b></span>' +
|
| 791 |
+
(d.unknown_hour ? '<span class="sj-tag">태어난 시각 모름 — 시주는 참고용</span>' : '') +
|
| 792 |
+
'</div></div>';
|
| 793 |
+
|
| 794 |
+
var oh = d.oheng_pct || d.oheng || {};
|
| 795 |
+
var keys = Object.keys(oh);
|
| 796 |
+
if(keys.length){
|
| 797 |
+
var bars = keys.map(function(k){
|
| 798 |
+
var v = Number(oh[k]) || 0;
|
| 799 |
+
return '<div class="sj-bar"><span class="n">' + esc(ELEM[k] || k) + '</span>' +
|
| 800 |
+
'<span class="t"><span class="f" style="width:' + Math.max(0, Math.min(100, v)) + '%"></span></span>' +
|
| 801 |
+
'<span class="v">' + v + '%</span></div>';
|
| 802 |
+
}).join('');
|
| 803 |
+
html += '<div class="sj-card"><h3>오행 분포</h3><div class="sj-bars">' + bars + '</div>' +
|
| 804 |
+
'<div class="sj-meta"><span class="sj-tag">가장 강한 기운 <b>' + esc(d.dominant) + '</b></span>' +
|
| 805 |
+
'<span class="sj-tag">가장 약한 기운 <b>' + esc(d.weak) + '</b></span></div></div>';
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
if(d.twelve_stages){
|
| 809 |
+
var st = Object.keys(d.twelve_stages).map(function(k){
|
| 810 |
+
return '<span class="sj-tag">' + esc(k) + ' <b>' + esc(d.twelve_stages[k]) + '</b></span>'; }).join('');
|
| 811 |
+
if(st) html += '<div class="sj-card"><h3>십이운성</h3><div class="sj-meta">' + st + '</div></div>';
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
box.innerHTML = html;
|
| 815 |
+
box.hidden = false;
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
form.addEventListener('submit', function(e){
|
| 819 |
+
e.preventDefault();
|
| 820 |
+
var dv = document.getElementById('sj-date').value;
|
| 821 |
+
if(!dv){ say('생년월일을 입력해 주세요.'); return; }
|
| 822 |
+
var parts = dv.split('-');
|
| 823 |
+
var payload = {
|
| 824 |
+
year: +parts[0], month: +parts[1], day: +parts[2],
|
| 825 |
+
hour: document.getElementById('sj-hour').value,
|
| 826 |
+
gender: document.getElementById('sj-gender').value,
|
| 827 |
+
is_lunar: document.getElementById('sj-cal').value === '1'
|
| 828 |
+
};
|
| 829 |
+
btn.disabled = true; box.hidden = true; say('계산하고 있습니다…');
|
| 830 |
+
fetch('/api/saju', {
|
| 831 |
+
method: 'POST',
|
| 832 |
+
headers: {'Content-Type': 'application/json'},
|
| 833 |
+
body: JSON.stringify(payload)
|
| 834 |
+
}).then(function(r){
|
| 835 |
+
return r.json().then(function(j){
|
| 836 |
+
if(!r.ok) throw new Error(j.detail || ('오류 ' + r.status));
|
| 837 |
+
return j;
|
| 838 |
+
});
|
| 839 |
+
}).then(function(d){
|
| 840 |
+
say(''); render(d);
|
| 841 |
+
}).catch(function(err){
|
| 842 |
+
say(String(err.message || err));
|
| 843 |
+
}).finally(function(){
|
| 844 |
+
btn.disabled = false;
|
| 845 |
+
});
|
| 846 |
+
});
|
| 847 |
+
})();
|
| 848 |
+
</script>
|
| 849 |
+
</body>
|
| 850 |
+
</html>
|