ummaya 0.2.2 → 0.2.4

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 (111) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/prompts/manifest.yaml +2 -2
  5. package/prompts/session_guidance_v1.md +3 -1
  6. package/prompts/system_v1.md +8 -7
  7. package/pyproject.toml +2 -7
  8. package/src/ummaya/context/builder.py +17 -11
  9. package/src/ummaya/engine/engine.py +27 -7
  10. package/src/ummaya/engine/query.py +20 -0
  11. package/src/ummaya/evidence/__init__.py +25 -0
  12. package/src/ummaya/evidence/__main__.py +7 -0
  13. package/src/ummaya/evidence/models.py +58 -0
  14. package/src/ummaya/evidence/runner.py +308 -0
  15. package/src/ummaya/evidence/task_registry.py +264 -0
  16. package/src/ummaya/ipc/frame_schema.py +47 -0
  17. package/src/ummaya/ipc/stdio.py +1349 -90
  18. package/src/ummaya/llm/client.py +132 -56
  19. package/src/ummaya/llm/reasoning.py +84 -0
  20. package/src/ummaya/tools/discovery_bridge.py +17 -1
  21. package/src/ummaya/tools/executor.py +32 -12
  22. package/src/ummaya/tools/geocoding/kakao_client.py +1 -2
  23. package/src/ummaya/tools/kma/apihub_catalog.py +984 -1
  24. package/src/ummaya/tools/kma/apihub_structured_adapter.py +86 -6
  25. package/src/ummaya/tools/kma/apihub_url_adapter.py +593 -0
  26. package/src/ummaya/tools/kma/apihub_url_catalog.py +296 -0
  27. package/src/ummaya/tools/location_adapters.py +8 -6
  28. package/src/ummaya/tools/manifest_metadata.py +16 -3
  29. package/src/ummaya/tools/mvp_surface.py +2 -2
  30. package/src/ummaya/tools/nmc/emergency_search.py +8 -6
  31. package/src/ummaya/tools/register_all.py +9 -0
  32. package/src/ummaya/tools/resolve_location.py +4 -4
  33. package/src/ummaya/tools/search.py +664 -18
  34. package/src/ummaya/tools/verified_data_go_kr/_manifest.py +115 -25
  35. package/src/ummaya/tools/verified_data_go_kr/airkorea_air_quality.py +109 -4
  36. package/src/ummaya/tools/verified_data_go_kr/nmc_aed_site.py +108 -2
  37. package/src/ummaya/tools/verified_data_go_kr/pps_bid_public_info.py +174 -9
  38. package/src/ummaya/tools/verified_data_go_kr/tago_bus_arrival.py +66 -3
  39. package/src/ummaya/tools/verified_data_go_kr/tago_bus_location.py +12 -2
  40. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route.py +8 -2
  41. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route_station.py +114 -0
  42. package/src/ummaya/tools/verified_data_go_kr/tago_bus_station.py +14 -3
  43. package/src/ummaya/tools/verify_canonical_map.py +21 -0
  44. package/tui/package.json +1 -2
  45. package/tui/src/QueryEngine.ts +4 -0
  46. package/tui/src/cli/handlers/auth.ts +1 -1
  47. package/tui/src/cli/handlers/mcp.tsx +3 -3
  48. package/tui/src/cli/print.ts +69 -18
  49. package/tui/src/cli/update.ts +13 -13
  50. package/tui/src/commands/copy/index.ts +1 -1
  51. package/tui/src/commands/cost/cost.ts +2 -2
  52. package/tui/src/commands/init-verifiers.ts +5 -5
  53. package/tui/src/commands/init.ts +30 -30
  54. package/tui/src/commands/insights.ts +43 -43
  55. package/tui/src/commands/install-github-app/install-github-app.tsx +2 -2
  56. package/tui/src/commands/install-github-app/setupGitHubActions.ts +3 -3
  57. package/tui/src/commands/install.tsx +5 -5
  58. package/tui/src/commands/mcp/addCommand.ts +5 -5
  59. package/tui/src/commands/mcp/xaaIdpCommand.ts +2 -2
  60. package/tui/src/commands/plugin/ManageMarketplaces.tsx +2 -2
  61. package/tui/src/commands/reasoning/index.ts +13 -0
  62. package/tui/src/commands/reasoning/reasoning.tsx +177 -0
  63. package/tui/src/commands/thinkback/thinkback.tsx +3 -3
  64. package/tui/src/commands.ts +2 -0
  65. package/tui/src/components/Messages.tsx +2 -1
  66. package/tui/src/components/Spinner.tsx +2 -2
  67. package/tui/src/components/design-system/LoadingState.tsx +2 -2
  68. package/tui/src/ipc/codec.ts +26 -0
  69. package/tui/src/ipc/frames.generated.ts +398 -303
  70. package/tui/src/ipc/llmClient.ts +130 -51
  71. package/tui/src/ipc/llmTypes.ts +16 -1
  72. package/tui/src/ipc/schema/frame.schema.json +1 -3475
  73. package/tui/src/main.tsx +3 -0
  74. package/tui/src/query.ts +467 -2
  75. package/tui/src/screens/REPL.tsx +3 -3
  76. package/tui/src/services/api/claude.ts +54 -25
  77. package/tui/src/services/api/client.ts +33 -12
  78. package/tui/src/services/api/ummaya.ts +70 -16
  79. package/tui/src/skills/bundled/stuck.ts +12 -12
  80. package/tui/src/state/AppStateStore.ts +7 -0
  81. package/tui/src/tools/AdapterTool/AdapterTool.ts +590 -7
  82. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +43 -17
  83. package/tui/src/tools/LookupPrimitive/prompt.ts +7 -6
  84. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +40 -19
  85. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +25 -9
  86. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +25 -9
  87. package/tui/src/tools/_shared/citizenUserText.ts +49 -0
  88. package/tui/src/tools/_shared/directPublicDataGuard.ts +362 -0
  89. package/tui/src/tools/_shared/kmaAnalysisGuard.ts +197 -0
  90. package/tui/src/tools/_shared/kmaAviationGuard.ts +70 -0
  91. package/tui/src/tools/_shared/locationInputRepair.ts +112 -0
  92. package/tui/src/tools/_shared/nmcAedGuard.ts +234 -0
  93. package/tui/src/tools/_shared/protectedCheckGuard.ts +207 -0
  94. package/tui/src/tools/_shared/rootPrimitiveInput.ts +67 -0
  95. package/tui/src/tools/_shared/textToolCallGuard.ts +91 -0
  96. package/tui/src/tools/_shared/toolChoiceRepair.ts +866 -0
  97. package/tui/src/utils/attachments.ts +1 -1
  98. package/tui/src/utils/kExaoneReasoning.ts +138 -0
  99. package/tui/src/utils/messages.ts +1 -0
  100. package/tui/src/utils/multiToolLayout.ts +13 -0
  101. package/tui/src/utils/processUserInput/processSlashCommand.tsx +2 -2
  102. package/tui/src/utils/processUserInput/processUserInput.ts +26 -0
  103. package/tui/src/utils/settings/applySettingsChange.ts +4 -0
  104. package/tui/src/utils/settings/types.ts +9 -3
  105. package/tui/src/utils/stats.ts +1 -1
  106. package/uv.lock +1 -15
  107. package/assets/copilot-gate-logo.svg +0 -58
  108. package/assets/govon-logo.svg +0 -40
  109. package/src/ummaya/eval/__init__.py +0 -5
  110. package/src/ummaya/eval/retrieval.py +0 -713
  111. package/tui/src/utils/messageStream.ts +0 -186
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
8
8
  from ummaya.tools.verified_data_go_kr._models import VerifiedAdapterSpec
9
9
 
10
10
  _LAST_VERIFIED = datetime(2026, 5, 16, tzinfo=UTC)
11
+ _LAST_VERIFIED_2026_05_28 = datetime(2026, 5, 28, tzinfo=UTC)
11
12
  _DATA_GO_KR_KEY = "UMMAYA_DATA_GO_KR_API_KEY"
12
13
 
13
14
 
@@ -67,9 +68,14 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
67
68
  policy_url=_data_go_policy("15073861"),
68
69
  policy_text="공공데이터포털 인증키 기반 한국환경공단 에어코리아 대기오염정보 조회 OpenAPI.",
69
70
  last_verified=_LAST_VERIFIED,
70
- search_hint="15073861 AirKorea 에어코리아 대기오염 시도별 대기질 미세먼지 find",
71
+ search_hint=(
72
+ "15073861 AirKorea 에어코리아 대기오염 시도별 대기질 미세먼지 "
73
+ "sidoName 서울 부산 경기 find"
74
+ ),
71
75
  llm_description=(
72
- "시도명(sido_name)으로 에어코리아 시도별 실시간 측정소 대기질 공개 데이터를 조회한다."
76
+ "짧은 시도명(sido_name: 서울, 부산, 경기 등)으로 에어코리아 시도별 "
77
+ "실시간 측정소 대기질 공개 데이터를 조회한다. 부산광역시처럼 긴 행정명은 "
78
+ "AirKorea 계약상 부산으로 줄여 호출한다."
73
79
  ),
74
80
  trigger_examples=["서울 대기질 측정소 데이터 조회해줘"],
75
81
  ),
@@ -148,7 +154,9 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
148
154
  last_verified=_LAST_VERIFIED,
149
155
  search_hint="15098529 TAGO 버스노선 cityCode routeNo bus route find",
150
156
  llm_description=(
151
- "도시코드(city_code)와 노선번호(route_no)로 TAGO 버스노선 공개 데이터를 조회한다."
157
+ "Search official TAGO bus route data by city_code and citizen-visible "
158
+ "route_no. Use this before tago_bus_location_search when route_id is "
159
+ "unknown."
152
160
  ),
153
161
  ),
154
162
  VerifiedAdapterSpec(
@@ -172,9 +180,44 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
172
180
  policy_url=_data_go_policy("15098530"),
173
181
  policy_text="공공데이터포털 인증키 기반 국토교통부 TAGO 버스도착정보 조회 OpenAPI.",
174
182
  last_verified=_LAST_VERIFIED,
175
- search_hint="15098530 TAGO 버스도착 정류소 nodeId arrival bus find",
183
+ search_hint="15098530 TAGO 버스도착 정류소 nodeId routeno routeid arrival bus find",
176
184
  llm_description=(
177
- "도시코드(city_code)와 정류소 ID(node_id)로 TAGO 버스도착 예정 정보를 조회한다."
185
+ "Search official TAGO bus-arrival predictions by city_code and node_id. "
186
+ "If the citizen gives a stop name such as 부산역 instead of node_id, call "
187
+ "tago_bus_station_search first and reuse its nodeid. If the citizen names "
188
+ "a route such as 1001, pass route_no as an optional client-side filter "
189
+ "against the returned TAGO routeno field; use route_id from "
190
+ "tago_bus_route_search when the route number is ambiguous."
191
+ ),
192
+ ),
193
+ VerifiedAdapterSpec(
194
+ dataset_id="15098529",
195
+ tool_id="tago_bus_route_station_search",
196
+ module_name="tago_bus_route_station",
197
+ name_ko="국토교통부 TAGO 노선별 경유정류소 조회",
198
+ ministry="MOLIT",
199
+ category=["transport", "bus", "public-data"],
200
+ endpoint="https://apis.data.go.kr/1613000/BusRouteInfoInqireService/getRouteAcctoThrghSttnList",
201
+ env_var=_DATA_GO_KR_KEY,
202
+ auth_query_param="serviceKey",
203
+ response_format="xml",
204
+ query_param_map={
205
+ "city_code": "cityCode",
206
+ "route_id": "routeId",
207
+ "page_no": "pageNo",
208
+ "num_of_rows": "numOfRows",
209
+ },
210
+ evidence_path="docs/api/data-go-kr-candidate-docs/15098529/probes/live-2026-05-28/tago-bus-route-station.body.xml",
211
+ policy_url=_data_go_policy("15098529"),
212
+ policy_text="공공데이터포털 인증키 기반 국토교통부 TAGO 버스노선정보 조회 OpenAPI.",
213
+ last_verified=_LAST_VERIFIED_2026_05_28,
214
+ search_hint=("15098529 TAGO 노선별 경유정류소 routeId nodenm nodeid nodeord bus stop find"),
215
+ llm_description=(
216
+ "Search the official TAGO route-station list by city_code and route_id. "
217
+ "For a citizen query that combines a route number and place, call "
218
+ "tago_bus_route_search to get route_id, then call this tool with node_nm "
219
+ "as a client-side filter against returned nodenm values. Use the matching "
220
+ "nodeid with tago_bus_arrival_search and include route_no or route_id."
178
221
  ),
179
222
  ),
180
223
  VerifiedAdapterSpec(
@@ -199,7 +242,10 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
199
242
  policy_text="공공데이터포털 인증키 기반 국토교통부 TAGO 버스위치정보 조회 OpenAPI.",
200
243
  last_verified=_LAST_VERIFIED,
201
244
  search_hint="15098533 TAGO 버스위치 routeId bus location find",
202
- llm_description="도시코드(city_code)와 노선 ID(route_id)로 TAGO 버스 위치 정보를 조회한다.",
245
+ llm_description=(
246
+ "Search official TAGO bus-location data by city_code and route_id. "
247
+ "Use tago_bus_route_search first when the citizen gives only a route number."
248
+ ),
203
249
  ),
204
250
  VerifiedAdapterSpec(
205
251
  dataset_id="15098534",
@@ -225,8 +271,9 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
225
271
  last_verified=_LAST_VERIFIED,
226
272
  search_hint="15098534 TAGO 버스정류소 nodeNm nodeNo station find",
227
273
  llm_description=(
228
- "도시코드(city_code), 정류소명(node_nm), "
229
- "정류소번호(node_no) TAGO 정류소 정보를 조회한다."
274
+ "Search official TAGO bus-stop data by city_code, stop-name fragment "
275
+ "(node_nm), or stop number (node_no). For bus-arrival questions with a "
276
+ "named place or stop, call this before tago_bus_arrival_search to obtain nodeid."
230
277
  ),
231
278
  ),
232
279
  VerifiedAdapterSpec(
@@ -264,27 +311,51 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
264
311
  name_ko="조달청 나라장터 입찰공고 조회",
265
312
  ministry="PPS",
266
313
  category=["procurement", "bid", "public-data"],
267
- endpoint="https://apis.data.go.kr/1230000/ad/BidPublicInfoService/getBidPblancListInfoServc",
314
+ endpoint="https://apis.data.go.kr/1230000/ad/BidPublicInfoService/getBidPblancListInfoCnstwkPPSSrch",
268
315
  env_var=_DATA_GO_KR_KEY,
269
316
  auth_query_param="serviceKey",
270
317
  response_format="json",
271
318
  query_param_map={
272
- "inqry_div": "inqryDiv",
273
- "bid_ntce_no": "bidNtceNo",
274
319
  "page_no": "pageNo",
275
320
  "num_of_rows": "numOfRows",
321
+ "inqry_div": "inqryDiv",
322
+ "inqry_bgn_dt": "inqryBgnDt",
323
+ "inqry_end_dt": "inqryEndDt",
324
+ "bid_ntce_nm": "bidNtceNm",
325
+ "ntce_instt_nm": "ntceInsttNm",
326
+ "dminstt_nm": "dminsttNm",
327
+ "prtcpt_lmt_rgn_nm": "prtcptLmtRgnNm",
328
+ "indstryty_nm": "indstrytyNm",
276
329
  },
277
330
  static_query_params={"type": "json"},
278
- evidence_path="docs/api/data-go-kr-candidate-docs/15129394/probes/live-2026-05-16/pps-bid-service.body.json",
331
+ evidence_path="docs/api/data-go-kr-candidate-docs/15129394/probes/live-2026-05-27/pps-bid-construction-search.body.json",
279
332
  policy_url=_data_go_policy("15129394"),
280
333
  policy_text="공공데이터포털 인증키 기반 조달청 나라장터 입찰공고정보 조회 OpenAPI.",
281
334
  last_verified=_LAST_VERIFIED,
282
- search_hint="15129394 조달청 나라장터 입찰공고 bid public info find",
283
- llm_description=(
284
- "조회구분 inqry_div='2'와 필수 입찰공고번호(bid_ntce_no)로 "
285
- "나라장터 입찰공고 공개 데이터를 조회한다. 등록일시/변경일시 "
286
- "목록 검색은 adapter가 아니라 별도 PPS operation으로 감싸야 한다."
335
+ search_hint=(
336
+ "15129394 조달청 나라장터 입찰공고 검색조건 공사조회 전기공사 부산시 "
337
+ "공고게시일시 개찰일시 inqryBgnDt inqryEndDt bidNtceNm ntceInsttNm "
338
+ "dminsttNm prtcptLmtRgnNm cnstrtsiteRgnNm region_name indstrytyNm "
339
+ "bid public procurement construction find"
287
340
  ),
341
+ llm_description=(
342
+ "Wraps official PPS operation getBidPblancListInfoCnstwkPPSSrch, "
343
+ "the construction-bid search-condition endpoint. Use it for ordinary "
344
+ "citizen list searches such as '이번 주 부산시 전기공사 입찰'. "
345
+ "Fill inqry_bgn_dt and inqry_end_dt as YYYYMMDDHHMM. Use inqry_div='1' "
346
+ "for posted-this-week questions and '2' for bid-opening-date questions. "
347
+ "Keep each upstream call within a 31-day-or-smaller date window; split "
348
+ "broader citizen ranges across multiple calls instead of sending one "
349
+ "over-broad request. "
350
+ "Use bid_ntce_nm for notice keywords such as 전기공사, prtcpt_lmt_rgn_nm "
351
+ "for official participation-limit region restrictions such as 부산광역시, "
352
+ "region_name for UMMAYA client-side filtering against documented PPS "
353
+ "response fields such as cnstrtsiteRgnNm/ntceInsttNm/dminsttNm, and "
354
+ "indstryty_nm for license/industry names such as 전기공사업. Do not "
355
+ "invent notice-number detail inputs for list-search questions; this "
356
+ "adapter no longer exposes the bid-notice-number detail path."
357
+ ),
358
+ trigger_examples=["이번 주 부산시 전기공사 입찰 올라온 거 있어?"],
288
359
  ),
289
360
  VerifiedAdapterSpec(
290
361
  dataset_id="15134761",
@@ -450,11 +521,14 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
450
521
  ),
451
522
  last_verified=_LAST_VERIFIED,
452
523
  search_hint=(
453
- "15001699 HIRA 의료기관 상세정보 응급실 주차 진료시간 ykiho hospital detail find"
524
+ "15001699 HIRA 의료기관 상세정보 병원 상세 진료과 진료과목 응급실 "
525
+ "주차 진료시간 ykiho hospital detail specialty find"
454
526
  ),
455
527
  llm_description=(
456
528
  "암호화 요양기호(ykiho)로 의료기관 세부정보와 "
457
- "응급실/주차/진료시간 공개 데이터를 조회한다."
529
+ "응급실/주차/진료시간/진료과목 공개 데이터를 조회한다. "
530
+ "일반 병원명·지역 검색에는 hira_hospital_search를 먼저 쓰고, "
531
+ "상세정보나 진료과목 확인에는 이 어댑터를 이어서 쓴다."
458
532
  ),
459
533
  ),
460
534
  VerifiedAdapterSpec(
@@ -478,9 +552,13 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
478
552
  policy_url=_data_go_policy("15155046"),
479
553
  policy_text="공공데이터포털 인증키 기반 행정안전부 안전비상벨 위치정보 조회 OpenAPI.",
480
554
  last_verified=_LAST_VERIFIED,
481
- search_hint="15155046 행안부 안전비상벨 위치 경찰연계 방범 emergency call box find",
555
+ search_hint=(
556
+ "15155046 행안부 안전비상벨 비상벨 긴급신고함 위치 경찰연계 "
557
+ "방범 emergency call box safety bell find"
558
+ ),
482
559
  llm_description=(
483
- "도로명주소 조각으로 안전비상벨 설치 위치와 관리기관 공개 데이터를 조회한다."
560
+ "도로명주소 조각으로 안전비상벨·비상벨·긴급신고함 설치 위치와 "
561
+ "관리기관 공개 데이터를 조회한다."
484
562
  ),
485
563
  ),
486
564
  VerifiedAdapterSpec(
@@ -525,10 +603,12 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
525
603
  policy_text="공공데이터포털 인증키 기반 계룡시 장애인 전동보장구 충전 장소 조회 OpenAPI.",
526
604
  last_verified=_LAST_VERIFIED,
527
605
  search_hint=(
528
- "15096040 계룡시 전동휠체어 전동보장구 충전 장소 실내 accessibility charger find"
606
+ "15096040 계룡시 전동휠체어 전동보장구 보장구 충전소 충전 장소 "
607
+ "장애인 실내 accessibility charger find"
529
608
  ),
530
609
  llm_description=(
531
- "계룡시 장애인 전동보장구 충전 장소, 위치, 이용 가능 시간 공개 데이터를 조회한다."
610
+ "계룡시 장애인 전동보장구·전동휠체어 충전소/충전 장소, "
611
+ "위치, 이용 가능 시간 공개 데이터를 조회한다."
532
612
  ),
533
613
  ),
534
614
  VerifiedAdapterSpec(
@@ -554,9 +634,19 @@ VERIFIED_DATA_GO_KR_ADAPTERS: tuple[VerifiedAdapterSpec, ...] = (
554
634
  "공공데이터포털 인증키 기반 국립중앙의료원 전국 자동심장충격기 정보 조회 OpenAPI."
555
635
  ),
556
636
  last_verified=_LAST_VERIFIED,
557
- search_hint="15000652 국립중앙의료원 AED 자동심장충격기 자동제세동기 위치 locate find",
637
+ search_hint=(
638
+ "15000652 국립중앙의료원 AED 자동심장충격기 자동제세동기 "
639
+ "응급실 주변 시도 시군구 q0 q1 find"
640
+ ),
558
641
  llm_description=(
559
- "시도(q0)와 시군구(q1)로 전국 AED 설치 위치와 이용 가능 시간 공개 데이터를 조회한다."
642
+ "시도(q0)와 시군구(q1)로 전국 AED 설치 위치와 이용 가능 시간 공개 데이터를 "
643
+ "조회한다. 시민이 '응급실이나 AED', '응급실/AED', '자동심장충격기'를 같이 "
644
+ "묻는 경우 nmc_emergency_search 결과만으로 AED를 답하지 말고, 이 find "
645
+ "어댑터를 별도 호출한다. q0/q1은 좌표가 아니라 공식 지역 필터다. "
646
+ "origin_lat/origin_lon은 업스트림 파라미터가 아니라 응답 WGS84 좌표를 "
647
+ "거리순으로 정렬하기 위한 선택 필드다. 예: "
648
+ "부산역 근처는 locate 후 부산광역시/동구 또는 중구 지역 필터로 본 도구를 "
649
+ "추가 조회한다."
560
650
  ),
561
651
  ),
562
652
  VerifiedAdapterSpec(
@@ -3,7 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from pydantic import BaseModel, ConfigDict, Field
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
7
7
 
8
8
  from ummaya.tools.executor import ToolExecutor
9
9
  from ummaya.tools.models import GovAPITool
@@ -21,11 +21,29 @@ class AirKoreaAirQualityInput(BaseModel):
21
21
 
22
22
  model_config = ConfigDict(extra="forbid")
23
23
 
24
- sido_name: str = Field(..., min_length=1, description="Korean province/city name.")
24
+ sido_name: str = Field(
25
+ ...,
26
+ min_length=1,
27
+ description="AirKorea short province/city name, e.g. 서울, 부산, 경기.",
28
+ )
25
29
  page_no: int = Field(default=1, ge=1, description="Page number.")
26
- num_of_rows: int = Field(default=10, ge=1, le=100, description="Rows per page.")
30
+ num_of_rows: int = Field(
31
+ default=100,
32
+ ge=1,
33
+ le=100,
34
+ description="Rows per page; 100 captures all city/province stations in normal use.",
35
+ )
27
36
  ver: str = Field(default="1.0", description="AirKorea response version.")
28
37
 
38
+ @field_validator("sido_name", mode="before")
39
+ @classmethod
40
+ def normalize_sido_name(cls, value: object) -> object:
41
+ """AirKorea returns rows for short 시도 names, not full 행정명 names."""
42
+
43
+ if not isinstance(value, str):
44
+ return value
45
+ return _normalize_airkorea_sido_name(value)
46
+
29
47
 
30
48
  SPEC = require_spec("airkorea_ctprvn_air_quality")
31
49
  INPUT_SCHEMA = AirKoreaAirQualityInput
@@ -39,7 +57,94 @@ async def handle(
39
57
  ) -> dict[str, object]:
40
58
  """Fetch or replay AirKorea air quality rows."""
41
59
 
42
- return await handle_verified_input(input_model, SPEC, fixture_body=fixture_body)
60
+ output = await handle_verified_input(input_model, SPEC, fixture_body=fixture_body)
61
+ return _enrich_air_quality_grades(output)
62
+
63
+
64
+ _AIRKOREA_GRADE_LABELS: dict[str, str] = {
65
+ "1": "좋음",
66
+ "2": "보통",
67
+ "3": "나쁨",
68
+ "4": "매우나쁨",
69
+ }
70
+
71
+ _AIRKOREA_ITEM_NAMES: dict[str, str] = {
72
+ "khai": "통합대기환경지수(CAI)",
73
+ "pm10": "미세먼지(PM10)",
74
+ "pm25": "초미세먼지(PM2.5)",
75
+ "o3": "오존(O3)",
76
+ "no2": "이산화질소(NO2)",
77
+ "co": "일산화탄소(CO)",
78
+ "so2": "아황산가스(SO2)",
79
+ }
80
+
81
+ _AIRKOREA_SIDO_ALIASES: dict[str, str] = {
82
+ "서울특별시": "서울",
83
+ "부산광역시": "부산",
84
+ "대구광역시": "대구",
85
+ "인천광역시": "인천",
86
+ "광주광역시": "광주",
87
+ "대전광역시": "대전",
88
+ "울산광역시": "울산",
89
+ "세종특별자치시": "세종",
90
+ "경기도": "경기",
91
+ "강원특별자치도": "강원",
92
+ "강원도": "강원",
93
+ "충청북도": "충북",
94
+ "충청남도": "충남",
95
+ "전북특별자치도": "전북",
96
+ "전라북도": "전북",
97
+ "전라남도": "전남",
98
+ "경상북도": "경북",
99
+ "경상남도": "경남",
100
+ "제주특별자치도": "제주",
101
+ "제주도": "제주",
102
+ }
103
+
104
+
105
+ def _normalize_airkorea_sido_name(value: str) -> str:
106
+ normalized = value.strip()
107
+ return _AIRKOREA_SIDO_ALIASES.get(normalized, normalized)
108
+
109
+
110
+ def _enrich_air_quality_grades(output: dict[str, object]) -> dict[str, object]:
111
+ items = output.get("items")
112
+ if not isinstance(items, list):
113
+ return output
114
+ enriched_items: list[dict[str, object]] = []
115
+ changed = False
116
+ for item in items:
117
+ if not isinstance(item, dict):
118
+ continue
119
+ record_raw = item.get("record")
120
+ if not isinstance(record_raw, dict):
121
+ enriched_items.append(item)
122
+ continue
123
+ record = dict(record_raw)
124
+ for prefix, name_ko in _AIRKOREA_ITEM_NAMES.items():
125
+ name_key = f"{prefix}NameKo"
126
+ if name_key not in record:
127
+ record[name_key] = name_ko
128
+ changed = True
129
+ grade_value = record.get(f"{prefix}Grade")
130
+ label = _airkorea_grade_label(grade_value)
131
+ if label is not None:
132
+ record[f"{prefix}GradeLabelKo"] = label
133
+ changed = True
134
+ next_item = dict(item)
135
+ next_item["record"] = record
136
+ enriched_items.append(next_item)
137
+ if not changed:
138
+ return output
139
+ enriched_output = dict(output)
140
+ enriched_output["items"] = enriched_items
141
+ return enriched_output
142
+
143
+
144
+ def _airkorea_grade_label(value: object) -> str | None:
145
+ if value is None:
146
+ return None
147
+ return _AIRKOREA_GRADE_LABELS.get(str(value).strip())
43
148
 
44
149
 
45
150
  def register(registry: ToolRegistry, executor: ToolExecutor) -> None:
@@ -3,7 +3,9 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from pydantic import BaseModel, ConfigDict, Field
6
+ import math
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
9
 
8
10
  from ummaya.tools.executor import ToolExecutor
9
11
  from ummaya.tools.models import GovAPITool
@@ -25,6 +27,30 @@ class NmcAedSiteInput(BaseModel):
25
27
  q1: str = Field(..., min_length=1, description="District/county name.")
26
28
  page_no: int = Field(default=1, ge=1, description="Page number.")
27
29
  num_of_rows: int = Field(default=10, ge=1, le=100, description="Rows per page.")
30
+ origin_lat: float | None = Field(
31
+ default=None,
32
+ ge=-90,
33
+ le=90,
34
+ description=(
35
+ "Optional original query latitude for client-side distance sorting. "
36
+ "Not sent to the upstream NMC AED API."
37
+ ),
38
+ )
39
+ origin_lon: float | None = Field(
40
+ default=None,
41
+ ge=-180,
42
+ le=180,
43
+ description=(
44
+ "Optional original query longitude for client-side distance sorting. "
45
+ "Not sent to the upstream NMC AED API."
46
+ ),
47
+ )
48
+
49
+ @model_validator(mode="after")
50
+ def _origin_pair_is_complete(self) -> NmcAedSiteInput:
51
+ if (self.origin_lat is None) ^ (self.origin_lon is None):
52
+ raise ValueError("origin_lat and origin_lon must be supplied together")
53
+ return self
28
54
 
29
55
 
30
56
  SPEC = require_spec("nmc_aed_site_locate")
@@ -39,7 +65,87 @@ async def handle(
39
65
  ) -> dict[str, object]:
40
66
  """Fetch or replay NMC AED rows."""
41
67
 
42
- return await handle_verified_input(input_model, SPEC, fixture_body=fixture_body)
68
+ output = await handle_verified_input(input_model, SPEC, fixture_body=fixture_body)
69
+ if input_model.origin_lat is None or input_model.origin_lon is None:
70
+ return output
71
+ _sort_items_by_origin_distance(
72
+ output,
73
+ origin_lat=input_model.origin_lat,
74
+ origin_lon=input_model.origin_lon,
75
+ )
76
+ return output
77
+
78
+
79
+ def _sort_items_by_origin_distance(
80
+ output: dict[str, object],
81
+ *,
82
+ origin_lat: float,
83
+ origin_lon: float,
84
+ ) -> None:
85
+ items = output.get("items")
86
+ if not isinstance(items, list):
87
+ return
88
+ for item in items:
89
+ if not isinstance(item, dict):
90
+ continue
91
+ record = item.get("record")
92
+ if not isinstance(record, dict):
93
+ continue
94
+ lat = _as_float(record.get("wgs84Lat"))
95
+ lon = _as_float(record.get("wgs84Lon"))
96
+ if lat is None or lon is None:
97
+ continue
98
+ distance_km = round(
99
+ _haversine_km(
100
+ lat1=origin_lat,
101
+ lon1=origin_lon,
102
+ lat2=lat,
103
+ lon2=lon,
104
+ ),
105
+ 3,
106
+ )
107
+ record["distance"] = distance_km
108
+ record["distance_km"] = distance_km
109
+ record["distance_unit"] = "km"
110
+ items.sort(key=_distance_sort_key)
111
+
112
+
113
+ def _distance_sort_key(item: object) -> tuple[int, float]:
114
+ if not isinstance(item, dict):
115
+ return (1, 0.0)
116
+ record = item.get("record")
117
+ if not isinstance(record, dict):
118
+ return (1, 0.0)
119
+ distance = _as_float(record.get("distance_km"))
120
+ if distance is None:
121
+ return (1, 0.0)
122
+ return (0, distance)
123
+
124
+
125
+ def _as_float(value: object) -> float | None:
126
+ if isinstance(value, bool):
127
+ return None
128
+ if isinstance(value, int | float):
129
+ return float(value)
130
+ if not isinstance(value, str):
131
+ return None
132
+ try:
133
+ return float(value.strip())
134
+ except ValueError:
135
+ return None
136
+
137
+
138
+ def _haversine_km(*, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
139
+ radius_km = 6371.0088
140
+ phi1 = math.radians(lat1)
141
+ phi2 = math.radians(lat2)
142
+ delta_phi = math.radians(lat2 - lat1)
143
+ delta_lambda = math.radians(lon2 - lon1)
144
+ a = (
145
+ math.sin(delta_phi / 2.0) ** 2
146
+ + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
147
+ )
148
+ return radius_km * 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
43
149
 
44
150
 
45
151
  def register(registry: ToolRegistry, executor: ToolExecutor) -> None: