a3s_code_core/session_review/
scenario.rs1use super::{
8 ReviewSubjectV1, SessionReviewError, SessionReviewFindingV1, SessionReviewSeverityV1,
9 SessionReviewStatusV1, SessionReviewStoreV1,
10};
11use std::collections::HashMap;
12use std::sync::{Arc, RwLock};
13
14pub const SCENARIO_REPLY_TRANSCRIPT: &str = "reply.transcript";
16
17pub struct ReplyTranscriptScenario;
19
20impl ReviewScenario for ReplyTranscriptScenario {
21 fn scenario_id(&self) -> &str {
22 SCENARIO_REPLY_TRANSCRIPT
23 }
24
25 fn triggers(&self) -> ReviewTriggerPolicy {
26 ReviewTriggerPolicy::AfterMainSuccess
27 }
28
29 fn injects_into_main(&self) -> bool {
30 true
31 }
32}
33
34pub fn register_default_scenarios(
36 registry: &ReviewScenarioRegistry,
37) -> Result<(), SessionReviewError> {
38 registry.register(Arc::new(ReplyTranscriptScenario))
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum ReviewTriggerPolicy {
44 AfterMainSuccess,
46 TurnEndPeriodic,
48 ExplicitRequest,
50 Never,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct ReviewFindingDraft {
57 pub finding_id: String,
58 pub severity: SessionReviewSeverityV1,
59 pub category: String,
60 pub claim: String,
61 pub evidence: String,
62 pub subject: ReviewSubjectV1,
63 pub quote: Option<String>,
64 pub suggestion: Option<String>,
65}
66
67pub trait ReviewScenario: Send + Sync {
69 fn scenario_id(&self) -> &str;
70
71 fn triggers(&self) -> ReviewTriggerPolicy {
72 ReviewTriggerPolicy::ExplicitRequest
73 }
74
75 fn injects_into_main(&self) -> bool {
77 true
78 }
79}
80
81#[derive(Default)]
83pub struct ReviewScenarioRegistry {
84 scenarios: RwLock<HashMap<String, Arc<dyn ReviewScenario>>>,
85}
86
87impl ReviewScenarioRegistry {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn register(&self, scenario: Arc<dyn ReviewScenario>) -> Result<(), SessionReviewError> {
93 let id = scenario.scenario_id().to_owned();
94 super::validate_id("scenarioId", &id)?;
95 let mut guard = self
96 .scenarios
97 .write()
98 .map_err(|_| SessionReviewError::Serialization("scenario registry poisoned".into()))?;
99 guard.insert(id, scenario);
100 Ok(())
101 }
102
103 pub fn get(&self, scenario_id: &str) -> Option<Arc<dyn ReviewScenario>> {
104 self.scenarios
105 .read()
106 .ok()
107 .and_then(|guard| guard.get(scenario_id).cloned())
108 }
109
110 pub fn contains(&self, scenario_id: &str) -> bool {
111 self.scenarios
112 .read()
113 .ok()
114 .is_some_and(|guard| guard.contains_key(scenario_id))
115 }
116
117 pub fn scenario_ids(&self) -> Vec<String> {
118 self.scenarios
119 .read()
120 .map(|guard| {
121 let mut ids: Vec<_> = guard.keys().cloned().collect();
122 ids.sort();
123 ids
124 })
125 .unwrap_or_default()
126 }
127}
128
129pub fn admit_finding_drafts(
131 store: &mut SessionReviewStoreV1,
132 scenario_id: &str,
133 source_review_id: &str,
134 observed_at_ms: u64,
135 drafts: Vec<ReviewFindingDraft>,
136) -> Result<usize, SessionReviewError> {
137 super::validate_id("scenarioId", scenario_id)?;
138 super::validate_id("sourceReviewId", source_review_id)?;
139 let mut admitted = 0;
140 for draft in drafts {
141 let mut finding = SessionReviewFindingV1::new(
142 draft.finding_id,
143 store.session_id.clone(),
144 draft.severity,
145 draft.category,
146 draft.claim,
147 draft.evidence,
148 draft.subject,
149 source_review_id,
150 observed_at_ms,
151 )?;
152 finding = finding.with_scenario_id(scenario_id)?;
153 if let Some(quote) = draft.quote {
154 finding = finding.with_quote(quote)?;
155 }
156 if let Some(suggestion) = draft.suggestion {
157 finding = finding.with_suggestion(suggestion)?;
158 }
159 debug_assert!(matches!(finding.status, SessionReviewStatusV1::Pending));
161 store.upsert(finding)?;
162 admitted += 1;
163 }
164 Ok(admitted)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::session_review::SessionReviewAnchorV1;
171
172 struct DummyScenario(&'static str);
173
174 impl ReviewScenario for DummyScenario {
175 fn scenario_id(&self) -> &str {
176 self.0
177 }
178
179 fn triggers(&self) -> ReviewTriggerPolicy {
180 ReviewTriggerPolicy::AfterMainSuccess
181 }
182 }
183
184 #[test]
185 fn registry_registers_and_lists_scenarios() {
186 let registry = ReviewScenarioRegistry::new();
187 registry
188 .register(Arc::new(DummyScenario("reply.transcript")))
189 .unwrap();
190 registry
191 .register(Arc::new(DummyScenario("office.contract")))
192 .unwrap();
193 assert!(registry.contains("office.contract"));
194 assert_eq!(
195 registry.scenario_ids(),
196 vec!["office.contract".to_owned(), "reply.transcript".to_owned()]
197 );
198 }
199
200 #[test]
201 fn register_default_scenarios_installs_reply_transcript() {
202 let registry = ReviewScenarioRegistry::new();
203 register_default_scenarios(®istry).unwrap();
204 assert!(registry.contains(SCENARIO_REPLY_TRANSCRIPT));
205 let scenario = registry.get(SCENARIO_REPLY_TRANSCRIPT).unwrap();
206 assert!(scenario.injects_into_main());
207 assert_eq!(scenario.triggers(), ReviewTriggerPolicy::AfterMainSuccess);
208 }
209
210 #[test]
211 fn admit_drafts_tags_scenario_and_stays_pending() {
212 let mut store = SessionReviewStoreV1::empty("session-1").unwrap();
213 let draft = ReviewFindingDraft {
214 finding_id: "f-1".into(),
215 severity: SessionReviewSeverityV1::Warning,
216 category: "numeric".into(),
217 claim: "claim".into(),
218 evidence: "evidence".into(),
219 subject: ReviewSubjectV1::transcript(SessionReviewAnchorV1::new("turn-1").unwrap()),
220 quote: None,
221 suggestion: None,
222 };
223 let n = admit_finding_drafts(
224 &mut store,
225 "office.contract",
226 "review-job-9",
227 1_000,
228 vec![draft],
229 )
230 .unwrap();
231 assert_eq!(n, 1);
232 assert_eq!(store.findings[0].scenario_id, "office.contract");
233 assert_eq!(store.findings[0].status, SessionReviewStatusV1::Pending);
234 assert_eq!(store.pending_for_scenario("office.contract").len(), 1);
235 assert!(store.pending_for_scenario("reply.transcript").is_empty());
236 }
237}