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
@@ -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(
@@ -199,7 +262,45 @@ def _should_append_tui_tool_to_llm_tools(
199
262
  """Return whether a TUI-sent tool should remain in the model tool list."""
200
263
  if tui_name and tui_name in backend_tool_names:
201
264
  return False
202
- return not (has_concrete_backend_tools and tui_name in _ROOT_PRIMITIVE_TOOL_IDS)
265
+ _ = has_concrete_backend_tools
266
+ return True
267
+
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}
203
304
 
204
305
 
205
306
  _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]], ...]] = (
@@ -258,6 +359,19 @@ _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]],
258
359
  "purpose_en": "Gov24 resident registration certificate civil petition",
259
360
  },
260
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
+ ),
261
375
  (
262
376
  ("복지 급여 신청", "한부모가족", "아동양육비"),
263
377
  {
@@ -303,6 +417,8 @@ _LOCATION_INDEPENDENT_WORKFLOW_HINTS_KO: Final[frozenset[str]] = frozenset(
303
417
  "모바일신분증",
304
418
  "마이데이터",
305
419
  "공공마이데이터",
420
+ "소득금액증명원",
421
+ "소득금액증명",
306
422
  "과태료",
307
423
  "교통범칙금",
308
424
  "범칙금",
@@ -542,23 +658,44 @@ def _kma_observation_base_slot_hint(now_kst: datetime) -> tuple[str, str, str]:
542
658
 
543
659
 
544
660
  def _final_answer_looks_like_pending_tool_plan(text: str) -> bool:
545
- """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."""
546
662
  normalized = " ".join(text.strip().split())
547
663
  if not normalized:
548
664
  return False
549
665
  pending_markers = (
550
666
  "호출하겠습니다",
551
667
  "조회하겠습니다",
668
+ "조회해 보겠습니다",
552
669
  "찾아보겠습니다",
553
670
  "검색하겠습니다",
554
671
  "진행하겠습니다",
555
672
  "확인하겠습니다",
673
+ "확인해 보겠습니다",
556
674
  "will call",
557
675
  "i'll call",
558
676
  "i will call",
559
677
  "will look up",
560
678
  )
561
- 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
+ )
562
699
 
563
700
 
564
701
  def _final_answer_looks_like_recursive_tool_message(text: str) -> bool:
@@ -674,6 +811,8 @@ def _final_answer_looks_like_tool_call_narration(text: str) -> bool:
674
811
  normalized = " ".join(text.strip().split())
675
812
  if not normalized:
676
813
  return False
814
+ if "<tool_call>" in normalized or "</tool_call>" in normalized:
815
+ return True
677
816
  head = normalized[:700]
678
817
  if "도구" not in head:
679
818
  return False
@@ -722,6 +861,174 @@ def _final_answer_looks_like_generic_retry_after_success(text: str) -> bool:
722
861
  return not bool(re.search(r"\d", normalized))
723
862
 
724
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
+
725
1032
  def _conversation_has_successful_any_primitive_result(llm_messages: list[Any]) -> bool:
726
1033
  """Return True when the loop already has a successful primitive result."""
727
1034
  return (
@@ -1753,6 +2060,43 @@ def _latest_successful_primitive_result(
1753
2060
  return None
1754
2061
 
1755
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
+
1756
2100
  def _latest_successful_primitive_result_for_tool(
1757
2101
  llm_messages: list[Any],
1758
2102
  *,
@@ -1949,6 +2293,38 @@ def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1949
2293
  return q0, q1
1950
2294
 
1951
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
+
1952
2328
  def _locate_result_region_pair(result: dict[str, object]) -> tuple[str, str] | None: # noqa: C901
1953
2329
  """Extract NMC region-mode q0/q1 from a locate result."""
1954
2330
  for key in ("region", "coords"):
@@ -2059,6 +2435,13 @@ def _nmc_lookup_params_with_clean_qn(
2059
2435
  return raw_params, params
2060
2436
 
2061
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
+
2062
2445
  def _is_whole_degree_pair(lat: object, lon: object) -> bool:
2063
2446
  """Return True for rounded whole-degree WGS-84 coordinate pairs."""
2064
2447
  if isinstance(lat, bool) or isinstance(lon, bool):
@@ -2083,10 +2466,15 @@ def _normalize_reverse_geocode_args_from_prior_locate(
2083
2466
  was already available in the prior locate result, keep the selected adapter
2084
2467
  and repair only this derived argument pair.
2085
2468
  """
2086
- 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:
2087
2476
  return args_obj
2088
2477
 
2089
- raw_params = args_obj.get("params")
2090
2478
  if not isinstance(raw_params, dict):
2091
2479
  return args_obj
2092
2480
 
@@ -2105,12 +2493,45 @@ def _normalize_reverse_geocode_args_from_prior_locate(
2105
2493
  if coords is None:
2106
2494
  return args_obj
2107
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
+
2108
2529
  next_params = dict(raw_params)
2109
2530
  next_params["lat"], next_params["lon"] = coords
2110
2531
  normalized = dict(args_obj)
2111
2532
  normalized["params"] = next_params
2112
2533
  logger.info(
2113
- "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",
2114
2535
  args_obj.get("tool_id"),
2115
2536
  coords[0],
2116
2537
  coords[1],
@@ -2145,7 +2566,8 @@ def _normalize_nmc_lookup_args_from_prior_locate(
2145
2566
  and bool(_nonempty_str(params.get("q0")))
2146
2567
  and bool(_nonempty_str(params.get("q1")))
2147
2568
  )
2148
- 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:
2149
2571
  if params != raw_params and isinstance(raw_params, dict):
2150
2572
  normalized = dict(args_obj)
2151
2573
  normalized["params"] = params
@@ -2158,13 +2580,24 @@ def _normalize_nmc_lookup_args_from_prior_locate(
2158
2580
  registry=registry,
2159
2581
  )
2160
2582
  if locate_result is None:
2161
- if has_region_params and needs_default_limit:
2583
+ if has_region_params:
2162
2584
  normalized = dict(args_obj)
2163
2585
  next_params = dict(params)
2164
- next_params["limit"] = 5
2165
- normalized["params"] = next_params
2166
- 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
2167
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
2168
2601
 
2169
2602
  return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2170
2603
 
@@ -2184,10 +2617,18 @@ def _normalize_nmc_lookup_args_from_locate_result(
2184
2617
  and bool(_nonempty_str(params.get("q0")))
2185
2618
  and bool(_nonempty_str(params.get("q1")))
2186
2619
  )
2187
- if has_region_params and not needs_default_limit:
2188
- 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)):
2189
2630
  normalized = dict(args_obj)
2190
- normalized["params"] = params
2631
+ normalized["params"] = next_params
2191
2632
  return normalized
2192
2633
  return args_obj
2193
2634
 
@@ -2226,6 +2667,27 @@ def _normalize_nmc_lookup_args_from_locate_result(
2226
2667
  return normalized
2227
2668
 
2228
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
+
2229
2691
  _HIRA_DEPARTMENT_HINTS: tuple[tuple[re.Pattern[str], str], ...] = (
2230
2692
  (re.compile(r"소아청소년과|소아과|pediatrics?", re.IGNORECASE), "소아청소년과"),
2231
2693
  (re.compile(r"이비인후과|ent\b", re.IGNORECASE), "이비인후과"),
@@ -2383,14 +2845,37 @@ def _normalize_lookup_args_from_cached_locate_result(
2383
2845
  args_obj: dict[str, object],
2384
2846
  locate_result: dict[str, object] | None,
2385
2847
  *,
2848
+ coordinate_locate_result: dict[str, object] | None = None,
2386
2849
  user_query: str = "",
2387
2850
  ) -> dict[str, object]:
2388
2851
  """Apply locate-derived argument repair in inbound concrete tool dispatch."""
2389
- 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":
2390
2857
  return args_obj
2391
2858
  tool_id = args_obj.get("tool_id")
2392
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
2393
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)
2394
2879
  if tool_id == "hira_hospital_search":
2395
2880
  return _normalize_hira_lookup_args_from_locate_result(
2396
2881
  args_obj,
@@ -2918,6 +3403,62 @@ def _canonicalize_lookup_tool_id_for_query(
2918
3403
  return normalized
2919
3404
 
2920
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
+
2921
3462
  def _normalize_lookup_args_for_query(
2922
3463
  fname: str,
2923
3464
  args_obj: dict[str, object],
@@ -2935,6 +3476,7 @@ def _normalize_lookup_args_for_query(
2935
3476
  user_query,
2936
3477
  adapter_param_names=adapter_param_names,
2937
3478
  )
3479
+ args_obj = _normalize_pps_bid_args_from_user_query(fname, args_obj, user_query)
2938
3480
  if args_obj.get("tool_id") != "mohw_welfare_eligibility_search":
2939
3481
  return args_obj
2940
3482
  if not _query_contains_any(user_query, ("한부모가족", "한부모", "아동양육비")):
@@ -3158,9 +3700,13 @@ def _check_verify_tool_choice_prerequisite(
3158
3700
  purpose_ko = requirement["purpose_ko"]
3159
3701
  purpose_en = requirement["purpose_en"]
3160
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)
3161
3706
  wrong_verify_tool = (
3162
3707
  tool_id == "check"
3163
3708
  or tool_id.startswith("mock_verify_")
3709
+ or canonical_verify_alias is not None
3164
3710
  or tool_id in allowed_tool_ids
3165
3711
  or _verify_tool_matches_requirement(
3166
3712
  args_obj,
@@ -3860,6 +4406,148 @@ def _check_chain_prerequisite( # noqa: C901
3860
4406
  )
3861
4407
 
3862
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
+
3863
4551
  _CURRENT_WEATHER_KEYWORDS_KO: frozenset[str] = frozenset(
3864
4552
  {"날씨", "기온", "온도", "습도", "강수", "바람", "풍속"}
3865
4553
  )
@@ -3917,6 +4605,16 @@ _AVAILABLE_ADAPTER_FIND_LINE_RE: Final = re.compile(
3917
4605
  r"^\s*-\s+[A-Za-z0-9_.:-]+\s+\(primitive=find\)",
3918
4606
  re.MULTILINE,
3919
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
+ )
3920
4618
 
3921
4619
 
3922
4620
  def _latest_available_adapters_block(llm_messages: list[Any]) -> str:
@@ -3944,6 +4642,25 @@ def _available_adapters_block_has_find_candidate(block: str) -> bool:
3944
4642
  return bool(block and _AVAILABLE_ADAPTER_FIND_LINE_RE.search(block))
3945
4643
 
3946
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
+
3947
4664
  def _query_implies_current_weather_observation(user_query: str) -> bool:
3948
4665
  """Return True when final weather prose should include current observation."""
3949
4666
  if not user_query:
@@ -3975,35 +4692,76 @@ def _query_implies_current_weather_observation(user_query: str) -> bool:
3975
4692
  )
3976
4693
 
3977
4694
 
3978
- def _check_current_weather_terminated_without_observation(
4695
+ def _check_current_weather_terminated_without_observation(
4696
+ llm_messages: list[Any],
4697
+ user_query: str,
4698
+ registry: Any = None,
4699
+ ) -> str | None:
4700
+ """Require KMA current observation before final current/today weather prose."""
4701
+ if not _query_implies_current_weather_observation(user_query):
4702
+ return None
4703
+ if _conversation_has_primitive_call(
4704
+ llm_messages,
4705
+ primitive="find",
4706
+ tool_id="kma_current_observation",
4707
+ ):
4708
+ return None
4709
+ if not _conversation_has_successful_primitive_any_tool(
4710
+ llm_messages,
4711
+ primitive="locate",
4712
+ registry=registry,
4713
+ ):
4714
+ return None
4715
+ return (
4716
+ "Current weather observation missing: the citizen asked for current/today "
4717
+ "weather, but the conversation is about to answer without calling "
4718
+ "kma_current_observation. RECOVERY: call "
4719
+ "kma_current_observation({base_date:<current KST YYYYMMDD>, "
4720
+ "base_time:<current or prior HH00>, nx:<latest locate KMA X>, "
4721
+ "ny:<latest locate KMA Y>}) using the latest locate result. Do NOT claim "
4722
+ "that live/current observation data is unavailable unless this adapter was "
4723
+ "called and returned an error."
4724
+ )
4725
+
4726
+
4727
+ def _check_medical_emergency_terminated_without_aed(
3979
4728
  llm_messages: list[Any],
3980
4729
  user_query: str,
3981
4730
  registry: Any = None,
3982
4731
  ) -> str | None:
3983
- """Require KMA current observation before final current/today weather prose."""
3984
- if not _query_implies_current_weather_observation(user_query):
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
+ ):
3985
4740
  return None
3986
4741
  if _conversation_has_primitive_call(
3987
4742
  llm_messages,
3988
4743
  primitive="find",
3989
- tool_id="kma_current_observation",
4744
+ tool_id="nmc_aed_site_locate",
3990
4745
  ):
3991
4746
  return None
3992
- if not _conversation_has_successful_primitive_any_tool(
4747
+ if not _conversation_has_successful_primitive(
3993
4748
  llm_messages,
3994
- primitive="locate",
3995
- registry=registry,
4749
+ primitive="find",
4750
+ tool_id="nmc_emergency_search",
3996
4751
  ):
3997
4752
  return None
3998
4753
  return (
3999
- "Current weather observation missing: the citizen asked for current/today "
4000
- "weather, but the conversation is about to answer without calling "
4001
- "kma_current_observation. RECOVERY: call "
4002
- "kma_current_observation({base_date:<current KST YYYYMMDD>, "
4003
- "base_time:<current or prior HH00>, nx:<latest locate KMA X>, "
4004
- "ny:<latest locate KMA Y>}) using the latest locate result. Do NOT claim "
4005
- "that live/current observation data is unavailable unless this adapter was "
4006
- "called and returned an error."
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."
4007
4765
  )
4008
4766
 
4009
4767
 
@@ -4400,8 +5158,8 @@ async def run( # noqa: C901
4400
5158
 
4401
5159
  # ---- spec-multi-turn-contamination diagnostic — optional log file
4402
5160
  # The TUI bridge spawns this process with `stderr: 'pipe'` and never
4403
- # drains the pipe, so `logger.info(...)` lines are invisible to any
4404
- # 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
4405
5163
  # sets UMMAYA_BACKEND_LOG_FILE=<path>, attach a FileHandler at INFO
4406
5164
  # so the diagnostic [CHAT_REQUEST_DUMP] / [LATEST_USER_UTT] /
4407
5165
  # [REASONING_PREVIEW] lines persist to disk for post-hoc analysis.
@@ -4554,6 +5312,8 @@ async def run( # noqa: C901
4554
5312
  _session_auth_contexts: dict[str, object] = {}
4555
5313
  _session_auth_session_ids: dict[str, str] = {}
4556
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] = {}
4557
5317
 
4558
5318
  # Epic #2077 T010 — single ToolRegistry + ToolExecutor instance pair
4559
5319
  # reused across every chat_request. Adapter registration happens lazily
@@ -4891,6 +5651,27 @@ async def run( # noqa: C901
4891
5651
  candidates = filtered_candidates
4892
5652
  if not candidates:
4893
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
+ )
4894
5675
  # Build a compact, LLM-readable block.
4895
5676
  #
4896
5677
  # Spec 2521 (2026-05-02) — emit per-field schema signatures so the
@@ -4916,6 +5697,7 @@ async def run( # noqa: C901
4916
5697
  lines.append(
4917
5698
  f"- {c.tool_id} (primitive={primitive}) [{c.score:.2f}] — {hint or '(설명 없음)'}"
4918
5699
  )
5700
+ lines.append(f" 호출: {c.tool_id}({{...schema fields...}})")
4919
5701
  # Render the adapter's llm_description (usage prose, ORDERING RULE,
4920
5702
  # prerequisites, worked examples) so the LLM sees the complete
4921
5703
  # "먼저 locate 호출" ordering rule.
@@ -4923,10 +5705,13 @@ async def run( # noqa: C901
4923
5705
  # and K-EXAONE skips locate, producing invalid_params.
4924
5706
  if c.llm_description:
4925
5707
  desc_text = c.llm_description.strip().replace("\n", " ")
4926
- # Emit at most 300 chars — enough for the ORDERING RULE and
4927
- # worked example without blowing the per-turn token budget.
4928
- if len(desc_text) > 300:
4929
- 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] + "..."
4930
5715
  lines.append(f" 설명: {desc_text}")
4931
5716
  # Render input schema signature so the LLM sees exact field
4932
5717
  # names + types + required flags + (truncated) descriptions.
@@ -5035,11 +5820,54 @@ async def run( # noqa: C901
5035
5820
  )
5036
5821
  lines.append("")
5037
5822
  lines.append(
5038
- "규칙: 위 목록의 tool_id는 이미 model-facing concrete function name입니다. "
5039
- "루트 wrapper find/locate/check/send 호출하지 말고, 해당 tool_id 이름의 "
5040
- "함수를 직접 호출하세요. 인자는 schema 필드명 그대로 전달합니다. "
5041
- "동일 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
+ "답하지 마세요."
5042
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
+ )
5043
5871
  listed_primitives = {str(candidate.primitive or "find") for candidate in candidates}
5044
5872
  if listed_primitives == {"find"}:
5045
5873
  lines.append(
@@ -5060,7 +5888,8 @@ async def run( # noqa: C901
5060
5888
  )
5061
5889
  lines.append(
5062
5890
  "BM25 도구 발견은 백엔드 internal 기능입니다. 모델은 검색 함수를 호출하지 "
5063
- "않고, backend가 tools[]에 실어준 concrete function 호출합니다."
5891
+ "않고, backend가 tools[]에 실어준 concrete adapter function 우선 "
5892
+ "호출합니다."
5064
5893
  )
5065
5894
  lines.append("</available_adapters>")
5066
5895
  return "\n".join(lines)
@@ -5580,6 +6409,7 @@ async def run( # noqa: C901
5580
6409
  )
5581
6410
  from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
5582
6411
  resolve_family,
6412
+ resolve_tool_id,
5583
6413
  )
5584
6414
 
5585
6415
  # Spec 2297 / Issue #C1 (2026-05-04) — translate
@@ -5594,7 +6424,11 @@ async def run( # noqa: C901
5594
6424
  # Accept both ``family`` (citizen-facing tool schema) and
5595
6425
  # ``family_hint`` (primitive's internal arg name) for
5596
6426
  # legacy / tools-bridge compatibility.
5597
- 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
5598
6432
  if tool_id:
5599
6433
  registry = _ensure_tool_registry()
5600
6434
  try:
@@ -5655,7 +6489,8 @@ async def run( # noqa: C901
5655
6489
  message=(
5656
6490
  "find(mode='search') 는 백엔드 internal 기능입니다 — "
5657
6491
  "직접 호출하지 마십시오. 시스템 프롬프트의 "
5658
- "<available_adapters> 에서 tool_id 골라 fetch 호출만 사용하세요."
6492
+ "<available_adapters> 에서 concrete adapter function을 "
6493
+ "골라 schema 필드로 직접 호출하세요."
5659
6494
  ),
5660
6495
  retryable=False,
5661
6496
  )
@@ -5812,6 +6647,8 @@ async def run( # noqa: C901
5812
6647
  locate_result = result_payload.get("result")
5813
6648
  if isinstance(locate_result, dict) and locate_result.get("kind") != "error":
5814
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
5815
6652
 
5816
6653
  # Drain the outbound HTTP trace buffer + attach to the envelope.
5817
6654
  outbound_traces = consume_outbound_capture(_outbound_trace_token)
@@ -5899,6 +6736,29 @@ async def run( # noqa: C901
5899
6736
  if not isinstance(frame, ChatRequestFrame):
5900
6737
  return
5901
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
+
5902
6762
  # ---- spec-multi-turn-contamination diagnostic emit (FR-001/FR-002)
5903
6763
  # Increment the per-session turn counter and dump the inbound
5904
6764
  # ChatRequestFrame.messages tail so we can prove which user turn
@@ -5941,11 +6801,9 @@ async def run( # noqa: C901
5941
6801
  except Exception: # noqa: BLE001 — diagnostic must never raise
5942
6802
  logger.exception("[CHAT_REQUEST_DUMP] failed to serialise")
5943
6803
 
5944
- latest_user_utt = ""
5945
- for _msg in reversed(frame.messages):
5946
- if _msg.role == "user" and _msg.content:
5947
- latest_user_utt = _msg.content
5948
- 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
5949
6807
 
5950
6808
  # Tool inventory — backend ToolRegistry is the single source of
5951
6809
  # truth. CC exposes concrete Tool objects as model-facing functions:
@@ -5953,8 +6811,9 @@ async def run( # noqa: C901
5953
6811
  # UMMAYA now follows that shape: BM25/dense retrieval selects a small
5954
6812
  # turn-local set of concrete adapter tools, and each selected
5955
6813
  # GovAPITool is exported directly as an OpenAI-compatible function.
5956
- # The root primitives remain internal dispatcher families and legacy
5957
- # transcript compatibility names, not the model-facing tool surface.
6814
+ # Keep the root primitives alongside that set to preserve the 0.2.1
6815
+ # CC-style loop contract: the model can paint progress prose, then call
6816
+ # a primitive dispatcher with a concrete adapter in `tool_id`.
5958
6817
  registry = cast("Any", _ensure_tool_registry())
5959
6818
  backend_tools_raw = [
5960
6819
  t.to_openai_tool() for t in _select_concrete_adapter_tools_for_turn(latest_user_utt)
@@ -5969,6 +6828,11 @@ async def run( # noqa: C901
5969
6828
  llm_tools: list[LLMToolDefinition] = [
5970
6829
  LLMToolDefinition.model_validate(raw) for raw in backend_tools_raw
5971
6830
  ]
6831
+ await _emit_progress_event(
6832
+ "tool_selection",
6833
+ "도구 후보와 질의 맥락을 정리하고 있습니다.",
6834
+ "Preparing tool candidates and query context.",
6835
+ )
5972
6836
  has_concrete_backend_tools = bool(backend_tools_raw)
5973
6837
  for t in frame.tools:
5974
6838
  tui_name = getattr(getattr(t, "function", None), "name", None)
@@ -6071,10 +6935,9 @@ async def run( # noqa: C901
6071
6935
  # calls were the source of the "● find(search:)" phantom tool-UI
6072
6936
  # noise that user surfaced via Layer 5 frame capture.
6073
6937
  try:
6074
- for m in reversed(frame.messages):
6075
- if m.role == "user" and m.content:
6076
- latest_user_utt = m.content
6077
- 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
6078
6941
  # spec-multi-turn-contamination diagnostic emit — log the
6079
6942
  # extracted latest user utterance BEFORE the BM25 suffix
6080
6943
  # builder runs. If this string disagrees with the wire-level
@@ -6168,28 +7031,53 @@ async def run( # noqa: C901
6168
7031
  verify_choice_mismatch_count = 0
6169
7032
  empty_final_retry_count = 0
6170
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
+ )
6171
7038
 
6172
7039
  for _turn in range(_AGENTIC_LOOP_MAX_TURNS):
6173
7040
  message_id = str(uuid.uuid4())
6174
7041
  assistant_text_chunks: list[str] = []
6175
- # Epic #2766 issue B — render-order fix. K-EXAONE emits the
6176
- # assistant's prose preamble ("내과 병원을 검색해 보겠습니다.")
6177
- # BEFORE the structured ``tool_call_delta`` events arrive in the
6178
- # SAME turn. If we forward those prose chunks immediately, the
6179
- # citizen sees ``assistant text → tool_call result``, the
6180
- # opposite of CC's canonical ``tool_call result → assistant
6181
- # text`` order. The fix: buffer prose chunks for this turn; emit
6182
- # them as a single AssistantChunkFrame ONLY after we know whether
6183
- # this turn invoked tools. When tools are invoked we suppress the
6184
- # preamble entirely — the next turn produces the real answer
6185
- # after the tool result is appended to context. When no tools
6186
- # are invoked we flush the buffer as a single chunk so the prose
6187
- # still reaches the citizen.
7042
+ # CC stream order: K-EXAONE may emit a visible progress sentence
7043
+ # before the structured ``tool_call_delta`` in the same assistant
7044
+ # turn. Claude Code commits that text block before opening the
7045
+ # following tool_use block, so the TUI can paint
7046
+ # ``assistant text → tool_call``. Buffer here only so textual
7047
+ # ``<tool_call>`` markers can be stripped accurately across chunk
7048
+ # boundaries; when a real ToolCallFrame is emitted below, flush the
7049
+ # cleaned visible text immediately before the tool frame.
6188
7050
  buffered_visible: list[str] = []
6189
7051
  tool_call_buf: dict[int, dict[str, str]] = {}
6190
7052
  stream_error: Exception | None = None
6191
7053
  stream_gate = StreamGate()
6192
7054
 
7055
+ async def _emit_buffered_visible_before_tool(current_message_id: str) -> None:
7056
+ """Emit same-turn visible prose before opening a tool_use block."""
7057
+ nonlocal buffered_visible
7058
+ if not buffered_visible:
7059
+ return
7060
+ from ummaya.llm.tool_call_parser import ( # noqa: PLC0415
7061
+ strip_leaked_thinking_markers,
7062
+ )
7063
+
7064
+ merged_prose = strip_leaked_thinking_markers("".join(buffered_visible))
7065
+ buffered_visible = []
7066
+ if not merged_prose.strip():
7067
+ return
7068
+ await write_frame(
7069
+ AssistantChunkFrame(
7070
+ session_id=frame.session_id,
7071
+ correlation_id=frame.correlation_id,
7072
+ role="llm",
7073
+ ts=_utcnow(),
7074
+ kind="assistant_chunk",
7075
+ message_id=current_message_id,
7076
+ delta=merged_prose,
7077
+ done=False,
7078
+ )
7079
+ )
7080
+
6193
7081
  def _append_tool_routing_observation(reason: str, message: str) -> None:
6194
7082
  """Add an internal routing repair instruction for the next model turn."""
6195
7083
  llm_messages.append(
@@ -6251,6 +7139,47 @@ async def run( # noqa: C901
6251
7139
  stream_tools = None
6252
7140
  no_tools_this_turn = True
6253
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
6254
7183
  elif (
6255
7184
  force_locate_next_turn
6256
7185
  or force_verify_next_turn is not None
@@ -6267,13 +7196,18 @@ async def run( # noqa: C901
6267
7196
  force_submit_next_turn,
6268
7197
  )
6269
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
6270
7209
  async for event in client.stream( # type: ignore[attr-defined]
6271
- messages=llm_messages,
6272
- tools=stream_tools,
6273
- temperature=frame.temperature,
6274
- top_p=frame.top_p,
6275
- max_tokens=_effective_chat_max_tokens(frame.max_tokens),
6276
- tool_choice=stream_tool_choice,
7210
+ **stream_kwargs,
6277
7211
  ):
6278
7212
  event_type = getattr(event, "type", None)
6279
7213
  if event_type == "content_delta":
@@ -6530,6 +7464,19 @@ async def run( # noqa: C901
6530
7464
  buffered_visible.clear()
6531
7465
  continue
6532
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
+
6533
7480
  current_weather_gate_msg = _check_current_weather_terminated_without_observation(
6534
7481
  llm_messages,
6535
7482
  latest_user_utt,
@@ -6559,6 +7506,25 @@ async def run( # noqa: C901
6559
7506
  has_successful_tool_result = _conversation_has_successful_any_primitive_result(
6560
7507
  llm_messages
6561
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
6562
7528
  if not merged_prose.strip() and has_successful_tool_result:
6563
7529
  if empty_final_retry_count < 2:
6564
7530
  empty_final_retry_count += 1
@@ -6610,6 +7576,59 @@ async def run( # noqa: C901
6610
7576
  )
6611
7577
  buffered_visible.clear()
6612
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
6613
7632
  if (
6614
7633
  merged_prose.strip()
6615
7634
  and has_successful_tool_result
@@ -6796,11 +7815,10 @@ async def run( # noqa: C901
6796
7815
  )
6797
7816
  )
6798
7817
  return
6799
- # Tool calls present suppress the prose preamble entirely.
6800
- # The next agentic-loop turn will produce the real answer after
6801
- # appending tool_result to context. CC-style ordering preserved:
6802
- # `tool_call tool_result final assistant prose`.
6803
- buffered_visible.clear()
7818
+ # Tool calls present. Preserve any same-turn progress prose by
7819
+ # emitting it immediately before the ToolCallFrame below; do not
7820
+ # send a done=True chunk because this provider call must still stop
7821
+ # at assistant(tool_use), not at an assistant final answer.
6804
7822
 
6805
7823
  # ---- T027/T029 — emit tool_call frames + register Futures -----
6806
7824
  issued_calls: list[tuple[str, str]] = [] # (call_id, name)
@@ -6830,6 +7848,7 @@ async def run( # noqa: C901
6830
7848
 
6831
7849
  model_tool_name = slot["name"]
6832
7850
  model_args_obj = dict(args_obj)
7851
+ canonical_model_tool_name = model_tool_name
6833
7852
 
6834
7853
  from ummaya.primitives import PRIMITIVE_REGISTRY # noqa: PLC0415
6835
7854
 
@@ -6837,8 +7856,15 @@ async def run( # noqa: C901
6837
7856
  fname = model_tool_name
6838
7857
  args_obj = dict(model_args_obj)
6839
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
+ )
6840
7866
  try:
6841
- concrete_tool = registry.find(model_tool_name)
7867
+ concrete_tool = registry.find(canonical_model_tool_name)
6842
7868
  except Exception:
6843
7869
  await write_frame(
6844
7870
  ErrorFrame(
@@ -6872,8 +7898,12 @@ async def run( # noqa: C901
6872
7898
  )
6873
7899
  continue
6874
7900
  fname = primitive_name
6875
- 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
+ }
6876
7905
 
7906
+ args_obj = _normalize_root_primitive_adapter_envelope(fname, args_obj)
6877
7907
  raw_dispatch_args_obj = _copy_primitive_args(args_obj)
6878
7908
 
6879
7909
  args_obj = _maybe_reroute_locate_admin_keyword_args(fname, args_obj)
@@ -6922,10 +7952,12 @@ async def run( # noqa: C901
6922
7952
  latest_user_utt,
6923
7953
  adapter_param_names=adapter_param_names,
6924
7954
  )
6925
- 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
+ )
6926
7958
  emit_args_obj = (
6927
7959
  dict(cast("dict[str, object]", args_obj.get("params") or {}))
6928
- if model_tool_name != fname
7960
+ if emit_tool_name != fname
6929
7961
  else args_obj
6930
7962
  )
6931
7963
 
@@ -6963,6 +7995,14 @@ async def run( # noqa: C901
6963
7995
  ToolResultFrame,
6964
7996
  )
6965
7997
 
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
+ )
6966
8006
  await write_frame(
6967
8007
  ToolCallFrame(
6968
8008
  session_id=frame.session_id,
@@ -7091,6 +8131,43 @@ async def run( # noqa: C901
7091
8131
  continue_free_next_turn = True
7092
8132
  continue
7093
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
+
7094
8171
  # Chain prerequisite gate — donga-univ-poi-bug Epic #2766.
7095
8172
  # CC mirror: ``Tool.validateInput?(input, context)`` from
7096
8173
  # ``.references/claude-code-sourcemap/restored-src/src/Tool.ts:489``
@@ -7109,11 +8186,9 @@ async def run( # noqa: C901
7109
8186
  # the coordinates AND no prior turn in llm_messages
7110
8187
  # invoked locate, that means the LLM guessed
7111
8188
  # the coordinates from prior knowledge instead of routing
7112
- # through the canonical resolver. Three live captures
7113
- # under specs/integration-verification/donga-univ-poi-bug/
7114
- # showed this exact pattern producing wrong-region
7115
- # hospital lists. Rejecting here forces the next turn
7116
- # 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.
7117
8192
  chain_error_msg = _check_chain_prerequisite(
7118
8193
  fname,
7119
8194
  args_obj,
@@ -7135,6 +8210,24 @@ async def run( # noqa: C901
7135
8210
  force_locate_next_turn = True
7136
8211
  continue
7137
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
+
7138
8231
  verify_choice_gate = _check_verify_tool_choice_prerequisite(
7139
8232
  fname,
7140
8233
  args_obj,
@@ -7146,6 +8239,14 @@ async def run( # noqa: C901
7146
8239
  ToolResultFrame,
7147
8240
  )
7148
8241
 
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
+ )
7149
8250
  await write_frame(
7150
8251
  ToolCallFrame(
7151
8252
  session_id=frame.session_id,
@@ -7301,6 +8402,14 @@ async def run( # noqa: C901
7301
8402
  ToolResultFrame,
7302
8403
  )
7303
8404
 
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
+ )
7304
8413
  await write_frame(
7305
8414
  ToolCallFrame(
7306
8415
  session_id=frame.session_id,
@@ -7378,6 +8487,14 @@ async def run( # noqa: C901
7378
8487
  )
7379
8488
  continue
7380
8489
 
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
+ )
7381
8498
  await write_frame(
7382
8499
  ToolCallFrame(
7383
8500
  session_id=frame.session_id,
@@ -7704,7 +8821,7 @@ async def run( # noqa: C901
7704
8821
  if not fut.done():
7705
8822
  fut.set_result(frame)
7706
8823
 
7707
- async def _handle_tool_call(frame: IPCFrame) -> None:
8824
+ async def _handle_tool_call(frame: IPCFrame) -> None: # noqa: C901
7708
8825
  """Execute a TUI-owned Tool.call request and emit a tool_result frame.
7709
8826
 
7710
8827
  Claude Code's query loop, not the provider, owns tool execution. The
@@ -7728,8 +8845,13 @@ async def run( # noqa: C901
7728
8845
  dispatch_name = frame.name
7729
8846
  dispatch_args = args_obj
7730
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
7731
8853
  try:
7732
- concrete_tool = _ensure_tool_registry().find(dispatch_name)
8854
+ concrete_tool = _ensure_tool_registry().find(canonical_dispatch_name)
7733
8855
  except Exception:
7734
8856
  from ummaya.ipc.frame_schema import ( # noqa: PLC0415
7735
8857
  ToolResultEnvelope,
@@ -7785,13 +8907,150 @@ async def run( # noqa: C901
7785
8907
  )
7786
8908
  return
7787
8909
  dispatch_name = primitive_name
7788
- dispatch_args = {"tool_id": frame.name, "params": dict(args_obj)}
8910
+ dispatch_args = {"tool_id": canonical_dispatch_name, "params": dict(args_obj)}
7789
8911
 
7790
8912
  dispatch_args = _normalize_lookup_args_from_cached_locate_result(
7791
8913
  dispatch_name,
7792
8914
  dispatch_args,
7793
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, ""),
7794
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
7795
9054
 
7796
9055
  await _dispatch_primitive(
7797
9056
  frame.call_id,