Skip to main content

tuitbot_core/context/
author.rs

1//! Author context aggregation.
2//!
3//! Builds a rich profile of an author from stored interaction history,
4//! conversation records, performance metrics, and risk signals.
5
6use crate::config::Config;
7use crate::error::StorageError;
8use crate::storage::DbPool;
9use serde::Serialize;
10
11/// Complete context profile for an author.
12#[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/// Summary of interaction history with an author.
24#[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/// A single conversation record (our reply to their tweet).
34#[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/// Performance metrics for a single reply.
45#[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/// A keyword/topic that appears in an author's tweets.
54#[derive(Debug, Clone, Serialize)]
55pub struct TopicAffinity {
56    pub keyword: String,
57    pub mention_count: i64,
58}
59
60/// A risk signal that may affect engagement decisions.
61#[derive(Debug, Clone, Serialize)]
62pub struct RiskSignal {
63    pub signal_type: String,
64    pub severity: String,
65    pub description: String,
66}
67
68/// Aggregate response metrics for interactions with this author.
69#[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
80/// Build a complete author context from stored data.
81///
82/// Accepts a username (with or without @) or an author ID.
83pub 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    // Resolve author identity from discovered_tweets
91    let (author_id, author_username) = resolve_author(pool, username, identifier).await?;
92
93    // Gather interaction summary
94    let interaction_summary = query_interaction_summary(pool, &author_id, &author_username).await?;
95
96    // Gather conversation history with performance data
97    let conversation_history = query_conversation_history(pool, &author_username).await?;
98
99    // Compute response metrics from conversation history
100    let response_metrics = compute_response_metrics(&conversation_history);
101
102    // Extract topic affinity from discovered tweets
103    let topic_affinity = query_topic_affinity(pool, &author_username).await?;
104
105    // Generate risk signals
106    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    // Try by username first
129    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    // Fall back to lookup by author_id
141    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    // Get today's count
176    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(
319    summary: &InteractionSummary,
320    metrics: &ResponseMetrics,
321    max_per_author_per_day: u32,
322) -> Vec<RiskSignal> {
323    let mut signals = Vec::new();
324
325    if summary.replies_today >= max_per_author_per_day as i64 {
326        signals.push(RiskSignal {
327            signal_type: "high_frequency_today".to_string(),
328            severity: "high".to_string(),
329            description: format!(
330                "Already sent {} replies today (limit: {})",
331                summary.replies_today, max_per_author_per_day
332            ),
333        });
334    }
335
336    if metrics.replies_measured >= 3 && metrics.response_rate < 0.1 {
337        signals.push(RiskSignal {
338            signal_type: "low_response_rate".to_string(),
339            severity: "medium".to_string(),
340            description: format!(
341                "Only {:.0}% of replies to this author received engagement ({}/{})",
342                metrics.response_rate * 100.0,
343                metrics.replies_with_engagement,
344                metrics.replies_measured
345            ),
346        });
347    }
348
349    if summary.total_replies_sent > 0 && metrics.replies_measured == 0 {
350        signals.push(RiskSignal {
351            signal_type: "no_measured_performance".to_string(),
352            severity: "low".to_string(),
353            description: "Replied before but no performance data collected yet".to_string(),
354        });
355    }
356
357    if summary.total_replies_sent == 0 {
358        signals.push(RiskSignal {
359            signal_type: "no_prior_interaction".to_string(),
360            severity: "low".to_string(),
361            description: "No prior interaction history with this author".to_string(),
362        });
363    }
364
365    signals
366}