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 )
234 .await?;
235
236 approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved").await?;
238
239 Ok(Json(json!({
240 "approval_queue_id": queue_id,
241 "tweet_id": tweet_id,
242 "status": "queued_for_posting"
243 })))
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn queue_reply_request_provenance_is_optional() {
252 let json = r#"{"content": "Great reply!"}"#;
253 let req: QueueReplyRequest = serde_json::from_str(json).expect("deserialize");
254 assert_eq!(req.content, "Great reply!");
255 assert!(req.provenance.is_none());
256 }
257
258 #[test]
259 fn queue_reply_request_with_provenance() {
260 let json = r#"{
261 "content": "Thanks!",
262 "provenance": [{"node_id": 1, "chunk_id": 2, "source_path": "notes/foo.md"}]
263 }"#;
264 let req: QueueReplyRequest = serde_json::from_str(json).expect("deserialize");
265 let refs = req.provenance.unwrap();
266 assert_eq!(refs.len(), 1);
267 assert_eq!(refs[0].node_id, Some(1));
268 }
269
270 #[test]
271 fn compose_reply_request_selected_node_ids_optional() {
272 let json = r#"{"mention_product": true}"#;
273 let req: ComposeReplyRequest = serde_json::from_str(json).expect("deserialize");
274 assert!(req.mention_product);
275 assert!(req.selected_node_ids.is_none());
276 }
277
278 #[test]
279 fn compose_reply_response_omits_empty_citations() {
280 let resp = ComposeReplyResponse {
281 content: "Nice!".to_string(),
282 tweet_id: "123".to_string(),
283 vault_citations: vec![],
284 };
285 let json = serde_json::to_string(&resp).expect("serialize");
286 assert!(!json.contains("vault_citations"));
287 }
288}