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.
- package/README.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/prompts/manifest.yaml +2 -2
- package/prompts/session_guidance_v1.md +3 -1
- package/prompts/system_v1.md +8 -7
- package/pyproject.toml +2 -7
- package/src/ummaya/context/builder.py +17 -11
- package/src/ummaya/engine/engine.py +27 -7
- package/src/ummaya/engine/query.py +20 -0
- package/src/ummaya/evidence/__init__.py +25 -0
- package/src/ummaya/evidence/__main__.py +7 -0
- package/src/ummaya/evidence/models.py +58 -0
- package/src/ummaya/evidence/runner.py +308 -0
- package/src/ummaya/evidence/task_registry.py +264 -0
- package/src/ummaya/ipc/frame_schema.py +47 -0
- package/src/ummaya/ipc/stdio.py +1349 -90
- package/src/ummaya/llm/client.py +132 -56
- package/src/ummaya/llm/reasoning.py +84 -0
- package/src/ummaya/tools/discovery_bridge.py +17 -1
- package/src/ummaya/tools/executor.py +32 -12
- package/src/ummaya/tools/geocoding/kakao_client.py +1 -2
- package/src/ummaya/tools/kma/apihub_catalog.py +984 -1
- package/src/ummaya/tools/kma/apihub_structured_adapter.py +86 -6
- package/src/ummaya/tools/kma/apihub_url_adapter.py +593 -0
- package/src/ummaya/tools/kma/apihub_url_catalog.py +296 -0
- package/src/ummaya/tools/location_adapters.py +8 -6
- package/src/ummaya/tools/manifest_metadata.py +16 -3
- package/src/ummaya/tools/mvp_surface.py +2 -2
- package/src/ummaya/tools/nmc/emergency_search.py +8 -6
- package/src/ummaya/tools/register_all.py +9 -0
- package/src/ummaya/tools/resolve_location.py +4 -4
- package/src/ummaya/tools/search.py +664 -18
- package/src/ummaya/tools/verified_data_go_kr/_manifest.py +115 -25
- package/src/ummaya/tools/verified_data_go_kr/airkorea_air_quality.py +109 -4
- package/src/ummaya/tools/verified_data_go_kr/nmc_aed_site.py +108 -2
- package/src/ummaya/tools/verified_data_go_kr/pps_bid_public_info.py +174 -9
- package/src/ummaya/tools/verified_data_go_kr/tago_bus_arrival.py +66 -3
- package/src/ummaya/tools/verified_data_go_kr/tago_bus_location.py +12 -2
- package/src/ummaya/tools/verified_data_go_kr/tago_bus_route.py +8 -2
- package/src/ummaya/tools/verified_data_go_kr/tago_bus_route_station.py +114 -0
- package/src/ummaya/tools/verified_data_go_kr/tago_bus_station.py +14 -3
- package/src/ummaya/tools/verify_canonical_map.py +21 -0
- package/tui/package.json +1 -2
- package/tui/src/QueryEngine.ts +4 -0
- package/tui/src/cli/handlers/auth.ts +1 -1
- package/tui/src/cli/handlers/mcp.tsx +3 -3
- package/tui/src/cli/print.ts +69 -18
- package/tui/src/cli/update.ts +13 -13
- package/tui/src/commands/copy/index.ts +1 -1
- package/tui/src/commands/cost/cost.ts +2 -2
- package/tui/src/commands/init-verifiers.ts +5 -5
- package/tui/src/commands/init.ts +30 -30
- package/tui/src/commands/insights.ts +43 -43
- package/tui/src/commands/install-github-app/install-github-app.tsx +2 -2
- package/tui/src/commands/install-github-app/setupGitHubActions.ts +3 -3
- package/tui/src/commands/install.tsx +5 -5
- package/tui/src/commands/mcp/addCommand.ts +5 -5
- package/tui/src/commands/mcp/xaaIdpCommand.ts +2 -2
- package/tui/src/commands/plugin/ManageMarketplaces.tsx +2 -2
- package/tui/src/commands/reasoning/index.ts +13 -0
- package/tui/src/commands/reasoning/reasoning.tsx +177 -0
- package/tui/src/commands/thinkback/thinkback.tsx +3 -3
- package/tui/src/commands.ts +2 -0
- package/tui/src/components/Messages.tsx +2 -1
- package/tui/src/components/Spinner.tsx +2 -2
- package/tui/src/components/design-system/LoadingState.tsx +2 -2
- package/tui/src/ipc/codec.ts +26 -0
- package/tui/src/ipc/frames.generated.ts +398 -303
- package/tui/src/ipc/llmClient.ts +130 -51
- package/tui/src/ipc/llmTypes.ts +16 -1
- package/tui/src/ipc/schema/frame.schema.json +1 -3475
- package/tui/src/main.tsx +3 -0
- package/tui/src/query.ts +467 -2
- package/tui/src/screens/REPL.tsx +3 -3
- package/tui/src/services/api/claude.ts +54 -25
- package/tui/src/services/api/client.ts +33 -12
- package/tui/src/services/api/ummaya.ts +70 -16
- package/tui/src/skills/bundled/stuck.ts +12 -12
- package/tui/src/state/AppStateStore.ts +7 -0
- package/tui/src/tools/AdapterTool/AdapterTool.ts +590 -7
- package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +43 -17
- package/tui/src/tools/LookupPrimitive/prompt.ts +7 -6
- package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +40 -19
- package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +25 -9
- package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +25 -9
- package/tui/src/tools/_shared/citizenUserText.ts +49 -0
- package/tui/src/tools/_shared/directPublicDataGuard.ts +362 -0
- package/tui/src/tools/_shared/kmaAnalysisGuard.ts +197 -0
- package/tui/src/tools/_shared/kmaAviationGuard.ts +70 -0
- package/tui/src/tools/_shared/locationInputRepair.ts +112 -0
- package/tui/src/tools/_shared/nmcAedGuard.ts +234 -0
- package/tui/src/tools/_shared/protectedCheckGuard.ts +207 -0
- package/tui/src/tools/_shared/rootPrimitiveInput.ts +67 -0
- package/tui/src/tools/_shared/textToolCallGuard.ts +91 -0
- package/tui/src/tools/_shared/toolChoiceRepair.ts +866 -0
- package/tui/src/utils/attachments.ts +1 -1
- package/tui/src/utils/kExaoneReasoning.ts +138 -0
- package/tui/src/utils/messages.ts +1 -0
- package/tui/src/utils/multiToolLayout.ts +13 -0
- package/tui/src/utils/processUserInput/processSlashCommand.tsx +2 -2
- package/tui/src/utils/processUserInput/processUserInput.ts +26 -0
- package/tui/src/utils/settings/applySettingsChange.ts +4 -0
- package/tui/src/utils/settings/types.ts +9 -3
- package/tui/src/utils/stats.ts +1 -1
- package/uv.lock +1 -15
- package/assets/copilot-gate-logo.svg +0 -58
- package/assets/govon-logo.svg +0 -40
- package/src/ummaya/eval/__init__.py +0 -5
- package/src/ummaya/eval/retrieval.py +0 -713
- package/tui/src/utils/messageStream.ts +0 -186
package/src/ummaya/ipc/stdio.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2583
|
+
if has_region_params:
|
|
2162
2584
|
normalized = dict(args_obj)
|
|
2163
2585
|
next_params = dict(params)
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
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
|
-
|
|
2188
|
-
|
|
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"] =
|
|
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
|
|
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
|
|
3984
|
-
if not
|
|
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="
|
|
4744
|
+
tool_id="nmc_aed_site_locate",
|
|
3990
4745
|
):
|
|
3991
4746
|
return None
|
|
3992
|
-
if not
|
|
4747
|
+
if not _conversation_has_successful_primitive(
|
|
3993
4748
|
llm_messages,
|
|
3994
|
-
primitive="
|
|
3995
|
-
|
|
4749
|
+
primitive="find",
|
|
4750
|
+
tool_id="nmc_emergency_search",
|
|
3996
4751
|
):
|
|
3997
4752
|
return None
|
|
3998
4753
|
return (
|
|
3999
|
-
"
|
|
4000
|
-
"
|
|
4001
|
-
"
|
|
4002
|
-
"
|
|
4003
|
-
"
|
|
4004
|
-
"
|
|
4005
|
-
"
|
|
4006
|
-
"
|
|
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
|
|
4404
|
-
#
|
|
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
|
|
4927
|
-
#
|
|
4928
|
-
|
|
4929
|
-
|
|
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는
|
|
5039
|
-
"
|
|
5040
|
-
"
|
|
5041
|
-
"
|
|
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
|
-
|
|
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> 에서
|
|
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
|
-
|
|
5946
|
-
|
|
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
|
-
#
|
|
5957
|
-
#
|
|
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
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
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
|
-
#
|
|
6176
|
-
#
|
|
6177
|
-
#
|
|
6178
|
-
#
|
|
6179
|
-
#
|
|
6180
|
-
#
|
|
6181
|
-
#
|
|
6182
|
-
#
|
|
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
|
-
|
|
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
|
|
6800
|
-
#
|
|
6801
|
-
#
|
|
6802
|
-
#
|
|
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(
|
|
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 = {
|
|
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 =
|
|
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
|
|
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.
|
|
7113
|
-
#
|
|
7114
|
-
#
|
|
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(
|
|
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":
|
|
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,
|