@nomadamas/k-skill 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bin/k-skill.js +64 -43
  2. package/package.json +2 -2
  3. package/skills/biz-health-check/instruction.md +2 -0
  4. package/skills/company-analysis/instruction.md +68 -0
  5. package/skills/company-analysis/references/source-contract.md +75 -0
  6. package/skills/company-analysis/scripts/dart-rows.js +52 -0
  7. package/skills/company-analysis/scripts/dart-rows.ts +48 -0
  8. package/skills/company-analysis/scripts/evidence.js +567 -0
  9. package/skills/company-analysis/scripts/evidence.ts +520 -0
  10. package/skills/company-analysis/scripts/fetch-public.js +85 -0
  11. package/skills/company-analysis/scripts/fetch-public.ts +75 -0
  12. package/skills/company-analysis/skill.json +6 -0
  13. package/skills/express-bus-booking/scripts/kobus_express_booking.py +46 -18
  14. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +53 -3
  15. package/skills/g2b-order-plan-search/instruction.md +17 -0
  16. package/skills/g2b-sanctioned-supplier/instruction.md +10 -1
  17. package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +8 -4
  18. package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +19 -0
  19. package/skills/government-bond-analysis/instruction.md +216 -0
  20. package/skills/government-bond-analysis/references/methodology.md +63 -0
  21. package/skills/government-bond-analysis/scripts/cli.mjs +268 -0
  22. package/skills/government-bond-analysis/scripts/collect.mjs +171 -0
  23. package/skills/government-bond-analysis/scripts/core.mjs +224 -0
  24. package/skills/government-bond-analysis/scripts/dual.mjs +178 -0
  25. package/skills/government-bond-analysis/scripts/harness.mjs +406 -0
  26. package/skills/government-bond-analysis/scripts/modes.mjs +136 -0
  27. package/skills/government-bond-analysis/scripts/news.mjs +113 -0
  28. package/skills/government-bond-analysis/scripts/render.mjs +120 -0
  29. package/skills/government-bond-analysis/scripts/schedule.mjs +96 -0
  30. package/skills/government-bond-analysis/scripts/ust.mjs +204 -0
  31. package/skills/government-bond-analysis/skill.json +8 -0
  32. package/skills/korean-bond-search/instruction.md +122 -0
  33. package/skills/korean-bond-search/references/fields.md +48 -0
  34. package/skills/korean-bond-search/references/source-notes.md +11 -0
  35. package/skills/korean-bond-search/scripts/bond_search.py +906 -0
  36. package/skills/korean-bond-search/skill.json +9 -0
  37. package/skills/market-event-impact/instruction.md +150 -0
  38. package/skills/market-event-impact/references/event-taxonomy.md +74 -0
  39. package/skills/market-event-impact/references/methodology.md +98 -0
  40. package/skills/market-event-impact/references/research-contract.md +120 -0
  41. package/skills/market-event-impact/references/source-priority.md +25 -0
  42. package/skills/market-event-impact/scripts/adapters.py +32 -0
  43. package/skills/market-event-impact/scripts/asset_selector.py +120 -0
  44. package/skills/market-event-impact/scripts/calendars.py +179 -0
  45. package/skills/market-event-impact/scripts/event_resolver.py +260 -0
  46. package/skills/market-event-impact/scripts/event_study.py +171 -0
  47. package/skills/market-event-impact/scripts/market_data.py +204 -0
  48. package/skills/market-event-impact/scripts/market_event.py +1065 -0
  49. package/skills/market-event-impact/scripts/research.py +389 -0
  50. package/skills/market-event-impact/skill.json +9 -0
  51. package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
  52. package/src/analytics-policy.json +18 -0
  53. package/src/analytics.js +106 -0
  54. package/src/metrics.js +49 -0
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Select event-sensitive assets from available sourced series."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+
9
+ PROFILES = {
10
+ "monetary_policy": (("short_rates", "long_rates", "curve", "fx", "broad_equity", "volatility"), ("rates", "fx", "equity", "volatility")),
11
+ "inflation": (("short_rates", "long_rates", "inflation_breakeven", "fx", "growth_equity", "broad_equity", "volatility"), ("rates", "fx", "equity", "volatility")),
12
+ "employment": (("short_rates", "long_rates", "curve", "fx", "growth_equity", "broad_equity", "volatility"), ("rates", "fx", "equity", "volatility")),
13
+ "growth": (("long_rates", "cyclical_equity", "broad_equity", "fx", "commodity"), ("rates", "equity", "fx", "commodity")),
14
+ "financial_stress": (("short_rates", "bank_equity", "credit", "volatility", "safe_haven", "broad_equity", "fx"), ("rates", "credit", "equity", "volatility", "fx")),
15
+ "sovereign_credit": (("government_bond", "sovereign_cds", "fx", "gold", "broad_equity", "volatility"), ("rates", "credit", "fx", "commodity", "equity", "volatility")),
16
+ "geopolitics": (("oil", "gold", "safe_haven", "fx", "volatility", "broad_equity"), ("commodity", "fx", "rates", "volatility", "equity")),
17
+ "commodity_supply": (("oil", "commodity", "inflation_breakeven", "commodity_equity", "fx", "broad_equity", "volatility"), ("commodity", "rates", "equity", "fx", "volatility")),
18
+ "corporate_earnings": (("event_security", "sector_equity", "broad_equity", "volatility"), ("equity", "volatility")),
19
+ "fiscal_policy": (("government_bond", "curve", "fx", "broad_equity"), ("rates", "fx", "equity")),
20
+ "trade_policy": (("short_rates", "long_rates", "curve", "affected_equity", "broad_equity", "fx", "oil", "commodity", "volatility"), ("rates", "equity", "fx", "commodity", "volatility")),
21
+ "regulation": (("affected_equity", "sector_equity", "broad_equity", "volatility"), ("equity", "volatility")),
22
+ "market_shock": (("event_security", "broad_equity", "volatility", "credit", "safe_haven"), ("equity", "volatility", "credit", "rates")),
23
+ "other": (("event_security", "broad_equity", "fx", "short_rates", "long_rates", "government_bond", "volatility"), ("price", "equity", "fx", "rates", "volatility")),
24
+ }
25
+
26
+ EVENT_TYPE_LABELS = {
27
+ "monetary_policy": "통화정책", "inflation": "물가", "employment": "고용", "growth": "성장",
28
+ "financial_stress": "금융스트레스", "sovereign_credit": "국가신용", "geopolitics": "지정학",
29
+ "commodity_supply": "원자재 공급", "corporate_earnings": "기업실적", "fiscal_policy": "재정정책",
30
+ "trade_policy": "무역정책", "regulation": "규제", "market_shock": "시장충격", "other": "사건별",
31
+ }
32
+ ROLE_LABELS = {
33
+ "short_rates": "단기금리", "long_rates": "장기금리", "curve": "금리곡선", "government_bond": "국채",
34
+ "bank_equity": "은행주", "credit": "신용", "volatility": "변동성", "safe_haven": "안전자산",
35
+ "oil": "원유", "gold": "금", "fx": "외환", "broad_equity": "시장지수", "sector_equity": "업종주",
36
+ "event_security": "사건 직접 노출 종목", "affected_equity": "영향 주식", "commodity": "원자재",
37
+ "growth_equity": "성장주", "cyclical_equity": "경기민감주", "commodity_equity": "원자재 관련주",
38
+ "inflation_breakeven": "기대인플레이션", "sovereign_cds": "국가 CDS",
39
+ }
40
+
41
+
42
+ def select_assets(series: list[dict[str, Any]], event: dict[str, Any], requested: tuple[str, ...] | None = None, limit: int = 8) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
43
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 20:
44
+ raise ValueError("asset selection limit must be an integer from 1 to 20")
45
+ by_symbol = {item["symbol"].casefold(): item for item in series}
46
+ if requested:
47
+ selected, missing = [], []
48
+ for symbol in requested:
49
+ item = by_symbol.get(symbol.casefold())
50
+ if item is None:
51
+ missing.append(symbol)
52
+ elif item not in selected:
53
+ selected.append(item)
54
+ reasons = [{"symbol": item["symbol"], "reason": "사용자 지정", "matched_roles": []} for item in selected]
55
+ return selected, reasons, missing
56
+
57
+ event_type = str(event.get("event_type") or "other")
58
+ preferred_roles, preferred_classes = PROFILES.get(event_type, PROFILES["other"])
59
+ event_region = str(event.get("region") or "GLOBAL").upper()
60
+ scored: list[tuple[int, int, str, dict[str, Any], list[str]]] = []
61
+ for item in series:
62
+ roles = [str(role).casefold() for role in item.get("roles", [])]
63
+ matched = [role for role in preferred_roles if role in roles]
64
+ class_rank = preferred_classes.index(item["asset_class"]) if item["asset_class"] in preferred_classes else len(preferred_classes)
65
+ role_score = sum(len(preferred_roles) - preferred_roles.index(role) for role in matched)
66
+ region_score = 3 if item.get("region") == event_region else (1 if item.get("region") == "GLOBAL" else 0)
67
+ explicit_priority = int(item.get("selection_priority", 0))
68
+ availability_penalty = 100000 if item.get("selection_usable") is False else 0
69
+ total = role_score * 100 + region_score * 10 + explicit_priority - availability_penalty
70
+ # A generic role equal to its asset class conveys no narrower exposure.
71
+ # Specific nonmatching roles (e.g. bank_equity for a CPI event) remain excluded.
72
+ if roles and not matched and not all(role in {item["asset_class"], "price"} for role in roles):
73
+ continue
74
+ if not matched and item["asset_class"] not in preferred_classes:
75
+ continue
76
+ scored.append((total, -class_rank, item["symbol"], item, matched))
77
+ scored.sort(key=lambda row: (-row[0], -row[1], row[2]))
78
+ # Cover distinct asset classes before filling finer roles. Otherwise a
79
+ # profile with many rate/equity roles can exhaust a small limit before FX
80
+ # or volatility is considered. Unusable series never displace usable ones.
81
+ selected_rows: list[tuple[int, int, str, dict[str, Any], list[str]]] = []
82
+ used_symbols: set[str] = set()
83
+ for pool in ([row for row in scored if row[3].get("selection_usable") is not False],
84
+ [row for row in scored if row[3].get("selection_usable") is False]):
85
+ for asset_class in preferred_classes:
86
+ candidate = next((row for row in pool if row[3]["asset_class"] == asset_class and row[2] not in used_symbols), None)
87
+ if candidate is not None:
88
+ selected_rows.append(candidate)
89
+ used_symbols.add(candidate[2])
90
+ if len(selected_rows) >= limit:
91
+ break
92
+ if len(selected_rows) >= limit:
93
+ break
94
+ for role in preferred_roles:
95
+ candidate = next((row for row in pool if role in row[4] and row[2] not in used_symbols), None)
96
+ if candidate is not None:
97
+ selected_rows.append(candidate)
98
+ used_symbols.add(candidate[2])
99
+ if len(selected_rows) >= limit:
100
+ break
101
+ if len(selected_rows) >= limit:
102
+ break
103
+ for row in pool:
104
+ if row[2] not in used_symbols:
105
+ selected_rows.append(row)
106
+ used_symbols.add(row[2])
107
+ if len(selected_rows) >= limit:
108
+ break
109
+ if len(selected_rows) >= limit:
110
+ break
111
+ selected_rows.sort(key=lambda row: (preferred_classes.index(row[3]["asset_class"])
112
+ if row[3]["asset_class"] in preferred_classes else len(preferred_classes),
113
+ -row[0], row[2]))
114
+ selected = [row[3] for row in selected_rows]
115
+ reasons = [{
116
+ "symbol": row[3]["symbol"],
117
+ "reason": f"{EVENT_TYPE_LABELS.get(event_type, event_type)} 전달경로",
118
+ "matched_roles": [ROLE_LABELS.get(role, role) for role in (row[4] or [row[3]["asset_class"]])],
119
+ } for row in selected_rows]
120
+ return selected, reasons, []
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Trading-calendar alignment helpers based on actual observations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import date, datetime, time, timedelta, timezone, tzinfo
7
+ from typing import Any, Iterable
8
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
9
+
10
+
11
+ MARKET_CLOSES = {
12
+ "KR": ("Asia/Seoul", time(15, 30)),
13
+ "JP": ("Asia/Tokyo", time(15, 0)),
14
+ "CN": ("Asia/Shanghai", time(15, 0)),
15
+ "EU": ("Europe/Frankfurt", time(17, 30)),
16
+ "UK": ("Europe/London", time(16, 30)),
17
+ "US": ("America/New_York", time(16, 0)),
18
+ }
19
+ SESSION_ORDER = {"JP": 0, "KR": 0, "CN": 0, "EU": 1, "UK": 1, "US": 2}
20
+ FALLBACK_ZONES = {"Asia/Seoul", "Asia/Tokyo", "Asia/Shanghai", "Europe/Frankfurt", "Europe/London", "America/New_York"}
21
+ TIMEZONE_CLOSES = {zone: close for zone, close in MARKET_CLOSES.values()}
22
+
23
+
24
+ def _nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date:
25
+ current = date(year, month, 1)
26
+ shift = (weekday - current.weekday()) % 7
27
+ return date(year, month, 1 + shift + 7 * (occurrence - 1))
28
+
29
+
30
+ def _last_weekday(year: int, month: int, weekday: int) -> date:
31
+ next_month = date(year + (month == 12), 1 if month == 12 else month + 1, 1)
32
+ current = next_month - timedelta(days=1)
33
+ return current - timedelta(days=(current.weekday() - weekday) % 7)
34
+
35
+
36
+ def zone_for_date(name: str, day: date) -> tzinfo:
37
+ try:
38
+ return ZoneInfo(name)
39
+ except ZoneInfoNotFoundError:
40
+ if name in {"Asia/Seoul", "Asia/Tokyo"}:
41
+ return timezone(timedelta(hours=9), name)
42
+ if name == "Asia/Shanghai":
43
+ return timezone(timedelta(hours=8), name)
44
+ if name == "America/New_York":
45
+ dst = _nth_weekday(day.year, 3, 6, 2) <= day < _nth_weekday(day.year, 11, 6, 1)
46
+ return timezone(timedelta(hours=-4 if dst else -5), name)
47
+ if name == "Europe/Frankfurt":
48
+ dst = _last_weekday(day.year, 3, 6) <= day < _last_weekday(day.year, 10, 6)
49
+ return timezone(timedelta(hours=2 if dst else 1), name)
50
+ if name == "Europe/London":
51
+ dst = _last_weekday(day.year, 3, 6) <= day < _last_weekday(day.year, 10, 6)
52
+ return timezone(timedelta(hours=1 if dst else 0), name)
53
+ raise
54
+
55
+
56
+ def valid_timezone(name: str, day: date) -> bool:
57
+ try:
58
+ zone_for_date(name, day)
59
+ return True
60
+ except ZoneInfoNotFoundError:
61
+ return False
62
+
63
+
64
+ def iso_date(value: str) -> str:
65
+ return date.fromisoformat(value).isoformat()
66
+
67
+
68
+ def sorted_unique_dates(values: Iterable[str]) -> list[str]:
69
+ return sorted({iso_date(value) for value in values})
70
+
71
+
72
+ def align_date(event_date: str, observation_dates: Iterable[str], mode: str = "next") -> tuple[str | None, int | None]:
73
+ target = iso_date(event_date)
74
+ dates = sorted_unique_dates(observation_dates)
75
+ if mode not in {"same", "next", "next_strict", "previous"}:
76
+ raise ValueError("alignment must be same, next, next_strict, or previous")
77
+ if mode == "same":
78
+ return (target, dates.index(target)) if target in dates else (None, None)
79
+ if mode == "next":
80
+ for index, observed in enumerate(dates):
81
+ if observed >= target:
82
+ return observed, index
83
+ return None, None
84
+ if mode == "next_strict":
85
+ for index, observed in enumerate(dates):
86
+ if observed > target:
87
+ return observed, index
88
+ return None, None
89
+ for reverse_index, observed in enumerate(reversed(dates)):
90
+ if observed <= target:
91
+ index = len(dates) - reverse_index - 1
92
+ return observed, index
93
+ return None, None
94
+
95
+
96
+ def window_indices(anchor: int, size: int, offsets: Iterable[int]) -> dict[int, int | None]:
97
+ return {offset: anchor + offset if 0 <= anchor + offset < size else None for offset in offsets}
98
+
99
+
100
+ def market_alignment_for_event(event: dict[str, Any], target_region: str | None) -> str:
101
+ """Choose same-date eligibility from event time versus target market close."""
102
+ event_region = str(event.get("region") or "").upper()
103
+ target = str(target_region or "").upper()
104
+ if target in {"", "GLOBAL"}:
105
+ return "next"
106
+ event_time = event.get("event_time_local")
107
+ event_timezone = event.get("event_timezone")
108
+ if event_time and event_timezone:
109
+ try:
110
+ parsed_time = time.fromisoformat(str(event_time))
111
+ if parsed_time.tzinfo is not None:
112
+ raise ValueError("event_time_local must not contain a timezone offset")
113
+ event_day = date.fromisoformat(str(event["event_date"]))
114
+ event_instant = datetime.combine(event_day, parsed_time, zone_for_date(str(event_timezone), event_day)).astimezone(timezone.utc)
115
+ target_zone, target_close = MARKET_CLOSES[target]
116
+ target_local_date = event_instant.astimezone(zone_for_date(target_zone, event_day)).date()
117
+ target_close_zone = zone_for_date(target_zone, target_local_date)
118
+ close_instant = datetime.combine(target_local_date, target_close, target_close_zone).astimezone(timezone.utc)
119
+ return "next" if event_instant <= close_instant else "next_strict"
120
+ except (KeyError, ValueError, ZoneInfoNotFoundError):
121
+ pass
122
+ if target == event_region:
123
+ return "next"
124
+ if event_region not in MARKET_CLOSES or target not in MARKET_CLOSES:
125
+ return "next_strict"
126
+ return "next" if SESSION_ORDER[event_region] < SESSION_ORDER[target] else "next_strict"
127
+
128
+
129
+ def market_alignment_for_series(event: dict[str, Any], series: dict[str, Any]) -> tuple[str, str, str]:
130
+ """Return alignment mode, basis, and target-local date for a series."""
131
+ event_time = event.get("event_time_local")
132
+ event_timezone = event.get("event_timezone")
133
+ observation_time = series.get("observation_time")
134
+ observation_timezone = series.get("observation_timezone")
135
+ if event_time and event_timezone:
136
+ try:
137
+ event_day = date.fromisoformat(str(event["event_date"]))
138
+ parsed_event_time = time.fromisoformat(str(event_time))
139
+ event_instant = datetime.combine(
140
+ event_day, parsed_event_time, zone_for_date(str(event_timezone), event_day)
141
+ ).astimezone(timezone.utc)
142
+ if observation_time and observation_timezone:
143
+ observation_zone = zone_for_date(str(observation_timezone), event_instant.date())
144
+ local_event = event_instant.astimezone(observation_zone)
145
+ local_date = local_event.date()
146
+ observation_instant = datetime.combine(
147
+ local_date, time.fromisoformat(str(observation_time)), observation_zone
148
+ ).astimezone(timezone.utc)
149
+ mode = "next" if event_instant < observation_instant else "next_strict"
150
+ return mode, "source_observation_time", local_date.isoformat()
151
+
152
+ market_timezone = series.get("market_timezone")
153
+ if market_timezone:
154
+ market_zone = zone_for_date(str(market_timezone), event_instant.date())
155
+ local_event = event_instant.astimezone(market_zone)
156
+ local_date = local_event.date()
157
+ close = TIMEZONE_CLOSES.get(str(market_timezone))
158
+ if close:
159
+ close_instant = datetime.combine(local_date, close, market_zone).astimezone(timezone.utc)
160
+ mode = "next" if event_instant <= close_instant else "next_strict"
161
+ return mode, "market_close_proxy", local_date.isoformat()
162
+ except (KeyError, ValueError, ZoneInfoNotFoundError):
163
+ pass
164
+
165
+ target_region = str(series.get("region") or "").upper()
166
+ if target_region in MARKET_CLOSES:
167
+ try:
168
+ market_timezone, close = MARKET_CLOSES[target_region]
169
+ market_zone = zone_for_date(market_timezone, event_instant.date())
170
+ local_event = event_instant.astimezone(market_zone)
171
+ local_date = local_event.date()
172
+ close_instant = datetime.combine(local_date, close, market_zone).astimezone(timezone.utc)
173
+ mode = "next" if event_instant <= close_instant else "next_strict"
174
+ return mode, "region_market_close_proxy", local_date.isoformat()
175
+ except (ValueError, ZoneInfoNotFoundError):
176
+ pass
177
+
178
+ alignment = market_alignment_for_event(event, series.get("region"))
179
+ return alignment, "region_calendar_proxy", str(event["event_date"])
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve free-form market events into a verified, generic event contract."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import hashlib
8
+ from datetime import date, time as dt_time
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ from calendars import valid_timezone
13
+
14
+
15
+ EVENT_TYPES = {
16
+ "monetary_policy",
17
+ "inflation",
18
+ "employment",
19
+ "growth",
20
+ "financial_stress",
21
+ "sovereign_credit",
22
+ "geopolitics",
23
+ "commodity_supply",
24
+ "corporate_earnings",
25
+ "fiscal_policy",
26
+ "trade_policy",
27
+ "regulation",
28
+ "market_shock",
29
+ "other",
30
+ }
31
+ EVENT_STRUCTURES = {"point_event", "scheduled_event", "developing_event"}
32
+
33
+ GENERIC_QUERY_TERMS = {"decision", "meeting", "event", "release", "announcement", "analysis", "update", "발표", "회의", "사건", "분석", "시장"}
34
+ KNOWN_ACRONYMS = {"svb", "fomc", "ecb", "boj", "bok", "cpi", "ppi", "hicp", "nfp", "gdp", "pmi", "opec", "s&p", "vix", "kre"}
35
+
36
+ TYPE_KEYWORDS = (
37
+ ("financial_stress", ("svb", "bank run", "bank failure", "banking crisis", "은행위기", "은행 파산", "예금유출", "금융불안")),
38
+ ("sovereign_credit", ("downgrade", "default", "credit rating", "신용등급", "국가부도", "디폴트")),
39
+ ("geopolitics", ("war", "invasion", "missile", "conflict", "전쟁", "침공", "군사충돌", "지정학")),
40
+ ("commodity_supply", ("opec", "production cut", "supply disruption", "감산", "공급차질")),
41
+ ("trade_policy", ("tariff", "trade war", "관세", "무역분쟁")),
42
+ ("corporate_earnings", ("earnings", "guidance", "실적", "가이던스")),
43
+ ("monetary_policy", ("fomc", "ecb", "boj", "bok", "금통위", "기준금리", "금리 결정")),
44
+ ("inflation", ("cpi", "ppi", "hicp", "inflation", "물가")),
45
+ ("employment", ("nfp", "payroll", "unemployment", "고용", "실업률")),
46
+ ("growth", ("gdp", "pmi", "growth", "성장률")),
47
+ ("fiscal_policy", ("budget", "stimulus", "fiscal", "예산", "재정", "부양책")),
48
+ ("regulation", ("regulation", "antitrust", "ban", "규제", "금지")),
49
+ ("market_shock", ("flash crash", "selloff", "short squeeze", "폭락", "급등", "쇼트스퀴즈")),
50
+ )
51
+
52
+ REGION_ALIASES = {
53
+ "KOREA": "KR", "SOUTH KOREA": "KR", "한국": "KR",
54
+ "UNITED STATES": "US", "USA": "US", "미국": "US",
55
+ "EURO AREA": "EU", "EUROZONE": "EU", "유로존": "EU",
56
+ "JAPAN": "JP", "일본": "JP", "CHINA": "CN", "중국": "CN",
57
+ "UNITED KINGDOM": "UK", "영국": "UK", "GLOBAL": "GLOBAL", "글로벌": "GLOBAL",
58
+ }
59
+
60
+
61
+ class EventResolutionError(ValueError):
62
+ pass
63
+
64
+
65
+ def normalize_token(value: Any) -> str:
66
+ return " ".join(re.sub(r"[^\w]+", " ", str(value or "").casefold(), flags=re.UNICODE).split())
67
+
68
+
69
+ def stable_event_id(name: str) -> str:
70
+ slug = re.sub(r"[^a-z0-9]+", "-", normalize_token(name)).strip("-")
71
+ if slug:
72
+ return slug[:80].rstrip("-")
73
+ digest = hashlib.sha256(normalize_token(name).encode("utf-8")).hexdigest()[:16]
74
+ return f"event-{digest}"
75
+
76
+
77
+ def infer_event_type(query: str) -> tuple[str, list[str]]:
78
+ normalized = normalize_token(query)
79
+ matches = [event_type for event_type, words in TYPE_KEYWORDS if any(normalize_token(word) in normalized for word in words)]
80
+ unique = list(dict.fromkeys(matches))
81
+ return (unique[0] if unique else "other", unique)
82
+
83
+
84
+ def _source(value: Any) -> dict[str, str]:
85
+ if not isinstance(value, dict):
86
+ raise EventResolutionError("event needs source.name and source.url")
87
+ name = str(value.get("name") or "").strip()
88
+ url = str(value.get("url") or "").strip()
89
+ parsed = urlparse(url)
90
+ if not name or parsed.scheme not in {"http", "https"} or not parsed.netloc:
91
+ raise EventResolutionError("event needs a valid source.name and http(s) source.url")
92
+ return {"name": name, "url": url}
93
+
94
+
95
+ def normalize_region(value: Any) -> str:
96
+ raw = str(value or "").strip().upper()
97
+ resolved = REGION_ALIASES.get(raw, raw)
98
+ if not resolved or not re.fullmatch(r"[A-Z]{2,6}", resolved):
99
+ raise EventResolutionError("event region must be a 2-6 letter market code such as US, KR, EU, JP, or GLOBAL")
100
+ return resolved
101
+
102
+
103
+ def normalize_event(item: dict[str, Any]) -> dict[str, Any]:
104
+ if not isinstance(item, dict):
105
+ raise EventResolutionError("event must be an object")
106
+ name = str(item.get("event_name") or item.get("event_family") or "").strip()
107
+ if not name:
108
+ raise EventResolutionError("event_name is required")
109
+ event_id = str(item.get("event_id") or "").strip().casefold() or stable_event_id(name)
110
+ if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,95}", event_id):
111
+ raise EventResolutionError("event_id must use 1-96 lowercase letters, numbers, dots, underscores, or hyphens")
112
+ try:
113
+ event_date = date.fromisoformat(str(item.get("event_date"))).isoformat()
114
+ except ValueError as exc:
115
+ raise EventResolutionError("event_date must be YYYY-MM-DD") from exc
116
+ explicit_type = str(item.get("event_type") or "").strip().casefold()
117
+ inferred_type, matches = infer_event_type(" ".join([name, str(item.get("event_family") or ""), " ".join(map(str, item.get("aliases") or []))]))
118
+ event_type = explicit_type or inferred_type
119
+ if event_type not in EVENT_TYPES:
120
+ raise EventResolutionError(f"unsupported event_type: {event_type}")
121
+ if not explicit_type and len(matches) > 1:
122
+ raise EventResolutionError("event_type is ambiguous; provide an explicit event_type")
123
+ aliases = item.get("aliases") or []
124
+ if not isinstance(aliases, list) or not all(isinstance(alias, str) and alias.strip() for alias in aliases):
125
+ raise EventResolutionError("event aliases must be non-empty strings")
126
+ event_time = item.get("event_time_local")
127
+ event_timezone = item.get("event_timezone")
128
+ if bool(event_time) != bool(event_timezone):
129
+ raise EventResolutionError("event_time_local and event_timezone must be provided together")
130
+ if event_time:
131
+ try:
132
+ parsed_time = dt_time.fromisoformat(str(event_time))
133
+ if parsed_time.tzinfo is not None or not valid_timezone(str(event_timezone), date.fromisoformat(event_date)):
134
+ raise ValueError
135
+ except ValueError as exc:
136
+ raise EventResolutionError("event time must be local HH:MM with a valid IANA timezone") from exc
137
+ family = str(item.get("event_family") or "").strip().upper().replace(" ", "_") or None
138
+ raw_sequence = item.get("event_sequence") or []
139
+ if not isinstance(raw_sequence, list) or any(not isinstance(step, dict) for step in raw_sequence):
140
+ raise EventResolutionError("event_sequence must be an object array")
141
+ if len(raw_sequence) > 20:
142
+ raise EventResolutionError("event_sequence supports at most 20 timeline steps")
143
+ sequence = []
144
+ sequence_keys = set()
145
+ previous_date = None
146
+ for index, step in enumerate(raw_sequence):
147
+ try:
148
+ step_date = date.fromisoformat(str(step.get("date"))).isoformat()
149
+ except ValueError as exc:
150
+ raise EventResolutionError(f"event_sequence[{index}].date must be YYYY-MM-DD") from exc
151
+ step_time = step.get("time_local")
152
+ step_timezone = step.get("timezone")
153
+ if bool(step_time) != bool(step_timezone):
154
+ raise EventResolutionError(f"event_sequence[{index}].time_local and timezone must be provided together")
155
+ if step_time:
156
+ try:
157
+ parsed_step_time = dt_time.fromisoformat(str(step_time))
158
+ if parsed_step_time.tzinfo is not None or not valid_timezone(str(step_timezone), date.fromisoformat(step_date)):
159
+ raise ValueError
160
+ step_time = parsed_step_time.isoformat(timespec="minutes" if parsed_step_time.second == 0 else "seconds")
161
+ except ValueError as exc:
162
+ raise EventResolutionError(f"event_sequence[{index}] needs a local time and valid IANA timezone") from exc
163
+ step_name = str(step.get("event") or "").strip()
164
+ if not step_name:
165
+ raise EventResolutionError(f"event_sequence[{index}].event is required")
166
+ stage = str(step.get("stage") or "milestone").strip().casefold()
167
+ if not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", stage):
168
+ raise EventResolutionError(f"event_sequence[{index}].stage must be a short identifier")
169
+ source = _source(step.get("source"))
170
+ if previous_date and step_date < previous_date:
171
+ raise EventResolutionError("event_sequence must be in chronological date order")
172
+ previous_date = step_date
173
+ key = (step_date, str(step_time or ""), step_name.casefold())
174
+ if key in sequence_keys:
175
+ raise EventResolutionError("event_sequence contains duplicate milestones")
176
+ sequence_keys.add(key)
177
+ sequence.append({
178
+ "date": step_date,
179
+ "time_local": step_time,
180
+ "timezone": str(step_timezone) if step_timezone else None,
181
+ "stage": stage,
182
+ "event": step_name,
183
+ "source": source,
184
+ })
185
+ raw_structure = str(item.get("event_structure") or "").strip().casefold()
186
+ if raw_structure:
187
+ if raw_structure not in EVENT_STRUCTURES:
188
+ raise EventResolutionError(f"event_structure must be one of {sorted(EVENT_STRUCTURES)}")
189
+ event_structure = raw_structure
190
+ structure_inferred = False
191
+ elif len(sequence) > 1:
192
+ event_structure = "developing_event"
193
+ structure_inferred = True
194
+ elif item.get("adapter") or family:
195
+ event_structure = "scheduled_event"
196
+ structure_inferred = True
197
+ else:
198
+ event_structure = "point_event"
199
+ structure_inferred = True
200
+ if event_structure == "developing_event":
201
+ if len(sequence) < 2:
202
+ raise EventResolutionError("developing_event needs at least two verified event_sequence milestones")
203
+ if not any(step["date"] == event_date for step in sequence):
204
+ raise EventResolutionError("developing_event sequence must include the primary event_date")
205
+ return {
206
+ **item,
207
+ "event_id": event_id,
208
+ "event_name": name,
209
+ "event_family": family,
210
+ "event_type": event_type,
211
+ "event_date": event_date,
212
+ "event_structure": event_structure,
213
+ "event_structure_inferred": structure_inferred,
214
+ "event_timestamp_quality": "exact_timestamp" if event_time else "date_only",
215
+ "event_sequence": sequence,
216
+ "region": normalize_region(item.get("region")),
217
+ "aliases": list(dict.fromkeys(alias.strip() for alias in aliases)),
218
+ "source": _source(item.get("source")),
219
+ }
220
+
221
+
222
+ def resolve_from_events(query: str, events: list[dict[str, Any]]) -> dict[str, Any]:
223
+ needle = normalize_token(query)
224
+ if not needle:
225
+ raise EventResolutionError("event query must be a non-empty string")
226
+ scored: list[tuple[int, dict[str, Any]]] = []
227
+ for raw in events:
228
+ item = normalize_event(raw)
229
+ candidates = [item["event_name"], item["event_id"], item.get("event_family") or "", *item["aliases"]]
230
+ normalized = [normalize_token(value) for value in candidates if value]
231
+ exact = any(needle == value for value in normalized)
232
+ contains = any(needle in value or value in needle for value in normalized)
233
+ needle_tokens = set(needle.split())
234
+ meaningful_tokens = needle_tokens - GENERIC_QUERY_TERMS
235
+ token_overlap = max((len(meaningful_tokens & (set(value.split()) - GENERIC_QUERY_TERMS)) for value in normalized), default=0)
236
+ acronym_match = bool(meaningful_tokens & KNOWN_ACRONYMS & set(" ".join(normalized).split()))
237
+ safe_contains = contains and not (len(needle_tokens) == 1 and needle in GENERIC_QUERY_TERMS)
238
+ score = 100 if exact else (50 if safe_contains else (10 if token_overlap >= 2 or acronym_match else 0))
239
+ if score:
240
+ scored.append((score, item))
241
+ if not scored:
242
+ inferred, matches = infer_event_type(query)
243
+ return {
244
+ "result": "verification_required",
245
+ "query": query,
246
+ "inferred_event_type": inferred,
247
+ "ambiguous_types": matches if len(matches) > 1 else [],
248
+ "message": "사건 날짜와 직접 출처를 확인한 뒤 events 파일에 추가해야 합니다.",
249
+ }
250
+ top_score = max(score for score, _ in scored)
251
+ top = [item for score, item in scored if score == top_score]
252
+ identities = {(item["event_id"], item["event_date"]) for item in top}
253
+ if len(identities) != 1:
254
+ return {
255
+ "result": "ambiguous",
256
+ "query": query,
257
+ "candidates": top,
258
+ "message": "동일하게 일치하는 사건이 여러 개입니다. 날짜나 지역을 추가해 주세요.",
259
+ }
260
+ return {"result": "ok", "query": query, "event": top[0], "resolution": "verified_event_file"}