Skip to content

Commit 29a7e69

Browse files
sangggggclaude
andauthored
feat: Add dirty check and caching for analytics requests (#62)
Implements session-level dirty checking to prevent redundant analysis when sessions haven't changed. When a session is unchanged since the last completed analysis, the system now: - Returns cached results automatically with informative messages - Provides guidance on how to force new analysis if needed - Bypasses dirty check when custom prompts are provided Includes comprehensive test coverage for dirty check behavior and custom prompt bypass scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1c1456 commit 29a7e69

File tree

2 files changed

+206
-5
lines changed

2 files changed

+206
-5
lines changed

src/cli/analytics.rs

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,66 @@ async fn execute_analysis_for_session(
114114
println!("Starting analysis for session: {session_id}");
115115

116116
// Create analysis request
117-
let request = service
117+
let request = match service
118118
.create_analysis_request(
119119
session_id.clone(),
120120
None, // created_by
121-
custom_prompt,
121+
custom_prompt.clone(),
122122
)
123123
.await
124-
.map_err(|e| anyhow::anyhow!("Failed to create analysis request: {e}"))?;
124+
{
125+
Ok(request) => request,
126+
Err(e) => {
127+
let error_msg = e.to_string();
128+
// Check if this is a dirty check error (session unchanged)
129+
if error_msg.contains("has not been modified since last analysis") {
130+
println!("ℹ Session has not changed since last analysis");
131+
println!("Retrieving cached results...\n");
132+
133+
// Find the latest completed request
134+
let requests = service
135+
.list_analyses(Some(session_id.clone()), None)
136+
.await
137+
.map_err(|e| anyhow::anyhow!("Failed to list analyses: {e}"))?;
138+
139+
if let Some(latest_request) = requests
140+
.iter()
141+
.filter(|r| matches!(r.status, OperationStatus::Completed))
142+
.max_by_key(|r| r.completed_at.as_ref())
143+
{
144+
// Determine output format
145+
let output_format = if plain {
146+
OutputFormat::Plain
147+
} else {
148+
OutputFormat::parse(&format)
149+
};
150+
151+
// Get and display cached results
152+
if let Some(analysis) = service
153+
.get_analysis_result(latest_request.id.clone())
154+
.await
155+
.map_err(|e| anyhow::anyhow!("Failed to get cached analysis: {e}"))?
156+
{
157+
print_unified_analysis(&analysis, output_format).await?;
158+
println!(
159+
"\n✓ Showing cached results from: {}",
160+
latest_request
161+
.completed_at
162+
.map(|dt| dt.to_rfc3339())
163+
.unwrap_or_else(|| "unknown".to_string())
164+
);
165+
println!(" To force new analysis, use: --custom-prompt \"your prompt\"");
166+
return Ok(());
167+
}
168+
}
169+
170+
return Err(anyhow::anyhow!("No cached results found"));
171+
}
172+
173+
// Other errors, propagate them
174+
return Err(anyhow::anyhow!("Failed to create analysis request: {e}"));
175+
}
176+
};
125177

126178
if background {
127179
println!("Analysis request created: {}", request.id);

src/services/analytics_request_service.rs

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::sync::Arc;
22

3-
use crate::database::{AnalyticsRepository, AnalyticsRequestRepository, DatabaseManager};
3+
use crate::database::{
4+
AnalyticsRepository, AnalyticsRequestRepository, ChatSessionRepository, DatabaseManager,
5+
};
46
use crate::models::{Analytics, AnalyticsRequest, OperationStatus};
57
use crate::services::analytics_service::AnalyticsService;
68
use 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

Comments
 (0)