ummaya 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ummaya +76 -31
- package/bun.lock +10 -16
- package/npm-shrinkwrap.json +32 -10
- package/package.json +2 -1
- package/pyproject.toml +2 -2
- package/src/ummaya/engine/engine.py +17 -34
- package/src/ummaya/engine/models.py +10 -4
- package/src/ummaya/engine/query.py +108 -5
- package/src/ummaya/ipc/stdio.py +530 -30
- package/src/ummaya/tools/executor.py +52 -6
- package/src/ummaya/tools/hira/hospital_search.py +15 -2
- package/src/ummaya/tools/location_adapters.py +25 -1
- package/src/ummaya/tools/mvp_surface.py +47 -46
- package/src/ummaya/tools/nmc/emergency_search.py +53 -1
- package/src/ummaya/tools/search.py +63 -2
- package/tui/package.json +1 -1
- package/tui/src/query.ts +1 -1
- package/tui/src/services/api/claude.ts +53 -3
- package/tui/src/tools/AdapterTool/AdapterTool.ts +262 -4
- package/tui/src/tools/LookupPrimitive/prompt.ts +19 -30
- package/tui/src/tools/ResolveLocationPrimitive/prompt.ts +12 -10
- package/tui/src/tools/SubmitPrimitive/prompt.ts +10 -6
- package/tui/src/tools/VerifyPrimitive/prompt.ts +10 -5
- package/uv.lock +1 -1
package/src/ummaya/ipc/stdio.py
CHANGED
|
@@ -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
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
3392
|
-
"
|
|
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
|
|
4201
|
-
|
|
4202
|
-
|
|
4649
|
+
from ummaya.context.prompt_loader import ( # noqa: PLC0415
|
|
4650
|
+
PromptLoader,
|
|
4651
|
+
default_manifest_path,
|
|
4652
|
+
)
|
|
4203
4653
|
|
|
4204
|
-
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
5609
|
-
# function, NOT an LLM-callable
|
|
5610
|
-
# latest citizen utterance and inject
|
|
5611
|
-
#
|
|
5612
|
-
#
|
|
5613
|
-
#
|
|
5614
|
-
#
|
|
5615
|
-
#
|
|
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
|
-
|
|
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,
|