From fb09c987820d9be31e50f28357193abc7b9da9e9 Mon Sep 17 00:00:00 2001 From: 1sjp Date: Tue, 21 Apr 2026 12:53:53 +0800 Subject: [PATCH 1/3] feat(noname): extend role context and apply observability --- src-tauri/src/noname_agent_registry.rs | 68 ++- src-tauri/src/noname_apply.rs | 290 +++++++++++-- src-tauri/src/noname_context_builder.rs | 408 ++++++++++++++++-- src-tauri/src/noname_context_types.rs | 12 + src-tauri/src/noname_guardrails.rs | 29 +- src-tauri/src/noname_output_interface.rs | 43 +- src-tauri/src/noname_prompts.rs | 30 +- src-tauri/src/noname_roles.rs | 107 ++++- src-tauri/src/noname_runtime.rs | 48 ++- src-tauri/src/noname_trace.rs | 15 + src-tauri/src/noname_types.rs | 6 + src-tauri/src/plot_consistency.rs | 1 + src-tauri/src/plot_engine.rs | 366 +++++++++++++++- src-tauri/src/tauri_commands.rs | 282 ++++++++++-- src/components/AgentTracePanel.vue | 336 +++++++++++++-- src/components/GameRuntimeView.vue | 45 +- src/components/InfoTabsDialog.vue | 13 +- src/components/NoNameDebugConsole.vue | 70 ++- .../__tests__/AgentTracePanel.test.ts | 137 +++++- .../__tests__/InfoTabsDialog.test.ts | 8 + .../__tests__/NoNameDebugConsole.test.ts | 44 +- src/platform/webRuntime.test.ts | 77 +++- src/platform/webRuntime.ts | 258 +++++++++-- src/stores/__tests__/gameStore.test.ts | 122 +++++- src/stores/gameStore.ts | 157 ++++++- src/types/game.ts | 51 +++ src/utils/noNameApplyLifecycle.test.ts | 127 ++++++ src/utils/noNameApplyLifecycle.ts | 167 ++++++- 28 files changed, 3081 insertions(+), 236 deletions(-) create mode 100644 src/utils/noNameApplyLifecycle.test.ts diff --git a/src-tauri/src/noname_agent_registry.rs b/src-tauri/src/noname_agent_registry.rs index d852ec7..3173158 100644 --- a/src-tauri/src/noname_agent_registry.rs +++ b/src-tauri/src/noname_agent_registry.rs @@ -1,4 +1,5 @@ -use crate::noname_context_types::NoNameContextPacket; +use crate::noname_context_builder::flatten_role_context_packet; +use crate::noname_context_types::{NoNameContextPacket, NoNameRoleContextPacket}; use crate::noname_errors::NoNameError; use crate::noname_roles::{ unsupported_role_error, CombatNarratorAgent, DirectorAgent, NoNameRoleObservation, @@ -118,11 +119,35 @@ impl NoNameAgentRegistry { NoNameRole::System => Err(unsupported_role_error(role)), } } + + pub fn dispatch_role_context_observe_turn( + &self, + trace: &mut NoNameTrace, + context_packet: &NoNameRoleContextPacket, + action_summary: &str, + ) -> Result { + let flattened_context = flatten_role_context_packet(context_packet); + let mut observation = self.dispatch_observe_turn( + context_packet.role, + trace, + &flattened_context, + action_summary, + )?; + observation.role_goal = Some(context_packet.role_goal.clone()); + observation.scene_focus = Some(context_packet.scene_focus.clone()); + observation.forbidden_scopes = context_packet.forbidden_scopes.clone(); + observation.note_type_hits = context_packet.note_type_hits.clone(); + observation.source_stats = context_packet.source_stats.clone(); + observation.context_token_budget_used = Some(context_packet.token_budget_used); + observation.context_slice_stats = context_packet.context_slice_stats.clone(); + Ok(observation) + } } #[cfg(test)] mod tests { use super::*; + use crate::noname_context_builder::specialize_context_packet; use crate::noname_context_types::{NoNameContextPacket, NoNameContextSourceStat}; use crate::noname_types::NoNameMode; @@ -226,4 +251,45 @@ mod tests { assert_eq!(err.code, "noname.agent.unsupported_role"); } + + #[test] + fn registry_can_dispatch_role_context_packet_directly() { + let registry = NoNameAgentRegistry::new_default(); + let mut trace = + NoNameTrace::empty("trace-3", "session-3", "turn-3", NoNameMode::ObserveOnly); + let role_context = specialize_context_packet(&sample_packet(NoNameRole::WorldCurator)); + + let observation = registry + .dispatch_role_context_observe_turn(&mut trace, &role_context, "检查护山法阵") + .expect("role context dispatch should work"); + + assert_eq!(observation.role, NoNameRole::WorldCurator); + assert_eq!( + observation.proposal.kind, + NoNameProposalKind::WorldPatchProposal + ); + assert!(observation.prompt_preview.contains(&role_context.role_goal)); + assert!(observation + .prompt_preview + .contains(&role_context.forbidden_scopes[0])); + assert_eq!( + observation.role_goal.as_deref(), + Some(role_context.role_goal.as_str()) + ); + assert_eq!( + observation.scene_focus.as_deref(), + Some(role_context.scene_focus.as_str()) + ); + assert_eq!(observation.forbidden_scopes, role_context.forbidden_scopes); + assert_eq!(observation.note_type_hits, role_context.note_type_hits); + assert_eq!(observation.source_stats, role_context.source_stats); + assert_eq!( + observation.context_token_budget_used, + Some(role_context.token_budget_used) + ); + assert_eq!( + observation.context_slice_stats, + role_context.context_slice_stats + ); + } } diff --git a/src-tauri/src/noname_apply.rs b/src-tauri/src/noname_apply.rs index 0adb65b..001a72e 100644 --- a/src-tauri/src/noname_apply.rs +++ b/src-tauri/src/noname_apply.rs @@ -21,6 +21,12 @@ pub struct NoNameApplyDiagnosticsSnapshot { pub expected_generation_diagnostics: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoNameApplyPlotAugmentationSnapshot { + pub chapter_index: u32, + pub expected_plot_augmentation_hints: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NoNameReviewedApplyRequest { pub request_id: String, @@ -28,6 +34,19 @@ pub struct NoNameReviewedApplyRequest { pub segment_snapshot: Option, pub summary_snapshot: Option, pub diagnostics_snapshot: Option, + pub plot_augmentation_snapshot: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoNameReviewedApplyRequestInput { + pub request_id: String, + pub scope: NoNameApplyScope, + pub chapter_index: Option, + pub segment_index: Option, + pub expected_segment_text: Option, + pub expected_summary: Option, + pub expected_generation_diagnostics: Option, + pub expected_plot_augmentation_hints: Option>, } #[derive(Debug, Clone, PartialEq)] @@ -52,18 +71,24 @@ pub fn build_manual_plot_text_apply_request( }), summary_snapshot: None, diagnostics_snapshot: None, + plot_augmentation_snapshot: None, } } pub fn build_reviewed_apply_request( - request_id: String, - scope: NoNameApplyScope, - chapter_index: Option, - segment_index: Option, - expected_segment_text: Option, - expected_summary: Option, - expected_generation_diagnostics: Option, + input: NoNameReviewedApplyRequestInput, ) -> Result { + let NoNameReviewedApplyRequestInput { + request_id, + scope, + chapter_index, + segment_index, + expected_segment_text, + expected_summary, + expected_generation_diagnostics, + expected_plot_augmentation_hints, + } = input; + let segment_snapshot = match scope { NoNameApplyScope::PlotTextHint => Some(NoNameApplySegmentSnapshot { chapter_index: chapter_index @@ -98,6 +123,20 @@ pub fn build_reviewed_apply_request( }), _ => None, }; + let plot_augmentation_snapshot = match scope { + NoNameApplyScope::PlotAugmentationHint => Some(NoNameApplyPlotAugmentationSnapshot { + chapter_index: chapter_index.ok_or_else(|| { + "plot_augmentation_hint manual apply requires chapter_index".to_string() + })?, + expected_plot_augmentation_hints: expected_plot_augmentation_hints.ok_or_else( + || { + "plot_augmentation_hint manual apply requires expected_plot_augmentation_hints" + .to_string() + }, + )?, + }), + _ => None, + }; Ok(NoNameReviewedApplyRequest { request_id, @@ -105,12 +144,14 @@ pub fn build_reviewed_apply_request( segment_snapshot, summary_snapshot, diagnostics_snapshot, + plot_augmentation_snapshot, }) } fn priority_for_apply_scope(scope: NoNameApplyScope) -> u32 { match scope { NoNameApplyScope::PlotTextHint => 300, + NoNameApplyScope::PlotAugmentationHint => 250, NoNameApplyScope::ChapterSummaryHint => 200, NoNameApplyScope::OptionBiasHint => 100, NoNameApplyScope::Diagnostics => 50, @@ -149,7 +190,7 @@ fn target_segment_supports_apply_scope( scope: NoNameApplyScope, ) -> bool { match scope { - NoNameApplyScope::PlotTextHint => matches!( + NoNameApplyScope::PlotTextHint | NoNameApplyScope::PlotAugmentationHint => matches!( target_segment, NoNameTargetSegment::CurrentTurnHead | NoNameTargetSegment::CurrentTurnTail ), @@ -187,9 +228,18 @@ fn build_option_bias_hint(proposal: &NoNameProposal) -> String { ) } +fn build_plot_augmentation_hint(proposal: &NoNameProposal) -> String { + format!( + "NoName plot augmentation: focus={} | effect={}", + proposal.focus.trim(), + proposal.intended_effect.trim() + ) +} + fn manual_apply_outcome_for_scope(scope: NoNameApplyScope) -> &'static str { match scope { NoNameApplyScope::PlotTextHint => "manual_plot_text_applied", + NoNameApplyScope::PlotAugmentationHint => "manual_plot_augmentation_hint_applied", NoNameApplyScope::ChapterSummaryHint => "manual_chapter_summary_hint_applied", NoNameApplyScope::OptionBiasHint => "manual_option_bias_hint_applied", NoNameApplyScope::Diagnostics => "manual_diagnostics_applied", @@ -371,24 +421,12 @@ fn apply_chapter_summary_hint( return Err("chapter summary already contains this NoName hint".to_string()); } - let summary = snapshot.expected_summary.trim(); - let updated_summary = if summary.is_empty() { - hint.clone() - } else { - match proposal.target_segment { - NoNameTargetSegment::ChapterSummaryHead => format!("{}; {}", hint, summary), - _ => format!("{}; {}", summary, hint), - } - }; - - plot_state.current_chapter.summary = updated_summary.clone(); - if let Some(history_chapter) = plot_state - .chapters - .iter_mut() - .find(|chapter| chapter.index == snapshot.chapter_index) - { - history_chapter.summary = updated_summary; - } + plot_state.apply_low_risk_chapter_summary_hint( + snapshot.chapter_index, + &snapshot.expected_summary, + &hint, + proposal.target_segment == NoNameTargetSegment::ChapterSummaryHead, + )?; let order = next_apply_plan_order(&trace); trace.record_apply_plan( @@ -432,34 +470,21 @@ fn apply_option_bias_hint( let Some(snapshot) = &request.diagnostics_snapshot else { return Err("option_bias_hint manual apply requires a diagnostics snapshot".to_string()); }; - if plot_state.current_chapter.index != snapshot.chapter_index { - return Err(format!( - "chapter mismatch: expected {}, current {}", - snapshot.chapter_index, plot_state.current_chapter.index - )); - } - if !plot_state.is_waiting_for_input { - return Err("option_bias_hint manual apply requires waiting-for-input state".to_string()); - } - let current_diagnostics = plot_state .last_generation_diagnostics .clone() .unwrap_or_default(); - if current_diagnostics != snapshot.expected_generation_diagnostics { - return Err("diagnostics snapshot mismatch; refusing stale manual apply".to_string()); - } let hint = build_option_bias_hint(proposal); if current_diagnostics.contains(&hint) { return Err("diagnostics already contains this NoName option bias hint".to_string()); } - plot_state.last_generation_diagnostics = Some(if current_diagnostics.trim().is_empty() { - hint.clone() - } else { - format!("{}; {}", current_diagnostics, hint) - }); + plot_state.apply_low_risk_generation_diagnostics_hint( + snapshot.chapter_index, + &snapshot.expected_generation_diagnostics, + &hint, + )?; let order = next_apply_plan_order(&trace); trace.record_apply_plan( @@ -494,6 +519,66 @@ fn apply_option_bias_hint( Ok(NoNameReviewedApplyOutcome { trace, plot_state }) } +fn apply_plot_augmentation_hint( + mut trace: NoNameTrace, + mut plot_state: PlotState, + request: &NoNameReviewedApplyRequest, + proposal: &NoNameProposal, +) -> Result { + let Some(snapshot) = &request.plot_augmentation_snapshot else { + return Err( + "plot_augmentation_hint manual apply requires a plot augmentation snapshot".to_string(), + ); + }; + + let hint = build_plot_augmentation_hint(proposal); + if snapshot + .expected_plot_augmentation_hints + .iter() + .any(|existing| existing == &hint) + { + return Err("plot augmentation already contains this NoName hint".to_string()); + } + + plot_state.apply_pending_plot_augmentation_hint( + snapshot.chapter_index, + &snapshot.expected_plot_augmentation_hints, + &hint, + )?; + + let order = next_apply_plan_order(&trace); + trace.record_apply_plan( + order, + request.scope.as_str(), + "manual_apply", + priority_for_apply_scope(request.scope) + 75, + Some(format!( + "manual apply confirmed for chapter={} scope={}", + snapshot.chapter_index, + request.scope.as_str() + )), + ); + trace.record_apply_execution( + request.scope.as_str(), + manual_apply_outcome_for_scope(request.scope), + Some(format!( + "manual plot augmentation hint staged for focus={}", + proposal.focus + )), + ); + trace.record_proposal_transition(format!( + "{}:manual_apply:plot_augmentation_hint", + request.request_id + )); + trace.set_apply_result( + true, + manual_apply_outcome_for_scope(request.scope), + Some("manual plot augmentation hint staged".to_string()), + ); + + Ok(NoNameReviewedApplyOutcome { trace, plot_state }) +} + pub fn apply_reviewed_output_to_plot_state( trace: NoNameTrace, request: NoNameReviewedApplyRequest, @@ -510,6 +595,9 @@ pub fn apply_reviewed_output_to_plot_state( NoNameApplyScope::OptionBiasHint => { apply_option_bias_hint(trace, plot_state, &request, &proposal) } + NoNameApplyScope::PlotAugmentationHint => { + apply_plot_augmentation_hint(trace, plot_state, &request, &proposal) + } scope => Err(format!( "manual reviewed apply currently does not support {}", scope.as_str() @@ -645,6 +733,50 @@ mod tests { (trace, request_id) } + fn make_trace_for_plot_augmentation_apply() -> (NoNameTrace, String) { + let proposal_id = "proposal-plot-augmentation".to_string(); + let request_id = + "controlled-output-proposal-plot-augmentation-plot_augmentation_hint".to_string(); + let mut trace = NoNameTrace::empty("trace-3", "session-1", "turn-3", NoNameMode::Assisted); + trace.record_proposal(NoNameProposal { + proposal_id: proposal_id.clone(), + kind: NoNameProposalKind::PlotCandidate, + producer_role: NoNameRole::Director, + title: "plot augmentation hint".to_string(), + summary: "suggest non-final plot augmentation".to_string(), + focus: "hidden cave".to_string(), + target_segment: NoNameTargetSegment::CurrentTurnTail, + intended_effect: "stage a reversible narrative beat".to_string(), + rationale: "keep plot augmentation outside final text".to_string(), + suggested_action: Some("manual apply".to_string()), + labels: vec!["test".to_string()], + apply_scopes: vec![NoNameApplyScope::PlotAugmentationHint], + status: NoNameProposalStatus::Applied, + applyable: true, + }); + trace.record_controlled_output_review( + Some(proposal_id.clone()), + NoNameControlledOutputKind::NonFinalPlotAugmentation, + Vec::new(), + NoNameControlledOutputReview { + request_id: request_id.clone(), + decision: NoNameControlledOutputDecision::NeedsReview, + reason: "plot augmentation hint requires human review".to_string(), + normalized_kind: Some(NoNameControlledOutputKind::NonFinalPlotAugmentation), + safe_apply_scope: Some(NoNameApplyScope::PlotAugmentationHint), + requires_human_review: true, + }, + ); + trace.controlled_output_reviews[0].human_review_decision = + Some(NoNameHumanReviewDecision::ApprovedForHigherApply); + trace.record_apply_execution( + NoNameApplyScope::PlotAugmentationHint.as_str(), + "second_guardrail_allowed", + None, + ); + (trace, request_id) + } + #[test] fn reviewed_apply_can_write_chapter_summary_hint() { let (trace, request_id) = make_trace_for_summary_apply(); @@ -661,6 +793,7 @@ mod tests { expected_summary: "existing summary".to_string(), }), diagnostics_snapshot: None, + plot_augmentation_snapshot: None, }, plot_state, ) @@ -698,6 +831,7 @@ mod tests { expected_summary: "older summary".to_string(), }), diagnostics_snapshot: None, + plot_augmentation_snapshot: None, }, plot_state, ) @@ -723,6 +857,7 @@ mod tests { chapter_index: 1, expected_generation_diagnostics: "base diagnostics".to_string(), }), + plot_augmentation_snapshot: None, }, plot_state, ) @@ -761,6 +896,7 @@ mod tests { chapter_index: 1, expected_generation_diagnostics: "older diagnostics".to_string(), }), + plot_augmentation_snapshot: None, }, plot_state, ) @@ -769,6 +905,71 @@ mod tests { assert!(error.contains("diagnostics snapshot mismatch")); } + #[test] + fn reviewed_apply_can_stage_plot_augmentation_hint() { + let (trace, request_id) = make_trace_for_plot_augmentation_apply(); + let mut plot_state = make_plot_state(""); + plot_state + .pending_plot_augmentation_hints + .push("existing hint".to_string()); + + let outcome = apply_reviewed_output_to_plot_state( + trace, + NoNameReviewedApplyRequest { + request_id, + scope: NoNameApplyScope::PlotAugmentationHint, + segment_snapshot: None, + summary_snapshot: None, + diagnostics_snapshot: None, + plot_augmentation_snapshot: Some(NoNameApplyPlotAugmentationSnapshot { + chapter_index: 1, + expected_plot_augmentation_hints: vec!["existing hint".to_string()], + }), + }, + plot_state, + ) + .expect("plot augmentation hint should be staged"); + + assert_eq!(outcome.plot_state.pending_plot_augmentation_hints.len(), 2); + assert!(outcome + .plot_state + .pending_plot_augmentation_hints + .iter() + .any(|hint| hint.contains("NoName plot augmentation: focus=hidden cave"))); + assert!(outcome.trace.apply_execution_log.iter().any(|entry| { + entry.target == "plot_augmentation_hint" + && entry.outcome == "manual_plot_augmentation_hint_applied" + })); + } + + #[test] + fn reviewed_apply_rejects_stale_plot_augmentation_snapshot() { + let (trace, request_id) = make_trace_for_plot_augmentation_apply(); + let mut plot_state = make_plot_state(""); + plot_state + .pending_plot_augmentation_hints + .push("new hint".to_string()); + + let error = apply_reviewed_output_to_plot_state( + trace, + NoNameReviewedApplyRequest { + request_id, + scope: NoNameApplyScope::PlotAugmentationHint, + segment_snapshot: None, + summary_snapshot: None, + diagnostics_snapshot: None, + plot_augmentation_snapshot: Some(NoNameApplyPlotAugmentationSnapshot { + chapter_index: 1, + expected_plot_augmentation_hints: vec!["old hint".to_string()], + }), + }, + plot_state, + ) + .expect_err("stale plot augmentation snapshot should be rejected"); + + assert!(error.contains("plot augmentation snapshot mismatch")); + } + #[test] fn reviewed_apply_rejects_when_reviewed_proposal_binding_is_missing() { let (mut trace, request_id) = make_trace_for_summary_apply(); @@ -786,6 +987,7 @@ mod tests { expected_summary: "existing summary".to_string(), }), diagnostics_snapshot: None, + plot_augmentation_snapshot: None, }, plot_state, ) diff --git a/src-tauri/src/noname_context_builder.rs b/src-tauri/src/noname_context_builder.rs index 2b0220f..d591384 100644 --- a/src-tauri/src/noname_context_builder.rs +++ b/src-tauri/src/noname_context_builder.rs @@ -2,9 +2,11 @@ use crate::entity_store::{EntityQuery, EntityStore}; use crate::entity_types::EntityType; use crate::noname_context_types::{ NoNameContextBuildInput, NoNameContextPacket, NoNameContextSourceStat, NoNameRoleContextPacket, + NoNameRoleContextSliceStat, }; use crate::noname_memory_manager::NoNameMemoryManager; use crate::noname_memory_retrieval::NoNameMemoryQuery; +use crate::noname_memory_types::{NoNameNarrativeMemoryItem, NoNameNarrativeNoteType}; use crate::noname_types::NoNameRole; pub fn build_context_packet( @@ -38,17 +40,18 @@ pub fn build_context_packet( .iter() .map(|item| item.summary.clone()) .collect::>(); - let mut narrative_notes = retrieved - .narrative + let active_notes = role_ranked_active_notes(memory_manager.active_notes(), input.role); + let mut narrative_notes = dedupe_ordered_strings( + active_notes + .iter() + .chain(retrieved.narrative.iter()) + .map(note_line) + .collect(), + ); + let mut chapter_summaries = active_notes .iter() - .map(|item| format!("{}: {}", item.title, item.summary)) - .collect::>(); - - let mut chapter_summaries = memory_manager - .active_notes() - .into_iter() .take(input.per_section_limit) - .map(|note| format!("{}: {}", note.title, note.summary)) + .map(note_line) .collect::>(); let mut recent_context = input @@ -154,6 +157,81 @@ pub fn build_context_packet( } } +fn note_line(note: &NoNameNarrativeMemoryItem) -> String { + format!("{}: {}", note.title, note.summary) +} + +fn role_ranked_active_notes( + mut notes: Vec, + role: NoNameRole, +) -> Vec { + notes.sort_by(|left, right| { + role_note_priority(role, left.note_type) + .cmp(&role_note_priority(role, right.note_type)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.note_id.cmp(&right.note_id)) + }); + notes +} + +fn role_note_priority(role: NoNameRole, note_type: NoNameNarrativeNoteType) -> u8 { + match role { + NoNameRole::Director => match note_type { + NoNameNarrativeNoteType::Conflict => 0, + NoNameNarrativeNoteType::Goal => 1, + NoNameNarrativeNoteType::UnresolvedThread => 2, + NoNameNarrativeNoteType::Foreshadowing => 3, + NoNameNarrativeNoteType::CharacterArc => 4, + }, + NoNameRole::WorldCurator => match note_type { + NoNameNarrativeNoteType::Goal => 0, + NoNameNarrativeNoteType::Foreshadowing => 1, + NoNameNarrativeNoteType::UnresolvedThread => 2, + NoNameNarrativeNoteType::Conflict => 3, + NoNameNarrativeNoteType::CharacterArc => 4, + }, + NoNameRole::NpcIntent => match note_type { + NoNameNarrativeNoteType::CharacterArc => 0, + NoNameNarrativeNoteType::Conflict => 1, + NoNameNarrativeNoteType::UnresolvedThread => 2, + NoNameNarrativeNoteType::Foreshadowing => 3, + NoNameNarrativeNoteType::Goal => 4, + }, + NoNameRole::CombatNarrator => match note_type { + NoNameNarrativeNoteType::Conflict => 0, + NoNameNarrativeNoteType::CharacterArc => 1, + NoNameNarrativeNoteType::Goal => 2, + NoNameNarrativeNoteType::UnresolvedThread => 3, + NoNameNarrativeNoteType::Foreshadowing => 4, + }, + NoNameRole::System => match note_type { + NoNameNarrativeNoteType::Goal => 0, + NoNameNarrativeNoteType::Conflict => 1, + NoNameNarrativeNoteType::Foreshadowing => 2, + NoNameNarrativeNoteType::UnresolvedThread => 3, + NoNameNarrativeNoteType::CharacterArc => 4, + }, + } +} + +fn build_role_note_hits(notes: &[NoNameNarrativeMemoryItem], limit: usize) -> Vec { + notes + .iter() + .take(limit) + .map(|note| format!("{}: {}", note_type_label(note.note_type), note.title)) + .collect() +} + +fn note_type_label(note_type: NoNameNarrativeNoteType) -> &'static str { + match note_type { + NoNameNarrativeNoteType::Goal => "goal", + NoNameNarrativeNoteType::Conflict => "conflict", + NoNameNarrativeNoteType::Foreshadowing => "foreshadowing", + NoNameNarrativeNoteType::UnresolvedThread => "unresolvedThread", + NoNameNarrativeNoteType::CharacterArc => "characterArc", + } +} + pub fn build_role_context_packet( store: &EntityStore, memory_manager: &NoNameMemoryManager, @@ -163,7 +241,10 @@ pub fn build_role_context_packet( let mut role_input = input.clone(); role_input.role = role; let packet = build_context_packet(store, memory_manager, &role_input); - specialize_context_packet(&packet) + let ranked_notes = role_ranked_active_notes(memory_manager.active_notes(), role); + let mut role_packet = specialize_context_packet(&packet); + role_packet.note_type_hits = build_role_note_hits(&ranked_notes, input.per_section_limit); + role_packet } pub fn build_role_context_packets( @@ -189,7 +270,35 @@ pub fn specialize_context_packet(packet: &NoNameContextPacket) -> NoNameRoleCont } } +pub fn flatten_role_context_packet(packet: &NoNameRoleContextPacket) -> NoNameContextPacket { + NoNameContextPacket { + role: packet.role, + hard_facts: packet.world_facts.clone(), + working_memory: packet.recent_signals.clone(), + episodic_memory: packet.recent_signals.clone(), + narrative_notes: packet.narrative_priorities.clone(), + chapter_summaries: packet.narrative_priorities.clone(), + recent_context: packet.recent_signals.clone(), + referenced_entities: packet.character_relationships.clone(), + compressed_summary: Some(format!( + "roleGoal: {}; sceneFocus: {}; noteTypeHits: {}; visibleConstraints: {}; forbiddenScopes: {}", + packet.role_goal, + packet.scene_focus, + packet.note_type_hits.join(" | "), + packet.visible_constraints.join(" | "), + packet.forbidden_scopes.join(" | ") + )), + token_budget_used: packet.token_budget_used, + source_stats: packet.source_stats.clone(), + } +} + fn director_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { + let world_facts = take_lines(&packet.hard_facts, 2); + let character_relationships = take_lines(&packet.referenced_entities, 4); + let narrative_priorities = + take_joined(&[&packet.narrative_notes, &packet.chapter_summaries], 6); + let recent_signals = take_joined(&[&packet.working_memory, &packet.recent_context], 4); NoNameRoleContextPacket { role: NoNameRole::Director, role_goal: "Select the safest narrative focus for the next beat.".to_string(), @@ -199,10 +308,11 @@ fn director_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { &packet.recent_context, &packet.hard_facts, ]), - world_facts: take_lines(&packet.hard_facts, 2), - character_relationships: take_lines(&packet.referenced_entities, 4), - narrative_priorities: take_joined(&[&packet.narrative_notes, &packet.chapter_summaries], 6), - recent_signals: take_joined(&[&packet.working_memory, &packet.recent_context], 4), + note_type_hits: Vec::new(), + world_facts: world_facts.clone(), + character_relationships: character_relationships.clone(), + narrative_priorities: narrative_priorities.clone(), + recent_signals: recent_signals.clone(), visible_constraints: vec![ "May propose low-risk narrative direction.".to_string(), "Should keep unresolved threads visible for later turns.".to_string(), @@ -211,12 +321,34 @@ fn director_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { "Must not directly rewrite final plot state.".to_string(), "Must not invent hard world canon without WorldCurator support.".to_string(), ], + context_slice_stats: vec![ + build_slice_stat("worldFacts", packet.hard_facts.len(), world_facts.len()), + build_slice_stat( + "characterRelationships", + packet.referenced_entities.len(), + character_relationships.len(), + ), + build_slice_stat( + "narrativePriorities", + packet.narrative_notes.len() + packet.chapter_summaries.len(), + narrative_priorities.len(), + ), + build_slice_stat( + "recentSignals", + packet.working_memory.len() + packet.recent_context.len(), + recent_signals.len(), + ), + ], source_stats: packet.source_stats.clone(), token_budget_used: packet.token_budget_used, } } fn world_curator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { + let world_facts = take_joined(&[&packet.hard_facts, &packet.chapter_summaries], 8); + let character_relationships = take_lines(&packet.referenced_entities, 3); + let narrative_priorities = take_lines(&packet.chapter_summaries, 3); + let recent_signals = take_lines(&packet.recent_context, 2); NoNameRoleContextPacket { role: NoNameRole::WorldCurator, role_goal: "Maintain world facts, scene constraints, and canon anchors.".to_string(), @@ -226,10 +358,11 @@ fn world_curator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacke &packet.referenced_entities, &packet.recent_context, ]), - world_facts: take_joined(&[&packet.hard_facts, &packet.chapter_summaries], 8), - character_relationships: take_lines(&packet.referenced_entities, 3), - narrative_priorities: take_lines(&packet.chapter_summaries, 3), - recent_signals: take_lines(&packet.recent_context, 2), + note_type_hits: Vec::new(), + world_facts: world_facts.clone(), + character_relationships: character_relationships.clone(), + narrative_priorities: narrative_priorities.clone(), + recent_signals: recent_signals.clone(), visible_constraints: vec![ "May clarify setting rules and location constraints.".to_string(), "Should preserve established facts over dramatic convenience.".to_string(), @@ -238,12 +371,39 @@ fn world_curator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacke "Must not decide NPC private intent.".to_string(), "Must not choose the main plot beat alone.".to_string(), ], + context_slice_stats: vec![ + build_slice_stat( + "worldFacts", + packet.hard_facts.len() + packet.chapter_summaries.len(), + world_facts.len(), + ), + build_slice_stat( + "characterRelationships", + packet.referenced_entities.len(), + character_relationships.len(), + ), + build_slice_stat( + "narrativePriorities", + packet.chapter_summaries.len(), + narrative_priorities.len(), + ), + build_slice_stat( + "recentSignals", + packet.recent_context.len(), + recent_signals.len(), + ), + ], source_stats: packet.source_stats.clone(), token_budget_used: packet.token_budget_used, } } fn npc_intent_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { + let world_facts = take_lines(&packet.hard_facts, 2); + let character_relationships = + take_joined(&[&packet.referenced_entities, &packet.narrative_notes], 6); + let narrative_priorities = take_lines(&packet.narrative_notes, 4); + let recent_signals = take_joined(&[&packet.episodic_memory, &packet.recent_context], 5); NoNameRoleContextPacket { role: NoNameRole::NpcIntent, role_goal: "Infer NPC motivation, stance changes, and relationship pressure.".to_string(), @@ -253,13 +413,11 @@ fn npc_intent_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { &packet.narrative_notes, &packet.recent_context, ]), - world_facts: take_lines(&packet.hard_facts, 2), - character_relationships: take_joined( - &[&packet.referenced_entities, &packet.narrative_notes], - 6, - ), - narrative_priorities: take_lines(&packet.narrative_notes, 4), - recent_signals: take_joined(&[&packet.episodic_memory, &packet.recent_context], 5), + note_type_hits: Vec::new(), + world_facts: world_facts.clone(), + character_relationships: character_relationships.clone(), + narrative_priorities: narrative_priorities.clone(), + recent_signals: recent_signals.clone(), visible_constraints: vec![ "May infer motivation only from visible context.".to_string(), "Should keep uncertainty explicit when evidence is thin.".to_string(), @@ -268,12 +426,34 @@ fn npc_intent_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { "Must not reveal hidden knowledge not present in context.".to_string(), "Must not override world facts or combat outcomes.".to_string(), ], + context_slice_stats: vec![ + build_slice_stat("worldFacts", packet.hard_facts.len(), world_facts.len()), + build_slice_stat( + "characterRelationships", + packet.referenced_entities.len() + packet.narrative_notes.len(), + character_relationships.len(), + ), + build_slice_stat( + "narrativePriorities", + packet.narrative_notes.len(), + narrative_priorities.len(), + ), + build_slice_stat( + "recentSignals", + packet.episodic_memory.len() + packet.recent_context.len(), + recent_signals.len(), + ), + ], source_stats: packet.source_stats.clone(), token_budget_used: packet.token_budget_used, } } fn combat_narrator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { + let world_facts = take_lines(&packet.hard_facts, 3); + let character_relationships = take_lines(&packet.referenced_entities, 3); + let narrative_priorities = take_lines(&packet.narrative_notes, 3); + let recent_signals = take_joined(&[&packet.recent_context, &packet.episodic_memory], 6); NoNameRoleContextPacket { role: NoNameRole::CombatNarrator, role_goal: "Track conflict rhythm, action feedback, and combat narration anchors." @@ -284,10 +464,11 @@ fn combat_narrator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPac &packet.working_memory, &packet.narrative_notes, ]), - world_facts: take_lines(&packet.hard_facts, 3), - character_relationships: take_lines(&packet.referenced_entities, 3), - narrative_priorities: take_lines(&packet.narrative_notes, 3), - recent_signals: take_joined(&[&packet.recent_context, &packet.episodic_memory], 6), + note_type_hits: Vec::new(), + world_facts: world_facts.clone(), + character_relationships: character_relationships.clone(), + narrative_priorities: narrative_priorities.clone(), + recent_signals: recent_signals.clone(), visible_constraints: vec![ "May describe action consequences as low-risk narration anchors.".to_string(), "Should preserve current combat constraints.".to_string(), @@ -296,27 +477,64 @@ fn combat_narrator_context(packet: &NoNameContextPacket) -> NoNameRoleContextPac "Must not determine final damage or victory state.".to_string(), "Must not invent new combat rules.".to_string(), ], + context_slice_stats: vec![ + build_slice_stat("worldFacts", packet.hard_facts.len(), world_facts.len()), + build_slice_stat( + "characterRelationships", + packet.referenced_entities.len(), + character_relationships.len(), + ), + build_slice_stat( + "narrativePriorities", + packet.narrative_notes.len(), + narrative_priorities.len(), + ), + build_slice_stat( + "recentSignals", + packet.recent_context.len() + packet.episodic_memory.len(), + recent_signals.len(), + ), + ], source_stats: packet.source_stats.clone(), token_budget_used: packet.token_budget_used, } } fn system_context(packet: &NoNameContextPacket) -> NoNameRoleContextPacket { + let recent_signals = take_joined(&[&packet.working_memory, &packet.recent_context], 4); NoNameRoleContextPacket { role: NoNameRole::System, role_goal: "Provide diagnostics without narrative authority.".to_string(), scene_focus: first_of(&[&packet.recent_context, &packet.working_memory]), + note_type_hits: Vec::new(), world_facts: Vec::new(), character_relationships: Vec::new(), narrative_priorities: Vec::new(), - recent_signals: take_joined(&[&packet.working_memory, &packet.recent_context], 4), + recent_signals: recent_signals.clone(), visible_constraints: vec!["May inspect available context shape.".to_string()], forbidden_scopes: vec!["Must not author narrative content.".to_string()], + context_slice_stats: vec![build_slice_stat( + "recentSignals", + packet.working_memory.len() + packet.recent_context.len(), + recent_signals.len(), + )], source_stats: packet.source_stats.clone(), token_budget_used: packet.token_budget_used, } } +fn build_slice_stat( + section: &str, + source_count: usize, + visible_count: usize, +) -> NoNameRoleContextSliceStat { + NoNameRoleContextSliceStat { + section: section.to_string(), + source_count, + visible_count, + } +} + fn trim_to_budget(items: &mut Vec, remain_budget: usize, used: &mut usize) { let mut current = 0usize; items.retain(|line| { @@ -381,6 +599,16 @@ fn take_joined(groups: &[&Vec], limit: usize) -> Vec { values } +fn dedupe_ordered_strings(values: Vec) -> Vec { + let mut deduped = Vec::new(); + for value in values { + if !deduped.iter().any(|existing| existing == &value) { + deduped.push(value); + } + } + deduped +} + #[cfg(test)] mod tests { use super::*; @@ -571,6 +799,15 @@ mod tests { assert_eq!(packets[2].role, NoNameRole::NpcIntent); assert_ne!(packets[0].role_goal, packets[1].role_goal); assert_ne!(packets[1].forbidden_scopes, packets[2].forbidden_scopes); + assert!(packets[0] + .context_slice_stats + .iter() + .any(|item| item.section == "narrativePriorities" + && item.source_count >= item.visible_count)); + assert!(packets[1] + .context_slice_stats + .iter() + .any(|item| item.section == "worldFacts" && item.source_count >= item.visible_count)); assert!(packets[1] .world_facts .iter() @@ -581,6 +818,57 @@ mod tests { .any(|item| item.contains("Elder Qinghe"))); } + #[test] + fn role_context_prioritizes_structured_note_types_per_role() { + let store = EntityStore::new(); + let mut manager = NoNameMemoryManager::new(); + manager.upsert_narrative_memory(test_note( + "goal-1", + crate::noname_memory_types::NoNameNarrativeNoteType::Goal, + "Hold Gate", + "Keep the mountain gate secure.", + 1, + )); + manager.upsert_narrative_memory(test_note( + "conflict-1", + crate::noname_memory_types::NoNameNarrativeNoteType::Conflict, + "Gate Assault", + "Enemy cultivators are pressing the ward line.", + 2, + )); + manager.upsert_narrative_memory(test_note( + "arc-1", + crate::noname_memory_types::NoNameNarrativeNoteType::CharacterArc, + "Elder Qinghe Doubt", + "Elder Qinghe is hesitating under pressure.", + 3, + )); + + let input = NoNameContextBuildInput { + role: NoNameRole::Director, + world_id: "w1".to_string(), + run_id: "r1".to_string(), + scene_id: "s1".to_string(), + character_ids: vec!["player".to_string()], + map_node_id: None, + player_intent: None, + recent_context_lines: vec!["The ward line flickers.".to_string()], + token_budget: 240, + per_section_limit: 3, + }; + + let director = build_role_context_packet(&store, &manager, &input, NoNameRole::Director); + let npc = build_role_context_packet(&store, &manager, &input, NoNameRole::NpcIntent); + let world = build_role_context_packet(&store, &manager, &input, NoNameRole::WorldCurator); + + assert!(director.narrative_priorities[0].contains("Gate Assault")); + assert!(npc.narrative_priorities[0].contains("Elder Qinghe Doubt")); + assert!(world.narrative_priorities[0].contains("Hold Gate")); + assert_eq!(director.note_type_hits[0], "conflict: Gate Assault"); + assert_eq!(npc.note_type_hits[0], "characterArc: Elder Qinghe Doubt"); + assert_eq!(world.note_type_hits[0], "goal: Hold Gate"); + } + #[test] fn role_context_specialization_limits_world_curator_npc_visibility_differently() { let packet = NoNameContextPacket { @@ -616,4 +904,64 @@ mod tests { .iter() .any(|item| item.contains("hidden knowledge"))); } + + #[test] + fn role_context_can_flatten_back_to_agent_context_packet() { + let packet = NoNameContextPacket { + role: NoNameRole::NpcIntent, + hard_facts: vec!["Gate has a ward".to_string()], + working_memory: vec!["Player suspects Elder Qinghe".to_string()], + episodic_memory: vec!["Elder Qinghe hesitated".to_string()], + narrative_notes: vec!["Broken Ward: saboteur unknown".to_string()], + chapter_summaries: vec!["Gate Crisis: ward damaged".to_string()], + recent_context: vec!["Ward flickers".to_string()], + referenced_entities: vec![ + "Character:player".to_string(), + "Character:ElderQinghe".to_string(), + ], + compressed_summary: None, + token_budget_used: 42, + source_stats: vec![NoNameContextSourceStat { + source: "test".to_string(), + count: 1, + }], + }; + + let role_packet = specialize_context_packet(&packet); + let flattened = flatten_role_context_packet(&role_packet); + + assert_eq!(flattened.role, NoNameRole::NpcIntent); + assert_eq!(flattened.hard_facts, role_packet.world_facts); + assert_eq!(flattened.narrative_notes, role_packet.narrative_priorities); + assert_eq!(flattened.recent_context, role_packet.recent_signals); + assert!(flattened + .referenced_entities + .iter() + .any(|item| item.contains("ElderQinghe"))); + assert!(flattened + .compressed_summary + .as_deref() + .unwrap_or_default() + .contains("roleGoal")); + } + + fn test_note( + note_id: &str, + note_type: crate::noname_memory_types::NoNameNarrativeNoteType, + title: &str, + summary: &str, + updated_at: u64, + ) -> crate::noname_memory_types::NoNameNarrativeMemoryItem { + crate::noname_memory_types::NoNameNarrativeMemoryItem { + note_id: note_id.to_string(), + chapter_index: 1, + arc_id: None, + note_type, + title: title.to_string(), + summary: summary.to_string(), + status: crate::noname_memory_types::NoNameNarrativeStatus::Active, + related_entities: vec![], + updated_at, + } + } } diff --git a/src-tauri/src/noname_context_types.rs b/src-tauri/src/noname_context_types.rs index 6bd3576..8a9366b 100644 --- a/src-tauri/src/noname_context_types.rs +++ b/src-tauri/src/noname_context_types.rs @@ -8,6 +8,14 @@ pub struct NoNameContextSourceStat { pub count: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoNameRoleContextSliceStat { + pub section: String, + pub source_count: usize, + pub visible_count: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NoNameContextPacket { @@ -30,12 +38,16 @@ pub struct NoNameRoleContextPacket { pub role: NoNameRole, pub role_goal: String, pub scene_focus: String, + #[serde(default)] + pub note_type_hits: Vec, pub world_facts: Vec, pub character_relationships: Vec, pub narrative_priorities: Vec, pub recent_signals: Vec, pub visible_constraints: Vec, pub forbidden_scopes: Vec, + #[serde(default)] + pub context_slice_stats: Vec, pub source_stats: Vec, pub token_budget_used: usize, } diff --git a/src-tauri/src/noname_guardrails.rs b/src-tauri/src/noname_guardrails.rs index 26e680e..b9cd3ad 100644 --- a/src-tauri/src/noname_guardrails.rs +++ b/src-tauri/src/noname_guardrails.rs @@ -359,16 +359,18 @@ pub fn validate_director_proposal_for_apply( if matches!( proposal.target_segment, NoNameTargetSegment::ChapterSummaryHead | NoNameTargetSegment::ChapterSummaryTail - ) && proposal - .apply_scopes - .contains(&NoNameApplyScope::PlotTextHint) - { + ) && proposal.apply_scopes.iter().any(|scope| { + matches!( + scope, + NoNameApplyScope::PlotTextHint | NoNameApplyScope::PlotAugmentationHint + ) + }) { return NoNameApplyGuardrailResult::blocked( NoNameApplyGuardrailOutcome::FallbackRequired, "proposal target_segment 与 apply_scope 不匹配,回退经典链路", vec![ format!("target_segment={}", proposal.target_segment.as_str()), - "apply_scope=plot_text_hint".to_string(), + "apply_scope=plot_text_or_augmentation_hint".to_string(), ], ); } @@ -468,6 +470,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), } } @@ -516,6 +519,13 @@ mod tests { focus: " ".to_string(), rationale: "保持山门线索推进".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: make_director_proposal(" ", "保持山门线索推进"), }; @@ -531,6 +541,13 @@ mod tests { focus: "山门危机".repeat(13), rationale: "优先观察山门内部的冲突升级与人物反应".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: make_director_proposal( &"山门危机".repeat(13), "优先观察山门内部的冲突升级与人物反应", @@ -638,7 +655,7 @@ mod tests { assert!(result .details .iter() - .any(|item| item == "apply_scope=plot_text_hint")); + .any(|item| item == "apply_scope=plot_text_or_augmentation_hint")); } #[test] diff --git a/src-tauri/src/noname_output_interface.rs b/src-tauri/src/noname_output_interface.rs index 0259ca0..6fdbf74 100644 --- a/src-tauri/src/noname_output_interface.rs +++ b/src-tauri/src/noname_output_interface.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; pub enum NoNameControlledOutputKind { RecapNote, SceneAugmentation, + NonFinalPlotAugmentation, NarrativeNote, IntermediateNarrativeHint, } @@ -16,6 +17,7 @@ impl NoNameControlledOutputKind { match self { Self::RecapNote => "recap_note", Self::SceneAugmentation => "scene_augmentation", + Self::NonFinalPlotAugmentation => "non_final_plot_augmentation", Self::NarrativeNote => "narrative_note", Self::IntermediateNarrativeHint => "intermediate_narrative_hint", } @@ -73,6 +75,7 @@ impl Default for NoNameControlledOutputPolicy { allowed_kinds: vec![ NoNameControlledOutputKind::RecapNote, NoNameControlledOutputKind::SceneAugmentation, + NoNameControlledOutputKind::NonFinalPlotAugmentation, NoNameControlledOutputKind::NarrativeNote, NoNameControlledOutputKind::IntermediateNarrativeHint, ], @@ -176,14 +179,18 @@ impl NoNameControlledOutputInterface { ); } - if request.target_scope == NoNameApplyScope::PlotTextHint - && self.policy.requires_human_review_for_plot_text + if matches!( + request.target_scope, + NoNameApplyScope::PlotTextHint | NoNameApplyScope::PlotAugmentationHint + ) && self.policy.requires_human_review_for_plot_text { return NoNameControlledOutputReview { request_id: request.request_id.clone(), decision: NoNameControlledOutputDecision::NeedsReview, - reason: "plot text hint requires human review before higher-layer apply" - .to_string(), + reason: format!( + "{} requires human review before higher-layer apply", + request.target_scope.as_str() + ), normalized_kind: Some(request.kind), safe_apply_scope: Some(request.target_scope), requires_human_review: true, @@ -327,6 +334,9 @@ pub fn default_apply_scope_for_kind(kind: NoNameControlledOutputKind) -> NoNameA match kind { NoNameControlledOutputKind::RecapNote => NoNameApplyScope::ChapterSummaryHint, NoNameControlledOutputKind::SceneAugmentation => NoNameApplyScope::PlotTextHint, + NoNameControlledOutputKind::NonFinalPlotAugmentation => { + NoNameApplyScope::PlotAugmentationHint + } NoNameControlledOutputKind::NarrativeNote => NoNameApplyScope::Diagnostics, NoNameControlledOutputKind::IntermediateNarrativeHint => NoNameApplyScope::OptionBiasHint, } @@ -346,6 +356,9 @@ mod tests { assert!(policy .allowed_kinds .contains(&NoNameControlledOutputKind::IntermediateNarrativeHint)); + assert!(policy + .allowed_kinds + .contains(&NoNameControlledOutputKind::NonFinalPlotAugmentation)); assert!(policy .forbidden_scopes .contains(&NoNameForbiddenOutputScope::FinalPlotState)); @@ -414,12 +427,33 @@ mod tests { assert!(review.requires_human_review); } + #[test] + fn non_final_plot_augmentation_requires_review_without_final_state() { + let interface = NoNameControlledOutputInterface::default(); + let request = interface.draft_stub( + NoNameControlledOutputKind::NonFinalPlotAugmentation, + NoNameRole::Director, + "Staged scene beat", + "Stage a reversible clue for the next turn without rewriting final plot state.", + ); + + let review = interface.review(&request); + + assert_eq!(review.decision, NoNameControlledOutputDecision::NeedsReview); + assert_eq!( + review.safe_apply_scope, + Some(NoNameApplyScope::PlotAugmentationHint) + ); + assert!(review.requires_human_review); + } + #[test] fn role_context_forbidden_scopes_map_to_output_policy() { let context = NoNameRoleContextPacket { role: NoNameRole::CombatNarrator, role_goal: "Track conflict rhythm".to_string(), scene_focus: "山门冲突".to_string(), + note_type_hits: vec![], world_facts: vec![], character_relationships: vec![], narrative_priorities: vec![], @@ -429,6 +463,7 @@ mod tests { "Must not determine final damage or victory state.".to_string(), "Must not override world facts or combat outcomes.".to_string(), ], + context_slice_stats: vec![], source_stats: vec![], token_budget_used: 0, }; diff --git a/src-tauri/src/noname_prompts.rs b/src-tauri/src/noname_prompts.rs index aacc5a9..605232c 100644 --- a/src-tauri/src/noname_prompts.rs +++ b/src-tauri/src/noname_prompts.rs @@ -8,11 +8,13 @@ pub const COMBAT_NARRATOR_OBSERVE_PROMPT_ID: &str = "prompt.combat_narrator.obse pub fn director_observe_prompt_template() -> NoNamePromptTemplate { NoNamePromptTemplate { prompt_id: DIRECTOR_OBSERVE_PROMPT_ID.to_string(), - template: "你是Nobody的DirectorAgent。当前目标={{goal}};玩家动作={{action}};当前场景={{scene}};请给出下一步最值得推进的冲突或关注点。".to_string(), + template: "你是Nobody的DirectorAgent。当前目标={{goal}};角色目标={{roleGoal}};玩家动作={{action}};当前场景={{scene}};禁止越权={{forbiddenScopes}};请给出下一步最值得推进的冲突或关注点。".to_string(), required_variables: vec![ "goal".to_string(), + "roleGoal".to_string(), "action".to_string(), "scene".to_string(), + "forbiddenScopes".to_string(), ], } } @@ -20,11 +22,13 @@ pub fn director_observe_prompt_template() -> NoNamePromptTemplate { pub fn world_curator_observe_prompt_template() -> NoNamePromptTemplate { NoNamePromptTemplate { prompt_id: WORLD_CURATOR_OBSERVE_PROMPT_ID.to_string(), - template: "你是Nobody的WorldCuratorAgent。当前目标={{goal}};玩家动作={{action}};当前场景={{scene}};请指出最需要补足或校准的世界事实、场景约束或设定缝隙。".to_string(), + template: "你是Nobody的WorldCuratorAgent。当前目标={{goal}};角色目标={{roleGoal}};玩家动作={{action}};当前场景={{scene}};禁止越权={{forbiddenScopes}};请指出最需要补足或校准的世界事实、场景约束或设定缝隙。".to_string(), required_variables: vec![ "goal".to_string(), + "roleGoal".to_string(), "action".to_string(), "scene".to_string(), + "forbiddenScopes".to_string(), ], } } @@ -32,11 +36,13 @@ pub fn world_curator_observe_prompt_template() -> NoNamePromptTemplate { pub fn npc_intent_observe_prompt_template() -> NoNamePromptTemplate { NoNamePromptTemplate { prompt_id: NPC_INTENT_OBSERVE_PROMPT_ID.to_string(), - template: "你是Nobody的NpcIntentAgent。当前目标={{goal}};玩家动作={{action}};当前场景={{scene}};请判断最值得关注的NPC意图、关系变化或反应方向。".to_string(), + template: "你是Nobody的NpcIntentAgent。当前目标={{goal}};角色目标={{roleGoal}};玩家动作={{action}};当前场景={{scene}};禁止越权={{forbiddenScopes}};请判断最值得关注的NPC意图、关系变化或反应方向。".to_string(), required_variables: vec![ "goal".to_string(), + "roleGoal".to_string(), "action".to_string(), "scene".to_string(), + "forbiddenScopes".to_string(), ], } } @@ -44,11 +50,13 @@ pub fn npc_intent_observe_prompt_template() -> NoNamePromptTemplate { pub fn combat_narrator_observe_prompt_template() -> NoNamePromptTemplate { NoNamePromptTemplate { prompt_id: COMBAT_NARRATOR_OBSERVE_PROMPT_ID.to_string(), - template: "你是Nobody的CombatNarratorAgent。当前目标={{goal}};玩家动作={{action}};当前场景={{scene}};请判断当前冲突节奏、战斗风险或动作表现上最值得强化的一点。".to_string(), + template: "你是Nobody的CombatNarratorAgent。当前目标={{goal}};角色目标={{roleGoal}};玩家动作={{action}};当前场景={{scene}};禁止越权={{forbiddenScopes}};请判断当前冲突节奏、战斗风险或动作表现上最值得强化的一点。".to_string(), required_variables: vec![ "goal".to_string(), + "roleGoal".to_string(), "action".to_string(), "scene".to_string(), + "forbiddenScopes".to_string(), ], } } @@ -61,11 +69,17 @@ mod tests { fn director_prompt_contains_required_variables() { let template = director_observe_prompt_template(); assert_eq!(template.prompt_id, DIRECTOR_OBSERVE_PROMPT_ID); - assert_eq!(template.required_variables.len(), 3); + assert_eq!(template.required_variables.len(), 5); + assert!(template + .required_variables + .contains(&"roleGoal".to_string())); + assert!(template + .required_variables + .contains(&"forbiddenScopes".to_string())); } #[test] - fn multi_role_prompts_keep_three_shared_inputs() { + fn multi_role_prompts_keep_role_boundary_inputs() { let templates = [ world_curator_observe_prompt_template(), npc_intent_observe_prompt_template(), @@ -73,8 +87,10 @@ mod tests { ]; for template in templates { - assert_eq!(template.required_variables.len(), 3); + assert_eq!(template.required_variables.len(), 5); assert!(template.template.contains("当前目标")); + assert!(template.template.contains("角色目标")); + assert!(template.template.contains("禁止越权")); } } } diff --git a/src-tauri/src/noname_roles.rs b/src-tauri/src/noname_roles.rs index b1571d6..6a8c20d 100644 --- a/src-tauri/src/noname_roles.rs +++ b/src-tauri/src/noname_roles.rs @@ -1,5 +1,7 @@ use crate::noname_capability_registry::NoNameCapabilityRegistry; -use crate::noname_context_types::NoNameContextPacket; +use crate::noname_context_types::{ + NoNameContextPacket, NoNameContextSourceStat, NoNameRoleContextSliceStat, +}; use crate::noname_errors::{NoNameError, NoNameErrorKind}; use crate::noname_prompts::{ COMBAT_NARRATOR_OBSERVE_PROMPT_ID, DIRECTOR_OBSERVE_PROMPT_ID, NPC_INTENT_OBSERVE_PROMPT_ID, @@ -30,6 +32,20 @@ pub struct NoNameRoleObservation { pub focus: String, pub rationale: String, pub prompt_preview: String, + #[serde(default)] + pub role_goal: Option, + #[serde(default)] + pub scene_focus: Option, + #[serde(default)] + pub forbidden_scopes: Vec, + #[serde(default)] + pub note_type_hits: Vec, + #[serde(default)] + pub source_stats: Vec, + #[serde(default)] + pub context_token_budget_used: Option, + #[serde(default)] + pub context_slice_stats: Vec, pub proposal: NoNameProposal, } @@ -103,6 +119,7 @@ impl DirectorAgent { NoNameApplyScope::Diagnostics, NoNameApplyScope::ChapterSummaryHint, NoNameApplyScope::OptionBiasHint, + NoNameApplyScope::PlotAugmentationHint, NoNameApplyScope::PlotTextHint, ], status: NoNameProposalStatus::Observed, @@ -117,6 +134,13 @@ impl DirectorAgent { focus, rationale, prompt_preview: artifacts.prompt_preview, + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal, }) } @@ -205,6 +229,13 @@ impl WorldCuratorAgent { focus, rationale, prompt_preview: artifacts.prompt_preview, + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal, }) } @@ -279,6 +310,7 @@ impl NpcIntentAgent { apply_scopes: vec![ NoNameApplyScope::Diagnostics, NoNameApplyScope::OptionBiasHint, + NoNameApplyScope::PlotAugmentationHint, NoNameApplyScope::PlotTextHint, ], status: NoNameProposalStatus::Observed, @@ -293,6 +325,13 @@ impl NpcIntentAgent { focus, rationale, prompt_preview: artifacts.prompt_preview, + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal, }) } @@ -360,6 +399,7 @@ impl CombatNarratorAgent { ], apply_scopes: vec![ NoNameApplyScope::Diagnostics, + NoNameApplyScope::PlotAugmentationHint, NoNameApplyScope::PlotTextHint, ], status: NoNameProposalStatus::Observed, @@ -374,6 +414,13 @@ impl CombatNarratorAgent { focus, rationale, prompt_preview: artifacts.prompt_preview, + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal, }) } @@ -425,8 +472,16 @@ fn run_observe_capability_pipeline( let mut variables = BTreeMap::new(); variables.insert("goal".to_string(), goal); + variables.insert( + "roleGoal".to_string(), + role_goal_from_context(context_packet), + ); variables.insert("action".to_string(), action_summary.to_string()); variables.insert("scene".to_string(), scene_from_context(context_packet)); + variables.insert( + "forbiddenScopes".to_string(), + forbidden_scopes_from_context(context_packet), + ); let prompt_resolve = NoNamePromptResolve { header: header.clone(), @@ -442,6 +497,8 @@ fn run_observe_capability_pipeline( args: json!({ "actionSummary": action_summary, "contextTokenBudgetUsed": context_packet.token_budget_used, + "roleGoal": role_goal_from_context(context_packet), + "forbiddenScopes": forbidden_scopes_from_context(context_packet), }), }; let tool_result = registry.invoke_tool(&tool_call)?; @@ -461,6 +518,31 @@ fn scene_from_context(context_packet: &NoNameContextPacket) -> String { .unwrap_or_else(|| "当前场景".to_string()) } +fn role_goal_from_context(context_packet: &NoNameContextPacket) -> String { + metadata_value_from_context(context_packet, "roleGoal").unwrap_or_else(|| { + "Follow the current role responsibility without expanding authority.".to_string() + }) +} + +fn forbidden_scopes_from_context(context_packet: &NoNameContextPacket) -> String { + metadata_value_from_context(context_packet, "forbiddenScopes") + .unwrap_or_else(|| "Must not directly mutate final plot state.".to_string()) +} + +fn metadata_value_from_context(context_packet: &NoNameContextPacket, key: &str) -> Option { + let summary = context_packet.compressed_summary.as_ref()?; + let marker = format!("{key}: "); + let start = summary.find(&marker)? + marker.len(); + let tail = &summary[start..]; + let end = tail.find(';').unwrap_or(tail.len()); + let value = tail[..end].trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + fn read_tool_field<'a>(content: &'a Value, field: &str, fallback: &'a str) -> &'a str { content .get(field) @@ -496,6 +578,7 @@ pub fn unsupported_role_error(role: NoNameRole) -> NoNameError { #[cfg(test)] mod tests { use super::*; + use crate::noname_context_builder::{flatten_role_context_packet, specialize_context_packet}; use crate::noname_context_types::{NoNameContextPacket, NoNameContextSourceStat}; use crate::noname_tools::{ build_combat_narrator_registry, build_director_registry, build_npc_intent_registry, @@ -589,6 +672,28 @@ mod tests { assert_eq!(trace.capability_calls.len(), 9); } + #[test] + fn role_prompt_reads_role_goal_and_forbidden_scopes_from_context() { + let mut base_packet = sample_packet(NoNameRole::WorldCurator); + base_packet.role = NoNameRole::WorldCurator; + let role_packet = specialize_context_packet(&base_packet); + let flattened = flatten_role_context_packet(&role_packet); + let registry = build_world_curator_registry(&flattened); + let mut trace = + NoNameTrace::empty("trace-3", "session-3", "turn-3", NoNameMode::ObserveOnly); + + let observation = WorldCuratorAgent::new() + .observe_turn(&mut trace, ®istry, &flattened, "检查山门法阵") + .expect("world curator observe should resolve role prompt"); + + assert!(observation.prompt_preview.contains("角色目标")); + assert!(observation.prompt_preview.contains(&role_packet.role_goal)); + assert!(observation.prompt_preview.contains("禁止越权")); + assert!(observation + .prompt_preview + .contains(&role_packet.forbidden_scopes[0])); + } + #[test] fn unsupported_role_error_uses_stable_code() { let err = unsupported_role_error(NoNameRole::System); diff --git a/src-tauri/src/noname_runtime.rs b/src-tauri/src/noname_runtime.rs index d5e618d..a36dd79 100644 --- a/src-tauri/src/noname_runtime.rs +++ b/src-tauri/src/noname_runtime.rs @@ -145,6 +145,13 @@ impl NoNameRuntime { action_summary: observation.action_summary.clone(), focus: observation.focus.clone(), rationale: observation.rationale.clone(), + role_goal: observation.role_goal.clone(), + scene_focus: observation.scene_focus.clone(), + forbidden_scopes: observation.forbidden_scopes.clone(), + note_type_hits: observation.note_type_hits.clone(), + source_stats: observation.source_stats.clone(), + context_token_budget_used: observation.context_token_budget_used, + context_slice_stats: observation.context_slice_stats.clone(), proposal: observation.proposal.clone(), }) .collect(), @@ -563,6 +570,10 @@ impl NoNameRuntime { .submit_agent_message(task_request.clone())?; task_request.record_on_trace(trace, queued.status.as_str()); + let mut base_context = context_packet.clone(); + base_context.role = role; + let role_context_packet = specialize_context_packet(&base_context); + let delegation = NoNameAgentMessage::new( header.clone(), orchestrator.clone(), @@ -572,6 +583,9 @@ impl NoNameRuntime { serde_json::json!({ "goal": action_summary, "targetRole": role.as_str(), + "roleGoal": &role_context_packet.role_goal, + "sceneFocus": &role_context_packet.scene_focus, + "forbiddenScopes": &role_context_packet.forbidden_scopes, }), ); let running = self @@ -579,8 +593,6 @@ impl NoNameRuntime { .submit_agent_message(delegation.clone())?; delegation.record_on_trace(trace, running.status.as_str()); - let mut role_context = context_packet.clone(); - role_context.role = role; let mut role_trace = NoNameTrace::empty( trace.trace_id.clone(), trace.session_id.clone(), @@ -588,10 +600,9 @@ impl NoNameRuntime { trace.mode, ); - match self.agent_registry.dispatch_observe_turn( - role, + match self.agent_registry.dispatch_role_context_observe_turn( &mut role_trace, - &role_context, + &role_context_packet, action_summary, ) { Ok(observation) => { @@ -656,6 +667,9 @@ fn controlled_output_kind_for_scope(scope: NoNameApplyScope) -> NoNameControlled NoNameApplyScope::Diagnostics => NoNameControlledOutputKind::NarrativeNote, NoNameApplyScope::ChapterSummaryHint => NoNameControlledOutputKind::RecapNote, NoNameApplyScope::OptionBiasHint => NoNameControlledOutputKind::IntermediateNarrativeHint, + NoNameApplyScope::PlotAugmentationHint => { + NoNameControlledOutputKind::NonFinalPlotAugmentation + } NoNameApplyScope::PlotTextHint => NoNameControlledOutputKind::SceneAugmentation, } } @@ -794,6 +808,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }, action_result: ActionResult { success: true, @@ -815,6 +830,25 @@ mod tests { ); assert_eq!(result.related_observations.len(), 3); assert_eq!(result.trace.related_observations.len(), 3); + assert!(result.trace.related_observations.iter().any(|item| { + item.role == NoNameRole::WorldCurator + && item + .role_goal + .as_deref() + .unwrap_or_default() + .contains("Maintain world facts") + && item + .scene_focus + .as_deref() + .unwrap_or_default() + .contains("玩家 位于 山门") + && item + .forbidden_scopes + .iter() + .any(|scope| scope.contains("NPC private intent")) + && item.source_stats.iter().any(|source| source.count > 0) + && item.context_token_budget_used.unwrap_or_default() > 0 + })); assert_eq!(result.trace.protocol_events.len(), 9); assert_eq!(result.trace.capability_calls.len(), 21); assert_eq!(result.trace.proposals.len(), 1); @@ -896,6 +930,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }, action_result: ActionResult { success: true, @@ -931,7 +966,7 @@ mod tests { .map(|item| item.outcome.as_str()), Some("applied_diagnostics_note") ); - assert_eq!(result.trace.controlled_output_reviews.len(), 4); + assert_eq!(result.trace.controlled_output_reviews.len(), 5); assert!(result.trace.controlled_output_reviews.iter().any(|item| { item.decision == NoNameControlledOutputDecision::NeedsReview && item.safe_apply_scope == Some(NoNameApplyScope::PlotTextHint) @@ -1014,6 +1049,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }, action_result: ActionResult { success: true, diff --git a/src-tauri/src/noname_trace.rs b/src-tauri/src/noname_trace.rs index f0c1178..c6e3c71 100644 --- a/src-tauri/src/noname_trace.rs +++ b/src-tauri/src/noname_trace.rs @@ -1,3 +1,4 @@ +use crate::noname_context_types::{NoNameContextSourceStat, NoNameRoleContextSliceStat}; use crate::noname_output_interface::{ NoNameControlledOutputDecision, NoNameControlledOutputKind, NoNameControlledOutputReview, NoNameForbiddenOutputScope, @@ -121,6 +122,20 @@ pub struct NoNameRelatedObservationRecord { pub action_summary: String, pub focus: String, pub rationale: String, + #[serde(default)] + pub role_goal: Option, + #[serde(default)] + pub scene_focus: Option, + #[serde(default)] + pub forbidden_scopes: Vec, + #[serde(default)] + pub note_type_hits: Vec, + #[serde(default)] + pub source_stats: Vec, + #[serde(default)] + pub context_token_budget_used: Option, + #[serde(default)] + pub context_slice_stats: Vec, pub proposal: NoNameProposal, } diff --git a/src-tauri/src/noname_types.rs b/src-tauri/src/noname_types.rs index 200f16f..8cb74ad 100644 --- a/src-tauri/src/noname_types.rs +++ b/src-tauri/src/noname_types.rs @@ -85,6 +85,7 @@ pub enum NoNameApplyScope { Diagnostics, ChapterSummaryHint, OptionBiasHint, + PlotAugmentationHint, PlotTextHint, } @@ -94,6 +95,7 @@ impl NoNameApplyScope { Self::Diagnostics => "diagnostics", Self::ChapterSummaryHint => "chapter_summary_hint", Self::OptionBiasHint => "option_bias_hint", + Self::PlotAugmentationHint => "plot_augmentation_hint", Self::PlotTextHint => "plot_text_hint", } } @@ -326,6 +328,10 @@ mod tests { NoNameApplyScope::ChapterSummaryHint.as_str(), "chapter_summary_hint" ); + assert_eq!( + NoNameApplyScope::PlotAugmentationHint.as_str(), + "plot_augmentation_hint" + ); } #[test] diff --git a/src-tauri/src/plot_consistency.rs b/src-tauri/src/plot_consistency.rs index f4a599d..2a4ec59 100644 --- a/src-tauri/src/plot_consistency.rs +++ b/src-tauri/src/plot_consistency.rs @@ -593,6 +593,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), } } diff --git a/src-tauri/src/plot_engine.rs b/src-tauri/src/plot_engine.rs index a370de5..4f7e1a3 100644 --- a/src-tauri/src/plot_engine.rs +++ b/src-tauri/src/plot_engine.rs @@ -152,6 +152,8 @@ pub struct PlotState { pub last_option_generation_source: Option, #[serde(default)] pub last_consistency_risk_score: Option, + #[serde(default)] + pub pending_plot_augmentation_hints: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1122,6 +1124,18 @@ impl PlotEngine { segment } + fn world_summary_with_pending_plot_augmentation( + base: impl Into, + current_state: &PlotState, + ) -> String { + let base = base.into(); + if let Some(context) = current_state.pending_plot_augmentation_prompt_context() { + format!("{base};{context}") + } else { + base + } + } + fn generate_chapter_segment_with_llm( &self, current_state: &PlotState, @@ -1153,9 +1167,12 @@ impl PlotEngine { actor_realm: None, actor_combat_power: None, history_events: action_result.events.clone(), - world_setting_summary: Some(format!( - "小说风格:{};参考设定文档 78 与补充稿,采用克制、具体、可验证的修仙叙事。请生成一段承接剧情的小说文本。玩家每章需要 2-3 次互动。", - settings.novel_style + world_setting_summary: Some(Self::world_summary_with_pending_plot_augmentation( + format!( + "小说风格:{};参考设定文档 78 与补充稿,采用克制、具体、可验证的修仙叙事。请生成一段承接剧情的小说文本。玩家每章需要 2-3 次互动。", + settings.novel_style + ), + current_state, )), }; @@ -1322,9 +1339,12 @@ impl PlotEngine { actor_realm: None, actor_combat_power: None, history_events: action_result.events.clone(), - world_setting_summary: Some(format!( - "小说风格:{};请生成一段承接剧情的小说文本。玩家每章需要 2-3 次互动。", - settings.novel_style + world_setting_summary: Some(Self::world_summary_with_pending_plot_augmentation( + format!( + "小说风格:{};请生成一段承接剧情的小说文本。玩家每章需要 2-3 次互动。", + settings.novel_style + ), + current_state, )), }; @@ -1566,7 +1586,10 @@ impl PlotEngine { actor_realm: None, actor_combat_power: None, history_events: action_result.events.clone(), - world_setting_summary: Some("参考设定文档 78 与补充稿,修仙叙事保持克制、写实、可验证,强调场景、事件与 NPC 反应".to_string()), + world_setting_summary: Some(Self::world_summary_with_pending_plot_augmentation( + "参考设定文档 78 与补充稿,修仙叙事保持克制、写实、可验证,强调场景、事件与 NPC 反应", + current_state, + )), }, &PromptConstraints { numerical_rules: vec!["必须与行动结果保持一致".to_string()], @@ -1788,7 +1811,10 @@ impl PlotEngine { actor_realm: None, actor_combat_power: None, history_events: action_result.events.clone(), - world_setting_summary: Some("先状态后叙事,输出可验证事实".to_string()), + world_setting_summary: Some(Self::world_summary_with_pending_plot_augmentation( + "先状态后叙事,输出可验证事实", + current_state, + )), }, &PromptConstraints { numerical_rules: vec!["必须与行动结果保持一致".to_string()], @@ -1928,7 +1954,10 @@ impl PlotEngine { actor_realm: None, actor_combat_power: None, history_events: action_result.events.clone(), - world_setting_summary: Some("轻量降级模式:优先可用与可验证性".to_string()), + world_setting_summary: Some(Self::world_summary_with_pending_plot_augmentation( + "轻量降级模式:优先可用与可验证性", + current_state, + )), }, &PromptConstraints { numerical_rules: vec!["必须与行动结果保持一致".to_string()], @@ -2368,9 +2397,10 @@ impl PlotEngine { actor_realm: None, actor_combat_power: None, history_events: action_result.events.clone(), - world_setting_summary: Some( - "输出连贯中文小说段落,强调具体行动与因果。".to_string(), - ), + world_setting_summary: Some(Self::world_summary_with_pending_plot_augmentation( + "输出连贯中文小说段落,强调具体行动与因果。", + current_state, + )), }, &PromptConstraints { numerical_rules: vec!["必须与行动结果保持一致".to_string()], @@ -2967,6 +2997,19 @@ fn fallback_chapter_title(index: u32, summary: &str) -> String { } } +fn sanitize_pending_plot_augmentation_hint(hint: &str) -> Option { + let normalized = hint + .replace(['\r', '\n', '\t'], " ") + .split_whitespace() + .collect::>() + .join(" "); + let trimmed = normalized.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(160).collect()) +} + impl Scene { pub fn new(id: String, name: String, description: String, location: String) -> Self { Self { @@ -3005,6 +3048,7 @@ impl PlotState { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), } } @@ -3029,6 +3073,153 @@ impl PlotState { self.current_scene.description = self.current_chapter.content.join("\n\n"); } + pub fn apply_low_risk_chapter_summary_hint( + &mut self, + chapter_index: u32, + expected_summary: &str, + hint: &str, + prepend: bool, + ) -> Result<(), String> { + if self.current_chapter.index != chapter_index { + return Err(format!( + "chapter mismatch: expected {}, current {}", + chapter_index, self.current_chapter.index + )); + } + if self.current_chapter.summary != expected_summary { + return Err("summary snapshot mismatch; refusing stale low-risk apply".to_string()); + } + let hint = hint.trim(); + if hint.is_empty() { + return Err("chapter summary hint is empty".to_string()); + } + if expected_summary.contains(hint) { + return Err("chapter summary already contains this low-risk hint".to_string()); + } + + let summary = expected_summary.trim(); + let updated_summary = if summary.is_empty() { + hint.to_string() + } else if prepend { + format!("{}; {}", hint, summary) + } else { + format!("{}; {}", summary, hint) + }; + + self.current_chapter.summary = updated_summary.clone(); + if let Some(history_chapter) = self + .chapters + .iter_mut() + .find(|chapter| chapter.index == chapter_index) + { + history_chapter.summary = updated_summary; + } + Ok(()) + } + + pub fn apply_low_risk_generation_diagnostics_hint( + &mut self, + chapter_index: u32, + expected_generation_diagnostics: &str, + hint: &str, + ) -> Result<(), String> { + if self.current_chapter.index != chapter_index { + return Err(format!( + "chapter mismatch: expected {}, current {}", + chapter_index, self.current_chapter.index + )); + } + if !self.is_waiting_for_input { + return Err("diagnostics hint requires waiting-for-input state".to_string()); + } + + let current_diagnostics = self.last_generation_diagnostics.clone().unwrap_or_default(); + if current_diagnostics != expected_generation_diagnostics { + return Err("diagnostics snapshot mismatch; refusing stale low-risk apply".to_string()); + } + + let hint = hint.trim(); + if hint.is_empty() { + return Err("generation diagnostics hint is empty".to_string()); + } + if current_diagnostics.contains(hint) { + return Err("generation diagnostics already contains this low-risk hint".to_string()); + } + + self.last_generation_diagnostics = Some(if current_diagnostics.trim().is_empty() { + hint.to_string() + } else { + format!("{}; {}", current_diagnostics, hint) + }); + Ok(()) + } + + pub fn apply_pending_plot_augmentation_hint( + &mut self, + chapter_index: u32, + expected_hints: &[String], + hint: &str, + ) -> Result<(), String> { + if self.current_chapter.index != chapter_index { + return Err(format!( + "chapter mismatch: expected {}, current {}", + chapter_index, self.current_chapter.index + )); + } + if !self.is_waiting_for_input { + return Err("plot augmentation hint requires waiting-for-input state".to_string()); + } + if self.pending_plot_augmentation_hints != expected_hints { + return Err( + "plot augmentation snapshot mismatch; refusing stale controlled apply".to_string(), + ); + } + + let hint = hint.trim(); + if hint.is_empty() { + return Err("plot augmentation hint is empty".to_string()); + } + if self + .pending_plot_augmentation_hints + .iter() + .any(|existing| existing == hint) + { + return Err("plot augmentation already contains this controlled hint".to_string()); + } + + self.pending_plot_augmentation_hints.push(hint.to_string()); + Ok(()) + } + + pub fn pending_plot_augmentation_prompt_context(&self) -> Option { + let hints = self + .pending_plot_augmentation_hints + .iter() + .filter_map(|hint| sanitize_pending_plot_augmentation_hint(hint)) + .take(3) + .collect::>(); + if hints.is_empty() { + return None; + } + + Some(format!( + "NoName pending plot augmentation hints(非最终提示,可忽略):{}。安全边界:只能作为下一轮叙事灵感;不得直接改写最终剧情状态、数值、背包、地图或章节生命周期;若与玩家行动结果或既有世界事实冲突,必须忽略。", + hints.join(";") + )) + } + + pub fn consume_pending_plot_augmentation_hints( + &mut self, + expected_hints: &[String], + ) -> Result { + if self.pending_plot_augmentation_hints != expected_hints { + return Err("plot augmentation snapshot mismatch; refusing stale consume".to_string()); + } + let consumed = self.pending_plot_augmentation_hints.len(); + self.pending_plot_augmentation_hints.clear(); + Ok(consumed) + } + pub fn finalize_chapter(&mut self, title: Option, summary: Option) { let mut resolved_summary = self.current_chapter.summary.clone(); if let Some(summary) = summary { @@ -3128,6 +3319,157 @@ mod tests { assert!(state.plot_history.is_empty()); } + #[test] + fn test_low_risk_chapter_summary_hint_updates_summary() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state.current_chapter.summary = "existing summary".to_string(); + + state + .apply_low_risk_chapter_summary_hint( + 1, + "existing summary", + "NoName summary hint: focus", + true, + ) + .expect("summary hint should apply"); + + assert_eq!( + state.current_chapter.summary, + "NoName summary hint: focus; existing summary" + ); + } + + #[test] + fn test_low_risk_chapter_summary_hint_rejects_stale_snapshot() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state.current_chapter.summary = "new summary".to_string(); + + let error = state + .apply_low_risk_chapter_summary_hint( + 1, + "old summary", + "NoName summary hint: focus", + false, + ) + .expect_err("stale summary should be rejected"); + + assert!(error.contains("summary snapshot mismatch")); + } + + #[test] + fn test_low_risk_generation_diagnostics_hint_updates_diagnostics() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state.last_generation_diagnostics = Some("base diagnostics".to_string()); + + state + .apply_low_risk_generation_diagnostics_hint( + 1, + "base diagnostics", + "NoName option bias: focus", + ) + .expect("diagnostics hint should apply"); + + assert_eq!( + state.last_generation_diagnostics.as_deref(), + Some("base diagnostics; NoName option bias: focus") + ); + } + + #[test] + fn test_low_risk_generation_diagnostics_hint_rejects_when_not_waiting() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state.is_waiting_for_input = false; + + let error = state + .apply_low_risk_generation_diagnostics_hint(1, "", "NoName option bias: focus") + .expect_err("non-waiting state should be rejected"); + + assert!(error.contains("waiting-for-input")); + } + + #[test] + fn test_pending_plot_augmentation_hint_updates_pending_hints() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state + .pending_plot_augmentation_hints + .push("existing augmentation".to_string()); + + state + .apply_pending_plot_augmentation_hint( + 1, + &["existing augmentation".to_string()], + "NoName plot augmentation: focus", + ) + .expect("plot augmentation hint should apply"); + + assert_eq!( + state.pending_plot_augmentation_hints, + vec![ + "existing augmentation".to_string(), + "NoName plot augmentation: focus".to_string() + ] + ); + } + + #[test] + fn test_pending_plot_augmentation_hint_rejects_stale_snapshot() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state + .pending_plot_augmentation_hints + .push("new augmentation".to_string()); + + let error = state + .apply_pending_plot_augmentation_hint( + 1, + &["old augmentation".to_string()], + "NoName plot augmentation: focus", + ) + .expect_err("stale plot augmentation snapshot should be rejected"); + + assert!(error.contains("plot augmentation snapshot mismatch")); + } + + #[test] + fn test_pending_plot_augmentation_prompt_context_is_sanitized_and_bounded() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + state + .pending_plot_augmentation_hints + .push("NoName plot augmentation:\n focus=hidden cave\t| effect=stage beat".to_string()); + state.pending_plot_augmentation_hints.push("".to_string()); + + let context = state + .pending_plot_augmentation_prompt_context() + .expect("pending hints should produce prompt context"); + + assert!(context.contains("非最终提示")); + assert!(context.contains("focus=hidden cave | effect=stage beat")); + assert!(context.contains("不得直接改写最终剧情状态")); + assert!(!context.contains('\n')); + assert!(!context.contains('\t')); + } + + #[test] + fn test_consume_pending_plot_augmentation_hints_clears_matching_snapshot() { + let scene = create_test_scene(); + let mut state = PlotState::new(scene); + let expected = vec!["NoName plot augmentation: focus=hidden cave".to_string()]; + state.pending_plot_augmentation_hints = expected.clone(); + + let consumed = state + .consume_pending_plot_augmentation_hints(&expected) + .expect("matching snapshot should be consumed"); + + assert_eq!(consumed, 1); + assert!(state.pending_plot_augmentation_hints.is_empty()); + } + #[test] fn test_plot_state_recalculate_auto_advance() { let scene = create_test_scene(); diff --git a/src-tauri/src/tauri_commands.rs b/src-tauri/src/tauri_commands.rs index 5707615..b24f807 100644 --- a/src-tauri/src/tauri_commands.rs +++ b/src-tauri/src/tauri_commands.rs @@ -16,7 +16,7 @@ use crate::llm_service::{LLMConfig, LLMRequest, LLMService}; use crate::memory_layers::{ChapterSummary, MemoryEntry, WorldFact}; use crate::noname_apply::{ apply_manual_plot_text_hint_to_plot_state, apply_reviewed_output_to_plot_state, - build_reviewed_apply_request, + build_reviewed_apply_request, NoNameReviewedApplyRequestInput, }; use crate::noname_config::NoNameConfig; use crate::noname_context_builder::build_context_packet; @@ -186,7 +186,7 @@ fn target_segment_supports_apply_scope( scope: NoNameApplyScope, ) -> bool { match scope { - NoNameApplyScope::PlotTextHint => matches!( + NoNameApplyScope::PlotTextHint | NoNameApplyScope::PlotAugmentationHint => matches!( target_segment, NoNameTargetSegment::CurrentTurnHead | NoNameTargetSegment::CurrentTurnTail ), @@ -243,7 +243,10 @@ fn apply_noname_outcome_with_engine( apply: F, ) -> Result where - F: FnOnce(NoNameTrace, PlotState) -> Result, + F: FnOnce( + NoNameTrace, + PlotState, + ) -> Result, { let trace = load_noname_trace(trace_id)?; let result = { @@ -269,6 +272,7 @@ where fn priority_for_apply_scope(scope: NoNameApplyScope) -> u32 { match scope { NoNameApplyScope::PlotTextHint => 300, + NoNameApplyScope::PlotAugmentationHint => 250, NoNameApplyScope::ChapterSummaryHint => 200, NoNameApplyScope::OptionBiasHint => 100, NoNameApplyScope::Diagnostics => 50, @@ -337,6 +341,53 @@ fn plan_noname_apply_target( }; } } + NoNameApplyScope::PlotAugmentationHint => { + let Some(state) = plot_state else { + return NoNameApplyTargetPlan { + target, + priority, + order: 0, + decision: NoNameApplyTargetDecision::Skip { + outcome: "skipped_missing_plot_state", + note: "缺少 plot_state,跳过剧情增强提示".to_string(), + }, + }; + }; + if !state.is_waiting_for_input { + return NoNameApplyTargetPlan { + target, + priority, + order: 0, + decision: NoNameApplyTargetDecision::Skip { + outcome: "skipped_not_waiting", + note: "当前不处于等待输入状态,跳过剧情增强提示".to_string(), + }, + }; + } + let hint = format!( + "NoName plot augmentation: focus={} | effect={}", + proposal.focus.trim(), + proposal.intended_effect.trim() + ); + if state + .pending_plot_augmentation_hints + .iter() + .any(|existing| existing == &hint) + { + return NoNameApplyTargetPlan { + target, + priority, + order: 0, + decision: NoNameApplyTargetDecision::Skip { + outcome: "skipped_duplicate", + note: format!( + "剧情增强提示已包含“{}”,跳过重复写入", + proposal.focus.trim() + ), + }, + }; + } + } NoNameApplyScope::ChapterSummaryHint => { if let Some(state) = plot_state { let summary = state.current_chapter.summary.trim(); @@ -422,6 +473,12 @@ fn build_noname_apply_plan_set( plot_state, plot_text, ), + plan_noname_apply_target( + proposal, + NoNameApplyScope::PlotAugmentationHint, + plot_state, + None, + ), plan_noname_apply_target( proposal, NoNameApplyScope::ChapterSummaryHint, @@ -537,12 +594,16 @@ fn record_noname_human_review_apply_intent( NoNameHumanReviewDecision::ApprovedForHigherApply => { let rejection_reason = if trace.mode != NoNameMode::Assisted { Some("当前 trace 不是 assisted 模式,拒绝进入高层 apply intent".to_string()) - } else if safe_apply_scope != Some(NoNameApplyScope::PlotTextHint) { + } else if !matches!( + safe_apply_scope, + Some(NoNameApplyScope::PlotTextHint | NoNameApplyScope::PlotAugmentationHint) + ) { Some(format!( - "当前仅允许 plot_text_hint 进入人工确认后的二次 guardrail,实际作用域为 {}", + "当前仅允许 plot_text_hint / plot_augmentation_hint 进入人工确认后的二次 guardrail,实际作用域为 {}", target )) } else { + let scope = safe_apply_scope.unwrap_or(NoNameApplyScope::PlotTextHint); match find_review_proposal(trace, request_id) { Some(proposal) if proposal.status @@ -550,7 +611,7 @@ fn record_noname_human_review_apply_intent( && proposal.applyable && target_segment_supports_apply_scope( proposal.target_segment, - NoNameApplyScope::PlotTextHint, + scope, ) => { None @@ -567,8 +628,9 @@ fn record_noname_human_review_apply_intent( )) } Some(proposal) => Some(format!( - "target_segment={} 不支持 plot_text_hint 二次 apply", - proposal.target_segment.as_str() + "target_segment={} 不支持 {} 二次 apply", + proposal.target_segment.as_str(), + scope.as_str() )), None => { Some("未找到 review 对应 proposal,拒绝进入高层 apply intent".to_string()) @@ -620,10 +682,10 @@ fn review_is_waiting_for_second_guardrail(trace: &NoNameTrace, request_id: &str) .proposal_transition_log .iter() .any(|entry| entry == &transition) - || trace.apply_execution_log.iter().any(|entry| { - entry.outcome == "awaiting_second_guardrail" - && entry.target == NoNameApplyScope::PlotTextHint.as_str() - }) + || trace + .apply_execution_log + .iter() + .any(|entry| entry.outcome == "awaiting_second_guardrail") } fn second_guardrail_revalidation_reason( @@ -634,8 +696,16 @@ fn second_guardrail_revalidation_reason( if trace.mode != NoNameMode::Assisted { return Some("当前 trace 不是 assisted 模式,二次 guardrail 拒绝".to_string()); } - if safe_apply_scope != Some(NoNameApplyScope::PlotTextHint) { - return Some("当前二次 guardrail 只允许处理 plot_text_hint".to_string()); + let Some(scope) = safe_apply_scope else { + return Some("二次 guardrail 缺少 safe_apply_scope".to_string()); + }; + if !matches!( + scope, + NoNameApplyScope::PlotTextHint | NoNameApplyScope::PlotAugmentationHint + ) { + return Some( + "当前二次 guardrail 只允许处理 plot_text_hint / plot_augmentation_hint".to_string(), + ); } if !review_is_waiting_for_second_guardrail(trace, request_id) { return Some("review 尚未进入 awaiting_second_guardrail 队列".to_string()); @@ -645,10 +715,7 @@ fn second_guardrail_revalidation_reason( Some(proposal) if proposal.status == crate::noname_types::NoNameProposalStatus::Applied && proposal.applyable - && target_segment_supports_apply_scope( - proposal.target_segment, - NoNameApplyScope::PlotTextHint, - ) => + && target_segment_supports_apply_scope(proposal.target_segment, scope) => { None } @@ -663,8 +730,9 @@ fn second_guardrail_revalidation_reason( )) } Some(proposal) => Some(format!( - "target_segment={} 不支持 plot_text_hint 二次 guardrail", - proposal.target_segment.as_str() + "target_segment={} 不支持 {} 二次 guardrail", + proposal.target_segment.as_str(), + scope.as_str() )), None => Some("未找到 review 对应 proposal,二次 guardrail 拒绝".to_string()), } @@ -1104,6 +1172,19 @@ fn diagnostics_used_preset_fallback(diag: Option<&str>) -> bool { text.contains("已使用预设文本") } +fn append_generation_diagnostic(existing: &mut Option, note: impl Into) { + let note = note.into(); + match existing { + Some(diag) => { + diag.push(';'); + diag.push_str(¬e); + } + None => { + *existing = Some(note); + } + } +} + fn effective_consistency_report_after_option_resolution( mut report: ConsistencyReport, is_waiting_for_input: bool, @@ -3584,6 +3665,7 @@ pub async fn execute_player_action( } else { build_plot_context_for_generation(&game_state, &plot_state, &action) }; + let pending_plot_augmentation_snapshot = plot_state.pending_plot_augmentation_hints.clone(); let mut plot_state_for_generation = plot_state.clone(); let narrative_hint = narrative_density_and_pacing_hint(plot_state.current_chapter.interaction_count); @@ -3601,7 +3683,7 @@ pub async fn execute_player_action( } } - let cache_key = if quick_mode { + let cache_key = if quick_mode || !pending_plot_augmentation_snapshot.is_empty() { None } else { Some(build_action_plot_cache_key( @@ -3856,6 +3938,7 @@ pub async fn execute_player_action( } // Phase 2: 双通道(state_patch + narrative)优先,失败则保留现有 plot_engine 结果。 + let mut plot_update_overridden_by_turn_update = false; if !quick_mode { if let Some(mut registry) = world_registry.clone() { let turn_update_started = Instant::now(); @@ -3936,6 +4019,7 @@ pub async fn execute_player_action( if entity_ref_ok { if !narrative.is_empty() { plot_update.plot_text = narrative; + plot_update_overridden_by_turn_update = true; } if !turn_result.choices.is_empty() { plot_update.available_options = @@ -4084,6 +4168,68 @@ pub async fn execute_player_action( } plot_state.last_generation_diagnostics = plot_update.generation_diagnostics.clone(); + let mut pending_plot_augmentation_trace: Option<(String, String)> = None; + if !pending_plot_augmentation_snapshot.is_empty() { + let should_consume_pending_plot_augmentation = !quick_mode + && !plot_update_overridden_by_turn_update + && !diagnostics_used_preset_fallback(plot_state.last_generation_diagnostics.as_deref()); + if should_consume_pending_plot_augmentation { + match plot_state + .consume_pending_plot_augmentation_hints(&pending_plot_augmentation_snapshot) + { + Ok(consumed) => { + let note = format!( + "pending plot augmentation consumed after plot_engine generation; count={}", + consumed + ); + append_generation_diagnostic( + &mut plot_state.last_generation_diagnostics, + format!("NoName pending plot augmentation consumed={}", consumed), + ); + pending_plot_augmentation_trace = + Some(("pending_plot_augmentation_consumed".to_string(), note)); + } + Err(err) => { + let note = format!( + "pending plot augmentation retained because snapshot changed: {}", + err + ); + append_generation_diagnostic( + &mut plot_state.last_generation_diagnostics, + format!("NoName pending plot augmentation retained({})", err), + ); + pending_plot_augmentation_trace = + Some(("pending_plot_augmentation_retained".to_string(), note)); + } + } + } else { + let retain_reason = if quick_mode { + "quick_mode" + } else if plot_update_overridden_by_turn_update { + "turn_update_override" + } else if diagnostics_used_preset_fallback( + plot_state.last_generation_diagnostics.as_deref(), + ) { + "preset_fallback" + } else { + "no_llm_consumption" + }; + let note = format!( + "pending plot augmentation retained because {}; count={}", + retain_reason, + pending_plot_augmentation_snapshot.len() + ); + append_generation_diagnostic( + &mut plot_state.last_generation_diagnostics, + format!( + "NoName pending plot augmentation retained({})", + retain_reason + ), + ); + pending_plot_augmentation_trace = + Some(("pending_plot_augmentation_retained".to_string(), note)); + } + } // 用最新段落更新场景描述,避免选项生成长期绑定旧描述导致“选项不变”。 if !plot_text_for_history.trim().is_empty() { @@ -4338,6 +4484,17 @@ pub async fn execute_player_action( } if let Some(mut noname_result) = noname_result { + if let Some((outcome, note)) = pending_plot_augmentation_trace.take() { + noname_result.trace.record_apply_execution( + NoNameApplyScope::PlotAugmentationHint.as_str(), + outcome.as_str(), + Some(note), + ); + noname_result.trace.record_proposal_transition(format!( + "{}:pending_plot_augmentation:{}", + noname_result.proposal.proposal_id, outcome + )); + } apply_noname_low_risk_outputs( &mut plot_state, &mut noname_result, @@ -4516,9 +4673,10 @@ pub async fn apply_noname_reviewed_output( expected_segment_text: Option, expected_summary: Option, expected_generation_diagnostics: Option, + expected_plot_augmentation_hints: Option>, engine: State<'_, Mutex>, ) -> Result { - let request = build_reviewed_apply_request( + let request = build_reviewed_apply_request(NoNameReviewedApplyRequestInput { request_id, scope, chapter_index, @@ -4526,7 +4684,8 @@ pub async fn apply_noname_reviewed_output( expected_segment_text, expected_summary, expected_generation_diagnostics, - )?; + expected_plot_augmentation_hints, + })?; apply_noname_outcome_with_engine(&trace_id, &engine, |trace, plot_state| { apply_reviewed_output_to_plot_state(trace, request, plot_state) }) @@ -7099,6 +7258,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let summary = build_noname_action_summary(&action, &plot_state); @@ -7114,6 +7274,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-1".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -7267,12 +7434,17 @@ mod tests { crate::noname_types::NoNameProposalStatus::Applied ); assert!(result.proposal.applyable); - assert_eq!(result.trace.controlled_output_reviews.len(), 4); + assert_eq!(result.trace.controlled_output_reviews.len(), 5); assert!(result .trace .controlled_output_reviews .iter() .any(|review| review.requires_human_review)); + assert!(result.trace.controlled_output_reviews.iter().any(|review| { + review.safe_apply_scope + == Some(crate::noname_types::NoNameApplyScope::PlotAugmentationHint) + && review.requires_human_review + })); assert!(plot_state_after .current_chapter .summary @@ -7449,6 +7621,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let applied = crate::noname_apply::apply_manual_plot_text_hint_to_plot_state( resolved, @@ -7495,6 +7668,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let mut result = NoNameDirectorRunResult { trace: NoNameTrace { @@ -7522,6 +7696,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-1".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -7637,6 +7818,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-plot-text".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -7719,6 +7907,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-plot-text-head".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -7785,6 +7980,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let mut result = NoNameDirectorRunResult { trace: NoNameTrace { @@ -7812,6 +8008,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-scope".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -7897,6 +8100,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let mut result = NoNameDirectorRunResult { trace: NoNameTrace { @@ -7924,6 +8128,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-summary-head".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -7997,6 +8208,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-plot-text-mismatch".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -8072,6 +8290,7 @@ mod tests { last_generation_diagnostics: None, last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let mut result = NoNameDirectorRunResult { trace: NoNameTrace { @@ -8099,6 +8318,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-summary-duplicate".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, @@ -8173,6 +8399,7 @@ mod tests { last_generation_diagnostics: Some(existing_hint.to_string()), last_option_generation_source: None, last_consistency_risk_score: None, + pending_plot_augmentation_hints: Vec::new(), }; let mut result = NoNameDirectorRunResult { trace: NoNameTrace { @@ -8200,6 +8427,13 @@ mod tests { focus: "山门危机".to_string(), rationale: "应优先观察山门危机".to_string(), prompt_preview: "prompt".to_string(), + role_goal: None, + scene_focus: None, + forbidden_scopes: Vec::new(), + note_type_hits: Vec::new(), + source_stats: Vec::new(), + context_token_budget_used: None, + context_slice_stats: Vec::new(), proposal: crate::noname_types::NoNameProposal { proposal_id: "proposal-option-duplicate".to_string(), kind: crate::noname_types::NoNameProposalKind::PlotCandidate, diff --git a/src/components/AgentTracePanel.vue b/src/components/AgentTracePanel.vue index a70f0f5..b5c703b 100644 --- a/src/components/AgentTracePanel.vue +++ b/src/components/AgentTracePanel.vue @@ -40,6 +40,7 @@
  • elapsedMs:{{ trace.elapsedMs }} ms
  • guardrail:{{ guardrailLabel }}
  • applyResult:{{ applyResultLabel }}
  • +
  • 剧情增强提示:{{ pendingPlotAugmentationSummary }}
  • @@ -156,6 +157,36 @@

    焦点:{{ item.focus }} · 目标段:{{ item.proposal.targetSegment }}

    +

    + 角色上下文:{{ item.roleGoal || '无角色目标' }} · {{ item.sceneFocus || '无场景焦点' }} +

    +

    + 角色禁区:{{ item.forbiddenScopes.join(' / ') }} +

    +

    + 笔记命中:{{ item.noteTypeHits.join(' / ') }} +

    +

    + 上下文来源:{{ formatSourceStats(item.sourceStats) }} · token={{ item.contextTokenBudgetUsed ?? 0 }} +

    +

    + 裁剪差异:{{ formatContextSliceStats(item.contextSliceStats) }} +

    状态:{{ item.proposal.status || (item.proposal.applyable ? 'ready' : 'observed') }}

    @@ -294,45 +325,45 @@

    人工写入预览:{{ manualApplyPreview(review.requestId).statusLabel }}

    - {{ manualApplyPreview(review.requestId).statusText }} + {{ manualApplyPreview(review).statusText }}

    写入前

    -
    {{ manualApplyPreview(review.requestId).before }}
    +
    {{ manualApplyPreview(review).before }}

    写入后

    -
    {{ manualApplyPreview(review.requestId).after }}
    +
    {{ manualApplyPreview(review).after }}
    @@ -392,7 +423,13 @@ class="agent-trace-row" >

    - {{ execution.target }} · {{ execution.outcome }} + {{ applyExecutionDisplay(execution).targetLabel }} · {{ applyExecutionDisplay(execution).outcomeLabel }} +

    +

    + 原始记录:{{ execution.target }} / {{ execution.outcome }}

    import { computed } from 'vue'; import type { + NoNameApplyExecutionRecord, + NoNameContextSourceStat, NoNameHumanReviewDecision, NoNameHumanReviewMarkPayload, + NoNameManualApplyDiagnosticsSnapshot, + NoNameManualApplyPlotAugmentationSnapshot, NoNameManualApplySegmentSnapshot, + NoNameManualApplySummarySnapshot, + NoNameManualChapterSummaryHintApplyPayload, + NoNameManualOptionBiasHintApplyPayload, + NoNameManualPlotAugmentationHintApplyPayload, NoNameManualPlotTextApplyPayload, + NoNameApplyScope, + NoNameControlledOutputReviewRecord, NoNameProposal, + NoNameRoleContextSliceStat, NoNameSecondGuardrailDecision, NoNameSecondGuardrailResolvePayload, NoNameTrace, } from '../types/game'; -import { buildNoNameApplyLifecycle } from '../utils/noNameApplyLifecycle'; +import { + buildNoNameApplyLifecycle, + formatNoNameApplyExecutionRecord, + summarizeNoNamePendingPlotAugmentation, +} from '../utils/noNameApplyLifecycle'; const props = withDefaults(defineProps<{ trace: NoNameTrace | null; @@ -455,18 +507,27 @@ const props = withDefaults(defineProps<{ activeMode?: string; reviewDecisions?: Record; manualApplySegment?: NoNameManualApplySegmentSnapshot | null; + manualApplySummary?: NoNameManualApplySummarySnapshot | null; + manualApplyDiagnostics?: NoNameManualApplyDiagnosticsSnapshot | null; + manualApplyPlotAugmentations?: NoNameManualApplyPlotAugmentationSnapshot | null; }>(), { selectedIndex: 0, totalCount: 0, activeMode: '', reviewDecisions: () => ({}), manualApplySegment: null, + manualApplySummary: null, + manualApplyDiagnostics: null, + manualApplyPlotAugmentations: null, }); const emit = defineEmits<{ (event: 'mark-controlled-output-review', payload: NoNameHumanReviewMarkPayload): void; (event: 'resolve-second-guardrail', payload: NoNameSecondGuardrailResolvePayload): void; (event: 'apply-manual-plot-text-hint', payload: NoNameManualPlotTextApplyPayload): void; + (event: 'apply-manual-chapter-summary-hint', payload: NoNameManualChapterSummaryHintApplyPayload): void; + (event: 'apply-manual-option-bias-hint', payload: NoNameManualOptionBiasHintApplyPayload): void; + (event: 'apply-manual-plot-augmentation-hint', payload: NoNameManualPlotAugmentationHintApplyPayload): void; }>(); const latestProposal = computed(() => { @@ -482,6 +543,27 @@ const controlledOutputReviews = computed(() => props.trace?.controlledOutputRevi const applyLifecycleSteps = computed(() => ( props.trace ? buildNoNameApplyLifecycle(props.trace, props.reviewDecisions) : [] )); +const pendingPlotAugmentationSummary = computed(() => ( + props.trace ? summarizeNoNamePendingPlotAugmentation(props.trace) : '无' +)); + +function applyExecutionDisplay(execution: NoNameApplyExecutionRecord) { + return formatNoNameApplyExecutionRecord(execution); +} + +function formatSourceStats(sourceStats: NoNameContextSourceStat[] | undefined) { + if (!sourceStats || sourceStats.length === 0) { + return '无'; + } + return sourceStats.map((item) => `${item.source}:${item.count}`).join(' / '); +} + +function formatContextSliceStats(sliceStats: NoNameRoleContextSliceStat[] | undefined) { + if (!sliceStats || sliceStats.length === 0) { + return '无'; + } + return sliceStats.map((item) => `${item.section}:${item.sourceCount}->${item.visibleCount}`).join(' / '); +} const guardrailLabel = computed(() => { const result = props.trace?.guardrailResult; @@ -536,23 +618,61 @@ function resolveSecondGuardrail(requestId: string, decision: NoNameSecondGuardra }); } -function hasSecondGuardrailAllow(requestId: string) { +function normalizeApplyScope(value: string | null | undefined) { + return (value ?? '').replace(/_/g, '').toLowerCase(); +} + +function scopeToTransitionSuffix(scope: NoNameApplyScope | null | undefined) { + if (scope === 'chapterSummaryHint') { + return 'chapter_summary_hint'; + } + if (scope === 'optionBiasHint') { + return 'option_bias_hint'; + } + if (scope === 'plotAugmentationHint') { + return 'plot_augmentation_hint'; + } + if (scope === 'plotTextHint') { + return 'plot_text_hint'; + } + return normalizeApplyScope(scope); +} + +function manualApplyOutcome(scope: NoNameApplyScope | null | undefined) { + if (scope === 'chapterSummaryHint') { + return 'manual_chapter_summary_hint_applied'; + } + if (scope === 'optionBiasHint') { + return 'manual_option_bias_hint_applied'; + } + if (scope === 'plotAugmentationHint') { + return 'manual_plot_augmentation_hint_applied'; + } + return 'manual_plot_text_applied'; +} + +function hasSecondGuardrailAllow(requestId: string, scope: NoNameApplyScope | null | undefined = 'plotTextHint') { const transition = `${requestId}:second_guardrail:allow`; + const target = normalizeApplyScope(scope); return Boolean(props.trace?.proposalTransitionLog?.includes(transition)) || Boolean(props.trace?.applyExecutionLog?.some((item) => ( - (item.target === 'plot_text_hint' || item.target === 'plotTextHint') + normalizeApplyScope(item.target) === target && item.outcome === 'second_guardrail_allowed' ))); } -function hasManualPlotTextApplied(requestId: string) { - return Boolean(props.trace?.proposalTransitionLog?.includes(`${requestId}:manual_apply:plot_text_hint`)) +function hasManualReviewedApplyApplied(requestId: string, scope: NoNameApplyScope | null | undefined = 'plotTextHint') { + return Boolean(props.trace?.proposalTransitionLog?.includes(`${requestId}:manual_apply:${scopeToTransitionSuffix(scope)}`)) || Boolean(props.trace?.applyExecutionLog?.some((item) => ( - (item.target === 'plot_text_hint' || item.target === 'plotTextHint') - && item.outcome === 'manual_plot_text_applied' + normalizeApplyScope(item.target) === normalizeApplyScope(scope) + && item.outcome === manualApplyOutcome(scope) ))); } +function hasManualPlotTextApplied(requestId: string) { + return hasManualReviewedApplyApplied(requestId, 'plotTextHint'); +} + function findProposalForReview(requestId: string): NoNameProposal | null { const proposals = props.trace?.proposals ?? []; return [...proposals].reverse().find((proposal) => requestId.includes(proposal.proposalId)) @@ -568,7 +688,149 @@ function buildManualPlotText(proposal: NoNameProposal, segmentText: string) { return `${segmentText.trim()}\n\n${hint}`; } -function manualApplyPreview(requestId: string) { +function manualApplyPreview(input: string | NoNameControlledOutputReviewRecord) { + const requestId = typeof input === 'string' ? input : input.requestId; + const scope: NoNameApplyScope | null | undefined = typeof input === 'string' + ? 'plotTextHint' + : input.safeApplyScope; + if (scope === 'chapterSummaryHint') { + const proposal = findProposalForReview(requestId); + const snapshot = props.manualApplySummary; + if (hasManualReviewedApplyApplied(requestId, scope)) { + return { + canApply: false, + toneClass: 'is-safe', + statusLabel: '已写入章节摘要提示', + statusText: '这条 review 已记录 manual_chapter_summary_hint_applied,避免重复写入。', + before: undefined, + after: undefined, + }; + } + if (!proposal || !snapshot) { + return { + canApply: false, + toneClass: 'is-warn', + statusLabel: '缺少章节摘要快照', + statusText: '当前章节摘要或关联 proposal 不可用,无法执行显式写入。', + before: undefined, + after: undefined, + }; + } + const hint = `NoName summary hint: ${proposal.focus.trim()}`; + const before = snapshot.summary; + if (before.includes(hint) || (proposal.focus.trim() && before.includes(proposal.focus.trim()))) { + return { + canApply: false, + toneClass: 'is-warn', + statusLabel: '摘要疑似已包含提示', + statusText: '当前章节摘要已经包含这条 NoName 提示或焦点,避免重复写入。', + before, + after: undefined, + }; + } + const after = before.trim() + ? proposal.targetSegment === 'chapter_summary_head' + ? `${hint}; ${before.trim()}` + : `${before.trim()}; ${hint}` + : hint; + return { + canApply: true, + toneClass: 'is-ready', + statusLabel: `将写入第 ${snapshot.chapterIndex} 章摘要`, + statusText: '后端会再次校验章节和摘要快照,避免陈旧写入。', + before, + after, + }; + } + if (scope === 'optionBiasHint') { + const proposal = findProposalForReview(requestId); + const snapshot = props.manualApplyDiagnostics; + if (hasManualReviewedApplyApplied(requestId, scope)) { + return { + canApply: false, + toneClass: 'is-safe', + statusLabel: '已写入选项偏置提示', + statusText: '这条 review 已记录 manual_option_bias_hint_applied,避免重复写入。', + before: undefined, + after: undefined, + }; + } + if (!proposal || !snapshot) { + return { + canApply: false, + toneClass: 'is-warn', + statusLabel: '缺少诊断提示快照', + statusText: '当前 diagnostics 或关联 proposal 不可用,无法执行显式写入。', + before: undefined, + after: undefined, + }; + } + const hint = `NoName option bias: next turn should prioritize actions around ${proposal.focus.trim()}`; + const before = snapshot.diagnostics; + if (before.includes(hint)) { + return { + canApply: false, + toneClass: 'is-warn', + statusLabel: '诊断提示已包含该偏置', + statusText: '当前 generation diagnostics 已包含这条 NoName 选项偏置提示,避免重复写入。', + before, + after: undefined, + }; + } + return { + canApply: true, + toneClass: 'is-ready', + statusLabel: `将写入第 ${snapshot.chapterIndex} 章诊断提示`, + statusText: '后端会再次校验章节和 diagnostics 快照,避免陈旧写入。', + before, + after: before.trim() ? `${before}; ${hint}` : hint, + }; + } + if (scope === 'plotAugmentationHint') { + const proposal = findProposalForReview(requestId); + const snapshot = props.manualApplyPlotAugmentations; + if (hasManualReviewedApplyApplied(requestId, scope)) { + return { + canApply: false, + toneClass: 'is-safe', + statusLabel: '已暂存剧情增强提示', + statusText: '这条 review 已记录 manual_plot_augmentation_hint_applied,避免重复写入。', + before: undefined, + after: undefined, + }; + } + if (!proposal || !snapshot) { + return { + canApply: false, + toneClass: 'is-warn', + statusLabel: '缺少剧情增强快照', + statusText: '当前 pending augmentation 列表或关联 proposal 不可用,无法执行显式写入。', + before: undefined, + after: undefined, + }; + } + const hint = `NoName plot augmentation: focus=${proposal.focus.trim()} | effect=${proposal.intendedEffect.trim()}`; + const beforeItems = snapshot.hints; + const before = beforeItems.length > 0 ? beforeItems.join('\n') : '(暂无暂存剧情增强提示)'; + if (beforeItems.includes(hint)) { + return { + canApply: false, + toneClass: 'is-warn', + statusLabel: '剧情增强提示已存在', + statusText: '当前 pending augmentation 列表已包含这条 NoName 提示,避免重复写入。', + before, + after: undefined, + }; + } + return { + canApply: true, + toneClass: 'is-ready', + statusLabel: `将暂存第 ${snapshot.chapterIndex} 章剧情增强提示`, + statusText: '这只进入 pending augmentation 列表,不直接改写最终正文或剧情状态机。', + before, + after: [...beforeItems, hint].join('\n'), + }; + } if (hasManualPlotTextApplied(requestId)) { return { canApply: false, @@ -625,17 +887,43 @@ function manualApplyPreview(requestId: string) { }; } -function applyManualPlotTextHint(requestId: string) { +function manualApplyButtonLabel(review: NoNameControlledOutputReviewRecord) { + if (review.safeApplyScope === 'chapterSummaryHint') { + return '显式人工写入章节摘要提示'; + } + if (review.safeApplyScope === 'optionBiasHint') { + return '显式人工写入选项偏置提示'; + } + if (review.safeApplyScope === 'plotAugmentationHint') { + return '显式人工暂存剧情增强提示'; + } + return '显式人工写入正文提示'; +} + +function applyManualReviewedOutput(review: NoNameControlledOutputReviewRecord) { if (!props.trace) { return; } - if (!manualApplyPreview(requestId).canApply) { + if (!manualApplyPreview(review).canApply) { return; } - emit('apply-manual-plot-text-hint', { + const payload = { traceId: props.trace.traceId, - requestId, - }); + requestId: review.requestId, + }; + if (review.safeApplyScope === 'chapterSummaryHint') { + emit('apply-manual-chapter-summary-hint', payload); + return; + } + if (review.safeApplyScope === 'optionBiasHint') { + emit('apply-manual-option-bias-hint', payload); + return; + } + if (review.safeApplyScope === 'plotAugmentationHint') { + emit('apply-manual-plot-augmentation-hint', payload); + return; + } + emit('apply-manual-plot-text-hint', payload); } diff --git a/src/components/GameRuntimeView.vue b/src/components/GameRuntimeView.vue index ed4f0e3..b6838a4 100644 --- a/src/components/GameRuntimeView.vue +++ b/src/components/GameRuntimeView.vue @@ -125,12 +125,18 @@ :no-name-mode="noNameMode" :is-dev-mode="isDevMode" :manual-apply-segment="manualApplySegment" + :manual-apply-summary="manualApplySummary" + :manual-apply-diagnostics="manualApplyDiagnostics" + :manual-apply-plot-augmentations="manualApplyPlotAugmentations" @close="showNoNameDebugConsole = false" @clear-traces="gameStore.clearNoNameTraces()" @set-no-name-mode="setNoNameMode" @mark-controlled-output-review="gameStore.markNoNameControlledOutputReview" @resolve-second-guardrail="gameStore.resolveNoNameSecondGuardrail" @apply-manual-plot-text-hint="gameStore.applyNoNameManualPlotTextHint" + @apply-manual-chapter-summary-hint="gameStore.applyNoNameManualChapterSummaryHint" + @apply-manual-option-bias-hint="gameStore.applyNoNameManualOptionBiasHint" + @apply-manual-plot-augmentation-hint="gameStore.applyNoNameManualPlotAugmentationHint" /> (() text: chapter.content[segmentIndex] ?? '', }; }); +const manualApplySummary = computed(() => { + const chapter = gameStore.plotState?.current_chapter; + if (!chapter) { + return null; + } + return { + chapterIndex: chapter.index, + summary: chapter.summary ?? '', + }; +}); +const manualApplyDiagnostics = computed(() => { + const chapter = gameStore.plotState?.current_chapter; + if (!chapter) { + return null; + } + return { + chapterIndex: chapter.index, + diagnostics: gameStore.plotState?.last_generation_diagnostics ?? '', + }; +}); +const manualApplyPlotAugmentations = computed(() => { + const chapter = gameStore.plotState?.current_chapter; + if (!chapter) { + return null; + } + return { + chapterIndex: chapter.index, + hints: [...(gameStore.plotState?.pending_plot_augmentation_hints ?? [])], + }; +}); const refreshNoNameMode = async () => { try { diff --git a/src/components/InfoTabsDialog.vue b/src/components/InfoTabsDialog.vue index 4c22929..8eb96ca 100644 --- a/src/components/InfoTabsDialog.vue +++ b/src/components/InfoTabsDialog.vue @@ -418,7 +418,13 @@ class="rounded border px-2 py-1" >

    - {{ item.target }} · {{ item.outcome }} + {{ applyExecutionDisplay(item).targetLabel }} · {{ applyExecutionDisplay(item).outcomeLabel }} +

    +

    + 原始记录:{{ item.target }} / {{ item.outcome }}

    props.debugNoNameTransitions ?? [] const debugNoNamePlans = computed(() => props.debugNoNamePlans ?? []); const debugNoNameExecutions = computed(() => props.debugNoNameExecutions ?? []); const debugNoNameScopes = computed(() => props.debugNoNameScopes ?? []); + +function applyExecutionDisplay(item: NoNameExecutionView) { + return formatNoNameApplyExecutionRecord(item); +}