ummaya 0.2.1 → 0.2.3

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,21 @@ _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
+ _ = has_concrete_backend_tools
203
+ return True
204
+
205
+
190
206
  _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]], ...]] = (
191
207
  (
192
208
  ("간편인증", "pass 인증", "kakao 인증", "naver 인증"),
@@ -1535,6 +1551,19 @@ def _primitive_payload_is_success(payload: object, *, primitive: str) -> bool:
1535
1551
  return True
1536
1552
 
1537
1553
 
1554
+ def _primitive_payload_result_dict(payload: object) -> dict[str, object] | None:
1555
+ """Extract the primitive result object from direct or wrapped payloads."""
1556
+ if not isinstance(payload, dict):
1557
+ return None
1558
+ result = payload.get("result")
1559
+ if not isinstance(result, dict):
1560
+ return None
1561
+ nested_result = result.get("result")
1562
+ if result.get("ok") is True and isinstance(nested_result, dict):
1563
+ return cast("dict[str, object]", nested_result)
1564
+ return cast("dict[str, object]", result)
1565
+
1566
+
1538
1567
  def _canonical_primitive_args(args: dict[str, object]) -> str:
1539
1568
  """Stable signature for comparing repeated primitive calls."""
1540
1569
  try:
@@ -1543,6 +1572,15 @@ def _canonical_primitive_args(args: dict[str, object]) -> str:
1543
1572
  return repr(sorted(args.items()))
1544
1573
 
1545
1574
 
1575
+ def _copy_primitive_args(args: dict[str, object]) -> dict[str, object]:
1576
+ """Copy primitive arguments while detaching the nested params dict."""
1577
+ copied = dict(args)
1578
+ params = copied.get("params")
1579
+ if isinstance(params, dict):
1580
+ copied["params"] = dict(params)
1581
+ return copied
1582
+
1583
+
1546
1584
  def _conversation_has_successful_identical_primitive_call( # noqa: C901
1547
1585
  llm_messages: list[Any],
1548
1586
  *,
@@ -1682,17 +1720,37 @@ def _latest_successful_primitive_result(
1682
1720
  llm_messages: list[Any],
1683
1721
  *,
1684
1722
  primitive: str,
1723
+ registry: Any = None,
1685
1724
  ) -> dict[str, object] | None:
1686
1725
  """Return the most recent successful primitive result payload."""
1726
+ if registry is not None:
1727
+ matching_call_ids = _primitive_call_ids_for_tool(
1728
+ llm_messages,
1729
+ primitive=primitive,
1730
+ registry=registry,
1731
+ )
1732
+ for msg in reversed(llm_messages):
1733
+ payload = _tool_result_payload_for_primitive_call(
1734
+ msg,
1735
+ primitive=primitive,
1736
+ matching_call_ids=matching_call_ids,
1737
+ )
1738
+ if payload is None or not _primitive_payload_is_success(
1739
+ payload,
1740
+ primitive=primitive,
1741
+ ):
1742
+ continue
1743
+ result = _primitive_payload_result_dict(payload)
1744
+ if result is not None:
1745
+ return result
1746
+
1687
1747
  for msg in reversed(llm_messages):
1688
1748
  payload = _tool_result_payload_for_primitive(msg, primitive=primitive)
1689
1749
  if payload is None or not _primitive_payload_is_success(payload, primitive=primitive):
1690
1750
  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)
1751
+ result = _primitive_payload_result_dict(payload)
1752
+ if result is not None:
1753
+ return result
1696
1754
  return None
1697
1755
 
1698
1756
 
@@ -1842,6 +1900,27 @@ def _nonempty_str(value: object) -> str | None:
1842
1900
  return stripped or None
1843
1901
 
1844
1902
 
1903
+ _KOREAN_SIDO_ABBREVIATIONS: dict[str, str] = {
1904
+ "서울": "서울특별시",
1905
+ "부산": "부산광역시",
1906
+ "대구": "대구광역시",
1907
+ "인천": "인천광역시",
1908
+ "광주": "광주광역시",
1909
+ "대전": "대전광역시",
1910
+ "울산": "울산광역시",
1911
+ "세종": "세종특별자치시",
1912
+ "경기": "경기도",
1913
+ "강원": "강원특별자치도",
1914
+ "충북": "충청북도",
1915
+ "충남": "충청남도",
1916
+ "전북": "전북특별자치도",
1917
+ "전남": "전라남도",
1918
+ "경북": "경상북도",
1919
+ "경남": "경상남도",
1920
+ "제주": "제주특별자치도",
1921
+ }
1922
+
1923
+
1845
1924
  def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1846
1925
  """Derive NMC Q0/Q1 from a structured Korean address string.
1847
1926
 
@@ -1853,7 +1932,7 @@ def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1853
1932
  parts = [part.strip() for part in text.split() if part.strip()]
1854
1933
  if len(parts) < 2:
1855
1934
  return None
1856
- q0 = parts[0]
1935
+ q0 = _KOREAN_SIDO_ABBREVIATIONS.get(parts[0], parts[0])
1857
1936
  if not q0.endswith(("특별시", "광역시", "특별자치시", "특별자치도", "도")):
1858
1937
  return None
1859
1938
  q1 = parts[1]
@@ -1920,10 +1999,132 @@ def _locate_result_coords(result: dict[str, object]) -> tuple[float, float] | No
1920
1999
  return None
1921
2000
 
1922
2001
 
2002
+ def _locate_result_adm_cd(result: dict[str, object]) -> str | None:
2003
+ """Extract a 10-digit administrative code from a locate result."""
2004
+ candidates: list[object] = [result.get("adm_cd"), result.get("region"), result]
2005
+ for candidate in candidates:
2006
+ if not isinstance(candidate, dict):
2007
+ continue
2008
+ for key in ("code", "adm_cd", "h_code", "b_code"):
2009
+ value = candidate.get(key)
2010
+ if isinstance(value, str) and len(value) == 10 and value.isdigit():
2011
+ return value
2012
+ return None
2013
+
2014
+
2015
+ _REVERSE_GEOCODE_TOOL_IDS = frozenset({"kakao_coord_to_region", "sgis_adm_cd_lookup"})
2016
+ _GENERIC_NMC_QN_FILTERS = frozenset(
2017
+ {
2018
+ "응급실",
2019
+ "야간응급실",
2020
+ "야간 응급실",
2021
+ "응급의료",
2022
+ "응급의료기관",
2023
+ "응급의료센터",
2024
+ "응급센터",
2025
+ "응급 병원",
2026
+ "emergency",
2027
+ "emergency room",
2028
+ "er",
2029
+ }
2030
+ )
2031
+
2032
+
2033
+ def _normalized_nmc_qn_filter(value: object) -> str | None:
2034
+ """Return a valid NMC institution-name filter or None for generic intent."""
2035
+ if not isinstance(value, str):
2036
+ return None
2037
+ stripped = value.strip()
2038
+ if not stripped:
2039
+ return None
2040
+ lowered = stripped.lower()
2041
+ if lowered in _GENERIC_NMC_QN_FILTERS:
2042
+ return None
2043
+ if "," in stripped or "," in stripped:
2044
+ return None
2045
+ return stripped
2046
+
2047
+
2048
+ def _nmc_lookup_params_with_clean_qn(
2049
+ args_obj: dict[str, object],
2050
+ ) -> tuple[object, dict[str, object]]:
2051
+ """Copy NMC params and remove generic emergency-intent QN filters."""
2052
+ raw_params = args_obj.get("params")
2053
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
2054
+ if "qn" in params:
2055
+ qn_filter = _normalized_nmc_qn_filter(params.get("qn"))
2056
+ if qn_filter is None:
2057
+ params.pop("qn", None)
2058
+ else:
2059
+ params["qn"] = qn_filter
2060
+ return raw_params, params
2061
+
2062
+
2063
+ def _is_whole_degree_pair(lat: object, lon: object) -> bool:
2064
+ """Return True for rounded whole-degree WGS-84 coordinate pairs."""
2065
+ if isinstance(lat, bool) or isinstance(lon, bool):
2066
+ return False
2067
+ if not isinstance(lat, int | float) or not isinstance(lon, int | float):
2068
+ return False
2069
+ return float(lat).is_integer() and float(lon).is_integer()
2070
+
2071
+
2072
+ def _normalize_reverse_geocode_args_from_prior_locate(
2073
+ fname: str,
2074
+ args_obj: dict[str, object],
2075
+ llm_messages: list[Any],
2076
+ *,
2077
+ registry: Any = None,
2078
+ ) -> dict[str, object]:
2079
+ """Copy exact locate coordinates into reverse-geocode retries.
2080
+
2081
+ KMA forecast adapters may accept coarse lat/lon because they convert to a
2082
+ grid internally, but reverse-geocode adapters resolve an exact coordinate
2083
+ back to an administrative region. When the model rounded a coordinate that
2084
+ was already available in the prior locate result, keep the selected adapter
2085
+ and repair only this derived argument pair.
2086
+ """
2087
+ if fname != "locate" or args_obj.get("tool_id") not in _REVERSE_GEOCODE_TOOL_IDS:
2088
+ return args_obj
2089
+
2090
+ raw_params = args_obj.get("params")
2091
+ if not isinstance(raw_params, dict):
2092
+ return args_obj
2093
+
2094
+ if not _is_whole_degree_pair(raw_params.get("lat"), raw_params.get("lon")):
2095
+ return args_obj
2096
+
2097
+ locate_result = _latest_successful_primitive_result(
2098
+ llm_messages,
2099
+ primitive="locate",
2100
+ registry=registry,
2101
+ )
2102
+ if locate_result is None:
2103
+ return args_obj
2104
+
2105
+ coords = _locate_result_coords(locate_result)
2106
+ if coords is None:
2107
+ return args_obj
2108
+
2109
+ next_params = dict(raw_params)
2110
+ next_params["lat"], next_params["lon"] = coords
2111
+ normalized = dict(args_obj)
2112
+ normalized["params"] = next_params
2113
+ logger.info(
2114
+ "locate: normalized %s rounded lat/lon from prior locate lat=%s lon=%s",
2115
+ args_obj.get("tool_id"),
2116
+ coords[0],
2117
+ coords[1],
2118
+ )
2119
+ return normalized
2120
+
2121
+
1923
2122
  def _normalize_nmc_lookup_args_from_prior_locate(
1924
2123
  fname: str,
1925
2124
  args_obj: dict[str, object],
1926
2125
  llm_messages: list[Any],
2126
+ *,
2127
+ registry: Any = None,
1927
2128
  ) -> dict[str, object]:
1928
2129
  """Fill NMC region-mode params from the latest successful locate result.
1929
2130
 
@@ -1936,8 +2137,7 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1936
2137
  if fname != "find" or args_obj.get("tool_id") != "nmc_emergency_search":
1937
2138
  return args_obj
1938
2139
 
1939
- raw_params = args_obj.get("params")
1940
- params = dict(raw_params) if isinstance(raw_params, dict) else {}
2140
+ raw_params, params = _nmc_lookup_params_with_clean_qn(args_obj)
1941
2141
  limit = params.get("limit")
1942
2142
  needs_default_limit = not isinstance(limit, int) or isinstance(limit, bool)
1943
2143
 
@@ -1947,9 +2147,17 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1947
2147
  and bool(_nonempty_str(params.get("q1")))
1948
2148
  )
1949
2149
  if has_region_params and not needs_default_limit:
2150
+ if params != raw_params and isinstance(raw_params, dict):
2151
+ normalized = dict(args_obj)
2152
+ normalized["params"] = params
2153
+ return normalized
1950
2154
  return args_obj
1951
2155
 
1952
- locate_result = _latest_successful_primitive_result(llm_messages, primitive="locate")
2156
+ locate_result = _latest_successful_primitive_result(
2157
+ llm_messages,
2158
+ primitive="locate",
2159
+ registry=registry,
2160
+ )
1953
2161
  if locate_result is None:
1954
2162
  if has_region_params and needs_default_limit:
1955
2163
  normalized = dict(args_obj)
@@ -1959,8 +2167,39 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1959
2167
  return normalized
1960
2168
  return args_obj
1961
2169
 
2170
+ return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2171
+
2172
+
2173
+ def _normalize_nmc_lookup_args_from_locate_result(
2174
+ args_obj: dict[str, object],
2175
+ locate_result: dict[str, object],
2176
+ ) -> dict[str, object]:
2177
+ """Fill NMC region-mode params from an already observed locate result."""
2178
+
2179
+ raw_params, params = _nmc_lookup_params_with_clean_qn(args_obj)
2180
+ limit = params.get("limit")
2181
+ needs_default_limit = not isinstance(limit, int) or isinstance(limit, bool)
2182
+
2183
+ has_region_params = (
2184
+ params.get("mode") == "region"
2185
+ and bool(_nonempty_str(params.get("q0")))
2186
+ and bool(_nonempty_str(params.get("q1")))
2187
+ )
2188
+ if has_region_params and not needs_default_limit:
2189
+ if params != raw_params and isinstance(raw_params, dict):
2190
+ normalized = dict(args_obj)
2191
+ normalized["params"] = params
2192
+ return normalized
2193
+ return args_obj
2194
+
1962
2195
  region_pair = _locate_result_region_pair(locate_result)
1963
2196
  if region_pair is None:
2197
+ if has_region_params and needs_default_limit:
2198
+ normalized = dict(args_obj)
2199
+ next_params = dict(params)
2200
+ next_params["limit"] = 5
2201
+ normalized["params"] = next_params
2202
+ return normalized
1964
2203
  return args_obj
1965
2204
  origin_coords = _locate_result_coords(locate_result)
1966
2205
 
@@ -1988,6 +2227,182 @@ def _normalize_nmc_lookup_args_from_prior_locate(
1988
2227
  return normalized
1989
2228
 
1990
2229
 
2230
+ _HIRA_DEPARTMENT_HINTS: tuple[tuple[re.Pattern[str], str], ...] = (
2231
+ (re.compile(r"소아청소년과|소아과|pediatrics?", re.IGNORECASE), "소아청소년과"),
2232
+ (re.compile(r"이비인후과|ent\b", re.IGNORECASE), "이비인후과"),
2233
+ (re.compile(r"내과|internal medicine", re.IGNORECASE), "내과"),
2234
+ (re.compile(r"피부과|dermatology", re.IGNORECASE), "피부과"),
2235
+ (re.compile(r"정형외과|orthopedics?", re.IGNORECASE), "정형외과"),
2236
+ (re.compile(r"산부인과|obgyn|ob/gyn", re.IGNORECASE), "산부인과"),
2237
+ (re.compile(r"안과|ophthalmology", re.IGNORECASE), "안과"),
2238
+ )
2239
+
2240
+
2241
+ def _hira_department_from_query(user_query: str) -> str | None:
2242
+ for pattern, value in _HIRA_DEPARTMENT_HINTS:
2243
+ if pattern.search(user_query):
2244
+ return value
2245
+ return None
2246
+
2247
+
2248
+ def _normalize_hira_lookup_args_from_prior_locate(
2249
+ fname: str,
2250
+ args_obj: dict[str, object],
2251
+ llm_messages: list[Any],
2252
+ user_query: str,
2253
+ *,
2254
+ registry: Any = None,
2255
+ ) -> dict[str, object]:
2256
+ """Fill HIRA coordinate-radius params from the latest successful locate result."""
2257
+ if fname != "find" or args_obj.get("tool_id") != "hira_hospital_search":
2258
+ return args_obj
2259
+ locate_result = _latest_successful_primitive_result(
2260
+ llm_messages,
2261
+ primitive="locate",
2262
+ registry=registry,
2263
+ )
2264
+ if locate_result is None:
2265
+ return args_obj
2266
+ return _normalize_hira_lookup_args_from_locate_result(args_obj, locate_result, user_query)
2267
+
2268
+
2269
+ def _normalize_hira_lookup_args_from_locate_result(
2270
+ args_obj: dict[str, object],
2271
+ locate_result: dict[str, object],
2272
+ user_query: str,
2273
+ ) -> dict[str, object]:
2274
+ """Fill HIRA coordinate-radius params from an already observed locate result."""
2275
+
2276
+ raw_params = args_obj.get("params")
2277
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
2278
+ valid_coords = (
2279
+ isinstance(params.get("xPos"), int | float)
2280
+ and isinstance(params.get("yPos"), int | float)
2281
+ and not _is_whole_degree_pair(params.get("yPos"), params.get("xPos"))
2282
+ )
2283
+ valid_radius = (
2284
+ isinstance(params.get("radius"), int)
2285
+ and not isinstance(params.get("radius"), bool)
2286
+ and 1 <= int(params["radius"]) <= 10000
2287
+ )
2288
+
2289
+ changed = not isinstance(raw_params, dict)
2290
+ if not valid_coords:
2291
+ coords = _locate_result_coords(locate_result)
2292
+ if coords is None:
2293
+ return args_obj
2294
+ lat, lon = coords
2295
+ params["xPos"] = lon
2296
+ params["yPos"] = lat
2297
+ changed = True
2298
+
2299
+ if not valid_radius:
2300
+ params["radius"] = 2000
2301
+ changed = True
2302
+
2303
+ if params.get("dgsbjt") in (None, ""):
2304
+ department = _hira_department_from_query(user_query)
2305
+ if department is not None:
2306
+ params["dgsbjt"] = department
2307
+ changed = True
2308
+
2309
+ if not changed:
2310
+ return args_obj
2311
+ normalized = dict(args_obj)
2312
+ normalized["params"] = params
2313
+ logger.info(
2314
+ "find: normalized hira_hospital_search params from prior locate "
2315
+ "xPos=%s yPos=%s radius=%s dgsbjt=%s",
2316
+ params.get("xPos"),
2317
+ params.get("yPos"),
2318
+ params.get("radius"),
2319
+ params.get("dgsbjt"),
2320
+ )
2321
+ return normalized
2322
+
2323
+
2324
+ def _normalize_koroad_lookup_args_from_prior_locate(
2325
+ fname: str,
2326
+ args_obj: dict[str, object],
2327
+ llm_messages: list[Any],
2328
+ *,
2329
+ registry: Any = None,
2330
+ ) -> dict[str, object]:
2331
+ """Fill KOROAD adm_cd/year params from the latest successful locate result."""
2332
+ if fname != "find" or args_obj.get("tool_id") != "koroad_accident_hazard_search":
2333
+ return args_obj
2334
+ locate_result = _latest_successful_primitive_result(
2335
+ llm_messages,
2336
+ primitive="locate",
2337
+ registry=registry,
2338
+ )
2339
+ if locate_result is None:
2340
+ return args_obj
2341
+ return _normalize_koroad_lookup_args_from_locate_result(args_obj, locate_result)
2342
+
2343
+
2344
+ def _normalize_koroad_lookup_args_from_locate_result(
2345
+ args_obj: dict[str, object],
2346
+ locate_result: dict[str, object],
2347
+ ) -> dict[str, object]:
2348
+ """Fill KOROAD adm_cd/year params from an already observed locate result."""
2349
+
2350
+ raw_params = args_obj.get("params")
2351
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
2352
+ changed = not isinstance(raw_params, dict)
2353
+
2354
+ adm_cd = params.get("adm_cd")
2355
+ if not (isinstance(adm_cd, str) and len(adm_cd) == 10 and adm_cd.isdigit()):
2356
+ adm_cd_from_locate = _locate_result_adm_cd(locate_result)
2357
+ if adm_cd_from_locate is None:
2358
+ return args_obj
2359
+ params["adm_cd"] = adm_cd_from_locate
2360
+ changed = True
2361
+
2362
+ year = params.get("year")
2363
+ if not isinstance(year, int) or isinstance(year, bool):
2364
+ # Latest KOROAD frequentzoneLg dataset code currently maps to 2024 in
2365
+ # accident_hazard_search._YEAR_TO_SEARCH_CD. Keep the adapter contract
2366
+ # explicit instead of asking the model to guess a dataset vintage.
2367
+ params["year"] = 2024
2368
+ changed = True
2369
+
2370
+ if not changed:
2371
+ return args_obj
2372
+ normalized = dict(args_obj)
2373
+ normalized["params"] = params
2374
+ logger.info(
2375
+ "find: normalized koroad_accident_hazard_search params from prior locate adm_cd=%s year=%s",
2376
+ params.get("adm_cd"),
2377
+ params.get("year"),
2378
+ )
2379
+ return normalized
2380
+
2381
+
2382
+ def _normalize_lookup_args_from_cached_locate_result(
2383
+ fname: str,
2384
+ args_obj: dict[str, object],
2385
+ locate_result: dict[str, object] | None,
2386
+ *,
2387
+ user_query: str = "",
2388
+ ) -> dict[str, object]:
2389
+ """Apply locate-derived argument repair in inbound concrete tool dispatch."""
2390
+ if fname != "find" or locate_result is None:
2391
+ return args_obj
2392
+ tool_id = args_obj.get("tool_id")
2393
+ if tool_id == "nmc_emergency_search":
2394
+ return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2395
+ if tool_id == "hira_hospital_search":
2396
+ return _normalize_hira_lookup_args_from_locate_result(
2397
+ args_obj,
2398
+ locate_result,
2399
+ user_query,
2400
+ )
2401
+ if tool_id == "koroad_accident_hazard_search":
2402
+ return _normalize_koroad_lookup_args_from_locate_result(args_obj, locate_result)
2403
+ return args_obj
2404
+
2405
+
1991
2406
  def _check_sensitive_lookup_terminated_without_lookup(
1992
2407
  llm_messages: list[Any],
1993
2408
  user_query: str,
@@ -3018,6 +3433,39 @@ def _maybe_reroute_locate_admin_keyword_args(
3018
3433
  return {**args_obj, "tool_id": "kakao_address_search", "params": next_params}
3019
3434
 
3020
3435
 
3436
+ _POI_ADDRESS_QUERY_RE: Final = re.compile(
3437
+ r"(역|터미널|공항|캠퍼스|대학교|대학|해수욕장|시장|공원|병원|의원|약국|랜드마크)"
3438
+ )
3439
+ _ROAD_ADDRESS_QUERY_RE: Final = re.compile(r"(?:로|길)\s*\d{0,4}(?:번길)?")
3440
+
3441
+
3442
+ def _maybe_reroute_locate_poi_address_args(
3443
+ fname: str,
3444
+ args_obj: dict[str, Any],
3445
+ ) -> dict[str, Any]:
3446
+ """Rewrite named-place Kakao address calls to the documented keyword adapter."""
3447
+
3448
+ if fname != "locate" or args_obj.get("tool_id") != "kakao_address_search":
3449
+ return args_obj
3450
+ params = args_obj.get("params")
3451
+ if not isinstance(params, dict):
3452
+ return args_obj
3453
+ query = params.get("query")
3454
+ if not isinstance(query, str):
3455
+ return args_obj
3456
+ if not _POI_ADDRESS_QUERY_RE.search(query):
3457
+ return args_obj
3458
+ if _ROAD_ADDRESS_QUERY_RE.search(query):
3459
+ return args_obj
3460
+
3461
+ next_params = {**params, "query": query.strip()}
3462
+ logger.info(
3463
+ "locate: rerouted Kakao address POI query to keyword search: %r",
3464
+ query,
3465
+ )
3466
+ return {**args_obj, "tool_id": "kakao_keyword_search", "params": next_params}
3467
+
3468
+
3021
3469
  def _effective_chat_max_tokens(requested: int) -> int:
3022
3470
  """Clamp interactive chat completions so bad tool-routing loops fail fast."""
3023
3471
  raw = os.getenv("UMMAYA_CHAT_MAX_TOKENS")
@@ -3345,7 +3793,8 @@ def _check_chain_prerequisite( # noqa: C901
3345
3793
  "nmc_emergency_search({mode:'region', q0:region.region_1depth_name, "
3346
3794
  "q1:region.region_2depth_name, origin_lat:coords.lat, "
3347
3795
  "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."
3796
+ "station/neighborhood ER search. Leave qn unset unless the citizen "
3797
+ "explicitly gave a specific institution name."
3349
3798
  )
3350
3799
 
3351
3800
  # Require a successful prior locate for this context. If resolve
@@ -3388,8 +3837,8 @@ def _check_chain_prerequisite( # noqa: C901
3388
3837
  "then kakao_coord_to_region({lat:<lat>, lon:<lon>}), "
3389
3838
  "then call nmc_emergency_search({mode:'region', q0:region.region_1depth_name, "
3390
3839
  "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."
3840
+ "limit:<N>}). Do NOT guess coordinates. Leave qn unset unless the citizen "
3841
+ "explicitly gave a specific institution name."
3393
3842
  )
3394
3843
  if has_coord or schema_coord_fields:
3395
3844
  field_kind = "coordinates"
@@ -4105,6 +4554,7 @@ async def run( # noqa: C901
4105
4554
  # LLM to reconstruct auth_context from a prior tool result.
4106
4555
  _session_auth_contexts: dict[str, object] = {}
4107
4556
  _session_auth_session_ids: dict[str, str] = {}
4557
+ _session_latest_locate_results: dict[str, dict[str, object]] = {}
4108
4558
 
4109
4559
  # Epic #2077 T010 — single ToolRegistry + ToolExecutor instance pair
4110
4560
  # reused across every chat_request. Adapter registration happens lazily
@@ -4197,14 +4647,12 @@ async def run( # noqa: C901
4197
4647
  if _llm_system_prompt_cached[0] is not None:
4198
4648
  return _llm_system_prompt_cached[0] or None
4199
4649
  try:
4200
- from pathlib import Path # noqa: PLC0415
4201
-
4202
- from ummaya.context.prompt_loader import PromptLoader # noqa: PLC0415
4650
+ from ummaya.context.prompt_loader import ( # noqa: PLC0415
4651
+ PromptLoader,
4652
+ default_manifest_path,
4653
+ )
4203
4654
 
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"
4655
+ manifest = default_manifest_path()
4208
4656
  if not manifest.is_file():
4209
4657
  _llm_system_prompt_cached[0] = ""
4210
4658
  return None
@@ -4348,7 +4796,7 @@ async def run( # noqa: C901
4348
4796
  _AVAILABLE_ADAPTERS_TOP_K = int( # noqa: N806 — env-derived constant
4349
4797
  _os_chat_env.environ.get("UMMAYA_AVAILABLE_ADAPTERS_TOP_K", "5")
4350
4798
  )
4351
- _root_primitive_tool_ids = frozenset({"locate", "find", "check", "send"})
4799
+ _root_primitive_tool_ids = _ROOT_PRIMITIVE_TOOL_IDS
4352
4800
 
4353
4801
  def _select_concrete_adapter_tools_for_turn(user_query: str) -> list[Any]:
4354
4802
  """Return concrete, non-core adapter tools for this citizen turn.
@@ -5361,6 +5809,11 @@ async def run( # noqa: C901
5361
5809
  "tool_id": str(args_obj.get("tool_id", fname)),
5362
5810
  }
5363
5811
 
5812
+ if fname == "locate" and dispatch_error is None:
5813
+ locate_result = result_payload.get("result")
5814
+ if isinstance(locate_result, dict) and locate_result.get("kind") != "error":
5815
+ _session_latest_locate_results[session_id] = locate_result
5816
+
5364
5817
  # Drain the outbound HTTP trace buffer + attach to the envelope.
5365
5818
  outbound_traces = consume_outbound_capture(_outbound_trace_token)
5366
5819
  if outbound_traces:
@@ -5501,8 +5954,9 @@ async def run( # noqa: C901
5501
5954
  # UMMAYA now follows that shape: BM25/dense retrieval selects a small
5502
5955
  # turn-local set of concrete adapter tools, and each selected
5503
5956
  # GovAPITool is exported directly as an OpenAI-compatible function.
5504
- # The root primitives remain internal dispatcher families and legacy
5505
- # transcript compatibility names, not the model-facing tool surface.
5957
+ # Keep the root primitives alongside that set to preserve the 0.2.1
5958
+ # CC-style loop contract: the model can paint progress prose, then call
5959
+ # a primitive dispatcher with a concrete adapter in `tool_id`.
5506
5960
  registry = cast("Any", _ensure_tool_registry())
5507
5961
  backend_tools_raw = [
5508
5962
  t.to_openai_tool() for t in _select_concrete_adapter_tools_for_turn(latest_user_utt)
@@ -5517,9 +5971,14 @@ async def run( # noqa: C901
5517
5971
  llm_tools: list[LLMToolDefinition] = [
5518
5972
  LLMToolDefinition.model_validate(raw) for raw in backend_tools_raw
5519
5973
  ]
5974
+ has_concrete_backend_tools = bool(backend_tools_raw)
5520
5975
  for t in frame.tools:
5521
5976
  tui_name = getattr(getattr(t, "function", None), "name", None)
5522
- if tui_name and tui_name in backend_tool_names:
5977
+ if not _should_append_tui_tool_to_llm_tools(
5978
+ tui_name,
5979
+ backend_tool_names,
5980
+ has_concrete_backend_tools=has_concrete_backend_tools,
5981
+ ):
5523
5982
  continue
5524
5983
  llm_tools.append(LLMToolDefinition.model_validate(t.model_dump()))
5525
5984
 
@@ -5605,14 +6064,14 @@ async def run( # noqa: C901
5605
6064
  "base_time 추측 금지 — 위 hint 또는 그 이전 정시 사용.\n"
5606
6065
  )
5607
6066
 
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).
6067
+ # 2026 tool-surface migration — adapter discovery is a backend
6068
+ # function, NOT an LLM-callable mode. Run the search against the
6069
+ # latest citizen utterance and inject top-K candidates into the
6070
+ # dynamic suffix as ``<available_adapters>``. The normal path is a
6071
+ # concrete adapter function call; the root primitive wrappers stay
6072
+ # available only for legacy transcript compatibility. Search-mode
6073
+ # calls were the source of the "● find(search:)" phantom tool-UI
6074
+ # noise that user surfaced via Layer 5 frame capture.
5616
6075
  try:
5617
6076
  for m in reversed(frame.messages):
5618
6077
  if m.role == "user" and m.content:
@@ -5715,24 +6174,45 @@ async def run( # noqa: C901
5715
6174
  for _turn in range(_AGENTIC_LOOP_MAX_TURNS):
5716
6175
  message_id = str(uuid.uuid4())
5717
6176
  assistant_text_chunks: list[str] = []
5718
- # Epic #2766 issue B — render-order fix. K-EXAONE emits the
5719
- # assistant's prose preamble ("내과 병원을 검색해 보겠습니다.")
5720
- # BEFORE the structured ``tool_call_delta`` events arrive in the
5721
- # SAME turn. If we forward those prose chunks immediately, the
5722
- # citizen sees ``assistant text → tool_call result``, the
5723
- # opposite of CC's canonical ``tool_call result → assistant
5724
- # text`` order. The fix: buffer prose chunks for this turn; emit
5725
- # them as a single AssistantChunkFrame ONLY after we know whether
5726
- # this turn invoked tools. When tools are invoked we suppress the
5727
- # preamble entirely — the next turn produces the real answer
5728
- # after the tool result is appended to context. When no tools
5729
- # are invoked we flush the buffer as a single chunk so the prose
5730
- # still reaches the citizen.
6177
+ # CC stream order: K-EXAONE may emit a visible progress sentence
6178
+ # before the structured ``tool_call_delta`` in the same assistant
6179
+ # turn. Claude Code commits that text block before opening the
6180
+ # following tool_use block, so the TUI can paint
6181
+ # ``assistant text → tool_call``. Buffer here only so textual
6182
+ # ``<tool_call>`` markers can be stripped accurately across chunk
6183
+ # boundaries; when a real ToolCallFrame is emitted below, flush the
6184
+ # cleaned visible text immediately before the tool frame.
5731
6185
  buffered_visible: list[str] = []
5732
6186
  tool_call_buf: dict[int, dict[str, str]] = {}
5733
6187
  stream_error: Exception | None = None
5734
6188
  stream_gate = StreamGate()
5735
6189
 
6190
+ async def _emit_buffered_visible_before_tool(current_message_id: str) -> None:
6191
+ """Emit same-turn visible prose before opening a tool_use block."""
6192
+ nonlocal buffered_visible
6193
+ if not buffered_visible:
6194
+ return
6195
+ from ummaya.llm.tool_call_parser import ( # noqa: PLC0415
6196
+ strip_leaked_thinking_markers,
6197
+ )
6198
+
6199
+ merged_prose = strip_leaked_thinking_markers("".join(buffered_visible))
6200
+ buffered_visible = []
6201
+ if not merged_prose.strip():
6202
+ return
6203
+ await write_frame(
6204
+ AssistantChunkFrame(
6205
+ session_id=frame.session_id,
6206
+ correlation_id=frame.correlation_id,
6207
+ role="llm",
6208
+ ts=_utcnow(),
6209
+ kind="assistant_chunk",
6210
+ message_id=current_message_id,
6211
+ delta=merged_prose,
6212
+ done=False,
6213
+ )
6214
+ )
6215
+
5736
6216
  def _append_tool_routing_observation(reason: str, message: str) -> None:
5737
6217
  """Add an internal routing repair instruction for the next model turn."""
5738
6218
  llm_messages.append(
@@ -6339,11 +6819,10 @@ async def run( # noqa: C901
6339
6819
  )
6340
6820
  )
6341
6821
  return
6342
- # Tool calls present suppress the prose preamble entirely.
6343
- # The next agentic-loop turn will produce the real answer after
6344
- # appending tool_result to context. CC-style ordering preserved:
6345
- # `tool_call tool_result final assistant prose`.
6346
- buffered_visible.clear()
6822
+ # Tool calls present. Preserve any same-turn progress prose by
6823
+ # emitting it immediately before the ToolCallFrame below; do not
6824
+ # send a done=True chunk because this provider call must still stop
6825
+ # at assistant(tool_use), not at an assistant final answer.
6347
6826
 
6348
6827
  # ---- T027/T029 — emit tool_call frames + register Futures -----
6349
6828
  issued_calls: list[tuple[str, str]] = [] # (call_id, name)
@@ -6417,7 +6896,35 @@ async def run( # noqa: C901
6417
6896
  fname = primitive_name
6418
6897
  args_obj = {"tool_id": model_tool_name, "params": dict(model_args_obj)}
6419
6898
 
6899
+ raw_dispatch_args_obj = _copy_primitive_args(args_obj)
6900
+
6420
6901
  args_obj = _maybe_reroute_locate_admin_keyword_args(fname, args_obj)
6902
+ args_obj = _maybe_reroute_locate_poi_address_args(fname, args_obj)
6903
+ args_obj = _normalize_reverse_geocode_args_from_prior_locate(
6904
+ fname,
6905
+ args_obj,
6906
+ llm_messages,
6907
+ registry=registry,
6908
+ )
6909
+ args_obj = _normalize_nmc_lookup_args_from_prior_locate(
6910
+ fname,
6911
+ args_obj,
6912
+ llm_messages,
6913
+ registry=registry,
6914
+ )
6915
+ args_obj = _normalize_hira_lookup_args_from_prior_locate(
6916
+ fname,
6917
+ args_obj,
6918
+ llm_messages,
6919
+ latest_user_utt,
6920
+ registry=registry,
6921
+ )
6922
+ args_obj = _normalize_koroad_lookup_args_from_prior_locate(
6923
+ fname,
6924
+ args_obj,
6925
+ llm_messages,
6926
+ registry=registry,
6927
+ )
6421
6928
  args_obj = _normalize_lookup_args_for_query(fname, args_obj, latest_user_utt)
6422
6929
  args_obj = _normalize_verify_args_for_query(fname, args_obj, latest_user_utt)
6423
6930
  args_obj = _normalize_verify_tool_id_for_query(fname, args_obj, latest_user_utt)
@@ -6478,6 +6985,7 @@ async def run( # noqa: C901
6478
6985
  ToolResultFrame,
6479
6986
  )
6480
6987
 
6988
+ await _emit_buffered_visible_before_tool(message_id)
6481
6989
  await write_frame(
6482
6990
  ToolCallFrame(
6483
6991
  session_id=frame.session_id,
@@ -6551,7 +7059,16 @@ async def run( # noqa: C901
6551
7059
  )
6552
7060
  continue
6553
7061
 
6554
- if _conversation_has_successful_identical_primitive_call(
7062
+ raw_duplicate = _canonical_primitive_args(
7063
+ raw_dispatch_args_obj
7064
+ ) != _canonical_primitive_args(
7065
+ args_obj
7066
+ ) and _conversation_has_successful_identical_primitive_call(
7067
+ llm_messages,
7068
+ primitive=fname,
7069
+ args=raw_dispatch_args_obj,
7070
+ )
7071
+ if raw_duplicate or _conversation_has_successful_identical_primitive_call(
6555
7072
  llm_messages,
6556
7073
  primitive=fname,
6557
7074
  args=args_obj,
@@ -6652,6 +7169,7 @@ async def run( # noqa: C901
6652
7169
  ToolResultFrame,
6653
7170
  )
6654
7171
 
7172
+ await _emit_buffered_visible_before_tool(message_id)
6655
7173
  await write_frame(
6656
7174
  ToolCallFrame(
6657
7175
  session_id=frame.session_id,
@@ -6807,6 +7325,7 @@ async def run( # noqa: C901
6807
7325
  ToolResultFrame,
6808
7326
  )
6809
7327
 
7328
+ await _emit_buffered_visible_before_tool(message_id)
6810
7329
  await write_frame(
6811
7330
  ToolCallFrame(
6812
7331
  session_id=frame.session_id,
@@ -6884,6 +7403,7 @@ async def run( # noqa: C901
6884
7403
  )
6885
7404
  continue
6886
7405
 
7406
+ await _emit_buffered_visible_before_tool(message_id)
6887
7407
  await write_frame(
6888
7408
  ToolCallFrame(
6889
7409
  session_id=frame.session_id,
@@ -7293,6 +7813,12 @@ async def run( # noqa: C901
7293
7813
  dispatch_name = primitive_name
7294
7814
  dispatch_args = {"tool_id": frame.name, "params": dict(args_obj)}
7295
7815
 
7816
+ dispatch_args = _normalize_lookup_args_from_cached_locate_result(
7817
+ dispatch_name,
7818
+ dispatch_args,
7819
+ _session_latest_locate_results.get(frame.session_id),
7820
+ )
7821
+
7296
7822
  await _dispatch_primitive(
7297
7823
  frame.call_id,
7298
7824
  dispatch_name,