ummaya 0.2.0 → 0.2.2
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/bin/ummaya +76 -31
- package/bun.lock +10 -16
- package/npm-shrinkwrap.json +32 -10
- package/package.json +2 -1
- package/pyproject.toml +2 -2
- package/src/ummaya/engine/engine.py +17 -34
- package/src/ummaya/engine/models.py +10 -4
- package/src/ummaya/engine/query.py +108 -5
- package/src/ummaya/ipc/stdio.py +530 -30
- package/src/ummaya/tools/executor.py +52 -6
- package/src/ummaya/tools/hira/hospital_search.py +15 -2
- package/src/ummaya/tools/location_adapters.py +25 -1
- package/src/ummaya/tools/mvp_surface.py +47 -46
- package/src/ummaya/tools/nmc/emergency_search.py +53 -1
- package/src/ummaya/tools/search.py +63 -2
- package/tui/package.json +1 -1
- package/tui/src/query.ts +1 -1
- package/tui/src/services/api/claude.ts +53 -3
- package/tui/src/tools/AdapterTool/AdapterTool.ts +262 -4
- package/tui/src/tools/LookupPrimitive/prompt.ts +19 -30
- package/tui/src/tools/ResolveLocationPrimitive/prompt.ts +12 -10
- package/tui/src/tools/SubmitPrimitive/prompt.ts +10 -6
- package/tui/src/tools/VerifyPrimitive/prompt.ts +10 -5
- package/uv.lock +1 -1
|
@@ -90,6 +90,44 @@ def _classify_adapter_exception(exc: Exception) -> tuple[LookupErrorReason, bool
|
|
|
90
90
|
return (LookupErrorReason.upstream_unavailable, True)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def _adapter_validation_recovery_hint(tool_id: str) -> str:
|
|
94
|
+
"""Return adapter-specific recovery text for model-visible validation errors."""
|
|
95
|
+
if tool_id == "kma_current_observation":
|
|
96
|
+
return (
|
|
97
|
+
" LOCATE FIRST: use a coordinate-producing locate adapter that returns"
|
|
98
|
+
" KMA nx/ny, then call kma_current_observation with params"
|
|
99
|
+
" {base_date, base_time, nx, ny}. Use the current KST session context"
|
|
100
|
+
" for base_date (YYYYMMDD) and the latest available observation hour"
|
|
101
|
+
" for base_time (HH00). Copy nx/ny exactly from the locate result."
|
|
102
|
+
)
|
|
103
|
+
if tool_id == "kma_forecast_fetch":
|
|
104
|
+
return (
|
|
105
|
+
" LOCATE FIRST: use a coordinate-producing locate adapter that returns"
|
|
106
|
+
" WGS-84 lat/lon, then call kma_forecast_fetch with params"
|
|
107
|
+
" {lat, lon, base_date, base_time}. Do NOT pass nx/ny, x/y, or"
|
|
108
|
+
" administrative code; this adapter converts lat/lon to the KMA grid"
|
|
109
|
+
" internally. Use the current KST session context and the nearest"
|
|
110
|
+
" valid KMA forecast base slot for base_date/base_time."
|
|
111
|
+
)
|
|
112
|
+
if tool_id == "hira_hospital_search":
|
|
113
|
+
return (
|
|
114
|
+
" LOCATE FIRST: use a coordinate-producing locate adapter, then call"
|
|
115
|
+
" hira_hospital_search with params {xPos:<exact lon>, yPos:<exact lat>,"
|
|
116
|
+
" radius:<meters>}. Copy decimal WGS-84 coordinates exactly from the"
|
|
117
|
+
" locate result; do NOT round to whole degrees. For 응급실/야간 응급실,"
|
|
118
|
+
" prefer nmc_emergency_search when authenticated, or a location POI"
|
|
119
|
+
" search when no authenticated NMC session is present."
|
|
120
|
+
)
|
|
121
|
+
if tool_id in {"kakao_coord_to_region", "sgis_adm_cd_lookup"}:
|
|
122
|
+
return (
|
|
123
|
+
" COPY EXACT COORDINATES: call this reverse-geocode adapter with"
|
|
124
|
+
" {lat:<exact decimal lat>, lon:<exact decimal lon>} copied from the"
|
|
125
|
+
" previous locate result. Do NOT round decimal WGS-84 coordinates"
|
|
126
|
+
" to whole degrees."
|
|
127
|
+
)
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
93
131
|
def _is_lookup_envelope_dict(value: dict[str, Any]) -> bool:
|
|
94
132
|
"""Return True when an adapter already emitted a LookupOutput-shaped envelope."""
|
|
95
133
|
kind = value.get("kind")
|
|
@@ -218,14 +256,20 @@ class ToolExecutor:
|
|
|
218
256
|
except ValidationError as exc:
|
|
219
257
|
field_paths = [".".join(str(p) for p in e["loc"]) for e in exc.errors()]
|
|
220
258
|
field_summary = ", ".join(field_paths) if field_paths else "(no field info)"
|
|
259
|
+
error_messages = "; ".join(str(e.get("msg", "")) for e in exc.errors() if e.get("msg"))
|
|
260
|
+
recovery_hint = _adapter_validation_recovery_hint(tool_id)
|
|
261
|
+
fallback_hint = (
|
|
262
|
+
"Read this adapter's input_schema in <available_adapters> and retry "
|
|
263
|
+
"with the exact field names."
|
|
264
|
+
)
|
|
221
265
|
return make_error_envelope(
|
|
222
266
|
tool_id=tool_id,
|
|
223
267
|
reason=LookupErrorReason.invalid_params,
|
|
224
268
|
message=(
|
|
225
269
|
f"Invalid parameters for tool {tool_id!r}. "
|
|
226
270
|
f"Missing or invalid fields: {field_summary}. "
|
|
227
|
-
"
|
|
228
|
-
"
|
|
271
|
+
f"{error_messages}. "
|
|
272
|
+
f"{recovery_hint or fallback_hint}"
|
|
229
273
|
),
|
|
230
274
|
request_id=request_id,
|
|
231
275
|
elapsed_ms=_elapsed(),
|
|
@@ -433,8 +477,8 @@ class ToolExecutor:
|
|
|
433
477
|
fp.split(".")[-1] in coord_fields or fp.split(".")[-1] in admcd_fields
|
|
434
478
|
for fp in field_paths
|
|
435
479
|
)
|
|
436
|
-
recovery_hint =
|
|
437
|
-
if tool_id == "nmc_emergency_search":
|
|
480
|
+
recovery_hint = _adapter_validation_recovery_hint(tool_id)
|
|
481
|
+
if not recovery_hint and tool_id == "nmc_emergency_search":
|
|
438
482
|
recovery_hint = (
|
|
439
483
|
" LOCATE FIRST: call locate with a locate adapter from"
|
|
440
484
|
" <available_adapters>. For a named place use"
|
|
@@ -443,9 +487,11 @@ class ToolExecutor:
|
|
|
443
487
|
" params:{lat:<lat>, lon:<lon>}}). Re-invoke this tool with"
|
|
444
488
|
" params {mode:'region', q0:region.region_1depth_name,"
|
|
445
489
|
" q1:region.region_2depth_name, origin_lat:<lat>, origin_lon:<lon>,"
|
|
446
|
-
" limit:<N>}.
|
|
490
|
+
" limit:<N>}. Copy decimal WGS-84 coordinates exactly from locate;"
|
|
491
|
+
" do NOT round, guess coordinates, or set QN unless the citizen"
|
|
492
|
+
" gave a specific institution name."
|
|
447
493
|
)
|
|
448
|
-
elif need_resolve:
|
|
494
|
+
elif not recovery_hint and need_resolve:
|
|
449
495
|
recovery_hint = (
|
|
450
496
|
" LOCATE FIRST: call locate with the appropriate locate adapter"
|
|
451
497
|
" from <available_adapters> to obtain the missing coordinates /"
|
|
@@ -39,7 +39,7 @@ from datetime import UTC, datetime
|
|
|
39
39
|
from typing import Any
|
|
40
40
|
|
|
41
41
|
import httpx
|
|
42
|
-
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
|
|
42
|
+
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator
|
|
43
43
|
|
|
44
44
|
from ummaya.tools._description_template import build_description_v4
|
|
45
45
|
from ummaya.tools._outbound_trace import traced_async_client
|
|
@@ -481,6 +481,16 @@ class HiraHospitalSearchInput(BaseModel):
|
|
|
481
481
|
f"(e.g. {valid_examples}) or a 2-digit clCd."
|
|
482
482
|
)
|
|
483
483
|
|
|
484
|
+
@model_validator(mode="after")
|
|
485
|
+
def _reject_rounded_coordinate_pair(self) -> HiraHospitalSearchInput:
|
|
486
|
+
"""Reject whole-degree coordinate pairs that lost locate precision."""
|
|
487
|
+
if float(self.xPos).is_integer() and float(self.yPos).is_integer():
|
|
488
|
+
raise ValueError(
|
|
489
|
+
"xPos/yPos must preserve decimal WGS84 precision from locate; "
|
|
490
|
+
"do not round both coordinates to whole degrees."
|
|
491
|
+
)
|
|
492
|
+
return self
|
|
493
|
+
|
|
484
494
|
|
|
485
495
|
# ---------------------------------------------------------------------------
|
|
486
496
|
# Async adapter handler
|
|
@@ -727,7 +737,10 @@ _HIRA_DESCRIPTION = build_description_v4(
|
|
|
727
737
|
"JSON requires '_type=json' (underscore prefix). 'type=json' silently "
|
|
728
738
|
"returns XML. Response coord fields uppercase: XPos/YPos. "
|
|
729
739
|
"HIRA does NOT sort — UMMAYA sorts client-side by distance ASC, so the "
|
|
730
|
-
"first item is the closest. Response does NOT echo dgsbjtCd back."
|
|
740
|
+
"first item is the closest. Response does NOT echo dgsbjtCd back. "
|
|
741
|
+
"This is not an emergency-room locator; for 응급실/야간 응급실 prefer "
|
|
742
|
+
"nmc_emergency_search when an authenticated citizen session exists, "
|
|
743
|
+
"or a location POI search when no authenticated NMC session is present."
|
|
731
744
|
),
|
|
732
745
|
self_contained_decl=(
|
|
733
746
|
"REQUIRED: xPos/yPos. Citizen location text needs "
|
|
@@ -17,7 +17,7 @@ import re
|
|
|
17
17
|
from datetime import UTC, datetime
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
-
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, RootModel, model_validator
|
|
21
21
|
|
|
22
22
|
from ummaya.settings import settings
|
|
23
23
|
from ummaya.tools.kma.projection import KMADomainError, latlon_to_lcc
|
|
@@ -41,6 +41,12 @@ _POI_HINT_RE = re.compile(
|
|
|
41
41
|
r"(대학교|캠퍼스|초등학교|중학교|고등학교|학교|역|터미널|공항|해수욕장|해변|시장|"
|
|
42
42
|
r"공원|병원|의원|약국|주민센터|보건소|도서관|박물관|카페|마트|백화점)"
|
|
43
43
|
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_whole_degree_pair(lat: float, lon: float) -> bool:
|
|
47
|
+
return float(lat).is_integer() and float(lon).is_integer()
|
|
48
|
+
|
|
49
|
+
|
|
44
50
|
_TRAILING_LOCATION_WORDS = (
|
|
45
51
|
"근처",
|
|
46
52
|
"주변",
|
|
@@ -97,6 +103,15 @@ class KakaoCoordToRegionInput(BaseModel):
|
|
|
97
103
|
lat: float = Field(ge=-90, le=90, description="Latitude returned by a prior locate adapter.")
|
|
98
104
|
lon: float = Field(ge=-180, le=180, description="Longitude returned by a prior locate adapter.")
|
|
99
105
|
|
|
106
|
+
@model_validator(mode="after")
|
|
107
|
+
def _reject_rounded_coordinate_pair(self) -> KakaoCoordToRegionInput:
|
|
108
|
+
if _is_whole_degree_pair(self.lat, self.lon):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"lat/lon must preserve decimal WGS84 precision from the prior locate result; "
|
|
111
|
+
"do not round both coordinates to whole degrees."
|
|
112
|
+
)
|
|
113
|
+
return self
|
|
114
|
+
|
|
100
115
|
|
|
101
116
|
class JusoAdmCdLookupInput(BaseModel):
|
|
102
117
|
"""JUSO address-link administrative code lookup input."""
|
|
@@ -118,6 +133,15 @@ class SgisAdmCdLookupInput(BaseModel):
|
|
|
118
133
|
lat: float = Field(ge=-90, le=90, description="Latitude returned by a prior locate adapter.")
|
|
119
134
|
lon: float = Field(ge=-180, le=180, description="Longitude returned by a prior locate adapter.")
|
|
120
135
|
|
|
136
|
+
@model_validator(mode="after")
|
|
137
|
+
def _reject_rounded_coordinate_pair(self) -> SgisAdmCdLookupInput:
|
|
138
|
+
if _is_whole_degree_pair(self.lat, self.lon):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"lat/lon must preserve decimal WGS84 precision from the prior locate result; "
|
|
141
|
+
"do not round both coordinates to whole degrees."
|
|
142
|
+
)
|
|
143
|
+
return self
|
|
144
|
+
|
|
121
145
|
|
|
122
146
|
def _provider_policy(provider: str, url: str) -> AdapterRealDomainPolicy:
|
|
123
147
|
return AdapterRealDomainPolicy(
|
|
@@ -19,9 +19,10 @@ values (10 active families per Epic ε #2296). The dispatcher's
|
|
|
19
19
|
of Spec 031). This is the same pattern find uses — permissive ``tool_id: str``
|
|
20
20
|
where the prompt + BM25 search hint tells the LLM what's valid.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
2026 migration note: these root primitives are high-level categories and legacy
|
|
23
|
+
transcript compatibility wrappers. The normal model-facing surface is a small
|
|
24
|
+
turn-local set of concrete adapter functions selected by ToolSearch or backend
|
|
25
|
+
retrieval.
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
28
|
from __future__ import annotations
|
|
@@ -136,11 +137,13 @@ RESOLVE_LOCATION_TOOL = GovAPITool(
|
|
|
136
137
|
input_schema=_LocateInputForLLM,
|
|
137
138
|
output_schema=_ResolveLocationOutput,
|
|
138
139
|
llm_description=(
|
|
139
|
-
"Location primitive
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"
|
|
140
|
+
"Location primitive category and legacy wrapper. Prefer concrete locate "
|
|
141
|
+
"adapter functions selected by ToolSearch or backend retrieval, and call "
|
|
142
|
+
"them directly with their schema arguments. Use locate({tool_id, params}) "
|
|
143
|
+
"only for old transcripts or compatibility paths. Provider endpoints are "
|
|
144
|
+
"separate adapters: Kakao address search, Kakao keyword/POI search, Kakao "
|
|
145
|
+
"coordinate-to-region, JUSO admCd lookup, and SGIS coordinate-to-adm_cd "
|
|
146
|
+
"lookup.\n\n"
|
|
144
147
|
"Do not invent coordinates or administrative codes. If the citizen gives "
|
|
145
148
|
"a named place/campus/station/landmark, prefer kakao_keyword_search. If "
|
|
146
149
|
"the citizen gives a structured road/jibun address or district text, "
|
|
@@ -148,8 +151,8 @@ RESOLVE_LOCATION_TOOL = GovAPITool(
|
|
|
148
151
|
"adapter needs q0/q1 region names after you have lat/lon, call "
|
|
149
152
|
"kakao_coord_to_region with those coordinates.\n\n"
|
|
150
153
|
"Examples:\n"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
154
|
+
" kakao_keyword_search({query:'동아대학교 승학캠퍼스'})\n"
|
|
155
|
+
" kakao_coord_to_region({lat:35.115446, lon:128.967669})"
|
|
153
156
|
),
|
|
154
157
|
search_hint=(
|
|
155
158
|
"위치 조회 주소 변환 행정동 코드 좌표 지오코딩 POI 장소 검색 "
|
|
@@ -190,26 +193,21 @@ LOOKUP_SEARCH_TOOL = GovAPITool(
|
|
|
190
193
|
input_schema=_LookupInputForLLM,
|
|
191
194
|
output_schema=_LookupOutput,
|
|
192
195
|
llm_description=(
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"adapter id from <available_adapters>.\n"
|
|
201
|
-
' {"tool_id": "<후보 목록의 adapter tool_id>", "params": {...}}\n'
|
|
202
|
-
" Never use root primitive names as tool_id values: find, locate, "
|
|
196
|
+
"Lookup primitive category and legacy wrapper for external-domain "
|
|
197
|
+
"public-service data such as KMA forecasts, KMA current observations, "
|
|
198
|
+
"HIRA hospital search, and KOROAD accident data. Prefer concrete adapter "
|
|
199
|
+
"functions selected by ToolSearch or backend retrieval, and call them "
|
|
200
|
+
"directly with their schema arguments.\n\n"
|
|
201
|
+
"Use find({tool_id, params}) only for old transcripts or compatibility "
|
|
202
|
+
"paths. Do not use root primitive names as tool_id values: find, locate, "
|
|
203
203
|
"check, send.\n\n"
|
|
204
|
-
"
|
|
205
|
-
" {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"분석 → 다음 도구 또는 답변. 동일 tool_id 를 한 turn 안에서 반복 호출하지 "
|
|
212
|
-
"않습니다 — 결과를 바탕으로 답변하거나, 필요하면 다른 tool_id 로 보완 호출."
|
|
204
|
+
"Example for a selected weather adapter:\n"
|
|
205
|
+
" kma_forecast_fetch({lat:35.18, lon:129.08, base_date:'20260501', "
|
|
206
|
+
"base_time:'1400'})\n\n"
|
|
207
|
+
"Ordering rule: select a concrete adapter, call it once with valid schema "
|
|
208
|
+
"arguments, analyze the result, then answer or choose a different adapter "
|
|
209
|
+
"if another official data source is needed. Do not repeat the same "
|
|
210
|
+
"adapter in a turn unless validation feedback requires corrected args."
|
|
213
211
|
),
|
|
214
212
|
search_hint=(
|
|
215
213
|
"데이터 조회 도구 호출 검색 패치 find search fetch invoke tool adapter data query"
|
|
@@ -470,17 +468,18 @@ VERIFY_TOOL = GovAPITool(
|
|
|
470
468
|
input_schema=_VerifyInputForLLM,
|
|
471
469
|
output_schema=_LookupOutput, # opaque envelope wrapper (RootModel[object])
|
|
472
470
|
llm_description=(
|
|
473
|
-
"Authentication-ceremony primitive
|
|
474
|
-
"
|
|
475
|
-
"
|
|
476
|
-
"
|
|
477
|
-
"
|
|
478
|
-
"
|
|
479
|
-
"
|
|
480
|
-
"
|
|
481
|
-
"
|
|
482
|
-
"
|
|
483
|
-
"lowest AAL satisfying the citizen's
|
|
471
|
+
"Authentication-ceremony primitive category and legacy wrapper. Prefer "
|
|
472
|
+
"concrete check adapter functions selected by ToolSearch or backend "
|
|
473
|
+
"retrieval, and call them directly with their schema arguments. A check "
|
|
474
|
+
"adapter issues a scope-bound DelegationContext or IdentityAssertion. "
|
|
475
|
+
"Call an appropriate check adapter first when the citizen requests an "
|
|
476
|
+
"OPAQUE-domain send-class action (홈택스 신고 / 정부24 민원 / 마이데이터 액션). "
|
|
477
|
+
"Pass the scope_list covering all downstream lookup and send adapters in "
|
|
478
|
+
"one check invocation. The returned DelegationContext is then passed as a "
|
|
479
|
+
"param into subsequent concrete lookup/send adapter calls.\n\n"
|
|
480
|
+
"Use check({tool_id, params}) only for old transcripts or compatibility "
|
|
481
|
+
"paths. The LLM defaults to the lowest AAL satisfying the citizen's "
|
|
482
|
+
"stated purpose.\n\n"
|
|
484
483
|
"Exception: family_hint='any_id_sso' returns an IdentityAssertion "
|
|
485
484
|
"with no DelegationToken — do NOT chain a send after this check."
|
|
486
485
|
),
|
|
@@ -521,12 +520,14 @@ SUBMIT_TOOL = GovAPITool(
|
|
|
521
520
|
input_schema=_SubmitInputForLLM,
|
|
522
521
|
output_schema=_LookupOutput,
|
|
523
522
|
llm_description=(
|
|
524
|
-
"
|
|
525
|
-
"정부24 민원, mydata 액션 등).
|
|
526
|
-
"
|
|
527
|
-
"
|
|
528
|
-
"
|
|
529
|
-
"
|
|
523
|
+
"Send primitive category and legacy wrapper for write-transaction "
|
|
524
|
+
"adapters (홈택스 신고, 정부24 민원, mydata 액션 등). Prefer concrete send "
|
|
525
|
+
"adapter functions selected by ToolSearch or backend retrieval, and call "
|
|
526
|
+
"them directly with their schema arguments. A send adapter requires a "
|
|
527
|
+
"valid DelegationContext from a prior check adapter with matching scope. "
|
|
528
|
+
"Use send({tool_id, params}) only for old transcripts or compatibility "
|
|
529
|
+
"paths. params must include the returned DelegationContext and the "
|
|
530
|
+
"adapter-specific payload.\n\n"
|
|
530
531
|
"On success: returns transaction_id (deterministic URN) + adapter_"
|
|
531
532
|
"receipt with the agency's 접수번호 (e.g. 'hometax-2026-MM-DD-RX-XXXXX'). "
|
|
532
533
|
"Cite the receipt in the citizen-facing Korean response.\n\n"
|
|
@@ -53,6 +53,33 @@ logger = logging.getLogger(__name__)
|
|
|
53
53
|
# is left for a follow-up adapter once region resolution lands.
|
|
54
54
|
_BASE_URL = "https://apis.data.go.kr/B552657/ErmctInfoInqireService/getEgytLcinfoInqire"
|
|
55
55
|
_LIST_URL = "https://apis.data.go.kr/B552657/ErmctInfoInqireService/getEgytListInfoInqire"
|
|
56
|
+
_GENERIC_QN_FILTERS = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"응급실",
|
|
59
|
+
"야간응급실",
|
|
60
|
+
"야간 응급실",
|
|
61
|
+
"응급의료",
|
|
62
|
+
"응급의료기관",
|
|
63
|
+
"응급의료센터",
|
|
64
|
+
"응급센터",
|
|
65
|
+
"응급 병원",
|
|
66
|
+
"emergency",
|
|
67
|
+
"emergency room",
|
|
68
|
+
"er",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _invalid_qn_filter(qn: str | None) -> bool:
|
|
74
|
+
if qn is None:
|
|
75
|
+
return False
|
|
76
|
+
stripped = qn.strip()
|
|
77
|
+
if not stripped:
|
|
78
|
+
return True
|
|
79
|
+
if stripped.lower() in _GENERIC_QN_FILTERS:
|
|
80
|
+
return True
|
|
81
|
+
return "," in stripped or "," in stripped
|
|
82
|
+
|
|
56
83
|
|
|
57
84
|
# ---------------------------------------------------------------------------
|
|
58
85
|
# Input schema (T032 — lat/lon/limit, Pydantic v2 strict)
|
|
@@ -118,7 +145,6 @@ class NmcEmergencySearchInput(BaseModel):
|
|
|
118
145
|
)
|
|
119
146
|
qn: str | None = Field(
|
|
120
147
|
default=None,
|
|
121
|
-
min_length=1,
|
|
122
148
|
max_length=80,
|
|
123
149
|
description=(
|
|
124
150
|
"Optional NMC QN institution-name filter. Leave unset for general "
|
|
@@ -157,10 +183,36 @@ class NmcEmergencySearchInput(BaseModel):
|
|
|
157
183
|
def _validate_operation_params(self) -> NmcEmergencySearchInput:
|
|
158
184
|
if self.mode == "coordinate" and (self.lat is None or self.lon is None):
|
|
159
185
|
raise ValueError("mode='coordinate' requires lat and lon")
|
|
186
|
+
if (
|
|
187
|
+
self.mode == "coordinate"
|
|
188
|
+
and self.lat is not None
|
|
189
|
+
and self.lon is not None
|
|
190
|
+
and float(self.lat).is_integer()
|
|
191
|
+
and float(self.lon).is_integer()
|
|
192
|
+
):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"mode='coordinate' requires decimal WGS84 lat/lon copied exactly "
|
|
195
|
+
"from locate; do not round both coordinates to whole degrees"
|
|
196
|
+
)
|
|
160
197
|
if self.mode == "region" and (not self.q0 or not self.q1):
|
|
161
198
|
raise ValueError("mode='region' requires q0 and q1 from locate region")
|
|
199
|
+
if _invalid_qn_filter(self.qn):
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"qn is an institution-name filter; leave qn unset for generic "
|
|
202
|
+
"emergency-room searches"
|
|
203
|
+
)
|
|
162
204
|
if (self.origin_lat is None) ^ (self.origin_lon is None):
|
|
163
205
|
raise ValueError("origin_lat and origin_lon must be supplied together")
|
|
206
|
+
if (
|
|
207
|
+
self.origin_lat is not None
|
|
208
|
+
and self.origin_lon is not None
|
|
209
|
+
and float(self.origin_lat).is_integer()
|
|
210
|
+
and float(self.origin_lon).is_integer()
|
|
211
|
+
):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
"origin_lat/origin_lon must preserve decimal WGS84 precision from locate; "
|
|
214
|
+
"do not round both coordinates to whole degrees"
|
|
215
|
+
)
|
|
164
216
|
return self
|
|
165
217
|
|
|
166
218
|
|
|
@@ -15,6 +15,7 @@ Public API (for external callers):
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import logging
|
|
18
|
+
import re
|
|
18
19
|
from typing import TYPE_CHECKING
|
|
19
20
|
|
|
20
21
|
from ummaya.tools.bm25_index import BM25Index
|
|
@@ -32,6 +33,61 @@ if TYPE_CHECKING:
|
|
|
32
33
|
logger = logging.getLogger(__name__)
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
_POI_LOCATION_RE = re.compile(
|
|
37
|
+
r"(근처|주변|인근|가까운|역|터미널|공항|캠퍼스|대학교|대학|해수욕장|시장|공원|랜드마크)"
|
|
38
|
+
)
|
|
39
|
+
_ADMIN_LOCATION_RE = re.compile(
|
|
40
|
+
r"(?:[가-힣]{2,}(?:시|군|구|동|읍|면)\b|[가-힣0-9]{2,}(?<!으)(?:로|길)\b)"
|
|
41
|
+
)
|
|
42
|
+
_EMERGENCY_RE = re.compile(r"(응급|응급실|응급의료|\bemergency\b|\ber\b)", re.IGNORECASE)
|
|
43
|
+
_TRAFFIC_HAZARD_RE = re.compile(
|
|
44
|
+
r"(교통사고|사고\s*위험|사고다발|위험\s*(?:구간|도로|지점)|어린이보호구역|보호구역|"
|
|
45
|
+
r"도로\s*구간|accident|hazard|hotspot)",
|
|
46
|
+
re.IGNORECASE,
|
|
47
|
+
)
|
|
48
|
+
_TRAFFIC_HAZARD_SPECIFIC_RE = re.compile(
|
|
49
|
+
r"(사고\s*위험|위험\s*(?:구간|도로|지점)|어린이보호구역|보호구역|스쿨존|"
|
|
50
|
+
r"도로\s*구간|행정동코드|adm_cd|hazard|hotspot)",
|
|
51
|
+
re.IGNORECASE,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expand_query_for_adapter_retrieval(query: str) -> str:
|
|
56
|
+
"""Add domain-neutral retrieval hints that Korean spacing can hide.
|
|
57
|
+
|
|
58
|
+
The retriever indexes adapter search hints, not a Korean morphological
|
|
59
|
+
parse. A station query such as "하단역 근처 응급실" contains a POI suffix,
|
|
60
|
+
but does not literally contain the "키워드/POI/랜드마크" terms in
|
|
61
|
+
``kakao_keyword_search``. Expanding only the retrieval query keeps the
|
|
62
|
+
adapter contract unchanged while letting the concrete locate adapter stay
|
|
63
|
+
visible to the model.
|
|
64
|
+
"""
|
|
65
|
+
additions: list[str] = []
|
|
66
|
+
if _POI_LOCATION_RE.search(query):
|
|
67
|
+
additions.extend(["장소", "키워드", "POI", "랜드마크", "역", "keyword"])
|
|
68
|
+
if _ADMIN_LOCATION_RE.search(query):
|
|
69
|
+
additions.extend(["주소", "행정동", "법정동", "도로명", "지번", "address"])
|
|
70
|
+
if _EMERGENCY_RE.search(query):
|
|
71
|
+
additions.extend(["응급실", "응급의료", "NMC", "emergency"])
|
|
72
|
+
if _TRAFFIC_HAZARD_RE.search(query):
|
|
73
|
+
additions.extend(
|
|
74
|
+
[
|
|
75
|
+
"교통사고",
|
|
76
|
+
"사고다발구역",
|
|
77
|
+
"위험지점",
|
|
78
|
+
"도로위험구역",
|
|
79
|
+
"어린이보호구역",
|
|
80
|
+
"행정동코드",
|
|
81
|
+
"KOROAD",
|
|
82
|
+
"accident",
|
|
83
|
+
"hazard",
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
if not additions:
|
|
87
|
+
return query
|
|
88
|
+
return f"{query} {' '.join(additions)}"
|
|
89
|
+
|
|
90
|
+
|
|
35
91
|
def search(
|
|
36
92
|
query: str,
|
|
37
93
|
bm25_index: BM25Index,
|
|
@@ -72,7 +128,7 @@ def search(
|
|
|
72
128
|
|
|
73
129
|
retriever = registry._retriever
|
|
74
130
|
try:
|
|
75
|
-
scored = retriever.score(query)
|
|
131
|
+
scored = retriever.score(_expand_query_for_adapter_retrieval(query))
|
|
76
132
|
except Exception as exc:
|
|
77
133
|
# FR-002 fail-open: a mid-session retriever failure (dense OOM,
|
|
78
134
|
# tokenizer crash, encoder corruption) must not surface as a 5xx
|
|
@@ -96,7 +152,7 @@ def search(
|
|
|
96
152
|
)
|
|
97
153
|
return []
|
|
98
154
|
try:
|
|
99
|
-
scored = bm25_companion.score(query)
|
|
155
|
+
scored = bm25_companion.score(_expand_query_for_adapter_retrieval(query))
|
|
100
156
|
except Exception as bm25_exc:
|
|
101
157
|
logger.warning(
|
|
102
158
|
"search: BM25 companion also failed (%s: %s) — returning empty ranking",
|
|
@@ -105,6 +161,11 @@ def search(
|
|
|
105
161
|
)
|
|
106
162
|
return []
|
|
107
163
|
|
|
164
|
+
if _TRAFFIC_HAZARD_SPECIFIC_RE.search(query):
|
|
165
|
+
scored = [
|
|
166
|
+
(tool_id, score) for tool_id, score in scored if tool_id != "koroad_accident_search"
|
|
167
|
+
]
|
|
168
|
+
|
|
108
169
|
# Enforce the deterministic tie-break once, here. Backend-internal
|
|
109
170
|
# orderings are not trusted (HybridBackend returns unordered union).
|
|
110
171
|
scored = sorted(scored, key=lambda pair: (-pair[1], pair[0]))
|
package/tui/package.json
CHANGED
package/tui/src/query.ts
CHANGED
|
@@ -284,7 +284,7 @@ async function* queryLoop(
|
|
|
284
284
|
params.deps === undefined &&
|
|
285
285
|
process.env.UMMAYA_SKIP_ADAPTER_MANIFEST_BOOTSTRAP !== 'true'
|
|
286
286
|
) {
|
|
287
|
-
const manifestSynced = await ensureUmmayaAdapterManifest()
|
|
287
|
+
const manifestSynced = await ensureUmmayaAdapterManifest(10_000)
|
|
288
288
|
if (manifestSynced && state.toolUseContext.options.refreshTools) {
|
|
289
289
|
const refreshedTools = state.toolUseContext.options.refreshTools()
|
|
290
290
|
if (refreshedTools !== state.toolUseContext.options.tools) {
|
|
@@ -215,6 +215,10 @@ import {
|
|
|
215
215
|
isDeferredTool,
|
|
216
216
|
TOOL_SEARCH_TOOL_NAME,
|
|
217
217
|
} from '../../tools/ToolSearchTool/prompt.js'
|
|
218
|
+
import {
|
|
219
|
+
isRootPrimitiveToolName,
|
|
220
|
+
selectTopKAdapterToolNamesForQuery,
|
|
221
|
+
} from '../../tools/AdapterTool/AdapterTool.js'
|
|
218
222
|
import { count } from '../../utils/array.js'
|
|
219
223
|
import { insertBlockAfterToolResults } from '../../utils/contentArray.js'
|
|
220
224
|
import { validateBoundedIntEnvVar } from '../../utils/envValidation.js'
|
|
@@ -820,6 +824,32 @@ function shouldDeferLspTool(tool: Tool): boolean {
|
|
|
820
824
|
return status.status === 'pending' || status.status === 'not-started'
|
|
821
825
|
}
|
|
822
826
|
|
|
827
|
+
function latestUserTextForToolRetrieval(messages: Message[]): string {
|
|
828
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
829
|
+
const message = messages[i] as {
|
|
830
|
+
type?: string
|
|
831
|
+
message?: { content?: unknown }
|
|
832
|
+
}
|
|
833
|
+
if (message?.type !== 'user') continue
|
|
834
|
+
const content = message.message?.content
|
|
835
|
+
if (typeof content === 'string') {
|
|
836
|
+
if (content.trim().length > 0) return content
|
|
837
|
+
continue
|
|
838
|
+
}
|
|
839
|
+
if (Array.isArray(content)) {
|
|
840
|
+
const text = content
|
|
841
|
+
.filter(
|
|
842
|
+
(block): block is { type: string; text: string } =>
|
|
843
|
+
block?.type === 'text' && typeof block.text === 'string',
|
|
844
|
+
)
|
|
845
|
+
.map(block => block.text)
|
|
846
|
+
.join('')
|
|
847
|
+
if (text.trim().length > 0) return text
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return ''
|
|
851
|
+
}
|
|
852
|
+
|
|
823
853
|
/**
|
|
824
854
|
* Per-attempt timeout for non-streaming fallback requests, in milliseconds.
|
|
825
855
|
* Reads API_TIMEOUT_MS when set so slow backends and the streaming path
|
|
@@ -1175,6 +1205,17 @@ async function* queryModel(
|
|
|
1175
1205
|
useToolSearch = false
|
|
1176
1206
|
}
|
|
1177
1207
|
|
|
1208
|
+
const turnLocalAdapterToolNames = new Set(
|
|
1209
|
+
selectTopKAdapterToolNamesForQuery(
|
|
1210
|
+
latestUserTextForToolRetrieval(messages),
|
|
1211
|
+
),
|
|
1212
|
+
)
|
|
1213
|
+
if (turnLocalAdapterToolNames.size > 0) {
|
|
1214
|
+
logForDebugging(
|
|
1215
|
+
`UMMAYA turn-local adapter schemas: ${[...turnLocalAdapterToolNames].join(', ')}`,
|
|
1216
|
+
)
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1178
1219
|
// Filter out ToolSearchTool if tool search is not enabled for this model
|
|
1179
1220
|
// ToolSearchTool returns tool_reference blocks which unsupported models can't handle
|
|
1180
1221
|
let filteredTools: Tools
|
|
@@ -1186,6 +1227,10 @@ async function* queryModel(
|
|
|
1186
1227
|
const discoveredToolNames = extractDiscoveredToolNames(messages)
|
|
1187
1228
|
|
|
1188
1229
|
filteredTools = tools.filter(tool => {
|
|
1230
|
+
if (turnLocalAdapterToolNames.size > 0 && isRootPrimitiveToolName(tool.name)) {
|
|
1231
|
+
return false
|
|
1232
|
+
}
|
|
1233
|
+
if (turnLocalAdapterToolNames.has(tool.name)) return true
|
|
1189
1234
|
// Always include non-deferred tools
|
|
1190
1235
|
if (!deferredToolNames.has(tool.name)) return true
|
|
1191
1236
|
// Always include ToolSearchTool (so it can discover more tools)
|
|
@@ -1194,9 +1239,14 @@ async function* queryModel(
|
|
|
1194
1239
|
return discoveredToolNames.has(tool.name)
|
|
1195
1240
|
})
|
|
1196
1241
|
} else {
|
|
1197
|
-
filteredTools = tools.filter(
|
|
1198
|
-
|
|
1199
|
-
|
|
1242
|
+
filteredTools = tools.filter(t => {
|
|
1243
|
+
if (toolMatchesName(t, TOOL_SEARCH_TOOL_NAME)) return false
|
|
1244
|
+
if (turnLocalAdapterToolNames.size > 0 && isRootPrimitiveToolName(t.name)) {
|
|
1245
|
+
return false
|
|
1246
|
+
}
|
|
1247
|
+
if (isDeferredTool(t)) return turnLocalAdapterToolNames.has(t.name)
|
|
1248
|
+
return true
|
|
1249
|
+
})
|
|
1200
1250
|
}
|
|
1201
1251
|
|
|
1202
1252
|
// Add tool search beta header if enabled - required for defer_loading to be accepted
|