5 Commits

Author SHA1 Message Date
javis-bot
67d0ae711c docs(readme): make Discord token setup userbot-first
The "디스코드 토큰은 마지막에" section still told users to fill DISCORD_BOT_TOKEN
and call the `/자비스` slash command, but the default mode is the userbot
(selfbot) — a normal bot account cannot Go Live. Rewrite the setup to lead with
DISCORD_SELFBOT_TOKEN + DISCORD_VOICE_CHANNEL_ID and the text-command control
(`!자비스 join`/`leave`), with the burner-account ToS warning, and keep the
normal-bot path documented as the optional legacy alternative.
2026-06-12 21:59:58 +09:00
javis-bot
932aacef6e feat(bot): delay login until MeloTTS voice is warm
The bot, bridge and melo worker boot together, but the MeloTTS model takes
tens of seconds to load. If the bot logged in and auto-joined the voice channel
before the voice was warm, the first reply synthesised to nothing and was
silently dropped.

- bridge /health now reports `tts_ready`. For MeloTTS this pings the worker,
  which only binds its HTTP port AFTER the model is loaded (main() warms before
  serve_forever()), so a successful ping is a precise "voice is warm" signal.
- The bot polls /health and waits for `tts_ready` before logging in. It does
  not wait on brain_ready (the reply engine / Whisper load lazily on the first
  turn — a slow first turn is fine, a silent one is the bug). After a 180s cap
  it proceeds anyway so a TTS load failure degrades to text-only.

Live-verified: startup logs show " MeloTTS 준비 대기 중" then
"✓ 음성(MeloTTS) 준비 완료 — 로그인 진행" then "✓ 유저봇 로그인", in that order.
2026-06-12 21:59:50 +09:00
javis-bot
ccbaed9030 feat(weather): romanise non-Latin place names before geocoding
Open-Meteo's geocoder only matches Latin/English spellings, so a Korean city
name like "서울" returns zero results even though the place exists. With
OUTPUT_LANGUAGE locked to Korean the tool-calling model naturally fills
`location` with the Korean name, which dead-ended on every weather request
("could not find location").

When the first geocode is empty and the name is non-ASCII, ask the warm small
model for the common English exonym ("서울" -> "Seoul") and retry once. ASCII
names skip the round-trip entirely.

Live-verified: "서울 날씨 알려줘" now returns real Seoul weather. Tests cover the
romanise-and-retry path and the ASCII short-circuit.
2026-06-12 21:59:42 +09:00
javis-bot
f3a1d92620 fix(brain): route auxiliary small-model calls to an available model
The config template never set intent_judge_model, so it fell back to the code
default gemma4:e2b. That model is not pulled by this stack (Ollama only has
qwen2.5:3b, qwen3:8b, nomic-embed-text), so every auxiliary small-model call —
intent judge, tool router, weather place extraction, query decomposition —
targeted a non-existent model, silently failed, and fell open. This crippled
tool routing and argument extraction on the 3B brain.

Render intent_judge_model from a new OLLAMA_INTENT_MODEL env var that defaults
to OLLAMA_CHAT_MODEL, so the auxiliary calls reuse the already-warm chat model
(one resident model, no extra load). tool_router_model="" then resolves through
the chain to the same model.

Verified: rendered jarvis.json now has intent_judge_model=qwen2.5:3b, and the
weather place extractor returns "서울" / "Tokyo" (it returned None for
everything while pointed at the missing gemma4:e2b).
2026-06-12 21:59:35 +09:00
javis-bot
c8a04a110f fix(brain): recover colon-JSON and single tool_call object forms
qwen2.5:3b emits tool calls in text shapes the parser dropped, breaking
two reviewer-reported behaviours:

- `getWeather: {"location": "Seoul"}` (a JSON object after the colon) was
  dumped wholesale into {"query": "{...}"}, so `location` never reached the
  tool. getWeather then ran with empty args, returned the auto-detected
  location's weather, the model noticed the mismatch and retried — looping up
  to 8 times before giving up with an English error. Now the JSON object after
  the colon is parsed directly as the argument dict.
- `call_stop: {"id":..., "function": {"name": "setBroadcast",
  "arguments": "{\"action\": \"stop\"}"}}` — a single tool_call object without
  the `tool_calls: [...]` array wrapper, behind a `call_xxx:` label — matched
  no form, so the raw JSON leaked to the user AND setBroadcast never ran
  ("방송 꺼줘" did nothing). Now name + arguments are pulled from the embedded
  `function` object when the name is in the allow-list.

Field-captured from the live qwen2.5:3b brain (2026-06-12). Tests cover both
shapes, non-ASCII args, dict/string arguments, and unknown-tool rejection.
2026-06-12 21:59:26 +09:00
10 changed files with 351 additions and 5 deletions

View File

@@ -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 # 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. # free-form chit-chat. Swap back to qwen3:8b for the strongest tool-calling.
OLLAMA_CHAT_MODEL=qwen2.5:3b 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 OLLAMA_EMBED_MODEL=nomic-embed-text
WHISPER_MODEL=small WHISPER_MODEL=small

View File

@@ -66,14 +66,20 @@ docker compose up -d --build
### 디스코드 토큰은 마지막에 ### 디스코드 토큰은 마지막에
토큰 없이도 위의 모든 게 정상 동작합니다(봇만 대기). 준비되면: 토큰 없이도 위의 모든 게 정상 동작합니다(봇만 대기). 준비되면 `.env`를 만들어 토큰을 채웁니다.
기본 모드는 **유저봇(selfbot)** 입니다. 음성 참여와 화면 송출(Go Live)을 한 유저 계정으로 처리하며, Discord가 일반 봇 계정에는 Go Live를 허용하지 않기 때문에 이 방식이 기본입니다.
```bash ```bash
cp .env.example .env # DISCORD_BOT_TOKEN / DISCORD_APP_ID / DISCORD_GUILD_ID 채우기 cp .env.example .env # DISCORD_SELFBOT_TOKEN(버너 계정) + DISCORD_VOICE_CHANNEL_ID 채우기
docker compose up -d # 봇이 시작되고 /자비스 명령 등록 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) ### GPU 가속 (기본 ON)

View File

@@ -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 // 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 // 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. // (voice + broadcast on one user account). Otherwise run the legacy normal bot.
(async () => { (async () => {
await waitForBridgeReady();
if (!config.botToken && config.selfbotToken) { if (!config.botToken && config.selfbotToken) {
const { runUserbot } = await import("./userbot.ts"); const { runUserbot } = await import("./userbot.ts");
await runUserbot(); await runUserbot();

View File

@@ -255,6 +255,29 @@ def _piper_synthesize(text: str) -> Optional[bytes]:
return buf.getvalue() 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]: def synthesize(text: str) -> Optional[bytes]:
"""Synthesize text to a 16-bit PCM WAV. The primary voice is MeloTTS """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 (Korean speaker, speed 1.5) served by the warm melo worker; Piper is a
@@ -286,6 +309,7 @@ def health():
"brain_error": _brain_error, "brain_error": _brain_error,
"tts_enabled": TTS_ENABLED, "tts_enabled": TTS_ENABLED,
"tts_engine": TTS_ENGINE, "tts_engine": TTS_ENGINE,
"tts_ready": _tts_ready(),
} }
) )

View File

@@ -9,6 +9,13 @@ set -euo pipefail
: "${VNC_RESOLUTION:=1920x1080}" : "${VNC_RESOLUTION:=1920x1080}"
: "${OLLAMA_BASE_URL:=http://ollama:11434}" : "${OLLAMA_BASE_URL:=http://ollama:11434}"
: "${OLLAMA_CHAT_MODEL:=qwen3:8b}" : "${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}" : "${OLLAMA_EMBED_MODEL:=nomic-embed-text}"
: "${WHISPER_MODEL:=small}" : "${WHISPER_MODEL:=small}"
: "${WHISPER_DEVICE:=cuda}" : "${WHISPER_DEVICE:=cuda}"
@@ -25,7 +32,7 @@ set -euo pipefail
: "${XDG_RUNTIME_DIR:=/run/user/0}" : "${XDG_RUNTIME_DIR:=/run/user/0}"
: "${PULSE_SERVER:=unix:${XDG_RUNTIME_DIR}/pulse/native}" : "${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 \ WHISPER_MODEL WHISPER_DEVICE WHISPER_COMPUTE_TYPE JARVIS_DB_PATH \
PIPER_VOICE PIPER_VOICE_DIR TTS_PIPER_MODEL_PATH BRIDGE_HOST BRIDGE_PORT \ PIPER_VOICE PIPER_VOICE_DIR TTS_PIPER_MODEL_PATH BRIDGE_HOST BRIDGE_PORT \
XDG_RUNTIME_DIR PULSE_SERVER XDG_RUNTIME_DIR PULSE_SERVER

View File

@@ -4,6 +4,7 @@
"ollama_base_url": "${OLLAMA_BASE_URL}", "ollama_base_url": "${OLLAMA_BASE_URL}",
"ollama_embed_model": "${OLLAMA_EMBED_MODEL}", "ollama_embed_model": "${OLLAMA_EMBED_MODEL}",
"ollama_chat_model": "${OLLAMA_CHAT_MODEL}", "ollama_chat_model": "${OLLAMA_CHAT_MODEL}",
"intent_judge_model": "${OLLAMA_INTENT_MODEL}",
"tts_enabled": true, "tts_enabled": true,
"tts_engine": "piper", "tts_engine": "piper",
"tts_piper_model_path": "${TTS_PIPER_MODEL_PATH}", "tts_piper_model_path": "${TTS_PIPER_MODEL_PATH}",

View File

@@ -431,6 +431,21 @@ def _extract_text_tool_call(content_field: str, known_names: set):
if m and m.group(1) in known_names: if m and m.group(1) in known_names:
name = m.group(1) name = m.group(1)
rest = m.group(2).strip() 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 = {} args: dict = {}
for pair in re.split(r"[\n,]", rest): for pair in re.split(r"[\n,]", rest):
pair = pair.strip() 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("'")} parsed_args = {"query": inside.strip().strip('"').strip("'")}
return name, parsed_args, f"call_{uuid.uuid4().hex[:8]}" 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 return None, None, None

View File

@@ -83,6 +83,65 @@ def _extract_place_from_user_text(text: str, cfg) -> Optional[str]:
return place 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 # WMO Weather interpretation codes
# https://open-meteo.com/en/docs # https://open-meteo.com/en/docs
WMO_CODES = { WMO_CODES = {
@@ -274,6 +333,23 @@ class WeatherTool(Tool):
geo_response.raise_for_status() geo_response.raise_for_status()
geo_data = geo_response.json() 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"): if not geo_data.get("results"):
return ToolExecutionResult( return ToolExecutionResult(
success=False, success=False,

View File

@@ -84,6 +84,75 @@ class TestSimplifiedColonForm:
assert name is None 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: class TestFunctionCallForm:
"""Form 3: `toolName(...)`.""" """Form 3: `toolName(...)`."""

View File

@@ -8,6 +8,7 @@ from src.jarvis.tools.builtin.weather import (
WeatherTool, WeatherTool,
WMO_CODES, WMO_CODES,
_extract_place_from_user_text, _extract_place_from_user_text,
_romanise_place,
) )
from src.jarvis.tools.base import ToolContext from src.jarvis.tools.base import ToolContext
from src.jarvis.tools.types import ToolExecutionResult from src.jarvis.tools.types import ToolExecutionResult
@@ -100,6 +101,77 @@ class TestWeatherTool:
assert "7-day forecast" in result.reply_text assert "7-day forecast" in result.reply_text
self.context.user_print.assert_called() 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') @patch('requests.get')
def test_run_location_not_found(self, mock_get): def test_run_location_not_found(self, mock_get):
"""Test weather with unknown location.""" """Test weather with unknown location."""