Compare commits
16 Commits
v1.2.3
...
23b1fe692b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b1fe692b | ||
|
|
7bb9718c34 | ||
|
|
7da2fcb5e5 | ||
|
|
680f5a656a | ||
|
|
b52ffd2b18 | ||
|
|
140fc56f18 | ||
|
|
5ee47827f3 | ||
|
|
c189ce2e65 | ||
|
|
086dd5cde7 | ||
|
|
f64d76e737 | ||
|
|
11c3621093 | ||
|
|
7ad5d99380 | ||
|
|
39a0944105 | ||
|
|
b9f637faa4 | ||
|
|
2f000ac6c8 | ||
|
|
677bfcd2a9 |
43
.env.example
43
.env.example
@@ -34,18 +34,18 @@ 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: "melo" (default) uses the MeloTTS Korean voice served by the warm
|
||||
# melo-worker (Korean speaker, speed 1.5). Set to "piper" to use Piper directly.
|
||||
TTS_ENGINE=melo
|
||||
# Melo-only by default: if MeloTTS synthesis fails the bridge returns no audio
|
||||
# rather than speaking Korean through the English Piper voice (which mangles it).
|
||||
# Set to 1 only if you explicitly want the Piper fallback.
|
||||
# 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).
|
||||
EDGE_TTS_VOICE=ko-KR-HyunsuMultilingualNeural
|
||||
EDGE_TTS_RATE=+45%
|
||||
# Neural-only by default: if synthesis fails the bridge returns no audio rather
|
||||
# than speaking Korean through the English Piper voice. Set to 1 to allow the
|
||||
# Piper fallback.
|
||||
MELO_FALLBACK_PIPER=0
|
||||
# Where the bridge reaches the in-container MeloTTS worker, and how long it
|
||||
# waits for a synthesis. Speaking rate is set on the worker via MELO_SPEED.
|
||||
MELO_WORKER_URL=http://127.0.0.1:8770
|
||||
MELO_TIMEOUT=30
|
||||
MELO_SPEED=1.5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jarvis brain (Ollama-backed). In Docker these populate the rendered
|
||||
@@ -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.
|
||||
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
|
||||
@@ -74,6 +78,12 @@ WHISPER_MODEL=small
|
||||
# occasional trailing CJK fragment small models leak on free-form chat).
|
||||
OUTPUT_LANGUAGE=
|
||||
|
||||
# Operator instruction folder: every *.md in this dir is appended to the main
|
||||
# reply LLM's system prompt (filename order), re-read each turn so edits apply
|
||||
# without a rebuild/restart. ./agents is bind-mounted here read-only; only
|
||||
# change this to relocate the folder inside the container. See README "운영자 지시문".
|
||||
AGENTS_DIR=/app/agents
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker desktop (VNC) — used only by the container image
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -220,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: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) ---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.
|
||||
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -65,20 +65,14 @@ 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
|
||||
|
||||
# --- MeloTTS Korean voice (separate /opt/melo py3.11 venv; see setup-melo.sh).
|
||||
# Heavy layer (torch CPU + transformers + MeCab); placed before the app
|
||||
# COPY so it stays cached across source-only changes. ---
|
||||
COPY docker/setup-melo.sh /app/docker/setup-melo.sh
|
||||
# Strip CR before running: a Windows checkout (autocrlf) yields CRLF, which makes
|
||||
# bash read line 18 as `set -euxo pipefail\r` and abort with
|
||||
# "set: pipefail: invalid option name". .gitattributes pins *.sh to LF, but this
|
||||
# keeps the build working even on a not-yet-renormalised working tree.
|
||||
RUN sed -i 's/\r$//' /app/docker/setup-melo.sh && bash /app/docker/setup-melo.sh
|
||||
# --- 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. ---
|
||||
|
||||
# --- Human input + window management for the on-screen Chrome control tool.
|
||||
# Placed AFTER the heavy melo layer so it doesn't bust that cache. xdotool
|
||||
# injects real X pointer/keyboard events (visible cursor, char-by-char
|
||||
# typing) into the broadcast; wmctrl lists/moves windows. ---
|
||||
# xdotool injects real X pointer/keyboard events (visible cursor,
|
||||
# char-by-char typing) into the broadcast; wmctrl lists/moves windows. ---
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
xdotool wmctrl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
22
README.md
22
README.md
@@ -69,7 +69,7 @@ docker compose -f docker-compose.yml -f docker-compose.gpu-linux.yml up -d --bui
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu-windows.yml up -d --build
|
||||
|
||||
# ── GPU 없이 (CPU 전용 호스트) ──
|
||||
# .env 에 WHISPER_DEVICE=cpu, MELO_DEVICE=cpu 를 넣고 베이스만 사용
|
||||
# .env 에 WHISPER_DEVICE=cpu 를 넣고 베이스만 사용
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
@@ -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 음성 자동 다운로드(볼륨에 캐시)
|
||||
|
||||
@@ -113,7 +113,7 @@ docker compose up -d # 유저봇이 로그인해 지정 음성채널에
|
||||
|
||||
### GPU 가속 (OS별)
|
||||
|
||||
LLM(Ollama), Whisper STT, MeloTTS가 GPU에서 돕니다(env 기본 `WHISPER_DEVICE=cuda`, `MELO_DEVICE=cuda`). NVIDIA Blackwell(sm_120, 예: RTX 5050/5070Ti)에서 검증: 컨테이너 내 torch cu128 CUDA 동작, Ollama GPU 오프로드, faster-whisper float16, MeloTTS GPU 합성 모두 확인.
|
||||
LLM(Ollama)과 Whisper STT가 GPU에서 돕니다(env 기본 `WHISPER_DEVICE=cuda`). TTS는 기본값이 Edge TTS(온라인 한국어 음성)라 GPU를 쓰지 않습니다. NVIDIA Blackwell(sm_120, 예: RTX 5050/5070Ti)에서 검증: 컨테이너 내 torch cu128 CUDA 동작, Ollama GPU 오프로드, faster-whisper float16 모두 확인.
|
||||
|
||||
GPU는 위 "실행 — Docker"의 OS별 override 파일로 켜집니다. 호스트 사전 준비는 OS마다 다릅니다:
|
||||
|
||||
@@ -137,7 +137,7 @@ docker run --rm --device nvidia.com/gpu=all ubuntu nvidia-smi -L # GPU 보이
|
||||
**공통:**
|
||||
|
||||
- 모델: 베이스 compose 기본은 `qwen2.5:3b`(8GB VRAM에서 도구호출 안정적). 더 무겁게(`qwen2.5:7b`, `qwen3:8b` 등) 쓰려면 `.env`의 `OLLAMA_CHAT_MODEL` 변경.
|
||||
- **GPU가 없거나 인식 실패 시 자동으로 CPU 폴백**(Whisper)하므로 안전합니다. 명시적으로 CPU만 쓰려면 override 파일 없이 베이스만 올리고 `.env`에 `WHISPER_DEVICE=cpu`, `MELO_DEVICE=cpu`를 두세요.
|
||||
- **GPU가 없거나 인식 실패 시 자동으로 CPU 폴백**(Whisper)하므로 안전합니다. 명시적으로 CPU만 쓰려면 override 파일 없이 베이스만 올리고 `.env`에 `WHISPER_DEVICE=cpu`를 두세요.
|
||||
|
||||
- 데이터(메모리 DB), Whisper 캐시, Piper 음성은 named volume에 영속됩니다.
|
||||
- 셀프봇 영상 송출 의존성은 이미지에 기본 포함하지 않습니다. 쓰려면 컨테이너에서 `cd /app/bot && bun add discord.js-selfbot-v13 @dank074/discord-video-stream` 후 재시작(또는 Dockerfile에 추가).
|
||||
@@ -243,10 +243,22 @@ cd bot; bun run register; bun run start # 창 2: (일반 봇이면) 슬래시
|
||||
- `BRIDGE_URL` — 봇이 호출할 브릿지 주소 (기본 `http://127.0.0.1:8765`)
|
||||
- `STREAM_BACKEND`, `DISCORD_SELFBOT_TOKEN`, `NOVNC_URL` — 화면 송출
|
||||
- `VNC_DISPLAY=:1`, `VNC_RESOLUTION`, `VNC_FRAMERATE`, `VNC_BITRATE_KBPS` — 캡처
|
||||
- `WHISPER_DEVICE/COMPUTE_TYPE`, `MELO_DEVICE` — GPU 호스트면 `cuda`/`float16`, CPU 전용이면 `cpu`(GPU 자체는 OS별 override compose 파일로 켬)
|
||||
- `WHISPER_DEVICE/COMPUTE_TYPE` — GPU 호스트면 `cuda`/`float16`, CPU 전용이면 `cpu`(GPU 자체는 OS별 override compose 파일로 켬)
|
||||
- `OLLAMA_CHAT_MODEL` — 두뇌 LLM (기본 `qwen2.5:3b`)
|
||||
- `COMPOSE_FILE` — OS별 GPU override를 매번 `-f`로 안 치고 자동 적용 (위 "실행 — Docker" 참고)
|
||||
- `output_language` — 출력 언어 고정(비우면 사용자 언어). 설정 웹 UI(`/settings`)에서 바꾸면 env 기본값보다 우선하며 컨테이너 재생성 후에도 유지됩니다.
|
||||
- `AGENTS_DIR` — 운영자 지시문 폴더(기본 `/app/agents`, `./agents`가 read-only로 마운트됨). 아래 "운영자 지시문" 참고.
|
||||
|
||||
---
|
||||
|
||||
## 운영자 지시문 (`agents/*.md`)
|
||||
|
||||
`agents/` 폴더에 마크다운 파일을 넣으면 그 내용이 어시스턴트의 메인 답변 시스템 프롬프트 뒤에 그대로 추가됩니다. 페르소나(집사 성격)는 그대로 두고 규칙·말투·금칙어 등을 덧붙일 때 쓰세요.
|
||||
|
||||
- `agents/` 안의 모든 `*.md`를 **파일명 순서**로 이어 붙입니다. 순서를 정하려면 `00-tone.md`, `10-rules.md`처럼 숫자 접두사를 쓰세요.
|
||||
- **매 답변마다 다시 읽습니다.** 파일을 저장하면 다음 발화부터 바로 반영되며, 재빌드/재시작이 필요 없습니다(폴더가 read-only로 마운트됨).
|
||||
- 폴더가 없거나 비어 있으면 아무 일도 일어나지 않습니다(fail-open).
|
||||
- `agents/example.md.sample`을 `rules.md` 등 `*.md`로 복사해서 시작하세요. `.sample` 파일은 로드되지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
15
agents/example.md.sample
Normal file
15
agents/example.md.sample
Normal file
@@ -0,0 +1,15 @@
|
||||
# Operator instruction file (example)
|
||||
#
|
||||
# HOW TO USE: copy or rename this file to anything ending in `.md`
|
||||
# (e.g. `rules.md`). Every `*.md` in this folder is appended to the assistant's
|
||||
# main reply system prompt, in filename order — use number prefixes like
|
||||
# `00-tone.md`, `10-rules.md` to control ordering. Edits take effect on the
|
||||
# NEXT reply; no rebuild or restart is needed (the folder is read per turn).
|
||||
#
|
||||
# Files ending in `.sample` (like this one) are ignored, so this template never
|
||||
# affects replies until you rename it to `*.md`.
|
||||
#
|
||||
# Everything below a heading is treated as plain instruction text for the LLM.
|
||||
|
||||
Always keep replies under two sentences.
|
||||
When the user asks about deployment, mention the relevant docker compose command.
|
||||
13
agents/llm.md
Normal file
13
agents/llm.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 자비스 운영자 지시
|
||||
|
||||
- 너의 이름은 자비스다.
|
||||
- 모든 답변은 음성(TTS)으로 읽혀 나간다. 그러니 무조건 한 문장으로만 답한다. 두 문장 이상 쓰지 않는다. 목록, 마크다운, 이모지, 그리고 소리 내어 읽기 어려운 특수문자는 쓰지 않는다.
|
||||
- 정해진 문구에만 반응하지 말고, 실제 사람처럼 말의 뉘앙스와 맥락으로 의도를 알아듣고 처리한다.
|
||||
|
||||
화면 속 크롬(방송 화면)에서 유튜브를 다룰 때 (화면에 보여야 하므로 반드시 on-screen 브라우저 제어 도구로 수행한다):
|
||||
|
||||
- "유튜브 켜줘" → 방송 크롬에서 유튜브를 연다.
|
||||
- "유튜브에서 OO 검색해줘" → 유튜브로 가서 검색창에 OO를 사람이 직접 타이핑하듯 입력하고 검색한다.
|
||||
- "위에서 N번째 영상 재생해줘" 또는 "왼쪽에서 N번째 영상 재생해줘" → 검색 결과 목록에서 그 위치의 영상을 재생한다.
|
||||
- "일시정지해줘" → 현재 영상을 일시정지한다. "다시 재생해줘" → 이어서 재생한다.
|
||||
- "영상 종료" 또는 "그만 보여줘" → 뒤로 가서 직전 화면으로 돌아간다.
|
||||
@@ -2,10 +2,11 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// - 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`:
|
||||
// 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';
|
||||
const query = process.argv[2] || '';
|
||||
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)); };
|
||||
|
||||
if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); }
|
||||
@@ -105,15 +109,21 @@ try {
|
||||
await page.bringToFront().catch(() => {});
|
||||
|
||||
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 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 title = (await first.getAttribute('title').catch(() => '')) || (await first.innerText().catch(() => ''));
|
||||
await first.click();
|
||||
const results = page.locator('ytd-video-renderer a#video-title, a#video-title');
|
||||
// Clamp to what's actually on the page so "play the 5th" still plays the
|
||||
// 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.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 {
|
||||
// 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);
|
||||
|
||||
@@ -21,7 +21,11 @@ nvidia-cudnn-cu12
|
||||
# --- Bridge HTTP service ---
|
||||
flask>=3.0.0
|
||||
|
||||
# --- Text-to-speech (Piper) ---
|
||||
# --- Text-to-speech ---
|
||||
# Edge TTS: the primary Korean voice (online MS neural). Lightweight (httpx);
|
||||
# emits MP3, transcoded to PCM16 by the system ffmpeg in the bridge.
|
||||
edge-tts>=6.1.0
|
||||
# Piper: offline English fallback.
|
||||
piper-tts>=1.3.0
|
||||
|
||||
# --- Built-in tools (lazily imported; needed for full functionality) ---
|
||||
|
||||
112
bridge/server.py
112
bridge/server.py
@@ -87,12 +87,22 @@ 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
|
||||
|
||||
# TTS engine: "melo" (MeloTTS Korean speaker, the warm worker) is the primary
|
||||
# voice; Piper is kept as a fallback if the worker is unreachable. Set
|
||||
# TTS_ENGINE=piper to disable MeloTTS entirely.
|
||||
# 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:
|
||||
"""TTS engine: settings-UI value (runtime config JSON) wins, else env, else
|
||||
melo. Read at startup; the settings UI restarts the bridge on apply."""
|
||||
edge. Read at startup; the settings UI restarts the bridge on apply."""
|
||||
try:
|
||||
_cp = os.environ.get("JARVIS_CONFIG_PATH", "/app/config/jarvis.json")
|
||||
_v = json.loads(open(_cp, encoding="utf-8").read()).get("tts_engine")
|
||||
@@ -100,16 +110,22 @@ def _tts_engine_setting() -> str:
|
||||
return str(_v).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get("TTS_ENGINE", "melo").strip().lower()
|
||||
return os.environ.get("TTS_ENGINE", "edge").strip().lower()
|
||||
|
||||
|
||||
TTS_ENGINE = _tts_engine_setting()
|
||||
# Edge TTS (online MS neural voice). Voice + rate are env-driven so they can be
|
||||
# changed without code. Default: Korean "Hyunsu" multilingual voice at +45%
|
||||
# (≈1.45×), the chosen settings. NOTE: edge synthesis sends the reply TEXT to
|
||||
# Microsoft's servers and needs internet — an intentional privacy trade-off for
|
||||
# the more natural voice.
|
||||
EDGE_TTS_VOICE = os.environ.get("EDGE_TTS_VOICE", "ko-KR-HyunsuMultilingualNeural").strip()
|
||||
EDGE_TTS_RATE = os.environ.get("EDGE_TTS_RATE", "+45%").strip()
|
||||
MELO_WORKER_URL = os.environ.get("MELO_WORKER_URL", "http://127.0.0.1:8770")
|
||||
MELO_TIMEOUT = float(os.environ.get("MELO_TIMEOUT", "30"))
|
||||
# When MeloTTS is the engine, do NOT silently fall back to the English Piper
|
||||
# voice on failure: speaking Korean text through an English voice produces
|
||||
# mangled audio. Default is melo-only (return no audio on failure); set
|
||||
# MELO_FALLBACK_PIPER=1 to opt into the Piper fallback.
|
||||
# Do NOT silently fall back to the English Piper voice on a neural-voice failure:
|
||||
# speaking Korean through an English voice produces mangled audio. Default is
|
||||
# neural-only (return no audio on failure); set MELO_FALLBACK_PIPER=1 to opt in.
|
||||
MELO_FALLBACK_PIPER = os.environ.get("MELO_FALLBACK_PIPER", "0") in ("1", "true", "True", "yes", "on")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -150,12 +166,17 @@ def _ensure_brain():
|
||||
compute = os.environ.get("WHISPER_COMPUTE_TYPE", "auto")
|
||||
try:
|
||||
whisper = WhisperModel(cfg.whisper_model, device=device, compute_type=compute)
|
||||
# Log the device actually resolved by CTranslate2 (device="auto"
|
||||
# picks cuda when available) so a silent CPU load is visible.
|
||||
resolved = str(getattr(getattr(whisper, "model", None), "device", device)).lower()
|
||||
print(f"[bridge] whisper loaded on {resolved} (compute={compute})", flush=True)
|
||||
except Exception as ge:
|
||||
# GPU not available / unsupported -> fall back to CPU so the
|
||||
# bridge still works without a GPU passed to the container.
|
||||
if device != "cpu":
|
||||
print(f"[bridge] whisper device='{device}' failed ({ge}); falling back to CPU", flush=True)
|
||||
whisper = WhisperModel(cfg.whisper_model, device="cpu", compute_type="int8")
|
||||
print("[bridge] whisper loaded on cpu (compute=int8)", flush=True)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -233,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
|
||||
@@ -297,6 +323,54 @@ def _coerce_bool(value) -> Optional[bool]:
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _edge_synthesize(text: str) -> Optional[bytes]:
|
||||
"""Synthesise via Microsoft Edge TTS (online neural voice) and return a
|
||||
16-bit PCM WAV, or None on any failure. Edge emits MP3; we transcode to
|
||||
PCM16 mono with the system ffmpeg, writing to a temp file (seekable) so the
|
||||
WAV header carries a correct length. Needs internet."""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
import edge_tts # type: ignore
|
||||
|
||||
async def _gen() -> bytes:
|
||||
comm = edge_tts.Communicate(text, EDGE_TTS_VOICE, rate=EDGE_TTS_RATE)
|
||||
buf = bytearray()
|
||||
async for chunk in comm.stream():
|
||||
if chunk.get("type") == "audio":
|
||||
buf.extend(chunk["data"])
|
||||
return bytes(buf)
|
||||
|
||||
mp3 = asyncio.run(_gen())
|
||||
if not mp3:
|
||||
print("[bridge] edge TTS returned no audio", flush=True)
|
||||
return None
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as t:
|
||||
out_path = t.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", "pipe:0", "-ac", "1", "-ar", "24000",
|
||||
"-acodec", "pcm_s16le", out_path],
|
||||
input=mp3, capture_output=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print(f"[bridge] edge ffmpeg transcode failed: {proc.stderr.decode('utf-8','ignore')[:200]}", flush=True)
|
||||
return None
|
||||
with open(out_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e: # pragma: no cover - network / dep dependent
|
||||
print(f"[bridge] edge synth failed: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _melo_synthesize(text: str) -> Optional[bytes]:
|
||||
"""Synthesise via the warm MeloTTS worker (separate /opt/melo venv, Korean
|
||||
speaker @ speed 1.5). Returns a 16-bit PCM WAV, or None on any failure so
|
||||
@@ -356,20 +430,22 @@ def _tts_ready() -> bool:
|
||||
|
||||
|
||||
def synthesize(text: str) -> Optional[bytes]:
|
||||
"""Synthesize text to a 16-bit PCM WAV. The primary voice is MeloTTS
|
||||
(Korean speaker, speed 1.5) served by the warm melo worker; Piper is a
|
||||
fallback if the worker is unavailable. Returns None if TTS is off."""
|
||||
"""Synthesize text to a 16-bit PCM WAV. The primary voice is Edge TTS (a
|
||||
natural Korean neural voice); "melo" uses the warm MeloTTS worker. For a
|
||||
neural engine, Piper (English) is only used if explicitly enabled, since
|
||||
speaking Korean through an English voice mangles it. Returns None if off."""
|
||||
if not TTS_ENABLED or not text.strip():
|
||||
return None
|
||||
if TTS_ENGINE == "melo":
|
||||
audio = _melo_synthesize(text)
|
||||
_neural = {"edge": _edge_synthesize, "melo": _melo_synthesize}.get(TTS_ENGINE)
|
||||
if _neural is not None:
|
||||
audio = _neural(text)
|
||||
if audio:
|
||||
return audio
|
||||
if not MELO_FALLBACK_PIPER:
|
||||
# Melo-only: better silent than mangled English for Korean text.
|
||||
print("[bridge] melo synth failed; no audio (Piper fallback disabled)", flush=True)
|
||||
# Neural-only: better silent than mangled English for Korean text.
|
||||
print(f"[bridge] {TTS_ENGINE} synth failed; no audio (Piper fallback disabled)", flush=True)
|
||||
return None
|
||||
print("[bridge] melo synth failed; falling back to Piper", flush=True)
|
||||
print(f"[bridge] {TTS_ENGINE} synth failed; falling back to Piper", flush=True)
|
||||
return _piper_synthesize(text)
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ from typing import Any, Dict
|
||||
FIELDS = [
|
||||
("ollama_chat_model", "LLM 모델", "model"),
|
||||
("whisper_model", "STT(Whisper) 모델", "select:tiny,base,small,medium,large,large-v3"),
|
||||
("tts_engine", "TTS 엔진", "select:melo,piper"),
|
||||
("melo_speed", "TTS 속도 (MeloTTS)", "number:0.5:2.5:0.1"),
|
||||
("tts_engine", "TTS 엔진", "select:edge,piper"),
|
||||
("output_language", "출력 언어 (비우면 사용자 언어)", "text"),
|
||||
("llm_thinking_enabled", "LLM 사고(thinking) 모드", "bool"),
|
||||
("agentic_max_turns", "에이전트 최대 반복", "number:1:12:1"),
|
||||
@@ -54,9 +53,7 @@ def _current() -> Dict[str, Any]:
|
||||
cfg = _read_config()
|
||||
out: Dict[str, Any] = {}
|
||||
for k in _KEYS:
|
||||
if k == "melo_speed":
|
||||
out[k] = cfg.get("melo_speed", os.environ.get("MELO_SPEED", "1.5"))
|
||||
elif k == "output_language":
|
||||
if k == "output_language":
|
||||
out[k] = cfg.get("output_language", os.environ.get("OUTPUT_LANGUAGE", ""))
|
||||
else:
|
||||
out[k] = cfg.get(k, "")
|
||||
@@ -78,12 +75,7 @@ def _coerce(updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for k, v in updates.items():
|
||||
if k not in _KEYS:
|
||||
continue
|
||||
if k == "melo_speed":
|
||||
try:
|
||||
v = float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
elif k == "agentic_max_turns":
|
||||
if k == "agentic_max_turns":
|
||||
try:
|
||||
v = int(v)
|
||||
except (TypeError, ValueError):
|
||||
@@ -114,15 +106,15 @@ def _save(updates: Dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _apply() -> str:
|
||||
# Restart melo + bridge AFTER this response is sent. Detached (new session)
|
||||
# so the bridge being killed mid-restart doesn't drop the restart itself,
|
||||
# and the HTTP client still receives this response.
|
||||
# Restart the bridge AFTER this response is sent. Detached (new session) so
|
||||
# the bridge being killed mid-restart doesn't drop the restart itself, and
|
||||
# the HTTP client still receives this response. (Edge TTS has no worker.)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["sh", "-c", "sleep 1; supervisorctl restart melo-worker bridge"],
|
||||
["sh", "-c", "sleep 1; supervisorctl restart bridge"],
|
||||
start_new_session=True,
|
||||
)
|
||||
return "1초 후 브리지/TTS 워커가 재시작되어 반영됩니다."
|
||||
return "1초 후 브리지가 재시작되어 반영됩니다."
|
||||
except Exception as e: # pragma: no cover
|
||||
return str(e)
|
||||
|
||||
|
||||
@@ -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.";
|
||||
@@ -62,13 +69,27 @@ services:
|
||||
# Point the brain at the ollama service and the bot at the in-container bridge.
|
||||
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}
|
||||
# MeloTTS on the GPU (cu128 torch baked by docker/setup-melo.sh). CPU synth
|
||||
# serialised under load and pushed TTS to 7-8s; GPU does ~0.3s/sentence.
|
||||
MELO_DEVICE: ${MELO_DEVICE:-cuda}
|
||||
# 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
|
||||
# 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%}
|
||||
# Optional single-language lock for replies (empty = user's own language).
|
||||
OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-ko}
|
||||
# Drop the pre-loop planner LLM call to cut voice-reply latency on small
|
||||
@@ -76,6 +97,9 @@ 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
|
||||
@@ -97,6 +121,10 @@ services:
|
||||
BROWSER_CONTROL_BIND: ${BROWSER_CONTROL_BIND:-0.0.0.0}
|
||||
BROWSER_CONTROL_PORT: ${BROWSER_CONTROL_PORT:-8777}
|
||||
BROWSER_CONTROL_URL: ${BROWSER_CONTROL_URL:-}
|
||||
# Folder of operator *.md instruction files appended to the main reply
|
||||
# LLM's system prompt. Bind-mounted from ./agents below; override only to
|
||||
# relocate it inside the container.
|
||||
AGENTS_DIR: ${AGENTS_DIR:-/app/agents}
|
||||
# No hard depends_on ollama: a browser-host (`docker compose up -d javis`)
|
||||
# must NOT pull in Ollama. Full/bot layouts start it with a plain
|
||||
# `docker compose up -d` (all services); the bridge tolerates Ollama warming
|
||||
@@ -149,6 +177,11 @@ services:
|
||||
# If unseeded, the path fail-opens to the DDG/Brave cascade and the
|
||||
# entrypoint logs a warning. Only consumed when GEMINI_AUTH=oauth.
|
||||
- ${GEMINI_OAUTH_DIR:-./docker/gemini-oauth}:/root/.gemini
|
||||
# Operator instruction files. Every *.md here is appended to the main
|
||||
# reply LLM's system prompt (filename order), read per turn so edits apply
|
||||
# on the next reply without a rebuild/restart. Read-only; a project-
|
||||
# relative path resolves identically on Linux and Windows Docker Desktop.
|
||||
- ./agents:/app/agents:ro
|
||||
|
||||
volumes:
|
||||
ollama_models:
|
||||
|
||||
@@ -10,12 +10,15 @@ 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}"
|
||||
: "${OLLAMA_EMBED_MODEL:=nomic-embed-text}"
|
||||
: "${WHISPER_MODEL:=small}"
|
||||
: "${WHISPER_DEVICE:=cuda}"
|
||||
@@ -32,7 +35,7 @@ set -euo pipefail
|
||||
: "${XDG_RUNTIME_DIR:=/run/user/0}"
|
||||
: "${PULSE_SERVER:=unix:${XDG_RUNTIME_DIR}/pulse/native}"
|
||||
|
||||
export VNC_RESOLUTION OLLAMA_BASE_URL OLLAMA_CHAT_MODEL OLLAMA_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 \
|
||||
PIPER_VOICE PIPER_VOICE_DIR TTS_PIPER_MODEL_PATH BRIDGE_HOST BRIDGE_PORT \
|
||||
XDG_RUNTIME_DIR PULSE_SERVER
|
||||
@@ -51,12 +54,18 @@ export JARVIS_CONFIG_PATH=/app/config/jarvis.json
|
||||
# the env-rendered config, so changes survive container recreate.
|
||||
if [ -f /data/jarvis-settings.json ]; then
|
||||
python3 - <<'PY' || true
|
||||
import json
|
||||
import json, os
|
||||
try:
|
||||
base = json.load(open("/app/config/jarvis.json"))
|
||||
ov = json.load(open("/data/jarvis-settings.json"))
|
||||
if isinstance(base, dict) and isinstance(ov, dict):
|
||||
base.update(ov)
|
||||
# A stale persisted tts_engine from an earlier voice (melo/xtts, no
|
||||
# longer built into the image) would override the configured engine and
|
||||
# leave the bot silent. Reset those to the env-configured engine.
|
||||
if base.get("tts_engine") in ("melo", "xtts"):
|
||||
base["tts_engine"] = os.environ.get("TTS_ENGINE", "edge")
|
||||
print(f"[entrypoint] reset stale tts_engine -> {base['tts_engine']}")
|
||||
json.dump(base, open("/app/config/jarvis.json", "w"), ensure_ascii=False, indent=2)
|
||||
print("[entrypoint] merged persistent settings overrides")
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
"ollama_base_url": "${OLLAMA_BASE_URL}",
|
||||
"ollama_embed_model": "${OLLAMA_EMBED_MODEL}",
|
||||
"ollama_chat_model": "${OLLAMA_CHAT_MODEL}",
|
||||
"ollama_num_predict": "${OLLAMA_NUM_PREDICT}",
|
||||
"intent_judge_model": "${OLLAMA_INTENT_MODEL}",
|
||||
"tts_enabled": true,
|
||||
"tts_engine": "piper",
|
||||
"tts_engine": "${TTS_ENGINE}",
|
||||
"tts_piper_model_path": "${TTS_PIPER_MODEL_PATH}",
|
||||
"whisper_model": "${WHISPER_MODEL}",
|
||||
"whisper_backend": "faster-whisper",
|
||||
|
||||
@@ -18,6 +18,27 @@ cat > /etc/opt/chrome/policies/managed/jarvis.json <<'JSON'
|
||||
{ "CommandLineFlagSecurityWarningsEnabled": false }
|
||||
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
|
||||
# drive this on-screen Chrome (Google/YouTube/Naver), --disable-features=Translate
|
||||
# hides the translate popup. NO --test-type / --disable-blink-features.
|
||||
@@ -26,6 +47,7 @@ exec google-chrome \
|
||||
--no-default-browser-check \
|
||||
--disable-features=Translate,TranslateUI \
|
||||
--lang=ko-KR \
|
||||
--accept-lang=ko-KR,ko \
|
||||
--remote-debugging-port="${CDP_PORT:-9222}" \
|
||||
--remote-debugging-address="${CDP_BIND:-127.0.0.1}" \
|
||||
--user-data-dir="${CHROME_PROFILE_DIR:-/root/chrome-profile}" \
|
||||
|
||||
@@ -49,28 +49,8 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:melo-worker]
|
||||
; Warm MeloTTS Korean voice (speed 1.5) in its own py3.11 venv. The bridge's
|
||||
; synthesize() POSTs here; if this is down the bridge falls back to Piper.
|
||||
command=/app/docker/run-if-role.sh full,bot /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 brain's 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 if the host is offline).
|
||||
; HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE force pure-cache reads: the pinned old
|
||||
; transformers/huggingface_hub otherwise retry the network on every load and
|
||||
; error out instead of falling back to the (complete) baked cache.
|
||||
; MELO_DEVICE inherits from the container env (compose sets it; default cuda)
|
||||
; so the worker runs MeloTTS on the GPU. supervisord interpolates %(ENV_x)s
|
||||
; from its own environment, which is the container's.
|
||||
environment=MELO_LANGUAGE="KR",MELO_SPEED="1.5",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
|
||||
# (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:bridge]
|
||||
command=/app/docker/run-if-role.sh full,bot /opt/venv/bin/python -m bridge.server
|
||||
|
||||
@@ -13,19 +13,20 @@ Every distinct LLM call in Jarvis, what feeds it, what consumes it, and how it i
|
||||
- Redacted user query
|
||||
- Recent dialogue (last 5 minutes), including in-loop tool-call + tool-role messages from prior replies within the active conversation (tool carryover, `DialogueMemory.record_tool_turn` / `get_recent_turns_with_tools` in [src/jarvis/memory/conversation.py](src/jarvis/memory/conversation.py); per-prompt cap via `cfg.tool_carryover_max_turns` / `tool_carryover_per_entry_chars`; storage cap `_tool_turns_max_storage = 16`; cleared on `stop` signal AND on new-conversation entry; UNTRUSTED WEB EXTRACT fence markers preserved on truncation; both `content` and `tool_calls[*].function.arguments` scrubbed on write)
|
||||
- Unified system prompt from [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) + ASR note + tool-protocol guidance. Reply language is resolved by `reply_language_directive(lang, cfg.tts_engine)` where `lang = _resolve_output_language()` — the single source of truth that prefers the settings-web UI value (config JSON `output_language`) over the compose `OUTPUT_LANGUAGE` env, so changing the language in the settings page takes effect. An explicit lock wins (forces "reply only in `<language>`", also forbidding other scripts so small models stop leaking trailing CJK/Hanja); else a Piper/Chatterbox TTS forces English (English-only voices); else (multilingual TTS, no lock) the assistant replies in the user's own language. The directive is inserted near the FRONT of the guidance list so a small model gives it primacy, and the SAME resolved `lang` feeds `build_system_prompt()`, which rewrites the persona's "in the user's language" clause to the locked language so the persona cannot contradict the directive (previously the persona read the raw env while the directive read the config value, so a settings-UI change was honoured by one and ignored by the other). Gated in `_build_initial_system_message()` at [engine.py](src/jarvis/reply/engine.py).
|
||||
- **Operator instructions** (two sources, both framed "Additional instructions from the operator:" and appended near the end of the guidance list): the settings-UI `llm_instructions` config field, and every `*.md` file in `AGENTS_DIR` (default `/app/agents`, bind-mounted from `./agents`). The file-based set is read once per turn by `load_agent_instructions()` in [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) and concatenated in filename order, so dropping/editing a `.md` applies on the next reply with no rebuild/restart; fail-open to `""` when the folder is absent/empty/unreadable.
|
||||
- **Warm profile block** (query-agnostic User + Directives excerpt from the knowledge graph, composed by `build_warm_profile()` / `format_warm_profile_block()` in [src/jarvis/memory/graph_ops.py](src/jarvis/memory/graph_ops.py) at Step 3.5 of `reply()`; no LLM call, pure SQLite read; injected unconditionally so personalisation is the default; result cached in `DialogueMemory._hot_cache` under `DialogueMemory.WARM_PROFILE_CACHE_KEY` for the lifetime of the active conversation. Invalidated on `stop`, on new-conversation entry, AND on User/Directives graph mutations via the listener registered in [src/jarvis/daemon.py](src/jarvis/daemon.py) against `register_graph_mutation_listener` in [src/jarvis/memory/graph.py](src/jarvis/memory/graph.py); World-branch writes are ignored)
|
||||
- Digested memory enrichment (optional, see #4)
|
||||
- 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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -245,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.
|
||||
|
||||
|
||||
@@ -85,6 +85,12 @@ class Settings:
|
||||
llm_digest_timeout_sec: float
|
||||
llm_embedding_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
|
||||
active_profiles: list[str]
|
||||
@@ -394,6 +400,9 @@ def get_default_config() -> Dict[str, Any]:
|
||||
"llm_digest_timeout_sec": 8.0,
|
||||
"llm_embedding_timeout_sec": 60.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
|
||||
"active_profiles": ["developer", "business", "life"],
|
||||
@@ -608,7 +617,11 @@ def load_settings() -> Settings:
|
||||
active_profiles = _ensure_list(merged.get("active_profiles"))
|
||||
tts_enabled = bool(merged.get("tts_enabled", True))
|
||||
tts_engine = str(merged.get("tts_engine", "piper")).lower()
|
||||
if tts_engine not in ("piper", "chatterbox"):
|
||||
# "edge" (Microsoft Edge TTS) is the containerized bridge's Korean voice;
|
||||
# "melo" is the legacy warm-worker voice. Both are multilingual, so they must
|
||||
# be preserved here — coercing them to "piper" would mislabel the engine as
|
||||
# English-only in reply_language_directive().
|
||||
if tts_engine not in ("piper", "chatterbox", "edge", "melo"):
|
||||
tts_engine = "piper" # Default to piper if invalid value
|
||||
tts_voice_val = merged.get("tts_voice")
|
||||
tts_voice = None if tts_voice_val in (None, "", "null") else str(tts_voice_val)
|
||||
@@ -759,6 +772,10 @@ def load_settings() -> Settings:
|
||||
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_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(
|
||||
# Database & Storage
|
||||
@@ -774,6 +791,7 @@ def load_settings() -> Settings:
|
||||
llm_digest_timeout_sec=llm_digest_timeout_sec,
|
||||
llm_embedding_timeout_sec=llm_embedding_timeout_sec,
|
||||
llm_profile_select_timeout_sec=llm_profile_select_timeout_sec,
|
||||
ollama_num_predict=ollama_num_predict,
|
||||
|
||||
# Profiles & Behavior
|
||||
active_profiles=active_profiles,
|
||||
|
||||
@@ -9,7 +9,11 @@ import os
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from ..utils.redact import redact
|
||||
from ..system_prompt import build_system_prompt, reply_language_directive
|
||||
from ..system_prompt import (
|
||||
build_system_prompt,
|
||||
load_agent_instructions,
|
||||
reply_language_directive,
|
||||
)
|
||||
from ..tools.registry import run_tool_with_retries, generate_tools_description, generate_tools_json_schema, BUILTIN_TOOLS
|
||||
from ..tools.builtin.stop import STOP_SIGNAL
|
||||
from ..debug import debug_log
|
||||
@@ -1702,6 +1706,10 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
|
||||
# the directive used the config value made the two contradict each other.
|
||||
_output_language = _resolve_output_language()
|
||||
_persona_prompt = build_system_prompt(_assistant_name, _output_language)
|
||||
# File-based operator instructions: every *.md in AGENTS_DIR (default
|
||||
# /app/agents, bind-mounted from ./agents). Read once per turn so edits in
|
||||
# the folder apply on the next reply without a restart; fail-open to "".
|
||||
_agent_instructions = load_agent_instructions()
|
||||
|
||||
def _build_initial_system_message() -> str:
|
||||
guidance = [_persona_prompt.strip()]
|
||||
@@ -1810,6 +1818,12 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
|
||||
if _user_instructions:
|
||||
guidance.append("Additional instructions from the operator:\n" + _user_instructions)
|
||||
|
||||
# File-based operator instructions: the concatenated agents/*.md content
|
||||
# resolved once above. Same framing/placement as the settings-UI field
|
||||
# so both are treated as authoritative operator guidance.
|
||||
if _agent_instructions:
|
||||
guidance.append("Additional instructions from the operator:\n" + _agent_instructions)
|
||||
|
||||
# Recency reinforcement: repeat the language lock at the very END too.
|
||||
# In a ~5k-token prompt the front-placed rule gets "lost in the middle";
|
||||
# bigger models (qwen2.5:7b) otherwise leak Chinese/Cyrillic mid-reply.
|
||||
@@ -2219,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 ""
|
||||
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
|
||||
# if the model returns HTTP 400 (native tools API not supported).
|
||||
_dump_tools_schema = None if use_text_tools else tools_json_schema
|
||||
@@ -2228,7 +2252,7 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
|
||||
chat_model=cfg.ollama_chat_model,
|
||||
messages=messages,
|
||||
timeout_sec=float(getattr(cfg, 'llm_chat_timeout_sec', 45.0)),
|
||||
extra_options=None,
|
||||
extra_options=_chat_extra_options,
|
||||
tools=_dump_tools_schema,
|
||||
thinking=getattr(cfg, 'llm_thinking_enabled', False),
|
||||
)
|
||||
@@ -2259,7 +2283,7 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
|
||||
chat_model=cfg.ollama_chat_model,
|
||||
messages=messages,
|
||||
timeout_sec=float(getattr(cfg, 'llm_chat_timeout_sec', 45.0)),
|
||||
extra_options=None,
|
||||
extra_options=_chat_extra_options,
|
||||
tools=None,
|
||||
thinking=getattr(cfg, 'llm_thinking_enabled', False),
|
||||
)
|
||||
|
||||
@@ -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*), "
|
||||
|
||||
@@ -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_embed_timeout_sec` (vector search)
|
||||
- `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_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.
|
||||
|
||||
@@ -6,8 +6,51 @@ who renames the wake word (e.g. "Friday") gets a butler with the matching
|
||||
name rather than a persona hardcoded to "Jarvis".
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Default location of the operator's file-based instruction folder. In the
|
||||
# Docker deployment ./agents is bind-mounted here (see docker-compose.yml), so a
|
||||
# user can drop *.md files in without rebuilding. Overridable via AGENTS_DIR.
|
||||
_DEFAULT_AGENTS_DIR = "/app/agents"
|
||||
|
||||
|
||||
def load_agent_instructions(agents_dir: Optional[str] = None) -> str:
|
||||
"""Concatenate every ``*.md`` in the agents dir into one instruction block.
|
||||
|
||||
Files are read in filename order (so ``00-tone.md`` precedes ``10-rules.md``)
|
||||
and joined with blank lines. This lets the operator extend the main reply
|
||||
LLM's system prompt by dropping Markdown files into a folder, no code change
|
||||
or restart required — the caller reads this once per turn.
|
||||
|
||||
Resolution order for the directory: explicit ``agents_dir`` arg, then the
|
||||
``AGENTS_DIR`` env var, then ``/app/agents``.
|
||||
|
||||
Fail-open by design: a missing or empty directory, an unreadable file, or
|
||||
any unexpected error yields ``""`` so a misconfigured folder can never break
|
||||
a reply. Only regular ``*.md`` files are read; other files are ignored.
|
||||
"""
|
||||
directory = agents_dir or os.environ.get("AGENTS_DIR") or _DEFAULT_AGENTS_DIR
|
||||
try:
|
||||
base = Path(directory)
|
||||
if not base.is_dir():
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for path in sorted(base.glob("*.md"), key=lambda p: p.name):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
_SYSTEM_PROMPT_TEMPLATE: str = (
|
||||
"Persona: you are a British butler named {name} — polite, composed, quietly amused, and "
|
||||
"quietly enjoying yourself. Default voice is dry, witty, and lightly sarcastic: you notice "
|
||||
@@ -19,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 "
|
||||
@@ -36,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 "
|
||||
|
||||
@@ -30,8 +30,10 @@ class BrowseAndPlayTool(Tool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"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 "
|
||||
"something. Only available in screen-share mode."
|
||||
"and playing a result. Use when the user asks you to play or watch "
|
||||
"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
|
||||
@@ -42,7 +44,16 @@ class BrowseAndPlayTool(Tool):
|
||||
"query": {
|
||||
"type": "string",
|
||||
"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"],
|
||||
}
|
||||
@@ -55,18 +66,25 @@ class BrowseAndPlayTool(Tool):
|
||||
reply_text="화면 공유 모드(STREAM_BROWSER=true)에서만 영상을 재생할 수 있습니다.",
|
||||
)
|
||||
query = ""
|
||||
index = 1
|
||||
if args and isinstance(args, dict):
|
||||
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:
|
||||
return ToolExecutionResult(success=False, reply_text="재생할 내용을 알려주세요.")
|
||||
if not _NODE_SCRIPT.exists():
|
||||
return ToolExecutionResult(success=False, reply_text="브라우저 재생 도구를 찾을 수 없습니다.")
|
||||
|
||||
context.user_print(f"▶️ 화면에서 '{query}' 재생 중…")
|
||||
debug_log(f" ▶️ browseAndPlay '{query}'", "tools")
|
||||
context.user_print(f"▶️ 화면에서 '{query}' 재생 중… (#{index})")
|
||||
debug_log(f" ▶️ browseAndPlay '{query}' index={index}", "tools")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["node", str(_NODE_SCRIPT), query, "youtube"],
|
||||
["node", str(_NODE_SCRIPT), query, "youtube", str(index)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=40,
|
||||
|
||||
@@ -6,16 +6,24 @@ video, or clip.
|
||||
|
||||
### 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`).
|
||||
In voice-only mode (false) there is no screen to show, so it returns a short
|
||||
message and does nothing.
|
||||
- Drives the on-screen Chrome by subprocessing the Node CDP helper
|
||||
`bot/scripts/stream-test/browse-search.mjs <query> youtube`, which searches
|
||||
YouTube and plays the first result on display `:1`. The broadcast captures
|
||||
that display, so the playback is what viewers see.
|
||||
- Returns `success` with the played video's title, or a failure message if the
|
||||
helper/Chrome is unavailable. It does NOT make an LLM call.
|
||||
`bot/scripts/stream-test/browse-search.mjs <query> youtube <index>`, which
|
||||
searches YouTube and plays the chosen result on display `:1`. The broadcast
|
||||
captures that display, so the playback is what viewers see.
|
||||
- The helper clicks the `index`-th `a#video-title` in the results list. The
|
||||
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
|
||||
|
||||
|
||||
79
tests/test_browse_and_play_index.py
Normal file
79
tests/test_browse_and_play_index.py
Normal 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()
|
||||
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
|
||||
112
tests/test_ollama_num_predict.py
Normal file
112
tests/test_ollama_num_predict.py
Normal 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
|
||||
@@ -121,6 +121,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
|
||||
|
||||
@@ -7,6 +7,7 @@ hardcoded to Jarvis.
|
||||
|
||||
from jarvis.system_prompt import (
|
||||
build_system_prompt,
|
||||
load_agent_instructions,
|
||||
output_language_directive,
|
||||
reply_language_directive,
|
||||
ENGLISH_ONLY_DIRECTIVE,
|
||||
@@ -43,6 +44,22 @@ 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
|
||||
|
||||
|
||||
class TestOutputLanguageDirective:
|
||||
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE.
|
||||
@@ -108,3 +125,65 @@ class TestReplyLanguageDirective:
|
||||
def test_lock_wins_even_with_multilingual_tts(self):
|
||||
directive = reply_language_directive("Korean", "melo")
|
||||
assert directive is not None and "Korean" in directive
|
||||
|
||||
def test_edge_is_multilingual(self):
|
||||
# Edge TTS (the default Korean voice) is not English-only: no lock → the
|
||||
# user's own language, and a lock is honoured (not forced to English).
|
||||
assert reply_language_directive(None, "edge") is None
|
||||
directive = reply_language_directive("Korean", "edge")
|
||||
assert directive is not None and "Korean" in directive
|
||||
assert directive != ENGLISH_ONLY_DIRECTIVE
|
||||
|
||||
|
||||
class TestLoadAgentInstructions:
|
||||
"""Operator can extend the reply LLM's system prompt by dropping *.md files
|
||||
into an agents/ folder. The loader concatenates them in filename order and
|
||||
fails open so a missing/empty/broken folder never breaks a reply."""
|
||||
|
||||
def test_missing_dir_returns_empty(self, tmp_path):
|
||||
assert load_agent_instructions(str(tmp_path / "does-not-exist")) == ""
|
||||
|
||||
def test_empty_dir_returns_empty(self, tmp_path):
|
||||
assert load_agent_instructions(str(tmp_path)) == ""
|
||||
|
||||
def test_reads_and_concatenates_single_file(self, tmp_path):
|
||||
(tmp_path / "rules.md").write_text("Always be brief.", encoding="utf-8")
|
||||
assert load_agent_instructions(str(tmp_path)) == "Always be brief."
|
||||
|
||||
def test_files_are_ordered_by_filename(self, tmp_path):
|
||||
# Filename prefixes let the operator control ordering.
|
||||
(tmp_path / "10-second.md").write_text("SECOND", encoding="utf-8")
|
||||
(tmp_path / "00-first.md").write_text("FIRST", encoding="utf-8")
|
||||
result = load_agent_instructions(str(tmp_path))
|
||||
assert result.index("FIRST") < result.index("SECOND")
|
||||
|
||||
def test_only_md_files_are_read(self, tmp_path):
|
||||
(tmp_path / "note.txt").write_text("IGNORE ME", encoding="utf-8")
|
||||
(tmp_path / "use.md").write_text("USE ME", encoding="utf-8")
|
||||
result = load_agent_instructions(str(tmp_path))
|
||||
assert "USE ME" in result
|
||||
assert "IGNORE ME" not in result
|
||||
|
||||
def test_blank_files_are_skipped(self, tmp_path):
|
||||
(tmp_path / "blank.md").write_text(" \n ", encoding="utf-8")
|
||||
(tmp_path / "real.md").write_text("Real instruction.", encoding="utf-8")
|
||||
assert load_agent_instructions(str(tmp_path)) == "Real instruction."
|
||||
|
||||
def test_env_var_is_used_when_no_arg(self, tmp_path, monkeypatch):
|
||||
(tmp_path / "a.md").write_text("FROM ENV", encoding="utf-8")
|
||||
monkeypatch.setenv("AGENTS_DIR", str(tmp_path))
|
||||
assert load_agent_instructions() == "FROM ENV"
|
||||
|
||||
def test_explicit_arg_overrides_env(self, tmp_path, monkeypatch):
|
||||
(tmp_path / "env.md").write_text("ENV", encoding="utf-8")
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
(other / "arg.md").write_text("ARG", encoding="utf-8")
|
||||
monkeypatch.setenv("AGENTS_DIR", str(tmp_path))
|
||||
assert load_agent_instructions(str(other)) == "ARG"
|
||||
|
||||
def test_a_file_path_instead_of_dir_returns_empty(self, tmp_path):
|
||||
f = tmp_path / "file.md"
|
||||
f.write_text("x", encoding="utf-8")
|
||||
# Pointed at a file, not a directory → fail-open.
|
||||
assert load_agent_instructions(str(f)) == ""
|
||||
|
||||
35
tests/test_tts_engine_config.py
Normal file
35
tests/test_tts_engine_config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""The container's TTS engine must be env-driven, not hardcoded.
|
||||
|
||||
Regression for a bug where docker/jarvis-config.template.json hardcoded
|
||||
`"tts_engine": "piper"`. The bridge reads the rendered /app/config/jarvis.json
|
||||
*before* the environment, so a hardcoded "piper" overrode `TTS_ENGINE=melo` in
|
||||
.env and the bot read Korean text with the English Piper voice ("foreign
|
||||
accent"). The template must carry `${TTS_ENGINE}` so envsubst (entrypoint.sh)
|
||||
renders whatever engine the deployment configured.
|
||||
"""
|
||||
|
||||
import json
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parent.parent / "docker" / "jarvis-config.template.json"
|
||||
|
||||
|
||||
def _render(**env) -> dict:
|
||||
"""Mimic entrypoint.sh `envsubst < template`: substitute env vars, leaving
|
||||
any unset ones as literal text (valid JSON string values)."""
|
||||
raw = TEMPLATE.read_text(encoding="utf-8")
|
||||
return json.loads(string.Template(raw).safe_substitute(**env))
|
||||
|
||||
|
||||
def test_template_does_not_hardcode_an_engine():
|
||||
raw = TEMPLATE.read_text(encoding="utf-8")
|
||||
assert '"tts_engine": "${TTS_ENGINE}"' in raw
|
||||
assert '"tts_engine": "piper"' not in raw
|
||||
assert '"tts_engine": "melo"' not in raw
|
||||
|
||||
|
||||
def test_rendered_engine_follows_env():
|
||||
assert _render(TTS_ENGINE="melo")["tts_engine"] == "melo"
|
||||
assert _render(TTS_ENGINE="piper")["tts_engine"] == "piper"
|
||||
assert _render(TTS_ENGINE="xtts")["tts_engine"] == "xtts"
|
||||
Reference in New Issue
Block a user