diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs index bdfcbdc..df0fb79 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs @@ -140,14 +140,19 @@ async fn main() -> anyhow::Result<()> { let narrator = Narrator { model, model_name: "deepseek-v4-pro".into(), + verifier_model_name: "deepseek-v4-flash".into(), }; - let (narration, narration_receipt) = narrator.project(&store, &post).await?; - store.insert( - "persona_stage_receipt.v1", - "ghostlight.persona_stage_receipt.v1", - narration_receipt.storage_key(), - &narration_receipt, - )?; + let (narration, narration_receipts) = narrator.project(&store, &post).await?; + for receipt in &narration_receipts { + store.insert( + "persona_stage_receipt.v1", + "ghostlight.persona_stage_receipt.v1", + receipt.storage_key(), + receipt, + )?; + } + let mut model_stage_receipts = vec![impossible_receipt, feasible_receipt]; + model_stage_receipts.extend(narration_receipts); let result = serde_json::json!({ "schema":"ghostlight.action_smoke.v1", @@ -158,7 +163,7 @@ async fn main() -> anyhow::Result<()> { "feasible_assessment":feasible, "attempt":attempt, "narration":narration, - "model_stage_receipts":[impossible_receipt, feasible_receipt, narration_receipt], + "model_stage_receipts":model_stage_receipts, "campaign_revision":post.revision, "store":root.join("campaign.cc"), "result_path":root.join("result.json") diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs index 56ceb4a..9c11cc8 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs @@ -35,15 +35,18 @@ async fn main() -> anyhow::Result<()> { let narrator = Narrator { model, model_name: "deepseek-v4-pro".into(), + verifier_model_name: "deepseek-v4-flash".into(), }; let started = Instant::now(); - let (projection, receipt) = narrator.project(&store, &campaign).await?; - store.insert( - "persona_stage_receipt.v1", - "ghostlight.persona_stage_receipt.v1", - receipt.storage_key(), - &receipt, - )?; + let (projection, receipts) = narrator.project(&store, &campaign).await?; + for receipt in &receipts { + store.insert( + "persona_stage_receipt.v1", + "ghostlight.persona_stage_receipt.v1", + receipt.storage_key(), + receipt, + )?; + } let result_path = store_path .parent() .unwrap_or_else(|| std::path::Path::new(".")) @@ -55,7 +58,7 @@ async fn main() -> anyhow::Result<()> { "schema":"ghostlight.narrator_smoke.v1", "elapsed_seconds":started.elapsed().as_secs_f64(), "projection":projection, - "model_stage_receipt":receipt, + "model_stage_receipts":receipts, "campaign_revision":campaign.revision, "result_path":result_path }); diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 3cb74eb..ed7e343 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -3917,20 +3917,23 @@ async fn publish_latest_narration( let narrator = Narrator { model: model.clone(), model_name: MODEL_CAPABLE.into(), + verifier_model_name: MODEL_FAST.into(), }; - let (projection, receipt) = narrator.project(&runtime.store, &campaign).await?; + let (projection, receipts) = narrator.project(&runtime.store, &campaign).await?; runtime.store.insert( "narration_projection.v1", "ghostlight.narration_projection.v1", &projection.id, &projection, )?; - let _ = runtime.store.insert( - "persona_stage_receipt.v1", - "ghostlight.persona_stage_receipt.v1", - receipt.storage_key(), - &receipt, - ); + for receipt in receipts { + let _ = runtime.store.insert( + "persona_stage_receipt.v1", + "ghostlight.persona_stage_receipt.v1", + receipt.storage_key(), + &receipt, + ); + } Ok(Some(projection)) } diff --git a/crates/ghostlight-dungeon/src/narrator.rs b/crates/ghostlight-dungeon/src/narrator.rs index b44d8b8..dd5a433 100644 --- a/crates/ghostlight-dungeon/src/narrator.rs +++ b/crates/ghostlight-dungeon/src/narrator.rs @@ -5,12 +5,23 @@ use crate::{ }; use anyhow::{Result, anyhow}; use chrono::Utc; +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; #[derive(Clone)] pub struct Narrator { pub model: Arc, pub model_name: String, + pub verifier_model_name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct NarrationVerification { + faithful_to_draft: bool, + prose: String, + unsupported_claims: Vec, } impl Narrator { @@ -18,7 +29,7 @@ impl Narrator { &self, store: &CampaignStore, campaign: &Campaign, - ) -> Result<(NarrationProjection, ModelStageReceipt)> { + ) -> Result<(NarrationProjection, Vec)> { let player = &campaign.actors[&campaign.player_actor_id]; let location = &campaign.locations[&player.location_id]; let recent_events: Vec<_> = campaign.events.iter().rev().take(4).cloned().collect(); @@ -77,6 +88,7 @@ impl Narrator { "campaign_contract": publication.as_ref().map(|value| &value.contract), "aggregate_content_boundaries": aggregate_boundaries, }); + let public_slice_json = serde_json::to_string(&public_slice)?; let output = run_validated_stage( self.model.as_ref(), &ModelStageRequest { @@ -88,7 +100,7 @@ impl Narrator { ), lived_stream: format!( "Narrate only latest_committed_turn and any latest_events it directly caused, in concrete, concise interactive-fiction prose. viewer_actor is the reader's character and the only actor that may be addressed in second person. If latest_committed_turn belongs to another visible actor, narrate that actor in third person and preserve their exact attribution; never transfer their speech, knowledge, uncertainty, choice, or action to the viewer. The campaign contract governs tone, pacing, focus, consequences, and DM style. Obey every aggregate content boundary: line excludes the topic, veil keeps it off-screen, ask_first permits no new depiction without a current explicit player acceptance. Never expose boundary attribution. Location, time, and visible actors are grounding constraints, not a request to restate every field. Do not repeat older setup, list routes, or recap unrelated world state. Do not mention JSON, state, commits, revisions, or the source representation. Every environmental noun, sensory adjective, object state, action, and consequence must be traceable to the supplied JSON. Do not invent lighting, temperature, sound, motion, posture, dialogue, private thoughts, expertise, geography, findings, or outcomes. It is better to be spare than to fabricate texture. Emit prose only.\n\n{}", - serde_json::to_string(&public_slice)? + public_slice_json ), output_schema: None, source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(), @@ -101,6 +113,47 @@ impl Narrator { if text.is_empty() || text.starts_with('{') || text.contains("```json") { return Err(anyhow!("narrator violated prose projection membrane")); } + let verification = run_validated_stage( + self.model.as_ref(), + &ModelStageRequest { + stage: "narration_verifier".into(), + model: self.verifier_model_name.clone(), + snapshot_binding: format!( + "campaign:{}:revision:{}:narration-verifier", + campaign.id, campaign.revision + ), + lived_stream: format!( + "You are Ghostlight's independent narration verifier. The typed source slice and draft are projections of one already-committed world revision. Return the draft unchanged only when every concrete claim, outcome, actor attribution, object state, sensory detail, and causal implication is entailed by the source. Otherwise return minimal corrected interactive-fiction prose containing only entailed claims. Preserve an explicit committed success, mixed result, or failure exactly; later reaction speech or uncertainty cannot reverse it. Do not invent a more specific finding than the committed event or actor knowledge. Do not add atmosphere merely to improve style. faithful_to_draft is true only when prose is byte-for-byte the supplied draft. unsupported_claims contains short descriptions of removed or corrected claims and is empty when faithful. Return one JSON object only.\n\nTYPED SOURCE SLICE:\n{}\n\nDRAFT NARRATION:\n{}", + public_slice_json, + serde_json::to_string(text)? + ), + output_schema: Some(serde_json::to_value(schema_for!(NarrationVerification))?), + source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(), + temperature: Some(0.0), + max_output_tokens: Some(384), + }, + ) + .await?; + let verification_value: NarrationVerification = serde_json::from_value( + verification + .structured + .clone() + .ok_or_else(|| anyhow!("narration verifier produced no typed result"))?, + )?; + let verified_text = verification_value.prose.trim(); + if verified_text.is_empty() + || verified_text.len() > 2_000 + || verified_text.starts_with('{') + || verified_text.contains("```json") + || verification_value.unsupported_claims.len() > 8 + || verification_value + .unsupported_claims + .iter() + .any(|claim| claim.trim().is_empty() || claim.len() > 240) + || (verification_value.faithful_to_draft && verified_text != text) + { + return Err(anyhow!("narration verifier returned an invalid projection")); + } let current = store .load::("campaign.v1", &campaign.id.to_string())? .map(|(_, campaign)| campaign) @@ -113,12 +166,12 @@ impl Narrator { id: format!("{}:{}", campaign.id, campaign.revision), campaign_id: campaign.id, source_revision: campaign.revision, - text: text.into(), + text: verified_text.into(), event_ids: recent_events.into_iter().map(|event| event.id).collect(), - model_receipt_hash: output.receipt.storage_key().to_owned(), + model_receipt_hash: verification.receipt.storage_key().to_owned(), published_at: Utc::now(), }; - Ok((projection, output.receipt)) + Ok((projection, vec![output.receipt, verification.receipt])) } } @@ -127,7 +180,7 @@ mod tests { use super::*; use crate::{ domain::{ActorState, BranchOrigin, Campaign, Location, NarrativeTurn}, - model::{FixtureModel, ModelStageRequest}, + model::ModelStageRequest, }; use async_trait::async_trait; use std::{ @@ -139,9 +192,38 @@ mod tests { prompt: Mutex, } + struct CorrectingNarratorModel; + + #[async_trait] + impl ModelPort for CorrectingNarratorModel { + async fn run(&self, request: &ModelStageRequest) -> Result { + if request.stage == "narration_verifier" { + return Ok(serde_json::json!({ + "faithful_to_draft":false, + "prose":"The clinic director answers.", + "unsupported_claims":["The draft invented a damaged seal."] + }) + .to_string()); + } + Ok("The clinic director answers beside an invented damaged seal.".into()) + } + + fn provider(&self) -> &'static str { + "narrator-correcting" + } + } + #[async_trait] impl ModelPort for CaptureNarratorModel { async fn run(&self, request: &ModelStageRequest) -> Result { + if request.stage == "narration_verifier" { + return Ok(serde_json::json!({ + "faithful_to_draft":true, + "prose":"The clinic director answers without moving the ledger.", + "unsupported_claims":[] + }) + .to_string()); + } *self.prompt.lock().unwrap() = request.lived_stream.clone(); Ok("The clinic director answers without moving the ledger.".into()) } @@ -218,8 +300,11 @@ mod tests { let seed = campaign(); store.create_campaign(&seed, &[], &[]).unwrap(); let narrator = Narrator { - model: Arc::new(FixtureModel), + model: Arc::new(CaptureNarratorModel { + prompt: Mutex::new(String::new()), + }), model_name: "fixture".into(), + verifier_model_name: "fixture".into(), }; let (projection, _) = narrator.project(&store, &seed).await.unwrap(); assert_eq!(projection.source_revision, 0); @@ -254,6 +339,7 @@ mod tests { let narrator = Narrator { model: model.clone(), model_name: "fixture".into(), + verifier_model_name: "fixture".into(), }; narrator.project(&store, &seed).await.unwrap(); @@ -267,4 +353,25 @@ mod tests { .contains("never transfer their speech, knowledge, uncertainty, choice, or action") ); } + + #[tokio::test] + async fn narration_publishes_the_verified_correction_and_binds_both_stage_receipts() { + let dir = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); + let seed = campaign(); + store.create_campaign(&seed, &[], &[]).unwrap(); + let narrator = Narrator { + model: Arc::new(CorrectingNarratorModel), + model_name: "fixture-pro".into(), + verifier_model_name: "fixture-flash".into(), + }; + + let (projection, receipts) = narrator.project(&store, &seed).await.unwrap(); + + assert_eq!(projection.text, "The clinic director answers."); + assert_eq!(receipts.len(), 2); + assert_eq!(receipts[0].stage, "narrator"); + assert_eq!(receipts[1].stage, "narration_verifier"); + assert_eq!(projection.model_receipt_hash, receipts[1].storage_key()); + } }