1use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tuitbot_core::content::ContentGenerator;
10use tuitbot_core::context::retrieval::VaultCitation;
11use tuitbot_core::storage::approval_queue::{self, ProvenanceInput};
12use tuitbot_core::storage::provenance::ProvenanceRef;
13use tuitbot_core::storage::{self};
14
15use crate::account::{require_mutate, AccountContext};
16use crate::error::ApiError;
17use crate::routes::rag_helpers::resolve_composer_rag_context;
18use crate::state::AppState;
19
20async fn get_generator(
25 state: &AppState,
26 account_id: &str,
27) -> Result<Arc<ContentGenerator>, ApiError> {
28 state
29 .get_or_create_content_generator(account_id)
30 .await
31 .map_err(ApiError::BadRequest)
32}
33
34#[derive(Deserialize)]
39pub struct FeedQuery {
40 #[serde(default = "default_min_score")]
41 pub min_score: f64,
42 pub max_score: Option<f64>,
43 pub keyword: Option<String>,
44 #[serde(default = "default_feed_limit")]
45 pub limit: u32,
46}
47
48fn default_min_score() -> f64 {
49 50.0
50}
51fn default_feed_limit() -> u32 {
52 20
53}
54
55#[derive(Serialize)]
56pub struct DiscoveryTweet {
57 pub id: String,
58 pub author_username: String,
59 pub content: String,
60 pub relevance_score: f64,
61 pub matched_keyword: Option<String>,
62 pub like_count: i64,
63 pub retweet_count: i64,
64 pub reply_count: i64,
65 pub replied_to: bool,
66 pub discovered_at: String,
67}
68
69pub async fn feed(
70 State(state): State<Arc<AppState>>,
71 ctx: AccountContext,
72 Query(q): Query<FeedQuery>,
73) -> Result<Json<Vec<DiscoveryTweet>>, ApiError> {
74 let rows = storage::tweets::get_discovery_feed_filtered_for(
75 &state.db,
76 &ctx.account_id,
77 q.min_score,
78 q.max_score,
79 q.keyword.as_deref(),
80 q.limit,
81 )
82 .await?;
83
84 let tweets = rows
85 .into_iter()
86 .map(|t| DiscoveryTweet {
87 id: t.id,
88 author_username: t.author_username,
89 content: t.content,
90 relevance_score: t.relevance_score.unwrap_or(0.0),
91 matched_keyword: t.matched_keyword,
92 like_count: t.like_count,
93 retweet_count: t.retweet_count,
94 reply_count: t.reply_count,
95 replied_to: t.replied_to != 0,
96 discovered_at: t.discovered_at,
97 })
98 .collect();
99
100 Ok(Json(tweets))
101}
102
103pub async fn keywords(
108 State(state): State<Arc<AppState>>,
109 ctx: AccountContext,
110) -> Result<Json<Vec<String>>, ApiError> {
111 let kws = storage::tweets::get_distinct_keywords_for(&state.db, &ctx.account_id).await?;
112 Ok(Json(kws))
113}
114
115#[derive(Deserialize)]
120pub struct ComposeReplyRequest {
121 #[serde(default)]
122 pub mention_product: bool,
123 #[serde(default)]
124 pub selected_node_ids: Option<Vec<i64>>,
125}
126
127#[derive(Serialize)]
128pub struct ComposeReplyResponse {
129 pub content: String,
130 pub tweet_id: String,
131 #[serde(skip_serializing_if = "Vec::is_empty")]
132 pub vault_citations: Vec<VaultCitation>,
133}
134
135pub async fn compose_reply(
136 State(state): State<Arc<AppState>>,
137 ctx: AccountContext,
138 Path(tweet_id): Path<String>,
139 Json(body): Json<ComposeReplyRequest>,
140) -> Result<Json<ComposeReplyResponse>, ApiError> {
141 let gen = get_generator(&state, &ctx.account_id).await?;
142
143 let tweet = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
145 .await?
146 .ok_or_else(|| {
147 ApiError::NotFound(format!("Tweet {tweet_id} not found in discovered tweets"))
148 })?;
149
150 let node_ids = body.selected_node_ids.as_deref();
151 let rag_context = resolve_composer_rag_context(&state, &ctx.account_id, node_ids).await;
152
153 let prompt_block = rag_context.as_ref().map(|c| c.prompt_block.as_str());
154 let citations = rag_context
155 .as_ref()
156 .map(|c| c.vault_citations.clone())
157 .unwrap_or_default();
158
159 let output = gen
160 .generate_reply_with_context(
161 &tweet.content,
162 &tweet.author_username,
163 body.mention_product,
164 None,
165 prompt_block,
166 )
167 .await
168 .map_err(|e| ApiError::Internal(e.to_string()))?;
169
170 Ok(Json(ComposeReplyResponse {
171 content: output.text,
172 tweet_id,
173 vault_citations: citations,
174 }))
175}
176
177#[derive(Deserialize)]
182pub struct QueueReplyRequest {
183 pub content: String,
184 #[serde(default)]
185 pub provenance: Option<Vec<ProvenanceRef>>,
186}
187
188pub async fn queue_reply(
189 State(state): State<Arc<AppState>>,
190 ctx: AccountContext,
191 Path(tweet_id): Path<String>,
192 Json(body): Json<QueueReplyRequest>,
193) -> Result<Json<Value>, ApiError> {
194 require_mutate(&ctx)?;
195
196 crate::routes::content::require_post_capable(&state, &ctx.account_id).await?;
198
199 if body.content.trim().is_empty() {
200 return Err(ApiError::BadRequest(
201 "content must not be empty".to_string(),
202 ));
203 }
204
205 let target_author = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
207 .await?
208 .map(|t| t.author_username)
209 .unwrap_or_default();
210
211 let provenance_input = body.provenance.as_ref().map(|refs| ProvenanceInput {
213 source_node_id: refs.first().and_then(|r| r.node_id),
214 source_seed_id: None,
215 source_chunks_json: "[]".to_string(),
216 refs: refs.clone(),
217 });
218
219 let queue_id = approval_queue::enqueue_with_provenance_for(
220 &state.db,
221 &ctx.account_id,
222 "reply",
223 &tweet_id,
224 &target_author,
225 &body.content,
226 "", "", 0.0, "[]",
230 None, None, provenance_input.as_ref(),
233 None, )
235 .await?;
236
237 approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved").await?;
239
240 Ok(Json(json!({
241 "approval_queue_id": queue_id,
242 "tweet_id": tweet_id,
243 "status": "queued_for_posting"
244 })))
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn queue_reply_request_provenance_is_optional() {
253 let json = r#"{"content": "Great reply!"}"#;
254 let req: QueueReplyRequest = serde_json::from_str(json).expect("deserialize");
255 assert_eq!(req.content, "Great reply!");
256 assert!(req.provenance.is_none());
257 }
258
259 #[test]
260 fn queue_reply_request_with_provenance() {
261 let json = r#"{
262 "content": "Thanks!",
263 "provenance": [{"node_id": 1, "chunk_id": 2, "source_path": "notes/foo.md"}]
264 }"#;
265 let req: QueueReplyRequest = serde_json::from_str(json).expect("deserialize");
266 let refs = req.provenance.unwrap();
267 assert_eq!(refs.len(), 1);
268 assert_eq!(refs[0].node_id, Some(1));
269 }
270
271 #[test]
272 fn compose_reply_request_selected_node_ids_optional() {
273 let json = r#"{"mention_product": true}"#;
274 let req: ComposeReplyRequest = serde_json::from_str(json).expect("deserialize");
275 assert!(req.mention_product);
276 assert!(req.selected_node_ids.is_none());
277 }
278
279 #[test]
282 fn feed_query_defaults() {
283 let json = "{}";
284 let q: FeedQuery = serde_json::from_str(json).expect("deser");
285 assert!((q.min_score - 50.0).abs() < 0.001);
286 assert!(q.max_score.is_none());
287 assert!(q.keyword.is_none());
288 assert_eq!(q.limit, 20);
289 }
290
291 #[test]
292 fn feed_query_custom() {
293 let json = r#"{"min_score":70.0,"max_score":95.0,"keyword":"rust","limit":10}"#;
294 let q: FeedQuery = serde_json::from_str(json).expect("deser");
295 assert!((q.min_score - 70.0).abs() < 0.001);
296 assert!((q.max_score.unwrap() - 95.0).abs() < 0.001);
297 assert_eq!(q.keyword.as_deref(), Some("rust"));
298 assert_eq!(q.limit, 10);
299 }
300
301 #[test]
304 fn discovery_tweet_serializes() {
305 let tweet = DiscoveryTweet {
306 id: "t123".into(),
307 author_username: "alice".into(),
308 content: "Hello world".into(),
309 relevance_score: 85.5,
310 matched_keyword: Some("rust".into()),
311 like_count: 10,
312 retweet_count: 3,
313 reply_count: 1,
314 replied_to: false,
315 discovered_at: "2026-03-15T10:00:00Z".into(),
316 };
317 let json = serde_json::to_string(&tweet).expect("serialize");
318 assert!(json.contains("alice"));
319 assert!(json.contains("85.5"));
320 assert!(json.contains("\"replied_to\":false"));
321 }
322
323 #[test]
326 fn compose_reply_request_defaults() {
327 let json = "{}";
328 let req: ComposeReplyRequest = serde_json::from_str(json).expect("deser");
329 assert!(!req.mention_product);
330 assert!(req.selected_node_ids.is_none());
331 }
332
333 #[test]
334 fn compose_reply_request_with_node_ids() {
335 let json = r#"{"mention_product":true,"selected_node_ids":[1,2,3]}"#;
336 let req: ComposeReplyRequest = serde_json::from_str(json).expect("deser");
337 assert!(req.mention_product);
338 let ids = req.selected_node_ids.unwrap();
339 assert_eq!(ids.len(), 3);
340 }
341
342 #[test]
343 fn compose_reply_response_omits_empty_citations() {
344 let resp = ComposeReplyResponse {
345 content: "Nice!".to_string(),
346 tweet_id: "123".to_string(),
347 vault_citations: vec![],
348 };
349 let json = serde_json::to_string(&resp).expect("serialize");
350 assert!(!json.contains("vault_citations"));
351 }
352}