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(
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}