Compare commits
5 Commits
f89246a14d
...
67d0ae711c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d0ae711c | ||
|
|
932aacef6e | ||
|
|
ccbaed9030 | ||
|
|
f3a1d92620 | ||
|
|
c8a04a110f |
@@ -56,6 +56,13 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
# Korean on factual/tool replies; can occasionally leak a trailing CJK phrase on
|
||||
# free-form chit-chat. Swap back to qwen3:8b for the strongest tool-calling.
|
||||
OLLAMA_CHAT_MODEL=qwen2.5:3b
|
||||
# Model for the auxiliary small-model calls: intent judge, tool router, weather
|
||||
# place extraction, query decomposition. BLANK (default) reuses OLLAMA_CHAT_MODEL
|
||||
# so the stack runs on one already-warm model. The code's built-in default
|
||||
# (gemma4:e2b) is NOT pulled by this stack, so leaving this unset previously made
|
||||
# every router/extractor call silently fail. Only set this if you also pull the
|
||||
# model into Ollama.
|
||||
OLLAMA_INTENT_MODEL=
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
WHISPER_MODEL=small
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -66,14 +66,20 @@ docker compose up -d --build
|
||||
|
||||
### 디스코드 토큰은 마지막에
|
||||
|
||||
토큰 없이도 위의 모든 게 정상 동작합니다(봇만 대기). 준비되면:
|
||||
토큰 없이도 위의 모든 게 정상 동작합니다(봇만 대기). 준비되면 `.env`를 만들어 토큰을 채웁니다.
|
||||
|
||||
기본 모드는 **유저봇(selfbot)** 입니다. 음성 참여와 화면 송출(Go Live)을 한 유저 계정으로 처리하며, Discord가 일반 봇 계정에는 Go Live를 허용하지 않기 때문에 이 방식이 기본입니다.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DISCORD_BOT_TOKEN / DISCORD_APP_ID / DISCORD_GUILD_ID 채우기
|
||||
docker compose up -d # 봇이 시작되고 /자비스 명령 등록
|
||||
cp .env.example .env # DISCORD_SELFBOT_TOKEN(버너 계정) + DISCORD_VOICE_CHANNEL_ID 채우기
|
||||
docker compose up -d # 유저봇이 로그인해 지정 음성채널에 자동 참여
|
||||
```
|
||||
|
||||
디스코드에서 `/자비스 join` 으로 호출하세요. (`OLLAMA_CHAT_MODEL` 등 모델을 바꾸려면 `.env`에서 지정 후 `docker compose up -d`.)
|
||||
유저봇은 슬래시 명령을 쓸 수 없으므로 텍스트로 제어합니다: 음성 채널에서 `!자비스 join` / `!자비스 leave`. `DISCORD_VOICE_CHANNEL_ID`를 채워두면 시작 시 자동 참여합니다.
|
||||
|
||||
> ⚠️ 유저봇은 Discord ToS 위반이며 계정 정지 위험이 있습니다. 반드시 일회용 **버너 계정** 토큰만 사용하세요. 자세한 주의사항은 아래 "셀프봇 주의" 절을 참고하세요.
|
||||
|
||||
일반 봇(슬래시 명령 `/자비스`)으로 돌리려면 `DISCORD_BOT_TOKEN` / `DISCORD_APP_ID` / `DISCORD_GUILD_ID`를 채우세요. 다만 일반 봇은 화면 송출(Go Live)을 할 수 없습니다. `DISCORD_BOT_TOKEN`이 비어 있고 `DISCORD_SELFBOT_TOKEN`이 있으면 자동으로 유저봇 모드로 동작합니다. (`OLLAMA_CHAT_MODEL` 등 모델을 바꾸려면 `.env`에서 지정 후 `docker compose up -d`.)
|
||||
|
||||
### GPU 가속 (기본 ON)
|
||||
|
||||
|
||||
@@ -184,10 +184,43 @@ async function handleStatus(i: ChatInputCommandInteraction) {
|
||||
);
|
||||
}
|
||||
|
||||
// Block until the brain bridge reports the TTS voice (MeloTTS worker) is warm.
|
||||
// The bot, bridge and melo worker all boot together; the voice model takes tens
|
||||
// of seconds to load. If we log in and auto-join the voice channel before
|
||||
// MeloTTS is ready, the first reply synthesises to nothing and is silently
|
||||
// dropped. Gating login on TTS readiness guarantees the first spoken turn has
|
||||
// audio. We deliberately do NOT wait on brain_ready: the reply engine / Whisper
|
||||
// load lazily on the first turn (so brain_ready stays false on an idle boot) —
|
||||
// a slow first turn is fine, a silent one is the bug. After `maxMs` we proceed
|
||||
// anyway so a TTS load failure degrades to text-only rather than taking the
|
||||
// whole bot offline.
|
||||
async function waitForBridgeReady(maxMs = 180_000): Promise<void> {
|
||||
const start = Date.now();
|
||||
let announced = false;
|
||||
while (Date.now() - start < maxMs) {
|
||||
try {
|
||||
const h = await health();
|
||||
if (h.tts_ready) {
|
||||
console.log("✓ 음성(MeloTTS) 준비 완료 — 로그인 진행");
|
||||
return;
|
||||
}
|
||||
if (!announced) {
|
||||
console.log("⏳ MeloTTS 준비 대기 중 (음성 모델 로딩)...");
|
||||
announced = true;
|
||||
}
|
||||
} catch {
|
||||
/* bridge not listening yet — keep polling */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
console.warn("⚠️ MeloTTS 준비 시간 초과 — 음성(TTS) 없이 진행합니다.");
|
||||
}
|
||||
|
||||
// Mode select: a USER account is the only kind Discord lets Go Live, so when
|
||||
// there's no normal-bot token but a selfbot token is present, run as a userbot
|
||||
// (voice + broadcast on one user account). Otherwise run the legacy normal bot.
|
||||
(async () => {
|
||||
await waitForBridgeReady();
|
||||
if (!config.botToken && config.selfbotToken) {
|
||||
const { runUserbot } = await import("./userbot.ts");
|
||||
await runUserbot();
|
||||
|
||||
@@ -255,6 +255,29 @@ def _piper_synthesize(text: str) -> Optional[bytes]:
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _tts_ready() -> bool:
|
||||
"""Whether the configured TTS voice can synthesise right now.
|
||||
|
||||
The bot polls this before logging in so the very first spoken reply is not
|
||||
silently dropped while the voice is still warming up. For MeloTTS the worker
|
||||
only binds its HTTP port AFTER the model is loaded (``main()`` warms the
|
||||
model before ``serve_forever()``), so a successful /health ping is a precise
|
||||
"voice is warm" signal. Piper loads on first synth and was never gated, so
|
||||
it reports ready. TTS disabled means there is nothing to wait for.
|
||||
"""
|
||||
if not TTS_ENABLED:
|
||||
return True
|
||||
if TTS_ENGINE == "melo":
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{MELO_WORKER_URL}/health", timeout=2) as resp:
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def synthesize(text: str) -> Optional[bytes]:
|
||||
"""Synthesize text to a 16-bit PCM WAV. The primary voice is MeloTTS
|
||||
(Korean speaker, speed 1.5) served by the warm melo worker; Piper is a
|
||||
@@ -286,6 +309,7 @@ def health():
|
||||
"brain_error": _brain_error,
|
||||
"tts_enabled": TTS_ENABLED,
|
||||
"tts_engine": TTS_ENGINE,
|
||||
"tts_ready": _tts_ready(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ set -euo pipefail
|
||||
: "${VNC_RESOLUTION:=1920x1080}"
|
||||
: "${OLLAMA_BASE_URL:=http://ollama:11434}"
|
||||
: "${OLLAMA_CHAT_MODEL:=qwen3:8b}"
|
||||
# Auxiliary small-model calls (intent judge, tool router, weather place
|
||||
# extraction, query decomposition). The code default is gemma4:e2b, which this
|
||||
# stack does not pull, so those calls would silently fail and fall open —
|
||||
# crippling tool routing and arg extraction. Reuse the (already warm) chat model
|
||||
# by default so everything runs on one resident model; override if you pull a
|
||||
# dedicated small model.
|
||||
: "${OLLAMA_INTENT_MODEL:=${OLLAMA_CHAT_MODEL}}"
|
||||
: "${OLLAMA_EMBED_MODEL:=nomic-embed-text}"
|
||||
: "${WHISPER_MODEL:=small}"
|
||||
: "${WHISPER_DEVICE:=cuda}"
|
||||
@@ -25,7 +32,7 @@ set -euo pipefail
|
||||
: "${XDG_RUNTIME_DIR:=/run/user/0}"
|
||||
: "${PULSE_SERVER:=unix:${XDG_RUNTIME_DIR}/pulse/native}"
|
||||
|
||||
export VNC_RESOLUTION OLLAMA_BASE_URL OLLAMA_CHAT_MODEL OLLAMA_EMBED_MODEL \
|
||||
export VNC_RESOLUTION OLLAMA_BASE_URL OLLAMA_CHAT_MODEL OLLAMA_INTENT_MODEL OLLAMA_EMBED_MODEL \
|
||||
WHISPER_MODEL WHISPER_DEVICE WHISPER_COMPUTE_TYPE JARVIS_DB_PATH \
|
||||
PIPER_VOICE PIPER_VOICE_DIR TTS_PIPER_MODEL_PATH BRIDGE_HOST BRIDGE_PORT \
|
||||
XDG_RUNTIME_DIR PULSE_SERVER
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"ollama_base_url": "${OLLAMA_BASE_URL}",
|
||||
"ollama_embed_model": "${OLLAMA_EMBED_MODEL}",
|
||||
"ollama_chat_model": "${OLLAMA_CHAT_MODEL}",
|
||||
"intent_judge_model": "${OLLAMA_INTENT_MODEL}",
|
||||
"tts_enabled": true,
|
||||
"tts_engine": "piper",
|
||||
"tts_piper_model_path": "${TTS_PIPER_MODEL_PATH}",
|
||||
|
||||
@@ -431,6 +431,21 @@ def _extract_text_tool_call(content_field: str, known_names: set):
|
||||
if m and m.group(1) in known_names:
|
||||
name = m.group(1)
|
||||
rest = m.group(2).strip()
|
||||
# If the value after the colon is itself a JSON object, it already IS
|
||||
# the argument dict — parse it directly. Small models routinely emit
|
||||
# `toolName: {"location": "Seoul"}`. Without this fast-path the whole
|
||||
# object is dumped into {"query": "{...}"} below, so the real named
|
||||
# arguments (e.g. location) never reach the tool. The tool then runs
|
||||
# with empty args (e.g. weather falls back to auto-detected location),
|
||||
# the model notices the answer doesn't match and retries, looping
|
||||
# until the turn cap.
|
||||
if rest.startswith("{"):
|
||||
try:
|
||||
obj = json.loads(rest)
|
||||
if isinstance(obj, dict):
|
||||
return name, obj, f"call_{uuid.uuid4().hex[:8]}"
|
||||
except Exception:
|
||||
pass
|
||||
args: dict = {}
|
||||
for pair in re.split(r"[\n,]", rest):
|
||||
pair = pair.strip()
|
||||
@@ -460,6 +475,42 @@ def _extract_text_tool_call(content_field: str, known_names: set):
|
||||
parsed_args = {"query": inside.strip().strip('"').strip("'")}
|
||||
return name, parsed_args, f"call_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Form: a single tool_call OBJECT emitted without the `tool_calls: [...]`
|
||||
# array wrapper, optionally behind a `call_xxx:` label. Captured from
|
||||
# qwen2.5:3b (2026-06-12) on "방송 꺼줘":
|
||||
# call_stop: {"id": "call_stop", "type": "function",
|
||||
# "function": {"name": "setBroadcast",
|
||||
# "arguments": "{\"action\": \"stop\"}"}}
|
||||
# The colon/array forms above don't match (the label isn't a tool name and
|
||||
# there's no array), so without this the raw JSON leaked to the user AND the
|
||||
# chosen tool never ran. Pull name + arguments straight out of the embedded
|
||||
# `"function": {...}` object.
|
||||
func_match = re.search(
|
||||
r'"function"\s*:\s*\{\s*"name"\s*:\s*"([^"]+)"'
|
||||
r'(?:\s*,\s*"arguments"\s*:\s*(\{.*?\}|"(?:[^"\\]|\\.)*"))?',
|
||||
content_field,
|
||||
re.DOTALL,
|
||||
)
|
||||
if func_match and func_match.group(1).strip() in known_names:
|
||||
fname = func_match.group(1).strip()
|
||||
raw_args = func_match.group(2)
|
||||
parsed_args = {}
|
||||
if raw_args:
|
||||
try:
|
||||
val = json.loads(raw_args)
|
||||
if isinstance(val, dict):
|
||||
parsed_args = val
|
||||
elif isinstance(val, str):
|
||||
# arguments was a JSON string (double-encoded) — unwrap once.
|
||||
try:
|
||||
inner = json.loads(val)
|
||||
parsed_args = inner if isinstance(inner, dict) else {"query": val}
|
||||
except Exception:
|
||||
parsed_args = {"query": val}
|
||||
except Exception:
|
||||
parsed_args = {}
|
||||
return fname, parsed_args, f"call_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,65 @@ def _extract_place_from_user_text(text: str, cfg) -> Optional[str]:
|
||||
return place
|
||||
|
||||
|
||||
def _romanise_place(name: str, cfg) -> Optional[str]:
|
||||
"""Romanise a non-Latin place name for the geocoder.
|
||||
|
||||
Open-Meteo's geocoding API only matches Latin/English spellings, so a
|
||||
Korean (or other non-Latin) city name like ``서울`` returns zero results
|
||||
even though the place plainly exists. When OUTPUT_LANGUAGE locks replies to
|
||||
Korean the tool-calling model naturally fills ``location`` with the Korean
|
||||
name, which would otherwise dead-end. Ask the (already warm) small model for
|
||||
the common English exonym so geocoding can succeed on a retry.
|
||||
|
||||
Returns the romanised name, or ``None`` if it is unavailable, unchanged, or
|
||||
doesn't look like a place.
|
||||
"""
|
||||
if not isinstance(name, str) or not name.strip() or name.isascii():
|
||||
return None
|
||||
if cfg is None:
|
||||
return None
|
||||
|
||||
model = (
|
||||
getattr(cfg, "tool_router_model", "")
|
||||
or getattr(cfg, "intent_judge_model", "")
|
||||
or getattr(cfg, "ollama_chat_model", "")
|
||||
)
|
||||
base_url = getattr(cfg, "ollama_base_url", "")
|
||||
if not model or not base_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
from ...llm import call_llm_direct
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
sys_prompt = (
|
||||
"You romanise place names for a geocoding API that only understands "
|
||||
"English/Latin spellings. Reply with ONLY the common English name of "
|
||||
"the place, no punctuation, quotes, or explanation. "
|
||||
"Examples: 서울 -> Seoul, 도쿄 -> Tokyo, 뮌헨 -> Munich."
|
||||
)
|
||||
user_prompt = f"Place: {name}\n\nEnglish name:"
|
||||
|
||||
try:
|
||||
resp = call_llm_direct(
|
||||
base_url, model, sys_prompt, user_prompt,
|
||||
timeout_sec=float(getattr(cfg, "llm_tools_timeout_sec", 8.0)),
|
||||
)
|
||||
except Exception as e:
|
||||
debug_log(f" ⚠️ place romanisation failed: {e}", "tools")
|
||||
return None
|
||||
|
||||
if not resp or not isinstance(resp, str):
|
||||
return None
|
||||
out = resp.strip().strip("'\"`*.,:;!?()[]{}<>").split("\n", 1)[0].strip()
|
||||
if not out or out.lower() in _NO_PLACE_SENTINELS:
|
||||
return None
|
||||
if len(out) > 60 or len(out.split()) > 5 or out == name:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
# WMO Weather interpretation codes
|
||||
# https://open-meteo.com/en/docs
|
||||
WMO_CODES = {
|
||||
@@ -274,6 +333,23 @@ class WeatherTool(Tool):
|
||||
geo_response.raise_for_status()
|
||||
geo_data = geo_response.json()
|
||||
|
||||
# Open-Meteo only matches Latin spellings, so a non-Latin name
|
||||
# (e.g. Korean "서울") returns nothing. Retry once with an
|
||||
# LLM-romanised name before giving up.
|
||||
if not geo_data.get("results"):
|
||||
romanised = _romanise_place(location_str, getattr(context, "cfg", None))
|
||||
if romanised:
|
||||
debug_log(
|
||||
f" 🌤️ geocode empty for '{location_str}'; retrying romanised '{romanised}'",
|
||||
"tools",
|
||||
)
|
||||
geocode_params["name"] = romanised
|
||||
geo_response = requests.get(geocode_url, params=geocode_params, timeout=10)
|
||||
geo_response.raise_for_status()
|
||||
geo_data = geo_response.json()
|
||||
if geo_data.get("results"):
|
||||
location_str = romanised
|
||||
|
||||
if not geo_data.get("results"):
|
||||
return ToolExecutionResult(
|
||||
success=False,
|
||||
|
||||
@@ -84,6 +84,75 @@ class TestSimplifiedColonForm:
|
||||
assert name is None
|
||||
|
||||
|
||||
class TestColonFormWithJsonObjectValue:
|
||||
"""Form 2b: `toolName: {json object}`.
|
||||
|
||||
Field-captured from qwen2.5:3b (2026-06-12): the model emits the weather
|
||||
call as ``getWeather: {"location": "Seoul"}``. The whole JSON object must
|
||||
become the argument dict. Before the fix it was dumped into
|
||||
``{"query": "{...}"}``, so ``location`` never reached the tool, the tool
|
||||
fell back to the auto-detected location, and the model looped retrying
|
||||
different cities until the turn cap (observed: 8 getWeather calls, then an
|
||||
English error fallback).
|
||||
"""
|
||||
|
||||
def test_json_object_after_colon_becomes_args(self):
|
||||
content = 'getWeather: {"location": "Seoul"}'
|
||||
name, args, _ = _extract(content, tool_name="getWeather")
|
||||
assert name == "getWeather"
|
||||
assert args.get("location") == "Seoul"
|
||||
assert "query" not in args
|
||||
|
||||
def test_empty_json_object_after_colon(self):
|
||||
content = "getWeather: {}"
|
||||
name, args, _ = _extract(content, tool_name="getWeather")
|
||||
assert name == "getWeather"
|
||||
assert args == {}
|
||||
|
||||
def test_non_ascii_location_after_colon(self):
|
||||
content = 'getWeather: {"location": "서울"}'
|
||||
name, args, _ = _extract(content, tool_name="getWeather")
|
||||
assert name == "getWeather"
|
||||
assert args.get("location") == "서울"
|
||||
|
||||
|
||||
class TestSingleToolCallObjectForm:
|
||||
"""Form 2c: a single tool_call object without the `tool_calls: [...]` array.
|
||||
|
||||
Field-captured from qwen2.5:3b (2026-06-12) on "방송 꺼줘": the model picked
|
||||
the right tool but emitted it behind a `call_xxx:` label as a bare object.
|
||||
The name + arguments must be pulled from the embedded ``function`` object;
|
||||
before the fix this leaked the raw JSON to the user and the tool never ran.
|
||||
"""
|
||||
|
||||
def test_single_object_with_string_arguments(self):
|
||||
content = (
|
||||
'call_stop: {"id": "call_stop", "type": "function", '
|
||||
'"function": {"name": "setBroadcast", '
|
||||
'"arguments": "{\\"action\\": \\"stop\\"}"}}'
|
||||
)
|
||||
name, args, _ = _extract(content, tool_name="setBroadcast")
|
||||
assert name == "setBroadcast"
|
||||
assert args.get("action") == "stop"
|
||||
|
||||
def test_single_object_with_dict_arguments(self):
|
||||
content = (
|
||||
'{"id": "c1", "type": "function", '
|
||||
'"function": {"name": "getWeather", "arguments": {"location": "Seoul"}}}'
|
||||
)
|
||||
name, args, _ = _extract(content, tool_name="getWeather")
|
||||
assert name == "getWeather"
|
||||
assert args.get("location") == "Seoul"
|
||||
|
||||
def test_single_object_rejects_unknown_tool(self):
|
||||
content = (
|
||||
'{"function": {"name": "fileSystem_write", '
|
||||
'"arguments": "{\\"path\\": \\"/tmp/x\\"}"}}'
|
||||
)
|
||||
name, _args, _ = _extract(content, tool_name="setBroadcast")
|
||||
assert name is None
|
||||
|
||||
|
||||
class TestFunctionCallForm:
|
||||
"""Form 3: `toolName(...)`."""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from src.jarvis.tools.builtin.weather import (
|
||||
WeatherTool,
|
||||
WMO_CODES,
|
||||
_extract_place_from_user_text,
|
||||
_romanise_place,
|
||||
)
|
||||
from src.jarvis.tools.base import ToolContext
|
||||
from src.jarvis.tools.types import ToolExecutionResult
|
||||
@@ -100,6 +101,77 @@ class TestWeatherTool:
|
||||
assert "7-day forecast" in result.reply_text
|
||||
self.context.user_print.assert_called()
|
||||
|
||||
@patch('src.jarvis.tools.builtin.weather._romanise_place')
|
||||
@patch('requests.get')
|
||||
def test_run_non_latin_location_romanises_and_retries(self, mock_get, mock_romanise):
|
||||
"""A Korean city name Open-Meteo can't geocode is romanised and retried.
|
||||
|
||||
Open-Meteo's geocoder only matches Latin spellings, so ``서울`` returns
|
||||
no results. The tool must romanise to ``Seoul`` and retry rather than
|
||||
dead-ending with "could not find location" (the failure the Korean-
|
||||
locked deployment hit on every weather request)."""
|
||||
mock_romanise.return_value = "Seoul"
|
||||
|
||||
geo_empty = Mock()
|
||||
geo_empty.raise_for_status = Mock()
|
||||
geo_empty.json.return_value = {} # no "results"
|
||||
|
||||
geo_seoul = Mock()
|
||||
geo_seoul.raise_for_status = Mock()
|
||||
geo_seoul.json.return_value = {
|
||||
"results": [{
|
||||
"latitude": 37.566,
|
||||
"longitude": 126.9784,
|
||||
"name": "Seoul",
|
||||
"country": "South Korea",
|
||||
"admin1": "Seoul",
|
||||
}]
|
||||
}
|
||||
|
||||
weather_response = Mock()
|
||||
weather_response.raise_for_status = Mock()
|
||||
weather_response.json.return_value = {
|
||||
"current": {
|
||||
"time": "2026-06-12T14:00",
|
||||
"temperature_2m": 24.0,
|
||||
"apparent_temperature": 25.0,
|
||||
"relative_humidity_2m": 55,
|
||||
"weather_code": 0,
|
||||
"wind_speed_10m": 6.0,
|
||||
"wind_gusts_10m": 10.0,
|
||||
},
|
||||
"hourly": {
|
||||
"time": [f"2026-06-12T{h:02d}:00" for h in range(24)],
|
||||
"temperature_2m": [20 + h * 0.2 for h in range(24)],
|
||||
"weather_code": [0] * 24,
|
||||
},
|
||||
"daily": {
|
||||
"time": [f"2026-06-{12+d:02d}" for d in range(7)],
|
||||
"weather_code": [0, 1, 2, 3, 1, 0, 2],
|
||||
"temperature_2m_max": [25, 26, 24, 23, 27, 28, 25],
|
||||
"temperature_2m_min": [16, 17, 15, 14, 18, 19, 16],
|
||||
},
|
||||
}
|
||||
|
||||
mock_get.side_effect = [geo_empty, geo_seoul, weather_response]
|
||||
|
||||
result = self.tool.run({"location": "서울"}, self.context)
|
||||
|
||||
assert result.success is True
|
||||
assert "Seoul" in result.reply_text
|
||||
mock_romanise.assert_called_once()
|
||||
# The romanised name must be what the second geocode actually queried.
|
||||
assert mock_get.call_args_list[1].kwargs["params"]["name"] == "Seoul"
|
||||
|
||||
def test_romanise_place_skips_ascii_names(self):
|
||||
"""ASCII names already geocode, so no LLM round-trip is spent on them."""
|
||||
cfg = Mock()
|
||||
cfg.ollama_base_url = "http://x"
|
||||
cfg.ollama_chat_model = "qwen2.5:3b"
|
||||
cfg.tool_router_model = ""
|
||||
cfg.intent_judge_model = ""
|
||||
assert _romanise_place("Seoul", cfg) is None
|
||||
|
||||
@patch('requests.get')
|
||||
def test_run_location_not_found(self, mock_get):
|
||||
"""Test weather with unknown location."""
|
||||
|
||||
Reference in New Issue
Block a user