Ground compiler retrieval through bounded model plans
This commit is contained in:
parent
db5fa97ffa
commit
27c1bf4c08
9 changed files with 249 additions and 51 deletions
|
|
@ -20,6 +20,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
let compiler = WorldCompiler::new(
|
||||
Arc::new(VoidBotMcpVault::starfire_loopback()),
|
||||
model,
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
let (preview, receipt) = compiler.compile_custom(CustomStart {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ pub struct SuggestedOpenings {
|
|||
pub openings: Vec<OpeningSuggestion>,
|
||||
pub evidence_receipts: Vec<VaultEvidenceReceipt>,
|
||||
pub model_receipt: ModelStageReceipt,
|
||||
pub retrieval_receipt: ModelStageReceipt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
|
@ -72,6 +73,7 @@ pub struct SuggestedRoles {
|
|||
pub roles: Vec<RoleSuggestion>,
|
||||
pub evidence_receipts: Vec<VaultEvidenceReceipt>,
|
||||
pub model_receipt: ModelStageReceipt,
|
||||
pub retrieval_receipt: ModelStageReceipt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
|
|
@ -84,6 +86,11 @@ struct RoleSet {
|
|||
roles: Vec<RoleSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
struct RetrievalQueryPlan {
|
||||
queries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
struct CompiledSeed {
|
||||
title: String,
|
||||
|
|
@ -115,6 +122,7 @@ struct CompiledExpansionSeed {
|
|||
pub struct WorldCompiler {
|
||||
vault: Arc<dyn VaultProvider>,
|
||||
model: Arc<dyn ModelPort>,
|
||||
retrieval_model: String,
|
||||
compiler_model: String,
|
||||
}
|
||||
|
||||
|
|
@ -122,21 +130,26 @@ impl WorldCompiler {
|
|||
pub fn new(
|
||||
vault: Arc<dyn VaultProvider>,
|
||||
model: Arc<dyn ModelPort>,
|
||||
retrieval_model: impl Into<String>,
|
||||
compiler_model: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
vault,
|
||||
model,
|
||||
retrieval_model: retrieval_model.into(),
|
||||
compiler_model: compiler_model.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn suggest_openings(&self, request: OpeningRequest) -> Result<SuggestedOpenings> {
|
||||
let queries = [
|
||||
format!("{} history eras locations institutions", request.setting),
|
||||
format!("{} conflicts pressures ordinary life", request.setting),
|
||||
format!("{} travel geography routes", request.setting),
|
||||
];
|
||||
let (queries, retrieval_receipt) = self
|
||||
.plan_queries(
|
||||
"opening_retrieval_plan",
|
||||
"opening-suggestions",
|
||||
&serde_json::to_string(&request)?,
|
||||
3,
|
||||
)
|
||||
.await?;
|
||||
let receipts = self.retrieve_all(&queries, "all", 8).await?;
|
||||
let evidence = evidence_text(&receipts);
|
||||
let output = self.structured("world_openings", "opening-suggestions", &format!("Generate exactly three source-grounded openings. They must use distinct eras, places, and pressures. Do not fill material evidence gaps with invention. REQUEST:\n{}\nEVIDENCE:\n{}", serde_json::to_string(&request)?, evidence), serde_json::to_value(schema_for!(OpeningSet))?, receipt_ids(&receipts)).await?;
|
||||
|
|
@ -149,20 +162,19 @@ impl WorldCompiler {
|
|||
openings: parsed.openings,
|
||||
evidence_receipts: receipts,
|
||||
model_receipt: output.1,
|
||||
retrieval_receipt,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn suggest_roles(&self, opening: &OpeningSuggestion) -> Result<SuggestedRoles> {
|
||||
let queries = [
|
||||
format!(
|
||||
"{} {} people occupations institutions",
|
||||
opening.era, opening.place
|
||||
),
|
||||
format!(
|
||||
"{} capabilities obligations {}",
|
||||
opening.place, opening.pressure
|
||||
),
|
||||
];
|
||||
let (queries, retrieval_receipt) = self
|
||||
.plan_queries(
|
||||
"role_retrieval_plan",
|
||||
&format!("roles:{}", opening.id),
|
||||
&serde_json::to_string(opening)?,
|
||||
2,
|
||||
)
|
||||
.await?;
|
||||
let receipts = self.retrieve_all(&queries, &opening.era, 8).await?;
|
||||
let output = self.structured("world_roles", &format!("roles:{}", opening.id), &format!("Generate exactly three materially distinct player roles grounded in this opening and evidence. OPENING:\n{}\nEVIDENCE:\n{}", serde_json::to_string(opening)?, evidence_text(&receipts)), serde_json::to_value(schema_for!(RoleSet))?, receipt_ids(&receipts)).await?;
|
||||
let parsed: RoleSet = serde_json::from_value(output.0)?;
|
||||
|
|
@ -173,21 +185,22 @@ impl WorldCompiler {
|
|||
roles: parsed.roles,
|
||||
evidence_receipts: receipts,
|
||||
model_receipt: output.1,
|
||||
retrieval_receipt,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn compile_custom(
|
||||
&self,
|
||||
start: CustomStart,
|
||||
) -> Result<(WorldCompilePreview, ModelStageReceipt)> {
|
||||
let queries = [
|
||||
format!(
|
||||
"{} {} geography routes containment",
|
||||
start.where_, start.when
|
||||
),
|
||||
format!("{} {} people institutions powers", start.where_, start.when),
|
||||
format!("{} capabilities mechanics {}", start.who, start.goal),
|
||||
];
|
||||
) -> Result<(WorldCompilePreview, Vec<ModelStageReceipt>)> {
|
||||
let (queries, retrieval_receipt) = self
|
||||
.plan_queries(
|
||||
"custom_retrieval_plan",
|
||||
"custom-start",
|
||||
&serde_json::to_string(&start)?,
|
||||
3,
|
||||
)
|
||||
.await?;
|
||||
let receipts = self.retrieve_all(&queries, &start.when, 10).await?;
|
||||
let output = self.structured("world_compile", "custom-start", &format!("Compile a bounded playable region and institutional pressure graph. Emit only supported canon facts; mark reversible texture provisional_local and list material gaps. The player location and every actor location must exist. Every route destination must exist, travel time must be positive, clocks need positive thresholds, and the player id must be unique. Represent populations that can act collectively (villages, crews, crowds, departments, corporations) as gestalt Personas. Seed a small roster of plausible durable member identities for people the player may encounter; member deltas contain only departures from their gestalt baseline and begin dematerialized. Do not duplicate a gestalt member in actors. Keep named plot-critical people as ordinary actors. Every gestalt home location and member gestalt reference must exist. START:\n{}\nEVIDENCE:\n{}", serde_json::to_string(&start)?, evidence_text(&receipts)), serde_json::to_value(schema_for!(CompiledSeed))?, receipt_ids(&receipts)).await?;
|
||||
let seed: CompiledSeed = serde_json::from_value(output.0)?;
|
||||
|
|
@ -203,14 +216,14 @@ impl WorldCompiler {
|
|||
branch_assumptions: seed.branch_assumptions,
|
||||
requires_approval: true,
|
||||
},
|
||||
output.1,
|
||||
vec![retrieval_receipt, output.1],
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn compile_selected(
|
||||
&self,
|
||||
start: SelectedStart,
|
||||
) -> Result<(WorldCompilePreview, ModelStageReceipt)> {
|
||||
) -> Result<(WorldCompilePreview, Vec<ModelStageReceipt>)> {
|
||||
self.compile_custom(CustomStart {
|
||||
campaign_name: start.campaign_name,
|
||||
who: format!("{} — {}", start.role.name, start.role.premise),
|
||||
|
|
@ -226,18 +239,26 @@ impl WorldCompiler {
|
|||
campaign: &Campaign,
|
||||
origin_location_id: &str,
|
||||
destination_request: &str,
|
||||
) -> Result<(crate::domain::RegionExpansionPreview, ModelStageReceipt)> {
|
||||
) -> Result<(
|
||||
crate::domain::RegionExpansionPreview,
|
||||
Vec<ModelStageReceipt>,
|
||||
)> {
|
||||
let origin = campaign
|
||||
.locations
|
||||
.get(origin_location_id)
|
||||
.ok_or_else(|| anyhow!("origin location is unknown"))?;
|
||||
let queries = [
|
||||
format!(
|
||||
"{} {} geography containment routes",
|
||||
origin.name, destination_request
|
||||
),
|
||||
format!("{} travel time institutions access", destination_request),
|
||||
];
|
||||
let (queries, retrieval_receipt) = self
|
||||
.plan_queries(
|
||||
"destination_retrieval_plan",
|
||||
&format!("campaign:{}:revision:{}", campaign.id, campaign.revision),
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"origin": origin,
|
||||
"destination": destination_request,
|
||||
"canon_cutoff": campaign.branch_origin.canon_cutoff,
|
||||
}))?,
|
||||
2,
|
||||
)
|
||||
.await?;
|
||||
let receipts = self
|
||||
.retrieve_all(&queries, &campaign.branch_origin.canon_cutoff, 10)
|
||||
.await?;
|
||||
|
|
@ -288,7 +309,7 @@ impl WorldCompiler {
|
|||
canon_candidates: candidates,
|
||||
requires_approval: true,
|
||||
},
|
||||
output.1,
|
||||
vec![retrieval_receipt, output.1],
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -354,6 +375,57 @@ impl WorldCompiler {
|
|||
Ok(receipts)
|
||||
}
|
||||
|
||||
async fn plan_queries(
|
||||
&self,
|
||||
stage: &str,
|
||||
binding: &str,
|
||||
subject: &str,
|
||||
count: usize,
|
||||
) -> Result<(Vec<String>, ModelStageReceipt)> {
|
||||
let schema = serde_json::to_value(schema_for!(RetrievalQueryPlan))?;
|
||||
let prompt = format!(
|
||||
"Plan exactly {count} distinct source-search queries for the supplied subject. Each query must be a concise natural-language search string of 1 to 240 Unicode characters. Preserve proper nouns, era, place, institutions, mechanics, geography, and pressure when relevant. Do not answer the subject. SUBJECT:\n{subject}\n\nOUTPUT JSON SCHEMA (follow exactly):\n{}",
|
||||
serde_json::to_string_pretty(&schema)?
|
||||
);
|
||||
let output = run_validated_stage(
|
||||
self.model.as_ref(),
|
||||
&ModelStageRequest {
|
||||
stage: stage.into(),
|
||||
model: self.retrieval_model.clone(),
|
||||
snapshot_binding: binding.into(),
|
||||
lived_stream: prompt,
|
||||
output_schema: Some(schema),
|
||||
source_receipt_ids: vec![],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let plan: RetrievalQueryPlan = serde_json::from_value(
|
||||
output
|
||||
.structured
|
||||
.ok_or_else(|| anyhow!("retrieval planner returned no structured output"))?,
|
||||
)?;
|
||||
let normalized = plan
|
||||
.queries
|
||||
.into_iter()
|
||||
.map(|query| query.trim().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let unique = normalized.iter().collect::<BTreeSet<_>>();
|
||||
if normalized.len() != count || unique.len() != count {
|
||||
return Err(anyhow!(
|
||||
"retrieval planner must return exactly {count} distinct queries"
|
||||
));
|
||||
}
|
||||
if normalized
|
||||
.iter()
|
||||
.any(|query| query.is_empty() || query.chars().count() > 240)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"retrieval planner query must contain 1 to 240 characters"
|
||||
));
|
||||
}
|
||||
Ok((normalized, output.receipt))
|
||||
}
|
||||
|
||||
async fn structured(
|
||||
&self,
|
||||
stage: &str,
|
||||
|
|
@ -631,10 +703,31 @@ mod tests {
|
|||
struct CompilerModel {
|
||||
invalid_route: bool,
|
||||
}
|
||||
|
||||
struct OversizedQueryModel;
|
||||
#[async_trait]
|
||||
impl ModelPort for OversizedQueryModel {
|
||||
async fn run(&self, _: &ModelStageRequest) -> Result<String> {
|
||||
Ok(serde_json::json!({"queries":["x".repeat(241)]}).to_string())
|
||||
}
|
||||
fn provider(&self) -> &'static str {
|
||||
"oversized-query-fixture"
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl ModelPort for CompilerModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
Ok(match request.stage.as_str() {
|
||||
stage if stage.ends_with("_retrieval_plan") => {
|
||||
let count = if stage == "role_retrieval_plan"
|
||||
|| stage == "destination_retrieval_plan"
|
||||
{
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
serde_json::json!({"queries":(1..=count).map(|index|format!("fixture grounded query {index}")).collect::<Vec<_>>()}).to_string()
|
||||
}
|
||||
"world_openings" => serde_json::json!({"openings":[
|
||||
{"id":"a","title":"Ash","era":"early","place":"ring","pressure":"strike","player_hook":"work","evidence_receipt_ids":[]},
|
||||
{"id":"b","title":"Glass","era":"middle","place":"moon","pressure":"siege","player_hook":"survive","evidence_receipt_ids":[]},
|
||||
|
|
@ -725,6 +818,7 @@ mod tests {
|
|||
Arc::new(CompilerModel {
|
||||
invalid_route: false,
|
||||
}),
|
||||
"flash",
|
||||
"pro",
|
||||
);
|
||||
let output = compiler
|
||||
|
|
@ -738,6 +832,16 @@ mod tests {
|
|||
assert_eq!(output.evidence_receipts.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrieval_planner_refuses_provider_oversized_queries() {
|
||||
let compiler = WorldCompiler::new(vault(), Arc::new(OversizedQueryModel), "flash", "pro");
|
||||
let error = compiler
|
||||
.plan_queries("custom_retrieval_plan", "test", "subject", 1)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("1 to 240"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrieval_receipts_bind_excerpts_to_exact_archive_hashes() {
|
||||
let compiler = WorldCompiler::new(
|
||||
|
|
@ -745,6 +849,7 @@ mod tests {
|
|||
Arc::new(CompilerModel {
|
||||
invalid_route: false,
|
||||
}),
|
||||
"flash",
|
||||
"pro",
|
||||
);
|
||||
let receipts = compiler
|
||||
|
|
@ -763,6 +868,7 @@ mod tests {
|
|||
Arc::new(CompilerModel {
|
||||
invalid_route: false,
|
||||
}),
|
||||
"flash",
|
||||
"pro",
|
||||
);
|
||||
let (preview, _) = compiler
|
||||
|
|
@ -798,6 +904,7 @@ mod tests {
|
|||
Arc::new(CompilerModel {
|
||||
invalid_route: true,
|
||||
}),
|
||||
"flash",
|
||||
"pro",
|
||||
);
|
||||
let result = compiler
|
||||
|
|
|
|||
|
|
@ -376,6 +376,8 @@ pub enum WorldCommand {
|
|||
CreateCampaign {
|
||||
campaign: Campaign,
|
||||
evidence_receipts: Vec<VaultEvidenceReceipt>,
|
||||
#[serde(default)]
|
||||
model_stage_receipts: Vec<crate::model::ModelStageReceipt>,
|
||||
},
|
||||
Speak {
|
||||
expected_revision: u64,
|
||||
|
|
@ -409,6 +411,8 @@ pub enum WorldCommand {
|
|||
expansion: RegionExpansion,
|
||||
evidence_receipts: Vec<VaultEvidenceReceipt>,
|
||||
canon_candidates: Vec<CanonCandidate>,
|
||||
#[serde(default)]
|
||||
model_stage_receipts: Vec<crate::model::ModelStageReceipt>,
|
||||
},
|
||||
MaterializeGestaltMember {
|
||||
expected_revision: u64,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ fn execute(
|
|||
if let WorldCommand::CreateCampaign {
|
||||
campaign,
|
||||
evidence_receipts,
|
||||
model_stage_receipts,
|
||||
} = command
|
||||
{
|
||||
if !store.keys("campaign.v1").map_err(persist)?.is_empty() {
|
||||
|
|
@ -91,7 +92,7 @@ fn execute(
|
|||
crate::compiler::validate_campaign_seed(&campaign)
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?;
|
||||
store
|
||||
.create_campaign(&campaign, &evidence_receipts)
|
||||
.create_campaign(&campaign, &evidence_receipts, &model_stage_receipts)
|
||||
.map_err(persist)?;
|
||||
return Ok(CommandResult::Created { campaign });
|
||||
}
|
||||
|
|
@ -292,6 +293,7 @@ fn execute(
|
|||
expansion,
|
||||
evidence_receipts,
|
||||
canon_candidates,
|
||||
model_stage_receipts,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
crate::compiler::validate_region_expansion(&campaign, &expansion)
|
||||
|
|
@ -314,6 +316,7 @@ fn execute(
|
|||
"expand_region",
|
||||
evidence_receipts,
|
||||
canon_candidates,
|
||||
model_stage_receipts,
|
||||
)
|
||||
}
|
||||
WorldCommand::MaterializeGestaltMember {
|
||||
|
|
@ -1204,6 +1207,7 @@ fn commit_with_records(
|
|||
kind: &str,
|
||||
evidence: Vec<VaultEvidenceReceipt>,
|
||||
candidates: Vec<CanonCandidate>,
|
||||
model_receipts: Vec<crate::model::ModelStageReceipt>,
|
||||
) -> Result<CommandResult, KernelError> {
|
||||
let previous_revision = campaign.revision;
|
||||
campaign.revision += 1;
|
||||
|
|
@ -1224,6 +1228,7 @@ fn commit_with_records(
|
|||
&receipt,
|
||||
&evidence,
|
||||
&candidates,
|
||||
&model_receipts,
|
||||
)
|
||||
.map_err(persist)?;
|
||||
Ok(CommandResult::Committed { campaign, receipt })
|
||||
|
|
@ -1302,6 +1307,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1343,6 +1349,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed,
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1371,6 +1378,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1428,6 +1436,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1488,6 +1497,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1562,6 +1572,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1608,6 +1619,7 @@ mod tests {
|
|||
},
|
||||
evidence_receipts: vec![evidence],
|
||||
canon_candidates: vec![candidate],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1680,6 +1692,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed,
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1849,6 +1862,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1926,6 +1940,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed,
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -2028,6 +2043,7 @@ mod tests {
|
|||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ impl Drop for LiveTurnGuard {
|
|||
struct OwnedPreview<T> {
|
||||
session_hash: String,
|
||||
value: T,
|
||||
model_receipts: Vec<ghostlight_dungeon::model::ModelStageReceipt>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
|
|
@ -172,6 +173,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
Some(Arc::new(WorldCompiler::new(
|
||||
Arc::new(VoidBotMcpVault::starfire_loopback()),
|
||||
provider.clone(),
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
))),
|
||||
Some(Arc::new(ActionAssessor::new(
|
||||
|
|
@ -370,16 +372,17 @@ async fn compile_custom(
|
|||
.into_response();
|
||||
};
|
||||
match compiler.compile_custom(request).await {
|
||||
Ok((preview, receipt)) => {
|
||||
Ok((preview, model_receipts)) => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
state.compile_previews.lock().await.insert(
|
||||
id.clone(),
|
||||
OwnedPreview {
|
||||
session_hash: session,
|
||||
value: preview.clone(),
|
||||
model_receipts: model_receipts.clone(),
|
||||
},
|
||||
);
|
||||
Json(serde_json::json!({"preview_id":id,"preview":preview,"model_receipt":receipt}))
|
||||
Json(serde_json::json!({"preview_id":id,"preview":preview,"model_receipts":model_receipts}))
|
||||
.into_response()
|
||||
}
|
||||
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, error.to_string()).into_response(),
|
||||
|
|
@ -445,20 +448,21 @@ async fn store_preview(
|
|||
session_hash: String,
|
||||
result: anyhow::Result<(
|
||||
WorldCompilePreview,
|
||||
ghostlight_dungeon::model::ModelStageReceipt,
|
||||
Vec<ghostlight_dungeon::model::ModelStageReceipt>,
|
||||
)>,
|
||||
) -> Response {
|
||||
match result {
|
||||
Ok((preview, receipt)) => {
|
||||
Ok((preview, model_receipts)) => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
state.compile_previews.lock().await.insert(
|
||||
id.clone(),
|
||||
OwnedPreview {
|
||||
session_hash,
|
||||
value: preview.clone(),
|
||||
model_receipts: model_receipts.clone(),
|
||||
},
|
||||
);
|
||||
Json(serde_json::json!({"preview_id":id,"preview":preview,"model_receipt":receipt}))
|
||||
Json(serde_json::json!({"preview_id":id,"preview":preview,"model_receipts":model_receipts}))
|
||||
.into_response()
|
||||
}
|
||||
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, error.to_string()).into_response(),
|
||||
|
|
@ -485,10 +489,11 @@ async fn approve_preview(
|
|||
previews.remove(&preview_id);
|
||||
drop(previews);
|
||||
let preview = owned.value;
|
||||
let model_receipts = owned.model_receipts;
|
||||
let campaign_id = preview.campaign.id;
|
||||
match state
|
||||
.registry
|
||||
.create(preview.campaign, preview.evidence_receipts)
|
||||
.create(preview.campaign, preview.evidence_receipts, model_receipts)
|
||||
.await
|
||||
{
|
||||
Ok(runtime) => match select_campaign(&state, &session, campaign_id).await {
|
||||
|
|
@ -549,16 +554,17 @@ async fn compile_destination(
|
|||
.compile_destination(&campaign, &request.origin_location_id, &request.destination)
|
||||
.await
|
||||
{
|
||||
Ok((preview, receipt)) => {
|
||||
Ok((preview, model_receipts)) => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
state.expansion_previews.lock().await.insert(
|
||||
id.clone(),
|
||||
OwnedPreview {
|
||||
session_hash: session,
|
||||
value: preview.clone(),
|
||||
model_receipts: model_receipts.clone(),
|
||||
},
|
||||
);
|
||||
Json(serde_json::json!({"preview_id":id,"preview":preview,"model_receipt":receipt}))
|
||||
Json(serde_json::json!({"preview_id":id,"preview":preview,"model_receipts":model_receipts}))
|
||||
.into_response()
|
||||
}
|
||||
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, error.to_string()).into_response(),
|
||||
|
|
@ -584,6 +590,7 @@ async fn approve_destination(
|
|||
previews.remove(&preview_id);
|
||||
drop(previews);
|
||||
let preview = owned.value;
|
||||
let model_receipts = owned.model_receipts;
|
||||
let runtime = match session_runtime(&state, &session).await {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => {
|
||||
|
|
@ -600,6 +607,7 @@ async fn approve_destination(
|
|||
expansion: preview.expansion,
|
||||
evidence_receipts: preview.evidence_receipts,
|
||||
canon_candidates: preview.canon_candidates,
|
||||
model_stage_receipts: model_receipts,
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
|
@ -1602,8 +1610,11 @@ mod tests {
|
|||
let registry = CampaignRegistry::new(dir.path().join("campaigns")).unwrap();
|
||||
let left = seed("Left");
|
||||
let right = seed("Right");
|
||||
registry.create(left.clone(), vec![]).await.unwrap();
|
||||
registry.create(right.clone(), vec![]).await.unwrap();
|
||||
registry.create(left.clone(), vec![], vec![]).await.unwrap();
|
||||
registry
|
||||
.create(right.clone(), vec![], vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let auth_store = CampaignStore::open(dir.path().join("auth.cc")).unwrap();
|
||||
let auth_state = AuthState {
|
||||
schema: "ghostlight.auth_state.v1".into(),
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ mod tests {
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
|
||||
let seed = campaign();
|
||||
store.create_campaign(&seed, &[]).unwrap();
|
||||
store.create_campaign(&seed, &[], &[]).unwrap();
|
||||
let narrator = Narrator {
|
||||
model: Arc::new(FixtureModel),
|
||||
model_name: "fixture".into(),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ impl CampaignStore {
|
|||
&self,
|
||||
campaign: &Campaign,
|
||||
receipts: &[VaultEvidenceReceipt],
|
||||
model_receipts: &[crate::model::ModelStageReceipt],
|
||||
) -> Result<CultCacheEnvelope> {
|
||||
let campaign_row = envelope(
|
||||
"campaign.v1",
|
||||
|
|
@ -99,6 +100,14 @@ impl CampaignStore {
|
|||
receipt,
|
||||
)?);
|
||||
}
|
||||
for receipt in model_receipts {
|
||||
rows.push(envelope(
|
||||
"persona_stage_receipt.v1",
|
||||
"ghostlight.persona_stage_receipt.v1",
|
||||
&receipt.output_hash,
|
||||
receipt,
|
||||
)?);
|
||||
}
|
||||
for candidate in campaign.canon_candidates.values() {
|
||||
rows.push(envelope(
|
||||
"canon_candidate.v1",
|
||||
|
|
@ -164,6 +173,7 @@ impl CampaignStore {
|
|||
receipt: &R,
|
||||
evidence: &[VaultEvidenceReceipt],
|
||||
candidates: &[crate::domain::CanonCandidate],
|
||||
model_receipts: &[crate::model::ModelStageReceipt],
|
||||
) -> Result<CultCacheEnvelope> {
|
||||
let next_row = envelope(
|
||||
&expected.r#type,
|
||||
|
|
@ -196,6 +206,14 @@ impl CampaignStore {
|
|||
item,
|
||||
)?);
|
||||
}
|
||||
for item in model_receipts {
|
||||
rows.push(envelope(
|
||||
"persona_stage_receipt.v1",
|
||||
"ghostlight.persona_stage_receipt.v1",
|
||||
&item.output_hash,
|
||||
item,
|
||||
)?);
|
||||
}
|
||||
if !self
|
||||
.inner
|
||||
.compare_and_swap_batch(std::slice::from_ref(expected), rows)?
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use crate::{
|
||||
domain::{Campaign, CampaignLifecycleReceipt, VaultEvidenceReceipt},
|
||||
kernel::WorldKernel,
|
||||
model::ModelStageReceipt,
|
||||
persistence::CampaignStore,
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
|
@ -84,6 +85,7 @@ impl CampaignRegistry {
|
|||
&self,
|
||||
campaign: Campaign,
|
||||
evidence: Vec<VaultEvidenceReceipt>,
|
||||
model_receipts: Vec<ModelStageReceipt>,
|
||||
) -> Result<CampaignRuntime> {
|
||||
if self.runtimes.read().await.contains_key(&campaign.id) {
|
||||
return Err(anyhow!("campaign already exists"));
|
||||
|
|
@ -96,6 +98,7 @@ impl CampaignRegistry {
|
|||
.command(crate::domain::WorldCommand::CreateCampaign {
|
||||
campaign: campaign.clone(),
|
||||
evidence_receipts: evidence,
|
||||
model_stage_receipts: model_receipts,
|
||||
})
|
||||
.await?;
|
||||
store.insert(
|
||||
|
|
@ -135,13 +138,16 @@ impl CampaignRegistry {
|
|||
evidence.push(receipt);
|
||||
}
|
||||
}
|
||||
let model_receipts = source
|
||||
.store
|
||||
.load_all::<ModelStageReceipt>("persona_stage_receipt.v1")?;
|
||||
let parent_revision = parent.revision;
|
||||
let mut fork = parent;
|
||||
fork.id = Uuid::new_v4();
|
||||
fork.name = name;
|
||||
fork.revision = 0;
|
||||
fork.pending_world_proposals.clear();
|
||||
let runtime = self.create(fork.clone(), evidence).await?;
|
||||
let runtime = self.create(fork.clone(), evidence, model_receipts).await?;
|
||||
runtime.store.insert(
|
||||
"campaign_lifecycle_receipt.v1",
|
||||
"ghostlight.campaign_lifecycle_receipt.v1",
|
||||
|
|
@ -183,7 +189,10 @@ impl CampaignRegistry {
|
|||
evidence.push(receipt);
|
||||
}
|
||||
}
|
||||
let runtime = self.create(seed.clone(), evidence).await?;
|
||||
let model_receipts = source
|
||||
.store
|
||||
.load_all::<ModelStageReceipt>("persona_stage_receipt.v1")?;
|
||||
let runtime = self.create(seed.clone(), evidence, model_receipts).await?;
|
||||
runtime.store.insert(
|
||||
"campaign_lifecycle_receipt.v1",
|
||||
"ghostlight.campaign_lifecycle_receipt.v1",
|
||||
|
|
@ -281,12 +290,33 @@ mod tests {
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry = CampaignRegistry::new(dir.path().join("campaigns")).unwrap();
|
||||
let original = seed("Original");
|
||||
registry.create(original.clone(), vec![]).await.unwrap();
|
||||
let model_receipt = ModelStageReceipt {
|
||||
schema: "ghostlight.persona_stage_receipt.v1".into(),
|
||||
provider: "fixture".into(),
|
||||
model: "fixture".into(),
|
||||
stage: "world_compile".into(),
|
||||
snapshot_binding: "custom-start".into(),
|
||||
request_hash: "sha256:request".into(),
|
||||
output_hash: "sha256:output".into(),
|
||||
source_receipt_ids: vec![],
|
||||
latency_ms: 1,
|
||||
validation_result: "valid".into(),
|
||||
};
|
||||
registry
|
||||
.create(original.clone(), vec![], vec![model_receipt.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
let fork = registry.fork(original.id, "Fork".into()).await.unwrap();
|
||||
let fork_id = fork.store.keys("campaign.v1").unwrap()[0]
|
||||
.parse::<Uuid>()
|
||||
.unwrap();
|
||||
assert_ne!(fork_id, original.id);
|
||||
assert_eq!(
|
||||
fork.store
|
||||
.load_all::<ModelStageReceipt>("persona_stage_receipt.v1")
|
||||
.unwrap(),
|
||||
vec![model_receipt]
|
||||
);
|
||||
let reset = registry.reset(original.id, "Reset".into()).await.unwrap();
|
||||
let reset_id = reset.store.keys("campaign.v1").unwrap()[0]
|
||||
.parse::<Uuid>()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,18 @@ impl VoidBotMcpVault {
|
|||
.lines()
|
||||
.find_map(|line| line.strip_prefix("data: "))
|
||||
.ok_or_else(|| anyhow!("VoidBot MCP returned no event payload"))?;
|
||||
Ok(serde_json::from_str(payload)?)
|
||||
let value: serde_json::Value = serde_json::from_str(payload)?;
|
||||
if let Some(error) = value.pointer("/error/message").and_then(|v| v.as_str()) {
|
||||
return Err(anyhow!("VoidBot MCP JSON-RPC error: {error}"));
|
||||
}
|
||||
if value.pointer("/result/isError").and_then(|v| v.as_bool()) == Some(true) {
|
||||
let detail = value
|
||||
.pointer("/result/content/0/text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("tool returned an unspecified error");
|
||||
return Err(anyhow!("VoidBot MCP tool error: {detail}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue