1use crate::{Observation, SignalType};
4
5pub struct TurnObserver {
7 session_id: Option<String>,
9 turn_number: u32,
11 observations: Vec<Observation>,
13}
14
15impl TurnObserver {
16 pub fn new(session_id: Option<String>) -> Self {
18 Self {
19 session_id,
20 turn_number: 0,
21 observations: Vec::new(),
22 }
23 }
24
25 pub fn find_marker(text: &str, markers: &[&str]) -> Option<usize> {
30 let lower = text.to_lowercase();
31 for marker in markers {
32 if let Some(pos) = lower.find(&marker.to_lowercase()) {
33 return Some(pos);
34 }
35 }
36 None
37 }
38
39 pub fn capture_window(text: &str, marker_pos: usize, window_bytes: usize) -> String {
44 let half = window_bytes / 2;
45 let text_len = text.len();
46
47 let start = marker_pos.saturating_sub(half);
48 let end = (marker_pos + half).min(text_len);
49
50 let start = text
51 .char_indices()
52 .rev()
53 .find(|(i, _)| *i <= start)
54 .map(|(i, _)| i)
55 .unwrap_or(0);
56
57 let end = text
58 .char_indices()
59 .find(|(i, _)| *i >= end)
60 .map(|(i, c)| (i + c.len_utf8()).min(text_len))
61 .unwrap_or(text_len);
62
63 let window = &text[start..end];
64
65 let mut result = String::with_capacity(window.len() + 8);
66 if start > 0 {
67 result.push_str("...");
68 }
69 result.push_str(window);
70 if end < text_len {
71 result.push_str("...");
72 }
73 result
74 }
75
76 #[deprecated(
78 since = "0.2.0",
79 note = "Use find_marker + capture_window instead. This function keeps the head of the string, losing the actual signal."
80 )]
81 #[allow(deprecated)]
82 pub fn truncate_context(context: String, max_bytes: usize) -> String {
83 if max_bytes == 0 {
84 return format!("... [truncated, original was {} bytes]", context.len());
85 }
86 let byte_len = context.len();
87 if byte_len <= max_bytes {
88 return context;
89 }
90 let marker = format!("... [truncated, original was {byte_len} bytes]");
91 let marker_len = marker.len();
92 if marker_len >= max_bytes {
93 return marker;
94 }
95 let truncate_at = max_bytes - marker_len;
96 let mut result = String::with_capacity(max_bytes);
97 result.push_str(&context[..truncate_at]);
98 result.push_str(&marker);
99 result
100 }
101
102 pub fn start_turn(&mut self) {
104 self.turn_number += 1;
105 self.observations.clear();
106 }
107
108 pub fn record_correction(&mut self, context: String, intensity: f64) {
110 let obs = Observation::new(
111 SignalType::Correction,
112 intensity.clamp(0.0, 1.0),
113 context,
114 self.session_id.clone(),
115 Some(self.turn_number),
116 );
117 self.observations.push(obs);
118 }
119
120 pub fn record_error(&mut self, context: String, intensity: f64) {
122 let obs = Observation::new(
123 SignalType::Error,
124 intensity.clamp(0.0, 1.0),
125 context,
126 self.session_id.clone(),
127 Some(self.turn_number),
128 );
129 self.observations.push(obs);
130 }
131
132 pub fn record_satisfaction(&mut self, context: String, intensity: f64) {
134 let obs = Observation::new(
135 SignalType::Satisfaction,
136 intensity.clamp(0.0, 1.0),
137 context,
138 self.session_id.clone(),
139 Some(self.turn_number),
140 );
141 self.observations.push(obs);
142 }
143
144 pub fn record_inefficiency(&mut self, context: String, intensity: f64) {
146 let obs = Observation::new(
147 SignalType::Inefficiency,
148 intensity.clamp(0.0, 1.0),
149 context,
150 self.session_id.clone(),
151 Some(self.turn_number),
152 );
153 self.observations.push(obs);
154 }
155
156 pub fn current_observations(&self) -> &[Observation] {
158 &self.observations
159 }
160
161 pub fn turn_number(&self) -> u32 {
163 self.turn_number
164 }
165
166 pub fn session_id(&self) -> Option<&str> {
168 self.session_id.as_deref()
169 }
170
171 pub fn drain_observations(&mut self) -> Vec<Observation> {
173 std::mem::take(&mut self.observations)
174 }
175}
176
177#[cfg(test)]
178#[allow(warnings)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_turn_observer_new() {
184 let observer = TurnObserver::new(Some("session-1".to_string()));
185 assert_eq!(observer.turn_number(), 0);
186 assert_eq!(observer.session_id(), Some("session-1"));
187 }
188
189 #[test]
190 fn test_record_signals() {
191 let mut observer = TurnObserver::new(None);
192 observer.start_turn();
193
194 observer.record_correction("User said to use functional style".to_string(), 0.8);
195 observer.record_error("File not found".to_string(), 0.5);
196 observer.record_satisfaction("Good response".to_string(), 0.9);
197 observer.record_inefficiency("Took too many steps".to_string(), 0.3);
198
199 let observations = observer.current_observations();
200 assert_eq!(observations.len(), 4);
201 assert_eq!(observations[0].signal_type, SignalType::Correction);
202 assert_eq!(observations[1].signal_type, SignalType::Error);
203 assert_eq!(observations[2].signal_type, SignalType::Satisfaction);
204 assert_eq!(observations[3].signal_type, SignalType::Inefficiency);
205 }
206
207 #[test]
208 fn test_drain_observations() {
209 let mut observer = TurnObserver::new(None);
210 observer.start_turn();
211 observer.record_correction("test".to_string(), 0.5);
212
213 let drained = observer.drain_observations();
214 assert_eq!(drained.len(), 1);
215 assert!(observer.current_observations().is_empty());
216 }
217
218 #[test]
219 fn test_turn_increment() {
220 let mut observer = TurnObserver::new(None);
221 observer.start_turn();
222 assert_eq!(observer.turn_number(), 1);
223
224 observer.start_turn();
225 assert_eq!(observer.turn_number(), 2);
226 }
227
228 #[test]
229 #[allow(deprecated)]
230 fn test_truncate_context_under_limit_unchanged() {
231 let input = "short text".to_string();
232 let result = TurnObserver::truncate_context(input.clone(), 4096);
233 assert_eq!(result, input);
234 }
235
236 #[test]
237 #[allow(deprecated)]
238 fn test_truncate_context_over_limit_truncated_with_marker() {
239 let input = "a".repeat(5000);
240 let result = TurnObserver::truncate_context(input.clone(), 4096);
241 assert!(result.len() <= 4096);
242 assert!(result.contains("[truncated, original was 5000 bytes]"));
243 }
244
245 #[test]
246 #[allow(deprecated)]
247 fn test_truncate_context_exact_limit_unchanged() {
248 let input = "a".repeat(100);
249 let result = TurnObserver::truncate_context(input.clone(), 100);
250 assert_eq!(result, input);
251 }
252
253 #[test]
254 #[allow(deprecated)]
255 fn test_truncate_context_empty_max_bytes_returns_marker_only() {
256 let input = "some context".to_string();
257 let result = TurnObserver::truncate_context(input, 0);
258 assert!(result.contains("[truncated, original was 12 bytes]"));
259 }
260
261 #[test]
264 fn test_find_marker_returns_byte_offset() {
265 let text = "Hello world, don't do that please";
266 let pos = TurnObserver::find_marker(text, &["don't", "do not"]);
267 assert_eq!(pos, Some(13));
268 }
269
270 #[test]
271 fn test_find_marker_case_insensitive() {
272 let text = "Hello world, DON'T do that please";
273 let pos = TurnObserver::find_marker(text, &["don't"]);
274 assert_eq!(pos, Some(13));
275 }
276
277 #[test]
278 fn test_find_marker_chinese() {
279 let text = "前面很多内容 不要用 sed 后面更多内容";
280 let pos = TurnObserver::find_marker(text, &["不要用 sed"]);
281 assert!(pos.is_some());
282 assert!(text[pos.expect("operation should succeed")..].starts_with("不要用 sed"));
283 }
284
285 #[test]
286 fn test_find_marker_not_found() {
287 let text = "Hello world, please continue";
288 let pos = TurnObserver::find_marker(text, &["don't", "do not"]);
289 assert_eq!(pos, None);
290 }
291
292 #[test]
293 fn test_capture_window_marker_in_center() {
294 let text = "AAAAAAAAAA marker BBBBBBBBBB";
295 let pos = text.find("marker").expect("operation should succeed");
296 let window = TurnObserver::capture_window(text, pos, 20);
297
298 assert!(
299 window.contains("marker"),
300 "window={window:?} len={}",
301 window.len()
302 );
303 }
304
305 #[test]
306 fn test_capture_window_marker_at_start() {
307 let text = "marker BBBBBBBBBBBBBBBBBBBB";
308 let window = TurnObserver::capture_window(text, 0, 20);
309
310 assert!(window.starts_with("marker"));
311 assert!(!window.starts_with("..."));
312 }
313
314 #[test]
315 fn test_capture_window_marker_at_end() {
316 let text = "AAAAAAAAAAAAAAAAAAAA marker";
317 let pos = text.find("marker").expect("operation should succeed");
318 let window = TurnObserver::capture_window(text, pos, 20);
319
320 assert!(window.contains("marker"));
321 assert!(window.ends_with("marker"));
322 }
323
324 #[test]
325 fn test_capture_window_5mb_input_small_output() {
326 let prefix = "x".repeat(5 * 1024 * 1024);
327 let text = format!("{}{}", prefix, "不要用 sed");
328 let pos = text.find("不要用 sed").expect("operation should succeed");
329 let window = TurnObserver::capture_window(&text, pos, 400);
330
331 assert!(
332 window.len() < 500,
333 "window {} bytes exceeds 500 for 5MB input",
334 window.len()
335 );
336 assert!(
337 window.contains("不要用 sed"),
338 "window must contain marker, got: {window:?}"
339 );
340 }
341
342 #[test]
343 fn test_capture_window_respects_utf8_boundaries() {
344 let text = "AAAA 你好世界 marker 你好世界 BBBB";
345 let pos = text.find("marker").expect("operation should succeed");
346 let window = TurnObserver::capture_window(text, pos, 30);
347
348 assert!(window.contains("marker"));
349 assert!(window.is_char_boundary(0));
350 }
351
352 #[test]
353 fn test_capture_window_no_marker_uses_full_text() {
354 let text = "short text";
355 let window = TurnObserver::capture_window(text, 0, 400);
356 assert_eq!(window, "short text");
357 }
358
359 #[test]
360 fn test_capture_window_5mb_with_chinese_tail_contains_marker() {
361 let prefix = "system_prompt content ".repeat(200_000);
362 let text = format!("{}{}", prefix, "不要用 sed");
363 let pos = text.find("不要用 sed").expect("operation should succeed");
364 let window = TurnObserver::capture_window(&text, pos, 400);
365
366 assert!(
367 window.len() < 500,
368 "window {} bytes exceeds 500",
369 window.len()
370 );
371 assert!(
372 window.contains("不要用 sed"),
373 "window must contain '不要用 sed', got: {window:?}"
374 );
375 }
376}