8 Commits

Author SHA1 Message Date
javis-bot
23b1fe692b docs: warn against setting OLLAMA_INTENT_MODEL larger than the chat model
Some checks failed
Release / semantic-release (push) Successful in 20s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m19s
Release / build-linux (push) Failing after 7m13s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
A deployment had OLLAMA_INTENT_MODEL=qwen2.5:7b while the chat model was a 4b,
so every auxiliary call (intent judge, tool router, place extraction, query
decomposition) ran on the bigger, slower model and added latency to each
command. Make the .env.example comment state the invariant explicitly.
2026-06-24 17:57:30 +09:00
javis-bot
7bb9718c34 feat(reply): cap spoken replies at a single sentence
Replies stayed long because the prompt stack gave conflicting length signals:
the persona said "one sentence (two at the very most)" AND told the model to
"state the answer in a sentence, then add a dry observation" (a 2nd sentence),
while voice_style said "two to three sentences maximum". The model followed the
longest. Make all three sources agree on exactly one sentence: the persona's
aside must now fold into the same sentence as a trailing clause (never a 2nd
sentence), voice_style caps at one sentence, and agents/llm.md says 한 문장.
Shorter replies also cut Edge-TTS latency, since synth time scales with text
length. Specs (prompts.spec.md) and docs/llm_contexts.md updated; deterministic
prompt-contract tests added.
2026-06-24 17:55:27 +09:00
javis-bot
7da2fcb5e5 feat(stt): beam-search decoding + no prev-text conditioning for accuracy
Whisper was decoding with beam_size=1 (greedy), the least accurate setting,
which hurt recognition on short/accented/noisy Discord-mic speech. Switch the
default to beam search (5, Whisper's own default) and stop conditioning on the
previous clip's transcript (which causes repetition/drift on isolated short
utterances rather than helping). Both are env-tunable (STT_BEAM_SIZE,
STT_CONDITION_ON_PREV) so accuracy/latency can be traded without a code change;
wired into docker-compose and documented in .env.example.
2026-06-24 17:55:20 +09:00
javis-bot
680f5a656a docs: reflect the separate auxiliary intent/router model in llm_contexts + README
Some checks failed
Release / semantic-release (push) Successful in 18s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m18s
Release / build-linux (push) Failing after 7m6s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Follow-up to the OLLAMA_INTENT_MODEL split: document that the Docker stack runs
intent judging / tool routing / extraction on a small qwen2.5:3b (pulled by
ollama-init) kept separate from the big chat answer model, and that setting
OLLAMA_INTENT_MODEL = OLLAMA_CHAT_MODEL folds them back onto one resident model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 17:38:58 +09:00
javis-bot
b52ffd2b18 perf: run auxiliary LLM calls on a small model, big model only for the answer
Some checks failed
Release / semantic-release (push) Successful in 19s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
tests / Unit tests (Linux, Python 3.11) (push) Has been cancelled
Intent judging, tool routing and arg extraction are classification/JSON calls,
not the spoken answer, yet the stack aliased OLLAMA_INTENT_MODEL back to the big
chat model — so each command paid the big model's cost 2-3 times for routing
before the reply even ran. With the GPU on, that round-trip stacking is the main
remaining per-turn latency. Default OLLAMA_INTENT_MODEL to qwen2.5:3b (the
project's reference small model, clean Korean on classification) and have
ollama-init pull it. The reply engine already routes these calls through
intent_judge_model, so answer quality is untouched; set OLLAMA_INTENT_MODEL =
OLLAMA_CHAT_MODEL to fold back onto one resident model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 17:35:40 +09:00
javis-bot
140fc56f18 feat: play the Nth YouTube result in browseAndPlay via an index arg
Some checks failed
Release / semantic-release (push) Successful in 22s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m16s
Release / build-linux (push) Failing after 7m10s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
agents/llm.md promises "play the Nth video from the top", but browseAndPlay
only ever clicked the first result. Add an optional 1-based index argument
(default 1, backward-compatible) threaded to the Node helper, which now clicks
the Nth a#video-title and clamps to the number of results returned so asking
beyond the list plays the last available video instead of failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:33:45 +09:00
javis-bot
5ee47827f3 perf: cap chat output tokens via ollama_num_predict to bound reply latency
Spoken (TTS) replies are 1-2 sentences, so an unbounded num_predict only
exposes the worst case where the chat model rambles or loops. Add an
ollama_num_predict config (default 512, 0 disables) wired into the reply
loop's chat call on both the native- and text-tool paths. The 512-token
headroom stays well above this app's short tool-call JSON, so capping never
truncates a tool call. This keeps the user's quality model instead of
downgrading it. Configurable in the container via OLLAMA_NUM_PREDICT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:33:45 +09:00
javis-bot
c189ce2e65 feat: Korean Chrome locale, agents/llm.md voice instructions, drop emoji rule
Some checks failed
Release / semantic-release (push) Successful in 26s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m17s
Release / build-linux (push) Failing after 7m7s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
- run-chrome.sh: render web content (YouTube/Google/Naver) in Korean. --lang
  only sets Chrome's UI; the Accept-Language sent to sites comes from the
  profile's intl.accept_languages, which a persisted user-data-dir kept at
  en-US. Seed the profile to ko-KR and add --accept-lang=ko-KR,ko.
- agents/llm.md: runtime instructions for the reply LLM (loaded by the agents
  feature) — name "자비스", concise 1-2 sentence TTS replies (no emojis/markdown),
  nuance-based intent, and YouTube voice controls (open/search/play Nth/pause/
  back) via the on-screen browser tool.
- CLAUDE.md: drop the "use emojis in CLI output" rule — this assistant replies by
  Discord voice, not CLI, so output should be plain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 04:34:17 +09:00
24 changed files with 471 additions and 49 deletions

View File

@@ -59,11 +59,15 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
# 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 # Model for the auxiliary small-model calls: intent judge, tool router, weather
# place extraction, query decomposition. BLANK (default) reuses OLLAMA_CHAT_MODEL # place extraction, query decomposition. These are classification/JSON calls,
# so the stack runs on one already-warm model. The code's built-in default # NOT the spoken answer, so a small fast model here cuts 2-3 big-model round
# (gemma4:e2b) is NOT pulled by this stack, so leaving this unset previously made # trips per command without touching answer quality. BLANK uses the stack
# every router/extractor call silently fail. Only set this if you also pull the # default qwen2.5:3b, which ollama-init pulls automatically. Set it equal to
# model into Ollama. # OLLAMA_CHAT_MODEL to run everything on one resident model instead (saves VRAM
# at the cost of slower routing when the chat model is large).
# NEVER set this LARGER than OLLAMA_CHAT_MODEL: the auxiliary calls would then
# run on the bigger, slower model and add latency to every command (the exact
# opposite of the split's purpose). Keep it <= the chat model, blank, or equal.
OLLAMA_INTENT_MODEL= OLLAMA_INTENT_MODEL=
OLLAMA_EMBED_MODEL=nomic-embed-text OLLAMA_EMBED_MODEL=nomic-embed-text
WHISPER_MODEL=small WHISPER_MODEL=small
@@ -226,6 +230,7 @@ COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml
# OLLAMA_CHAT_MODEL=qwen2.5:7b # quality (needs ~5GB VRAM + whisper small) # OLLAMA_CHAT_MODEL=qwen2.5:7b # quality (needs ~5GB VRAM + whisper small)
# OLLAMA_CHAT_MODEL=qwen2.5:3b # speed (fits easily, faster on 8GB GPUs) # OLLAMA_CHAT_MODEL=qwen2.5:3b # speed (fits easily, faster on 8GB GPUs)
# WHISPER_MODEL=small # small frees VRAM for a bigger LLM; medium=more accurate # WHISPER_MODEL=small # small frees VRAM for a bigger LLM; medium=more accurate
# STT_BEAM_SIZE=5 # beam search (5) > greedy (1) for accuracy; lower for speed
# MELO_DEVICE=cuda # cpu if no GPU on the bot host # MELO_DEVICE=cuda # cpu if no GPU on the bot host
# --- Settings web UI (http://localhost:8765/settings on the bot host) --- # --- Settings web UI (http://localhost:8765/settings on the bot host) ---

View File

@@ -1,6 +1,6 @@
Data privacy comes first, always. Data privacy comes first, always.
All user-facing command line output should make use of emojis. Especially an initial emoji to start off the lines that depict what the line is about. Output should make use of indentation spacing to establish a visual hierarchy and aim to make output as easy to sift through as possible. Exception: Windows .bat scripts cannot use emojis (cmd.exe doesn't render Unicode properly). This assistant is used through a Discord bot with voice (TTS) replies, not a CLI. Do not add emojis to user-facing assistant output. Keep output plain and readable. (Runtime assistant behaviour lives in `agents/*.md`, which is injected into the reply LLM's prompt.)
Any important point in our logical flows should have debug logs using the `debug_log` method from `src/jarvis/debug.py`. Avoid excessive logging to keep the logs easily readable and actionable. Any important point in our logical flows should have debug logs using the `debug_log` method from `src/jarvis/debug.py`. Avoid excessive logging to keep the logs easily readable and actionable.

View File

@@ -87,7 +87,7 @@ COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml
> Linux와 Windows는 GPU를 컨테이너에 넣는 방식이 달라서 override 파일이 갈립니다. Linux는 CDI(`devices: nvidia.com/gpu=all`), Windows(Docker Desktop)는 Compose의 `deploy.resources.reservations.devices`(`driver: nvidia`)를 씁니다. 호스트 사전 준비는 아래 "GPU 가속" 절 참고. > Linux와 Windows는 GPU를 컨테이너에 넣는 방식이 달라서 override 파일이 갈립니다. Linux는 CDI(`devices: nvidia.com/gpu=all`), Windows(Docker Desktop)는 Compose의 `deploy.resources.reservations.devices`(`driver: nvidia`)를 씁니다. 호스트 사전 준비는 아래 "GPU 가속" 절 참고.
`docker compose up` 한 번이면 자동으로: `docker compose up` 한 번이면 자동으로:
- Ollama 서버가 뜨고, `ollama-init`이 채팅/임베딩 모델을 **자동 pull** - Ollama 서버가 뜨고, `ollama-init`이 채팅/보조(의도·라우팅)/임베딩 모델을 **자동 pull** (보조 모델 `OLLAMA_INTENT_MODEL`은 기본 `qwen2.5:3b`로, 큰 채팅 모델은 답변에만 쓰고 내부 분류 호출은 이 작은 모델이 처리)
- VNC+XFCE 데스크톱 + 크롬 + Python 브릿지가 기동 - VNC+XFCE 데스크톱 + 크롬 + Python 브릿지가 기동
- Whisper STT 모델 / Piper TTS 음성 자동 다운로드(볼륨에 캐시) - Whisper STT 모델 / Piper TTS 음성 자동 다운로드(볼륨에 캐시)

13
agents/llm.md Normal file
View File

@@ -0,0 +1,13 @@
# 자비스 운영자 지시
- 너의 이름은 자비스다.
- 모든 답변은 음성(TTS)으로 읽혀 나간다. 그러니 무조건 한 문장으로만 답한다. 두 문장 이상 쓰지 않는다. 목록, 마크다운, 이모지, 그리고 소리 내어 읽기 어려운 특수문자는 쓰지 않는다.
- 정해진 문구에만 반응하지 말고, 실제 사람처럼 말의 뉘앙스와 맥락으로 의도를 알아듣고 처리한다.
화면 속 크롬(방송 화면)에서 유튜브를 다룰 때 (화면에 보여야 하므로 반드시 on-screen 브라우저 제어 도구로 수행한다):
- "유튜브 켜줘" → 방송 크롬에서 유튜브를 연다.
- "유튜브에서 OO 검색해줘" → 유튜브로 가서 검색창에 OO를 사람이 직접 타이핑하듯 입력하고 검색한다.
- "위에서 N번째 영상 재생해줘" 또는 "왼쪽에서 N번째 영상 재생해줘" → 검색 결과 목록에서 그 위치의 영상을 재생한다.
- "일시정지해줘" → 현재 영상을 일시정지한다. "다시 재생해줘" → 이어서 재생한다.
- "영상 종료" 또는 "그만 보여줘" → 뒤로 가서 직전 화면으로 돌아간다.

View File

@@ -2,10 +2,11 @@
// 9222) so the action is visible on the Go-Live broadcast, and prints a JSON // 9222) so the action is visible on the Go-Live broadcast, and prints a JSON
// result on stdout for the Python `browseAndSearch` tool to wrap. // result on stdout for the Python `browseAndSearch` tool to wrap.
// //
// node browse-search.mjs "<query>" [search|youtube] // node browse-search.mjs "<query>" [search|youtube] [index]
// //
// - search : Google-search the query, return the top organic results. // - search : Google-search the query, return the top organic results.
// - youtube : search YouTube and play the first result. // - youtube : search YouTube and play a result. `index` is the 1-based position
// from the top of the result list (default 1 = first result).
// //
// Backend selection for `search`: // Backend selection for `search`:
// 1. The broadcast Chrome over CDP (visible on the Go-Live stream). // 1. The broadcast Chrome over CDP (visible on the Go-Live stream).
@@ -29,6 +30,9 @@ const UA =
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'; '(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
const query = process.argv[2] || ''; const query = process.argv[2] || '';
const mode = (process.argv[3] || 'search').toLowerCase(); const mode = (process.argv[3] || 'search').toLowerCase();
// 1-based position of the YouTube result to play, counted from the top of the
// list. Defaults to 1 (first result). Anything <1 or non-numeric falls back to 1.
const playIndex = Math.max(1, parseInt(process.argv[4], 10) || 1);
const out = (o) => { process.stdout.write(JSON.stringify(o)); }; const out = (o) => { process.stdout.write(JSON.stringify(o)); };
if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); } if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); }
@@ -105,15 +109,21 @@ try {
await page.bringToFront().catch(() => {}); await page.bringToFront().catch(() => {});
if (mode === 'youtube') { if (mode === 'youtube') {
// Type into YouTube's search box like a person, then play the first result. // Type into YouTube's search box like a person, then play the requested
// result (the Nth from the top of the list; default the first).
await typeSearch('https://www.youtube.com/?hl=ko', 'input#search, input[name="search_query"]', query); await typeSearch('https://www.youtube.com/?hl=ko', 'input#search, input[name="search_query"]', query);
await page.waitForSelector('ytd-video-renderer a#video-title, a#video-title', { timeout: 20000 }); await page.waitForSelector('ytd-video-renderer a#video-title, a#video-title', { timeout: 20000 });
const first = page.locator('ytd-video-renderer a#video-title, a#video-title').first(); const results = page.locator('ytd-video-renderer a#video-title, a#video-title');
const title = (await first.getAttribute('title').catch(() => '')) || (await first.innerText().catch(() => '')); // Clamp to what's actually on the page so "play the 5th" still plays the
await first.click(); // last available result rather than failing when fewer were returned.
const available = await results.count();
const targetIdx = Math.min(playIndex, Math.max(available, 1)) - 1;
const target = results.nth(targetIdx);
const title = (await target.getAttribute('title').catch(() => '')) || (await target.innerText().catch(() => ''));
await target.click();
await page.waitForSelector('#movie_player', { timeout: 20000 }); await page.waitForSelector('#movie_player', { timeout: 20000 });
await page.evaluate(() => { const v = document.querySelector('video'); if (v && v.paused) v.play(); }); await page.evaluate(() => { const v = document.querySelector('video'); if (v && v.paused) v.play(); });
out({ ok: true, mode, title: (title || '').trim(), url: page.url() }); out({ ok: true, mode, index: targetIdx + 1, title: (title || '').trim(), url: page.url() });
} else { } else {
// Type into Google's search box like a person, then read the results. // Type into Google's search box like a person, then read the results.
await typeSearch('https://www.google.com/?hl=ko', 'textarea[name="q"], input[name="q"]', query); await typeSearch('https://www.google.com/?hl=ko', 'textarea[name="q"], input[name="q"]', query);

View File

@@ -87,6 +87,17 @@ VAD_MIN_SPEECH_MS = int(os.environ.get("VAD_MIN_SPEECH_MS", "200"))
# Korean phrase decoded as Chinese) and shaves a little latency. Empty = auto. # Korean phrase decoded as Chinese) and shaves a little latency. Empty = auto.
STT_LANGUAGE = os.environ.get("STT_LANGUAGE", "ko").strip() or None STT_LANGUAGE = os.environ.get("STT_LANGUAGE", "ko").strip() or None
# Whisper decoding accuracy knobs. beam_size=1 is greedy decoding — fast but the
# least accurate; beam search (5 is the Whisper default) explores alternatives
# and noticeably improves recognition on short, accented, or noisy Discord-mic
# speech. condition_on_previous_text=False stops Whisper from feeding a previous
# clip's transcript back in as a prompt, which on isolated short utterances
# causes repetition loops and drift rather than helping. Both are env-tunable so
# accuracy/latency can be traded without a code change (lower STT_BEAM_SIZE for
# speed, raise it for accuracy).
STT_BEAM_SIZE = max(1, int(os.environ.get("STT_BEAM_SIZE", "5")))
STT_CONDITION_ON_PREV = os.environ.get("STT_CONDITION_ON_PREV", "0") in ("1", "true", "True", "yes", "on")
# TTS engine: "edge" (Microsoft Edge TTS, natural Korean neural voice) is the # TTS engine: "edge" (Microsoft Edge TTS, natural Korean neural voice) is the
# primary voice. "melo" (a warm MeloTTS worker) and "piper" remain selectable. # primary voice. "melo" (a warm MeloTTS worker) and "piper" remain selectable.
def _tts_engine_setting() -> str: def _tts_engine_setting() -> str:
@@ -243,7 +254,12 @@ def transcribe(wav_bytes: bytes) -> dict:
print("[bridge] no speech detected (VAD) — skipping STT", flush=True) print("[bridge] no speech detected (VAD) — skipping STT", flush=True)
return {"text": "", "language": None, "note": "음성 아님(VAD 차단)"} return {"text": "", "language": None, "note": "음성 아님(VAD 차단)"}
segments, info = _whisper.transcribe(audio, beam_size=1, language=STT_LANGUAGE) segments, info = _whisper.transcribe(
audio,
beam_size=STT_BEAM_SIZE,
language=STT_LANGUAGE,
condition_on_previous_text=STT_CONDITION_ON_PREV,
)
# Second line of defence: drop non-speech / hallucinated segments by # Second line of defence: drop non-speech / hallucinated segments by
# Whisper's own no_speech_prob. The no_speech_prob hard cutoff (plus the VAD # Whisper's own no_speech_prob. The no_speech_prob hard cutoff (plus the VAD
# pre-gate above) is what rejects noise/hallucinations. The avg_logprob # pre-gate above) is what rejects noise/hallucinations. The avg_logprob

View File

@@ -40,6 +40,9 @@ services:
environment: environment:
OLLAMA_HOST: http://ollama:11434 OLLAMA_HOST: http://ollama:11434
CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen2.5:3b} CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen2.5:3b}
# Small auxiliary model for intent/router/extraction calls (see javis
# service). Pulled here so the split is ready out of the box.
INTENT_MODEL: ${OLLAMA_INTENT_MODEL:-qwen2.5:3b}
EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text} EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text}
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
@@ -48,6 +51,10 @@ services:
until ollama list >/dev/null 2>&1; do sleep 2; done; until ollama list >/dev/null 2>&1; do sleep 2; done;
echo "[ollama-init] pulling $$CHAT_MODEL"; echo "[ollama-init] pulling $$CHAT_MODEL";
ollama pull "$$CHAT_MODEL"; ollama pull "$$CHAT_MODEL";
if [ -n "$$INTENT_MODEL" ] && [ "$$INTENT_MODEL" != "$$CHAT_MODEL" ]; then
echo "[ollama-init] pulling $$INTENT_MODEL (auxiliary intent/router model)";
ollama pull "$$INTENT_MODEL";
fi;
echo "[ollama-init] pulling $$EMBED_MODEL"; echo "[ollama-init] pulling $$EMBED_MODEL";
ollama pull "$$EMBED_MODEL"; ollama pull "$$EMBED_MODEL";
echo "[ollama-init] models ready."; echo "[ollama-init] models ready.";
@@ -62,6 +69,14 @@ services:
# Point the brain at the ollama service and the bot at the in-container bridge. # Point the brain at the ollama service and the bot at the in-container bridge.
OLLAMA_BASE_URL: http://ollama:11434 OLLAMA_BASE_URL: http://ollama:11434
OLLAMA_CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen2.5:3b} OLLAMA_CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen2.5:3b}
# Auxiliary small-model calls (intent judge, tool router, arg extraction,
# query decomposition) run on this fast model so the big chat model only
# runs for the actual spoken answer. With the GPU on, this is the main
# per-turn latency win: a command no longer pays the big model's cost 2-3
# times for routing/extraction. Defaults to qwen2.5:3b (the project's
# reference small model, clean Korean on classification); set it equal to
# OLLAMA_CHAT_MODEL to fold everything back onto one resident model.
OLLAMA_INTENT_MODEL: ${OLLAMA_INTENT_MODEL:-qwen2.5:3b}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text} OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text}
WHISPER_MODEL: ${WHISPER_MODEL:-medium} WHISPER_MODEL: ${WHISPER_MODEL:-medium}
WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda} WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda}
@@ -82,6 +97,9 @@ services:
PLANNER_ENABLED: ${PLANNER_ENABLED:-0} PLANNER_ENABLED: ${PLANNER_ENABLED:-0}
# Lock STT to Korean (skip Whisper auto-detect). # Lock STT to Korean (skip Whisper auto-detect).
STT_LANGUAGE: ${STT_LANGUAGE:-ko} STT_LANGUAGE: ${STT_LANGUAGE:-ko}
# Whisper decode accuracy: beam search (5) over greedy (1) lifts recognition
# on short/noisy Discord speech. Lower to 1 for minimum latency.
STT_BEAM_SIZE: ${STT_BEAM_SIZE:-5}
VOICE_SILENCE_MS: ${VOICE_SILENCE_MS:-600} VOICE_SILENCE_MS: ${VOICE_SILENCE_MS:-600}
BRIDGE_URL: http://127.0.0.1:8765 BRIDGE_URL: http://127.0.0.1:8765
# Split-deployment role: full (default, all-in-one), browser (only the # Split-deployment role: full (default, all-in-one), browser (only the

View File

@@ -10,12 +10,15 @@ set -euo pipefail
: "${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 # Auxiliary small-model calls (intent judge, tool router, weather place
# extraction, query decomposition). The code default is gemma4:e2b, which this # extraction, query decomposition). Default to a small fast model so the big
# stack does not pull, so those calls would silently fail and fall open — # chat model only runs for the actual spoken answer — the main per-turn latency
# crippling tool routing and arg extraction. Reuse the (already warm) chat model # win once the GPU is in use, since the 2-3 routing/extraction calls per command
# by default so everything runs on one resident model; override if you pull a # no longer pay the big model's cost. ollama-init pulls this model. Set it equal
# dedicated small model. # to OLLAMA_CHAT_MODEL to fold everything back onto one resident model.
: "${OLLAMA_INTENT_MODEL:=${OLLAMA_CHAT_MODEL}}" : "${OLLAMA_INTENT_MODEL:=qwen2.5:3b}"
# Cap chat-model output tokens per turn (worst-case latency guard). Spoken
# answers are 1-2 sentences; 512 is safe headroom above tool-call JSON. 0 = off.
: "${OLLAMA_NUM_PREDICT:=512}"
: "${OLLAMA_EMBED_MODEL:=nomic-embed-text}" : "${OLLAMA_EMBED_MODEL:=nomic-embed-text}"
: "${WHISPER_MODEL:=small}" : "${WHISPER_MODEL:=small}"
: "${WHISPER_DEVICE:=cuda}" : "${WHISPER_DEVICE:=cuda}"
@@ -32,7 +35,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_INTENT_MODEL OLLAMA_EMBED_MODEL \ export VNC_RESOLUTION OLLAMA_BASE_URL OLLAMA_CHAT_MODEL OLLAMA_NUM_PREDICT 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}",
"ollama_num_predict": "${OLLAMA_NUM_PREDICT}",
"intent_judge_model": "${OLLAMA_INTENT_MODEL}", "intent_judge_model": "${OLLAMA_INTENT_MODEL}",
"tts_enabled": true, "tts_enabled": true,
"tts_engine": "${TTS_ENGINE}", "tts_engine": "${TTS_ENGINE}",

View File

@@ -18,6 +18,27 @@ cat > /etc/opt/chrome/policies/managed/jarvis.json <<'JSON'
{ "CommandLineFlagSecurityWarningsEnabled": false } { "CommandLineFlagSecurityWarningsEnabled": false }
JSON JSON
# Seed the profile's web-content language to Korean so sites (YouTube, Google,
# Naver) render in Korean. --lang sets Chrome's own UI, but the Accept-Language
# sent to sites comes from the profile's intl.accept_languages, which a persisted
# user-data-dir would otherwise keep at en-US regardless of --accept-lang.
PREFS_DIR="${CHROME_PROFILE_DIR:-/root/chrome-profile}/Default"
PREFS="${PREFS_DIR}/Preferences"
mkdir -p "$PREFS_DIR"
if [ -f "$PREFS" ]; then
python3 - "$PREFS" <<'PY' 2>/dev/null || true
import json, sys
p = sys.argv[1]
d = json.load(open(p))
d.setdefault("intl", {})
d["intl"]["accept_languages"] = "ko-KR,ko"
d["intl"]["selected_languages"] = "ko-KR,ko"
json.dump(d, open(p, "w"), ensure_ascii=False)
PY
else
printf '%s' '{"intl":{"accept_languages":"ko-KR,ko","selected_languages":"ko-KR,ko"}}' > "$PREFS"
fi
# Minimal, non-automation flags. --remote-debugging exposes CDP so the brain can # Minimal, non-automation flags. --remote-debugging exposes CDP so the brain can
# drive this on-screen Chrome (Google/YouTube/Naver), --disable-features=Translate # drive this on-screen Chrome (Google/YouTube/Naver), --disable-features=Translate
# hides the translate popup. NO --test-type / --disable-blink-features. # hides the translate popup. NO --test-type / --disable-blink-features.
@@ -26,6 +47,7 @@ exec google-chrome \
--no-default-browser-check \ --no-default-browser-check \
--disable-features=Translate,TranslateUI \ --disable-features=Translate,TranslateUI \
--lang=ko-KR \ --lang=ko-KR \
--accept-lang=ko-KR,ko \
--remote-debugging-port="${CDP_PORT:-9222}" \ --remote-debugging-port="${CDP_PORT:-9222}" \
--remote-debugging-address="${CDP_BIND:-127.0.0.1}" \ --remote-debugging-address="${CDP_BIND:-127.0.0.1}" \
--user-data-dir="${CHROME_PROFILE_DIR:-/root/chrome-profile}" \ --user-data-dir="${CHROME_PROFILE_DIR:-/root/chrome-profile}" \

View File

@@ -19,14 +19,14 @@ Every distinct LLM call in Jarvis, what feeds it, what consumes it, and how it i
- Time + location context (re-injected each turn) - Time + location context (re-injected each turn)
- Tool schema: native via `generate_tools_json_schema()` ([src/jarvis/tools/registry.py](src/jarvis/tools/registry.py)) or text fallback via `_text_tool_call_guidance()` ([engine.py:68](src/jarvis/reply/engine.py:68)) - Tool schema: native via `generate_tools_json_schema()` ([src/jarvis/tools/registry.py](src/jarvis/tools/registry.py)) or text fallback via `_text_tool_call_guidance()` ([engine.py:68](src/jarvis/reply/engine.py:68))
- Tool results from prior turns (raw or digested — see #5) - Tool results from prior turns (raw or digested — see #5)
- **Output**: OpenAI-style `{content, tool_calls, thinking}`. Consumed by the tool orchestrator and TTS pipeline. Natural-language content is delivered immediately; no post-turn evaluator runs. - **Output**: OpenAI-style `{content, tool_calls, thinking}`. Consumed by the tool orchestrator and TTS pipeline. Natural-language content is delivered immediately; no post-turn evaluator runs. Spoken-answer length: the persona (`system_prompt.py`) and `voice_style` (`prompts/system.py`) both constrain the reply to a SINGLE sentence — any dry aside must fold into that one sentence as a trailing clause, never a second sentence. This keeps TTS latency down (synth time scales with text length) and matches the `agents/llm.md` operator instruction.
- **Limits**: `num_ctx: 8192` (explicit). Timeout `llm_chat_timeout_sec` (45s). Auto-fallback from native to text tool-calls on HTTP 400 (`ToolsNotSupportedError`), sticky for the session. Risk: `fetch_web_page` truncates at 50,000 chars (~37k tokens) — mitigated for SMALL models by tool-result digest (#5) which compresses the payload before it enters the messages history. LARGE models receive the raw payload and may silently see a truncated context. - **Limits**: `num_ctx: 8192` (explicit). Output `num_predict: cfg.ollama_num_predict` (default 512, `0`/negative disables) caps generated tokens per turn — a worst-case latency guard for short spoken answers; the headroom stays above tool-call JSON so it does not truncate tool calls (both native and text tool-call paths). Timeout `llm_chat_timeout_sec` (45s). Auto-fallback from native to text tool-calls on HTTP 400 (`ToolsNotSupportedError`), sticky for the session. Risk: `fetch_web_page` truncates at 50,000 chars (~37k tokens) — mitigated for SMALL models by tool-result digest (#5) which compresses the payload before it enters the messages history. LARGE models receive the raw payload and may silently see a truncated context.
## 2. Intent Judge ## 2. Intent Judge
- **File**: [src/jarvis/listening/intent_judge.py](src/jarvis/listening/intent_judge.py) — `IntentJudge.evaluate()`. - **File**: [src/jarvis/listening/intent_judge.py](src/jarvis/listening/intent_judge.py) — `IntentJudge.evaluate()`.
- **Trigger**: on a speech segment *only if* there is an engagement signal (wake word detected, hot-window active, or TTS playing). Pure ambient speech skips it. - **Trigger**: on a speech segment *only if* there is an engagement signal (wake word detected, hot-window active, or TTS playing). Pure ambient speech skips it.
- **Model / gating**: `cfg.intent_judge_model` (default `gemma4:e2b`, ~2B). Falls back to text-based wake detection if Ollama is unavailable. - **Model / gating**: `cfg.intent_judge_model`. Code-level default `gemma4:e2b` (~2B); the **Docker stack** renders it from `OLLAMA_INTENT_MODEL` (default `qwen2.5:3b`, pulled by `ollama-init`), kept deliberately **separate from `ollama_chat_model`** so this judge and the tool router (#3, #7) run on a small fast model while the big chat model is reserved for the spoken answer. Setting `OLLAMA_INTENT_MODEL = OLLAMA_CHAT_MODEL` folds them back onto one resident model. Falls back to text-based wake detection if Ollama is unavailable.
- **Inputs**: - **Inputs**:
- Rolling transcript buffer (last 120s, with timestamps) - Rolling transcript buffer (last 120s, with timestamps)
- Wake-word timestamp (if any), normalised aliases - Wake-word timestamp (if any), normalised aliases
@@ -246,7 +246,7 @@ user input
3. Pre-warm the intent-judge model before TTS finishes. 3. Pre-warm the intent-judge model before TTS finishes.
4. Cache tool-router (#7) output by query hash. 4. Cache tool-router (#7) output by query hash.
5. Give each digest its own timeout budget rather than sharing `llm_digest_timeout_sec` (today a slow memory digest can starve the max-turn digest). 5. Give each digest its own timeout budget rather than sharing `llm_digest_timeout_sec` (today a slow memory digest can starve the max-turn digest).
6. Consider single-model deployments: router+planner prefer `intent_judge_model`; loading a second model hurts cold-start latency on small hardware. 6. Two-model vs single-model tradeoff: the Docker default keeps a **separate** small `intent_judge_model` (`OLLAMA_INTENT_MODEL=qwen2.5:3b`) so routing/judging/extraction don't pay the big chat model's per-call cost — the main win once the GPU holds both models resident. On VRAM-constrained hardware, fold them onto one model by setting `OLLAMA_INTENT_MODEL = OLLAMA_CHAT_MODEL` (saves a resident model at the cost of slower routing when the chat model is large).
7. Narrow `llm_thinking_enabled` to router/planner only, not every context. 7. Narrow `llm_thinking_enabled` to router/planner only, not every context.
8. Reduce `intent_judge_timeout_sec` (15s) or race it against text-based wake detection to avoid blocking the audio loop. 8. Reduce `intent_judge_timeout_sec` (15s) or race it against text-based wake detection to avoid blocking the audio loop.

View File

@@ -85,6 +85,12 @@ class Settings:
llm_digest_timeout_sec: float llm_digest_timeout_sec: float
llm_embedding_timeout_sec: float llm_embedding_timeout_sec: float
llm_profile_select_timeout_sec: float llm_profile_select_timeout_sec: float
# Upper bound on tokens the chat model may generate per reply turn. Spoken
# (TTS) answers are 1-2 sentences, so a cap bounds the worst-case latency of
# a model that occasionally rambles or loops without changing normal answers.
# The headroom (default 512) sits well above this app's short tool-call JSON,
# so capping never truncates a tool call. 0 (or negative) disables the cap.
ollama_num_predict: int
# Profiles & Behavior # Profiles & Behavior
active_profiles: list[str] active_profiles: list[str]
@@ -394,6 +400,9 @@ def get_default_config() -> Dict[str, Any]:
"llm_digest_timeout_sec": 8.0, "llm_digest_timeout_sec": 8.0,
"llm_embedding_timeout_sec": 60.0, "llm_embedding_timeout_sec": 60.0,
"llm_profile_select_timeout_sec": 30.0, "llm_profile_select_timeout_sec": 30.0,
# Cap on chat-model output tokens per turn (worst-case latency guard).
# 512 is safe headroom above short TTS answers and tool-call JSON; 0 off.
"ollama_num_predict": 512,
# Profiles & Behavior # Profiles & Behavior
"active_profiles": ["developer", "business", "life"], "active_profiles": ["developer", "business", "life"],
@@ -763,6 +772,10 @@ def load_settings() -> Settings:
llm_digest_timeout_sec = float(merged.get("llm_digest_timeout_sec", 8.0)) llm_digest_timeout_sec = float(merged.get("llm_digest_timeout_sec", 8.0))
llm_embedding_timeout_sec = float(merged.get("llm_embedding_timeout_sec", 60.0)) llm_embedding_timeout_sec = float(merged.get("llm_embedding_timeout_sec", 60.0))
llm_profile_select_timeout_sec = float(merged.get("llm_profile_select_timeout_sec", 30.0)) llm_profile_select_timeout_sec = float(merged.get("llm_profile_select_timeout_sec", 30.0))
try:
ollama_num_predict = int(merged.get("ollama_num_predict", 512))
except (TypeError, ValueError):
ollama_num_predict = 512
return Settings( return Settings(
# Database & Storage # Database & Storage
@@ -778,6 +791,7 @@ def load_settings() -> Settings:
llm_digest_timeout_sec=llm_digest_timeout_sec, llm_digest_timeout_sec=llm_digest_timeout_sec,
llm_embedding_timeout_sec=llm_embedding_timeout_sec, llm_embedding_timeout_sec=llm_embedding_timeout_sec,
llm_profile_select_timeout_sec=llm_profile_select_timeout_sec, llm_profile_select_timeout_sec=llm_profile_select_timeout_sec,
ollama_num_predict=ollama_num_predict,
# Profiles & Behavior # Profiles & Behavior
active_profiles=active_profiles, active_profiles=active_profiles,

View File

@@ -2233,6 +2233,16 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
has_tool_calls = " (has tool_calls)" if msg.get("tool_calls") else "" has_tool_calls = " (has tool_calls)" if msg.get("tool_calls") else ""
debug_log(f" [{i}] {role}: {content}{has_tool_calls}", "planning") debug_log(f" [{i}] {role}: {content}{has_tool_calls}", "planning")
# Bound worst-case generation latency: spoken answers are 1-2 sentences,
# so cap the chat model's output tokens. The default headroom sits well
# above this app's tool-call JSON, so capping never truncates a tool
# call. 0/negative disables it. See config.ollama_num_predict.
try:
_num_predict = int(getattr(cfg, 'ollama_num_predict', 0) or 0)
except (TypeError, ValueError):
_num_predict = 0
_chat_extra_options = {"num_predict": _num_predict} if _num_predict > 0 else None
# Send messages to Ollama — try native tool calling first, fall back to text-based # Send messages to Ollama — try native tool calling first, fall back to text-based
# if the model returns HTTP 400 (native tools API not supported). # if the model returns HTTP 400 (native tools API not supported).
_dump_tools_schema = None if use_text_tools else tools_json_schema _dump_tools_schema = None if use_text_tools else tools_json_schema
@@ -2242,7 +2252,7 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
chat_model=cfg.ollama_chat_model, chat_model=cfg.ollama_chat_model,
messages=messages, messages=messages,
timeout_sec=float(getattr(cfg, 'llm_chat_timeout_sec', 45.0)), timeout_sec=float(getattr(cfg, 'llm_chat_timeout_sec', 45.0)),
extra_options=None, extra_options=_chat_extra_options,
tools=_dump_tools_schema, tools=_dump_tools_schema,
thinking=getattr(cfg, 'llm_thinking_enabled', False), thinking=getattr(cfg, 'llm_thinking_enabled', False),
) )
@@ -2273,7 +2283,7 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
chat_model=cfg.ollama_chat_model, chat_model=cfg.ollama_chat_model,
messages=messages, messages=messages,
timeout_sec=float(getattr(cfg, 'llm_chat_timeout_sec', 45.0)), timeout_sec=float(getattr(cfg, 'llm_chat_timeout_sec', 45.0)),
extra_options=None, extra_options=_chat_extra_options,
tools=None, tools=None,
thinking=getattr(cfg, 'llm_thinking_enabled', False), thinking=getattr(cfg, 'llm_thinking_enabled', False),
) )

View File

@@ -43,7 +43,7 @@ from jarvis.reply.prompts import (
Both model sizes share these base components: Both model sizes share these base components:
- `asr_note`: Voice transcription error handling - `asr_note`: Voice transcription error handling
- `inference_guidance`: Prefer inference over clarification - `inference_guidance`: Prefer inference over clarification
- `voice_style`: Concise, conversational responses - `voice_style`: Single-sentence, conversational responses (spoken aloud, so one sentence only — never more)
Model-size-specific components: Model-size-specific components:
- `tool_incentives`: When/how aggressively to use tools - `tool_incentives`: When/how aggressively to use tools

View File

@@ -26,8 +26,8 @@ INFERENCE_GUIDANCE = (
# Voice assistant communication style - concise, conversational # Voice assistant communication style - concise, conversational
VOICE_STYLE = ( VOICE_STYLE = (
"Keep responses concise and conversational since this is a voice assistant. " "Keep responses concise and conversational since this is a voice assistant. "
"Two to three sentences maximum. Prioritize clarity and brevity - users are listening, not reading. " "Reply in a SINGLE sentence - never more than one sentence. Prioritize clarity and brevity - users are listening, not reading. "
"Avoid unnecessary elaboration unless specifically requested. " "Avoid unnecessary elaboration. "
"Do NOT offer follow-up suggestions or ask if the user wants more info - just respond directly. " "Do NOT offer follow-up suggestions or ask if the user wants more info - just respond directly. "
"IMPORTANT: Always respond in natural language - never output JSON, code, or structured data as your response. " "IMPORTANT: Always respond in natural language - never output JSON, code, or structured data as your response. "
"NEVER use markdown formatting in your replies: no asterisks for emphasis (**bold**, *italic*), " "NEVER use markdown formatting in your replies: no asterisks for emphasis (**bold**, *italic*), "

View File

@@ -287,6 +287,8 @@ Turn 4: LLM → {content: "Here's a comprehensive comparison of the iPhone 15 mo
- `llm_tools_timeout_sec` (enrichment extraction) - `llm_tools_timeout_sec` (enrichment extraction)
- `llm_embed_timeout_sec` (vector search) - `llm_embed_timeout_sec` (vector search)
- `llm_chat_timeout_sec` (messages loop turn) - `llm_chat_timeout_sec` (messages loop turn)
- Output bound:
- `ollama_num_predict` (default `512`, `0`/negative disables) caps the chat model's generated tokens per turn via the Ollama `num_predict` option on the messages-loop call. Spoken (TTS) answers are 1-2 sentences, so this never clips a normal answer; it bounds the worst-case latency of a model that occasionally rambles or loops. The default headroom sits well above this app's short tool-call JSON, so it does not truncate tool calls. Applied uniformly to the reply loop's chat call (both native-tools and text-tools paths); the small classification passes (intent judge, digests) keep their own caps. Note: this is a worst-case guard, not the primary latency lever, which is model size and GPU residency.
- Memory enrichment: - Memory enrichment:
- `memory_enrichment_max_results` limits recalled snippets. - `memory_enrichment_max_results` limits recalled snippets.
- `memory_digest_enabled` (default `null` = auto-on for SMALL models ≤7B, off for LARGE) distils the combined diary + graph dump into a short relevance-filtered note via a cheap LLM pass before injecting into the system prompt. See **Memory Digest for Small Models** below. - `memory_digest_enabled` (default `null` = auto-on for SMALL models ≤7B, off for LARGE) distils the combined diary + graph dump into a short relevance-filtered note via a cheap LLM pass before injecting into the system prompt. See **Memory Digest for Small Models** below.

View File

@@ -62,10 +62,11 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
"Tone rails (hard): never mean, never condescending, never passive-aggressive, never " "Tone rails (hard): never mean, never condescending, never passive-aggressive, never "
"sulking, never preachy, never sycophantic ('great question', 'I'd be happy to'). " "sulking, never preachy, never sycophantic ('great question', 'I'd be happy to'). "
"Sarcasm points at the situation, the topic, or mildly at yourself — never at the user. " "Sarcasm points at the situation, the topic, or mildly at yourself — never at the user. "
"Shape for casual, factual, or small-talk replies: state the answer in a sentence, then add " "Shape for casual, factual, or small-talk replies: give the answer in a SINGLE sentence. If a "
"one short dry observation about it (an understated aside, a raised-eyebrow remark, a gentle " "dry aside fits, fold it into that same sentence as a short trailing clause — never add it as "
"noticing of the irony). One aside — not two, not a joke opener, not a joke-shaped sentence " "a second sentence, never a joke opener, never a joke-shaped sentence replacing the answer. "
"replacing the answer. The aside is a tail, not the head. " "Whenever the wit would require a second sentence, drop the wit and keep the one-sentence "
"answer. The aside is a tail inside the sentence, not a head and not a new sentence. "
"Examples of the MOVE (shape, not wording — never copy these): stating a fact and then noting " "Examples of the MOVE (shape, not wording — never copy these): stating a fact and then noting "
"its mild absurdity; giving the weather and then commenting on what it implies for the day; " "its mild absurdity; giving the weather and then commenting on what it implies for the day; "
"answering a trivia question and then offering a wry footnote about the subject; admitting " "answering a trivia question and then offering a wry footnote about the subject; admitting "
@@ -79,8 +80,8 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
"butler clichés, and never address the user as 'sir', 'madam', 'my liege', or similar. " "butler clichés, and never address the user as 'sir', 'madam', 'my liege', or similar. "
"Never stack multiple jokes in one reply. " "Never stack multiple jokes in one reply. "
"Be concise, conversational, and actionable. " "Be concise, conversational, and actionable. "
"This is a spoken voice assistant: answer in ONE short sentence whenever possible " "This is a spoken voice assistant: your ENTIRE reply must be a single short sentence. "
"(two at the very most). No lists, no preamble, no 'is there anything else' offers. " "Never write a second sentence. No lists, no preamble, no 'is there anything else' offers. "
"When a controlBrowser tool is available, use IT (never webSearch) for anything that " "When a controlBrowser tool is available, use IT (never webSearch) for anything that "
"should happen in the on-screen browser — opening a site, searching on a site " "should happen in the on-screen browser — opening a site, searching on a site "
"(controlBrowser action 'search' with the right site), clicking, typing — because only " "(controlBrowser action 'search' with the right site), clicking, typing — because only "

View File

@@ -30,8 +30,10 @@ class BrowseAndPlayTool(Tool):
def description(self) -> str: def description(self) -> str:
return ( return (
"Play a song / music video / clip on the shared screen by searching YouTube " "Play a song / music video / clip on the shared screen by searching YouTube "
"and playing the first result. Use when the user asks you to play or watch " "and playing a result. Use when the user asks you to play or watch "
"something. Only available in screen-share mode." "something. Plays the first result by default; pass 'index' to play the "
"Nth result from the top of the search list (e.g. 'play the 3rd video' -> "
"index=3). Only available in screen-share mode."
) )
@property @property
@@ -42,7 +44,16 @@ class BrowseAndPlayTool(Tool):
"query": { "query": {
"type": "string", "type": "string",
"description": "What to play, e.g. 'IU Good Day' or 'lofi hip hop'.", "description": "What to play, e.g. 'IU Good Day' or 'lofi hip hop'.",
} },
"index": {
"type": "integer",
"description": (
"1-based position of the video to play in the search results, "
"counted from the top of the list. Defaults to 1 (first result). "
"Use for 'play the Nth video' / 'play the second one'."
),
"minimum": 1,
},
}, },
"required": ["query"], "required": ["query"],
} }
@@ -55,18 +66,25 @@ class BrowseAndPlayTool(Tool):
reply_text="화면 공유 모드(STREAM_BROWSER=true)에서만 영상을 재생할 수 있습니다.", reply_text="화면 공유 모드(STREAM_BROWSER=true)에서만 영상을 재생할 수 있습니다.",
) )
query = "" query = ""
index = 1
if args and isinstance(args, dict): if args and isinstance(args, dict):
query = str(args.get("query", "")).strip() query = str(args.get("query", "")).strip()
try:
index = int(args.get("index", 1) or 1)
except (TypeError, ValueError):
index = 1
if index < 1:
index = 1
if not query: if not query:
return ToolExecutionResult(success=False, reply_text="재생할 내용을 알려주세요.") return ToolExecutionResult(success=False, reply_text="재생할 내용을 알려주세요.")
if not _NODE_SCRIPT.exists(): if not _NODE_SCRIPT.exists():
return ToolExecutionResult(success=False, reply_text="브라우저 재생 도구를 찾을 수 없습니다.") return ToolExecutionResult(success=False, reply_text="브라우저 재생 도구를 찾을 수 없습니다.")
context.user_print(f"▶️ 화면에서 '{query}' 재생 중…") context.user_print(f"▶️ 화면에서 '{query}' 재생 중… (#{index})")
debug_log(f" ▶️ browseAndPlay '{query}'", "tools") debug_log(f" ▶️ browseAndPlay '{query}' index={index}", "tools")
try: try:
proc = subprocess.run( proc = subprocess.run(
["node", str(_NODE_SCRIPT), query, "youtube"], ["node", str(_NODE_SCRIPT), query, "youtube", str(index)],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=40, timeout=40,

View File

@@ -6,16 +6,24 @@ video, or clip.
### Behaviour ### Behaviour
- Public schema is a single required `query` string (what to play). - Public schema is a required `query` string (what to play) plus an optional
`index` integer (1-based position in the search results, counted from the top
of the list). `index` defaults to `1` (first result), so existing callers and
"play X" requests are unchanged; "play the 3rd video" / "play the second one"
map to `index=3` / `index=2`.
- **Mode-gated**: only acts when `STREAM_BROWSER` is true (`cfg.stream_browser`). - **Mode-gated**: only acts when `STREAM_BROWSER` is true (`cfg.stream_browser`).
In voice-only mode (false) there is no screen to show, so it returns a short In voice-only mode (false) there is no screen to show, so it returns a short
message and does nothing. message and does nothing.
- Drives the on-screen Chrome by subprocessing the Node CDP helper - Drives the on-screen Chrome by subprocessing the Node CDP helper
`bot/scripts/stream-test/browse-search.mjs <query> youtube`, which searches `bot/scripts/stream-test/browse-search.mjs <query> youtube <index>`, which
YouTube and plays the first result on display `:1`. The broadcast captures searches YouTube and plays the chosen result on display `:1`. The broadcast
that display, so the playback is what viewers see. captures that display, so the playback is what viewers see.
- Returns `success` with the played video's title, or a failure message if the - The helper clicks the `index`-th `a#video-title` in the results list. The
helper/Chrome is unavailable. It does NOT make an LLM call. index is clamped to the number of results actually returned, so asking for a
position beyond the list plays the last available result rather than failing.
- Returns `success` with the played video's title (and the resolved `index`), or
a failure message if the helper/Chrome is unavailable. It does NOT make an LLM
call.
### Principles ### Principles

View File

@@ -0,0 +1,79 @@
"""Tests for browseAndPlay's ``index`` argument (play the Nth search result).
Behaviour verified:
- default plays the first result (index 1) and stays backward-compatible,
- an explicit index is forwarded to the Node helper as the 4th argv,
- bad / sub-1 index values clamp to 1,
- the index is advertised in the tool schema.
"""
import json
from unittest.mock import Mock, patch
import pytest
from src.jarvis.tools.builtin.browse_and_play import BrowseAndPlayTool, _NODE_SCRIPT
def _ctx():
cfg = Mock()
cfg.stream_browser = True
return Mock(cfg=cfg, user_print=Mock())
def _run(args):
tool = BrowseAndPlayTool()
with patch("src.jarvis.tools.builtin.browse_and_play.subprocess.run") as mock_run:
mock_run.return_value = Mock(
stdout=json.dumps({"ok": True, "title": "Some Video"}),
stderr="",
)
result = tool.run(args, _ctx())
return mock_run, result
def _argv(mock_run):
return list(mock_run.call_args[0][0])
@pytest.mark.unit
def test_schema_exposes_index():
schema = BrowseAndPlayTool().inputSchema
assert "index" in schema["properties"]
assert schema["properties"]["index"]["type"] == "integer"
assert "query" in schema["required"]
assert "index" not in schema["required"] # optional
@pytest.mark.unit
def test_default_index_is_first():
mock_run, result = _run({"query": "IU Good Day"})
argv = _argv(mock_run)
assert argv[:4] == ["node", str(_NODE_SCRIPT), "IU Good Day", "youtube"]
assert argv[4] == "1"
assert result.success is True
@pytest.mark.unit
def test_explicit_index_forwarded():
mock_run, _ = _run({"query": "lofi", "index": 3})
assert _argv(mock_run)[4] == "3"
@pytest.mark.unit
@pytest.mark.parametrize("bad", [0, -2, "nope", None])
def test_bad_index_clamps_to_one(bad):
mock_run, _ = _run({"query": "lofi", "index": bad})
assert _argv(mock_run)[4] == "1"
@pytest.mark.unit
def test_voice_only_mode_does_not_play():
tool = BrowseAndPlayTool()
cfg = Mock()
cfg.stream_browser = False
ctx = Mock(cfg=cfg, user_print=Mock())
with patch("src.jarvis.tools.builtin.browse_and_play.subprocess.run") as mock_run:
result = tool.run({"query": "x", "index": 2}, ctx)
assert result.success is False
mock_run.assert_not_called()

View File

@@ -0,0 +1,62 @@
"""The docker deployment must run auxiliary calls on a small model.
Latency win: intent judging, tool routing and arg extraction are
classification/JSON calls, not the spoken answer. Running them on a small fast
model means the big chat model only runs once per command (for the answer),
instead of 2-3 times per command for routing/extraction too.
The wiring is: docker/jarvis-config.template.json renders `intent_judge_model`
from `${OLLAMA_INTENT_MODEL}`, and the reply engine's resolver falls through
`tool_router_model -> intent_judge_model -> ollama_chat_model`. The template
sets no `tool_router_model`, so the auxiliary model is whatever
`OLLAMA_INTENT_MODEL` renders to. These tests pin that behaviour end to end.
"""
import json
import string
from pathlib import Path
import pytest
from jarvis.reply.engine import resolve_tool_router_model
TEMPLATE = Path(__file__).resolve().parent.parent / "docker" / "jarvis-config.template.json"
def _render(**env) -> dict:
raw = TEMPLATE.read_text(encoding="utf-8")
return json.loads(string.Template(raw).safe_substitute(**env))
class _Cfg:
"""cfg stand-in carrying only the fields the resolver reads. The template
does not render `tool_router_model`, so it stays empty here too."""
def __init__(self, rendered: dict):
self.tool_router_model = rendered.get("tool_router_model", "") or ""
self.intent_judge_model = rendered.get("intent_judge_model", "") or ""
self.ollama_chat_model = rendered.get("ollama_chat_model", "") or ""
def test_template_renders_separate_intent_model():
cfg = _render(OLLAMA_CHAT_MODEL="qwen3:8b", OLLAMA_INTENT_MODEL="qwen2.5:3b")
assert cfg["ollama_chat_model"] == "qwen3:8b"
assert cfg["intent_judge_model"] == "qwen2.5:3b"
assert cfg["intent_judge_model"] != cfg["ollama_chat_model"]
@pytest.mark.unit
def test_aux_calls_route_to_small_model_not_chat_model():
"""The whole point: with a big chat model and a small intent model, tool
routing must resolve to the small model, leaving the big model for answers."""
cfg = _Cfg(_render(OLLAMA_CHAT_MODEL="qwen3:8b", OLLAMA_INTENT_MODEL="qwen2.5:3b"))
assert resolve_tool_router_model(cfg) == "qwen2.5:3b"
@pytest.mark.unit
def test_folding_intent_onto_chat_model_keeps_one_model():
"""Setting OLLAMA_INTENT_MODEL == OLLAMA_CHAT_MODEL folds everything back
onto a single resident model (the documented VRAM-saving opt-out)."""
cfg = _Cfg(_render(OLLAMA_CHAT_MODEL="qwen2.5:3b", OLLAMA_INTENT_MODEL="qwen2.5:3b"))
assert resolve_tool_router_model(cfg) == "qwen2.5:3b"
assert cfg.intent_judge_model == cfg.ollama_chat_model

View File

@@ -0,0 +1,112 @@
"""Tests for the ``ollama_num_predict`` chat-output cap.
The cap bounds worst-case reply latency by limiting how many tokens the chat
model may generate per turn. Spoken (TTS) answers are 1-2 sentences, so the
default headroom never clips a normal answer and stays above tool-call JSON.
These tests verify behaviour:
- the config default is present,
- the value is threaded into the Ollama request as the ``num_predict`` option,
- the reply loop forwards it to the chat call (and disables it at 0).
"""
from unittest.mock import Mock, patch
import pytest
from src.jarvis.config import get_default_config
from src.jarvis.memory.conversation import DialogueMemory
from src.jarvis.reply.engine import run_reply_engine
# ---------------------------------------------------------------------------
# Config default
# ---------------------------------------------------------------------------
def test_default_config_has_num_predict_cap():
config = get_default_config()
assert config["ollama_num_predict"] == 512
# ---------------------------------------------------------------------------
# Transport: extra_options.num_predict reaches the Ollama payload options
# ---------------------------------------------------------------------------
@patch("jarvis.llm.requests.post")
def test_chat_with_messages_forwards_num_predict(mock_post):
from jarvis.llm import chat_with_messages
mock_resp = Mock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"message": {"content": "ok"}}
mock_resp.raise_for_status = Mock()
mock_post.return_value = mock_resp
chat_with_messages(
"http://localhost:11434",
"test-large",
[{"role": "user", "content": "hi"}],
extra_options={"num_predict": 512},
)
_, kwargs = mock_post.call_args
options = (kwargs.get("json") or {}).get("options") or {}
assert options.get("num_predict") == 512
# ---------------------------------------------------------------------------
# Reply loop wiring
# ---------------------------------------------------------------------------
def _mock_cfg(num_predict):
cfg = Mock()
cfg.ollama_base_url = "http://localhost:11434"
cfg.ollama_chat_model = "test-large" # avoid SMALL-model text-tool path
cfg.ollama_num_predict = num_predict
cfg.voice_debug = False
cfg.llm_tools_timeout_sec = 8.0
cfg.llm_embed_timeout_sec = 10.0
cfg.llm_chat_timeout_sec = 45.0
cfg.llm_digest_timeout_sec = 8.0
cfg.memory_enrichment_max_results = 5
cfg.memory_enrichment_source = "diary"
cfg.memory_digest_enabled = False
cfg.tool_result_digest_enabled = False
cfg.location_ip_address = None
cfg.location_auto_detect = False
cfg.location_enabled = False
cfg.agentic_max_turns = 8
cfg.tool_search_max_calls = 3
cfg.tool_selection_strategy = "all"
cfg.tool_carryover_max_turns = 2
cfg.tool_carryover_per_entry_chars = 1200
cfg.mcps = {}
cfg.llm_thinking_enabled = False
cfg.tts_engine = "none"
cfg.ollama_embed_model = "test-embed"
return cfg
def _run_single_turn(cfg):
"""Drive one reply turn that answers in plain text and capture the
chat call's extra_options."""
with patch("src.jarvis.reply.engine.plan_query", return_value=[]), \
patch("src.jarvis.reply.engine.extract_search_params_for_memory", return_value={}), \
patch("src.jarvis.reply.engine.extract_text_from_response", return_value="Hello."), \
patch("src.jarvis.reply.engine.chat_with_messages") as mock_chat:
mock_chat.return_value = {"message": {"content": "Hello."}}
run_reply_engine(db=Mock(), cfg=cfg, tts=None,
text="hi", dialogue_memory=DialogueMemory())
assert mock_chat.called
return mock_chat.call_args.kwargs.get("extra_options")
@pytest.mark.unit
def test_reply_loop_caps_output_when_enabled():
extra = _run_single_turn(_mock_cfg(512))
assert extra == {"num_predict": 512}
@pytest.mark.unit
def test_reply_loop_no_cap_when_zero():
extra = _run_single_turn(_mock_cfg(0))
assert extra is None

View File

@@ -121,6 +121,18 @@ class TestPromptComponents:
assert prompts.voice_style, f"{size.value} missing voice_style" assert prompts.voice_style, f"{size.value} missing voice_style"
assert prompts.tool_guidance, f"{size.value} missing tool_guidance" assert prompts.tool_guidance, f"{size.value} missing tool_guidance"
def test_voice_style_enforces_single_sentence(self):
"""voice_style must cap replies at one sentence (spoken aloud). The old
'Two to three sentences maximum' wording let the model run long, which
also slowed TTS since synth time scales with text length."""
from jarvis.reply.prompts import get_system_prompts, ModelSize
for size in [ModelSize.SMALL, ModelSize.LARGE]:
voice_style = get_system_prompts(size).voice_style
assert "SINGLE sentence" in voice_style, f"{size.value} voice_style not single-sentence"
assert "never more than one sentence" in voice_style
assert "Two to three" not in voice_style
def test_to_list_returns_non_empty_strings(self): def test_to_list_returns_non_empty_strings(self):
"""to_list() returns only non-empty prompt strings.""" """to_list() returns only non-empty prompt strings."""
from jarvis.reply.prompts import get_system_prompts, ModelSize from jarvis.reply.prompts import get_system_prompts, ModelSize

View File

@@ -44,6 +44,22 @@ class TestBuildSystemPrompt:
assert "in the user's language" not in prompt assert "in the user's language" not in prompt
assert "in Korean" in prompt assert "in Korean" in prompt
def test_persona_enforces_single_sentence(self):
# Spoken replies must be one sentence (TTS latency scales with text
# length, and the user asked for one-sentence answers). The persona must
# state the single-sentence rule and must NOT carry the old "two at the
# very most" allowance that let the model run long.
prompt = build_system_prompt("Jarvis")
assert "single short sentence" in prompt
assert "Never write a second sentence" in prompt
assert "two at the very most" not in prompt
def test_persona_aside_does_not_authorise_a_second_sentence(self):
# The dry aside must fold into the one sentence, not become a 2nd one.
prompt = build_system_prompt("Jarvis")
assert "SINGLE sentence" in prompt
assert "never add it as " in prompt
class TestOutputLanguageDirective: class TestOutputLanguageDirective:
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE. """A deployment may lock replies to a single language via OUTPUT_LANGUAGE.