archivist-core 0.2.0

Platform-neutral core for the FicHub companion bot: FicHub REST API client, search/recommendation/download command logic, intent classification, pagination cache, and the PlatformMessage IR. Shared by every platform adapter (Discord, Telegram, Matrix, Slack, IRC, fediverse, CLI, web).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
//! Redis-backed pagination cache for list commands.
//!
//! FicHub shares its Redis instance with the bot. When a user runs `/recs`,
//! `/search`, `/ask`, `/quote`, or `/requests`, the full result list is stored
//! under a short-lived session key; `!next`/`!prev`/`!page X` then read from
//! Redis instead of re-calling the API (no DB hammering).
//!
//! Key layout: `archivist:session:<user_id>:<session_id>` → JSON array of
//! `PageEntry` (the whole list, so pagination is trivial).
//!
//! Response cache: `archivist:askcache:<sha256(q)>` → JSON `AskResponse`,
//! and `archivist:searchcache:<sha256(qs)>` → JSON `SearchResponse`. These
//! protect the public bot from hammering the LLM (`/ask`) and the API
//! (`/search`). Only `Ok` responses are cached; a Redis failure never blocks
//! the request (best-effort, like `log_search`).

use redis::aio::ConnectionManager;
use serde::{Deserialize, Serialize};

use crate::config::BotConfig;
use crate::error::Result;

/// A single paginated-list entry: the raw item plus a stable ordering index.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageEntry {
    /// Stable index into the list (0-based).
    pub index: usize,
    /// The raw JSON object of the item (flexible — each command knows its shape).
    pub item: serde_json::Value,
}

/// A paginated list session stored in Redis.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageList {
    /// Session id (uuid) for the list.
    pub session_id: String,
    /// Title of the list ("Recommendations", "Search: drarry", ...).
    pub title: String,
    /// All entries (whole list; may be large but Redis handles it fine).
    pub entries: Vec<PageEntry>,
    /// Optional footer/timestamp.
    pub created_at: String,
}

impl PageList {
    /// Empty placeholder (no Redis) used by tests and offline adapters.
    pub fn empty() -> Self {
        Self {
            session_id: String::new(),
            title: String::new(),
            entries: Vec::new(),
            created_at: String::new(),
        }
    }
}

/// Redis-backed cache for paginated lists.
#[derive(Clone)]
pub struct PageCache {
    /// Backend: a live Redis connection manager, or the offline no-op.
    backend: PageCacheBackend,
    /// TTL for session keys.
    ttl_secs: u64,
}

#[derive(Clone)]
enum PageCacheBackend {
    Redis(ConnectionManager),
    /// In-memory no-op (CLI/offline adapters; every operation succeeds with
    /// empty/throwaway data but never touches the network).
    Offline,
}

impl PageCache {
    /// In-memory placeholder (no Redis) for offline adapters/tests: every
    /// operation is a no-op. Real adapters should use `connect()`.
    pub fn offline() -> Self {
        Self { backend: PageCacheBackend::Offline, ttl_secs: 300 }
    }

    /// Connect to Redis (same URL as FicHub).
    pub async fn connect(config: &BotConfig) -> Result<Self> {
        let client = redis::Client::open(config.redis_url.as_str())?;
        let conn = ConnectionManager::new(client).await?;
        Ok(Self {
            backend: PageCacheBackend::Redis(conn),
            ttl_secs: config.link_code_ttl_secs.max(300),
        })
    }

    /// Store a new list, returning its session id.
    pub async fn store(&self, title: &str, entries: Vec<PageEntry>) -> Result<String> {
        let session_id = uuid::Uuid::new_v4().to_string();
        let PageCacheBackend::Redis(conn) = &self.backend else {
            // Offline: return a session id that will never resolve, so callers
            // can paginate but always get "no results" on fetch.
            return Ok(session_id);
        };
        let list = PageList {
            session_id: session_id.clone(),
            title: title.to_string(),
            entries,
            created_at: chrono::Utc::now().to_rfc3339(),
        };
        let key = format!("archivist:session:{session_id}");
        let data = serde_json::to_vec(&list)?;
        redis::cmd("SETEX")
            .arg(&key)
            .arg(self.ttl_secs)
            .arg(data)
            .exec_async(&mut conn.clone())
            .await?;
        Ok(session_id)
    }

    /// Store a new list for a user, remembering it as their "latest" session
    /// (so `!next`/`!prev`/`!page X` know which list to paginate).
    pub async fn store_for_user(
        &self,
        user_id: u64,
        title: &str,
        entries: Vec<PageEntry>,
    ) -> Result<String> {
        let session_id = self.store(title, entries).await?;
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return Ok(session_id);
        };
        let key = format!("archivist:user:{user_id}:latest");
        redis::cmd("SETEX")
            .arg(&key)
            .arg(self.ttl_secs)
            .arg(&session_id)
            .exec_async(&mut conn.clone())
            .await?;
        Ok(session_id)
    }

    /// The user's most recent list session id, if any.
    pub async fn latest_for_user(&self, user_id: u64) -> Result<Option<String>> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return Ok(None);
        };
        let key = format!("archivist:user:{user_id}:latest");
        let val: Option<String> = redis::cmd("GET")
            .arg(&key)
            .query_async(&mut conn.clone())
            .await?;
        Ok(val)
    }

    /// Current page for a session (defaults to 1 when unset).
    pub async fn current_page(&self, session_id: &str) -> Result<Option<usize>> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return Ok(None);
        };
        let key = format!("archivist:page:{session_id}");
        let val: Option<u64> = redis::cmd("GET")
            .arg(&key)
            .query_async(&mut conn.clone())
            .await?;
        Ok(val.map(|v| v as usize))
    }

    /// Record the current page for a session.
    pub async fn set_current_page(&self, session_id: &str, page: usize) -> Result<()> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return Ok(());
        };
        let key = format!("archivist:page:{session_id}");
        redis::cmd("SETEX")
            .arg(&key)
            .arg(self.ttl_secs)
            .arg(page as u64)
            .exec_async(&mut conn.clone())
            .await?;
        Ok(())
    }

    /// Fetch a stored list by session id.
    pub async fn fetch(&self, session_id: &str) -> Result<Option<PageList>> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return Ok(None);
        };
        let key = format!("archivist:session:{session_id}");
        let raw: Option<Vec<u8>> = redis::cmd("GET")
            .arg(&key)
            .query_async(&mut conn.clone())
            .await?;
        match raw {
            Some(bytes) => {
                let list: PageList = serde_json::from_slice(&bytes)?;
                Ok(Some(list))
            }
            None => Ok(None),
        }
    }

    /// Delete a stored list (call after a session ends).
    pub async fn delete(&self, session_id: &str) -> Result<()> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return Ok(());
        };
        let key = format!("archivist:session:{session_id}");
        redis::cmd("DEL")
            .arg(&key)
            .exec_async(&mut conn.clone())
            .await?;
        Ok(())
    }

    /// Append a search-log entry to today's Redis list, trimming to the last
    /// `SEARCH_LOG_CAP` entries and giving the key a 7-day TTL.
    ///
    /// Logging is best-effort: a Redis failure must never break the search
    /// command, so the caller ignores the error. Keys look like
    /// `archivist:searchlog:2026-08-15`.
    pub async fn log_search(&self, entry: &SearchLogEntry) {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return;
        };
        let key = format!(
            "archivist:searchlog:{}",
            chrono::Utc::now().format("%Y-%m-%d")
        );
        let data = match serde_json::to_vec(entry) {
            Ok(d) => d,
            Err(e) => {
                tracing::warn!("search log serialization failed: {e}");
                return;
            }
        };
        let mut conn = conn.clone();
        let res: redis::RedisResult<i64> = redis::cmd("RPUSH")
            .arg(&key)
            .arg(&data)
            .query_async(&mut conn)
            .await;
        match res {
            Ok(_) => {
                // Trim to cap and set TTL (best-effort, ignore failures).
                let _ = redis::cmd("LTRIM")
                    .arg(&key)
                    .arg(-(SEARCH_LOG_CAP as isize))
                    .arg(-1)
                    .exec_async(&mut conn.clone())
                    .await;
                let _ = redis::cmd("EXPIRE")
                    .arg(&key)
                    .arg(7 * 24 * 60 * 60)
                    .exec_async(&mut conn.clone())
                    .await;
            }
            Err(e) => tracing::warn!("search log append failed: {e}"),
        }
    }

    // ── Response cache (ask + search) ──────────────────────────────────

    /// Look up a cached response value by kind + raw query.
    ///
    /// `kind` selects the key prefix + TTL: `"ask"` (`archivist:askcache:<h>`)
    /// or `"search"` (`archivist:searchcache:<h>`). Only the raw JSON bytes
    /// are cached; the caller deserializes into the typed response so this
    /// method stays generic. Returns `Ok(None)` on miss or Redis failure
    /// (best-effort — a broken cache must never break the command).
    pub async fn cached_response(&self, kind: &str, query: &str) -> Option<serde_json::Value> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return None;
        };
        let key = response_cache_key(kind, query);
        let res: redis::RedisResult<Option<Vec<u8>>> = redis::cmd("GET")
            .arg(&key)
            .query_async(&mut conn.clone())
            .await;
        match res {
            Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
                Ok(v) => Some(v),
                Err(e) => {
                    tracing::warn!("response cache deserialize failed for {key}: {e}");
                    None
                }
            },
            Ok(None) => None,
            Err(e) => {
                tracing::warn!("response cache get failed for {key}: {e}");
                None
            }
        }
    }

    /// Store a response value in the cache (best-effort, like `log_search`).
    ///
    /// `kind` selects the TTL: ask entries live longer (LLM cost is the
    /// dominant expense); search entries get a short TTL so results do not go
    /// stale as the archive grows.
    pub async fn cache_response(&self, kind: &str, query: &str, value: &serde_json::Value) {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return;
        };
        let key = response_cache_key(kind, query);
        let data = match serde_json::to_vec(value) {
            Ok(d) => d,
            Err(e) => {
                tracing::warn!("response cache serialization failed for {key}: {e}");
                return;
            }
        };
        let ttl = match kind {
            "ask" => ASK_CACHE_TTL_SECS,
            _ => SEARCH_CACHE_TTL_SECS,
        };
        let res: redis::RedisResult<()> = redis::cmd("SETEX")
            .arg(&key)
            .arg(ttl)
            .arg(data)
            .exec_async(&mut conn.clone())
            .await;
        if let Err(e) = res {
            tracing::warn!("response cache set failed for {key}: {e}");
        }
    }

    /// Look up a raw cached value by an EXPLICIT key (e.g. the intent cache,
    /// which uses its own TTL). Returns `Ok(None)` on miss or Redis failure.
    pub async fn cached_raw(&self, key: &str) -> Option<serde_json::Value> {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return None;
        };
        let res: redis::RedisResult<Option<Vec<u8>>> = redis::cmd("GET")
            .arg(key)
            .query_async(&mut conn.clone())
            .await;
        match res {
            Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
                Ok(v) => Some(v),
                Err(e) => {
                    tracing::warn!("raw cache deserialize failed for {key}: {e}");
                    None
                }
            },
            Ok(None) => None,
            Err(e) => {
                tracing::warn!("raw cache get failed for {key}: {e}");
                None
            }
        }
    }

    /// Store a raw JSON value under an EXPLICIT key + TTL (best-effort).
    pub async fn cache_raw(&self, key: &str, ttl_secs: u64, value: &serde_json::Value) {
        let PageCacheBackend::Redis(conn) = &self.backend else {
            return;
        };
        let data = match serde_json::to_vec(value) {
            Ok(d) => d,
            Err(e) => {
                tracing::warn!("raw cache serialization failed for {key}: {e}");
                return;
            }
        };
        let res: redis::RedisResult<()> = redis::cmd("SETEX")
            .arg(key)
            .arg(ttl_secs)
            .arg(data)
            .exec_async(&mut conn.clone())
            .await;
        if let Err(e) = res {
            tracing::warn!("raw cache set failed for {key}: {e}");
        }
    }
}

/// How long cached `/ask` responses live (LLM generation is expensive — a
/// popular public bot would otherwise hammer Ollama with identical queries).
pub const ASK_CACHE_TTL_SECS: u64 = 24 * 60 * 60; // 24h

/// How long successful `/search` responses live (cheap, but rate-limits /
/// transient API errors should not force a re-fetch for every caller).
pub const SEARCH_CACHE_TTL_SECS: u64 = 5 * 60; // 5 min

/// Build a stable cache key for a response-cache entry.
///
/// Normalizes the query (trim + lowercase) so `"Drarry"` and `" drarry "`
/// share one entry, then hashes with SHA-256 to keep the key bounded.
fn response_cache_key(kind: &str, query: &str) -> String {
    use sha2::{Digest, Sha256};
    let norm = query.trim().to_lowercase();
    let mut hasher = Sha256::new();
    hasher.update(norm.as_bytes());
    let hex = hasher.finalize();
    let h = hex.iter().map(|b| format!("{b:02x}")).collect::<String>();
    format!("archivist:{kind}cache:{h}")
}

/// Max entries kept per day in the search log.
pub const SEARCH_LOG_CAP: usize = 500;

/// One search-log row: everything needed to reproduce/debug a search request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchLogEntry {
    /// ISO-8601 UTC timestamp.
    pub ts: String,
    /// Discord user id.
    pub user_id: u64,
    /// Command that ran: "search" | "body" | "ask".
    pub command: String,
    /// The user's raw query.
    pub query: String,
    /// Full query string sent to the API (e.g. `q=...&page=1`).
    pub api_query: String,
    /// HTTP status (0 when transport failed).
    pub status: u16,
    /// Result count from the API (0 on error).
    pub result_count: usize,
    /// Wall-clock latency in milliseconds.
    pub latency_ms: u64,
    /// Error message if the attempt failed (empty when OK).
    pub error: String,
}

/// Pagination helper: slice a list for a given page.
pub fn slice_page<T>(items: &[T], page: usize, page_size: usize) -> Vec<&T> {
    let page = page.max(1);
    let start = (page - 1) * page_size;
    if start >= items.len() {
        return Vec::new();
    }
    let end = (start + page_size).min(items.len());
    items[start..end].iter().collect()
}

/// Number of pages for a list.
pub fn page_count(len: usize, page_size: usize) -> usize {
    if len == 0 {
        return 0;
    }
    (len + page_size - 1) / page_size
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slice_page_first() {
        let items = vec![1, 2, 3, 4, 5, 6, 7];
        let page = slice_page(&items, 1, 3);
        assert_eq!(page, vec![&1, &2, &3]);
    }

    #[test]
    fn slice_page_middle() {
        let items = vec![1, 2, 3, 4, 5, 6, 7];
        let page = slice_page(&items, 2, 3);
        assert_eq!(page, vec![&4, &5, &6]);
    }

    #[test]
    fn slice_page_last_partial() {
        let items = vec![1, 2, 3, 4, 5, 6, 7];
        let page = slice_page(&items, 3, 3);
        assert_eq!(page, vec![&7]);
    }

    #[test]
    fn slice_page_out_of_range() {
        let items = vec![1, 2, 3, 4, 5, 6, 7];
        let page = slice_page(&items, 99, 3);
        assert!(page.is_empty());
    }

    #[test]
    fn page_count_calc() {
        assert_eq!(page_count(0, 3), 0);
        assert_eq!(page_count(7, 3), 3);
        assert_eq!(page_count(9, 3), 3);
        assert_eq!(page_count(10, 3), 4);
        assert_eq!(page_count(5, 5), 1);
    }

    #[test]
    fn response_cache_key_prefixes_by_kind() {
        let ask = response_cache_key("ask", "dark harry");
        let search = response_cache_key("search", "dark harry");
        assert!(ask.starts_with("archivist:askcache:"));
        assert!(search.starts_with("archivist:searchcache:"));
        assert_ne!(ask, search);
        // Hex sha256 = 64 chars.
        assert_eq!(ask.len(), "archivist:askcache:".len() + 64);
    }

    #[test]
    fn response_cache_key_normalizes_query() {
        let a = response_cache_key("ask", "  Dark Harry  ");
        let b = response_cache_key("ask", "dark harry");
        let c = response_cache_key("ask", "DARK HARRY");
        assert_eq!(a, b);
        assert_eq!(b, c);
    }

    #[test]
    fn response_cache_key_differs_for_diff_queries() {
        let a = response_cache_key("ask", "drarry");
        let b = response_cache_key("ask", "enemies to lovers");
        assert_ne!(a, b);
    }
}