Compare commits
12 Commits
140fc56f18
...
codex/owne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb60c4db3b | ||
|
|
8562548571 | ||
|
|
db51bb92e6 | ||
|
|
ffc16665e5 | ||
|
|
5629da7e9f | ||
|
|
83999a5b0b | ||
|
|
d5fd218c86 | ||
|
|
23b1fe692b | ||
|
|
7bb9718c34 | ||
|
|
7da2fcb5e5 | ||
|
|
680f5a656a | ||
|
|
b52ffd2b18 |
51
.env.example
51
.env.example
@@ -29,17 +29,27 @@ BRIDGE_PORT=8765
|
||||
JARVIS_BRAIN_ENABLED=1
|
||||
JARVIS_TTS_ENABLED=1
|
||||
# faster-whisper device/compute. GPU by default (RTX 5050 / sm_120, verified).
|
||||
# Falls back to CPU automatically if no GPU is passed to the container.
|
||||
# On a NO-GPU host (e.g. an app host that offloads the LLM to a separate GPU box)
|
||||
# set WHISPER_DEVICE=cpu AND WHISPER_COMPUTE_TYPE=int8 — float16 is GPU-only, so
|
||||
# CPU needs int8 (STT is slower on CPU).
|
||||
WHISPER_DEVICE=cuda
|
||||
WHISPER_COMPUTE_TYPE=float16
|
||||
# Optional explicit Piper voice model (.onnx). If empty, the jarvis default is used.
|
||||
TTS_PIPER_MODEL_PATH=
|
||||
# TTS engine: "edge" (default) uses Microsoft Edge TTS, a natural Korean neural
|
||||
# voice. Set to "piper" for the offline English voice. NOTE: edge is ONLINE —
|
||||
# reply text is sent to Microsoft's servers and needs internet.
|
||||
TTS_ENGINE=edge
|
||||
# Edge voice + speaking rate. Rate is a percentage (+45% ≈ 1.45×). Korean voices:
|
||||
# ko-KR-HyunsuMultilingualNeural (M), ko-KR-InJoonNeural (M), ko-KR-SunHiNeural (F).
|
||||
# TTS engine:
|
||||
# melo (default) GPU MeloTTS — offline Korean voice, privacy-first (text never
|
||||
# leaves the host), synthesises on the GPU via the melo-worker. Needs a
|
||||
# GPU (MELO_DEVICE=cuda); falls back to CPU (slow) with MELO_DEVICE=cpu.
|
||||
# edge Microsoft Edge TTS — natural Korean neural voice, but ONLINE: reply
|
||||
# text is sent to Microsoft's servers and needs internet.
|
||||
# piper offline English voice (fallback).
|
||||
TTS_ENGINE=melo
|
||||
# GPU MeloTTS knobs (used when TTS_ENGINE=melo).
|
||||
MELO_DEVICE=cuda
|
||||
MELO_SPEED=1.5
|
||||
# Edge voice + speaking rate (used when TTS_ENGINE=edge). Rate is a percentage
|
||||
# (+45% ≈ 1.45×). Korean voices: ko-KR-HyunsuMultilingualNeural (M),
|
||||
# ko-KR-InJoonNeural (M), ko-KR-SunHiNeural (F).
|
||||
EDGE_TTS_VOICE=ko-KR-HyunsuMultilingualNeural
|
||||
EDGE_TTS_RATE=+45%
|
||||
# Neural-only by default: if synthesis fails the bridge returns no audio rather
|
||||
@@ -51,7 +61,13 @@ MELO_FALLBACK_PIPER=0
|
||||
# Jarvis brain (Ollama-backed). In Docker these populate the rendered
|
||||
# config (docker/jarvis-config.template.json). See src/jarvis/config.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
# In docker-compose this is overridden to http://ollama:11434 automatically.
|
||||
# In docker-compose this defaults to the in-stack `ollama` service
|
||||
# (http://ollama:11434). To use an EXTERNAL Ollama on another LAN machine (e.g. a
|
||||
# GPU host), set it here to that host's IP — the compose now respects this value:
|
||||
# OLLAMA_BASE_URL=http://192.168.10.9:11434 # Ollama runs on the .9 host
|
||||
# In that split setup: do NOT start the in-stack ollama/ollama-init (run
|
||||
# `docker compose up -d javis`), pull the models on the LLM host, and make that
|
||||
# host's Ollama listen on the LAN (OLLAMA_HOST=0.0.0.0:11434).
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
# qwen2.5:3b — small non-reasoning instruct model. ~2.4GB, runs 100% on the GPU
|
||||
# (the 8B offloads ~8% to CPU), warm voice turns ~2-4s vs ~5-7s on 8B. Clean
|
||||
@@ -59,11 +75,15 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
# 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.
|
||||
# place extraction, query decomposition. These are classification/JSON calls,
|
||||
# NOT the spoken answer, so a small fast model here cuts 2-3 big-model round
|
||||
# trips per command without touching answer quality. BLANK uses the stack
|
||||
# default qwen2.5:3b, which ollama-init pulls automatically. Set it equal to
|
||||
# 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_EMBED_MODEL=nomic-embed-text
|
||||
WHISPER_MODEL=small
|
||||
@@ -193,6 +213,10 @@ VOICE_SILENCE_MS=800
|
||||
# full (default) everything in one container
|
||||
# browser ONLY the desktop + Chrome + control-server (driven over the LAN)
|
||||
# bot ONLY the bot + bridge + TTS (drives a REMOTE browser)
|
||||
# brain ONLY the bridge (STT + reply + TTS) on a GPU host — no bot/desktop.
|
||||
# The app host reaches it via BRIDGE_URL. See docs/DEPLOY.md "GPU split".
|
||||
# app the bot + desktop/Chrome, but NOT the local bridge: offloads
|
||||
# STT/LLM/TTS to a remote brain host (set BRIDGE_URL=http://<brain>:8765).
|
||||
JARVIS_ROLE=full
|
||||
|
||||
# --- GPU per OS: pick the matching compose override via COMPOSE_FILE ---
|
||||
@@ -226,6 +250,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:3b # speed (fits easily, faster on 8GB GPUs)
|
||||
# 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
|
||||
|
||||
# --- Settings web UI (http://localhost:8765/settings on the bot host) ---
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -65,10 +65,19 @@ RUN ls -d /opt/venv/lib/python*/site-packages/nvidia/cublas/lib \
|
||||
> /etc/ld.so.conf.d/nvidia-cu12.conf 2>/dev/null \
|
||||
&& /sbin/ldconfig || true
|
||||
|
||||
# --- Korean voice: Microsoft Edge TTS (online neural). No model is baked — the
|
||||
# `edge-tts` pip package (in requirements-bridge.txt) calls the MS service at
|
||||
# runtime and the bridge transcodes the MP3 to PCM16 with ffmpeg. No heavy
|
||||
# TTS build layer is needed. ---
|
||||
# --- Korean voice: GPU MeloTTS (offline, privacy-first) in a separate /opt/melo
|
||||
# py3.11 venv; see docker/setup-melo.sh. Heavy layer (torch cu128 GPU +
|
||||
# transformers + MeCab + baked KR model), placed before the app COPY so it
|
||||
# stays cached across source-only changes. Runtime device is MELO_DEVICE=cuda
|
||||
# (compose), so TTS synthesises on the GPU next to Ollama + Whisper. Started
|
||||
# only in full/bot/brain roles (see docker/supervisord.conf). The offline
|
||||
# Edge TTS (`edge-tts`, in requirements-bridge.txt) remains available as an
|
||||
# opt-in fallback via TTS_ENGINE=edge, but it is ONLINE (text leaves the
|
||||
# host), so MeloTTS is the default. ---
|
||||
COPY docker/setup-melo.sh /app/docker/setup-melo.sh
|
||||
# Strip CR first: a Windows (autocrlf) checkout yields CRLF, which makes bash
|
||||
# read `set -euxo pipefail\r` and abort. Idempotent on an LF tree.
|
||||
RUN sed -i 's/\r$//' /app/docker/setup-melo.sh && bash /app/docker/setup-melo.sh
|
||||
|
||||
# --- Human input + window management for the on-screen Chrome control tool.
|
||||
# xdotool injects real X pointer/keyboard events (visible cursor,
|
||||
|
||||
@@ -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 가속" 절 참고.
|
||||
|
||||
`docker compose up` 한 번이면 자동으로:
|
||||
- Ollama 서버가 뜨고, `ollama-init`이 채팅/임베딩 모델을 **자동 pull**
|
||||
- Ollama 서버가 뜨고, `ollama-init`이 채팅/보조(의도·라우팅)/임베딩 모델을 **자동 pull** (보조 모델 `OLLAMA_INTENT_MODEL`은 기본 `qwen2.5:3b`로, 큰 채팅 모델은 답변에만 쓰고 내부 분류 호출은 이 작은 모델이 처리)
|
||||
- VNC+XFCE 데스크톱 + 크롬 + Python 브릿지가 기동
|
||||
- Whisper STT 모델 / Piper TTS 음성 자동 다운로드(볼륨에 캐시)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 자비스 운영자 지시
|
||||
|
||||
- 너의 이름은 자비스다.
|
||||
- 모든 답변은 음성(TTS)으로 읽혀 나간다. 그러니 최대한 간결하게, 한두 문장으로 답한다. 목록, 마크다운, 이모지, 그리고 소리 내어 읽기 어려운 특수문자는 쓰지 않는다.
|
||||
- 모든 답변은 음성(TTS)으로 읽혀 나간다. 그러니 무조건 한 문장으로만 답한다. 두 문장 이상 쓰지 않는다. 목록, 마크다운, 이모지, 그리고 소리 내어 읽기 어려운 특수문자는 쓰지 않는다.
|
||||
- 정해진 문구에만 반응하지 말고, 실제 사람처럼 말의 뉘앙스와 맥락으로 의도를 알아듣고 처리한다.
|
||||
|
||||
화면 속 크롬(방송 화면)에서 유튜브를 다룰 때 (화면에 보여야 하므로 반드시 on-screen 브라우저 제어 도구로 수행한다):
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// status | listTabs
|
||||
// navigate {url} | back | forward | refresh
|
||||
// newTab {url?} | closeTab {index?} | activateTab {index} | closePopups
|
||||
// moveMouse {selector | site} (hover the real cursor, no click)
|
||||
// click {selector} | type {text, selector?} | scroll {dir, notches?}
|
||||
// pressKey {key} | screenshot {path}
|
||||
import { chromium } from 'playwright';
|
||||
@@ -40,6 +41,15 @@ if (!action) { out({ ok: false, error: 'no action' }); process.exit(1); }
|
||||
|
||||
const norm = (u) => (/^https?:\/\//i.test(u) ? u : `https://${u}`);
|
||||
|
||||
// Per-site homepage + search-box selector, shared by `search` and `moveMouse`.
|
||||
const SITES = {
|
||||
naver: { home: 'https://www.naver.com', box: '#query, input[name="query"]' },
|
||||
google: { home: 'https://www.google.com', box: 'textarea[name="q"], input[name="q"]' },
|
||||
daum: { home: 'https://www.daum.net', box: '#q, input[name="q"]' },
|
||||
youtube: { home: 'https://www.youtube.com', box: 'input#search, input[name="search_query"]' },
|
||||
bing: { home: 'https://www.bing.com', box: '#sb_form_q, input[name="q"]' },
|
||||
};
|
||||
|
||||
// The genuinely-active tab is the one whose document is visible. Playwright has
|
||||
// no "active page" accessor over CDP, so probe visibilityState (fixes treating
|
||||
// tab 0 as active and breaking sequential ops on a specific tab).
|
||||
@@ -103,13 +113,6 @@ try {
|
||||
const q = String(cmd.query || '').trim();
|
||||
if (!q) throw new Error('search: no query');
|
||||
const siteKey = String(cmd.site || 'google').toLowerCase();
|
||||
const SITES = {
|
||||
naver: { home: 'https://www.naver.com', box: '#query, input[name="query"]' },
|
||||
google: { home: 'https://www.google.com', box: 'textarea[name="q"], input[name="q"]' },
|
||||
daum: { home: 'https://www.daum.net', box: '#q, input[name="q"]' },
|
||||
youtube: { home: 'https://www.youtube.com', box: 'input#search, input[name="search_query"]' },
|
||||
bing: { home: 'https://www.bing.com', box: '#sb_form_q, input[name="q"]' },
|
||||
};
|
||||
const s = SITES[siteKey] || SITES.google;
|
||||
await front(page);
|
||||
// 1) Go to the homepage.
|
||||
@@ -122,23 +125,31 @@ try {
|
||||
// 2) Click the on-page search box, type the query, submit.
|
||||
const box = page.locator(s.box).first();
|
||||
await box.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
|
||||
// Report which input path actually ran: 'human' = real xdotool cursor
|
||||
// move + char typing; 'api-fallback' = the humanClick path threw and we
|
||||
// fell back to cursor-less DOM click/fill; 'api' = no xdotool at all. This
|
||||
// makes "did the cursor really move" verifiable from the result.
|
||||
let searchInput;
|
||||
if (HAS_XDOTOOL && cmd.human !== false) {
|
||||
try {
|
||||
await human.humanClick(page, box);
|
||||
await human.humanType(q);
|
||||
await human.pressKey('Return');
|
||||
searchInput = 'human';
|
||||
} catch {
|
||||
searchInput = 'api-fallback';
|
||||
await box.click().catch(() => {});
|
||||
await box.fill(q).catch(() => {});
|
||||
await page.keyboard.press('Enter').catch(() => {});
|
||||
}
|
||||
} else {
|
||||
searchInput = 'api';
|
||||
await box.click().catch(() => {});
|
||||
await box.fill(q);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
out({ ok: true, site: SITES[siteKey] ? siteKey : 'google', query: q, url: page.url(), title: await page.title().catch(() => '') });
|
||||
out({ ok: true, site: SITES[siteKey] ? siteKey : 'google', query: q, url: page.url(), title: await page.title().catch(() => ''), input: searchInput });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -205,6 +216,45 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'moveMouse': {
|
||||
// Move/hover the REAL cursor onto an element WITHOUT clicking. Target is a
|
||||
// CSS selector, or site=naver/google/... for that site's search box.
|
||||
// Only meaningful with xdotool (the visible cursor); with no xdotool there
|
||||
// is no cursor to move, so report that rather than faking success. Every
|
||||
// failure to actually move (no xdotool, selector never matches, element
|
||||
// has no on-screen box) returns ok:false — we must never claim the cursor
|
||||
// moved when it did not (the exact bug the user reported).
|
||||
const siteKey = String(cmd.site || '').toLowerCase();
|
||||
const selector = String(cmd.selector || '').trim() || (SITES[siteKey] ? SITES[siteKey].box : '');
|
||||
if (!selector) throw new Error('moveMouse: no selector or known site');
|
||||
if (!(HAS_XDOTOOL && cmd.human !== false)) {
|
||||
out({ ok: false, error: 'no xdotool: cannot move the visible cursor on this host' });
|
||||
break;
|
||||
}
|
||||
await front(page);
|
||||
let locator = page.locator(selector).first();
|
||||
let visible = await locator.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||
// A named site whose search box isn't on the current page: go to its home
|
||||
// first (real omnibox), then target the box there.
|
||||
if (!visible && SITES[siteKey]) {
|
||||
try { await human.navigateOmnibox(SITES[siteKey].home); await page.waitForLoadState('domcontentloaded').catch(() => {}); }
|
||||
catch { await page.goto(SITES[siteKey].home, { waitUntil: 'domcontentloaded' }).catch(() => {}); }
|
||||
locator = page.locator(SITES[siteKey].box).first();
|
||||
visible = await locator.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||
}
|
||||
if (!visible) {
|
||||
out({ ok: false, error: `moveMouse: target not found (${cmd.selector || siteKey})` });
|
||||
break;
|
||||
}
|
||||
const moved = await human.humanHover(page, locator);
|
||||
if (!moved) {
|
||||
out({ ok: false, error: 'moveMouse: element has no on-screen box; cursor not moved' });
|
||||
break;
|
||||
}
|
||||
out({ ok: true, target: cmd.selector || siteKey, input: 'human' });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
const selector = String(cmd.selector || '').trim();
|
||||
if (!selector) throw new Error('click: no selector');
|
||||
|
||||
@@ -136,14 +136,18 @@ export async function navigateOmnibox(text) {
|
||||
}
|
||||
|
||||
// Move the real cursor over an element (hover, no click) - e.g. to reveal a
|
||||
// video player's controls or to focus it for a keyboard shortcut.
|
||||
// video player's controls or to focus it for a keyboard shortcut. Returns true
|
||||
// only if the element had an on-screen box and the cursor was actually moved;
|
||||
// returns false when there is nothing to move to (so callers must not report
|
||||
// success). Brings the element into view with a real wheel scroll first.
|
||||
export async function humanHover(page, locator) {
|
||||
const box = await locator.boundingBox().catch(() => null);
|
||||
if (!box) return;
|
||||
const box = await bringIntoView(page, locator);
|
||||
if (!box) return false;
|
||||
const g = await page.evaluate(() => ({ sx: window.screenX, sy: window.screenY, ow: window.outerWidth, oh: window.outerHeight, iw: window.innerWidth, ih: window.innerHeight }));
|
||||
const bx = Math.max(0, Math.round((g.ow - g.iw) / 2));
|
||||
const oy = g.sy + Math.max(0, g.oh - g.ih - bx);
|
||||
await humanMove(Math.round(g.sx + bx + box.x + box.width * 0.5), Math.round(oy + box.y + box.height * 0.4));
|
||||
return true;
|
||||
}
|
||||
|
||||
export { sleep, rand };
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Does not broadcast natively into Discord. Instead it shares a noVNC web URL
|
||||
* that anyone can open in a browser to watch (and optionally control) the VNC
|
||||
* desktop live. Set NOVNC_URL in .env (e.g. http://192.168.10.9:6080/vnc.html).
|
||||
* desktop live. Set NOVNC_URL in .env to the browser host's LAN IP
|
||||
* (e.g. http://<browser-host-ip>:6080/vnc.html).
|
||||
*
|
||||
* Stand up noVNC once on the host with websockify, e.g.:
|
||||
* websockify --web=/usr/share/novnc 6080 localhost:5901
|
||||
@@ -22,7 +23,7 @@ export class NoVncStreamer implements ScreenStreamer {
|
||||
|
||||
async start(_ctx: StreamContext): Promise<string> {
|
||||
if (!this.config.novncUrl) {
|
||||
return "NOVNC_URL이 설정되지 않았습니다 (.env). 예: http://192.168.10.9:6080/vnc.html";
|
||||
return "NOVNC_URL이 설정되지 않았습니다 (.env). 예: http://<브라우저호스트IP>:6080/vnc.html";
|
||||
}
|
||||
this.active = true;
|
||||
return `🖥️ VNC 화면 실시간 보기 (브라우저): ${this.config.novncUrl}`;
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
# primary voice. "melo" (a warm MeloTTS worker) and "piper" remain selectable.
|
||||
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)
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -40,6 +40,9 @@ services:
|
||||
environment:
|
||||
OLLAMA_HOST: http://ollama:11434
|
||||
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}
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
@@ -48,6 +51,10 @@ services:
|
||||
until ollama list >/dev/null 2>&1; do sleep 2; done;
|
||||
echo "[ollama-init] pulling $$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";
|
||||
ollama pull "$$EMBED_MODEL";
|
||||
echo "[ollama-init] models ready.";
|
||||
@@ -59,19 +66,38 @@ services:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
# Point the brain at the ollama service and the bot at the in-container bridge.
|
||||
OLLAMA_BASE_URL: http://ollama:11434
|
||||
# Point the brain at Ollama. Default is the in-stack `ollama` service
|
||||
# (all-in-one). Override OLLAMA_BASE_URL in .env to reach an EXTERNAL Ollama
|
||||
# over the LAN (e.g. a GPU host at http://192.168.10.9:11434) — used when
|
||||
# this stack runs on a separate machine from the LLM. When overridden, do
|
||||
# NOT start the in-stack `ollama`/`ollama-init` services (bring the app up
|
||||
# with `docker compose up -d javis`) and pull the models on the LLM host.
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
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}
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-medium}
|
||||
WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda}
|
||||
WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-float16}
|
||||
# TTS engine. Rendered into /app/config/jarvis.json via envsubst (the
|
||||
# bridge reads that JSON BEFORE the env, so it must carry the real engine,
|
||||
# not a hardcoded one — otherwise Korean text is read by the English Piper
|
||||
# voice). Default edge; .env can override (e.g. piper for offline).
|
||||
TTS_ENGINE: ${TTS_ENGINE:-edge}
|
||||
# Edge TTS voice + rate (the chosen natural Korean voice). NOTE: edge is an
|
||||
# bridge reads that JSON BEFORE the env, so it must carry the real engine).
|
||||
# Default melo: GPU MeloTTS (offline, privacy-first) via the melo-worker.
|
||||
# Override with TTS_ENGINE=edge for the online Microsoft neural voice (text
|
||||
# leaves the host) or piper for the offline English voice.
|
||||
TTS_ENGINE: ${TTS_ENGINE:-melo}
|
||||
# GPU MeloTTS worker knobs. MELO_DEVICE=cuda runs synth on the GPU (next to
|
||||
# Ollama + Whisper); cpu is the no-GPU fallback. MELO_SPEED must always be
|
||||
# set — supervisord's melo-worker expands %(ENV_MELO_SPEED)s at startup.
|
||||
MELO_DEVICE: ${MELO_DEVICE:-cuda}
|
||||
MELO_SPEED: ${MELO_SPEED:-1.5}
|
||||
# Edge TTS voice + rate (used only when TTS_ENGINE=edge). NOTE: edge is an
|
||||
# ONLINE engine — reply text is sent to Microsoft and needs internet.
|
||||
EDGE_TTS_VOICE: ${EDGE_TTS_VOICE:-ko-KR-HyunsuMultilingualNeural}
|
||||
EDGE_TTS_RATE: ${EDGE_TTS_RATE:-+45%}
|
||||
@@ -82,11 +108,21 @@ services:
|
||||
PLANNER_ENABLED: ${PLANNER_ENABLED:-0}
|
||||
# Lock STT to Korean (skip Whisper auto-detect).
|
||||
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}
|
||||
BRIDGE_URL: http://127.0.0.1:8765
|
||||
# Split-deployment role: full (default, all-in-one), browser (only the
|
||||
# desktop + Chrome + CDP, reused over the LAN), or bot (only bot + bridge
|
||||
# + TTS, driving a remote browser via CDP_HOST). See docker/run-if-role.sh.
|
||||
# Where the bot reaches the brain bridge (STT + reply + TTS). Loopback for
|
||||
# all-in-one. In a GPU-split layout the bot host (JARVIS_ROLE=app) points
|
||||
# this at the brain host: BRIDGE_URL=http://192.168.10.9:8765 (that host
|
||||
# runs JARVIS_ROLE=brain with BRIDGE_HOST=0.0.0.0 + SETTINGS_PUBLISH_BIND=0.0.0.0).
|
||||
BRIDGE_URL: ${BRIDGE_URL:-http://127.0.0.1:8765}
|
||||
# Split-deployment role (see docker/run-if-role.sh):
|
||||
# full (default) everything in one container
|
||||
# browser only desktop + Chrome + control-server (driven over the LAN)
|
||||
# bot only bot + bridge + TTS (drives a remote browser)
|
||||
# brain only the bridge (STT + reply + TTS) — GPU host, no bot/desktop
|
||||
# app bot + browser/desktop, NO local bridge (uses a remote brain)
|
||||
JARVIS_ROLE: ${JARVIS_ROLE:-full}
|
||||
# Chrome CDP bind address INSIDE the container. 0.0.0.0 lets a remote bot
|
||||
# (JARVIS_ROLE=bot on another PC) drive this host's browser. Loopback by
|
||||
@@ -94,7 +130,7 @@ services:
|
||||
CDP_BIND: ${CDP_BIND:-127.0.0.1}
|
||||
CDP_PORT: ${CDP_PORT:-9222}
|
||||
# Where the bot drives Chrome. Loopback for full/browser; on a remote bot
|
||||
# set CDP_HOST to the browser host's LAN IP (e.g. 192.168.10.9).
|
||||
# set CDP_HOST to the browser host's LAN IP (e.g. 192.168.10.5).
|
||||
CDP_HOST: ${CDP_HOST:-127.0.0.1}
|
||||
# Browser-control endpoint. The browser host serves it (BIND/PORT); a
|
||||
# remote bot sets BROWSER_CONTROL_URL=http://<browser-host>:8777 so its
|
||||
|
||||
@@ -10,12 +10,12 @@ set -euo pipefail
|
||||
: "${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}}"
|
||||
# extraction, query decomposition). Default to a small fast model so the big
|
||||
# chat model only runs for the actual spoken answer — the main per-turn latency
|
||||
# win once the GPU is in use, since the 2-3 routing/extraction calls per command
|
||||
# no longer pay the big model's cost. ollama-init pulls this model. Set it equal
|
||||
# to OLLAMA_CHAT_MODEL to fold everything back onto one resident 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}"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
# JARVIS_ROLE=browser only the desktop + Chrome + CDP (reused over the LAN)
|
||||
# JARVIS_ROLE=bot only the bot + bridge + TTS (drives a remote browser
|
||||
# via CDP_HOST/CDP_PORT)
|
||||
# JARVIS_ROLE=brain only the bridge (STT + reply + TTS) on a GPU host; no
|
||||
# bot, no desktop. The app host reaches it via BRIDGE_URL.
|
||||
# JARVIS_ROLE=app the bot + desktop/Chrome, but NOT the local bridge:
|
||||
# offloads STT/LLM/TTS to a remote brain host (BRIDGE_URL)
|
||||
set -e
|
||||
want="$1"; shift
|
||||
role="${JARVIS_ROLE:-full}"
|
||||
|
||||
@@ -14,7 +14,7 @@ serverurl=unix:///run/supervisor.sock
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[program:xvnc]
|
||||
command=/app/docker/run-if-role.sh full,browser /app/docker/run-xvnc.sh
|
||||
command=/app/docker/run-if-role.sh full,browser,app /app/docker/run-xvnc.sh
|
||||
priority=100
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
@@ -23,7 +23,7 @@ stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:pulse]
|
||||
command=/app/docker/run-if-role.sh full,browser /app/docker/run-pulse.sh
|
||||
command=/app/docker/run-if-role.sh full,browser,app /app/docker/run-pulse.sh
|
||||
priority=150
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
@@ -32,7 +32,7 @@ stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:xfce]
|
||||
command=/app/docker/run-if-role.sh full,browser /app/docker/run-xfce.sh
|
||||
command=/app/docker/run-if-role.sh full,browser,app /app/docker/run-xfce.sh
|
||||
priority=200
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
@@ -41,7 +41,7 @@ stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:novnc]
|
||||
command=/app/docker/run-if-role.sh full,browser websockify --web=/usr/share/novnc 6080 localhost:5901
|
||||
command=/app/docker/run-if-role.sh full,browser,app websockify --web=/usr/share/novnc 6080 localhost:5901
|
||||
priority=250
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
@@ -49,11 +49,30 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
# (No TTS worker program: the default Edge TTS engine synthesises in-process in
|
||||
# the bridge via the `edge-tts` package — no warm model/worker is needed.)
|
||||
[program:melo-worker]
|
||||
; Warm GPU MeloTTS Korean voice (offline) in its own py3.11 venv (/opt/melo). The
|
||||
; bridge's synthesize() POSTs here when TTS_ENGINE=melo. Runs wherever the bridge
|
||||
; runs (full/bot/brain) so a GPU brain host serves TTS on the GPU. If TTS_ENGINE
|
||||
; =edge the bridge synthesises in-process and never calls this worker.
|
||||
command=/app/docker/run-if-role.sh full,bot,brain /opt/melo/bin/python /app/bridge/melo_worker.py
|
||||
directory=/app
|
||||
; HF_HOME points at the dedicated, image-baked melo cache (warmed in
|
||||
; setup-melo.sh). The whisper_cache volume is mounted over /root/.cache/huggingface,
|
||||
; so without this the pre-cached BERT + KR checkpoint would be shadowed and
|
||||
; re-downloaded (and would fail offline). HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE
|
||||
; force pure-cache reads. MELO_DEVICE/MELO_SPEED inherit from the container env
|
||||
; (compose sets both with defaults cuda / 1.5), so MELO_SPEED must always be set
|
||||
; in the env or this %(ENV_x)s expansion fails at startup (compose guarantees it).
|
||||
environment=MELO_LANGUAGE="KR",MELO_SPEED="%(ENV_MELO_SPEED)s",MELO_DEVICE="%(ENV_MELO_DEVICE)s",MELO_WORKER_HOST="127.0.0.1",MELO_WORKER_PORT="8770",HF_HOME="/opt/melo-cache",HF_HUB_OFFLINE="1",TRANSFORMERS_OFFLINE="1"
|
||||
priority=280
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:bridge]
|
||||
command=/app/docker/run-if-role.sh full,bot /opt/venv/bin/python -m bridge.server
|
||||
command=/app/docker/run-if-role.sh full,bot,brain /opt/venv/bin/python -m bridge.server
|
||||
directory=/app
|
||||
priority=300
|
||||
autorestart=true
|
||||
@@ -63,7 +82,7 @@ stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:chrome]
|
||||
command=/app/docker/run-if-role.sh full,browser /app/docker/run-chrome.sh
|
||||
command=/app/docker/run-if-role.sh full,browser,app /app/docker/run-chrome.sh
|
||||
priority=350
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
@@ -75,7 +94,7 @@ stderr_logfile_maxbytes=0
|
||||
; Browser-control HTTP endpoint on the BROWSER HOST. A remote `bot` posts
|
||||
; commands here so xdotool / CDP run on THIS machine (real input on this
|
||||
; screen). Only meaningful in full/browser roles. Internal network only.
|
||||
command=/app/docker/run-if-role.sh full,browser node /app/bot/scripts/stream-test/control-server.mjs
|
||||
command=/app/docker/run-if-role.sh full,browser,app node /app/bot/scripts/stream-test/control-server.mjs
|
||||
directory=/app/bot
|
||||
priority=360
|
||||
autorestart=true
|
||||
@@ -85,7 +104,7 @@ stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:bot]
|
||||
command=/app/docker/run-if-role.sh full,bot /app/docker/run-bot.sh
|
||||
command=/app/docker/run-if-role.sh full,bot,app /app/docker/run-bot.sh
|
||||
directory=/app/bot
|
||||
priority=400
|
||||
autorestart=true
|
||||
|
||||
@@ -30,7 +30,7 @@ The on-screen Chrome, real mouse/keyboard (xdotool) and screen live on the
|
||||
**browser host**. Your PC runs the **bot** and drives that browser over the
|
||||
internal network — no auth (internal only).
|
||||
|
||||
### Browser host (the LAN machine that shows Chrome, e.g. 192.168.10.9)
|
||||
### Browser host (the LAN machine that shows Chrome, e.g. 192.168.10.5)
|
||||
|
||||
```
|
||||
# .env
|
||||
@@ -50,7 +50,7 @@ Watch it on this machine’s VNC (`localhost:5901`) / noVNC (`localhost:6080`).
|
||||
```
|
||||
# .env
|
||||
JARVIS_ROLE=bot
|
||||
BROWSER_CONTROL_URL=http://192.168.10.9:8777 # the browser host's LAN IP
|
||||
BROWSER_CONTROL_URL=http://192.168.10.5:8777 # the browser host's LAN IP
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml # Ubuntu/macOS (":" )
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml # Windows 11 (";" )
|
||||
DISCORD_SELFBOT_TOKEN=...
|
||||
@@ -63,6 +63,73 @@ The bot’s `controlBrowser` tool posts commands to `BROWSER_CONTROL_URL`, so
|
||||
"네이버에서 X 검색", "구글로 돌아가" etc. drive the **browser host’s** Chrome with real
|
||||
human-style input (visible on its VNC).
|
||||
|
||||
### External Ollama (LLM on a separate host)
|
||||
|
||||
To run Ollama on a different LAN machine (e.g. a dedicated GPU host at
|
||||
`192.168.10.9`) instead of inside this stack:
|
||||
|
||||
```
|
||||
# .env on the app host
|
||||
OLLAMA_BASE_URL=http://192.168.10.9:11434
|
||||
```
|
||||
|
||||
Then start the app WITHOUT the in-stack LLM services: `docker compose up -d javis`
|
||||
(do not `docker compose up -d`, which would also start `ollama`/`ollama-init`).
|
||||
On the LLM host, make Ollama listen on the LAN (`OLLAMA_HOST=0.0.0.0:11434`) and
|
||||
pull the models there (`ollama pull <chat>`, `<intent>`, `<embed>`). The compose
|
||||
respects `OLLAMA_BASE_URL`, defaulting to the in-stack `ollama` service when unset.
|
||||
|
||||
## C. GPU split: brain host (.9, GPU) + app host (.5, no GPU)
|
||||
|
||||
All the GPU-heavy compute (Whisper STT, the Ollama LLM, and TTS) runs on the
|
||||
**brain host** (a GPU box, e.g. `192.168.10.9`). The **app host** (no GPU, e.g.
|
||||
`192.168.10.5`) runs the Discord bot and the on-screen browser, and offloads
|
||||
every STT/LLM/TTS call to the brain over the LAN.
|
||||
|
||||
Flow: the bot (app host) POSTs captured voice to the brain's bridge; the brain
|
||||
runs STT -> LLM -> TTS on the GPU and returns the reply audio; when the LLM
|
||||
drives the browser, the brain's `controlBrowser` tool POSTs to the app host's
|
||||
control-server (port 8777), so Chrome moves on the app host's screen.
|
||||
|
||||
### Brain host (.9 — GPU: STT + LLM + TTS)
|
||||
|
||||
```
|
||||
# .env
|
||||
JARVIS_ROLE=brain
|
||||
BRIDGE_HOST=0.0.0.0 # bridge listens on the LAN
|
||||
SETTINGS_PUBLISH_BIND=0.0.0.0 # publish bridge port 8765 to the LAN
|
||||
OLLAMA_BASE_URL=http://192.168.10.9:11434 # Ollama on this host (bind 0.0.0.0)
|
||||
OLLAMA_CHAT_MODEL=qwen2.5:7b
|
||||
WHISPER_DEVICE=cuda # STT on the GPU
|
||||
WHISPER_COMPUTE_TYPE=float16
|
||||
TTS_ENGINE=melo # GPU MeloTTS (offline, privacy-first)
|
||||
MELO_DEVICE=cuda # TTS on the GPU (melo-worker)
|
||||
BROWSER_CONTROL_URL=http://192.168.10.5:8777 # drive the app host's Chrome
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml # GPU (":" on Linux)
|
||||
|
||||
# STT (Whisper), LLM (Ollama) and TTS (MeloTTS) all run on this host's GPU.
|
||||
# Ollama listens on 0.0.0.0:11434 (systemd or a container).
|
||||
docker compose up -d javis # brain bridge + melo-worker only (no bot, no desktop)
|
||||
```
|
||||
|
||||
### App host (.5 — bot + browser, no GPU)
|
||||
|
||||
```
|
||||
# .env
|
||||
JARVIS_ROLE=app
|
||||
BRIDGE_URL=http://192.168.10.9:8765 # offload STT/LLM/TTS to the brain
|
||||
CDP_PUBLISH_BIND=0.0.0.0 # publish control-server 8777 to the LAN
|
||||
STREAM_HW=0 # no NVENC on a no-GPU host
|
||||
DISCORD_SELFBOT_TOKEN=...
|
||||
DISCORD_GUILD_ID=...
|
||||
# no GPU → leave COMPOSE_FILE unset (base compose only)
|
||||
|
||||
docker compose up -d javis # bot + desktop + Chrome + control-server
|
||||
```
|
||||
|
||||
The `brain` and `app` roles are backward-compatible additions to `full`/`browser`
|
||||
/`bot`; see `docker/run-if-role.sh`.
|
||||
|
||||
## Windows 11 notes
|
||||
|
||||
- Install the NVIDIA driver on Windows and enable GPU in Docker Desktop
|
||||
|
||||
@@ -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)
|
||||
- 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)
|
||||
- **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). 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
|
||||
|
||||
- **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.
|
||||
- **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**:
|
||||
- Rolling transcript buffer (last 120s, with timestamps)
|
||||
- Wake-word timestamp (if any), normalised aliases
|
||||
@@ -246,7 +246,7 @@ user input
|
||||
3. Pre-warm the intent-judge model before TTS finishes.
|
||||
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).
|
||||
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.
|
||||
8. Reduce `intent_judge_timeout_sec` (15s) or race it against text-based wake detection to avoid blocking the audio loop.
|
||||
|
||||
|
||||
@@ -22,11 +22,15 @@ class ModelSize(Enum):
|
||||
LARGE = "large" # 8b+ - can infer tool usage from context
|
||||
|
||||
|
||||
# Model size patterns - models matching these are considered SMALL
|
||||
# Model size patterns - models matching these are considered SMALL.
|
||||
# Covers every sub-8B size (1b-7b): these models need the explicit, repeated
|
||||
# tool/greeting/instruction constraints and falter on the terse LARGE prompt.
|
||||
# Without 2b/4b/5b/6b here a genuinely small model (e.g. qwen*:4b) was
|
||||
# misclassified as LARGE and given the less-guided prompt set.
|
||||
_SMALL_MODEL_PATTERNS = (
|
||||
":1b", ":3b", ":7b",
|
||||
"-1b", "-3b", "-7b",
|
||||
"_1b", "_3b", "_7b",
|
||||
":1b", ":2b", ":3b", ":4b", ":5b", ":6b", ":7b",
|
||||
"-1b", "-2b", "-3b", "-4b", "-5b", "-6b", "-7b",
|
||||
"_1b", "_2b", "_3b", "_4b", "_5b", "_6b", "_7b",
|
||||
"gemma4", # Gemma 4 - always small regardless of tag
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This module provides model-size-aware prompt generation for the reply engine.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Small models (1b, 3b, 7b parameters) lack the reasoning capacity to infer when NOT to use tools. When given prompts like "Proactively use available tools," they may incorrectly call tools for simple greetings like "hello" or "ni hao" because they cannot distinguish between:
|
||||
Small models (every sub-8B size, 1b-7b parameters) lack the reasoning capacity to infer when NOT to use tools. When given prompts like "Proactively use available tools," they may incorrectly call tools for simple greetings like "hello" or "ni hao" because they cannot distinguish between:
|
||||
- Requests that require tools (weather, search, data retrieval)
|
||||
- Simple conversation (greetings, small talk, general knowledge)
|
||||
|
||||
@@ -14,7 +14,7 @@ The module detects model size from the model name and selects appropriate prompt
|
||||
|
||||
| Model Size | Detection Pattern | Tool Prompts |
|
||||
|------------|-------------------|--------------|
|
||||
| SMALL | `:1b`, `:3b`, `:7b`, `gemma4` | Conservative — explicit "DO NOT use tools for greetings" + worked negative examples + repetition |
|
||||
| SMALL | `:1b`-`:7b` (every size 1-7B, all separators), `gemma4` | Conservative — explicit "DO NOT use tools for greetings" + worked negative examples + repetition |
|
||||
| LARGE | All others (8b+) | Proactive — "use tools confidently" + short anti-confabulation + auto-derive clause |
|
||||
|
||||
### Architecture
|
||||
@@ -43,7 +43,7 @@ from jarvis.reply.prompts import (
|
||||
Both model sizes share these base components:
|
||||
- `asr_note`: Voice transcription error handling
|
||||
- `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:
|
||||
- `tool_incentives`: When/how aggressively to use tools
|
||||
|
||||
@@ -26,8 +26,8 @@ INFERENCE_GUIDANCE = (
|
||||
# Voice assistant communication style - concise, conversational
|
||||
VOICE_STYLE = (
|
||||
"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. "
|
||||
"Avoid unnecessary elaboration unless specifically requested. "
|
||||
"Reply in a SINGLE sentence - never more than one sentence. Prioritize clarity and brevity - users are listening, not reading. "
|
||||
"Avoid unnecessary elaboration. "
|
||||
"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. "
|
||||
"NEVER use markdown formatting in your replies: no asterisks for emphasis (**bold**, *italic*), "
|
||||
|
||||
@@ -62,10 +62,11 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
|
||||
"Tone rails (hard): never mean, never condescending, never passive-aggressive, never "
|
||||
"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. "
|
||||
"Shape for casual, factual, or small-talk replies: state the answer in a sentence, then add "
|
||||
"one short dry observation about it (an understated aside, a raised-eyebrow remark, a gentle "
|
||||
"noticing of the irony). One aside — not two, not a joke opener, not a joke-shaped sentence "
|
||||
"replacing the answer. The aside is a tail, not the head. "
|
||||
"Shape for casual, factual, or small-talk replies: give the answer in a SINGLE sentence. If a "
|
||||
"dry aside fits, fold it into that same sentence as a short trailing clause — never add it as "
|
||||
"a second sentence, never a joke opener, never a joke-shaped sentence replacing the answer. "
|
||||
"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 "
|
||||
"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 "
|
||||
@@ -79,8 +80,8 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
|
||||
"butler clichés, and never address the user as 'sir', 'madam', 'my liege', or similar. "
|
||||
"Never stack multiple jokes in one reply. "
|
||||
"Be concise, conversational, and actionable. "
|
||||
"This is a spoken voice assistant: answer in ONE short sentence whenever possible "
|
||||
"(two at the very most). No lists, no preamble, no 'is there anything else' offers. "
|
||||
"This is a spoken voice assistant: your ENTIRE reply must be a single short sentence. "
|
||||
"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 "
|
||||
"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 "
|
||||
@@ -119,10 +120,13 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
|
||||
"'tell me a joke', 'chat with me'), never reply with a bare greeting like 'Hey there!', "
|
||||
"'Hi!', 'How can I help you?', or a generic observation about an unrelated topic. "
|
||||
"When the 'Information the user has shared…' section is present, you MUST pick one concrete "
|
||||
"fact from it and build the reply around that fact (e.g. 'You mentioned you box at Trenches "
|
||||
"Gym — how's training going this week?'). Do not talk about things that are not in that "
|
||||
"section. Only when that section is absent may you invent a fresh observation, question, or "
|
||||
"joke. Produce a varied response each time — do not repeat a previous reply verbatim. "
|
||||
"fact from it and build the reply around that fact, opening with a short natural reference to "
|
||||
"it. CRITICAL: use ONLY names, places, activities, and details that literally appear in that "
|
||||
"section — never borrow any name, place, or activity from these instructions or from any "
|
||||
"example wording, and never invent specifics that are not in that section. Do not talk about "
|
||||
"things that are not in that section. Only when that section is absent may you invent a fresh "
|
||||
"observation, question, or joke. Produce a varied response each time — do not repeat a "
|
||||
"previous reply verbatim. "
|
||||
"Banned phrasings: 'I can only tell you what you have shared with me in this conversation', "
|
||||
"'I don't have access to any personal information outside of what you tell me', 'I don't have "
|
||||
"personal details outside of our conversation history', 'I do not store personal details "
|
||||
|
||||
@@ -45,9 +45,16 @@ class ControlBrowserTool(Tool):
|
||||
"Use this (NOT webSearch) whenever the user wants something done or shown IN the "
|
||||
"browser on screen: open a website or URL, search on a specific site (action "
|
||||
"'search' with site=naver/google/daum/youtube/bing), go back/forward, refresh, "
|
||||
"manage tabs (list/new/close/switch), close popups, click, type, scroll, or "
|
||||
"screenshot. webSearch only returns text and shows nothing on screen; this tool "
|
||||
"actually navigates the visible browser. Only available in screen-share mode. "
|
||||
"manage tabs (list/new/close/switch), close popups, move the mouse onto an element, "
|
||||
"click, type, scroll, or screenshot. webSearch only returns text and shows nothing "
|
||||
"on screen; this tool actually navigates the visible browser. "
|
||||
"Cursor behaviour matters: 'search' and 'type' (with a selector) move the REAL mouse "
|
||||
"cursor to the on-page box, click it, then type one character at a time — use these "
|
||||
"when the user wants to search or type on a page. 'navigate' only types the URL into "
|
||||
"the address bar (no mouse movement). 'moveMouse' moves/hovers the visible cursor "
|
||||
"onto an element (selector, or site=naver/... for that site's search box) WITHOUT "
|
||||
"clicking — use it when the user just asks to move the mouse somewhere. "
|
||||
"Only available in screen-share mode. "
|
||||
"Never claim you did any of this unless this tool returns success."
|
||||
)
|
||||
|
||||
@@ -61,16 +68,16 @@ class ControlBrowserTool(Tool):
|
||||
"enum": [
|
||||
"status", "listTabs", "navigate", "search", "back",
|
||||
"forward", "refresh", "newTab", "closeTab", "activateTab",
|
||||
"closePopups", "click", "type", "scroll", "pressKey",
|
||||
"closePopups", "moveMouse", "click", "type", "scroll", "pressKey",
|
||||
"screenshot",
|
||||
],
|
||||
"description": "What to do in the browser.",
|
||||
},
|
||||
"url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."},
|
||||
"query": {"type": "string", "description": "Search text for action 'search'."},
|
||||
"site": {"type": "string", "description": "Search site for action 'search': naver, google, daum, youtube, bing."},
|
||||
"site": {"type": "string", "description": "Site for action 'search' or 'moveMouse': naver, google, daum, youtube, bing. For moveMouse it targets that site's search box."},
|
||||
"index": {"type": "integer", "description": "Tab index for closeTab/activateTab (from listTabs)."},
|
||||
"selector": {"type": "string", "description": "CSS selector for click/type."},
|
||||
"selector": {"type": "string", "description": "CSS selector for click/type/moveMouse."},
|
||||
"text": {"type": "string", "description": "Text to type."},
|
||||
"key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."},
|
||||
"dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."},
|
||||
@@ -149,7 +156,12 @@ class ControlBrowserTool(Tool):
|
||||
if action == "navigate":
|
||||
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
|
||||
if action == "search":
|
||||
return f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
||||
base = f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
||||
# Flag when the real cursor path didn't run, so a silent fallback to
|
||||
# cursor-less DOM input is visible rather than reported as "human".
|
||||
if data.get("input") in ("api", "api-fallback"):
|
||||
base += " (참고: 실제 마우스 커서 이동 없이 처리됨)"
|
||||
return base
|
||||
if action in ("back", "forward", "refresh"):
|
||||
return f"브라우저: {action} 완료 ({data.get('url', '')})."
|
||||
if action in ("status", "listTabs"):
|
||||
@@ -164,6 +176,9 @@ class ControlBrowserTool(Tool):
|
||||
return f"탭 {data.get('active')}번으로 전환했습니다."
|
||||
if action == "closePopups":
|
||||
return f"팝업/빈 탭 {data.get('closed')}개를 닫았습니다."
|
||||
if action == "moveMouse":
|
||||
target = data.get("target") or args.get("selector") or args.get("site") or "대상"
|
||||
return f"마우스 커서를 {target} 위치로 옮겼습니다."
|
||||
if action == "screenshot":
|
||||
return f"화면을 캡처했습니다: {data.get('path')}"
|
||||
return "완료했습니다."
|
||||
|
||||
56
tests/test_control_browser.py
Normal file
56
tests/test_control_browser.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for the controlBrowser tool's action surface.
|
||||
|
||||
These are deterministic schema/summary checks — they do not drive a real
|
||||
browser. The actual cursor movement is exercised live on the browser host
|
||||
(xdotool + CDP), which these tests cannot reach.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from jarvis.tools.builtin.control_browser import ControlBrowserTool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool():
|
||||
return ControlBrowserTool()
|
||||
|
||||
|
||||
def test_movemouse_is_an_exposed_action(tool):
|
||||
# A weak model confabulated "moved the mouse" because no move/hover action
|
||||
# existed to call. The cursor-move capability must be a real action so the
|
||||
# request "move the mouse to the search box" maps to a tool call.
|
||||
enum = tool.inputSchema["properties"]["action"]["enum"]
|
||||
assert "moveMouse" in enum
|
||||
|
||||
|
||||
def test_movemouse_summary_reports_the_target(tool):
|
||||
summary = tool._summarise("moveMouse", {"site": "naver"}, {"ok": True, "target": "naver"})
|
||||
assert "마우스" in summary and "naver" in summary
|
||||
|
||||
|
||||
def test_description_distinguishes_cursor_paths(tool):
|
||||
# The model must know navigate is address-bar only (no mouse) while
|
||||
# search/type/moveMouse move the real cursor — that distinction is the
|
||||
# whole point of the fix.
|
||||
desc = tool.description
|
||||
assert "moveMouse" in desc
|
||||
assert "address bar" in desc # navigate is described as address-bar typing
|
||||
|
||||
|
||||
def test_search_summary_flags_cursorless_fallback(tool):
|
||||
# When the real xdotool cursor path didn't run, the summary must say so
|
||||
# rather than implying a human-like search happened.
|
||||
human = tool._summarise("search", {"query": "날씨"}, {"ok": True, "site": "naver", "query": "날씨", "input": "human"})
|
||||
assert "참고: 실제 마우스" not in human
|
||||
|
||||
fell_back = tool._summarise("search", {"query": "날씨"}, {"ok": True, "site": "naver", "query": "날씨", "input": "api-fallback"})
|
||||
assert "실제 마우스 커서 이동 없이" in fell_back
|
||||
|
||||
|
||||
def test_movemouse_summary_only_runs_on_success(tool):
|
||||
# _summarise is only called on ok:true; an ok:false (target not found / no
|
||||
# xdotool) is handled by run() as a failure reply, so a failed move can no
|
||||
# longer be reported as "moved". Sanity-check the success summary names a
|
||||
# target rather than a placeholder when one is present.
|
||||
summary = tool._summarise("moveMouse", {"selector": "#query"}, {"ok": True, "target": "#query"})
|
||||
assert "#query" in summary
|
||||
62
tests/test_intent_model_split.py
Normal file
62
tests/test_intent_model_split.py
Normal 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
|
||||
@@ -21,9 +21,15 @@ class TestModelSizeDetection:
|
||||
("gemma:7b", True),
|
||||
("phi3:3b", True),
|
||||
("qwen2:7b", True),
|
||||
# Sub-8B sizes that were previously misclassified as LARGE.
|
||||
("qwen3.5:4b", True), # the deployed model that produced weak, off-tone replies
|
||||
("gemma2:2b", True),
|
||||
("model:5b", True),
|
||||
("model:6b", True),
|
||||
# Various separators
|
||||
("model-3b-instruct", True),
|
||||
("model_1b_chat", True),
|
||||
("model-4b-instruct", True),
|
||||
# Large models (should return LARGE)
|
||||
("gpt-oss:20b", False),
|
||||
("llama3.1:8b", False),
|
||||
@@ -121,6 +127,18 @@ class TestPromptComponents:
|
||||
assert prompts.voice_style, f"{size.value} missing voice_style"
|
||||
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):
|
||||
"""to_list() returns only non-empty prompt strings."""
|
||||
from jarvis.reply.prompts import get_system_prompts, ModelSize
|
||||
|
||||
@@ -44,6 +44,32 @@ class TestBuildSystemPrompt:
|
||||
assert "in the user's language" not 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
|
||||
|
||||
def test_persona_has_no_copyable_proper_noun_examples(self):
|
||||
# A weak model parroted the literal "Trenches Gym" example from the
|
||||
# persona as if it were a real user fact (boxing mangled to tennis).
|
||||
# The persona must not embed copyable personal proper nouns, and must
|
||||
# tell the model to use ONLY facts that literally appear in the memory
|
||||
# section — never borrow names/places from the instructions themselves.
|
||||
prompt = build_system_prompt("Jarvis")
|
||||
assert "Trenches" not in prompt
|
||||
assert "never borrow any name, place, or activity from these instructions" in prompt
|
||||
|
||||
|
||||
class TestOutputLanguageDirective:
|
||||
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE.
|
||||
|
||||
Reference in New Issue
Block a user