ummaya 0.2.1 → 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.
@@ -66,6 +66,7 @@ _tracer = trace.get_tracer(__name__)
66
66
  _BACKGROUND_FRAME_KINDS: frozenset[str] = frozenset(
67
67
  {"chat_request", "user_input", "tool_call", "plugin_op"}
68
68
  )
69
+ _ROOT_PRIMITIVE_TOOL_IDS: Final[frozenset[str]] = frozenset({"locate", "find", "check", "send"})
69
70
  _SCOPE_ENTRY_RE = re.compile(r"^(find|send|check):[a-z0-9_]+\.[a-z0-9_-]+$")
70
71
  _TOOL_ID_SCOPE_RE = re.compile(r"^(?P<verb>find|send|check):(?P<tool_id>[a-z][a-z0-9_]*[a-z0-9])$")
71
72
  _LEGACY_SCOPE_VERB_ALIASES: Final[dict[str, str]] = {
@@ -187,6 +188,20 @@ _PRIMITIVE_ERROR_REASONS: Final[frozenset[str]] = frozenset(
187
188
  "verify_tool_choice_mismatch",
188
189
  }
189
190
  )
191
+
192
+
193
+ def _should_append_tui_tool_to_llm_tools(
194
+ tui_name: object | None,
195
+ backend_tool_names: set[object],
196
+ *,
197
+ has_concrete_backend_tools: bool,
198
+ ) -> bool:
199
+ """Return whether a TUI-sent tool should remain in the model tool list."""
200
+ if tui_name and tui_name in backend_tool_names:
201
+ return False
202
+ return not (has_concrete_backend_tools and tui_name in _ROOT_PRIMITIVE_TOOL_IDS)
203
+
204
+
190
205
  _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]], ...]] = (
191
206
  (
192
207
  ("간편인증", "pass 인증", "kakao 인증", "naver 인증"),
@@ -1535,6 +1550,19 @@ def _primitive_payload_is_success(payload: object, *, primitive: str) -> bool:
1535
1550
  return True
1536
1551
 
1537
1552
 
1553
+ def _primitive_payload_result_dict(payload: object) -> dict[str, object] | None:
1554
+ """Extract the primitive result object from direct or wrapped payloads."""
1555
+ if not isinstance(payload, dict):
1556
+ return None
1557
+ result = payload.get("result")
1558
+ if not isinstance(result, dict):
1559
+ return None
1560
+ nested_result = result.get("result")
1561
+ if result.get("ok") is True and isinstance(nested_result, dict):
1562
+ return cast("dict[str, object]", nested_result)
1563
+ return cast("dict[str, object]", result)
1564
+
1565
+
1538
1566
  def _canonical_primitive_args(args: dict[str, object]) -> str:
1539
1567
  """Stable signature for comparing repeated primitive calls."""
1540
1568
  try:
@@ -1543,6 +1571,15 @@ def _canonical_primitive_args(args: dict[str, object]) -> str:
1543
1571
  return repr(sorted(args.items()))
1544
1572
 
1545
1573
 
1574
+ def _copy_primitive_args(args: dict[str, object]) -> dict[str, object]:
1575
+ """Copy primitive arguments while detaching the nested params dict."""
1576
+ copied = dict(args)
1577
+ params = copied.get("params")
1578
+ if isinstance(params, dict):
1579
+ copied["params"] = dict(params)
1580
+ return copied
1581
+
1582
+
1546
1583
  def _conversation_has_successful_identical_primitive_call( # noqa: C901
1547
1584
  llm_messages: list[Any],
1548
1585
  *,
@@ -1682,17 +1719,37 @@ def _latest_successful_primitive_result(
1682
1719
  llm_messages: list[Any],
1683
1720
  *,
1684
1721
  primitive: str,
1722
+ registry: Any = None,
1685
1723
  ) -> dict[str, object] | None:
1686
1724
  """Return the most recent successful primitive result payload."""
1725
+ if registry is not None:
1726
+ matching_call_ids = _primitive_call_ids_for_tool(
1727
+ llm_messages,
1728
+ primitive=primitive,
1729
+ registry=registry,
1730
+ )
1731
+ for msg in reversed(llm_messages):
1732
+ payload = _tool_result_payload_for_primitive_call(
1733
+ msg,
1734
+ primitive=primitive,
1735
+ matching_call_ids=matching_call_ids,
1736
+ )
1737
+ if payload is None or not _primitive_payload_is_success(
1738
+ payload,
1739
+ primitive=primitive,
1740
+ ):
1741
+ continue
1742
+ result = _primitive_payload_result_dict(payload)
1743
+ if result is not None:
1744
+ return result
1745
+
1687
1746
  for msg in reversed(llm_messages):
1688
1747
  payload = _tool_result_payload_for_primitive(msg, primitive=primitive)
1689
1748
  if payload is None or not _primitive_payload_is_success(payload, primitive=primitive):
1690
1749
  continue
1691
- if not isinstance(payload, dict):
1692
- continue
1693
- result = payload.get("result")
1694
- if isinstance(result, dict):
1695
- return cast("dict[str, object]", result)
1750
+ result = _primitive_payload_result_dict(payload)
1751
+ if result is not None:
1752
+ return result
1696
1753
  return None
1697
1754
 
1698
1755
 
@@ -1842,6 +1899,27 @@ def _nonempty_str(value: object) -> str | None:
1842
1899
  return stripped or None
1843
1900
 
1844
1901
 
1902
+ _KOREAN_SIDO_ABBREVIATIONS: dict[str, str] = {
1903
+ "서울": "서울특별시",
1904
+ "부산": "부산광역시",
1905
+ "대구": "대구광역시",
1906
+ "인천": "인천광역시",
1907
+ "광주": "광주광역시",
1908
+ "대전": "대전광역시",
1909
+ "울산": "울산광역시",
1910
+ "세종": "세종특별자치시",
1911
+ "경기": "경기도",
1912
+ "강원": "강원특별자치도",
1913
+ "충북": "충청북도",
1914
+ "충남": "충청남도",
1915
+ "전북": "전북특별자치도",
1916
+ "전남": "전라남도",
1917
+ "경북": "경상북도",
1918
+ "경남": "경상남도",
1919
+ "제주": "제주특별자치도",
1920
+ }
1921
+
1922
+
1845
1923
  def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1846
1924
  """Derive NMC Q0/Q1 from a structured Korean address string.
1847
1925
 
@@ -1853,7 +1931,7 @@ def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1853
1931
  parts = [part.strip() for part in text.split() if part.strip()]
1854
1932
  if len(parts) < 2:
1855
1933
  return None
1856
- q0 = parts[0]
1934
+ q0 = _KOREAN_SIDO_ABBREVIATIONS.get(parts[0], parts[0])
1857
1935
  if not q0.endswith(("특별시", "광역시", "특별자치시", "특별자치도", "도")):
1858
1936
  return None
1859
1937
  q1 = parts[1]
@@ -1920,10 +1998,132 @@ def _locate_result_coords(result: dict[str, object]) -> tuple[float, float] | No
1920
1998
  return None
1921
1999
 
1922
2000
 
2001
+ def _locate_result_adm_cd(result: dict[str, object]) -> str | None:
2002
+ """Extract a 10-digit administrative code from a locate result."""
2003
+ candidates: list[object] = [result.get("adm_cd"), result.get("region"), result]
2004
+ for candidate in candidates:
2005
+ if not isinstance(candidate, dict):
2006
+ continue
2007
+ for key in ("code", "adm_cd", "h_code", "b_code"):
2008
+ value = candidate.get(key)
2009
+ if isinstance(value, str) and len(value) == 10 and value.isdigit():
2010
+ return value
2011
+ return None
2012
+
2013
+
2014
+ _REVERSE_GEOCODE_TOOL_IDS = frozenset({"kakao_coord_to_region", "sgis_adm_cd_lookup"})
2015
+ _GENERIC_NMC_QN_FILTERS = frozenset(
2016
+ {
2017
+ "응급실",
2018
+ "야간응급실",
2019
+ "야간 응급실",
2020
+ "응급의료",
2021
+ "응급의료기관",
2022
+ "응급의료센터",
2023
+ "응급센터",
2024
+ "응급 병원",
2025
+ "emergency",
2026
+ "emergency room",
2027
+ "er",
2028
+ }
2029
+ )
2030
+
2031
+
2032
+ def _normalized_nmc_qn_filter(value: object) -> str | None:
2033
+ """Return a valid NMC institution-name filter or None for generic intent."""
2034
+ if not isinstance(value, str):
2035
+ return None
2036
+ stripped = value.strip()
2037
+ if not stripped:
2038
+ return None
2039
+ lowered = stripped.lower()
2040
+ if lowered in _GENERIC_NMC_QN_FILTERS:
2041
+ return None
2042
+ if "," in stripped or "," in stripped:
2043
+ return None
2044
+ return stripped
2045
+
2046
+
2047
+ def _nmc_lookup_params_with_clean_qn(
2048
+ args_obj: dict[str, object],
2049
+ ) -> tuple[object, dict[str, object]]:
2050
+ """Copy NMC params and remove generic emergency-intent QN filters."""
2051
+ raw_params = args_obj.get("params")
2052
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
2053
+ if "qn" in params:
2054
+ qn_filter = _normalized_nmc_qn_filter(params.get("qn"))
2055
+ if qn_filter is None:
2056
+ params.pop("qn", None)
2057
+ else:
2058
+ params["qn"] = qn_filter
2059
+ return raw_params, params
2060
+
2061
+
2062
+ def _is_whole_degree_pair(lat: object, lon: object) -> bool:
2063
+ """Return True for rounded whole-degree WGS-84 coordinate pairs."""
2064
+ if isinstance(lat, bool) or isinstance(lon, bool):
2065
+ return False
2066
+ if not isinstance(lat, int | float) or not isinstance(lon, int | float):
2067
+ return False
2068
+ return float(lat).is_integer() and float(lon).is_integer()
2069
+
2070
+
2071
+ def _normalize_reverse_geocode_args_from_prior_locate(
2072
+ fname: str,
2073
+ args_obj: dict[str, object],
2074
+ llm_messages: list[Any],
2075
+ *,
2076
+ registry: Any = None,
2077
+ ) -> dict[str, object]:
2078
+ """Copy exact locate coordinates into reverse-geocode retries.
2079
+
2080
+ KMA forecast adapters may accept coarse lat/lon because they convert to a
2081
+ grid internally, but reverse-geocode adapters resolve an exact coordinate
2082
+ back to an administrative region. When the model rounded a coordinate that
2083
+ was already available in the prior locate result, keep the selected adapter
2084
+ and repair only this derived argument pair.
2085
+ """
2086
+ if fname != "locate" or args_obj.get("tool_id") not in _REVERSE_GEOCODE_TOOL_IDS:
2087
+ return args_obj
2088
+
2089
+ raw_params = args_obj.get("params")
2090
+ if not isinstance(raw_params, dict):
2091
+ return args_obj
2092
+
2093
+ if not _is_whole_degree_pair(raw_params.get("lat"), raw_params.get("lon")):
2094
+ return args_obj
2095
+
2096
+ locate_result = _latest_successful_primitive_result(
2097
+ llm_messages,
2098
+ primitive="locate",
2099
+ registry=registry,
2100
+ )
2101
+ if locate_result is None:
2102
+ return args_obj
2103
+
2104
+ coords = _locate_result_coords(locate_result)
2105
+ if coords is None:
2106
+ return args_obj
2107
+
2108
+ next_params = dict(raw_params)
2109
+ next_params["lat"], next_params["lon"] = coords
2110
+ normalized = dict(args_obj)
2111
+ normalized["params"] = next_params
2112
+ logger.info(
2113
+ "locate: normalized %s rounded lat/lon from prior locate lat=%s lon=%s",
2114
+ args_obj.get("tool_id"),
2115
+ coords[0],
2116
+ coords[1],
2117
+ )
2118
+ return normalized
2119
+
2120
+
1923
2121
  def _normalize_nmc_lookup_args_from_prior_locate(
1924
2122
  fname: str,
1925
2123
  args_obj: dict[str, object],
1926
2124
  llm_messages: list[Any],
2125
+ *,
2126
+ registry: Any = None,
1927
2127
  ) -> dict[str, object]:
1928
2128
  """Fill NMC region-mode params from the latest successful locate result.
1929
2129
 
@@ -1936,8 +2136,7 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1936
2136
  if fname != "find" or args_obj.get("tool_id") != "nmc_emergency_search":
1937
2137
  return args_obj
1938
2138
 
1939
- raw_params = args_obj.get("params")
1940
- params = dict(raw_params) if isinstance(raw_params, dict) else {}
2139
+ raw_params, params = _nmc_lookup_params_with_clean_qn(args_obj)
1941
2140
  limit = params.get("limit")
1942
2141
  needs_default_limit = not isinstance(limit, int) or isinstance(limit, bool)
1943
2142
 
@@ -1947,9 +2146,17 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1947
2146
  and bool(_nonempty_str(params.get("q1")))
1948
2147
  )
1949
2148
  if has_region_params and not needs_default_limit:
2149
+ if params != raw_params and isinstance(raw_params, dict):
2150
+ normalized = dict(args_obj)
2151
+ normalized["params"] = params
2152
+ return normalized
1950
2153
  return args_obj
1951
2154
 
1952
- locate_result = _latest_successful_primitive_result(llm_messages, primitive="locate")
2155
+ locate_result = _latest_successful_primitive_result(
2156
+ llm_messages,
2157
+ primitive="locate",
2158
+ registry=registry,
2159
+ )
1953
2160
  if locate_result is None:
1954
2161
  if has_region_params and needs_default_limit:
1955
2162
  normalized = dict(args_obj)
@@ -1959,8 +2166,39 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1959
2166
  return normalized
1960
2167
  return args_obj
1961
2168
 
2169
+ return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2170
+
2171
+
2172
+ def _normalize_nmc_lookup_args_from_locate_result(
2173
+ args_obj: dict[str, object],
2174
+ locate_result: dict[str, object],
2175
+ ) -> dict[str, object]:
2176
+ """Fill NMC region-mode params from an already observed locate result."""
2177
+
2178
+ raw_params, params = _nmc_lookup_params_with_clean_qn(args_obj)
2179
+ limit = params.get("limit")
2180
+ needs_default_limit = not isinstance(limit, int) or isinstance(limit, bool)
2181
+
2182
+ has_region_params = (
2183
+ params.get("mode") == "region"
2184
+ and bool(_nonempty_str(params.get("q0")))
2185
+ and bool(_nonempty_str(params.get("q1")))
2186
+ )
2187
+ if has_region_params and not needs_default_limit:
2188
+ if params != raw_params and isinstance(raw_params, dict):
2189
+ normalized = dict(args_obj)
2190
+ normalized["params"] = params
2191
+ return normalized
2192
+ return args_obj
2193
+
1962
2194
  region_pair = _locate_result_region_pair(locate_result)
1963
2195
  if region_pair is None:
2196
+ if has_region_params and needs_default_limit:
2197
+ normalized = dict(args_obj)
2198
+ next_params = dict(params)
2199
+ next_params["limit"] = 5
2200
+ normalized["params"] = next_params
2201
+ return normalized
1964
2202
  return args_obj
1965
2203
  origin_coords = _locate_result_coords(locate_result)
1966
2204
 
@@ -1988,6 +2226,182 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1988
2226
  return normalized
1989
2227
 
1990
2228
 
2229
+ _HIRA_DEPARTMENT_HINTS: tuple[tuple[re.Pattern[str], str], ...] = (
2230
+ (re.compile(r"소아청소년과|소아과|pediatrics?", re.IGNORECASE), "소아청소년과"),
2231
+ (re.compile(r"이비인후과|ent\b", re.IGNORECASE), "이비인후과"),
2232
+ (re.compile(r"내과|internal medicine", re.IGNORECASE), "내과"),
2233
+ (re.compile(r"피부과|dermatology", re.IGNORECASE), "피부과"),
2234
+ (re.compile(r"정형외과|orthopedics?", re.IGNORECASE), "정형외과"),
2235
+ (re.compile(r"산부인과|obgyn|ob/gyn", re.IGNORECASE), "산부인과"),
2236
+ (re.compile(r"안과|ophthalmology", re.IGNORECASE), "안과"),
2237
+ )
2238
+
2239
+
2240
+ def _hira_department_from_query(user_query: str) -> str | None:
2241
+ for pattern, value in _HIRA_DEPARTMENT_HINTS:
2242
+ if pattern.search(user_query):
2243
+ return value
2244
+ return None
2245
+
2246
+
2247
+ def _normalize_hira_lookup_args_from_prior_locate(
2248
+ fname: str,
2249
+ args_obj: dict[str, object],
2250
+ llm_messages: list[Any],
2251
+ user_query: str,
2252
+ *,
2253
+ registry: Any = None,
2254
+ ) -> dict[str, object]:
2255
+ """Fill HIRA coordinate-radius params from the latest successful locate result."""
2256
+ if fname != "find" or args_obj.get("tool_id") != "hira_hospital_search":
2257
+ return args_obj
2258
+ locate_result = _latest_successful_primitive_result(
2259
+ llm_messages,
2260
+ primitive="locate",
2261
+ registry=registry,
2262
+ )
2263
+ if locate_result is None:
2264
+ return args_obj
2265
+ return _normalize_hira_lookup_args_from_locate_result(args_obj, locate_result, user_query)
2266
+
2267
+
2268
+ def _normalize_hira_lookup_args_from_locate_result(
2269
+ args_obj: dict[str, object],
2270
+ locate_result: dict[str, object],
2271
+ user_query: str,
2272
+ ) -> dict[str, object]:
2273
+ """Fill HIRA coordinate-radius params from an already observed locate result."""
2274
+
2275
+ raw_params = args_obj.get("params")
2276
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
2277
+ valid_coords = (
2278
+ isinstance(params.get("xPos"), int | float)
2279
+ and isinstance(params.get("yPos"), int | float)
2280
+ and not _is_whole_degree_pair(params.get("yPos"), params.get("xPos"))
2281
+ )
2282
+ valid_radius = (
2283
+ isinstance(params.get("radius"), int)
2284
+ and not isinstance(params.get("radius"), bool)
2285
+ and 1 <= int(params["radius"]) <= 10000
2286
+ )
2287
+
2288
+ changed = not isinstance(raw_params, dict)
2289
+ if not valid_coords:
2290
+ coords = _locate_result_coords(locate_result)
2291
+ if coords is None:
2292
+ return args_obj
2293
+ lat, lon = coords
2294
+ params["xPos"] = lon
2295
+ params["yPos"] = lat
2296
+ changed = True
2297
+
2298
+ if not valid_radius:
2299
+ params["radius"] = 2000
2300
+ changed = True
2301
+
2302
+ if params.get("dgsbjt") in (None, ""):
2303
+ department = _hira_department_from_query(user_query)
2304
+ if department is not None:
2305
+ params["dgsbjt"] = department
2306
+ changed = True
2307
+
2308
+ if not changed:
2309
+ return args_obj
2310
+ normalized = dict(args_obj)
2311
+ normalized["params"] = params
2312
+ logger.info(
2313
+ "find: normalized hira_hospital_search params from prior locate "
2314
+ "xPos=%s yPos=%s radius=%s dgsbjt=%s",
2315
+ params.get("xPos"),
2316
+ params.get("yPos"),
2317
+ params.get("radius"),
2318
+ params.get("dgsbjt"),
2319
+ )
2320
+ return normalized
2321
+
2322
+
2323
+ def _normalize_koroad_lookup_args_from_prior_locate(
2324
+ fname: str,
2325
+ args_obj: dict[str, object],
2326
+ llm_messages: list[Any],
2327
+ *,
2328
+ registry: Any = None,
2329
+ ) -> dict[str, object]:
2330
+ """Fill KOROAD adm_cd/year params from the latest successful locate result."""
2331
+ if fname != "find" or args_obj.get("tool_id") != "koroad_accident_hazard_search":
2332
+ return args_obj
2333
+ locate_result = _latest_successful_primitive_result(
2334
+ llm_messages,
2335
+ primitive="locate",
2336
+ registry=registry,
2337
+ )
2338
+ if locate_result is None:
2339
+ return args_obj
2340
+ return _normalize_koroad_lookup_args_from_locate_result(args_obj, locate_result)
2341
+
2342
+
2343
+ def _normalize_koroad_lookup_args_from_locate_result(
2344
+ args_obj: dict[str, object],
2345
+ locate_result: dict[str, object],
2346
+ ) -> dict[str, object]:
2347
+ """Fill KOROAD adm_cd/year params from an already observed locate result."""
2348
+
2349
+ raw_params = args_obj.get("params")
2350
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
2351
+ changed = not isinstance(raw_params, dict)
2352
+
2353
+ adm_cd = params.get("adm_cd")
2354
+ if not (isinstance(adm_cd, str) and len(adm_cd) == 10 and adm_cd.isdigit()):
2355
+ adm_cd_from_locate = _locate_result_adm_cd(locate_result)
2356
+ if adm_cd_from_locate is None:
2357
+ return args_obj
2358
+ params["adm_cd"] = adm_cd_from_locate
2359
+ changed = True
2360
+
2361
+ year = params.get("year")
2362
+ if not isinstance(year, int) or isinstance(year, bool):
2363
+ # Latest KOROAD frequentzoneLg dataset code currently maps to 2024 in
2364
+ # accident_hazard_search._YEAR_TO_SEARCH_CD. Keep the adapter contract
2365
+ # explicit instead of asking the model to guess a dataset vintage.
2366
+ params["year"] = 2024
2367
+ changed = True
2368
+
2369
+ if not changed:
2370
+ return args_obj
2371
+ normalized = dict(args_obj)
2372
+ normalized["params"] = params
2373
+ logger.info(
2374
+ "find: normalized koroad_accident_hazard_search params from prior locate adm_cd=%s year=%s",
2375
+ params.get("adm_cd"),
2376
+ params.get("year"),
2377
+ )
2378
+ return normalized
2379
+
2380
+
2381
+ def _normalize_lookup_args_from_cached_locate_result(
2382
+ fname: str,
2383
+ args_obj: dict[str, object],
2384
+ locate_result: dict[str, object] | None,
2385
+ *,
2386
+ user_query: str = "",
2387
+ ) -> dict[str, object]:
2388
+ """Apply locate-derived argument repair in inbound concrete tool dispatch."""
2389
+ if fname != "find" or locate_result is None:
2390
+ return args_obj
2391
+ tool_id = args_obj.get("tool_id")
2392
+ if tool_id == "nmc_emergency_search":
2393
+ return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2394
+ if tool_id == "hira_hospital_search":
2395
+ return _normalize_hira_lookup_args_from_locate_result(
2396
+ args_obj,
2397
+ locate_result,
2398
+ user_query,
2399
+ )
2400
+ if tool_id == "koroad_accident_hazard_search":
2401
+ return _normalize_koroad_lookup_args_from_locate_result(args_obj, locate_result)
2402
+ return args_obj
2403
+
2404
+
1991
2405
  def _check_sensitive_lookup_terminated_without_lookup(
1992
2406
  llm_messages: list[Any],
1993
2407
  user_query: str,
@@ -3018,6 +3432,39 @@ def _maybe_reroute_locate_admin_keyword_args(
3018
3432
  return {**args_obj, "tool_id": "kakao_address_search", "params": next_params}
3019
3433
 
3020
3434
 
3435
+ _POI_ADDRESS_QUERY_RE: Final = re.compile(
3436
+ r"(역|터미널|공항|캠퍼스|대학교|대학|해수욕장|시장|공원|병원|의원|약국|랜드마크)"
3437
+ )
3438
+ _ROAD_ADDRESS_QUERY_RE: Final = re.compile(r"(?:로|길)\s*\d{0,4}(?:번길)?")
3439
+
3440
+
3441
+ def _maybe_reroute_locate_poi_address_args(
3442
+ fname: str,
3443
+ args_obj: dict[str, Any],
3444
+ ) -> dict[str, Any]:
3445
+ """Rewrite named-place Kakao address calls to the documented keyword adapter."""
3446
+
3447
+ if fname != "locate" or args_obj.get("tool_id") != "kakao_address_search":
3448
+ return args_obj
3449
+ params = args_obj.get("params")
3450
+ if not isinstance(params, dict):
3451
+ return args_obj
3452
+ query = params.get("query")
3453
+ if not isinstance(query, str):
3454
+ return args_obj
3455
+ if not _POI_ADDRESS_QUERY_RE.search(query):
3456
+ return args_obj
3457
+ if _ROAD_ADDRESS_QUERY_RE.search(query):
3458
+ return args_obj
3459
+
3460
+ next_params = {**params, "query": query.strip()}
3461
+ logger.info(
3462
+ "locate: rerouted Kakao address POI query to keyword search: %r",
3463
+ query,
3464
+ )
3465
+ return {**args_obj, "tool_id": "kakao_keyword_search", "params": next_params}
3466
+
3467
+
3021
3468
  def _effective_chat_max_tokens(requested: int) -> int:
3022
3469
  """Clamp interactive chat completions so bad tool-routing loops fail fast."""
3023
3470
  raw = os.getenv("UMMAYA_CHAT_MAX_TOKENS")
@@ -3345,7 +3792,8 @@ def _check_chain_prerequisite( # noqa: C901
3345
3792
  "nmc_emergency_search({mode:'region', q0:region.region_1depth_name, "
3346
3793
  "q1:region.region_2depth_name, origin_lat:coords.lat, "
3347
3794
  "origin_lon:coords.lon, limit:<N>}). Do NOT retry coordinate mode for "
3348
- "station/neighborhood ER search and do NOT invent NMC filters such as QZ."
3795
+ "station/neighborhood ER search. Leave qn unset unless the citizen "
3796
+ "explicitly gave a specific institution name."
3349
3797
  )
3350
3798
 
3351
3799
  # Require a successful prior locate for this context. If resolve
@@ -3388,8 +3836,8 @@ def _check_chain_prerequisite( # noqa: C901
3388
3836
  "then kakao_coord_to_region({lat:<lat>, lon:<lon>}), "
3389
3837
  "then call nmc_emergency_search({mode:'region', q0:region.region_1depth_name, "
3390
3838
  "q1:region.region_2depth_name, origin_lat:coords.lat, origin_lon:coords.lon, "
3391
- "limit:<N>}). Do NOT guess coordinates or set NMC filters such as QZ unless "
3392
- "the citizen explicitly supplied them."
3839
+ "limit:<N>}). Do NOT guess coordinates. Leave qn unset unless the citizen "
3840
+ "explicitly gave a specific institution name."
3393
3841
  )
3394
3842
  if has_coord or schema_coord_fields:
3395
3843
  field_kind = "coordinates"
@@ -4105,6 +4553,7 @@ async def run( # noqa: C901
4105
4553
  # LLM to reconstruct auth_context from a prior tool result.
4106
4554
  _session_auth_contexts: dict[str, object] = {}
4107
4555
  _session_auth_session_ids: dict[str, str] = {}
4556
+ _session_latest_locate_results: dict[str, dict[str, object]] = {}
4108
4557
 
4109
4558
  # Epic #2077 T010 — single ToolRegistry + ToolExecutor instance pair
4110
4559
  # reused across every chat_request. Adapter registration happens lazily
@@ -4197,14 +4646,12 @@ async def run( # noqa: C901
4197
4646
  if _llm_system_prompt_cached[0] is not None:
4198
4647
  return _llm_system_prompt_cached[0] or None
4199
4648
  try:
4200
- from pathlib import Path # noqa: PLC0415
4201
-
4202
- from ummaya.context.prompt_loader import PromptLoader # noqa: PLC0415
4649
+ from ummaya.context.prompt_loader import ( # noqa: PLC0415
4650
+ PromptLoader,
4651
+ default_manifest_path,
4652
+ )
4203
4653
 
4204
- # Default manifest lives at repo-root/prompts/manifest.yaml. The
4205
- # stdio backend runs from repo root when invoked via
4206
- # `uv run ummaya --ipc stdio`, so resolve relative to CWD.
4207
- manifest = Path("prompts") / "manifest.yaml"
4654
+ manifest = default_manifest_path()
4208
4655
  if not manifest.is_file():
4209
4656
  _llm_system_prompt_cached[0] = ""
4210
4657
  return None
@@ -4348,7 +4795,7 @@ async def run( # noqa: C901
4348
4795
  _AVAILABLE_ADAPTERS_TOP_K = int( # noqa: N806 — env-derived constant
4349
4796
  _os_chat_env.environ.get("UMMAYA_AVAILABLE_ADAPTERS_TOP_K", "5")
4350
4797
  )
4351
- _root_primitive_tool_ids = frozenset({"locate", "find", "check", "send"})
4798
+ _root_primitive_tool_ids = _ROOT_PRIMITIVE_TOOL_IDS
4352
4799
 
4353
4800
  def _select_concrete_adapter_tools_for_turn(user_query: str) -> list[Any]:
4354
4801
  """Return concrete, non-core adapter tools for this citizen turn.
@@ -5361,6 +5808,11 @@ async def run( # noqa: C901
5361
5808
  "tool_id": str(args_obj.get("tool_id", fname)),
5362
5809
  }
5363
5810
 
5811
+ if fname == "locate" and dispatch_error is None:
5812
+ locate_result = result_payload.get("result")
5813
+ if isinstance(locate_result, dict) and locate_result.get("kind") != "error":
5814
+ _session_latest_locate_results[session_id] = locate_result
5815
+
5364
5816
  # Drain the outbound HTTP trace buffer + attach to the envelope.
5365
5817
  outbound_traces = consume_outbound_capture(_outbound_trace_token)
5366
5818
  if outbound_traces:
@@ -5517,9 +5969,14 @@ async def run( # noqa: C901
5517
5969
  llm_tools: list[LLMToolDefinition] = [
5518
5970
  LLMToolDefinition.model_validate(raw) for raw in backend_tools_raw
5519
5971
  ]
5972
+ has_concrete_backend_tools = bool(backend_tools_raw)
5520
5973
  for t in frame.tools:
5521
5974
  tui_name = getattr(getattr(t, "function", None), "name", None)
5522
- if tui_name and tui_name in backend_tool_names:
5975
+ if not _should_append_tui_tool_to_llm_tools(
5976
+ tui_name,
5977
+ backend_tool_names,
5978
+ has_concrete_backend_tools=has_concrete_backend_tools,
5979
+ ):
5523
5980
  continue
5524
5981
  llm_tools.append(LLMToolDefinition.model_validate(t.model_dump()))
5525
5982
 
@@ -5605,14 +6062,14 @@ async def run( # noqa: C901
5605
6062
  "base_time 추측 금지 — 위 hint 또는 그 이전 정시 사용.\n"
5606
6063
  )
5607
6064
 
5608
- # Spec 2521 (2026-05-01)BM25 adapter discovery is a backend
5609
- # function, NOT an LLM-callable tool. Run the search against the
5610
- # latest citizen utterance and inject the top-K candidates into
5611
- # the dynamic suffix as ``<available_adapters>``. The LLM picks
5612
- # a tool_id from this block and calls ``find({tool_id, params})``
5613
- # search-mode calls were the source of the "● find(search:)"
5614
- # phantom tool-UI noise that user surfaced via Layer 5 frame
5615
- # capture (specs/2521 frames/raw.cast frame_0160 onwards).
6065
+ # 2026 tool-surface migration — adapter discovery is a backend
6066
+ # function, NOT an LLM-callable mode. Run the search against the
6067
+ # latest citizen utterance and inject top-K candidates into the
6068
+ # dynamic suffix as ``<available_adapters>``. The normal path is a
6069
+ # concrete adapter function call; the root primitive wrappers stay
6070
+ # available only for legacy transcript compatibility. Search-mode
6071
+ # calls were the source of the "● find(search:)" phantom tool-UI
6072
+ # noise that user surfaced via Layer 5 frame capture.
5616
6073
  try:
5617
6074
  for m in reversed(frame.messages):
5618
6075
  if m.role == "user" and m.content:
@@ -6417,7 +6874,35 @@ async def run( # noqa: C901
6417
6874
  fname = primitive_name
6418
6875
  args_obj = {"tool_id": model_tool_name, "params": dict(model_args_obj)}
6419
6876
 
6877
+ raw_dispatch_args_obj = _copy_primitive_args(args_obj)
6878
+
6420
6879
  args_obj = _maybe_reroute_locate_admin_keyword_args(fname, args_obj)
6880
+ args_obj = _maybe_reroute_locate_poi_address_args(fname, args_obj)
6881
+ args_obj = _normalize_reverse_geocode_args_from_prior_locate(
6882
+ fname,
6883
+ args_obj,
6884
+ llm_messages,
6885
+ registry=registry,
6886
+ )
6887
+ args_obj = _normalize_nmc_lookup_args_from_prior_locate(
6888
+ fname,
6889
+ args_obj,
6890
+ llm_messages,
6891
+ registry=registry,
6892
+ )
6893
+ args_obj = _normalize_hira_lookup_args_from_prior_locate(
6894
+ fname,
6895
+ args_obj,
6896
+ llm_messages,
6897
+ latest_user_utt,
6898
+ registry=registry,
6899
+ )
6900
+ args_obj = _normalize_koroad_lookup_args_from_prior_locate(
6901
+ fname,
6902
+ args_obj,
6903
+ llm_messages,
6904
+ registry=registry,
6905
+ )
6421
6906
  args_obj = _normalize_lookup_args_for_query(fname, args_obj, latest_user_utt)
6422
6907
  args_obj = _normalize_verify_args_for_query(fname, args_obj, latest_user_utt)
6423
6908
  args_obj = _normalize_verify_tool_id_for_query(fname, args_obj, latest_user_utt)
@@ -6551,7 +7036,16 @@ async def run( # noqa: C901
6551
7036
  )
6552
7037
  continue
6553
7038
 
6554
- if _conversation_has_successful_identical_primitive_call(
7039
+ raw_duplicate = _canonical_primitive_args(
7040
+ raw_dispatch_args_obj
7041
+ ) != _canonical_primitive_args(
7042
+ args_obj
7043
+ ) and _conversation_has_successful_identical_primitive_call(
7044
+ llm_messages,
7045
+ primitive=fname,
7046
+ args=raw_dispatch_args_obj,
7047
+ )
7048
+ if raw_duplicate or _conversation_has_successful_identical_primitive_call(
6555
7049
  llm_messages,
6556
7050
  primitive=fname,
6557
7051
  args=args_obj,
@@ -7293,6 +7787,12 @@ async def run( # noqa: C901
7293
7787
  dispatch_name = primitive_name
7294
7788
  dispatch_args = {"tool_id": frame.name, "params": dict(args_obj)}
7295
7789
 
7790
+ dispatch_args = _normalize_lookup_args_from_cached_locate_result(
7791
+ dispatch_name,
7792
+ dispatch_args,
7793
+ _session_latest_locate_results.get(frame.session_id),
7794
+ )
7795
+
7296
7796
  await _dispatch_primitive(
7297
7797
  frame.call_id,
7298
7798
  dispatch_name,