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