11use std:: sync:: Arc ;
22
3- use crate :: database:: { AnalyticsRepository , AnalyticsRequestRepository , DatabaseManager } ;
3+ use crate :: database:: {
4+ AnalyticsRepository , AnalyticsRequestRepository , ChatSessionRepository , DatabaseManager ,
5+ } ;
46use crate :: models:: { Analytics , AnalyticsRequest , OperationStatus } ;
57use crate :: services:: analytics_service:: AnalyticsService ;
68use crate :: services:: google_ai:: GoogleAiClient ;
@@ -32,7 +34,7 @@ impl AnalyticsRequestService {
3234 ) -> Result < AnalyticsRequest , Box < dyn std:: error:: Error + Send + Sync > > {
3335 // Check if there's already an active request for this session
3436 let existing_requests = self . request_repo . find_by_session_id ( & session_id) . await ?;
35- for existing_request in existing_requests {
37+ for existing_request in & existing_requests {
3638 match existing_request. status {
3739 OperationStatus :: Pending | OperationStatus :: Running => {
3840 return Err ( format ! (
@@ -44,6 +46,42 @@ impl AnalyticsRequestService {
4446 }
4547 }
4648
49+ // Dirty check: Check if session has been updated since last completed analysis
50+ if custom_prompt. is_none ( ) {
51+ // Only perform dirty check if there's no custom prompt
52+ // (custom prompts should always create new requests)
53+ if let Some ( latest_completed) = existing_requests
54+ . iter ( )
55+ . filter ( |r| matches ! ( r. status, OperationStatus :: Completed ) )
56+ . max_by_key ( |r| r. completed_at . as_ref ( ) )
57+ {
58+ // Get the session to check its updated_at timestamp
59+ let session_repo = ChatSessionRepository :: new ( & self . db_manager ) ;
60+ if let Ok ( Some ( session) ) = session_repo
61+ . get_by_id (
62+ & uuid:: Uuid :: parse_str ( & session_id)
63+ . map_err ( |e| format ! ( "Invalid session ID: {e}" ) ) ?,
64+ )
65+ . await
66+ {
67+ if let Some ( completed_at) = latest_completed. completed_at {
68+ // If session hasn't been updated since the last analysis, return existing request
69+ if session. updated_at <= completed_at {
70+ tracing:: info!(
71+ session_id = %session_id,
72+ last_analysis = %completed_at,
73+ session_updated = %session. updated_at,
74+ "Session unchanged since last analysis - using cached results"
75+ ) ;
76+ return Err ( format ! (
77+ "Session {session_id} has not been modified since last analysis (completed at {completed_at}). Use 'retrochat analytics show {session_id}' to view cached results, or provide --custom-prompt to force new analysis."
78+ ) . into ( ) ) ;
79+ }
80+ }
81+ }
82+ }
83+ }
84+
4785 let request = AnalyticsRequest :: new ( session_id, created_by, custom_prompt) ;
4886
4987 self . request_repo . create ( & request) . await ?;
@@ -372,4 +410,115 @@ mod tests {
372410 assert_eq ! ( status. id, request. id) ;
373411 assert_eq ! ( status. status, OperationStatus :: Pending ) ;
374412 }
413+
414+ #[ tokio:: test]
415+ async fn test_dirty_check_prevents_duplicate_analysis ( ) {
416+ let database = Database :: new_in_memory ( ) . await . unwrap ( ) ;
417+ database. initialize ( ) . await . unwrap ( ) ;
418+
419+ // Create a test project
420+ let project_repo = crate :: database:: ProjectRepository :: new ( & database. manager ) ;
421+ let test_project = crate :: models:: Project :: new ( "test_project3" . to_string ( ) ) ;
422+ project_repo. create ( & test_project) . await . unwrap ( ) ;
423+
424+ // Create a test session
425+ let session_repo = crate :: database:: ChatSessionRepository :: new ( & database. manager ) ;
426+ let test_session = crate :: models:: ChatSession :: new (
427+ crate :: models:: Provider :: ClaudeCode ,
428+ "/test/chat3.jsonl" . to_string ( ) ,
429+ "test_hash3" . to_string ( ) ,
430+ chrono:: Utc :: now ( ) ,
431+ )
432+ . with_project ( "test_project3" . to_string ( ) ) ;
433+ session_repo. create ( & test_session) . await . unwrap ( ) ;
434+
435+ let service = AnalyticsRequestService :: new (
436+ Arc :: new ( database. manager . clone ( ) ) ,
437+ GoogleAiClient :: new ( GoogleAiConfig :: new ( "test-api-key" . to_string ( ) ) ) . unwrap ( ) ,
438+ ) ;
439+
440+ let session_id = test_session. id . to_string ( ) ;
441+
442+ // Create first request
443+ let first_request = service
444+ . create_analysis_request ( session_id. clone ( ) , None , None )
445+ . await
446+ . unwrap ( ) ;
447+
448+ // Mark it as completed
449+ let mut completed_request = first_request. clone ( ) ;
450+ completed_request. mark_completed ( ) ;
451+ let request_repo = AnalyticsRequestRepository :: new ( Arc :: new ( database. manager . clone ( ) ) ) ;
452+ request_repo. update ( & completed_request) . await . unwrap ( ) ;
453+
454+ // Wait a bit to ensure timestamp difference
455+ tokio:: time:: sleep ( tokio:: time:: Duration :: from_millis ( 10 ) ) . await ;
456+
457+ // Try to create second request without updating the session
458+ // This should fail with dirty check error
459+ let result = service
460+ . create_analysis_request ( session_id. clone ( ) , None , None )
461+ . await ;
462+
463+ assert ! ( result. is_err( ) ) ;
464+ let error_msg = result. unwrap_err ( ) . to_string ( ) ;
465+ assert ! ( error_msg. contains( "has not been modified since last analysis" ) ) ;
466+ }
467+
468+ #[ tokio:: test]
469+ async fn test_dirty_check_bypassed_with_custom_prompt ( ) {
470+ let database = Database :: new_in_memory ( ) . await . unwrap ( ) ;
471+ database. initialize ( ) . await . unwrap ( ) ;
472+
473+ // Create a test project
474+ let project_repo = crate :: database:: ProjectRepository :: new ( & database. manager ) ;
475+ let test_project = crate :: models:: Project :: new ( "test_project4" . to_string ( ) ) ;
476+ project_repo. create ( & test_project) . await . unwrap ( ) ;
477+
478+ // Create a test session
479+ let session_repo = crate :: database:: ChatSessionRepository :: new ( & database. manager ) ;
480+ let test_session = crate :: models:: ChatSession :: new (
481+ crate :: models:: Provider :: ClaudeCode ,
482+ "/test/chat4.jsonl" . to_string ( ) ,
483+ "test_hash4" . to_string ( ) ,
484+ chrono:: Utc :: now ( ) ,
485+ )
486+ . with_project ( "test_project4" . to_string ( ) ) ;
487+ session_repo. create ( & test_session) . await . unwrap ( ) ;
488+
489+ let service = AnalyticsRequestService :: new (
490+ Arc :: new ( database. manager . clone ( ) ) ,
491+ GoogleAiClient :: new ( GoogleAiConfig :: new ( "test-api-key" . to_string ( ) ) ) . unwrap ( ) ,
492+ ) ;
493+
494+ let session_id = test_session. id . to_string ( ) ;
495+
496+ // Create first request
497+ let first_request = service
498+ . create_analysis_request ( session_id. clone ( ) , None , None )
499+ . await
500+ . unwrap ( ) ;
501+
502+ // Mark it as completed
503+ let mut completed_request = first_request. clone ( ) ;
504+ completed_request. mark_completed ( ) ;
505+ let request_repo = AnalyticsRequestRepository :: new ( Arc :: new ( database. manager . clone ( ) ) ) ;
506+ request_repo. update ( & completed_request) . await . unwrap ( ) ;
507+
508+ // Create second request with custom prompt (should bypass dirty check)
509+ let result = service
510+ . create_analysis_request (
511+ session_id. clone ( ) ,
512+ None ,
513+ Some ( "Custom analysis prompt" . to_string ( ) ) ,
514+ )
515+ . await ;
516+
517+ assert ! ( result. is_ok( ) ) ;
518+ let second_request = result. unwrap ( ) ;
519+ assert_eq ! (
520+ second_request. custom_prompt,
521+ Some ( "Custom analysis prompt" . to_string( ) )
522+ ) ;
523+ }
375524}
0 commit comments