feat(simulation): voice pipeline integration — Factual bypass, observer wiring, tell iteration (#650 #652 #651)
ContentType::Factual bypasses LLM for fact-bearing lines (numbers, denials). Observer enrichment systems rewrite dialogue/conversation text with voiced versions before snapshot assembly. Friendly and RoutineDeviation tells get concrete surface-pattern examples. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -220,6 +220,7 @@ impl Serialize for ContentType {
|
|||||||
match self {
|
match self {
|
||||||
ContentType::Dialogue => serializer.serialize_u8(0),
|
ContentType::Dialogue => serializer.serialize_u8(0),
|
||||||
ContentType::Behavior => serializer.serialize_u8(1),
|
ContentType::Behavior => serializer.serialize_u8(1),
|
||||||
|
ContentType::Factual => serializer.serialize_u8(2),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +231,7 @@ impl<'de> Deserialize<'de> for ContentType {
|
|||||||
match v {
|
match v {
|
||||||
0 => Ok(ContentType::Dialogue),
|
0 => Ok(ContentType::Dialogue),
|
||||||
1 => Ok(ContentType::Behavior),
|
1 => Ok(ContentType::Behavior),
|
||||||
|
2 => Ok(ContentType::Factual),
|
||||||
_ => Err(serde::de::Error::custom("invalid ContentType")),
|
_ => Err(serde::de::Error::custom("invalid ContentType")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Observer integration for the voice pipeline (D-138, Phase 3).
|
||||||
|
//!
|
||||||
|
//! Two enrichment systems run before `compute_observer_snapshot` and rewrite
|
||||||
|
//! NPC text in the dialogue and conversation buffers with voiced variants
|
||||||
|
//! looked up from the cache. Cache miss → base text (never blocks).
|
||||||
|
//!
|
||||||
|
//! ## System ordering
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! process_talk_interaction ─┐
|
||||||
|
//! run_npc_conversations ─┤─► voice_enrich_dialogue_response ─┐
|
||||||
|
//! └─► voice_enrich_conversation_events ─► compute_observer_snapshot
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Content index derivation
|
||||||
|
//!
|
||||||
|
//! The voice cache key uses a `content_index: u16` to distinguish individual
|
||||||
|
//! lines for the same NPC. For dialogue lines the index is derived via
|
||||||
|
//! FNV-1a hash of `line_id`, matching the derivation used when requests
|
||||||
|
//! are submitted to the worker queue for pre-voicing.
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
use crate::knowledge::{EntityRegistry, StableId};
|
||||||
|
use crate::npc::tell_state::DerivedTellState;
|
||||||
|
use crate::npc::{Npc, NpcVoiceProfile};
|
||||||
|
use crate::simulation::conversation::ConversationEventBuffer;
|
||||||
|
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||||
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||||
|
use crate::simulation::zone::ZoneMap;
|
||||||
|
use crate::voice::lookup::{voiced_behavior, VoiceCacheResource};
|
||||||
|
use crate::voice::prompt_builder::ContentType;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content index derivation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Derive a deterministic content_index (u16) from a string line identifier.
|
||||||
|
///
|
||||||
|
/// Uses FNV-1a (64-bit) truncated to u16 — same algorithm used by the worker
|
||||||
|
/// queue when submitting pre-voicing requests, ensuring cache key consistency.
|
||||||
|
pub fn content_index_from_line_id(line_id: &str) -> u16 {
|
||||||
|
let mut hash: u64 = 0xcbf29ce484222325;
|
||||||
|
for b in line_id.bytes() {
|
||||||
|
hash ^= b as u64;
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
hash as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Player zone resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn player_zone_id(
|
||||||
|
player_query: &Query<&TilePosition, With<PlayerCharacter>>,
|
||||||
|
zone_map: Option<&ZoneMap>,
|
||||||
|
) -> u32 {
|
||||||
|
let Ok(pos) = player_query.single() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
zone_map
|
||||||
|
.and_then(|zm| zm.zone_at(pos.x, pos.y, pos.z))
|
||||||
|
.map(|id| id as u32)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Enrich the dialogue response buffer with a voiced line from the cache.
|
||||||
|
///
|
||||||
|
/// Must run after `process_talk_interaction` and before
|
||||||
|
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
|
||||||
|
///
|
||||||
|
/// The system looks up the NPC's voice culture and current tell state, then
|
||||||
|
/// calls `voiced_behavior()` to retrieve a cached voiced line. On cache miss
|
||||||
|
/// the buffer text is unchanged (base text serves as fallback).
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn voice_enrich_dialogue_response(
|
||||||
|
voice_cache: Option<Res<VoiceCacheResource>>,
|
||||||
|
registry: Res<EntityRegistry>,
|
||||||
|
zone_map: Option<Res<ZoneMap>>,
|
||||||
|
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||||
|
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
|
||||||
|
mut dialogue_buffer: Query<&mut DialogueResponseBuffer, With<PlayerCharacter>>,
|
||||||
|
) {
|
||||||
|
let Some(voice_cache) = voice_cache else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut buffer) = dialogue_buffer.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref mut response) = buffer.response else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
|
||||||
|
|
||||||
|
let speaker_entity = registry.to_entity(&StableId(response.speaker_entity_id));
|
||||||
|
let Some(entity) = speaker_entity else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tell_state = tell_state_opt.and_then(|t| t.category);
|
||||||
|
let content_index = content_index_from_line_id(&response.line_id);
|
||||||
|
|
||||||
|
let voiced = voiced_behavior(
|
||||||
|
&voice_cache.cache,
|
||||||
|
zone_id,
|
||||||
|
&voice_profile.culture_id,
|
||||||
|
response.speaker_entity_id,
|
||||||
|
ContentType::Dialogue,
|
||||||
|
content_index,
|
||||||
|
tell_state,
|
||||||
|
&response.text,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.text = voiced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrich the conversation event buffer with voiced lines from the cache.
|
||||||
|
///
|
||||||
|
/// Must run after `run_npc_conversations` and before
|
||||||
|
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
|
||||||
|
///
|
||||||
|
/// Each event in the buffer is processed: the pre-occlusion base text is
|
||||||
|
/// not available at this point (occlusion has already been applied), so the
|
||||||
|
/// `occluded_line` is treated as the base text for the voice lookup.
|
||||||
|
/// This means the voice register wraps the already-occluded line.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn voice_enrich_conversation_events(
|
||||||
|
voice_cache: Option<Res<VoiceCacheResource>>,
|
||||||
|
registry: Res<EntityRegistry>,
|
||||||
|
zone_map: Option<Res<ZoneMap>>,
|
||||||
|
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||||
|
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
|
||||||
|
mut conversation_buffer: Query<&mut ConversationEventBuffer, With<PlayerCharacter>>,
|
||||||
|
) {
|
||||||
|
let Some(voice_cache) = voice_cache else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut buffer) = conversation_buffer.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if buffer.events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
|
||||||
|
|
||||||
|
for event in &mut buffer.events {
|
||||||
|
let speaker_entity = registry.to_entity(&StableId(event.speaker_id));
|
||||||
|
let Some(entity) = speaker_entity else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tell_state = tell_state_opt.and_then(|t| t.category);
|
||||||
|
// Use speaker_id as the content derivation input — conversation lines
|
||||||
|
// don't have a stable line_id, so use a hash of the line text itself.
|
||||||
|
let content_index = content_index_from_line_id(&event.occluded_line);
|
||||||
|
|
||||||
|
let voiced = voiced_behavior(
|
||||||
|
&voice_cache.cache,
|
||||||
|
zone_id,
|
||||||
|
&voice_profile.culture_id,
|
||||||
|
event.speaker_id,
|
||||||
|
ContentType::Dialogue,
|
||||||
|
content_index,
|
||||||
|
tell_state,
|
||||||
|
&event.occluded_line,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
event.occluded_line = voiced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_index_is_deterministic() {
|
||||||
|
let a = content_index_from_line_id("dock_d_001");
|
||||||
|
let b = content_index_from_line_id("dock_d_001");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_index_differs_for_different_ids() {
|
||||||
|
let a = content_index_from_line_id("dock_d_001");
|
||||||
|
let b = content_index_from_line_id("dock_d_002");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_index_empty_string() {
|
||||||
|
// Should not panic
|
||||||
|
let _ = content_index_from_line_id("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
//! Voice cache lookup for behavior text (D-138, Phase 3 stub).
|
//! Voice cache lookup for behavior text (D-138, Phase 3).
|
||||||
//!
|
//!
|
||||||
//! Provides the integration point between the voice cache and any system
|
//! Provides the integration point between the voice cache and any system
|
||||||
//! that serves NPC text to the client. Not yet wired into a runtime system —
|
//! that serves NPC text to the client. Wired into the observer pipeline
|
||||||
//! `observable_behaviors` on `NpcBlueprint` is generator output only.
|
//! via `voice::integration` enrichment systems.
|
||||||
//!
|
|
||||||
//! Wire `voiced_behavior()` into the behavior-serving path once it exists.
|
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::npc::tell_state::TellCategory;
|
use crate::npc::tell_state::TellCategory;
|
||||||
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
||||||
use crate::voice::prompt_builder::ContentType;
|
use crate::voice::prompt_builder::ContentType;
|
||||||
|
|
||||||
|
/// ECS resource wrapping the voice cache store.
|
||||||
|
///
|
||||||
|
/// Optional — systems that use it gracefully degrade to base text when absent.
|
||||||
|
/// Populated at startup by the voice subsystem when the pipeline is enabled.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct VoiceCacheResource {
|
||||||
|
pub cache: Arc<Mutex<VoiceCacheStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a voiced behavior from cache, falling back to base text.
|
/// Look up a voiced behavior from cache, falling back to base text.
|
||||||
///
|
///
|
||||||
/// Tell behaviors (from `NpcBlueprint.tell_behaviors`) are NEVER re-voiced —
|
/// Tell behaviors (from `NpcBlueprint.tell_behaviors`) are NEVER re-voiced —
|
||||||
@@ -33,6 +41,11 @@ pub fn voiced_behavior(
|
|||||||
return base_text.to_string();
|
return base_text.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Factual content is always base text — no LLM, no cache lookup.
|
||||||
|
if content_type == ContentType::Factual {
|
||||||
|
return base_text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
let key = CacheKey {
|
let key = CacheKey {
|
||||||
culture_id: culture_id.to_string(),
|
culture_id: culture_id.to_string(),
|
||||||
npc_stable_id,
|
npc_stable_id,
|
||||||
@@ -99,6 +112,36 @@ mod tests {
|
|||||||
assert_eq!(result, "Base line.");
|
assert_eq!(result, "Base line.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factual_content_always_passthrough() {
|
||||||
|
let cache = test_cache();
|
||||||
|
// Store a voiced version under the Factual key — it must never be returned.
|
||||||
|
let key = CacheKey {
|
||||||
|
culture_id: "krenn".into(),
|
||||||
|
npc_stable_id: 42,
|
||||||
|
content_type: ContentType::Factual,
|
||||||
|
content_index: 0,
|
||||||
|
tell_state: None,
|
||||||
|
};
|
||||||
|
cache
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.store(100, key, "Voiced factual (should be ignored).".into());
|
||||||
|
|
||||||
|
let result = voiced_behavior(
|
||||||
|
&cache,
|
||||||
|
100,
|
||||||
|
"krenn",
|
||||||
|
42,
|
||||||
|
ContentType::Factual,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
"14 crates in bay seven.",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(result, "14 crates in bay seven.");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tell_behavior_always_passthrough() {
|
fn tell_behavior_always_passthrough() {
|
||||||
let cache = test_cache();
|
let cache = test_cache();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
|
pub mod integration;
|
||||||
pub mod lookup;
|
pub mod lookup;
|
||||||
pub mod prompt_builder;
|
pub mod prompt_builder;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ pub enum ContentType {
|
|||||||
Dialogue,
|
Dialogue,
|
||||||
/// Observable behavior description — re-voiced with "Describe".
|
/// Observable behavior description — re-voiced with "Describe".
|
||||||
Behavior,
|
Behavior,
|
||||||
|
/// Fact-bearing line — numbers, causal chains, denials.
|
||||||
|
///
|
||||||
|
/// Factual lines bypass the LLM entirely and are served as base text.
|
||||||
|
/// Spike 2 showed 2B models corrupt quantitative content ("14 crates in
|
||||||
|
/// bay seven" → "fourteen crates are missing") and invert denials.
|
||||||
|
/// The simulation is the truth layer — the LLM only handles register.
|
||||||
|
Factual,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of prompt building, including which injections fired.
|
/// Result of prompt building, including which injections fired.
|
||||||
@@ -57,6 +64,15 @@ OUTPUT CONSTRAINTS:\n\
|
|||||||
/// "make sentences shorter" instruction causes destructive compression that
|
/// "make sentences shorter" instruction causes destructive compression that
|
||||||
/// strips facts. Long-Angry instead preserves the full claim and focuses
|
/// strips facts. Long-Angry instead preserves the full claim and focuses
|
||||||
/// intensity on one sentence.
|
/// intensity on one sentence.
|
||||||
|
///
|
||||||
|
/// ## Iteration history for Friendly and RoutineDeviation
|
||||||
|
///
|
||||||
|
/// Spike 2 showed these two tells produce output indistinguishable from neutral
|
||||||
|
/// on Gemma 2B. Tracked against a 3-iteration budget (#651):
|
||||||
|
///
|
||||||
|
/// - **Iteration 1** (Sprint 26): Replaced abstract instructions with concrete
|
||||||
|
/// surface-pattern examples. Friendly: closing aside pattern. RoutineDeviation:
|
||||||
|
/// self-correction/echo pattern. Both more imitable for a 2B model.
|
||||||
fn tell_injector(category: TellCategory) -> &'static str {
|
fn tell_injector(category: TellCategory) -> &'static str {
|
||||||
match category {
|
match category {
|
||||||
TellCategory::Nervous => {
|
TellCategory::Nervous => {
|
||||||
@@ -70,9 +86,12 @@ fn tell_injector(category: TellCategory) -> &'static str {
|
|||||||
Do not say they seem angry."
|
Do not say they seem angry."
|
||||||
}
|
}
|
||||||
TellCategory::Friendly => {
|
TellCategory::Friendly => {
|
||||||
"TONE: Add one small extra detail or aside that wasn't strictly necessary. \
|
// Iteration 1: replaced abstract "add detail" with a concrete closing-aside
|
||||||
Example: \"Inspection's tomorrow — should be fine, though.\" \
|
// pattern. 2B models need a recognisable syntactic target, not a description
|
||||||
Do not add compliments or forced warmth."
|
// of intent. "actually" / "so that's something" are learnable short tokens.
|
||||||
|
"TONE: End with a brief unprompted aside — a phrase the person didn't need to say. \
|
||||||
|
Example: \"Parts are in, so that's something.\" or \"Shift's been quiet, actually.\" \
|
||||||
|
Do not use praise or warm adjectives. One short beat after the main point."
|
||||||
}
|
}
|
||||||
TellCategory::Guarded => {
|
TellCategory::Guarded => {
|
||||||
"TONE: Use formal, precise words. Answer exactly what was asked, nothing extra. \
|
"TONE: Use formal, precise words. Answer exactly what was asked, nothing extra. \
|
||||||
@@ -80,9 +99,12 @@ fn tell_injector(category: TellCategory) -> &'static str {
|
|||||||
Do not say they seem guarded."
|
Do not say they seem guarded."
|
||||||
}
|
}
|
||||||
TellCategory::RoutineDeviation => {
|
TellCategory::RoutineDeviation => {
|
||||||
"TONE: Start the sentence on topic, then add a brief unfinished thought about something else. \
|
// Iteration 1: replaced the two-step interrupted-thought pattern (too complex
|
||||||
Example: \"Pressure's fine. I was going to — anyway, it's logged.\" \
|
// for 2B) with a simpler self-correction / word-echo pattern. The model only
|
||||||
Do not explain what they were thinking about."
|
// needs to repeat a key word or phrase — one concrete surface action.
|
||||||
|
"TONE: Repeat a key word or phrase, as if catching mid-thought. \
|
||||||
|
Example: \"Logged it. Got it logged, yeah.\" or \"Pressure's — pressure's holding.\" \
|
||||||
|
Do not explain. Just the echo."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +291,11 @@ pub fn build_prompt(
|
|||||||
"Describe the following action as a third-person observer. \
|
"Describe the following action as a third-person observer. \
|
||||||
Preserve all individual actions in sequence. Do not extract conclusions"
|
Preserve all individual actions in sequence. Do not extract conclusions"
|
||||||
}
|
}
|
||||||
|
ContentType::Factual => {
|
||||||
|
// Factual lines must be intercepted before reaching build_prompt.
|
||||||
|
// If this branch is hit, the caller has a bug.
|
||||||
|
unreachable!("ContentType::Factual must not reach build_prompt — bypass at worker")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
parts.push(String::new());
|
parts.push(String::new());
|
||||||
parts.push(format!("TASK: {}.", task_verb));
|
parts.push(format!("TASK: {}.", task_verb));
|
||||||
@@ -370,6 +397,7 @@ mod tests {
|
|||||||
TellCategory::Friendly,
|
TellCategory::Friendly,
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
|
behavior_modifiers: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +427,7 @@ mod tests {
|
|||||||
voice_persona: None,
|
voice_persona: None,
|
||||||
voice_examples: vec![],
|
voice_examples: vec![],
|
||||||
occasional_injections: vec![],
|
occasional_injections: vec![],
|
||||||
|
behavior_modifiers: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crossbeam_channel::Receiver;
|
|||||||
|
|
||||||
use crate::npc::blueprint::CultureProfile;
|
use crate::npc::blueprint::CultureProfile;
|
||||||
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
||||||
use crate::voice::prompt_builder;
|
use crate::voice::prompt_builder::{self, ContentType};
|
||||||
use crate::voice::queue::VoiceRequest;
|
use crate::voice::queue::VoiceRequest;
|
||||||
|
|
||||||
/// Minimum token count for a valid response. Below this, retry once.
|
/// Minimum token count for a valid response. Below this, retry once.
|
||||||
@@ -326,6 +326,13 @@ fn process_request(
|
|||||||
request: &VoiceRequest,
|
request: &VoiceRequest,
|
||||||
ctx: &WorkerContext,
|
ctx: &WorkerContext,
|
||||||
) {
|
) {
|
||||||
|
// Factual lines bypass the LLM entirely — serve base text directly.
|
||||||
|
// 2B models corrupt numbers and invert denials (Spike 2 finding, D-138).
|
||||||
|
if request.content_type == ContentType::Factual {
|
||||||
|
cache_base_text(request, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let culture = match ctx.cultures.get(&request.culture_id) {
|
let culture = match ctx.cultures.get(&request.culture_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ fn krenn_culture() -> CultureProfile {
|
|||||||
TellCategory::Friendly,
|
TellCategory::Friendly,
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
|
behavior_modifiers: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ fn krenn_culture() -> CultureProfile {
|
|||||||
TellCategory::Friendly,
|
TellCategory::Friendly,
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
|
behavior_modifiers: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user