1use chrono::{DateTime, Utc};
16use serde::Serialize;
17use sqlx::{FromRow, PgConnection, PgPool};
18use uuid::Uuid;
19
20use crate::{auth::Actor, error::AppResult, services::audit};
21
22pub const DEFAULT_RETENTION_DAYS: u32 = 30;
24pub const MAX_HISTORY_MESSAGES: usize = 16;
26pub const MAX_HISTORY_CHARS: usize = 8_000;
28pub const MAX_STORED_MESSAGES: i64 = 200;
30
31#[derive(Debug, Clone, FromRow, Serialize)]
33pub struct StoredMessage {
34 pub role: String,
36 pub text: String,
37 pub action: String,
39 pub context_kind: String,
41 pub context_label: String,
42 pub tainted: bool,
45 pub created_at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Serialize)]
50pub struct Thread {
51 pub messages: Vec<StoredMessage>,
52 pub retention_days: u32,
53}
54
55pub async fn purge_expired(pool: &PgPool, retention_days: u32) -> AppResult<u64> {
58 let done = sqlx::query(
59 "DELETE FROM assistant_threads WHERE updated_at < now() - make_interval(days => $1)",
60 )
61 .bind(retention_days as i32)
62 .execute(pool)
63 .await?;
64 Ok(done.rows_affected())
65}
66
67pub async fn load(pool: &PgPool, me: &Actor, retention_days: u32) -> AppResult<Thread> {
70 sqlx::query(
71 "DELETE FROM assistant_threads
72 WHERE user_id = $1 AND updated_at < now() - make_interval(days => $2)",
73 )
74 .bind(me.id)
75 .bind(retention_days as i32)
76 .execute(pool)
77 .await?;
78 let messages = sqlx::query_as::<_, StoredMessage>(
79 "SELECT m.role, m.text, m.action, m.context_kind, m.context_label, m.tainted, m.created_at
80 FROM assistant_messages m
81 JOIN assistant_threads t ON t.id = m.thread_id
82 WHERE t.user_id = $1
83 ORDER BY m.seq",
84 )
85 .bind(me.id)
86 .fetch_all(pool)
87 .await?;
88 Ok(Thread {
89 messages,
90 retention_days,
91 })
92}
93
94pub fn history_of(thread: &Thread) -> Vec<(String, String)> {
96 let skip = thread.messages.len().saturating_sub(MAX_HISTORY_MESSAGES);
97 let mut out: Vec<(String, String)> = thread
98 .messages
99 .iter()
100 .skip(skip)
101 .map(|m| {
102 let text: String = m.text.chars().take(MAX_HISTORY_CHARS).collect();
103 (m.role.clone(), text)
104 })
105 .collect();
106 while out.first().is_some_and(|(role, _)| role != "user") {
108 out.remove(0);
109 }
110 out
111}
112
113pub fn was_tainted(thread: &Thread) -> bool {
115 thread.messages.iter().any(|m| m.tainted)
116}
117
118pub struct Exchange<'a> {
120 pub action: &'a str,
121 pub question: &'a str,
123 pub context_kind: &'a str,
124 pub context_label: &'a str,
125 pub answer: &'a str,
126 pub tainted: bool,
127}
128
129pub async fn append(conn: &mut PgConnection, me: &Actor, x: &Exchange<'_>) -> AppResult<()> {
133 let thread: Uuid = sqlx::query_scalar(
134 "INSERT INTO assistant_threads (id, user_id) VALUES ($1, $2)
135 ON CONFLICT (user_id) DO UPDATE SET updated_at = now()
136 RETURNING id",
137 )
138 .bind(Uuid::new_v4())
139 .bind(me.id)
140 .fetch_one(&mut *conn)
141 .await?;
142 sqlx::query("SELECT id FROM assistant_threads WHERE id = $1 FOR UPDATE")
144 .bind(thread)
145 .fetch_one(&mut *conn)
146 .await?;
147 let next: i32 = sqlx::query_scalar(
148 "SELECT COALESCE(MAX(seq), 0) + 1 FROM assistant_messages WHERE thread_id = $1",
149 )
150 .bind(thread)
151 .fetch_one(&mut *conn)
152 .await?;
153 let rows = [
154 ("user", x.question, x.context_kind, x.context_label, false),
155 ("assistant", x.answer, "", "", x.tainted),
156 ];
157 for (n, (role, text, kind, label, tainted)) in rows.into_iter().enumerate() {
158 sqlx::query(
159 "INSERT INTO assistant_messages
160 (thread_id, seq, role, text, action, context_kind, context_label, tainted)
161 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
162 )
163 .bind(thread)
164 .bind(next + n as i32)
165 .bind(role)
166 .bind(text)
167 .bind(if role == "user" { x.action } else { "" })
168 .bind(kind)
169 .bind(label)
170 .bind(tainted)
171 .execute(&mut *conn)
172 .await?;
173 }
174 sqlx::query(
175 "DELETE FROM assistant_messages
176 WHERE thread_id = $1
177 AND seq <= (SELECT MAX(seq) FROM assistant_messages WHERE thread_id = $1) - $2",
178 )
179 .bind(thread)
180 .bind(MAX_STORED_MESSAGES)
181 .execute(&mut *conn)
182 .await?;
183 Ok(())
184}
185
186pub async fn reset(pool: &PgPool, me: &Actor) -> AppResult<()> {
198 let mut tx = pool.begin().await?;
199 let done = sqlx::query("DELETE FROM assistant_threads WHERE user_id = $1")
200 .bind(me.id)
201 .execute(&mut *tx)
202 .await?;
203 if done.rows_affected() > 0 {
204 audit::record(
205 &mut tx,
206 Some(me),
207 "assistant.thread_reset",
208 "assistant",
209 None,
210 "Assistant: conversation deleted",
211 serde_json::json!({}),
212 )
213 .await?;
214 }
215 tx.commit().await?;
216 Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn msg(role: &str, text: &str, tainted: bool) -> StoredMessage {
224 StoredMessage {
225 role: role.into(),
226 text: text.into(),
227 action: "ask".into(),
228 context_kind: String::new(),
229 context_label: String::new(),
230 tainted,
231 created_at: Utc::now(),
232 }
233 }
234
235 #[test]
236 fn history_is_capped_and_starts_with_the_user() {
237 let mut messages = Vec::new();
238 for n in 0..20 {
239 messages.push(msg("user", &format!("q{n}"), false));
240 messages.push(msg("assistant", &"a".repeat(MAX_HISTORY_CHARS + 50), false));
241 }
242 let thread = Thread {
243 messages,
244 retention_days: 30,
245 };
246 let h = history_of(&thread);
247 assert_eq!(h.len(), MAX_HISTORY_MESSAGES);
248 assert_eq!(h[0].0, "user");
249 assert_eq!(h[0].1, "q12");
250 assert!(h
251 .iter()
252 .all(|(_, t)| t.chars().count() <= MAX_HISTORY_CHARS));
253 let thread = Thread {
255 messages: vec![msg("assistant", "x", false), msg("user", "y", false)],
256 retention_days: 30,
257 };
258 assert_eq!(
259 history_of(&thread),
260 vec![("user".to_string(), "y".to_string())]
261 );
262 }
263
264 #[test]
265 fn taint_is_remembered() {
266 let mut thread = Thread {
267 messages: vec![msg("user", "q", false), msg("assistant", "a", false)],
268 retention_days: 30,
269 };
270 assert!(!was_tainted(&thread));
271 thread.messages[1].tainted = true;
272 assert!(was_tainted(&thread));
273 }
274}