Skip to main content

tuitbot_server/routes/
discovery.rs

1//! Discovery feed endpoints for browsing scored tweets and composing replies.
2
3use 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
20// ---------------------------------------------------------------------------
21// Helpers
22// ---------------------------------------------------------------------------
23
24async 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// ---------------------------------------------------------------------------
35// GET /api/discovery/feed
36// ---------------------------------------------------------------------------
37
38#[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
103// ---------------------------------------------------------------------------
104// GET /api/discovery/keywords
105// ---------------------------------------------------------------------------
106
107pub 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// ---------------------------------------------------------------------------
116// POST /api/discovery/{tweet_id}/compose-reply
117// ---------------------------------------------------------------------------
118
119#[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    // Fetch the tweet content from discovered_tweets.
144    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// ---------------------------------------------------------------------------
178// POST /api/discovery/{tweet_id}/queue-reply
179// ---------------------------------------------------------------------------
180
181#[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    // Block posting unless the backend can actually post for this account.
197    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    // Look up author from discovered_tweets.
206    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    // Build provenance input when citations are provided.
212    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        "",  // topic
227        "",  // archetype
228        0.0, // score
229        "[]",
230        None, // reason
231        None, // detected_risks
232        provenance_input.as_ref(),
233        None, // no scheduling intent — discovery replies post immediately
234    )
235    .await?;
236
237    // Auto-approve for immediate posting.
238    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    // --- FeedQuery deserialization ---
280
281    #[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    // --- DiscoveryTweet serialization ---
302
303    #[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    // --- ComposeReplyRequest ---
324
325    #[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}