1use crate::config::Config;
7use crate::error::StorageError;
8use crate::storage::DbPool;
9use serde::Serialize;
10
11#[derive(Debug, Clone, Serialize)]
13pub struct AuthorContext {
14 pub author_username: String,
15 pub author_id: Option<String>,
16 pub interaction_summary: InteractionSummary,
17 pub conversation_history: Vec<ConversationRecord>,
18 pub topic_affinity: Vec<TopicAffinity>,
19 pub risk_signals: Vec<RiskSignal>,
20 pub response_metrics: ResponseMetrics,
21}
22
23#[derive(Debug, Clone, Serialize)]
25pub struct InteractionSummary {
26 pub total_replies_sent: i64,
27 pub replies_today: i64,
28 pub first_interaction: Option<String>,
29 pub last_interaction: Option<String>,
30 pub distinct_days_active: i64,
31}
32
33#[derive(Debug, Clone, Serialize)]
35pub struct ConversationRecord {
36 pub tweet_id: String,
37 pub tweet_content: String,
38 pub reply_content: String,
39 pub reply_status: String,
40 pub created_at: String,
41 pub performance: Option<PerformanceSnapshot>,
42}
43
44#[derive(Debug, Clone, Serialize)]
46pub struct PerformanceSnapshot {
47 pub likes: i64,
48 pub replies_received: i64,
49 pub impressions: i64,
50 pub performance_score: f64,
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct TopicAffinity {
56 pub keyword: String,
57 pub mention_count: i64,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct RiskSignal {
63 pub signal_type: String,
64 pub severity: String,
65 pub description: String,
66}
67
68#[derive(Debug, Clone, Serialize)]
70pub struct ResponseMetrics {
71 pub replies_with_engagement: i64,
72 pub replies_measured: i64,
73 pub response_rate: f64,
74 pub avg_performance_score: f64,
75}
76
77type ConvRow = (String, String, String, String, String, Option<String>);
78type PerfRow = (i64, i64, i64, f64);
79
80pub async fn get_author_context(
84 pool: &DbPool,
85 identifier: &str,
86 config: &Config,
87) -> Result<AuthorContext, StorageError> {
88 let username = identifier.trim_start_matches('@');
89
90 let (author_id, author_username) = resolve_author(pool, username, identifier).await?;
92
93 let interaction_summary = query_interaction_summary(pool, &author_id, &author_username).await?;
95
96 let conversation_history = query_conversation_history(pool, &author_username).await?;
98
99 let response_metrics = compute_response_metrics(&conversation_history);
101
102 let topic_affinity = query_topic_affinity(pool, &author_username).await?;
104
105 let risk_signals = generate_risk_signals(
107 &interaction_summary,
108 &response_metrics,
109 config.limits.max_replies_per_author_per_day,
110 );
111
112 Ok(AuthorContext {
113 author_username,
114 author_id,
115 interaction_summary,
116 conversation_history,
117 topic_affinity,
118 risk_signals,
119 response_metrics,
120 })
121}
122
123async fn resolve_author(
124 pool: &DbPool,
125 username: &str,
126 raw_identifier: &str,
127) -> Result<(Option<String>, String), StorageError> {
128 let row: Option<(String,)> =
130 sqlx::query_as("SELECT author_id FROM discovered_tweets WHERE author_username = ? LIMIT 1")
131 .bind(username)
132 .fetch_optional(pool)
133 .await
134 .map_err(|e| StorageError::Query { source: e })?;
135
136 if let Some((id,)) = row {
137 return Ok((Some(id), username.to_string()));
138 }
139
140 let row: Option<(String,)> =
142 sqlx::query_as("SELECT author_username FROM discovered_tweets WHERE author_id = ? LIMIT 1")
143 .bind(raw_identifier)
144 .fetch_optional(pool)
145 .await
146 .map_err(|e| StorageError::Query { source: e })?;
147
148 match row {
149 Some((uname,)) => Ok((Some(raw_identifier.to_string()), uname)),
150 None => Ok((None, username.to_string())),
151 }
152}
153
154async fn query_interaction_summary(
155 pool: &DbPool,
156 author_id: &Option<String>,
157 author_username: &str,
158) -> Result<InteractionSummary, StorageError> {
159 let row: Option<(i64, Option<String>, Option<String>, i64)> = sqlx::query_as(
160 "SELECT COALESCE(SUM(reply_count), 0), \
161 MIN(interaction_date), \
162 MAX(interaction_date), \
163 COUNT(DISTINCT interaction_date) \
164 FROM author_interactions \
165 WHERE author_id = ? OR author_username = ?",
166 )
167 .bind(author_id.as_deref().unwrap_or(""))
168 .bind(author_username)
169 .fetch_optional(pool)
170 .await
171 .map_err(|e| StorageError::Query { source: e })?;
172
173 let (total, first, last, distinct) = row.unwrap_or((0, None, None, 0));
174
175 let today_row: (i64,) = sqlx::query_as(
177 "SELECT COALESCE(SUM(reply_count), 0) \
178 FROM author_interactions \
179 WHERE (author_id = ? OR author_username = ?) \
180 AND interaction_date = date('now')",
181 )
182 .bind(author_id.as_deref().unwrap_or(""))
183 .bind(author_username)
184 .fetch_one(pool)
185 .await
186 .map_err(|e| StorageError::Query { source: e })?;
187
188 Ok(InteractionSummary {
189 total_replies_sent: total,
190 replies_today: today_row.0,
191 first_interaction: first,
192 last_interaction: last,
193 distinct_days_active: distinct,
194 })
195}
196
197async fn query_conversation_history(
198 pool: &DbPool,
199 author_username: &str,
200) -> Result<Vec<ConversationRecord>, StorageError> {
201 let rows: Vec<ConvRow> = sqlx::query_as(
202 "SELECT dt.id, SUBSTR(dt.content, 1, 200), \
203 rs.reply_content, rs.status, rs.created_at, rs.reply_tweet_id \
204 FROM replies_sent rs \
205 JOIN discovered_tweets dt ON dt.id = rs.target_tweet_id \
206 WHERE dt.author_username = ? \
207 ORDER BY rs.created_at DESC \
208 LIMIT 20",
209 )
210 .bind(author_username)
211 .fetch_all(pool)
212 .await
213 .map_err(|e| StorageError::Query { source: e })?;
214
215 let mut records = Vec::with_capacity(rows.len());
216 for (tweet_id, tweet_content, reply_content, status, created_at, reply_tweet_id) in rows {
217 let performance = if let Some(ref rtid) = reply_tweet_id {
218 query_reply_performance(pool, rtid).await?
219 } else {
220 None
221 };
222 records.push(ConversationRecord {
223 tweet_id,
224 tweet_content,
225 reply_content,
226 reply_status: status,
227 created_at,
228 performance,
229 });
230 }
231 Ok(records)
232}
233
234async fn query_reply_performance(
235 pool: &DbPool,
236 reply_tweet_id: &str,
237) -> Result<Option<PerformanceSnapshot>, StorageError> {
238 let row: Option<PerfRow> = sqlx::query_as(
239 "SELECT likes_received, replies_received, impressions, performance_score \
240 FROM reply_performance WHERE reply_id = ?",
241 )
242 .bind(reply_tweet_id)
243 .fetch_optional(pool)
244 .await
245 .map_err(|e| StorageError::Query { source: e })?;
246
247 Ok(
248 row.map(|(likes, replies, impressions, score)| PerformanceSnapshot {
249 likes,
250 replies_received: replies,
251 impressions,
252 performance_score: score,
253 }),
254 )
255}
256
257fn compute_response_metrics(history: &[ConversationRecord]) -> ResponseMetrics {
258 let measured = history.iter().filter(|c| c.performance.is_some()).count() as i64;
259 let with_engagement = history
260 .iter()
261 .filter(|c| {
262 c.performance
263 .as_ref()
264 .is_some_and(|p| p.likes > 0 || p.replies_received > 0)
265 })
266 .count() as i64;
267 let avg_score = if measured > 0 {
268 history
269 .iter()
270 .filter_map(|c| c.performance.as_ref())
271 .map(|p| p.performance_score)
272 .sum::<f64>()
273 / measured as f64
274 } else {
275 0.0
276 };
277 let rate = if measured > 0 {
278 with_engagement as f64 / measured as f64
279 } else {
280 0.0
281 };
282
283 ResponseMetrics {
284 replies_with_engagement: with_engagement,
285 replies_measured: measured,
286 response_rate: rate,
287 avg_performance_score: avg_score,
288 }
289}
290
291async fn query_topic_affinity(
292 pool: &DbPool,
293 author_username: &str,
294) -> Result<Vec<TopicAffinity>, StorageError> {
295 let rows: Vec<(String, i64)> = sqlx::query_as(
296 "SELECT matched_keyword, COUNT(*) as cnt \
297 FROM discovered_tweets \
298 WHERE author_username = ? \
299 AND matched_keyword IS NOT NULL AND matched_keyword != '' \
300 GROUP BY matched_keyword \
301 ORDER BY cnt DESC \
302 LIMIT 10",
303 )
304 .bind(author_username)
305 .fetch_all(pool)
306 .await
307 .map_err(|e| StorageError::Query { source: e })?;
308
309 Ok(rows
310 .into_iter()
311 .map(|(keyword, count)| TopicAffinity {
312 keyword,
313 mention_count: count,
314 })
315 .collect())
316}
317
318fn generate_risk_signals(
320 summary: &InteractionSummary,
321 metrics: &ResponseMetrics,
322 max_per_author_per_day: u32,
323) -> Vec<RiskSignal> {
324 let mut signals = Vec::new();
325
326 if summary.replies_today >= max_per_author_per_day as i64 {
327 signals.push(RiskSignal {
328 signal_type: "high_frequency_today".to_string(),
329 severity: "high".to_string(),
330 description: format!(
331 "Already sent {} replies today (limit: {})",
332 summary.replies_today, max_per_author_per_day
333 ),
334 });
335 }
336
337 if metrics.replies_measured >= 3 && metrics.response_rate < 0.1 {
338 signals.push(RiskSignal {
339 signal_type: "low_response_rate".to_string(),
340 severity: "medium".to_string(),
341 description: format!(
342 "Only {:.0}% of replies to this author received engagement ({}/{})",
343 metrics.response_rate * 100.0,
344 metrics.replies_with_engagement,
345 metrics.replies_measured
346 ),
347 });
348 }
349
350 if summary.total_replies_sent > 0 && metrics.replies_measured == 0 {
351 signals.push(RiskSignal {
352 signal_type: "no_measured_performance".to_string(),
353 severity: "low".to_string(),
354 description: "Replied before but no performance data collected yet".to_string(),
355 });
356 }
357
358 if summary.total_replies_sent == 0 {
359 signals.push(RiskSignal {
360 signal_type: "no_prior_interaction".to_string(),
361 severity: "low".to_string(),
362 description: "No prior interaction history with this author".to_string(),
363 });
364 }
365
366 signals
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::storage::init_test_db;
373
374 fn empty_summary() -> InteractionSummary {
375 InteractionSummary {
376 total_replies_sent: 0,
377 replies_today: 0,
378 first_interaction: None,
379 last_interaction: None,
380 distinct_days_active: 0,
381 }
382 }
383
384 fn active_summary() -> InteractionSummary {
385 InteractionSummary {
386 total_replies_sent: 10,
387 replies_today: 3,
388 first_interaction: Some("2026-01-01T00:00:00Z".to_string()),
389 last_interaction: Some("2026-03-15T00:00:00Z".to_string()),
390 distinct_days_active: 8,
391 }
392 }
393
394 fn zero_metrics() -> ResponseMetrics {
395 ResponseMetrics {
396 replies_with_engagement: 0,
397 replies_measured: 0,
398 response_rate: 0.0,
399 avg_performance_score: 0.0,
400 }
401 }
402
403 fn low_engagement_metrics() -> ResponseMetrics {
404 ResponseMetrics {
405 replies_with_engagement: 0,
406 replies_measured: 5,
407 response_rate: 0.0,
408 avg_performance_score: 10.0,
409 }
410 }
411
412 fn good_metrics() -> ResponseMetrics {
413 ResponseMetrics {
414 replies_with_engagement: 4,
415 replies_measured: 5,
416 response_rate: 0.8,
417 avg_performance_score: 75.0,
418 }
419 }
420
421 #[test]
426 fn compute_metrics_empty_history() {
427 let metrics = compute_response_metrics(&[]);
428 assert_eq!(metrics.replies_measured, 0);
429 assert_eq!(metrics.replies_with_engagement, 0);
430 assert_eq!(metrics.response_rate, 0.0);
431 assert_eq!(metrics.avg_performance_score, 0.0);
432 }
433
434 #[test]
435 fn compute_metrics_no_performance_data() {
436 let history = vec![ConversationRecord {
437 tweet_id: "t1".to_string(),
438 tweet_content: "Hello".to_string(),
439 reply_content: "Hi there!".to_string(),
440 reply_status: "sent".to_string(),
441 created_at: "2026-01-01T10:00:00Z".to_string(),
442 performance: None,
443 }];
444 let metrics = compute_response_metrics(&history);
445 assert_eq!(metrics.replies_measured, 0);
446 assert_eq!(metrics.avg_performance_score, 0.0);
447 }
448
449 #[test]
450 fn compute_metrics_with_performance() {
451 let history = vec![
452 ConversationRecord {
453 tweet_id: "t1".to_string(),
454 tweet_content: "Hello".to_string(),
455 reply_content: "Hi!".to_string(),
456 reply_status: "sent".to_string(),
457 created_at: "2026-01-01T10:00:00Z".to_string(),
458 performance: Some(PerformanceSnapshot {
459 likes: 5,
460 replies_received: 2,
461 impressions: 500,
462 performance_score: 80.0,
463 }),
464 },
465 ConversationRecord {
466 tweet_id: "t2".to_string(),
467 tweet_content: "World".to_string(),
468 reply_content: "Hey!".to_string(),
469 reply_status: "sent".to_string(),
470 created_at: "2026-01-02T10:00:00Z".to_string(),
471 performance: Some(PerformanceSnapshot {
472 likes: 0,
473 replies_received: 0,
474 impressions: 100,
475 performance_score: 20.0,
476 }),
477 },
478 ];
479 let metrics = compute_response_metrics(&history);
480 assert_eq!(metrics.replies_measured, 2);
481 assert_eq!(metrics.replies_with_engagement, 1);
482 assert!((metrics.response_rate - 0.5).abs() < 0.01);
483 assert!((metrics.avg_performance_score - 50.0).abs() < 0.01);
484 }
485
486 #[test]
487 fn compute_metrics_all_with_engagement() {
488 let history = vec![
489 ConversationRecord {
490 tweet_id: "t1".to_string(),
491 tweet_content: "A".to_string(),
492 reply_content: "B".to_string(),
493 reply_status: "sent".to_string(),
494 created_at: "2026-01-01T00:00:00Z".to_string(),
495 performance: Some(PerformanceSnapshot {
496 likes: 10,
497 replies_received: 3,
498 impressions: 1000,
499 performance_score: 90.0,
500 }),
501 },
502 ConversationRecord {
503 tweet_id: "t2".to_string(),
504 tweet_content: "C".to_string(),
505 reply_content: "D".to_string(),
506 reply_status: "sent".to_string(),
507 created_at: "2026-01-02T00:00:00Z".to_string(),
508 performance: Some(PerformanceSnapshot {
509 likes: 3,
510 replies_received: 0,
511 impressions: 200,
512 performance_score: 60.0,
513 }),
514 },
515 ];
516 let metrics = compute_response_metrics(&history);
517 assert_eq!(metrics.replies_with_engagement, 2);
518 assert!((metrics.response_rate - 1.0).abs() < 0.01);
519 }
520
521 #[test]
526 fn risk_no_prior_interaction() {
527 let signals = generate_risk_signals(&empty_summary(), &zero_metrics(), 5);
528 assert!(signals
529 .iter()
530 .any(|s| s.signal_type == "no_prior_interaction"));
531 assert_eq!(signals.len(), 1);
532 }
533
534 #[test]
535 fn risk_high_frequency_today() {
536 let mut summary = active_summary();
537 summary.replies_today = 5;
538 let signals = generate_risk_signals(&summary, &good_metrics(), 5);
539 assert!(signals
540 .iter()
541 .any(|s| s.signal_type == "high_frequency_today"));
542 }
543
544 #[test]
545 fn risk_low_response_rate() {
546 let summary = active_summary();
547 let signals = generate_risk_signals(&summary, &low_engagement_metrics(), 10);
548 assert!(signals.iter().any(|s| s.signal_type == "low_response_rate"));
549 }
550
551 #[test]
552 fn risk_no_measured_performance() {
553 let summary = active_summary();
554 let metrics = zero_metrics();
555 let signals = generate_risk_signals(&summary, &metrics, 10);
556 assert!(signals
557 .iter()
558 .any(|s| s.signal_type == "no_measured_performance"));
559 }
560
561 #[test]
562 fn risk_good_engagement_no_signals() {
563 let mut summary = active_summary();
564 summary.replies_today = 1;
565 let signals = generate_risk_signals(&summary, &good_metrics(), 10);
566 assert!(signals.is_empty(), "no risk signals expected: {signals:?}");
567 }
568
569 #[test]
570 fn risk_multiple_signals() {
571 let mut summary = active_summary();
572 summary.replies_today = 5;
573 let signals = generate_risk_signals(&summary, &low_engagement_metrics(), 5);
574 assert!(signals.len() >= 2, "expected multiple signals: {signals:?}");
575 assert!(signals
576 .iter()
577 .any(|s| s.signal_type == "high_frequency_today"));
578 assert!(signals.iter().any(|s| s.signal_type == "low_response_rate"));
579 }
580
581 #[tokio::test]
586 async fn resolve_author_by_username() {
587 let pool = init_test_db().await.expect("init db");
588
589 let tweet = crate::storage::tweets::DiscoveredTweet {
591 id: "t_resolve".to_string(),
592 author_id: "uid_resolve".to_string(),
593 author_username: "resolveuser".to_string(),
594 content: "Test tweet".to_string(),
595 like_count: 5,
596 retweet_count: 1,
597 reply_count: 0,
598 impression_count: None,
599 relevance_score: Some(70.0),
600 matched_keyword: Some("test".to_string()),
601 discovered_at: "2026-02-20T10:00:00Z".to_string(),
602 replied_to: 0,
603 };
604 crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
605 .await
606 .expect("insert");
607
608 let (author_id, username) = resolve_author(&pool, "resolveuser", "resolveuser")
609 .await
610 .expect("resolve");
611 assert_eq!(author_id, Some("uid_resolve".to_string()));
612 assert_eq!(username, "resolveuser");
613 }
614
615 #[tokio::test]
616 async fn resolve_author_by_id() {
617 let pool = init_test_db().await.expect("init db");
618
619 let tweet = crate::storage::tweets::DiscoveredTweet {
620 id: "t_resolve_id".to_string(),
621 author_id: "uid_by_id".to_string(),
622 author_username: "byiduser".to_string(),
623 content: "Test".to_string(),
624 like_count: 0,
625 retweet_count: 0,
626 reply_count: 0,
627 impression_count: None,
628 relevance_score: None,
629 matched_keyword: None,
630 discovered_at: "2026-02-20T10:00:00Z".to_string(),
631 replied_to: 0,
632 };
633 crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
634 .await
635 .expect("insert");
636
637 let (author_id, username) = resolve_author(&pool, "uid_by_id", "uid_by_id")
638 .await
639 .expect("resolve");
640 assert_eq!(author_id, Some("uid_by_id".to_string()));
641 assert_eq!(username, "byiduser");
642 }
643
644 #[tokio::test]
645 async fn resolve_author_not_found() {
646 let pool = init_test_db().await.expect("init db");
647
648 let (author_id, username) = resolve_author(&pool, "nobody", "nobody")
649 .await
650 .expect("resolve");
651 assert!(author_id.is_none());
652 assert_eq!(username, "nobody");
653 }
654
655 #[tokio::test]
656 async fn interaction_summary_empty_db() {
657 let pool = init_test_db().await.expect("init db");
658
659 let summary = query_interaction_summary(&pool, &None, "nobody")
660 .await
661 .expect("summary");
662 assert_eq!(summary.total_replies_sent, 0);
663 assert_eq!(summary.replies_today, 0);
664 assert!(summary.first_interaction.is_none());
665 }
666
667 #[tokio::test]
668 async fn topic_affinity_empty_db() {
669 let pool = init_test_db().await.expect("init db");
670
671 let topics = query_topic_affinity(&pool, "nobody").await.expect("topics");
672 assert!(topics.is_empty());
673 }
674
675 #[tokio::test]
676 async fn topic_affinity_with_data() {
677 let pool = init_test_db().await.expect("init db");
678
679 for (i, kw) in ["rust", "rust", "async", "rust"].iter().enumerate() {
681 let tweet = crate::storage::tweets::DiscoveredTweet {
682 id: format!("ta_{i}"),
683 author_id: "uid_ta".to_string(),
684 author_username: "tauser".to_string(),
685 content: format!("Tweet about {kw}"),
686 like_count: 5,
687 retweet_count: 0,
688 reply_count: 0,
689 impression_count: None,
690 relevance_score: Some(70.0),
691 matched_keyword: Some(kw.to_string()),
692 discovered_at: "2026-02-20T10:00:00Z".to_string(),
693 replied_to: 0,
694 };
695 crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
696 .await
697 .expect("insert");
698 }
699
700 let topics = query_topic_affinity(&pool, "tauser").await.expect("topics");
701 assert_eq!(topics.len(), 2);
702 assert_eq!(topics[0].keyword, "rust");
703 assert_eq!(topics[0].mention_count, 3);
704 assert_eq!(topics[1].keyword, "async");
705 assert_eq!(topics[1].mention_count, 1);
706 }
707
708 #[tokio::test]
709 async fn conversation_history_empty() {
710 let pool = init_test_db().await.expect("init db");
711
712 let history = query_conversation_history(&pool, "nobody")
713 .await
714 .expect("history");
715 assert!(history.is_empty());
716 }
717
718 #[tokio::test]
719 async fn get_author_context_full_integration() {
720 let pool = init_test_db().await.expect("init db");
721 let config = crate::config::Config::default();
722
723 let ctx = get_author_context(&pool, "@testuser", &config)
724 .await
725 .expect("context");
726 assert_eq!(ctx.author_username, "testuser");
727 assert!(ctx.author_id.is_none());
728 assert_eq!(ctx.interaction_summary.total_replies_sent, 0);
729 assert!(ctx
731 .risk_signals
732 .iter()
733 .any(|s| s.signal_type == "no_prior_interaction"));
734 }
735
736 #[tokio::test]
737 async fn get_author_context_with_seeded_data() {
738 let pool = init_test_db().await.expect("init db");
739 let config = crate::config::Config::default();
740
741 let tweet = crate::storage::tweets::DiscoveredTweet {
743 id: "ctx_tweet".to_string(),
744 author_id: "uid_ctx".to_string(),
745 author_username: "ctxuser".to_string(),
746 content: "Context test tweet".to_string(),
747 like_count: 10,
748 retweet_count: 2,
749 reply_count: 1,
750 impression_count: Some(500),
751 relevance_score: Some(85.0),
752 matched_keyword: Some("rust".to_string()),
753 discovered_at: "2026-02-20T10:00:00Z".to_string(),
754 replied_to: 0,
755 };
756 crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
757 .await
758 .expect("insert");
759
760 let ctx = get_author_context(&pool, "ctxuser", &config)
761 .await
762 .expect("context");
763 assert_eq!(ctx.author_username, "ctxuser");
764 assert_eq!(ctx.author_id, Some("uid_ctx".to_string()));
765 assert_eq!(ctx.topic_affinity.len(), 1);
766 assert_eq!(ctx.topic_affinity[0].keyword, "rust");
767 }
768}