ummaya 0.2.3 → 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 +1287 -54
  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 +48 -18
  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
@@ -188,6 +188,69 @@ _PRIMITIVE_ERROR_REASONS: Final[frozenset[str]] = frozenset(
188
188
  "verify_tool_choice_mismatch",
189
189
  }
190
190
  )
191
+ _ROOT_PRIMITIVE_TOOL_NAMES: Final[frozenset[str]] = frozenset({"find", "locate", "check", "send"})
192
+ _KMA_AIR_TOOL_IDS: Final[frozenset[str]] = frozenset(
193
+ {
194
+ "kma_apihub_url_air_amos_minute",
195
+ "kma_apihub_url_air_metar_decoded",
196
+ }
197
+ )
198
+ _KMA_ORDINARY_WEATHER_TOOL_IDS: Final[frozenset[str]] = frozenset(
199
+ {
200
+ "kma_current_observation",
201
+ "kma_ultra_short_term_forecast",
202
+ "kma_short_term_forecast",
203
+ }
204
+ )
205
+ _KMA_LOCATION_TOOL_IDS: Final[frozenset[str]] = frozenset(
206
+ {
207
+ "kakao_keyword_search",
208
+ "kakao_address_search",
209
+ "kakao_coord_to_region",
210
+ }
211
+ )
212
+ _KMA_AIRPORT_PLACE_RE: Final = re.compile(
213
+ r"(김해|김포|김해공항|김포공항|gimhae|gimpo|rkpk|rkss|\bairport\b|공항)",
214
+ re.IGNORECASE,
215
+ )
216
+ _KMA_AIRPORT_AVIATION_RE: Final = re.compile(
217
+ r"(비행기|항공편|비행편|운항|이륙|착륙|결항|지연|뜰\s*만|뜨나|뜰\s*수|"
218
+ r"flight|take\s*off|landing|delay|cancel|metar|speci|amos|rvr|활주로|"
219
+ r"시정|visibility|공항기상|항공기상)",
220
+ re.IGNORECASE,
221
+ )
222
+ _SYNTHETIC_USER_CONTEXT_RE: Final = re.compile(
223
+ r"<available_adapters\b|</available_adapters>|"
224
+ r"Pick a concrete adapter from <available_adapters>|"
225
+ r"Prefer concrete adapter function calls",
226
+ re.IGNORECASE,
227
+ )
228
+ _TOOL_RESULT_USER_CONTEXT_RE: Final = re.compile(
229
+ r"^\s*(?:<tool_use_error>|AdapterNotFound:|Permission delegation required:|Error:)"
230
+ r"|</tool_use_error>",
231
+ re.IGNORECASE,
232
+ )
233
+
234
+
235
+ def _is_citizen_user_utterance_text(content: object) -> bool:
236
+ if not isinstance(content, str):
237
+ return False
238
+ text = content.strip()
239
+ if not text:
240
+ return False
241
+ if _SYNTHETIC_USER_CONTEXT_RE.search(text):
242
+ return False
243
+ return _TOOL_RESULT_USER_CONTEXT_RE.search(text) is None
244
+
245
+
246
+ def _latest_citizen_user_utterance(messages: Collection[Any]) -> str:
247
+ for message in reversed(list(messages)):
248
+ if getattr(message, "role", None) != "user":
249
+ continue
250
+ content = getattr(message, "content", None)
251
+ if _is_citizen_user_utterance_text(content):
252
+ return cast(str, content)
253
+ return ""
191
254
 
192
255
 
193
256
  def _should_append_tui_tool_to_llm_tools(
@@ -203,6 +266,43 @@ def _should_append_tui_tool_to_llm_tools(
203
266
  return True
204
267
 
205
268
 
269
+ def _normalize_root_primitive_adapter_envelope(
270
+ fname: str,
271
+ args_obj: dict[str, object],
272
+ ) -> dict[str, object]:
273
+ """Normalize root primitive envelopes before strict adapter validation."""
274
+ if fname not in _ROOT_PRIMITIVE_TOOL_NAMES:
275
+ return args_obj
276
+ params_raw = args_obj.get("params")
277
+ if not isinstance(params_raw, dict):
278
+ return args_obj
279
+ nested_tool_id = params_raw.get("tool_id")
280
+ if not isinstance(nested_tool_id, str) or not nested_tool_id:
281
+ return args_obj
282
+ top_level_tool_id = args_obj.get("tool_id")
283
+ if top_level_tool_id == fname and nested_tool_id not in _ROOT_PRIMITIVE_TOOL_NAMES:
284
+ normalized = dict(args_obj)
285
+ normalized["tool_id"] = nested_tool_id
286
+ normalized["params"] = {key: value for key, value in params_raw.items() if key != "tool_id"}
287
+ return normalized
288
+ if nested_tool_id == top_level_tool_id:
289
+ normalized = dict(args_obj)
290
+ normalized["params"] = {key: value for key, value in params_raw.items() if key != "tool_id"}
291
+ return normalized
292
+ return args_obj
293
+
294
+
295
+ def _function_tool_choice(tool_name: str) -> dict[str, object]:
296
+ """Return OpenAI-compatible forced function-call syntax."""
297
+ return {"type": "function", "function": {"name": tool_name}}
298
+
299
+
300
+ def _tool_definition_names(tool_defs: list[Any] | None) -> set[str]:
301
+ if tool_defs is None:
302
+ return set()
303
+ return {tool.function.name for tool in tool_defs}
304
+
305
+
206
306
  _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]], ...]] = (
207
307
  (
208
308
  ("간편인증", "pass 인증", "kakao 인증", "naver 인증"),
@@ -259,6 +359,19 @@ _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]],
259
359
  "purpose_en": "Gov24 resident registration certificate civil petition",
260
360
  },
261
361
  ),
362
+ (
363
+ ("소득금액증명원", "소득금액증명"),
364
+ {
365
+ "verify_tool_id": "mock_verify_module_simple_auth",
366
+ "allowed_tool_ids": (
367
+ "mock_verify_module_simple_auth,mock_verify_mobile_id,mock_verify_ganpyeon_injeung"
368
+ ),
369
+ "scope": "check:ganpyeon.identity",
370
+ "allowed_scopes": "check:ganpyeon.identity,check:mobile_id.identity",
371
+ "purpose_ko": "소득금액증명원 발급 본인확인",
372
+ "purpose_en": "Income certificate identity verification",
373
+ },
374
+ ),
262
375
  (
263
376
  ("복지 급여 신청", "한부모가족", "아동양육비"),
264
377
  {
@@ -304,6 +417,8 @@ _LOCATION_INDEPENDENT_WORKFLOW_HINTS_KO: Final[frozenset[str]] = frozenset(
304
417
  "모바일신분증",
305
418
  "마이데이터",
306
419
  "공공마이데이터",
420
+ "소득금액증명원",
421
+ "소득금액증명",
307
422
  "과태료",
308
423
  "교통범칙금",
309
424
  "범칙금",
@@ -543,23 +658,44 @@ def _kma_observation_base_slot_hint(now_kst: datetime) -> tuple[str, str, str]:
543
658
 
544
659
 
545
660
  def _final_answer_looks_like_pending_tool_plan(text: str) -> bool:
546
- """Return true when final prose says it will call tools after tools already ran."""
661
+ """Return true when final prose is still planning after tools already ran."""
547
662
  normalized = " ".join(text.strip().split())
548
663
  if not normalized:
549
664
  return False
550
665
  pending_markers = (
551
666
  "호출하겠습니다",
552
667
  "조회하겠습니다",
668
+ "조회해 보겠습니다",
553
669
  "찾아보겠습니다",
554
670
  "검색하겠습니다",
555
671
  "진행하겠습니다",
556
672
  "확인하겠습니다",
673
+ "확인해 보겠습니다",
557
674
  "will call",
558
675
  "i'll call",
559
676
  "i will call",
560
677
  "will look up",
561
678
  )
562
- return any(marker in normalized.lower() for marker in pending_markers)
679
+ lowered = normalized.lower()
680
+ if any(marker in lowered for marker in pending_markers):
681
+ return True
682
+
683
+ meta_instruction_markers = (
684
+ "이제 응급 상황에 대한 지침을 제공해야 합니다",
685
+ "최종 답변은 다음과 같아야 합니다",
686
+ "도구 결과에서 그대로 가져와야 합니다",
687
+ "final answer should",
688
+ "the final answer should",
689
+ "should provide",
690
+ "should answer",
691
+ )
692
+ if any(marker in lowered for marker in meta_instruction_markers):
693
+ return True
694
+ return bool(
695
+ re.search(r"(?:답변|응답|최종 답변)[^.?!。]{0,40}해야 합니다", normalized)
696
+ or re.search(r"이제 [^.?!。]{0,60}(?:제공|안내|작성)해야 합니다", normalized)
697
+ or re.search(r"도구 결과[^.?!。]{0,60}가져와야 합니다", normalized)
698
+ )
563
699
 
564
700
 
565
701
  def _final_answer_looks_like_recursive_tool_message(text: str) -> bool:
@@ -675,6 +811,8 @@ def _final_answer_looks_like_tool_call_narration(text: str) -> bool:
675
811
  normalized = " ".join(text.strip().split())
676
812
  if not normalized:
677
813
  return False
814
+ if "<tool_call>" in normalized or "</tool_call>" in normalized:
815
+ return True
678
816
  head = normalized[:700]
679
817
  if "도구" not in head:
680
818
  return False
@@ -723,6 +861,174 @@ def _final_answer_looks_like_generic_retry_after_success(text: str) -> bool:
723
861
  return not bool(re.search(r"\d", normalized))
724
862
 
725
863
 
864
+ _KMA_ANALYSIS_USER_QUERY_RE: Final = re.compile(
865
+ r"(분석자료|이미\s*분석|고해상도\s*격자|객관분석|AWS\s*객관|지도\s*자료|"
866
+ r"일기도|분석일기도|비구름|바람\s*흐름|synoptic|weather\s*chart|"
867
+ r"objective\s*analysis|high[-\s]?resolution|grid)",
868
+ re.IGNORECASE,
869
+ )
870
+ _KMA_ANALYSIS_MAP_USER_QUERY_RE: Final = re.compile(
871
+ r"(일기도|분석일기도|지도\s*자료|비구름|바람\s*흐름|synoptic|weather\s*chart)",
872
+ re.IGNORECASE,
873
+ )
874
+ _KMA_ANALYSIS_TOOL_IDS: Final[frozenset[str]] = frozenset(
875
+ {
876
+ "kma_apihub_url_high_resolution_grid_point",
877
+ "kma_apihub_url_aws_objective_analysis_grid",
878
+ "kma_apihub_url_analysis_weather_chart_image",
879
+ }
880
+ )
881
+ _PPS_BID_USER_QUERY_RE: Final = re.compile(
882
+ r"(입찰|나라장터|조달청|공고|공사조회|전기공사|bid|procurement)",
883
+ re.IGNORECASE,
884
+ )
885
+ _AIRKOREA_USER_QUERY_RE: Final = re.compile(
886
+ r"(미세먼지|초미세먼지|대기질|대기오염|마스크|pm\s*2\.?5|pm\s*10|air\s*korea|airkorea)",
887
+ re.IGNORECASE,
888
+ )
889
+ _TAGO_BUS_USER_QUERY_RE: Final = re.compile(
890
+ r"(버스|시내버스|정류장|정류소|노선|도착|언제\s*와|몇\s*분|bus|route|arrival|station)",
891
+ re.IGNORECASE,
892
+ )
893
+ _TAGO_ROUTE_NO_RE: Final = re.compile(r"(?:^|[^\d])(\d{1,4}(?:-\d)?)\s*번")
894
+ _TAGO_TOOL_IDS: Final[frozenset[str]] = frozenset(
895
+ {
896
+ "tago_bus_station_search",
897
+ "tago_bus_arrival_search",
898
+ "tago_bus_route_search",
899
+ "tago_bus_route_station_search",
900
+ "tago_bus_location_search",
901
+ }
902
+ )
903
+ _AIRKOREA_TOOL_ID: Final = "airkorea_ctprvn_air_quality"
904
+ _PPS_BID_TOOL_ID: Final = "pps_bid_public_info"
905
+ _KMA_ANALYSIS_CHART_TOOL_ID: Final = "kma_apihub_url_analysis_weather_chart_image"
906
+
907
+
908
+ def _initial_concrete_tool_choice_for_query(
909
+ user_query: str,
910
+ available_tool_names: Collection[str],
911
+ ) -> str | None:
912
+ """Force direct first calls only for unambiguous single-adapter lookups."""
913
+ available = set(available_tool_names)
914
+ if (
915
+ _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query)
916
+ and _KMA_ANALYSIS_CHART_TOOL_ID in available
917
+ ):
918
+ return _KMA_ANALYSIS_CHART_TOOL_ID
919
+ if _KMA_AIRPORT_PLACE_RE.search(user_query) and _KMA_AIRPORT_AVIATION_RE.search(user_query):
920
+ if (
921
+ re.search(r"(김포|gimpo|rkss)", user_query, re.IGNORECASE)
922
+ and re.search(r"(amos|활주로|rvr|runway|매분)", user_query, re.IGNORECASE)
923
+ and "kma_apihub_url_air_amos_minute" in available
924
+ ):
925
+ return "kma_apihub_url_air_amos_minute"
926
+ if "kma_apihub_url_air_metar_decoded" in available:
927
+ return "kma_apihub_url_air_metar_decoded"
928
+ if _PPS_BID_USER_QUERY_RE.search(user_query) and _PPS_BID_TOOL_ID in available:
929
+ return _PPS_BID_TOOL_ID
930
+ if _AIRKOREA_USER_QUERY_RE.search(user_query) and _AIRKOREA_TOOL_ID in available:
931
+ return _AIRKOREA_TOOL_ID
932
+ if _TAGO_BUS_USER_QUERY_RE.search(user_query):
933
+ if _TAGO_ROUTE_NO_RE.search(user_query) and "tago_bus_route_search" in available:
934
+ return "tago_bus_route_search"
935
+ if "tago_bus_station_search" in available:
936
+ return "tago_bus_station_search"
937
+ return None
938
+
939
+
940
+ def _final_answer_looks_like_kma_analysis_fabrication(text: str, user_query: str) -> bool:
941
+ """Detect KMA analysis answers that fill failed lookups with general knowledge."""
942
+ if not _KMA_ANALYSIS_USER_QUERY_RE.search(user_query):
943
+ return False
944
+ normalized = " ".join(text.strip().split())
945
+ if not normalized:
946
+ return False
947
+ failure_markers = (
948
+ "데이터가 비어",
949
+ "확인할 수 없",
950
+ "접근할 수 없",
951
+ "조회가 어려",
952
+ "직접 접근할 수 없는",
953
+ "전체 내용을 확인할 수 없",
954
+ "실패",
955
+ )
956
+ fabrication_markers = (
957
+ "일반적인 지식",
958
+ "일반적 정보",
959
+ "일반적으로",
960
+ "판단됩니다",
961
+ "특별한 기상 상황은 아닌",
962
+ )
963
+ return any(marker in normalized for marker in failure_markers) and any(
964
+ marker in normalized for marker in fabrication_markers
965
+ )
966
+
967
+
968
+ def _conversation_has_kma_chart_upstream_failure(llm_messages: list[Any]) -> bool:
969
+ """Return True when a KMA analyzed weather-chart lookup failed upstream."""
970
+ for msg in reversed(llm_messages):
971
+ if getattr(msg, "role", None) != "tool":
972
+ continue
973
+ content = str(getattr(msg, "content", "") or "")
974
+ name = str(getattr(msg, "name", "") or "")
975
+ if (
976
+ "kma_apihub_url_analysis_weather_chart_image" not in content
977
+ and name != "kma_apihub_url_analysis_weather_chart_image"
978
+ ):
979
+ continue
980
+ normalized = " ".join(content.split())
981
+ return any(
982
+ marker in normalized
983
+ for marker in (
984
+ "활용신청",
985
+ "approval",
986
+ "upstream_error",
987
+ "403",
988
+ "error",
989
+ "failed",
990
+ )
991
+ )
992
+ return False
993
+
994
+
995
+ def _final_answer_substitutes_after_kma_chart_failure(
996
+ text: str,
997
+ user_query: str,
998
+ llm_messages: list[Any],
999
+ ) -> bool:
1000
+ """Detect map/chart answers that substitute other evidence after chart failure."""
1001
+ if not _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
1002
+ return False
1003
+ if not _conversation_has_kma_chart_upstream_failure(llm_messages):
1004
+ return False
1005
+ normalized = " ".join(text.strip().split())
1006
+ if not normalized:
1007
+ return False
1008
+ substitution_markers = (
1009
+ "AWS 객관",
1010
+ "고해상도",
1011
+ "현재 관측망",
1012
+ "관측값",
1013
+ "기온",
1014
+ "풍속",
1015
+ "풍향",
1016
+ "시정",
1017
+ "강수량",
1018
+ "상대습도",
1019
+ "대안으로",
1020
+ "일반적인",
1021
+ "일반적",
1022
+ "패턴상",
1023
+ "가능성",
1024
+ "추정",
1025
+ "보입니다",
1026
+ "서풍",
1027
+ "남해안",
1028
+ )
1029
+ return any(marker in normalized for marker in substitution_markers)
1030
+
1031
+
726
1032
  def _conversation_has_successful_any_primitive_result(llm_messages: list[Any]) -> bool:
727
1033
  """Return True when the loop already has a successful primitive result."""
728
1034
  return (
@@ -1754,6 +2060,43 @@ def _latest_successful_primitive_result(
1754
2060
  return None
1755
2061
 
1756
2062
 
2063
+ def _latest_successful_locate_result_with_coords(
2064
+ llm_messages: list[Any],
2065
+ *,
2066
+ registry: Any = None,
2067
+ ) -> dict[str, object] | None:
2068
+ """Return the most recent successful locate result that carries WGS-84 coords."""
2069
+ if registry is not None:
2070
+ matching_call_ids = _primitive_call_ids_for_tool(
2071
+ llm_messages,
2072
+ primitive="locate",
2073
+ registry=registry,
2074
+ )
2075
+ for msg in reversed(llm_messages):
2076
+ payload = _tool_result_payload_for_primitive_call(
2077
+ msg,
2078
+ primitive="locate",
2079
+ matching_call_ids=matching_call_ids,
2080
+ )
2081
+ if payload is None or not _primitive_payload_is_success(
2082
+ payload,
2083
+ primitive="locate",
2084
+ ):
2085
+ continue
2086
+ result = _primitive_payload_result_dict(payload)
2087
+ if result is not None and _locate_result_coords(result) is not None:
2088
+ return result
2089
+
2090
+ for msg in reversed(llm_messages):
2091
+ payload = _tool_result_payload_for_primitive(msg, primitive="locate")
2092
+ if payload is None or not _primitive_payload_is_success(payload, primitive="locate"):
2093
+ continue
2094
+ result = _primitive_payload_result_dict(payload)
2095
+ if result is not None and _locate_result_coords(result) is not None:
2096
+ return result
2097
+ return None
2098
+
2099
+
1757
2100
  def _latest_successful_primitive_result_for_tool(
1758
2101
  llm_messages: list[Any],
1759
2102
  *,
@@ -1950,6 +2293,38 @@ def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1950
2293
  return q0, q1
1951
2294
 
1952
2295
 
2296
+ def _sido_name_from_user_query(user_query: str) -> str | None:
2297
+ """Extract a Korean 시도 name when citizen wording contains one."""
2298
+
2299
+ for full_name in _KOREAN_SIDO_ABBREVIATIONS.values():
2300
+ if full_name in user_query:
2301
+ return full_name
2302
+ for short_name, full_name in _KOREAN_SIDO_ABBREVIATIONS.items():
2303
+ pattern = re.compile(
2304
+ rf"{re.escape(short_name)}(?:시|도|특별시|광역시|특별자치시|특별자치도)?"
2305
+ )
2306
+ if pattern.search(user_query):
2307
+ return full_name
2308
+ return None
2309
+
2310
+
2311
+ def _pps_current_week_window(now: datetime | None = None) -> tuple[str, str]:
2312
+ """Return PPS YYYYMMDDHHMM bounds for the current KST week through today."""
2313
+
2314
+ from zoneinfo import ZoneInfo # noqa: PLC0415
2315
+
2316
+ kst = ZoneInfo("Asia/Seoul")
2317
+ kst_now = datetime.now(kst) if now is None else now.astimezone(kst)
2318
+ week_start = (kst_now - timedelta(days=kst_now.weekday())).replace(
2319
+ hour=0,
2320
+ minute=0,
2321
+ second=0,
2322
+ microsecond=0,
2323
+ )
2324
+ today_end = kst_now.replace(hour=23, minute=59, second=0, microsecond=0)
2325
+ return week_start.strftime("%Y%m%d%H%M"), today_end.strftime("%Y%m%d%H%M")
2326
+
2327
+
1953
2328
  def _locate_result_region_pair(result: dict[str, object]) -> tuple[str, str] | None: # noqa: C901
1954
2329
  """Extract NMC region-mode q0/q1 from a locate result."""
1955
2330
  for key in ("region", "coords"):
@@ -2060,6 +2435,13 @@ def _nmc_lookup_params_with_clean_qn(
2060
2435
  return raw_params, params
2061
2436
 
2062
2437
 
2438
+ def _nmc_origin_needs_locate_repair(params: dict[str, object]) -> bool:
2439
+ """Return True when region-mode origin coords lost locate precision."""
2440
+ if params.get("origin_lat") is None and params.get("origin_lon") is None:
2441
+ return True
2442
+ return _is_whole_degree_pair(params.get("origin_lat"), params.get("origin_lon"))
2443
+
2444
+
2063
2445
  def _is_whole_degree_pair(lat: object, lon: object) -> bool:
2064
2446
  """Return True for rounded whole-degree WGS-84 coordinate pairs."""
2065
2447
  if isinstance(lat, bool) or isinstance(lon, bool):
@@ -2084,10 +2466,15 @@ def _normalize_reverse_geocode_args_from_prior_locate(
2084
2466
  was already available in the prior locate result, keep the selected adapter
2085
2467
  and repair only this derived argument pair.
2086
2468
  """
2087
- if fname != "locate" or args_obj.get("tool_id") not in _REVERSE_GEOCODE_TOOL_IDS:
2469
+ wraps_root_primitive = False
2470
+ if fname == "locate" and args_obj.get("tool_id") in _REVERSE_GEOCODE_TOOL_IDS:
2471
+ raw_params = args_obj.get("params")
2472
+ wraps_root_primitive = True
2473
+ elif fname in _REVERSE_GEOCODE_TOOL_IDS:
2474
+ raw_params = args_obj
2475
+ else:
2088
2476
  return args_obj
2089
2477
 
2090
- raw_params = args_obj.get("params")
2091
2478
  if not isinstance(raw_params, dict):
2092
2479
  return args_obj
2093
2480
 
@@ -2106,12 +2493,45 @@ def _normalize_reverse_geocode_args_from_prior_locate(
2106
2493
  if coords is None:
2107
2494
  return args_obj
2108
2495
 
2496
+ next_params = dict(raw_params)
2497
+ next_params["lat"], next_params["lon"] = coords
2498
+ if wraps_root_primitive:
2499
+ normalized = dict(args_obj)
2500
+ normalized["params"] = next_params
2501
+ else:
2502
+ normalized = next_params
2503
+ logger.info(
2504
+ "locate: normalized %s rounded lat/lon from prior locate lat=%s lon=%s",
2505
+ args_obj.get("tool_id") if wraps_root_primitive else fname,
2506
+ coords[0],
2507
+ coords[1],
2508
+ )
2509
+ return normalized
2510
+
2511
+
2512
+ def _normalize_reverse_geocode_args_from_locate_result(
2513
+ args_obj: dict[str, object],
2514
+ locate_result: dict[str, object],
2515
+ ) -> dict[str, object]:
2516
+ """Fill reverse-geocode lat/lon from an already observed locate result."""
2517
+ if args_obj.get("tool_id") not in _REVERSE_GEOCODE_TOOL_IDS:
2518
+ return args_obj
2519
+ raw_params = args_obj.get("params")
2520
+ if not isinstance(raw_params, dict):
2521
+ return args_obj
2522
+ if not _is_whole_degree_pair(raw_params.get("lat"), raw_params.get("lon")):
2523
+ return args_obj
2524
+
2525
+ coords = _locate_result_coords(locate_result)
2526
+ if coords is None:
2527
+ return args_obj
2528
+
2109
2529
  next_params = dict(raw_params)
2110
2530
  next_params["lat"], next_params["lon"] = coords
2111
2531
  normalized = dict(args_obj)
2112
2532
  normalized["params"] = next_params
2113
2533
  logger.info(
2114
- "locate: normalized %s rounded lat/lon from prior locate lat=%s lon=%s",
2534
+ "locate: normalized cached %s rounded lat/lon from latest locate lat=%s lon=%s",
2115
2535
  args_obj.get("tool_id"),
2116
2536
  coords[0],
2117
2537
  coords[1],
@@ -2146,7 +2566,8 @@ def _normalize_nmc_lookup_args_from_prior_locate(
2146
2566
  and bool(_nonempty_str(params.get("q0")))
2147
2567
  and bool(_nonempty_str(params.get("q1")))
2148
2568
  )
2149
- if has_region_params and not needs_default_limit:
2569
+ needs_origin_repair = _nmc_origin_needs_locate_repair(params)
2570
+ if has_region_params and not needs_default_limit and not needs_origin_repair:
2150
2571
  if params != raw_params and isinstance(raw_params, dict):
2151
2572
  normalized = dict(args_obj)
2152
2573
  normalized["params"] = params
@@ -2159,13 +2580,24 @@ def _normalize_nmc_lookup_args_from_prior_locate(
2159
2580
  registry=registry,
2160
2581
  )
2161
2582
  if locate_result is None:
2162
- if has_region_params and needs_default_limit:
2583
+ if has_region_params:
2163
2584
  normalized = dict(args_obj)
2164
2585
  next_params = dict(params)
2165
- next_params["limit"] = 5
2166
- normalized["params"] = next_params
2167
- return normalized
2586
+ if needs_default_limit:
2587
+ next_params["limit"] = 5
2588
+ if next_params != raw_params and isinstance(raw_params, dict):
2589
+ normalized["params"] = next_params
2590
+ return normalized
2168
2591
  return args_obj
2592
+ if _locate_result_coords(locate_result) is None:
2593
+ locate_result_with_coords = _latest_successful_locate_result_with_coords(
2594
+ llm_messages,
2595
+ registry=registry,
2596
+ )
2597
+ if locate_result_with_coords is not None:
2598
+ merged_locate_result = dict(locate_result_with_coords)
2599
+ merged_locate_result.update(locate_result)
2600
+ locate_result = merged_locate_result
2169
2601
 
2170
2602
  return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2171
2603
 
@@ -2185,10 +2617,18 @@ def _normalize_nmc_lookup_args_from_locate_result(
2185
2617
  and bool(_nonempty_str(params.get("q0")))
2186
2618
  and bool(_nonempty_str(params.get("q1")))
2187
2619
  )
2188
- if has_region_params and not needs_default_limit:
2189
- if params != raw_params and isinstance(raw_params, dict):
2620
+ needs_origin_repair = _nmc_origin_needs_locate_repair(params)
2621
+ if has_region_params:
2622
+ origin_coords = _locate_result_coords(locate_result)
2623
+ next_params = dict(params)
2624
+ if needs_origin_repair and origin_coords is not None:
2625
+ next_params["origin_lat"] = origin_coords[0]
2626
+ next_params["origin_lon"] = origin_coords[1]
2627
+ if needs_default_limit:
2628
+ next_params["limit"] = 5
2629
+ if next_params != params or (params != raw_params and isinstance(raw_params, dict)):
2190
2630
  normalized = dict(args_obj)
2191
- normalized["params"] = params
2631
+ normalized["params"] = next_params
2192
2632
  return normalized
2193
2633
  return args_obj
2194
2634
 
@@ -2227,6 +2667,27 @@ def _normalize_nmc_lookup_args_from_locate_result(
2227
2667
  return normalized
2228
2668
 
2229
2669
 
2670
+ def _normalize_nmc_aed_args_from_locate_result(
2671
+ args_obj: dict[str, object],
2672
+ locate_result: dict[str, object],
2673
+ ) -> dict[str, object]:
2674
+ """Fill NMC AED origin coords for client-side distance sorting."""
2675
+ raw_params = args_obj.get("params")
2676
+ if not isinstance(raw_params, dict):
2677
+ return args_obj
2678
+ coords = _locate_result_coords(locate_result)
2679
+ if coords is None:
2680
+ return args_obj
2681
+ if not _nmc_origin_needs_locate_repair(raw_params):
2682
+ return args_obj
2683
+ next_params = dict(raw_params)
2684
+ next_params["origin_lat"] = coords[0]
2685
+ next_params["origin_lon"] = coords[1]
2686
+ normalized = dict(args_obj)
2687
+ normalized["params"] = next_params
2688
+ return normalized
2689
+
2690
+
2230
2691
  _HIRA_DEPARTMENT_HINTS: tuple[tuple[re.Pattern[str], str], ...] = (
2231
2692
  (re.compile(r"소아청소년과|소아과|pediatrics?", re.IGNORECASE), "소아청소년과"),
2232
2693
  (re.compile(r"이비인후과|ent\b", re.IGNORECASE), "이비인후과"),
@@ -2384,14 +2845,37 @@ def _normalize_lookup_args_from_cached_locate_result(
2384
2845
  args_obj: dict[str, object],
2385
2846
  locate_result: dict[str, object] | None,
2386
2847
  *,
2848
+ coordinate_locate_result: dict[str, object] | None = None,
2387
2849
  user_query: str = "",
2388
2850
  ) -> dict[str, object]:
2389
2851
  """Apply locate-derived argument repair in inbound concrete tool dispatch."""
2390
- if fname != "find" or locate_result is None:
2852
+ if locate_result is None:
2853
+ return args_obj
2854
+ if fname == "locate":
2855
+ return _normalize_reverse_geocode_args_from_locate_result(args_obj, locate_result)
2856
+ if fname != "find":
2391
2857
  return args_obj
2392
2858
  tool_id = args_obj.get("tool_id")
2393
2859
  if tool_id == "nmc_emergency_search":
2860
+ if (
2861
+ _locate_result_coords(locate_result) is None
2862
+ and coordinate_locate_result is not None
2863
+ and _locate_result_coords(coordinate_locate_result) is not None
2864
+ ):
2865
+ merged_locate_result = dict(coordinate_locate_result)
2866
+ merged_locate_result.update(locate_result)
2867
+ locate_result = merged_locate_result
2394
2868
  return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2869
+ if tool_id == "nmc_aed_site_locate":
2870
+ if (
2871
+ _locate_result_coords(locate_result) is None
2872
+ and coordinate_locate_result is not None
2873
+ and _locate_result_coords(coordinate_locate_result) is not None
2874
+ ):
2875
+ merged_locate_result = dict(coordinate_locate_result)
2876
+ merged_locate_result.update(locate_result)
2877
+ locate_result = merged_locate_result
2878
+ return _normalize_nmc_aed_args_from_locate_result(args_obj, locate_result)
2395
2879
  if tool_id == "hira_hospital_search":
2396
2880
  return _normalize_hira_lookup_args_from_locate_result(
2397
2881
  args_obj,
@@ -2919,6 +3403,62 @@ def _canonicalize_lookup_tool_id_for_query(
2919
3403
  return normalized
2920
3404
 
2921
3405
 
3406
+ def _set_param_if_empty(
3407
+ params: dict[str, object],
3408
+ key: str,
3409
+ value: object,
3410
+ ) -> bool:
3411
+ if params.get(key) in (None, ""):
3412
+ params[key] = value
3413
+ return True
3414
+ return False
3415
+
3416
+
3417
+ def _set_param_if_changed(
3418
+ params: dict[str, object],
3419
+ key: str,
3420
+ value: object,
3421
+ ) -> bool:
3422
+ if params.get(key) != value:
3423
+ params[key] = value
3424
+ return True
3425
+ return False
3426
+
3427
+
3428
+ def _normalize_pps_bid_args_from_user_query(
3429
+ fname: str,
3430
+ args_obj: dict[str, object],
3431
+ user_query: str,
3432
+ ) -> dict[str, object]:
3433
+ """Fill PPS search-condition fields that are explicit in citizen wording."""
3434
+
3435
+ if fname != "find" or args_obj.get("tool_id") != _PPS_BID_TOOL_ID:
3436
+ return args_obj
3437
+ raw_params = args_obj.get("params")
3438
+ params: dict[str, object] = dict(raw_params) if isinstance(raw_params, dict) else {}
3439
+ changed = not isinstance(raw_params, dict)
3440
+
3441
+ if re.search(r"이번\s*주", user_query):
3442
+ start_dt, end_dt = _pps_current_week_window()
3443
+ changed = _set_param_if_changed(params, "inqry_bgn_dt", start_dt) or changed
3444
+ changed = _set_param_if_changed(params, "inqry_end_dt", end_dt) or changed
3445
+
3446
+ if re.search(r"전기\s*공사", user_query, re.IGNORECASE):
3447
+ changed = _set_param_if_empty(params, "bid_ntce_nm", "전기공사") or changed
3448
+ changed = _set_param_if_empty(params, "indstryty_nm", "전기공사업") or changed
3449
+
3450
+ region_name = _sido_name_from_user_query(user_query)
3451
+ if region_name is not None:
3452
+ changed = _set_param_if_empty(params, "region_name", region_name) or changed
3453
+ changed = _set_param_if_empty(params, "prtcpt_lmt_rgn_nm", region_name) or changed
3454
+
3455
+ if not changed:
3456
+ return args_obj
3457
+ normalized = dict(args_obj)
3458
+ normalized["params"] = params
3459
+ return normalized
3460
+
3461
+
2922
3462
  def _normalize_lookup_args_for_query(
2923
3463
  fname: str,
2924
3464
  args_obj: dict[str, object],
@@ -2936,6 +3476,7 @@ def _normalize_lookup_args_for_query(
2936
3476
  user_query,
2937
3477
  adapter_param_names=adapter_param_names,
2938
3478
  )
3479
+ args_obj = _normalize_pps_bid_args_from_user_query(fname, args_obj, user_query)
2939
3480
  if args_obj.get("tool_id") != "mohw_welfare_eligibility_search":
2940
3481
  return args_obj
2941
3482
  if not _query_contains_any(user_query, ("한부모가족", "한부모", "아동양육비")):
@@ -3159,9 +3700,13 @@ def _check_verify_tool_choice_prerequisite(
3159
3700
  purpose_ko = requirement["purpose_ko"]
3160
3701
  purpose_en = requirement["purpose_en"]
3161
3702
  if fname != "check":
3703
+ from ummaya.tools.verify_canonical_map import resolve_tool_id # noqa: PLC0415
3704
+
3705
+ canonical_verify_alias = resolve_tool_id(tool_id)
3162
3706
  wrong_verify_tool = (
3163
3707
  tool_id == "check"
3164
3708
  or tool_id.startswith("mock_verify_")
3709
+ or canonical_verify_alias is not None
3165
3710
  or tool_id in allowed_tool_ids
3166
3711
  or _verify_tool_matches_requirement(
3167
3712
  args_obj,
@@ -3861,6 +4406,148 @@ def _check_chain_prerequisite( # noqa: C901
3861
4406
  )
3862
4407
 
3863
4408
 
4409
+ def _check_kma_analysis_tool_choice_prerequisite(
4410
+ fname: str,
4411
+ args_obj: dict[str, object],
4412
+ user_query: str,
4413
+ ) -> str | None:
4414
+ """Reject cross-contaminated KMA analysis tool choices for map/chart wording."""
4415
+ if not _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
4416
+ return None
4417
+ tool_id = fname if fname in _KMA_ANALYSIS_TOOL_IDS else args_obj.get("tool_id")
4418
+ if not isinstance(tool_id, str):
4419
+ params = args_obj.get("params")
4420
+ if isinstance(params, dict):
4421
+ nested_tool_id = params.get("tool_id")
4422
+ tool_id = nested_tool_id if isinstance(nested_tool_id, str) else None
4423
+ if tool_id not in _KMA_ANALYSIS_TOOL_IDS:
4424
+ return None
4425
+ if tool_id == "kma_apihub_url_analysis_weather_chart_image":
4426
+ return None
4427
+ return (
4428
+ "KMA analysis tool-choice mismatch: the latest citizen request asks for "
4429
+ "analyzed weather charts/map evidence such as 일기도, 지도 자료, 비구름, "
4430
+ "or 바람 흐름. Do not carry over a prior point/grid-analysis path. "
4431
+ "RECOVERY: call find with tool_id "
4432
+ "kma_apihub_url_analysis_weather_chart_image for the latest query. If "
4433
+ "APIHub returns 403 approval-required or another upstream error, report "
4434
+ "that failure directly and do not substitute point-grid data or prior "
4435
+ "airport observations."
4436
+ )
4437
+
4438
+
4439
+ def _emitted_tool_id(fname: str, args_obj: dict[str, object]) -> str | None:
4440
+ """Return the concrete adapter id represented by a root or direct tool call."""
4441
+ tool_id = fname if fname not in _ROOT_PRIMITIVE_TOOL_NAMES else args_obj.get("tool_id")
4442
+ if isinstance(tool_id, str) and tool_id:
4443
+ return tool_id
4444
+ params = args_obj.get("params")
4445
+ if isinstance(params, dict):
4446
+ nested_tool_id = params.get("tool_id")
4447
+ if isinstance(nested_tool_id, str) and nested_tool_id:
4448
+ return nested_tool_id
4449
+ return None
4450
+
4451
+
4452
+ def _direct_public_data_target_for_query(user_query: str) -> tuple[frozenset[str], str, str] | None:
4453
+ """Return target adapter family for public-data wording that should not use substitutes."""
4454
+ if _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
4455
+ return (
4456
+ frozenset({_KMA_ANALYSIS_CHART_TOOL_ID}),
4457
+ _KMA_ANALYSIS_CHART_TOOL_ID,
4458
+ "call the KMA APIHub analyzed weather-chart adapter and do not "
4459
+ "substitute location, AirKorea, or ordinary weather tools.",
4460
+ )
4461
+ if _PPS_BID_USER_QUERY_RE.search(user_query):
4462
+ return (
4463
+ frozenset({_PPS_BID_TOOL_ID}),
4464
+ _PPS_BID_TOOL_ID,
4465
+ "call the PPS/NaraJangteo bid adapter with its bid notice date fields.",
4466
+ )
4467
+ if _AIRKOREA_USER_QUERY_RE.search(user_query):
4468
+ return (
4469
+ frozenset({_AIRKOREA_TOOL_ID}),
4470
+ _AIRKOREA_TOOL_ID,
4471
+ "call the AirKorea city/province air-quality adapter with sido_name such as '부산'.",
4472
+ )
4473
+ if _TAGO_BUS_USER_QUERY_RE.search(user_query):
4474
+ preferred = (
4475
+ "tago_bus_route_search"
4476
+ if _TAGO_ROUTE_NO_RE.search(user_query)
4477
+ else "tago_bus_station_search"
4478
+ )
4479
+ return (
4480
+ _TAGO_TOOL_IDS,
4481
+ preferred,
4482
+ "use TAGO bus schemas; for a route number, start with "
4483
+ "tago_bus_route_search, then route_station and arrival.",
4484
+ )
4485
+ if _query_implies_current_weather_observation(user_query):
4486
+ return (
4487
+ _KMA_ORDINARY_WEATHER_TOOL_IDS | _KMA_LOCATION_TOOL_IDS,
4488
+ "kakao_keyword_search",
4489
+ "use a location adapter first when coordinates are missing, then "
4490
+ "KMA current observation for rain/umbrella/current-weather values.",
4491
+ )
4492
+ return None
4493
+
4494
+
4495
+ def _check_direct_public_data_tool_choice_prerequisite(
4496
+ fname: str,
4497
+ args_obj: dict[str, object],
4498
+ user_query: str,
4499
+ ) -> tuple[str, str] | None:
4500
+ """Reject concrete public-data adapters that do not match the latest citizen request."""
4501
+ target = _direct_public_data_target_for_query(user_query)
4502
+ if target is None:
4503
+ return None
4504
+ allowed_tool_ids, preferred_tool_id, hint = target
4505
+ emitted_tool_id = _emitted_tool_id(fname, args_obj)
4506
+ if emitted_tool_id is None or emitted_tool_id in allowed_tool_ids:
4507
+ return None
4508
+ return (
4509
+ preferred_tool_id,
4510
+ "Public-data tool-choice mismatch: the latest citizen request matches "
4511
+ f"{preferred_tool_id}. The model emitted {emitted_tool_id} instead. "
4512
+ f"RECOVERY: {hint}",
4513
+ )
4514
+
4515
+
4516
+ def _check_kma_aviation_tool_choice_prerequisite(
4517
+ fname: str,
4518
+ args_obj: dict[str, object],
4519
+ user_query: str,
4520
+ ) -> str | None:
4521
+ """Reject ordinary weather/location tools for airport aviation wording."""
4522
+ if not (
4523
+ _KMA_AIRPORT_PLACE_RE.search(user_query) and _KMA_AIRPORT_AVIATION_RE.search(user_query)
4524
+ ):
4525
+ return None
4526
+ tool_id = _emitted_tool_id(fname, args_obj)
4527
+ if tool_id in _KMA_AIR_TOOL_IDS:
4528
+ return None
4529
+ if tool_id is None:
4530
+ return None
4531
+ return (
4532
+ "KMA aviation tool-choice mismatch: the latest citizen request asks for "
4533
+ "airport METAR/AMOS aviation evidence such as flight operation, wind, "
4534
+ "runway, RVR, or visibility. RECOVERY: call find with tool_id "
4535
+ "kma_apihub_url_air_metar_decoded for airport METAR/시정/풍향/풍속 "
4536
+ "evidence, or kma_apihub_url_air_amos_minute for documented AMOS "
4537
+ "runway-minute evidence. Do not call locate or ordinary KMA current "
4538
+ "observation before the aviation adapter."
4539
+ )
4540
+
4541
+
4542
+ def _preferred_kma_aviation_tool_id(user_query: str) -> str:
4543
+ """Return the aviation adapter that best matches the airport wording."""
4544
+ if re.search(r"(김포|gimpo|rkss)", user_query, re.IGNORECASE) and re.search(
4545
+ r"(amos|활주로|rvr|runway|매분)", user_query, re.IGNORECASE
4546
+ ):
4547
+ return "kma_apihub_url_air_amos_minute"
4548
+ return "kma_apihub_url_air_metar_decoded"
4549
+
4550
+
3864
4551
  _CURRENT_WEATHER_KEYWORDS_KO: frozenset[str] = frozenset(
3865
4552
  {"날씨", "기온", "온도", "습도", "강수", "바람", "풍속"}
3866
4553
  )
@@ -3918,6 +4605,16 @@ _AVAILABLE_ADAPTER_FIND_LINE_RE: Final = re.compile(
3918
4605
  r"^\s*-\s+[A-Za-z0-9_.:-]+\s+\(primitive=find\)",
3919
4606
  re.MULTILINE,
3920
4607
  )
4608
+ _MEDICAL_COLLAPSE_RE: Final = re.compile(
4609
+ r"(사람[이가은는 ]*쓰러|쓰러졌|쓰러져|의식[을 ]*(?:잃|없)|심정지|"
4610
+ r"숨[을 ]*(?:안|못)|호흡[이가은는 ]*없|자동심장|심장충격|제세동|"
4611
+ r"\bAED\b|collapsed|unconscious|cardiac arrest|not breathing)",
4612
+ re.IGNORECASE,
4613
+ )
4614
+ _CIVIL_SAFETY_CALL_BOX_RE: Final = re.compile(
4615
+ r"(비상벨|안심벨|비상\s*호출|긴급\s*호출|emergency\s*bell|call\s*box)",
4616
+ re.IGNORECASE,
4617
+ )
3921
4618
 
3922
4619
 
3923
4620
  def _latest_available_adapters_block(llm_messages: list[Any]) -> str:
@@ -3945,6 +4642,25 @@ def _available_adapters_block_has_find_candidate(block: str) -> bool:
3945
4642
  return bool(block and _AVAILABLE_ADAPTER_FIND_LINE_RE.search(block))
3946
4643
 
3947
4644
 
4645
+ def _available_adapters_block_has_tool_id(block: str, tool_id: str) -> bool:
4646
+ """Return True when the latest dynamic adapter block surfaced tool_id."""
4647
+ if not block or not tool_id:
4648
+ return False
4649
+ escaped = re.escape(tool_id)
4650
+ line_re = re.compile(rf"^\s*-\s*{escaped}(?:\s|\(|:)", re.MULTILINE)
4651
+ yaml_re = re.compile(rf"^\s*tool_id:\s*{escaped}\s*$", re.MULTILINE)
4652
+ return bool(line_re.search(block) or yaml_re.search(block) or f"tool_id: {tool_id}" in block)
4653
+
4654
+
4655
+ def _query_implies_medical_collapse_aed(user_query: str) -> bool:
4656
+ """Return True for medical collapse/cardiac-arrest wording that needs AED data."""
4657
+ if not user_query:
4658
+ return False
4659
+ if _CIVIL_SAFETY_CALL_BOX_RE.search(user_query):
4660
+ return False
4661
+ return _MEDICAL_COLLAPSE_RE.search(user_query) is not None
4662
+
4663
+
3948
4664
  def _query_implies_current_weather_observation(user_query: str) -> bool:
3949
4665
  """Return True when final weather prose should include current observation."""
3950
4666
  if not user_query:
@@ -4008,6 +4724,47 @@ def _check_current_weather_terminated_without_observation(
4008
4724
  )
4009
4725
 
4010
4726
 
4727
+ def _check_medical_emergency_terminated_without_aed(
4728
+ llm_messages: list[Any],
4729
+ user_query: str,
4730
+ registry: Any = None,
4731
+ ) -> str | None:
4732
+ """Require AED search before final prose for collapse/cardiac-arrest wording."""
4733
+ if not _query_implies_medical_collapse_aed(user_query):
4734
+ return None
4735
+ available_adapters_block = _latest_available_adapters_block(llm_messages)
4736
+ if not _available_adapters_block_has_tool_id(
4737
+ available_adapters_block,
4738
+ "nmc_aed_site_locate",
4739
+ ):
4740
+ return None
4741
+ if _conversation_has_primitive_call(
4742
+ llm_messages,
4743
+ primitive="find",
4744
+ tool_id="nmc_aed_site_locate",
4745
+ ):
4746
+ return None
4747
+ if not _conversation_has_successful_primitive(
4748
+ llm_messages,
4749
+ primitive="find",
4750
+ tool_id="nmc_emergency_search",
4751
+ ):
4752
+ return None
4753
+ return (
4754
+ "Medical emergency chain incomplete: the citizen described a collapse, "
4755
+ "unconsciousness, cardiac arrest, or AED-relevant situation. The "
4756
+ "conversation already found emergency-room data but is about to answer "
4757
+ "without attempting the AED adapter that was surfaced in "
4758
+ "<available_adapters>. RECOVERY: call "
4759
+ "nmc_aed_site_locate({q0:<region from locate or NMC context>, "
4760
+ "q1:<district when available>, limit:5}) or the equivalent schema-valid "
4761
+ "parameters before final prose. If the AED adapter returns no data or an "
4762
+ "upstream error, report that result explicitly alongside 119/emergency-room "
4763
+ "guidance. Do NOT substitute emergency-room data for AED data. Do NOT "
4764
+ "produce a final answer this turn."
4765
+ )
4766
+
4767
+
4011
4768
  def _weather_value_tokens(value: object) -> set[str]:
4012
4769
  """Return compact numeric strings a final weather answer may cite."""
4013
4770
  if isinstance(value, bool):
@@ -4401,8 +5158,8 @@ async def run( # noqa: C901
4401
5158
 
4402
5159
  # ---- spec-multi-turn-contamination diagnostic — optional log file
4403
5160
  # The TUI bridge spawns this process with `stderr: 'pipe'` and never
4404
- # drains the pipe, so `logger.info(...)` lines are invisible to any
4405
- # external observer (tmux pane, asciinema cast). When the operator
5161
+ # drains the pipe, so `logger.info(...)` lines are invisible to the
5162
+ # normal terminal transcript. When the operator
4406
5163
  # sets UMMAYA_BACKEND_LOG_FILE=<path>, attach a FileHandler at INFO
4407
5164
  # so the diagnostic [CHAT_REQUEST_DUMP] / [LATEST_USER_UTT] /
4408
5165
  # [REASONING_PREVIEW] lines persist to disk for post-hoc analysis.
@@ -4555,6 +5312,8 @@ async def run( # noqa: C901
4555
5312
  _session_auth_contexts: dict[str, object] = {}
4556
5313
  _session_auth_session_ids: dict[str, str] = {}
4557
5314
  _session_latest_locate_results: dict[str, dict[str, object]] = {}
5315
+ _session_latest_locate_results_with_coords: dict[str, dict[str, object]] = {}
5316
+ _session_latest_user_utterances: dict[str, str] = {}
4558
5317
 
4559
5318
  # Epic #2077 T010 — single ToolRegistry + ToolExecutor instance pair
4560
5319
  # reused across every chat_request. Adapter registration happens lazily
@@ -4892,6 +5651,27 @@ async def run( # noqa: C901
4892
5651
  candidates = filtered_candidates
4893
5652
  if not candidates:
4894
5653
  return ""
5654
+ candidate_ids = tuple(candidate.tool_id for candidate in candidates)
5655
+ first_candidate_id = candidate_ids[0]
5656
+ has_amos_candidate = "kma_apihub_url_air_amos_minute" in candidate_ids
5657
+ has_metar_candidate = "kma_apihub_url_air_metar_decoded" in candidate_ids
5658
+ has_analysis_candidate = any(
5659
+ candidate_id
5660
+ in {
5661
+ "kma_apihub_url_high_resolution_grid_point",
5662
+ "kma_apihub_url_aws_objective_analysis_grid",
5663
+ "kma_apihub_url_analysis_weather_chart_image",
5664
+ }
5665
+ for candidate_id in candidate_ids
5666
+ )
5667
+ is_gimpo_runway_query = bool(
5668
+ re.search(r"(김포공항|Gimpo|RKSS)", q, re.IGNORECASE)
5669
+ and re.search(
5670
+ r"(AMOS|활주로|RVR|runway|시정|visibility|공항기상관측|매분)",
5671
+ q,
5672
+ re.IGNORECASE,
5673
+ )
5674
+ )
4895
5675
  # Build a compact, LLM-readable block.
4896
5676
  #
4897
5677
  # Spec 2521 (2026-05-02) — emit per-field schema signatures so the
@@ -4917,6 +5697,7 @@ async def run( # noqa: C901
4917
5697
  lines.append(
4918
5698
  f"- {c.tool_id} (primitive={primitive}) [{c.score:.2f}] — {hint or '(설명 없음)'}"
4919
5699
  )
5700
+ lines.append(f" 호출: {c.tool_id}({{...schema fields...}})")
4920
5701
  # Render the adapter's llm_description (usage prose, ORDERING RULE,
4921
5702
  # prerequisites, worked examples) so the LLM sees the complete
4922
5703
  # "먼저 locate 호출" ordering rule.
@@ -4924,10 +5705,13 @@ async def run( # noqa: C901
4924
5705
  # and K-EXAONE skips locate, producing invalid_params.
4925
5706
  if c.llm_description:
4926
5707
  desc_text = c.llm_description.strip().replace("\n", " ")
4927
- # Emit at most 300 chars — enough for the ORDERING RULE and
4928
- # worked example without blowing the per-turn token budget.
4929
- if len(desc_text) > 300:
4930
- desc_text = desc_text[:297] + "..."
5708
+ # Emit enough text for adapter-specific negative routing and
5709
+ # output-use rules. KMA METAR/AMOS descriptions carry critical
5710
+ # "Gimhae is not AMOS" and "safe_weather only" instructions
5711
+ # after the purpose sentence; truncating them makes the TUI
5712
+ # path claim no METAR tool exists.
5713
+ if len(desc_text) > 900:
5714
+ desc_text = desc_text[:897] + "..."
4931
5715
  lines.append(f" 설명: {desc_text}")
4932
5716
  # Render input schema signature so the LLM sees exact field
4933
5717
  # names + types + required flags + (truncated) descriptions.
@@ -5036,11 +5820,54 @@ async def run( # noqa: C901
5036
5820
  )
5037
5821
  lines.append("")
5038
5822
  lines.append(
5039
- "규칙: 위 목록의 tool_id는 이미 model-facing concrete function name입니다. "
5040
- "루트 wrapper find/locate/check/send 호출하지 말고, 해당 tool_id 이름의 "
5041
- "함수를 직접 호출하세요. 인자는 schema 필드명 그대로 전달합니다. "
5042
- "동일 tool_id turn 안에서 반복 호출하지 마세요."
5823
+ "규칙: 위 목록의 tool_id는 concrete adapter id입니다. model-facing "
5824
+ "함수명도 tools[]에 로드된 concrete tool_id입니다. concrete adapter "
5825
+ "function은 schema 필드만 받으므로 tool_id/params envelope를 안에 "
5826
+ "넣지 마세요. concrete function이 로드되지 않고 root primitive만 "
5827
+ '있을 때만 legacy envelope 예: find({"tool_id":"...", "params":{...}}) '
5828
+ "형식을 사용합니다. 동일 tool_id 를 한 turn 안에서 반복 호출하지 "
5829
+ "마세요. 위 목록에 요청과 일치하는 adapter가 있으면 도구가 없다고 "
5830
+ "답하지 마세요."
5043
5831
  )
5832
+ if has_analysis_candidate:
5833
+ lines.append(
5834
+ "분석자료 특수 규칙: 위 후보에 고해상도 격자자료, AWS 객관분석, "
5835
+ "분석일기도 이미지가 있으면 기상청이 이미 분석한 자료 도구가 있는 "
5836
+ "것입니다. 공항 관측값/METAR/AMOS/일반 예보가 아니라 시민이 말한 "
5837
+ "분석자료 계열 후보를 호출하세요. 지도/일기도/비구름/바람 흐름 "
5838
+ "질의는 kma_apihub_url_analysis_weather_chart_image 를 우선 호출하고, "
5839
+ "특정 지점 주변 값은 locate 뒤 "
5840
+ "kma_apihub_url_high_resolution_grid_point 또는 "
5841
+ "kma_apihub_url_aws_objective_analysis_grid 를 호출하세요. 공항/랜드마크 "
5842
+ "주변 좌표는 kakao_keyword_search 를 kakao_address_search 보다 먼저 "
5843
+ "사용하세요. locate 가 실패하면 다른 후보 위치 도구를 시도하고, 도구 "
5844
+ "결과 없이 좌표를 추정하지 마세요. APIHub 승인 대기나 upstream 오류가 "
5845
+ "나면 그 실패를 그대로 설명하고, 도구 결과 없이 지도 기반 내용을 "
5846
+ "추정하지 마세요."
5847
+ )
5848
+ if has_amos_candidate and (
5849
+ is_gimpo_runway_query or first_candidate_id == "kma_apihub_url_air_amos_minute"
5850
+ ):
5851
+ lines.append(
5852
+ "AMOS 특수 규칙: kma_apihub_url_air_amos_minute 가 김포공항 "
5853
+ "활주로/시정/RVR/매분 관측 후보이면 AMOS 공항기상관측 도구가 "
5854
+ "있는 것입니다. 김포공항은 stn=110 을 사용하세요. 이 후보는 "
5855
+ "좌표를 요구하지 않으므로 locate/kma_current_observation 을 먼저 "
5856
+ '호출하지 말고 즉시 kma_apihub_url_air_amos_minute({"stn":"110",'
5857
+ '"help":1}) 를 호출하세요. METAR 는 '
5858
+ "보조 확인이 필요할 때만 추가로 사용하세요."
5859
+ )
5860
+ if has_metar_candidate and not (has_amos_candidate and is_gimpo_runway_query):
5861
+ lines.append(
5862
+ "METAR 특수 규칙: kma_apihub_url_air_metar_decoded 가 후보에 있으면 "
5863
+ "공항 METAR 해독자료 조회 도구가 있는 것입니다. 김해공항/RKPK는 "
5864
+ "decoded_records 의 station 153 Gimhae Airport / RKPK record를 "
5865
+ "사용하고, 날씨 값은 decoded_records[].safe_weather 만 사용하세요. "
5866
+ "raw_fields/raw_report에서 별도 값을 만들지 마세요. 이 후보는 좌표를 "
5867
+ "요구하지 않으므로 locate/kma_current_observation 을 먼저 호출하지 "
5868
+ '말고 즉시 kma_apihub_url_air_metar_decoded({"org":"K","help":1}) '
5869
+ "를 호출하세요."
5870
+ )
5044
5871
  listed_primitives = {str(candidate.primitive or "find") for candidate in candidates}
5045
5872
  if listed_primitives == {"find"}:
5046
5873
  lines.append(
@@ -5061,7 +5888,8 @@ async def run( # noqa: C901
5061
5888
  )
5062
5889
  lines.append(
5063
5890
  "BM25 도구 발견은 백엔드 internal 기능입니다. 모델은 검색 함수를 호출하지 "
5064
- "않고, backend가 tools[]에 실어준 concrete function 호출합니다."
5891
+ "않고, backend가 tools[]에 실어준 concrete adapter function 우선 "
5892
+ "호출합니다."
5065
5893
  )
5066
5894
  lines.append("</available_adapters>")
5067
5895
  return "\n".join(lines)
@@ -5581,6 +6409,7 @@ async def run( # noqa: C901
5581
6409
  )
5582
6410
  from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
5583
6411
  resolve_family,
6412
+ resolve_tool_id,
5584
6413
  )
5585
6414
 
5586
6415
  # Spec 2297 / Issue #C1 (2026-05-04) — translate
@@ -5595,7 +6424,11 @@ async def run( # noqa: C901
5595
6424
  # Accept both ``family`` (citizen-facing tool schema) and
5596
6425
  # ``family_hint`` (primitive's internal arg name) for
5597
6426
  # legacy / tools-bridge compatibility.
5598
- tool_id = str(args_obj.get("tool_id") or "")
6427
+ raw_tool_id = str(args_obj.get("tool_id") or "")
6428
+ canonical_tool_id = resolve_tool_id(raw_tool_id) or raw_tool_id
6429
+ if canonical_tool_id != raw_tool_id:
6430
+ args_obj = {**args_obj, "tool_id": canonical_tool_id}
6431
+ tool_id = canonical_tool_id
5599
6432
  if tool_id:
5600
6433
  registry = _ensure_tool_registry()
5601
6434
  try:
@@ -5656,7 +6489,8 @@ async def run( # noqa: C901
5656
6489
  message=(
5657
6490
  "find(mode='search') 는 백엔드 internal 기능입니다 — "
5658
6491
  "직접 호출하지 마십시오. 시스템 프롬프트의 "
5659
- "<available_adapters> 에서 tool_id 골라 fetch 호출만 사용하세요."
6492
+ "<available_adapters> 에서 concrete adapter function을 "
6493
+ "골라 schema 필드로 직접 호출하세요."
5660
6494
  ),
5661
6495
  retryable=False,
5662
6496
  )
@@ -5813,6 +6647,8 @@ async def run( # noqa: C901
5813
6647
  locate_result = result_payload.get("result")
5814
6648
  if isinstance(locate_result, dict) and locate_result.get("kind") != "error":
5815
6649
  _session_latest_locate_results[session_id] = locate_result
6650
+ if _locate_result_coords(locate_result) is not None:
6651
+ _session_latest_locate_results_with_coords[session_id] = locate_result
5816
6652
 
5817
6653
  # Drain the outbound HTTP trace buffer + attach to the envelope.
5818
6654
  outbound_traces = consume_outbound_capture(_outbound_trace_token)
@@ -5900,6 +6736,29 @@ async def run( # noqa: C901
5900
6736
  if not isinstance(frame, ChatRequestFrame):
5901
6737
  return
5902
6738
 
6739
+ async def _emit_progress_event(
6740
+ phase: Literal[
6741
+ "analysis",
6742
+ "tool_selection",
6743
+ "tool_call",
6744
+ "tool_result",
6745
+ "answer_synthesis",
6746
+ ],
6747
+ message_ko: str,
6748
+ message_en: str,
6749
+ *,
6750
+ tool_id: str | None = None,
6751
+ call_id: str | None = None,
6752
+ ) -> None:
6753
+ _ = (phase, message_ko, message_en, tool_id, call_id)
6754
+ return
6755
+
6756
+ await _emit_progress_event(
6757
+ "analysis",
6758
+ "요청을 분석하고 있습니다.",
6759
+ "Analyzing the request.",
6760
+ )
6761
+
5903
6762
  # ---- spec-multi-turn-contamination diagnostic emit (FR-001/FR-002)
5904
6763
  # Increment the per-session turn counter and dump the inbound
5905
6764
  # ChatRequestFrame.messages tail so we can prove which user turn
@@ -5942,11 +6801,9 @@ async def run( # noqa: C901
5942
6801
  except Exception: # noqa: BLE001 — diagnostic must never raise
5943
6802
  logger.exception("[CHAT_REQUEST_DUMP] failed to serialise")
5944
6803
 
5945
- latest_user_utt = ""
5946
- for _msg in reversed(frame.messages):
5947
- if _msg.role == "user" and _msg.content:
5948
- latest_user_utt = _msg.content
5949
- break
6804
+ latest_user_utt = _latest_citizen_user_utterance(frame.messages)
6805
+ if latest_user_utt:
6806
+ _session_latest_user_utterances[frame.session_id] = latest_user_utt
5950
6807
 
5951
6808
  # Tool inventory — backend ToolRegistry is the single source of
5952
6809
  # truth. CC exposes concrete Tool objects as model-facing functions:
@@ -5971,6 +6828,11 @@ async def run( # noqa: C901
5971
6828
  llm_tools: list[LLMToolDefinition] = [
5972
6829
  LLMToolDefinition.model_validate(raw) for raw in backend_tools_raw
5973
6830
  ]
6831
+ await _emit_progress_event(
6832
+ "tool_selection",
6833
+ "도구 후보와 질의 맥락을 정리하고 있습니다.",
6834
+ "Preparing tool candidates and query context.",
6835
+ )
5974
6836
  has_concrete_backend_tools = bool(backend_tools_raw)
5975
6837
  for t in frame.tools:
5976
6838
  tui_name = getattr(getattr(t, "function", None), "name", None)
@@ -6073,10 +6935,9 @@ async def run( # noqa: C901
6073
6935
  # calls were the source of the "● find(search:)" phantom tool-UI
6074
6936
  # noise that user surfaced via Layer 5 frame capture.
6075
6937
  try:
6076
- for m in reversed(frame.messages):
6077
- if m.role == "user" and m.content:
6078
- latest_user_utt = m.content
6079
- break
6938
+ latest_user_utt = _latest_citizen_user_utterance(frame.messages)
6939
+ if latest_user_utt:
6940
+ _session_latest_user_utterances[frame.session_id] = latest_user_utt
6080
6941
  # spec-multi-turn-contamination diagnostic emit — log the
6081
6942
  # extracted latest user utterance BEFORE the BM25 suffix
6082
6943
  # builder runs. If this string disagrees with the wire-level
@@ -6170,6 +7031,10 @@ async def run( # noqa: C901
6170
7031
  verify_choice_mismatch_count = 0
6171
7032
  empty_final_retry_count = 0
6172
7033
  duplicate_nonprogress_count = 0
7034
+ initial_concrete_tool_choice = _initial_concrete_tool_choice_for_query(
7035
+ latest_user_utt,
7036
+ _tool_definition_names(llm_tools),
7037
+ )
6173
7038
 
6174
7039
  for _turn in range(_AGENTIC_LOOP_MAX_TURNS):
6175
7040
  message_id = str(uuid.uuid4())
@@ -6274,6 +7139,47 @@ async def run( # noqa: C901
6274
7139
  stream_tools = None
6275
7140
  no_tools_this_turn = True
6276
7141
  force_no_tools_next_turn = False
7142
+ elif (
7143
+ initial_concrete_tool_choice is not None
7144
+ and initial_concrete_tool_choice in _tool_definition_names(stream_tools)
7145
+ ):
7146
+ stream_tool_choice = _function_tool_choice(initial_concrete_tool_choice)
7147
+ logger.warning(
7148
+ "_handle_chat_request: forcing initial concrete adapter %s "
7149
+ "for unambiguous query",
7150
+ initial_concrete_tool_choice,
7151
+ )
7152
+ initial_concrete_tool_choice = None
7153
+ elif (
7154
+ force_lookup_next_turn is not None
7155
+ and force_lookup_next_turn in _tool_definition_names(stream_tools)
7156
+ ):
7157
+ stream_tool_choice = _function_tool_choice(force_lookup_next_turn)
7158
+ logger.warning(
7159
+ "_handle_chat_request: forcing concrete find adapter %s after validation gate",
7160
+ force_lookup_next_turn,
7161
+ )
7162
+ force_lookup_next_turn = None
7163
+ elif (
7164
+ force_verify_next_turn is not None
7165
+ and force_verify_next_turn in _tool_definition_names(stream_tools)
7166
+ ):
7167
+ stream_tool_choice = _function_tool_choice(force_verify_next_turn)
7168
+ logger.warning(
7169
+ "_handle_chat_request: forcing concrete check adapter %s after validation gate",
7170
+ force_verify_next_turn,
7171
+ )
7172
+ force_verify_next_turn = None
7173
+ elif (
7174
+ force_submit_next_turn is not None
7175
+ and force_submit_next_turn in _tool_definition_names(stream_tools)
7176
+ ):
7177
+ stream_tool_choice = _function_tool_choice(force_submit_next_turn)
7178
+ logger.warning(
7179
+ "_handle_chat_request: forcing concrete send adapter %s after validation gate",
7180
+ force_submit_next_turn,
7181
+ )
7182
+ force_submit_next_turn = None
6277
7183
  elif (
6278
7184
  force_locate_next_turn
6279
7185
  or force_verify_next_turn is not None
@@ -6290,13 +7196,18 @@ async def run( # noqa: C901
6290
7196
  force_submit_next_turn,
6291
7197
  )
6292
7198
  try:
7199
+ stream_kwargs: dict[str, object] = {
7200
+ "messages": llm_messages,
7201
+ "tools": stream_tools,
7202
+ "temperature": frame.temperature,
7203
+ "top_p": frame.top_p,
7204
+ "max_tokens": _effective_chat_max_tokens(frame.max_tokens),
7205
+ "tool_choice": stream_tool_choice,
7206
+ }
7207
+ if frame.reasoning_mode is not None:
7208
+ stream_kwargs["reasoning_mode"] = frame.reasoning_mode
6293
7209
  async for event in client.stream( # type: ignore[attr-defined]
6294
- messages=llm_messages,
6295
- tools=stream_tools,
6296
- temperature=frame.temperature,
6297
- top_p=frame.top_p,
6298
- max_tokens=_effective_chat_max_tokens(frame.max_tokens),
6299
- tool_choice=stream_tool_choice,
7210
+ **stream_kwargs,
6300
7211
  ):
6301
7212
  event_type = getattr(event, "type", None)
6302
7213
  if event_type == "content_delta":
@@ -6553,6 +7464,19 @@ async def run( # noqa: C901
6553
7464
  buffered_visible.clear()
6554
7465
  continue
6555
7466
 
7467
+ medical_aed_followup_msg = _check_medical_emergency_terminated_without_aed(
7468
+ llm_messages,
7469
+ latest_user_utt,
7470
+ registry=_ensure_tool_registry(),
7471
+ )
7472
+ if medical_aed_followup_msg is not None:
7473
+ _append_tool_routing_observation(
7474
+ "rejected final-answer turn — collapse emergency missing AED lookup",
7475
+ medical_aed_followup_msg,
7476
+ )
7477
+ buffered_visible.clear()
7478
+ continue
7479
+
6556
7480
  current_weather_gate_msg = _check_current_weather_terminated_without_observation(
6557
7481
  llm_messages,
6558
7482
  latest_user_utt,
@@ -6582,6 +7506,25 @@ async def run( # noqa: C901
6582
7506
  has_successful_tool_result = _conversation_has_successful_any_primitive_result(
6583
7507
  llm_messages
6584
7508
  )
7509
+ if (
7510
+ merged_prose.strip()
7511
+ and _final_answer_looks_like_tool_call_narration(merged_prose)
7512
+ and empty_final_retry_count < 2
7513
+ ):
7514
+ empty_final_retry_count += 1
7515
+ _append_tool_routing_observation(
7516
+ "rejected textual tool-call final answer",
7517
+ (
7518
+ "The previous assistant turn printed <tool_call> or JSON "
7519
+ "tool-call text as citizen-facing prose. Never print tool "
7520
+ "calls. If another lookup is required, emit a structured "
7521
+ "function call from the current tools[] list. If enough "
7522
+ "evidence is already available, write a Korean prose final "
7523
+ "answer only."
7524
+ ),
7525
+ )
7526
+ buffered_visible.clear()
7527
+ continue
6585
7528
  if not merged_prose.strip() and has_successful_tool_result:
6586
7529
  if empty_final_retry_count < 2:
6587
7530
  empty_final_retry_count += 1
@@ -6633,6 +7576,59 @@ async def run( # noqa: C901
6633
7576
  )
6634
7577
  buffered_visible.clear()
6635
7578
  continue
7579
+ if (
7580
+ merged_prose.strip()
7581
+ and _final_answer_looks_like_kma_analysis_fabrication(
7582
+ merged_prose,
7583
+ latest_user_utt,
7584
+ )
7585
+ and empty_final_retry_count < 2
7586
+ ):
7587
+ empty_final_retry_count += 1
7588
+ _append_final_answer_observation(
7589
+ "rejected KMA analysis final answer filled from general knowledge",
7590
+ (
7591
+ "The citizen asked for KMA analyzed-data evidence. The "
7592
+ "previous assistant turn described failed, empty, or "
7593
+ "unparseable KMA APIHub analysis lookups, then filled the "
7594
+ "weather answer with general knowledge. Do not fill gaps "
7595
+ "from prior knowledge. If the successful tool_results do "
7596
+ "not contain parseable analyzed values for the request, "
7597
+ "answer that the KMA APIHub lookup did not provide usable "
7598
+ "analyzed data in this run, cite the APIHub upstream/approval "
7599
+ "failure when present, and avoid weather-condition claims."
7600
+ ),
7601
+ )
7602
+ buffered_visible.clear()
7603
+ continue
7604
+ if (
7605
+ merged_prose.strip()
7606
+ and _final_answer_substitutes_after_kma_chart_failure(
7607
+ merged_prose,
7608
+ latest_user_utt,
7609
+ llm_messages,
7610
+ )
7611
+ and empty_final_retry_count < 2
7612
+ ):
7613
+ empty_final_retry_count += 1
7614
+ _append_final_answer_observation(
7615
+ (
7616
+ "rejected KMA chart answer substituted non-chart "
7617
+ "evidence after upstream failure"
7618
+ ),
7619
+ (
7620
+ "The citizen asked for analyzed weather-chart/map evidence. "
7621
+ "The KMA APIHub chart lookup failed or required approval, "
7622
+ "and the previous assistant answer substituted point-grid, "
7623
+ "AWS objective-analysis, or observation values. Do not "
7624
+ "substitute other evidence for this chart/map request. "
7625
+ "Answer that the KMA APIHub analyzed chart lookup could "
7626
+ "not be used in this run, cite the upstream approval/error "
7627
+ "state, and avoid weather-condition claims."
7628
+ ),
7629
+ )
7630
+ buffered_visible.clear()
7631
+ continue
6636
7632
  if (
6637
7633
  merged_prose.strip()
6638
7634
  and has_successful_tool_result
@@ -6852,6 +7848,7 @@ async def run( # noqa: C901
6852
7848
 
6853
7849
  model_tool_name = slot["name"]
6854
7850
  model_args_obj = dict(args_obj)
7851
+ canonical_model_tool_name = model_tool_name
6855
7852
 
6856
7853
  from ummaya.primitives import PRIMITIVE_REGISTRY # noqa: PLC0415
6857
7854
 
@@ -6859,8 +7856,15 @@ async def run( # noqa: C901
6859
7856
  fname = model_tool_name
6860
7857
  args_obj = dict(model_args_obj)
6861
7858
  else:
7859
+ from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
7860
+ resolve_tool_id as _resolve_verify_tool_id,
7861
+ )
7862
+
7863
+ canonical_model_tool_name = (
7864
+ _resolve_verify_tool_id(model_tool_name) or model_tool_name
7865
+ )
6862
7866
  try:
6863
- concrete_tool = registry.find(model_tool_name)
7867
+ concrete_tool = registry.find(canonical_model_tool_name)
6864
7868
  except Exception:
6865
7869
  await write_frame(
6866
7870
  ErrorFrame(
@@ -6894,8 +7898,12 @@ async def run( # noqa: C901
6894
7898
  )
6895
7899
  continue
6896
7900
  fname = primitive_name
6897
- args_obj = {"tool_id": model_tool_name, "params": dict(model_args_obj)}
7901
+ args_obj = {
7902
+ "tool_id": canonical_model_tool_name,
7903
+ "params": dict(model_args_obj),
7904
+ }
6898
7905
 
7906
+ args_obj = _normalize_root_primitive_adapter_envelope(fname, args_obj)
6899
7907
  raw_dispatch_args_obj = _copy_primitive_args(args_obj)
6900
7908
 
6901
7909
  args_obj = _maybe_reroute_locate_admin_keyword_args(fname, args_obj)
@@ -6944,10 +7952,12 @@ async def run( # noqa: C901
6944
7952
  latest_user_utt,
6945
7953
  adapter_param_names=adapter_param_names,
6946
7954
  )
6947
- emit_tool_name = model_tool_name
7955
+ emit_tool_name = (
7956
+ canonical_model_tool_name if model_tool_name != fname else model_tool_name
7957
+ )
6948
7958
  emit_args_obj = (
6949
7959
  dict(cast("dict[str, object]", args_obj.get("params") or {}))
6950
- if model_tool_name != fname
7960
+ if emit_tool_name != fname
6951
7961
  else args_obj
6952
7962
  )
6953
7963
 
@@ -6986,6 +7996,13 @@ async def run( # noqa: C901
6986
7996
  )
6987
7997
 
6988
7998
  await _emit_buffered_visible_before_tool(message_id)
7999
+ await _emit_progress_event(
8000
+ "tool_call",
8001
+ "선택된 도구를 호출하고 있습니다.",
8002
+ "Calling the selected tool.",
8003
+ tool_id=emit_tool_name,
8004
+ call_id=call_id,
8005
+ )
6989
8006
  await write_frame(
6990
8007
  ToolCallFrame(
6991
8008
  session_id=frame.session_id,
@@ -7114,6 +8131,43 @@ async def run( # noqa: C901
7114
8131
  continue_free_next_turn = True
7115
8132
  continue
7116
8133
 
8134
+ kma_aviation_choice_msg = _check_kma_aviation_tool_choice_prerequisite(
8135
+ fname,
8136
+ args_obj,
8137
+ latest_user_utt,
8138
+ )
8139
+ if kma_aviation_choice_msg is not None:
8140
+ force_lookup_next_turn = _preferred_kma_aviation_tool_id(latest_user_utt)
8141
+ _append_tool_routing_observation(
8142
+ f"rejected {fname} call_id={call_id[:12]} — KMA aviation tool mismatch",
8143
+ kma_aviation_choice_msg,
8144
+ )
8145
+ logger.warning(
8146
+ "_handle_chat_request: rejected %s call_id=%s — KMA aviation tool mismatch",
8147
+ fname,
8148
+ call_id[:12],
8149
+ )
8150
+ continue
8151
+
8152
+ direct_public_data_choice = _check_direct_public_data_tool_choice_prerequisite(
8153
+ fname,
8154
+ args_obj,
8155
+ latest_user_utt,
8156
+ )
8157
+ if direct_public_data_choice is not None:
8158
+ preferred_tool_id, direct_public_data_msg = direct_public_data_choice
8159
+ force_lookup_next_turn = preferred_tool_id
8160
+ _append_tool_routing_observation(
8161
+ f"rejected {fname} call_id={call_id[:12]} — public-data tool mismatch",
8162
+ direct_public_data_msg,
8163
+ )
8164
+ logger.warning(
8165
+ "_handle_chat_request: rejected %s call_id=%s — public-data tool mismatch",
8166
+ fname,
8167
+ call_id[:12],
8168
+ )
8169
+ continue
8170
+
7117
8171
  # Chain prerequisite gate — donga-univ-poi-bug Epic #2766.
7118
8172
  # CC mirror: ``Tool.validateInput?(input, context)`` from
7119
8173
  # ``.references/claude-code-sourcemap/restored-src/src/Tool.ts:489``
@@ -7132,11 +8186,9 @@ async def run( # noqa: C901
7132
8186
  # the coordinates AND no prior turn in llm_messages
7133
8187
  # invoked locate, that means the LLM guessed
7134
8188
  # the coordinates from prior knowledge instead of routing
7135
- # through the canonical resolver. Three live captures
7136
- # under specs/integration-verification/donga-univ-poi-bug/
7137
- # showed this exact pattern producing wrong-region
7138
- # hospital lists. Rejecting here forces the next turn
7139
- # through locate.
8189
+ # through the canonical resolver. Historical live captures
8190
+ # showed this exact pattern producing wrong-region hospital
8191
+ # lists. Rejecting here forces the next turn through locate.
7140
8192
  chain_error_msg = _check_chain_prerequisite(
7141
8193
  fname,
7142
8194
  args_obj,
@@ -7158,6 +8210,24 @@ async def run( # noqa: C901
7158
8210
  force_locate_next_turn = True
7159
8211
  continue
7160
8212
 
8213
+ kma_analysis_choice_msg = _check_kma_analysis_tool_choice_prerequisite(
8214
+ fname,
8215
+ args_obj,
8216
+ latest_user_utt,
8217
+ )
8218
+ if kma_analysis_choice_msg is not None:
8219
+ _append_tool_routing_observation(
8220
+ f"rejected {fname} call_id={call_id[:12]} — KMA analysis tool mismatch",
8221
+ kma_analysis_choice_msg,
8222
+ )
8223
+ logger.warning(
8224
+ "_handle_chat_request: rejected %s call_id=%s — KMA analysis tool mismatch",
8225
+ fname,
8226
+ call_id[:12],
8227
+ )
8228
+ continue_free_next_turn = True
8229
+ continue
8230
+
7161
8231
  verify_choice_gate = _check_verify_tool_choice_prerequisite(
7162
8232
  fname,
7163
8233
  args_obj,
@@ -7170,6 +8240,13 @@ async def run( # noqa: C901
7170
8240
  )
7171
8241
 
7172
8242
  await _emit_buffered_visible_before_tool(message_id)
8243
+ await _emit_progress_event(
8244
+ "tool_call",
8245
+ "선택된 도구를 호출하고 있습니다.",
8246
+ "Calling the selected tool.",
8247
+ tool_id=emit_tool_name,
8248
+ call_id=call_id,
8249
+ )
7173
8250
  await write_frame(
7174
8251
  ToolCallFrame(
7175
8252
  session_id=frame.session_id,
@@ -7326,6 +8403,13 @@ async def run( # noqa: C901
7326
8403
  )
7327
8404
 
7328
8405
  await _emit_buffered_visible_before_tool(message_id)
8406
+ await _emit_progress_event(
8407
+ "tool_call",
8408
+ "선택된 도구를 호출하고 있습니다.",
8409
+ "Calling the selected tool.",
8410
+ tool_id=emit_tool_name,
8411
+ call_id=call_id,
8412
+ )
7329
8413
  await write_frame(
7330
8414
  ToolCallFrame(
7331
8415
  session_id=frame.session_id,
@@ -7404,6 +8488,13 @@ async def run( # noqa: C901
7404
8488
  continue
7405
8489
 
7406
8490
  await _emit_buffered_visible_before_tool(message_id)
8491
+ await _emit_progress_event(
8492
+ "tool_call",
8493
+ "선택된 도구를 호출하고 있습니다.",
8494
+ "Calling the selected tool.",
8495
+ tool_id=emit_tool_name,
8496
+ call_id=call_id,
8497
+ )
7407
8498
  await write_frame(
7408
8499
  ToolCallFrame(
7409
8500
  session_id=frame.session_id,
@@ -7730,7 +8821,7 @@ async def run( # noqa: C901
7730
8821
  if not fut.done():
7731
8822
  fut.set_result(frame)
7732
8823
 
7733
- async def _handle_tool_call(frame: IPCFrame) -> None:
8824
+ async def _handle_tool_call(frame: IPCFrame) -> None: # noqa: C901
7734
8825
  """Execute a TUI-owned Tool.call request and emit a tool_result frame.
7735
8826
 
7736
8827
  Claude Code's query loop, not the provider, owns tool execution. The
@@ -7754,8 +8845,13 @@ async def run( # noqa: C901
7754
8845
  dispatch_name = frame.name
7755
8846
  dispatch_args = args_obj
7756
8847
  if dispatch_name not in PRIMITIVE_REGISTRY:
8848
+ from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
8849
+ resolve_tool_id as _resolve_verify_tool_id,
8850
+ )
8851
+
8852
+ canonical_dispatch_name = _resolve_verify_tool_id(dispatch_name) or dispatch_name
7757
8853
  try:
7758
- concrete_tool = _ensure_tool_registry().find(dispatch_name)
8854
+ concrete_tool = _ensure_tool_registry().find(canonical_dispatch_name)
7759
8855
  except Exception:
7760
8856
  from ummaya.ipc.frame_schema import ( # noqa: PLC0415
7761
8857
  ToolResultEnvelope,
@@ -7811,13 +8907,150 @@ async def run( # noqa: C901
7811
8907
  )
7812
8908
  return
7813
8909
  dispatch_name = primitive_name
7814
- dispatch_args = {"tool_id": frame.name, "params": dict(args_obj)}
8910
+ dispatch_args = {"tool_id": canonical_dispatch_name, "params": dict(args_obj)}
7815
8911
 
7816
8912
  dispatch_args = _normalize_lookup_args_from_cached_locate_result(
7817
8913
  dispatch_name,
7818
8914
  dispatch_args,
7819
8915
  _session_latest_locate_results.get(frame.session_id),
8916
+ coordinate_locate_result=_session_latest_locate_results_with_coords.get(
8917
+ frame.session_id
8918
+ ),
8919
+ user_query=_session_latest_user_utterances.get(frame.session_id, ""),
8920
+ )
8921
+ dispatch_args = _normalize_root_primitive_adapter_envelope(dispatch_name, dispatch_args)
8922
+ dispatch_args = _normalize_lookup_args_for_query(
8923
+ dispatch_name,
8924
+ dispatch_args,
8925
+ _session_latest_user_utterances.get(frame.session_id, ""),
8926
+ )
8927
+
8928
+ kma_aviation_choice_msg = _check_kma_aviation_tool_choice_prerequisite(
8929
+ dispatch_name,
8930
+ dispatch_args,
8931
+ _session_latest_user_utterances.get(frame.session_id, ""),
8932
+ )
8933
+ if kma_aviation_choice_msg is not None:
8934
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
8935
+ ToolResultEnvelope,
8936
+ ToolResultFrame,
8937
+ )
8938
+
8939
+ envelope = ToolResultEnvelope.model_validate(
8940
+ {
8941
+ "kind": cast("Any", dispatch_name),
8942
+ "result": {
8943
+ "kind": "error",
8944
+ "reason": "kma_aviation_tool_choice_mismatch",
8945
+ "message": kma_aviation_choice_msg,
8946
+ "retryable": False,
8947
+ },
8948
+ }
8949
+ )
8950
+ result_frame = ToolResultFrame(
8951
+ session_id=frame.session_id,
8952
+ correlation_id=frame.correlation_id,
8953
+ role="backend",
8954
+ ts=_utcnow(),
8955
+ kind="tool_result",
8956
+ call_id=frame.call_id,
8957
+ envelope=envelope,
8958
+ )
8959
+ await write_frame(result_frame)
8960
+ fut = _pending_calls.pop(frame.call_id, None)
8961
+ if fut is not None and not fut.done():
8962
+ fut.set_result(result_frame)
8963
+ logger.warning(
8964
+ "_handle_tool_call: rejected %s call_id=%s — KMA aviation tool mismatch",
8965
+ dispatch_name,
8966
+ frame.call_id[:12],
8967
+ )
8968
+ return
8969
+
8970
+ direct_public_data_choice = _check_direct_public_data_tool_choice_prerequisite(
8971
+ dispatch_name,
8972
+ dispatch_args,
8973
+ _session_latest_user_utterances.get(frame.session_id, ""),
8974
+ )
8975
+ if direct_public_data_choice is not None:
8976
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
8977
+ ToolResultEnvelope,
8978
+ ToolResultFrame,
8979
+ )
8980
+
8981
+ _preferred_tool_id, direct_public_data_msg = direct_public_data_choice
8982
+ envelope = ToolResultEnvelope.model_validate(
8983
+ {
8984
+ "kind": cast("Any", dispatch_name),
8985
+ "result": {
8986
+ "kind": "error",
8987
+ "reason": "public_data_tool_choice_mismatch",
8988
+ "message": direct_public_data_msg,
8989
+ "retryable": False,
8990
+ },
8991
+ }
8992
+ )
8993
+ result_frame = ToolResultFrame(
8994
+ session_id=frame.session_id,
8995
+ correlation_id=frame.correlation_id,
8996
+ role="backend",
8997
+ ts=_utcnow(),
8998
+ kind="tool_result",
8999
+ call_id=frame.call_id,
9000
+ envelope=envelope,
9001
+ )
9002
+ await write_frame(result_frame)
9003
+ fut = _pending_calls.pop(frame.call_id, None)
9004
+ if fut is not None and not fut.done():
9005
+ fut.set_result(result_frame)
9006
+ logger.warning(
9007
+ "_handle_tool_call: rejected %s call_id=%s — public-data tool mismatch",
9008
+ dispatch_name,
9009
+ frame.call_id[:12],
9010
+ )
9011
+ return
9012
+
9013
+ kma_analysis_choice_msg = _check_kma_analysis_tool_choice_prerequisite(
9014
+ dispatch_name,
9015
+ dispatch_args,
9016
+ _session_latest_user_utterances.get(frame.session_id, ""),
7820
9017
  )
9018
+ if kma_analysis_choice_msg is not None:
9019
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
9020
+ ToolResultEnvelope,
9021
+ ToolResultFrame,
9022
+ )
9023
+
9024
+ envelope = ToolResultEnvelope.model_validate(
9025
+ {
9026
+ "kind": cast("Any", dispatch_name),
9027
+ "result": {
9028
+ "kind": "error",
9029
+ "reason": "kma_analysis_tool_choice_mismatch",
9030
+ "message": kma_analysis_choice_msg,
9031
+ "retryable": False,
9032
+ },
9033
+ }
9034
+ )
9035
+ result_frame = ToolResultFrame(
9036
+ session_id=frame.session_id,
9037
+ correlation_id=frame.correlation_id,
9038
+ role="backend",
9039
+ ts=_utcnow(),
9040
+ kind="tool_result",
9041
+ call_id=frame.call_id,
9042
+ envelope=envelope,
9043
+ )
9044
+ await write_frame(result_frame)
9045
+ fut = _pending_calls.pop(frame.call_id, None)
9046
+ if fut is not None and not fut.done():
9047
+ fut.set_result(result_frame)
9048
+ logger.warning(
9049
+ "_handle_tool_call: rejected %s call_id=%s — KMA analysis tool mismatch",
9050
+ dispatch_name,
9051
+ frame.call_id[:12],
9052
+ )
9053
+ return
7821
9054
 
7822
9055
  await _dispatch_primitive(
7823
9056
  frame.call_id,