openfree commited on
Commit
155af86
·
verified ·
1 Parent(s): fc81ed6

feat: 애플톤 UI 전면 적용 · 사주 만세력(자체 엔진+JGOS UI) · TeXray AI글판별 흡수

Browse files
Files changed (4) hide show
  1. app.py +52 -0
  2. requirements.txt +23 -20
  3. saju_manseryeok.py +1600 -0
  4. 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
- <style id="jgos-home-css">
18
- #view-home{padding:22px 20px 60px;overflow-y:auto}
19
- .svc-wrap{max-width:1080px;margin:0 auto}
20
- .svc-hero{background:linear-gradient(135deg,#0b5a87,#0d4a6e);color:#fff;border-radius:16px;padding:26px 28px;margin-bottom:26px;cursor:pointer}
21
- .svc-hero:hover{filter:brightness(1.06)}
22
- .svc-hero .eyebrow{font-size:12px;letter-spacing:.12em;opacity:.85;font-weight:600}
23
- .svc-hero h2{margin:8px 0 8px;font-size:clamp(23px,3.2vw,31px);line-height:1.3}
24
- .svc-hero p{margin:0;opacity:.92;font-size:14.5px;max-width:50ch;line-height:1.65}
25
- .svc-hero .go{display:inline-block;margin-top:16px;background:#fff;color:#0b5a87;border-radius:8px;padding:9px 18px;font-weight:700;font-size:14px}
26
- .svc-group{margin-top:28px}
27
- .svc-group h3{font-size:16px;margin:0 0 4px;padding-bottom:9px;border-bottom:1px solid rgba(128,128,128,.28)}
28
- .svc-grid{display:grid;gap:13px;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));margin-top:15px}
29
- .svc-card{border:1px solid rgba(128,128,128,.3);border-radius:12px;padding:16px 16px 14px;background:rgba(128,128,128,.045);display:flex;flex-direction:column;gap:6px;cursor:pointer;transition:border-color .15s,transform .15s}
30
- .svc-card:hover,.svc-card:focus-visible{border-color:#0b5a87;transform:translateY(-2px);outline:none}
31
- .svc-card .row{display:flex;align-items:flex-start;gap:8px}
32
- .svc-card h4{margin:0;font-size:15px;font-weight:700;line-height:1.4}
33
- .svc-card p{margin:0;font-size:12.8px;opacity:.78;line-height:1.6}
34
- .svc-st{flex:none;margin-left:auto;font-size:10.5px;font-weight:700;padding:3px 8px;border-radius:5px;white-space:nowrap}
35
- .st-live{background:rgba(31,107,90,.16);color:#1f6b5a}
36
- .st-soon{background:rgba(138,100,20,.16);color:#8a6414}
37
- .svc-card.soon{cursor:default;opacity:.72}
38
- .svc-card.soon:hover{border-color:rgba(128,128,128,.3);transform:none}
39
- .svc-card.saju{background:linear-gradient(180deg,rgba(180,85,31,.12),rgba(128,128,128,.045) 60%)}
40
- #view-building{padding:0}
41
- #building-frame{width:100%;height:calc(100vh - 168px);border:0;display:block}
42
- @media(prefers-color-scheme:dark){.svc-card{background:rgba(255,255,255,.04)}}
43
- </style>
44
- </head>
45
- <body>
46
- <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>
47
- <div id="app">
48
-
49
- <!-- ===== 모바일 햄버거 ===== -->
50
- <button id="hamburger-btn" aria-label="메뉴 열기">
51
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
52
- <line x1="3" y1="6" x2="21" y2="6"/>
53
- <line x1="3" y1="12" x2="21" y2="12"/>
54
- <line x1="3" y1="18" x2="21" y2="18"/>
55
- </svg>
56
- </button>
57
-
58
- <!-- ===== 사이드바 ===== -->
59
- <aside id="sidebar">
60
- <div class="sidebar-head">
61
- <div class="brand-top">
62
- <div class="emblem">🏛️</div>
63
- <div>
64
- <div class="brand-title">전남광주 통합특별시</div>
65
- <div class="brand-sub">시민의 AI</div>
66
- </div>
67
- </div>
68
- <button id="new-chat-btn" title="새 대화">+ 새 상담</button>
69
- </div>
70
- <div class="sidebar-section-label">최근 상담</div>
71
- <ul id="chat-list"></ul>
72
- <div class="sidebar-foot">
73
- <div class="brand-small">
74
- <strong>Powered by @비드래프트</strong>
75
- <small>JGOS v0.1 preview</small>
76
- </div>
77
- </div>
78
- </aside>
79
-
80
- <main id="main">
81
- <!-- ===== 상단 시민포털 헤더 ===== -->
82
- <header id="topbar">
83
- <div class="topbar-left">
84
- <button id="sidebar-toggle" class="mobile-only" aria-label="사이드바">☰</button>
85
- <div class="title-block">
86
- <div class="title-main">🏛️ 전남광주 통합특별시 · 시민의 AI</div>
87
- <div class="title-sub">행정 특화 언어모델 <b>JGOS</b> 탑재 · 행정·법령·관광·민원 안내</div>
88
- </div>
89
- </div>
90
- <nav class="top-tabs">
91
- <button class="top-tab active" data-view="home">🏠 서비스</button>
92
- <button class="top-tab" data-view="chat">💬 JGOS 챗봇</button>
93
- <button class="top-tab" data-view="building">🏗️ 건축 도면</button>
94
- <button class="top-tab" data-view="office">📑 무료 오피스</button>
95
- <button class="top-tab" data-view="image">🎨 이미지 생성</button>
96
- <button class="top-tab" data-view="ax">🏛️ 행정 AX(테스트)</button>
97
- </nav>
98
- <div class="topbar-right">
99
- <span class="status-pill" id="status">● 정상</span>
100
- </div>
101
- </header>
102
-
103
- <div id="view-chat">
104
- <div id="messages-wrap">
105
- <div id="messages"></div>
106
-
107
- <!-- ===== 첫 화면 시민포털 (메시지 0건 시 표시) ===== -->
108
- <div id="portal" class="portal">
109
- <div class="portal-hero">
110
- <div class="hero-emblem">🏛️</div>
111
- <h1 class="hero-title">전남광주 통합특별시민과<br>함께하는 AI <strong>JGOS</strong></h1>
112
- <p class="hero-sub">행정·민원·법령·관광·교통·문화·통계·산업·농수산 정보를<br>친절하게 안내해 드립니다. 무엇이 궁금하신가요?</p>
113
- </div>
114
-
115
- <!-- ===== 방안1: 히어로 파일 드롭존 ===== -->
116
- <button class="hero-dropzone" id="hero-dropzone" type="button">
117
- <div class="hd-icon">📎</div>
118
- <div class="hd-main">파일을 끌어다 놓거나 클릭하세요</div>
119
- <div class="hd-sub">HWP · 이미지 · PDF · 엑셀 무엇이든 올리면 <strong>바로 분석</strong>해 드립니다</div>
120
- <div class="hd-badge">🇰🇷 타 AI가 못 읽는 <strong>한글 HWP</strong>도 분석하는 sovereign AI</div>
121
- <!-- 방안2: 지원 형식 아이콘 띠 -->
122
- <div class="fmt-strip">
123
- <span class="fmt hwp">HWP</span><span class="fmt hwp">HWPX</span>
124
- <span class="fmt pdf">PDF</span><span class="fmt doc">DOCX</span>
125
- <span class="fmt xls">XLSX</span><span class="fmt ppt">PPTX</span>
126
- <span class="fmt csv">CSV</span><span class="fmt img">이미지</span>
127
- <span class="fmt more">+10종</span>
128
- </div>
129
- </button>
130
-
131
- <!-- 8개 카테고리 큰 카드 -->
132
- <div class="cat-grid">
133
- <button class="cat-card" data-q="민원 처리 절차와 필요한 서류를 알려주세요.">
134
- <div class="cat-emoji">📝</div>
135
- <div class="cat-name">민원</div>
136
- <div class="cat-desc">절차·서류·전화</div>
137
- </button>
138
- <button class="cat-card" data-q="전남광주통합특별법의 주요 조항을 알려주세요.">
139
- <div class="cat-emoji">📜</div>
140
- <div class="cat-name">법령</div>
141
- <div class="cat-desc">특별법·조례</div>
142
- </button>
143
- <button class="cat-card" data-q="전남광주 통합특별시 관광 명소를 추천해 주세요.">
144
- <div class="cat-emoji">🗺️</div>
145
- <div class="cat-name">관광</div>
146
- <div class="cat-desc">22 시군 + 5구</div>
147
- </button>
148
- <button class="cat-card" data-q="전남광주 통합특별시 교통편을 안내해 주세요.">
149
- <div class="cat-emoji">🚆</div>
150
- <div class="cat-name">교통</div>
151
- <div class="cat-desc">KTX·공항·페리</div>
152
- </button>
153
- <button class="cat-card" data-q="전남광주 통합특별시의 농수산 특산품을 알려주세요.">
154
- <div class="cat-emoji">🌾</div>
155
- <div class="cat-name">농수산</div>
156
- <div class="cat-desc">GI 특산·수산물</div>
157
- </button>
158
- <button class="cat-card" data-q="전남광주 통합특별시의 주요 산업과 경제 규모를 알려주세요.">
159
- <div class="cat-emoji">🏭</div>
160
- <div class="cat-name">산업</div>
161
- <div class="cat-desc">경제·기업</div>
162
- </button>
163
- <button class="cat-card" data-q="전남광주 통합특별시의 인구 통계와 지역 현황을 알려주세요.">
164
- <div class="cat-emoji">📊</div>
165
- <div class="cat-name">통계</div>
166
- <div class="cat-desc">인구·경제</div>
167
- </button>
168
- <button class="cat-card" data-attach="true">
169
- <div class="cat-emoji">📎</div>
170
- <div class="cat-name">첨부문서</div>
171
- <div class="cat-desc">HWP·PDF·이미지</div>
172
- </button>
173
- </div>
174
-
175
- <!-- 추천 질문 (자주 묻는) -->
176
- <div class="quick-section">
177
- <div class="quick-label">자주 묻는 질문</div>
178
- <div class="quick-grid">
179
- <button class="quick-btn" data-q="전남광주 통합특별시장과 광주 5개 구청장, 전남 시장·군수 당선인이 각각 누구인가요?">🏛️ 지자체장 당선인</button>
180
- <button class="quick-btn" data-q="전남광주(광주·전남) 지역구 국회의원 명단을 알려주세요.">🗳️ 지역구 국회의원</button>
181
- <button class="quick-btn" data-q="주민등록증 재발급 절차와 필요한 서류는?">📇 주민등록증 재발급</button>
182
- <button class="quick-btn" data-q="전남광주 통합특별시 인구는?">👥 통합특별시 인구</button>
183
- <button class="quick-btn" data-q="보성 녹차밭 관광 안내 부탁해.">🍃 보성 녹차밭</button>
184
- <button class="quick-btn" data-q="전남광주통합특별법 제6조 내용은?">📜 특별법 제6조</button>
185
- <button class="quick-btn" data-q="전남광주 통합특별시청 대표 전화번호와 부서 안내는?">📞 시청 대표번호</button>
186
- <button class="quick-btn" data-q="여수에서 가볼 만한 곳 3곳만 추천해.">🏖️ 여수 가볼 곳</button>
187
- <button class="quick-btn" data-q="5·18 민주화운동 기록관 위치와 관람 안내는?">🏛️ 5·18 기록관</button>
188
- <button class="quick-btn" data-q="2026년 7월 1일 전남광주 통합특별시 출범의 의미와 일정은?">🎌 7·1 출범</button>
189
- </div>
190
- </div>
191
-
192
- <!-- 신뢰 배지 (첫 화면 only — NIA 본문은 composer footer에서 영구 표시) -->
193
- <div class="portal-foot">
194
- <div class="trust-line">
195
- <span class="trust-badge">🔒 sovereign AI</span>
196
- <span class="trust-badge">📅 기준일 2026-06-01</span>
197
- <span class="trust-badge">📚 RAG 21,192 청크</span>
198
- <span class="trust-badge">⚖️ 정식 행정 안내 아님 · 062-120 확인 권장</span>
199
- </div>
200
- </div>
201
- </div>
202
- </div>
203
-
204
- <!-- ===== 입력 영역 ===== -->
205
- <footer id="composer">
206
- <div class="warn-bar" id="warn-bar" hidden>
207
- ✅ 2026년 6월 3일 지방선거 완료 — 민형배 통합특별시장 당선(7월 1일 취임). 단체장·지역구 국회의원 명단 반영 완료.
208
- </div>
209
- <div class="attach-row" id="attach-row"></div>
210
- <div class="input-row">
211
- <button id="attach-btn" title="파일 첨부 (HWP/HWPX/PDF/DOCX/PPTX/XLSX/XLS/CSV/이미지)" aria-label="파일 첨부">📎</button>
212
- <button id="web-btn" title="웹검색 — 켜면 네이버·웹에서 최신 정보를 함께 검색합니다" aria-label="웹검색 켜기/끄기" aria-pressed="false">🔍</button>
213
- <button id="minwon-btn" title="민원 — 켜면 질문을 민원으로 보고 담당 부서·연락처를 찾아드립니다" aria-label="민원 모드 켜기/끄기" aria-pressed="false">📞</button>
214
- <input type="file" id="file-input" hidden multiple
215
- accept=".hwp,.hwpx,.pdf,.docx,.pptx,.xlsx,.xls,.csv,.txt,.md,.jpg,.jpeg,.png,.gif,.webp">
216
- <textarea id="input" rows="1" placeholder="질문을 입력하거나 파일을 첨부하세요 (Shift+Enter 줄바꿈)"></textarea>
217
- <button id="send-btn" title="전송 (Enter)" aria-label="전송">
218
- <svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
219
- </button>
220
- </div>
221
-
222
- <!-- ===== NIA K-AI 1위 출처 문구 (항상 표시 — 모든 화면 하단) ===== -->
223
- <div class="nia-disclaimer">
224
- 본 모델은 과학기술정보통신부·한국지능정보사회진흥원(NIA) K-AI 리더보드 1위 달성 모델을 기반으로,
225
- 전남광주 통합특별시 관련 공개정보를 추가 학습·최적화한 한국어 특화 모델입니다.
226
- 한국어 처리 능력과 한국 문화·사회·행정·전문 분야에 대한 이해를 바탕으로
227
- 시민 질의에 정확하고 친절하게 응답합니다.
228
- </div>
229
- </footer>
230
- </div><!-- /view-chat -->
231
-
232
- <!-- ===== 무료 오피스 (HanSoo 엔진 흡수 · same-origin iframe 임베디드) ===== -->
233
-
234
- <section id="view-home" hidden>
235
- <div class="svc-wrap">
236
-
237
- <div class="svc-hero" data-view="chat">
238
- <div class="eyebrow">가장 먼저 · 무엇이든 물어보세요</div>
239
- <h2>JGOS 챗봇</h2>
240
- <p>전입신고부터 재산세 납부 기한까지. 전남광주 통합특별시 행정에 맞춰 만든 우리 시 전용 AI가 24시간 답합니다.</p>
241
- <span class="go">상담 시작하기 →</span>
242
- </div>
243
-
244
- <div class="svc-group">
245
- <h3>지금 바로 쓰는 서비스</h3>
246
- <div class="svc-grid">
247
- <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>
248
- <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>
249
- <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>
250
- <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>
251
- <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>
252
- <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>
253
- <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>
254
- <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>
255
- </div>
256
- </div>
257
-
258
- <div class="svc-group">
259
- <h3>준비 중인 서비스</h3>
260
- <div class="svc-grid">
261
- <div class="svc-card soon"><div class="row"><h4>🔮 사주 라이프차트</h4><span class="svc-st st-soon">준비 중</span></div><p>생년월일시로 백 년 흐름을 그려 보는 남도 전통문화 체험.</p></div>
262
- <div class="svc-card soon"><div class="row"><h4>📄 문서 판독</h4><span class="svc-st st-soon">준비 중</span></div><p>손글씨 서식·진단서를 읽어 표로 정리합니다.</p></div>
263
- <div class="svc-card soon"><div class="row"><h4>🗣️ 안내 아바타</h4><span class="svc-st st-soon">준비 중</span></div><p>사람 얼굴로 말하며 안내합니다. 글보다 말이 편한 분께.</p></div>
264
- <div class="svc-card soon"><div class="row"><h4>☎️ AI 전화상담</h4><span class="svc-st st-soon">준비 중</span></div><p>인터넷을 안 쓰셔도 전화 한 통이면 됩니다.</p></div>
265
- <div class="svc-card soon"><div class="row"><h4>🛡️ 어르신 생활반응</h4><span class="svc-st st-soon">준비 중</span></div><p>카메라 없이 집 안 움직임을 살펴 이상이 길어지면 알립니다.</p></div>
266
- <div class="svc-card soon"><div class="row"><h4>🌾 영농 상담</h4><span class="svc-st st-soon">준비 중</span></div><p>작물 시기, 판로, 지원 사업을 물어보세요.</p></div>
267
- <div class="svc-card soon"><div class="row"><h4>🏢 조달·나라장터</h4><span class="svc-st st-soon">준비 중</span></div><p>우리 시 기업이 낼 수 있는 공고를 찾아 드립니다.</p></div>
268
- <div class="svc-card soon"><div class="row"><h4>💡 특허·지식재산</h4><span class="svc-st st-soon">준비 중</span></div><p>아이디어가 이미 나온 것인지 확인하고 출원을 돕습니다.</p></div>
269
- </div>
270
- </div>
271
-
272
- </div>
273
- </section>
274
-
275
- <section id="view-building" hidden>
276
- <iframe id="building-frame" title="건축 도면 만들기" src="about:blank"></iframe>
277
- </section>
278
-
279
- <section id="view-office" hidden>
280
- <iframe id="office-frame" title="무료 오피스 (HWP·DOCX·XLSX·PPTX·PDF)"
281
- allow="clipboard-read; clipboard-write"></iframe>
282
- </section>
283
-
284
- <!-- ===== JGOS-Image (Qwen-Image-2512 한글 이미지 생성) ===== -->
285
- <section id="view-image" hidden>
286
- <div class="img-studio">
287
- <div class="img-head">
288
- <h2>🎨 이미지 생성 <small>한국형 AI 이미지 · 한국 인물·배경 · 영문 텍스트 · Qwen-Image-2512</small></h2>
289
- </div>
290
- <div class="img-examples">
291
- <button class="img-ex" data-p="전남광주 통합특별시 출범 기념 포스터, 한국 시민들이 함께 모인 장면, 영문 제목 'Better Together', 밝고 희망찬">🎌 출범 포스터</button>
292
- <button class="img-ex" data-p="보성 녹차밭 관광 포스터, 한국 전통 정취, 영문 문구 'Green Healing Tour', 청량하고 시원한">🍃 관광 홍보</button>
293
- <button class="img-ex" data-p="민원 안내 인포그래픽, 깔끔한 한국 행정 디자인, 영문 제목 'Civil Service Guide', 아이콘">📋 민원 안내</button>
294
- <button class="img-ex" data-p="전남 청정 농수산물 홍보, 한국 농촌 배경, 영문 문구 'Fresh Local Food', 풍성한">🌾 특산물</button>
295
- </div>
296
- <div class="img-opts">
297
- <label class="img-opt-label">📐 비율
298
- <select id="img-ratio">
299
- <option value="1:1" selected>⬜ 정사각형 1:1</option>
300
- <option value="3:4">📄 세로 3:4 (포스터)</option>
301
- <option value="9:16">📱 세로 9:16 (모바일)</option>
302
- <option value="4:3">🖼️ 가로 4:3</option>
303
- <option value="16:9">🖥️ 가로 16:9 (배너)</option>
304
- </select>
305
- </label>
306
- </div>
307
- <div class="img-input-row">
308
- <textarea id="img-prompt" rows="2" placeholder="원하는 이미지를 설명하세요 (예: 여수 밤바다 야경, 영문 문구 'Visit Yeosu')"></textarea>
309
- <button id="img-gen-btn">🎨 생성</button>
310
- </div>
311
- <div id="img-status" class="img-status" hidden></div>
312
- <div id="img-result" class="img-result"></div>
313
- <div class="img-foot">⚖️ AI 생성 이미지 · 공식 행정자료 아님 · 상업적 이용 시 별도 검토</div>
314
- </div>
315
- </section>
316
-
317
- <!-- ===== 🎓 AI 교육 (AI 시민 대학) ===== -->
318
- <section id="view-edu" hidden>
319
- <div class="edu-wrap">
320
- <div class="edu-head">
321
- <h2>🎓 AI 시민 대학 <small>AI 선생님에게 배우고 바로 실습하세요 · 누구나 무료</small></h2>
322
- </div>
323
- <div class="edu-cat-title">📱 JGOS 100% 활용</div>
324
- <div class="edu-cards">
325
- <button class="edu-card" data-q="JGOS에서 민원을 어떻게 물어보나요? 처음 쓰는 사람도 알 수 있게 쉽게 알려주세요." data-go="chat">① 민원 물어보기</button>
326
- <button class="edu-card" data-q="JGOS 무료 오피스로 한글(HWP) 문서를 편집하고 저장하는 법을 알려주세요." data-go="office">② HWP 편집</button>
327
- <button class="edu-card" data-q="JGOS 이미지 생성 기능 사용법과 좋은 설명(프롬프트) 쓰는 법을 알려주세요." data-go="image">③ 이미지 생성</button>
328
- <button class="edu-card" data-q="JGOS에 PDF나 사진 파일을 올려서 내용을 분석하는 방법을 알려주세요." data-go="chat">④ 파일 분석</button>
329
- </div>
330
- <div class="edu-cat-title">🤖 AI 기본 소양</div>
331
- <div class="edu-cards">
332
- <button class="edu-card" data-q="AI가 무엇인지 어르신도 이해하기 쉽게 비유를 들어 설명해주세요.">⑤ AI란 무엇?</button>
333
- <button class="edu-card" data-q="AI에게 질문을 잘 하는 방법(프롬프트)을 쉬운 예시와 함께 알려주세요.">⑥ 질문 잘하기</button>
334
- <button class="edu-card" data-q="AI가 만든 가짜 정보(환각)를 구별하고 조심하는 방법을 알려주세요.">⑦ 가짜정보 판별</button>
335
- <button class="edu-card" data-q="AI를 사용할 때 내 개인정보를 안전하게 지키는 방법을 알려주세요.">⑧ 개인정보 보호</button>
336
- </div>
337
- <div class="edu-cat-title">🌱 생활 맞춤</div>
338
- <div class="edu-cards">
339
- <button class="edu-card" data-q="컴퓨터가 익숙하지 않은 어르신을 위해 AI를 처음 사용하는 법을 아주 쉽고 천천히 알려주세요.">⑨ 어르신 첫걸음</button>
340
- <button class="edu-card" data-q="소상공인이 AI로 가게 홍보물과 안내 문서를 만드는 방법을 알려주세요.">⑩ 소상공인 홍보</button>
341
- <button class="edu-card" data-q="학생이 AI를 공부와 학습에 올바르고 똑똑하게 활용하는 방법을 알려주세요.">⑪ 학생 학습</button>
342
- <button class="edu-card" data-q="농어민이 AI로 농수산물 홍보·판매와 영농 정보를 활용하는 방법을 알려주세요.">⑫ 농어민 활용</button>
343
- </div>
344
- <div class="edu-teacher">
345
- <div class="edu-teacher-head">🧑‍🏫 AI 선생님 — 무엇이든 편하게 물어보세요</div>
346
- <div id="edu-messages" class="edu-messages"></div>
347
- <div id="edu-goto" class="edu-goto" hidden></div>
348
- <div class="edu-input-row">
349
- <textarea id="edu-input" rows="1" placeholder="예: AI가 뭐예요? / 프롬프트가 뭔가요?"></textarea>
350
- <button id="edu-send">질문</button>
351
- </div>
352
- </div>
353
- <div class="edu-foot">🎓 전남광주 통합특별시 AI 시민 대학 · 모든 시민 무료 · 위 강의를 누르면 AI 선생님이 설명해 드려요</div>
354
- </div>
355
- </section>
356
-
357
- <!-- ===== 🏛️ 행정 AX 데이터 통합 (테스트 데모) ===== -->
358
- <section id="view-ax" hidden>
359
- <div class="ax-container">
360
- <div class="ax-header">
361
- <h2>🏛️ 행정 AX 데이터 통합 — 테스트 데모</h2>
362
- <p class="ax-desc">이종 파일 포맷·서식의 행정 데이터를 자동으로 인식·정규화·통합합니다</p>
363
- </div>
364
-
365
- <!-- Demo 1: 이종 Excel 통합 -->
366
- <div class="ax-demo-card">
367
- <div class="ax-demo-header">
368
- <span class="ax-demo-badge">Demo 1</span>
369
- <h3>📊 이종 Excel 자동 통합</h3>
370
- <p>27개 지자체가 제각각 다른 서식으로 제출한 Excel을 자동으로 표준화·통합합니다</p>
371
- </div>
372
- <div class="ax-demo-files">
373
- <div class="ax-files-label">📁 예제 파일 (클릭하여 미리보기)</div>
374
- <div class="ax-file-icons" id="ax-excel-demos"><!-- JS로 채움 --></div>
375
- </div>
376
- <div class="ax-upload-zone" id="ax-excel-drop">
377
- <div class="ax-upload-inner">
378
- <span class="ax-upload-icon">📤</span>
379
- <div>Excel/CSV 파일을 여기에 드래그하거나 클릭하세요</div>
380
- <div class="ax-upload-hint">XLSX, XLS, CSV — 여러 파일 동시 업로드 가능</div>
381
- </div>
382
- <input type="file" id="ax-excel-input" hidden multiple accept=".xlsx,.xls,.csv">
383
- </div>
384
- <div class="ax-actions">
385
- <button class="ax-btn ax-btn-primary" id="ax-excel-run" disabled>
386
- <span>⚡</span> 통합 실행
387
- </button>
388
- <span class="ax-file-count" id="ax-excel-count"></span>
389
- </div>
390
- <div class="ax-result-area" id="ax-excel-result" hidden></div>
391
- </div>
392
-
393
- <!-- Demo 2: 이종 포맷 통합 -->
394
- <div class="ax-demo-card">
395
- <div class="ax-demo-header">
396
- <span class="ax-demo-badge ax-demo-badge-2">Demo 2</span>
397
- <h3>🗂️ 이종 포맷 자동 통합</h3>
398
- <p>HWP, XLSX, CSV, PPTX, DOCX, PDF — 7가지 다른 포맷의 데이터를 하나의 표로 통합합니다</p>
399
- </div>
400
- <div class="ax-demo-files">
401
- <div class="ax-files-label">📁 예제 파일 7종 (클릭하여 미리보기)</div>
402
- <div class="ax-file-icons" id="ax-format-demos"><!-- JS로 채움 --></div>
403
- </div>
404
- <div class="ax-upload-zone" id="ax-format-drop">
405
- <div class="ax-upload-inner">
406
- <span class="ax-upload-icon">📤</span>
407
- <div>이종 포맷 파일을 여기에 드래그하거나 클릭하세요</div>
408
- <div class="ax-upload-hint">HWPX, XLSX, CSV, PPTX, DOCX, PDF — 혼합 업로드 가능</div>
409
- </div>
410
- <input type="file" id="ax-format-input" hidden multiple accept=".hwp,.hwpx,.xlsx,.xls,.csv,.docx,.pptx,.pdf">
411
- </div>
412
- <div class="ax-actions">
413
- <button class="ax-btn ax-btn-primary ax-btn-2" id="ax-format-run" disabled>
414
- <span>⚡</span> 통합 실행
415
- </button>
416
- <span class="ax-file-count" id="ax-format-count"></span>
417
- </div>
418
- <div class="ax-result-area" id="ax-format-result" hidden></div>
419
- </div>
420
- </div>
421
-
422
- <!-- 파일 미리보기 모달 -->
423
- <div id="ax-modal" class="ax-modal-overlay" hidden>
424
- <div class="ax-modal-box">
425
- <div class="ax-modal-header">
426
- <span id="ax-modal-title">파일 미리보기</span>
427
- <button class="ax-modal-close" id="ax-modal-close">✕</button>
428
- </div>
429
- <div class="ax-modal-body" id="ax-modal-body">
430
- <div class="ax-loading">⏳ 불러오는 중...</div>
431
- </div>
432
- </div>
433
- </div>
434
- </section>
435
- </main>
436
-
437
- <!-- 모바일 사이드바 백드롭 -->
438
- <div id="sidebar-backdrop" hidden></div>
439
- </div>
440
-
441
- <div id="drag-overlay" hidden>
442
- <div class="drop-card">
443
- 📂 여기에 파일을 놓으세요
444
- <small>HWP · HWPX · PDF · DOCX · PPTX · XLSX · CSV · 이미지<br>(ESC 또는 클릭으로 닫기)</small>
445
- </div>
446
- </div>
447
-
448
- <script src="/static/jgos_app_v611.js?v=612"></script>
449
-
450
- <script>
451
- /* JGOS AX 미리보기 — 인라인 (캐시 우회, v611-INLINE) */
452
- window.axPreviewDemo = async function(filename, label){
453
- var modal=document.getElementById("ax-modal");
454
- var body=document.getElementById("ax-modal-body");
455
- var title=document.getElementById("ax-modal-title");
456
- if(!modal||!body)return;
457
- if(title)title.textContent="\uD83D\uDCC4 "+label+" \u2014 \uBBF8\uB9AC\uBCF4\uAE30";
458
- body.innerHTML='<div class="ax-loading">\u23F3 \uBD88\uB7EC\uC624\uB294 \uC911... [INLINE]</div>';
459
- modal.hidden=false;
460
- try{
461
- var r=await fetch("/api/demo/preview/"+encodeURIComponent(filename)+"?t="+Date.now());
462
- if(!r.ok){body.innerHTML="<p style=\'color:#b91c1c\'>\uC11C\uBC84 \uC624\uB958 ("+r.status+")</p>";return;}
463
- var d=await r.json();
464
- body.innerHTML=(d.rows>0)?(d.html||"<p>\uBE48 \uACB0\uACFC</p>"):"<p style=\'color:#92400e\'>\u26A0\uFE0F \uD30C\uC2F1 \uC2E4\uD328</p>";
465
- }catch(e){
466
- body.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e&&e.message?e.message:e)+"</p>";
467
- }
468
- };
469
- /* 칩 onclick 재바인딩 (외부 js가 이미 그렸어도 인라인 함수로 연결) */
470
- (function(){
471
- function rebind(){
472
- document.querySelectorAll(".ax-file-chip").forEach(function(chip){
473
- chip.onclick=function(){ window.axPreviewDemo(chip.dataset.demo, chip.dataset.label); };
474
- });
475
- }
476
- if(document.readyState!=="loading")setTimeout(rebind,300);
477
- else document.addEventListener("DOMContentLoaded",function(){setTimeout(rebind,300);});
478
- // 탭 전환 후에도 재바인딩
479
- document.addEventListener("click",function(e){
480
- var t=e.target.closest&&e.target.closest('[data-view="ax"]');
481
- if(t)setTimeout(rebind,400);
482
- });
483
- })();
484
- console.log("[JGOS] AX inline preview v611-INLINE loaded");
485
- </script>
486
-
487
-
488
- <script>
489
- /* JGOS AX 예제 바로 실행 버튼 — DEMO-RUN-v1 */
490
- (function(){
491
- async function runDemoMerge(btn, resultId, demoType){
492
- var result=document.getElementById(resultId);
493
- var old=btn.innerHTML; btn.disabled=true; btn.innerHTML="\u23F3 \uD1B5\uD569 \uC911...";
494
- if(result){result.hidden=false;result.innerHTML='<div class="ax-loading">\u23F3 \uC608\uC81C \uD1B5\uD569 \uC911...</div>';}
495
- try{
496
- var r=await fetch("/api/demo/merge/"+demoType+"?t="+Date.now());
497
- var d=await r.json();
498
- if(result){
499
- 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("");
500
- result.innerHTML='<div class="ax-source-list">'+src+'</div>'+(d.html||"");
501
- }
502
- }catch(e){if(result)result.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e&&e.message?e.message:e)+"</p>";}
503
- btn.disabled=false; btn.innerHTML=old;
504
- }
505
- function addBtn(runBtnId, resultId, demoType){
506
- var runBtn=document.getElementById(runBtnId);
507
- if(!runBtn) return;
508
- if(document.getElementById(runBtnId+"-demo")) return;
509
- var btn=document.createElement("button");
510
- btn.id=runBtnId+"-demo";
511
- btn.className=runBtn.className;
512
- btn.style.background="#0d7377";
513
- btn.style.marginLeft="8px";
514
- btn.innerHTML="\uD83D\uDCC2 \uC608\uC81C\uB85C \uBC14\uB85C \uD1B5\uD569";
515
- runBtn.parentNode.insertBefore(btn, runBtn.nextSibling);
516
- btn.onclick=function(){ runDemoMerge(btn, resultId, demoType); };
517
- }
518
- function setup(){
519
- addBtn("ax-excel-run","ax-excel-result","excel");
520
- addBtn("ax-format-run","ax-format-result","formats");
521
- }
522
- if(document.readyState!=="loading") setTimeout(setup,500);
523
- else document.addEventListener("DOMContentLoaded",function(){setTimeout(setup,500);});
524
- document.addEventListener("click",function(e){
525
- var t=e.target.closest&&e.target.closest('[data-view="ax"]');
526
- if(t) setTimeout(setup,500);
527
- });
528
- console.log("[JGOS] AX demo-run buttons v1 loaded");
529
- })();
530
- </script>
531
-
532
-
533
- <script>
534
- /* AX-INTEGRATE-v2: 통합 결과 표시 + XLSX 다운로드 */
535
- (function(){
536
- function chip(s){return '<span class="ax-source-chip">'+(s["\uD3EC\uB9F7"]||"")+" "+(s["\uD30C\uC77C"]||"")+" ("+(s["\uAC74\uC218"]||0)+"\uAC74)</span>";}
537
- window.__axShow=function(resultId,d){
538
- var result=document.getElementById(resultId);
539
- if(!result)return;
540
- result.hidden=false;
541
- var src=(d.sources||[]).map(chip).join("");
542
- result.innerHTML='<div class="ax-source-list">'+src+'</div>'+(d.html||"");
543
- if(d.records&&d.records.length){
544
- var dl=document.createElement("button");
545
- 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";
546
- dl.textContent="\uD83D\uDCE5 \uD1B5\uD569\uBCF8 XLSX \uB2E4\uC6B4\uB85C\uB4DC ("+d.records.length+"\uAC74)";
547
- dl.onclick=async function(){
548
- var o=dl.textContent; dl.disabled=true; dl.textContent="\u23F3 \uC0DD\uC131 \uC911...";
549
- try{
550
- 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"})});
551
- if(!rr.ok)throw new Error("\uC11C\uBC84 "+rr.status);
552
- var b=await rr.blob(); var u=URL.createObjectURL(b);
553
- var a=document.createElement("a"); a.href=u; a.download="JGOS_\uD589\uC815AX_\uD1B5\uD569\uACB0\uACFC.xlsx";
554
- document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(u);
555
- }catch(e){alert("\uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: "+(e.message||e));}
556
- dl.disabled=false; dl.textContent=o;
557
- };
558
- result.appendChild(dl);
559
- }
560
- };
561
- async function exampleMerge(btn,resultId,type){
562
- var o=btn.innerHTML; btn.disabled=true; btn.innerHTML="\u23F3 \uD1B5\uD569 \uC911...";
563
- var result=document.getElementById(resultId);
564
- if(result){result.hidden=false;result.innerHTML='<div class="ax-loading">\u23F3 \uC608\uC81C \uD1B5\uD569 \uC911...</div>';}
565
- try{var r=await fetch("/api/demo/merge/"+type+"?t="+Date.now());var d=await r.json();window.__axShow(resultId,d);}
566
- catch(e){if(result)result.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e.message||e)+"</p>";}
567
- btn.disabled=false; btn.innerHTML=o;
568
- }
569
- async function uploadMerge(btn,inputId,resultId,apiPath){
570
- var input=document.getElementById(inputId);
571
- if(!input||!input.files||!input.files.length){alert("\uD30C\uC77C\uC744 \uC120\uD0DD\uD558\uC138\uC694");return;}
572
- var o=btn.innerHTML; btn.disabled=true; btn.innerHTML="\u23F3 \uD1B5\uD569 \uC911...";
573
- var result=document.getElementById(resultId);
574
- if(result){result.hidden=false;result.innerHTML='<div class="ax-loading">\u23F3 \uD1B5\uD569 \uC911...</div>';}
575
- var fd=new FormData(); for(var i=0;i<input.files.length;i++)fd.append("files",input.files[i]);
576
- try{var r=await fetch(apiPath,{method:"POST",body:fd});var d=await r.json();window.__axShow(resultId,d);}
577
- catch(e){if(result)result.innerHTML="<p style=\'color:red\'>\uC624\uB958: "+(e.message||e)+"</p>";}
578
- btn.disabled=false; btn.innerHTML=o;
579
- }
580
- function setup(){
581
- var er=document.getElementById("ax-excel-run");
582
- if(er)er.onclick=function(){uploadMerge(er,"ax-excel-input","ax-excel-result","/api/excel/merge");};
583
- var fr=document.getElementById("ax-format-run");
584
- if(fr)fr.onclick=function(){uploadMerge(fr,"ax-format-input","ax-format-result","/api/files/merge-formats");};
585
- [["ax-excel-run","ax-excel-result","excel"],["ax-format-run","ax-format-result","formats"]].forEach(function(x){
586
- var run=document.getElementById(x[0]); if(!run)return;
587
- var id=x[0]+"-demo"; var b=document.getElementById(id);
588
- 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);}
589
- b.onclick=function(){exampleMerge(b,x[1],x[2]);};
590
- });
591
- }
592
- if(document.readyState!=="loading")setTimeout(setup,600);
593
- else document.addEventListener("DOMContentLoaded",function(){setTimeout(setup,600);});
594
- document.addEventListener("click",function(e){var t=e.target.closest&&e.target.closest('[data-view="ax"]');if(t)setTimeout(setup,600);});
595
- console.log("[JGOS] AX integrate+download v2 loaded");
596
- })();
597
- </script>
598
-
599
-
600
- <script id="jgos-home-js">
601
- /* 서비스 첫화면 + 건축도면 뷰 — 기존 showView/switchTab 을 감싸서 확장 (원본 JS 무수정) */
602
- (function(){
603
- var home=document.getElementById('view-home');
604
- var build=document.getElementById('view-building');
605
- var frame=document.getElementById('building-frame');
606
- var chat=document.getElementById('view-chat');
607
- var loaded=false;
608
- function paint(name){
609
- home.hidden = name!=='home';
610
- build.hidden = name!=='building';
611
- if(name==='building' && !loaded){ frame.src='https://VIDraft-building.hf.space/'; loaded=true; }
612
- if(name==='home'||name==='building'){ if(chat) chat.style.display='none'; }
613
- }
614
- function go(name){
615
- document.querySelectorAll('.top-tab').forEach(function(t){
616
- t.classList.toggle('active', t.dataset.view===name);
617
- });
618
- if(typeof showView==='function') showView(name==='home'||name==='building' ? '__none__' : name);
619
- paint(name);
620
- }
621
- window.jgosGo = go;
622
- document.querySelectorAll('.top-tab').forEach(function(t){
623
- t.addEventListener('click', function(){ go(t.dataset.view); });
624
- });
625
- document.addEventListener('click', function(e){
626
- var c=e.target.closest('.svc-card, .svc-hero');
627
- if(!c || c.classList.contains('soon')) return;
628
- var v=c.dataset.view; if(!v) return;
629
- go(v);
630
- if(v==='chat' && c.dataset.q){
631
- var box=document.querySelector('#composer-input, #input, textarea');
632
- if(box){ box.value=c.dataset.q; box.focus(); }
633
- }
634
- });
635
- go('home'); /* 첫 화면 = 서비스 */
636
- })();
637
- </script>
638
- </body>
639
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>