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.
@@ -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
- "Read this adapter's input_schema in <available_adapters> and retry "
228
- "with the exact field names."
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>}. Do NOT guess coordinates or invent NMC filters such as QZ."
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
- FR-001 (Epic η updated): The LLM sees exactly four tools: locate,
23
- find, check, send. Subscribe is deferred until UMMAYA has a real
24
- app/push-notification runtime rather than a CLI-only subscription surface.
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. Choose a locate adapter from the dynamic "
140
- "<available_adapters> block and call locate({tool_id, params}). "
141
- "Provider endpoints are separate adapters: Kakao address search, Kakao "
142
- "keyword/POI search, Kakao coordinate-to-region, JUSO admCd lookup, and "
143
- "SGIS coordinate-to-adm_cd lookup.\n\n"
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
- " locate({tool_id:'kakao_keyword_search', params:{query:'동아대학교 승학캠퍼스'}})\n"
152
- " locate({tool_id:'kakao_coord_to_region', params:{lat:35.115446, lon:128.967669}})"
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
- "외부 도메인 API (기상청 단기예보, HIRA 병원 검색, KOROAD 사고 데이터, "
194
- "KMA 현재 관측 등) 조회하는 추상 도구. 시스템 프롬프트의 "
195
- "<available_adapters> 블록에 백엔드가 사용자 발화마다 후보 어댑터를 "
196
- "자동으로 inject 합니다 LLM 목록의 tool_id 하나를 선택해 "
197
- " find 도구를 호출하면 됩니다.\n\n"
198
- "사용법:\n"
199
- " Call the root function named find, but set tool_id to a concrete "
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
- "예시 (시민: '오늘 부산 날씨'):\n"
205
- " {\n"
206
- ' "tool_id": "kma_forecast_fetch",\n'
207
- ' "params": {"lat": 35.18, "lon": 129.08,\n'
208
- ' "base_date": "20260501", "base_time": "1400"}\n'
209
- " }\n\n"
210
- "ORDERING RULE: <available_adapters> 에서 tool_id 선택 호출 결과 "
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 that issues a scope-bound "
474
- "DelegationContext (or IdentityAssertion for any_id_sso). Call this "
475
- "FIRST when the citizen requests any OPAQUE-domain send-class action "
476
- "(홈택스 신고 / 정부24 민원 / 마이데이터 액션). Pass the scope_list "
477
- "covering ALL downstream find + send calls in a single check "
478
- "invocation. The returned DelegationContext is then passed as a "
479
- "param into the subsequent find(mode='fetch', params={'delegation_"
480
- "context': ctx}) and send(delegation_context=ctx) calls.\n\n"
481
- "family_hint values + canonical AAL hints are documented in the "
482
- "system prompt's <check_families> table. The LLM defaults to the "
483
- "lowest AAL satisfying the citizen's stated purpose.\n\n"
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
- "send primitive invokes a write-transaction adapter (홈택스 신고, "
525
- "정부24 민원, mydata 액션 등). REQUIRES a valid DelegationContext "
526
- "from a prior check call with matching scope. tool_id MUST be one of "
527
- "the registered send adapters (e.g. mock_submit_module_hometax_"
528
- "taxreturn). params MUST include 'delegation_context' (the value "
529
- "returned by check) and the adapter-specific payload.\n\n"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ummaya",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
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
- t => !toolMatchesName(t, TOOL_SEARCH_TOOL_NAME),
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