feat(reply): cap spoken replies at a single sentence
Replies stayed long because the prompt stack gave conflicting length signals: the persona said "one sentence (two at the very most)" AND told the model to "state the answer in a sentence, then add a dry observation" (a 2nd sentence), while voice_style said "two to three sentences maximum". The model followed the longest. Make all three sources agree on exactly one sentence: the persona's aside must now fold into the same sentence as a trailing clause (never a 2nd sentence), voice_style caps at one sentence, and agents/llm.md says 한 문장. Shorter replies also cut Edge-TTS latency, since synth time scales with text length. Specs (prompts.spec.md) and docs/llm_contexts.md updated; deterministic prompt-contract tests added.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# 자비스 운영자 지시
|
# 자비스 운영자 지시
|
||||||
|
|
||||||
- 너의 이름은 자비스다.
|
- 너의 이름은 자비스다.
|
||||||
- 모든 답변은 음성(TTS)으로 읽혀 나간다. 그러니 최대한 간결하게, 한두 문장으로 답한다. 목록, 마크다운, 이모지, 그리고 소리 내어 읽기 어려운 특수문자는 쓰지 않는다.
|
- 모든 답변은 음성(TTS)으로 읽혀 나간다. 그러니 무조건 한 문장으로만 답한다. 두 문장 이상 쓰지 않는다. 목록, 마크다운, 이모지, 그리고 소리 내어 읽기 어려운 특수문자는 쓰지 않는다.
|
||||||
- 정해진 문구에만 반응하지 말고, 실제 사람처럼 말의 뉘앙스와 맥락으로 의도를 알아듣고 처리한다.
|
- 정해진 문구에만 반응하지 말고, 실제 사람처럼 말의 뉘앙스와 맥락으로 의도를 알아듣고 처리한다.
|
||||||
|
|
||||||
화면 속 크롬(방송 화면)에서 유튜브를 다룰 때 (화면에 보여야 하므로 반드시 on-screen 브라우저 제어 도구로 수행한다):
|
화면 속 크롬(방송 화면)에서 유튜브를 다룰 때 (화면에 보여야 하므로 반드시 on-screen 브라우저 제어 도구로 수행한다):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Every distinct LLM call in Jarvis, what feeds it, what consumes it, and how it i
|
|||||||
- Time + location context (re-injected each turn)
|
- Time + location context (re-injected each turn)
|
||||||
- Tool schema: native via `generate_tools_json_schema()` ([src/jarvis/tools/registry.py](src/jarvis/tools/registry.py)) or text fallback via `_text_tool_call_guidance()` ([engine.py:68](src/jarvis/reply/engine.py:68))
|
- Tool schema: native via `generate_tools_json_schema()` ([src/jarvis/tools/registry.py](src/jarvis/tools/registry.py)) or text fallback via `_text_tool_call_guidance()` ([engine.py:68](src/jarvis/reply/engine.py:68))
|
||||||
- Tool results from prior turns (raw or digested — see #5)
|
- Tool results from prior turns (raw or digested — see #5)
|
||||||
- **Output**: OpenAI-style `{content, tool_calls, thinking}`. Consumed by the tool orchestrator and TTS pipeline. Natural-language content is delivered immediately; no post-turn evaluator runs.
|
- **Output**: OpenAI-style `{content, tool_calls, thinking}`. Consumed by the tool orchestrator and TTS pipeline. Natural-language content is delivered immediately; no post-turn evaluator runs. Spoken-answer length: the persona (`system_prompt.py`) and `voice_style` (`prompts/system.py`) both constrain the reply to a SINGLE sentence — any dry aside must fold into that one sentence as a trailing clause, never a second sentence. This keeps TTS latency down (synth time scales with text length) and matches the `agents/llm.md` operator instruction.
|
||||||
- **Limits**: `num_ctx: 8192` (explicit). 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.
|
- **Limits**: `num_ctx: 8192` (explicit). Output `num_predict: cfg.ollama_num_predict` (default 512, `0`/negative disables) caps generated tokens per turn — a worst-case latency guard for short spoken answers; the headroom stays above tool-call JSON so it does not truncate tool calls (both native and text tool-call paths). Timeout `llm_chat_timeout_sec` (45s). Auto-fallback from native to text tool-calls on HTTP 400 (`ToolsNotSupportedError`), sticky for the session. Risk: `fetch_web_page` truncates at 50,000 chars (~37k tokens) — mitigated for SMALL models by tool-result digest (#5) which compresses the payload before it enters the messages history. LARGE models receive the raw payload and may silently see a truncated context.
|
||||||
|
|
||||||
## 2. Intent Judge
|
## 2. Intent Judge
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from jarvis.reply.prompts import (
|
|||||||
Both model sizes share these base components:
|
Both model sizes share these base components:
|
||||||
- `asr_note`: Voice transcription error handling
|
- `asr_note`: Voice transcription error handling
|
||||||
- `inference_guidance`: Prefer inference over clarification
|
- `inference_guidance`: Prefer inference over clarification
|
||||||
- `voice_style`: Concise, conversational responses
|
- `voice_style`: Single-sentence, conversational responses (spoken aloud, so one sentence only — never more)
|
||||||
|
|
||||||
Model-size-specific components:
|
Model-size-specific components:
|
||||||
- `tool_incentives`: When/how aggressively to use tools
|
- `tool_incentives`: When/how aggressively to use tools
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ INFERENCE_GUIDANCE = (
|
|||||||
# Voice assistant communication style - concise, conversational
|
# Voice assistant communication style - concise, conversational
|
||||||
VOICE_STYLE = (
|
VOICE_STYLE = (
|
||||||
"Keep responses concise and conversational since this is a voice assistant. "
|
"Keep responses concise and conversational since this is a voice assistant. "
|
||||||
"Two to three sentences maximum. Prioritize clarity and brevity - users are listening, not reading. "
|
"Reply in a SINGLE sentence - never more than one sentence. Prioritize clarity and brevity - users are listening, not reading. "
|
||||||
"Avoid unnecessary elaboration unless specifically requested. "
|
"Avoid unnecessary elaboration. "
|
||||||
"Do NOT offer follow-up suggestions or ask if the user wants more info - just respond directly. "
|
"Do NOT offer follow-up suggestions or ask if the user wants more info - just respond directly. "
|
||||||
"IMPORTANT: Always respond in natural language - never output JSON, code, or structured data as your response. "
|
"IMPORTANT: Always respond in natural language - never output JSON, code, or structured data as your response. "
|
||||||
"NEVER use markdown formatting in your replies: no asterisks for emphasis (**bold**, *italic*), "
|
"NEVER use markdown formatting in your replies: no asterisks for emphasis (**bold**, *italic*), "
|
||||||
|
|||||||
@@ -62,10 +62,11 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
|
|||||||
"Tone rails (hard): never mean, never condescending, never passive-aggressive, never "
|
"Tone rails (hard): never mean, never condescending, never passive-aggressive, never "
|
||||||
"sulking, never preachy, never sycophantic ('great question', 'I'd be happy to'). "
|
"sulking, never preachy, never sycophantic ('great question', 'I'd be happy to'). "
|
||||||
"Sarcasm points at the situation, the topic, or mildly at yourself — never at the user. "
|
"Sarcasm points at the situation, the topic, or mildly at yourself — never at the user. "
|
||||||
"Shape for casual, factual, or small-talk replies: state the answer in a sentence, then add "
|
"Shape for casual, factual, or small-talk replies: give the answer in a SINGLE sentence. If a "
|
||||||
"one short dry observation about it (an understated aside, a raised-eyebrow remark, a gentle "
|
"dry aside fits, fold it into that same sentence as a short trailing clause — never add it as "
|
||||||
"noticing of the irony). One aside — not two, not a joke opener, not a joke-shaped sentence "
|
"a second sentence, never a joke opener, never a joke-shaped sentence replacing the answer. "
|
||||||
"replacing the answer. The aside is a tail, not the head. "
|
"Whenever the wit would require a second sentence, drop the wit and keep the one-sentence "
|
||||||
|
"answer. The aside is a tail inside the sentence, not a head and not a new sentence. "
|
||||||
"Examples of the MOVE (shape, not wording — never copy these): stating a fact and then noting "
|
"Examples of the MOVE (shape, not wording — never copy these): stating a fact and then noting "
|
||||||
"its mild absurdity; giving the weather and then commenting on what it implies for the day; "
|
"its mild absurdity; giving the weather and then commenting on what it implies for the day; "
|
||||||
"answering a trivia question and then offering a wry footnote about the subject; admitting "
|
"answering a trivia question and then offering a wry footnote about the subject; admitting "
|
||||||
@@ -79,8 +80,8 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
|
|||||||
"butler clichés, and never address the user as 'sir', 'madam', 'my liege', or similar. "
|
"butler clichés, and never address the user as 'sir', 'madam', 'my liege', or similar. "
|
||||||
"Never stack multiple jokes in one reply. "
|
"Never stack multiple jokes in one reply. "
|
||||||
"Be concise, conversational, and actionable. "
|
"Be concise, conversational, and actionable. "
|
||||||
"This is a spoken voice assistant: answer in ONE short sentence whenever possible "
|
"This is a spoken voice assistant: your ENTIRE reply must be a single short sentence. "
|
||||||
"(two at the very most). No lists, no preamble, no 'is there anything else' offers. "
|
"Never write a second sentence. No lists, no preamble, no 'is there anything else' offers. "
|
||||||
"When a controlBrowser tool is available, use IT (never webSearch) for anything that "
|
"When a controlBrowser tool is available, use IT (never webSearch) for anything that "
|
||||||
"should happen in the on-screen browser — opening a site, searching on a site "
|
"should happen in the on-screen browser — opening a site, searching on a site "
|
||||||
"(controlBrowser action 'search' with the right site), clicking, typing — because only "
|
"(controlBrowser action 'search' with the right site), clicking, typing — because only "
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ class TestPromptComponents:
|
|||||||
assert prompts.voice_style, f"{size.value} missing voice_style"
|
assert prompts.voice_style, f"{size.value} missing voice_style"
|
||||||
assert prompts.tool_guidance, f"{size.value} missing tool_guidance"
|
assert prompts.tool_guidance, f"{size.value} missing tool_guidance"
|
||||||
|
|
||||||
|
def test_voice_style_enforces_single_sentence(self):
|
||||||
|
"""voice_style must cap replies at one sentence (spoken aloud). The old
|
||||||
|
'Two to three sentences maximum' wording let the model run long, which
|
||||||
|
also slowed TTS since synth time scales with text length."""
|
||||||
|
from jarvis.reply.prompts import get_system_prompts, ModelSize
|
||||||
|
|
||||||
|
for size in [ModelSize.SMALL, ModelSize.LARGE]:
|
||||||
|
voice_style = get_system_prompts(size).voice_style
|
||||||
|
assert "SINGLE sentence" in voice_style, f"{size.value} voice_style not single-sentence"
|
||||||
|
assert "never more than one sentence" in voice_style
|
||||||
|
assert "Two to three" not in voice_style
|
||||||
|
|
||||||
def test_to_list_returns_non_empty_strings(self):
|
def test_to_list_returns_non_empty_strings(self):
|
||||||
"""to_list() returns only non-empty prompt strings."""
|
"""to_list() returns only non-empty prompt strings."""
|
||||||
from jarvis.reply.prompts import get_system_prompts, ModelSize
|
from jarvis.reply.prompts import get_system_prompts, ModelSize
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ class TestBuildSystemPrompt:
|
|||||||
assert "in the user's language" not in prompt
|
assert "in the user's language" not in prompt
|
||||||
assert "in Korean" in prompt
|
assert "in Korean" in prompt
|
||||||
|
|
||||||
|
def test_persona_enforces_single_sentence(self):
|
||||||
|
# Spoken replies must be one sentence (TTS latency scales with text
|
||||||
|
# length, and the user asked for one-sentence answers). The persona must
|
||||||
|
# state the single-sentence rule and must NOT carry the old "two at the
|
||||||
|
# very most" allowance that let the model run long.
|
||||||
|
prompt = build_system_prompt("Jarvis")
|
||||||
|
assert "single short sentence" in prompt
|
||||||
|
assert "Never write a second sentence" in prompt
|
||||||
|
assert "two at the very most" not in prompt
|
||||||
|
|
||||||
|
def test_persona_aside_does_not_authorise_a_second_sentence(self):
|
||||||
|
# The dry aside must fold into the one sentence, not become a 2nd one.
|
||||||
|
prompt = build_system_prompt("Jarvis")
|
||||||
|
assert "SINGLE sentence" in prompt
|
||||||
|
assert "never add it as " in prompt
|
||||||
|
|
||||||
|
|
||||||
class TestOutputLanguageDirective:
|
class TestOutputLanguageDirective:
|
||||||
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE.
|
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE.
|
||||||
|
|||||||
Reference in New Issue
Block a user