feat: multiple persona conversations with branching and generated titles
Persona chat stored a flat Vec<ChatMessage> keyed on (user_id, persona_id), which meant one rolling transcript per persona and no way to revisit a turn. This moves it onto the same ChatHistoryStore tree the file chat uses and gives conversations their own identity. Storage - messages_json now holds a serialized ChatHistoryStore. Reads accept the old flat array and upgrade it in place, so existing transcripts survive without a data migration. The upgrade drops the v1 seed greeting, which the flat renderer hid but the tree renderer would surface as a bubble the user has never seen. - New migration re-keys persona_chat_conversations on an opaque conversation_id and adds title + created_at, so one persona can hold any number of separate threads. Every DAO read and write is scoped by user_id as well: a conversation id is a bearer token for someone's transcript and must never grant access on its own. - The per-conversation lock and in-flight turn slot key on conversation_id, so two threads with the same persona can run turns concurrently. Endpoints - POST/DELETE /persona_chat/conversations — start and remove a thread, replacing /persona_chat/reset. - GET /persona_chat/conversations — the chat list, with snippet and counts derived from each tree's active branch. - POST /persona_chat/rewind, POST /persona_chat/switch-branch, GET /persona_chat/branches — rewind and fork, mirroring the file chat. Index 0 is rewindable here (it is the user's own first question, not a synthetic prompt) and re-anchors on the seed node. - history/turn/rewind/switch-branch/branches all key on conversation_id; history gained branch_id and now returns real fork_info, active_leaf_id and viewing_branch_id instead of placeholders. The turn body no longer carries a persona at all — it is read from the stored conversation, so a stale client cannot swap a thread's voice midway. Titles After the first turn persists, the conversation is named from its opening exchange on the same backend the turn ran on. Small models wrap titles in quotes, prefix them with "Title:" and append explanations, so sanitize_title strips all of that and truncates on a character boundary. Any failure falls back to the user's opening question. Generation runs after persistence: a failed title must not cost the turn. Fixes found along the way - insight_chat: both file-chat turn paths captured path.len() before apply_context_budget drained messages out of the middle, then sliced messages[path_len..] for the new tree nodes. Once truncation fired that dropped the user turn from the tree or panicked on an out-of-range start index. Now read after the budget pass as history_len. - Persona chat had no context budget at all and hardcoded truncated: false in the done frame, so a rolling transcript grew unbounded. - A cancelled turn persisted a half-finished transcript and pushed a second terminal frame; it now returns early like the file chat. - The seeded system prompt was frozen at conversation creation, so editing a persona never reached a thread already in flight. Re-resolved per turn. - turn_count was overwritten each write with the per-turn message delta; it is now the cumulative assistant-turn count on the active branch. - is_initial is always false: the file chat reserves it for its synthetic "describe this photo" prompt, and marking a persona chat's first question with it made the opening reply impossible to regenerate. 600 lib tests pass, clippy --all-targets clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -2051,7 +2051,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image-api"
|
name = "image-api"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "image-api"
|
name = "image-api"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Collapse back to one conversation per (user, persona). Where a persona has
|
||||||
|
-- several, the most recently updated one wins and the rest are dropped —
|
||||||
|
-- the v1 schema has nowhere to put them.
|
||||||
|
|
||||||
|
CREATE TABLE persona_chat_conversations_old (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
persona_id TEXT NOT NULL,
|
||||||
|
messages_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
turn_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, persona_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO persona_chat_conversations_old (
|
||||||
|
user_id, persona_id, messages_json, turn_count, updated_at
|
||||||
|
)
|
||||||
|
SELECT user_id, persona_id, messages_json, turn_count, updated_at
|
||||||
|
FROM persona_chat_conversations c
|
||||||
|
WHERE c.updated_at = (
|
||||||
|
SELECT MAX(c2.updated_at)
|
||||||
|
FROM persona_chat_conversations c2
|
||||||
|
WHERE c2.user_id = c.user_id AND c2.persona_id = c.persona_id
|
||||||
|
)
|
||||||
|
GROUP BY user_id, persona_id;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_persona_chat_updated;
|
||||||
|
DROP INDEX IF EXISTS idx_persona_chat_persona;
|
||||||
|
DROP TABLE persona_chat_conversations;
|
||||||
|
ALTER TABLE persona_chat_conversations_old RENAME TO persona_chat_conversations;
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_updated
|
||||||
|
ON persona_chat_conversations (user_id, updated_at DESC);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- Multiple conversations per persona.
|
||||||
|
--
|
||||||
|
-- v1 keyed a transcript on (user_id, persona_id), so a persona had exactly
|
||||||
|
-- one rolling conversation and there was no way to start a fresh topic
|
||||||
|
-- without discarding the old one. The key is now an opaque `conversation_id`,
|
||||||
|
-- with (user_id, persona_id) demoted to an index.
|
||||||
|
--
|
||||||
|
-- `title` is a short generated summary of the opening exchange, used as the
|
||||||
|
-- conversation's name in the list. Empty until the first turn completes; the
|
||||||
|
-- client falls back to the persona name while it is blank.
|
||||||
|
--
|
||||||
|
-- SQLite cannot redefine a primary key in place, so this is the standard
|
||||||
|
-- create-copy-drop-rename dance. Existing transcripts carry over with a
|
||||||
|
-- generated id and an empty title.
|
||||||
|
|
||||||
|
CREATE TABLE persona_chat_conversations_new (
|
||||||
|
conversation_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
persona_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
messages_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
turn_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO persona_chat_conversations_new (
|
||||||
|
conversation_id, user_id, persona_id, title,
|
||||||
|
messages_json, turn_count, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
lower(hex(randomblob(16))),
|
||||||
|
user_id,
|
||||||
|
persona_id,
|
||||||
|
'',
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
updated_at,
|
||||||
|
updated_at
|
||||||
|
FROM persona_chat_conversations;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_persona_chat_updated;
|
||||||
|
DROP TABLE persona_chat_conversations;
|
||||||
|
ALTER TABLE persona_chat_conversations_new RENAME TO persona_chat_conversations;
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_updated
|
||||||
|
ON persona_chat_conversations (user_id, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_persona
|
||||||
|
ON persona_chat_conversations (user_id, persona_id);
|
||||||
+1
-1
@@ -2051,7 +2051,7 @@ pub(crate) async fn cancel_turn_impl(
|
|||||||
entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Cancelled);
|
entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Cancelled);
|
||||||
span.set_status(Status::Ok);
|
span.set_status(Status::Ok);
|
||||||
|
|
||||||
HttpResponse::Ok().json(serde_json::json!({
|
HttpResponse::Ok().json(serde_json::json!({
|
||||||
"cancelled": true
|
"cancelled": true
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-8
@@ -30,11 +30,11 @@ pub const DEFAULT_MAX_ITERATIONS: usize = 6;
|
|||||||
const DEFAULT_NUM_CTX: i32 = 32768;
|
const DEFAULT_NUM_CTX: i32 = 32768;
|
||||||
/// Headroom reserved for the model's response, deducted from the context
|
/// Headroom reserved for the model's response, deducted from the context
|
||||||
/// budget when deciding whether to truncate the replayed history.
|
/// budget when deciding whether to truncate the replayed history.
|
||||||
const RESPONSE_HEADROOM_TOKENS: usize = 2048;
|
pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
|
||||||
/// Cheap byte-to-token approximation used by the truncation pass. The real
|
/// Cheap byte-to-token approximation used by the truncation pass. The real
|
||||||
/// tokenization is model-specific; this avoids carrying tiktoken just for a
|
/// tokenization is model-specific; this avoids carrying tiktoken just for a
|
||||||
/// soft bound.
|
/// soft bound.
|
||||||
const BYTES_PER_TOKEN: usize = 4;
|
pub(crate) const BYTES_PER_TOKEN: usize = 4;
|
||||||
/// Flat token cost charged per inlined image in the truncation budget. A
|
/// Flat token cost charged per inlined image in the truncation budget. A
|
||||||
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
|
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
|
||||||
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
|
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
|
||||||
@@ -335,6 +335,13 @@ impl InsightChatService {
|
|||||||
if truncated {
|
if truncated {
|
||||||
span.set_attribute(KeyValue::new("history_truncated", true));
|
span.set_attribute(KeyValue::new("history_truncated", true));
|
||||||
}
|
}
|
||||||
|
// Everything in `messages` at this point is replayed history that is
|
||||||
|
// ALREADY in the tree; only what the turn appends past this mark
|
||||||
|
// becomes new nodes. Captured after the budget pass, not from
|
||||||
|
// `path.len()`: truncation drains from the middle, so `path.len()`
|
||||||
|
// over-counts and the slice below would either swallow the new user
|
||||||
|
// turn or panic on an out-of-range start index.
|
||||||
|
let history_len = messages.len();
|
||||||
|
|
||||||
// 7. Append the new user turn.
|
// 7. Append the new user turn.
|
||||||
messages.push(ChatMessage::user(req.user_message.clone()));
|
messages.push(ChatMessage::user(req.user_message.clone()));
|
||||||
@@ -461,8 +468,7 @@ impl InsightChatService {
|
|||||||
// relying on store_insight to flip prior rows' is_current=false.
|
// relying on store_insight to flip prior rows' is_current=false.
|
||||||
// Append new messages (this turn's user + assistant exchanges) as
|
// Append new messages (this turn's user + assistant exchanges) as
|
||||||
// tree nodes chained from the previous active_leaf_id.
|
// tree nodes chained from the previous active_leaf_id.
|
||||||
let path_len = path.len();
|
let new_messages = messages[history_len..].to_vec();
|
||||||
let new_messages = messages[path_len..].to_vec();
|
|
||||||
let mut parent_id = Some(store.active_leaf_id);
|
let mut parent_id = Some(store.active_leaf_id);
|
||||||
for msg in &new_messages {
|
for msg in &new_messages {
|
||||||
let new_id = store.append_node(parent_id, msg.clone());
|
let new_id = store.append_node(parent_id, msg.clone());
|
||||||
@@ -942,7 +948,6 @@ impl InsightChatService {
|
|||||||
let path = store
|
let path = store
|
||||||
.path_to_leaf(store.active_leaf_id)
|
.path_to_leaf(store.active_leaf_id)
|
||||||
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
||||||
let path_len = path.len();
|
|
||||||
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
||||||
|
|
||||||
let stored_backend = insight.backend.clone();
|
let stored_backend = insight.backend.clone();
|
||||||
@@ -1003,6 +1008,10 @@ impl InsightChatService {
|
|||||||
if truncated {
|
if truncated {
|
||||||
let _ = entry.push_event(ChatStreamEvent::Truncated).await;
|
let _ = entry.push_event(ChatStreamEvent::Truncated).await;
|
||||||
}
|
}
|
||||||
|
// See the note in `chat_turn`: the new-node boundary must be read
|
||||||
|
// after the budget pass, because truncation drains replayed history
|
||||||
|
// out of the middle of `messages`.
|
||||||
|
let history_len = messages.len();
|
||||||
|
|
||||||
messages.push(ChatMessage::user(req.user_message.clone()));
|
messages.push(ChatMessage::user(req.user_message.clone()));
|
||||||
|
|
||||||
@@ -1046,7 +1055,7 @@ impl InsightChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append new messages as tree nodes.
|
// Append new messages as tree nodes.
|
||||||
let new_messages = messages[path_len..].to_vec();
|
let new_messages = messages[history_len..].to_vec();
|
||||||
let mut parent_id = Some(store.active_leaf_id);
|
let mut parent_id = Some(store.active_leaf_id);
|
||||||
for msg in &new_messages {
|
for msg in &new_messages {
|
||||||
let new_id = store.append_node(parent_id, msg.clone());
|
let new_id = store.append_node(parent_id, msg.clone());
|
||||||
@@ -2219,7 +2228,7 @@ pub fn env_max_iterations() -> usize {
|
|||||||
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
|
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
|
||||||
/// window for the truncation budget when the request omits `num_ctx`. Same
|
/// window for the truncation budget when the request omits `num_ctx`. Same
|
||||||
/// no-static-global rationale as `env_max_iterations` above.
|
/// no-static-global rationale as `env_max_iterations` above.
|
||||||
fn env_default_num_ctx() -> i32 {
|
pub(crate) fn env_default_num_ctx() -> i32 {
|
||||||
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
|
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
.and_then(|s| s.parse::<i32>().ok())
|
||||||
@@ -2534,7 +2543,7 @@ pub struct ForkInfo {
|
|||||||
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
|
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
|
||||||
/// the next rendered message. Detecting forks only on rendered nodes would miss
|
/// the next rendered message. Detecting forks only on rendered nodes would miss
|
||||||
/// regenerations where the model replied with a tool call.
|
/// regenerations where the model replied with a tool call.
|
||||||
fn render_tree_path(
|
pub(crate) fn render_tree_path(
|
||||||
store: &ChatHistoryStore,
|
store: &ChatHistoryStore,
|
||||||
path: &[&StoredChatNode],
|
path: &[&StoredChatNode],
|
||||||
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
|
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
|
||||||
|
|||||||
@@ -407,8 +407,8 @@ impl ChatHistoryStore {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parent of the given node, if any. Test-only helper.
|
/// The parent of the given node, if any. Used to re-anchor a rewind
|
||||||
#[cfg(test)]
|
/// that discards every rendered message onto the seed node above them.
|
||||||
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
|
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
|
||||||
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
||||||
let parent_id = node.parent_id?;
|
let parent_id = node.parent_id?;
|
||||||
|
|||||||
+7
-5
@@ -7,13 +7,13 @@ pub mod gpu;
|
|||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod insight_chat;
|
pub mod insight_chat;
|
||||||
pub mod insight_generator;
|
pub mod insight_generator;
|
||||||
pub mod persona_chat;
|
|
||||||
pub mod llamacpp;
|
pub mod llamacpp;
|
||||||
pub mod llm_client;
|
pub mod llm_client;
|
||||||
pub mod local_llm;
|
pub mod local_llm;
|
||||||
pub mod nl_query;
|
pub mod nl_query;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod openrouter;
|
pub mod openrouter;
|
||||||
|
pub mod persona_chat;
|
||||||
pub mod pronunciation;
|
pub mod pronunciation;
|
||||||
pub mod sms_client;
|
pub mod sms_client;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
@@ -33,16 +33,18 @@ pub use handlers::{
|
|||||||
get_available_models_handler, get_insight_handler, get_insight_history_handler,
|
get_available_models_handler, get_insight_handler, get_insight_history_handler,
|
||||||
get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler,
|
get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler,
|
||||||
};
|
};
|
||||||
pub use persona_chat::{
|
|
||||||
persona_chat_history_handler, persona_chat_reset_handler, persona_chat_turn_handler,
|
|
||||||
persona_turn_cancel_handler, persona_turn_replay_handler,
|
|
||||||
};
|
|
||||||
pub use insight_generator::InsightGenerator;
|
pub use insight_generator::InsightGenerator;
|
||||||
pub use llamacpp::LlamaCppClient;
|
pub use llamacpp::LlamaCppClient;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use llm_client::{
|
pub use llm_client::{
|
||||||
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
|
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
|
||||||
};
|
};
|
||||||
|
pub use persona_chat::{
|
||||||
|
persona_chat_branches_handler, persona_chat_conversations_handler,
|
||||||
|
persona_chat_create_conversation_handler, persona_chat_delete_conversation_handler,
|
||||||
|
persona_chat_history_handler, persona_chat_rewind_handler, persona_chat_switch_branch_handler,
|
||||||
|
persona_chat_turn_handler, persona_turn_cancel_handler, persona_turn_replay_handler,
|
||||||
|
};
|
||||||
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
|
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use local_llm::LocalLlm;
|
pub use local_llm::LocalLlm;
|
||||||
|
|||||||
+1380
-315
File diff suppressed because it is too large
Load Diff
+345
-114
@@ -13,16 +13,48 @@ use crate::otel::trace_db_call;
|
|||||||
/// One row of the persona-chat transcript. Lives in
|
/// One row of the persona-chat transcript. Lives in
|
||||||
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
|
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
|
||||||
///
|
///
|
||||||
/// `turn_count` is the number of new turn-rows since the last persisted
|
/// One persona conversation.
|
||||||
/// slice — used by the SSE `done` event for stats dashboards. The actual
|
///
|
||||||
/// transcript is the `messages_json` blob.
|
/// `turn_count` is the cumulative number of assistant turns on the active
|
||||||
|
/// branch — what the list screen shows as the length of a conversation. The
|
||||||
|
/// transcript itself is the `messages_json` blob, holding a serialized
|
||||||
|
/// `ChatHistoryStore` tree. `title` is a generated summary of the opening
|
||||||
|
/// exchange, empty until the first turn completes.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PersonaChatRow {
|
pub struct PersonaChatRow {
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub persona_id: String,
|
||||||
|
pub title: String,
|
||||||
pub messages_json: String,
|
pub messages_json: String,
|
||||||
pub turn_count: i32,
|
pub turn_count: i32,
|
||||||
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Column tuple → `PersonaChatRow`. Shared by the single-row and list
|
||||||
|
/// queries so their `select(...)` orders can never drift apart.
|
||||||
|
fn persona_chat_row(
|
||||||
|
(conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
i32,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
),
|
||||||
|
) -> PersonaChatRow {
|
||||||
|
PersonaChatRow {
|
||||||
|
conversation_id,
|
||||||
|
persona_id,
|
||||||
|
title,
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
||||||
/// allowed to flip `include_all_memories` but should reject name/prompt
|
/// allowed to flip `include_all_memories` but should reject name/prompt
|
||||||
/// edits at the handler layer (built-in copy lives in the migration).
|
/// edits at the handler layer (built-in copy lives in the migration).
|
||||||
@@ -96,39 +128,72 @@ pub trait PersonaDao: Sync + Send {
|
|||||||
|
|
||||||
// ── Persona-chat (open chat with persona) persistence ───────────
|
// ── Persona-chat (open chat with persona) persistence ───────────
|
||||||
//
|
//
|
||||||
// Keyed by `(user_id, persona_id)` with a single rolling transcript
|
// Keyed on an opaque `conversation_id` so one persona can hold several
|
||||||
// per pair. No tree branching in v1 — matches the locked-in scope.
|
// separate conversations. `user_id` is checked on every read and write:
|
||||||
|
// the id is a bearer token for a transcript, so ownership can never be
|
||||||
|
// assumed from the id alone.
|
||||||
|
|
||||||
/// Fetch the rolling transcript for one `(user, persona)`. None when
|
/// Fetch one conversation. None when it doesn't exist or belongs to
|
||||||
/// the user has never started a conversation with this persona.
|
/// another user — the two are deliberately indistinguishable to callers.
|
||||||
fn get_persona_chat(
|
fn get_persona_chat(
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &opentelemetry::Context,
|
cx: &opentelemetry::Context,
|
||||||
user_id: i32,
|
user_id: i32,
|
||||||
persona_id: &str,
|
conversation_id: &str,
|
||||||
) -> Result<Option<PersonaChatRow>, DbError>;
|
) -> Result<Option<PersonaChatRow>, DbError>;
|
||||||
|
|
||||||
/// Upsert (create-or-replace) the rolling transcript. Called once per
|
/// Every conversation this user has going, newest first. Backs the chat
|
||||||
/// completed turn with the full new `messages_json`. The `turn_count`
|
/// list screen, so it returns the transcript blob too — the snippet is
|
||||||
/// is the number of user/assistant pairs added in this write.
|
/// derived from the tree rather than denormalized into its own column.
|
||||||
fn upsert_persona_chat(
|
fn list_persona_chats(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Vec<PersonaChatRow>, DbError>;
|
||||||
|
|
||||||
|
/// Start a new conversation with a persona, returning its id. Several
|
||||||
|
/// conversations with the same persona are expected, so this never
|
||||||
|
/// reuses an existing row.
|
||||||
|
fn create_persona_chat(
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &opentelemetry::Context,
|
cx: &opentelemetry::Context,
|
||||||
user_id: i32,
|
user_id: i32,
|
||||||
persona_id: &str,
|
persona_id: &str,
|
||||||
|
created_at: i64,
|
||||||
|
) -> Result<String, DbError>;
|
||||||
|
|
||||||
|
/// Replace a conversation's transcript. Called once per completed turn
|
||||||
|
/// with the full new `messages_json`; `turn_count` is the cumulative
|
||||||
|
/// assistant-turn count on the resulting active branch. Returns the
|
||||||
|
/// number of rows written — 0 means the conversation is gone or is not
|
||||||
|
/// this user's.
|
||||||
|
fn update_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
conversation_id: &str,
|
||||||
messages_json: &str,
|
messages_json: &str,
|
||||||
turn_count: i32,
|
turn_count: i32,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> Result<(), DbError>;
|
) -> Result<usize, DbError>;
|
||||||
|
|
||||||
/// Wipe the rolling transcript. Reserved for a future "New
|
/// Name a conversation. Written once, after the first turn produces
|
||||||
/// conversation" affordance; the row itself stays (so a subsequent
|
/// enough of an exchange to summarize.
|
||||||
/// get returns None, not 404).
|
fn set_persona_chat_title(
|
||||||
fn clear_persona_chat(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &opentelemetry::Context,
|
cx: &opentelemetry::Context,
|
||||||
user_id: i32,
|
user_id: i32,
|
||||||
persona_id: &str,
|
conversation_id: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<(), DbError>;
|
||||||
|
|
||||||
|
/// Delete a conversation outright. Backs both the list screen's delete
|
||||||
|
/// affordance and the reset endpoint.
|
||||||
|
fn delete_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
conversation_id: &str,
|
||||||
) -> Result<(), DbError>;
|
) -> Result<(), DbError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,84 +416,155 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
&mut self,
|
&mut self,
|
||||||
cx: &opentelemetry::Context,
|
cx: &opentelemetry::Context,
|
||||||
uid: i32,
|
uid: i32,
|
||||||
pid: &str,
|
cid: &str,
|
||||||
) -> Result<Option<PersonaChatRow>, DbError> {
|
) -> Result<Option<PersonaChatRow>, DbError> {
|
||||||
trace_db_call(cx, "query", "get_persona_chat", |_span| {
|
trace_db_call(cx, "query", "get_persona_chat", |_span| {
|
||||||
use schema::persona_chat_conversations::dsl::*;
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
persona_chat_conversations
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
// Scoped by user as well as id: the id is a bearer token for
|
||||||
|
// someone's transcript, so it never grants access on its own.
|
||||||
.filter(user_id.eq(uid))
|
.filter(user_id.eq(uid))
|
||||||
.filter(persona_id.eq(pid))
|
.select((
|
||||||
.select((messages_json, turn_count, updated_at))
|
conversation_id,
|
||||||
.first::<(String, i32, i64)>(conn.deref_mut())
|
persona_id,
|
||||||
|
title,
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
))
|
||||||
|
.first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
||||||
.optional()
|
.optional()
|
||||||
.map(|opt| {
|
.map(|opt| opt.map(persona_chat_row))
|
||||||
opt.map(|(m, t, u)| PersonaChatRow {
|
|
||||||
messages_json: m,
|
|
||||||
turn_count: t,
|
|
||||||
updated_at: u,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||||
})
|
})
|
||||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_persona_chat(
|
fn list_persona_chats(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
) -> Result<Vec<PersonaChatRow>, DbError> {
|
||||||
|
trace_db_call(cx, "query", "list_persona_chats", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(user_id.eq(uid))
|
||||||
|
.order(updated_at.desc())
|
||||||
|
.select((
|
||||||
|
conversation_id,
|
||||||
|
persona_id,
|
||||||
|
title,
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
))
|
||||||
|
.load::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
||||||
|
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_persona_chat(
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &opentelemetry::Context,
|
cx: &opentelemetry::Context,
|
||||||
uid: i32,
|
uid: i32,
|
||||||
pid: &str,
|
pid: &str,
|
||||||
json: &str,
|
created: i64,
|
||||||
count: i32,
|
) -> Result<String, DbError> {
|
||||||
updated_at: i64,
|
trace_db_call(cx, "insert", "create_persona_chat", |_span| {
|
||||||
) -> Result<(), DbError> {
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
trace_db_call(cx, "upsert", "upsert_persona_chat", |_span| {
|
|
||||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
// INSERT OR REPLACE on the (user_id, persona_id) PRIMARY KEY —
|
let new_id = uuid::Uuid::new_v4().to_string();
|
||||||
// single rolling transcript, so a new write always supersedes
|
diesel::insert_into(persona_chat_conversations)
|
||||||
// the prior one in full. The mobile hook serialises turns with
|
.values((
|
||||||
// a per-persona mutex, so this never races. Plain
|
conversation_id.eq(&new_id),
|
||||||
// `sql_query` sidesteps a Diesel type-recursion blow-up that
|
user_id.eq(uid),
|
||||||
// hits `insert_into(...).on_conflict(...).do_update().set(...)`
|
persona_id.eq(pid),
|
||||||
// with this many typed columns.
|
title.eq(""),
|
||||||
diesel::sql_query(
|
// The empty tree; the first turn seeds it with the
|
||||||
"INSERT INTO persona_chat_conversations \
|
// persona's system prompt.
|
||||||
(user_id, persona_id, messages_json, turn_count, updated_at) \
|
messages_json.eq("[]"),
|
||||||
VALUES (?, ?, ?, ?, ?) \
|
turn_count.eq(0),
|
||||||
ON CONFLICT(user_id, persona_id) DO UPDATE SET \
|
created_at.eq(created),
|
||||||
messages_json = excluded.messages_json, \
|
updated_at.eq(created),
|
||||||
turn_count = excluded.turn_count, \
|
))
|
||||||
updated_at = excluded.updated_at",
|
.execute(conn.deref_mut())
|
||||||
)
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||||
.bind::<diesel::sql_types::Integer, _>(uid)
|
Ok(new_id)
|
||||||
.bind::<diesel::sql_types::Text, _>(pid)
|
|
||||||
.bind::<diesel::sql_types::Text, _>(json)
|
|
||||||
.bind::<diesel::sql_types::Integer, _>(count)
|
|
||||||
.bind::<diesel::sql_types::BigInt, _>(updated_at)
|
|
||||||
.execute(conn.deref_mut())
|
|
||||||
.map_err(|e| anyhow::anyhow!("Upsert error: {}", e))?;
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_persona_chat(
|
fn update_persona_chat(
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &opentelemetry::Context,
|
cx: &opentelemetry::Context,
|
||||||
uid: i32,
|
uid: i32,
|
||||||
pid: &str,
|
cid: &str,
|
||||||
) -> Result<(), DbError> {
|
json: &str,
|
||||||
trace_db_call(cx, "delete", "clear_persona_chat", |_span| {
|
count: i32,
|
||||||
|
updated: i64,
|
||||||
|
) -> Result<usize, DbError> {
|
||||||
|
trace_db_call(cx, "update", "update_persona_chat", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
diesel::update(
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
.filter(user_id.eq(uid)),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
messages_json.eq(json),
|
||||||
|
turn_count.eq(count),
|
||||||
|
updated_at.eq(updated),
|
||||||
|
))
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_persona_chat_title(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
cid: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
trace_db_call(cx, "update", "set_persona_chat_title", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
diesel::update(
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
.filter(user_id.eq(uid)),
|
||||||
|
)
|
||||||
|
.set(title.eq(new_title))
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
cid: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
trace_db_call(cx, "delete", "delete_persona_chat", |_span| {
|
||||||
use schema::persona_chat_conversations::dsl::*;
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
// Delete the row entirely so the next get returns None.
|
|
||||||
// The next send will INSERT a fresh seed (system + greeting)
|
|
||||||
// via the same code path a brand-new persona uses.
|
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
persona_chat_conversations
|
persona_chat_conversations
|
||||||
.filter(user_id.eq(uid))
|
.filter(conversation_id.eq(cid))
|
||||||
.filter(persona_id.eq(pid)),
|
.filter(user_id.eq(uid)),
|
||||||
)
|
)
|
||||||
.execute(conn.deref_mut())
|
.execute(conn.deref_mut())
|
||||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||||
@@ -437,7 +573,6 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -587,74 +722,170 @@ mod tests {
|
|||||||
|
|
||||||
// ── Persona-chat DAO tests ─────────────────────────────────────
|
// ── Persona-chat DAO tests ─────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
/// Second user, for the isolation tests.
|
||||||
fn persona_chat_get_returns_none_for_never_started() {
|
fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 {
|
||||||
let cx = opentelemetry::Context::new();
|
use crate::database::schema::users::dsl as u;
|
||||||
let (mut dao, uid) = dao_with_user("p1");
|
let conn = dao.connection.clone();
|
||||||
let row = dao.get_persona_chat(&cx, uid, "default").unwrap();
|
diesel::insert_into(u::users)
|
||||||
assert!(row.is_none());
|
.values((u::username.eq(username), u::password.eq("x")))
|
||||||
|
.execute(conn.lock().unwrap().deref_mut())
|
||||||
|
.unwrap();
|
||||||
|
u::users
|
||||||
|
.filter(u::username.eq(username))
|
||||||
|
.select(u::id)
|
||||||
|
.first(conn.lock().unwrap().deref_mut())
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persona_chat_upsert_then_get_round_trip() {
|
fn persona_chat_get_returns_none_for_an_unknown_conversation() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p1");
|
||||||
|
assert!(
|
||||||
|
dao.get_persona_chat(&cx, uid, "no-such-id")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_create_then_get_round_trip() {
|
||||||
let cx = opentelemetry::Context::new();
|
let cx = opentelemetry::Context::new();
|
||||||
let (mut dao, uid) = dao_with_user("p2");
|
let (mut dao, uid) = dao_with_user("p2");
|
||||||
dao.upsert_persona_chat(&cx, uid, "journal", "[]", 1, 100)
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
.unwrap();
|
|
||||||
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
||||||
assert_eq!(row.messages_json, "[]");
|
assert_eq!(row.conversation_id, cid);
|
||||||
assert_eq!(row.turn_count, 1);
|
assert_eq!(row.persona_id, "journal");
|
||||||
|
assert_eq!(row.title, "", "unnamed until the first turn completes");
|
||||||
|
assert_eq!(row.turn_count, 0);
|
||||||
|
assert_eq!(row.created_at, 100);
|
||||||
assert_eq!(row.updated_at, 100);
|
assert_eq!(row.updated_at, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persona_chat_upsert_replaces_existing_row() {
|
fn persona_chat_create_makes_a_separate_row_each_time() {
|
||||||
|
// The whole point of the conversation_id key: one persona, several
|
||||||
|
// independent threads.
|
||||||
let cx = opentelemetry::Context::new();
|
let cx = opentelemetry::Context::new();
|
||||||
let (mut dao, uid) = dao_with_user("p3");
|
let (mut dao, uid) = dao_with_user("p3");
|
||||||
dao.upsert_persona_chat(&cx, uid, "journal", "first", 1, 100).unwrap();
|
let first = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
dao.upsert_persona_chat(&cx, uid, "journal", "second", 2, 200).unwrap();
|
let second = dao.create_persona_chat(&cx, uid, "journal", 200).unwrap();
|
||||||
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert_eq!(dao.list_persona_chats(&cx, uid).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_update_replaces_the_transcript() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p4");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
|
||||||
|
let rows = dao
|
||||||
|
.update_persona_chat(&cx, uid, &cid, "first", 1, 150)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
let rows = dao
|
||||||
|
.update_persona_chat(&cx, uid, &cid, "second", 2, 200)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
|
||||||
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
||||||
assert_eq!(row.messages_json, "second");
|
assert_eq!(row.messages_json, "second");
|
||||||
assert_eq!(row.turn_count, 2);
|
assert_eq!(row.turn_count, 2);
|
||||||
assert_eq!(row.updated_at, 200);
|
assert_eq!(row.updated_at, 200);
|
||||||
// Single rolling transcript → exactly one row per (user, persona).
|
assert_eq!(row.created_at, 100, "creation time is not disturbed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persona_chat_isolation_between_users() {
|
fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
|
||||||
|
// The turn loop treats 0 as "the conversation went away mid-turn"
|
||||||
|
// rather than silently succeeding.
|
||||||
let cx = opentelemetry::Context::new();
|
let cx = opentelemetry::Context::new();
|
||||||
let (mut dao, uid1) = dao_with_user("u1");
|
let (mut dao, uid) = dao_with_user("p5");
|
||||||
let uid2: i32 = {
|
let rows = dao
|
||||||
let conn = dao.connection.clone();
|
.update_persona_chat(&cx, uid, "no-such-id", "x", 1, 1)
|
||||||
use crate::database::schema::users::dsl as u;
|
.unwrap();
|
||||||
diesel::insert_into(u::users)
|
assert_eq!(rows, 0);
|
||||||
.values((u::username.eq("u2"), u::password.eq("x")))
|
}
|
||||||
.execute(conn.lock().unwrap().deref_mut())
|
|
||||||
.unwrap();
|
#[test]
|
||||||
u::users
|
fn persona_chat_set_title_names_the_conversation() {
|
||||||
.filter(u::username.eq("u2"))
|
let cx = opentelemetry::Context::new();
|
||||||
.select(u::id)
|
let (mut dao, uid) = dao_with_user("p6");
|
||||||
.first(conn.lock().unwrap().deref_mut())
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
.unwrap()
|
|
||||||
};
|
dao.set_persona_chat_title(&cx, uid, &cid, "June recap")
|
||||||
dao.upsert_persona_chat(&cx, uid1, "default", "u1-row", 1, 1).unwrap();
|
.unwrap();
|
||||||
dao.upsert_persona_chat(&cx, uid2, "default", "u2-row", 1, 2).unwrap();
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
||||||
|
assert_eq!(row.title, "June recap");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_list_orders_newest_first() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p7");
|
||||||
|
let older = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
let newer = dao.create_persona_chat(&cx, uid, "coach", 300).unwrap();
|
||||||
|
let middle = dao.create_persona_chat(&cx, uid, "default", 200).unwrap();
|
||||||
|
|
||||||
|
let ids: Vec<String> = dao
|
||||||
|
.list_persona_chats(&cx, uid)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.conversation_id)
|
||||||
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dao.get_persona_chat(&cx, uid1, "default").unwrap().unwrap().messages_json,
|
ids,
|
||||||
"u1-row"
|
vec![newer, middle, older],
|
||||||
);
|
"ordered by updated_at descending so the list screen needs no re-sort"
|
||||||
assert_eq!(
|
|
||||||
dao.get_persona_chat(&cx, uid2, "default").unwrap().unwrap().messages_json,
|
|
||||||
"u2-row"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persona_chat_clear_wipes_the_row_so_get_returns_none() {
|
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
|
||||||
let cx = opentelemetry::Context::new();
|
let cx = opentelemetry::Context::new();
|
||||||
let (mut dao, uid) = dao_with_user("p4");
|
let (mut dao, uid) = dao_with_user("p8");
|
||||||
dao.upsert_persona_chat(&cx, uid, "default", "[]", 1, 1).unwrap();
|
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
|
||||||
dao.clear_persona_chat(&cx, uid, "default").unwrap();
|
}
|
||||||
assert!(dao.get_persona_chat(&cx, uid, "default").unwrap().is_none());
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_delete_removes_the_conversation() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p9");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
|
||||||
|
dao.delete_persona_chat(&cx, uid, &cid).unwrap();
|
||||||
|
assert!(dao.get_persona_chat(&cx, uid, &cid).unwrap().is_none());
|
||||||
|
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_reads_and_writes_are_scoped_to_the_owner() {
|
||||||
|
// A conversation_id is a bearer token for someone's transcript, so
|
||||||
|
// holding one must not grant another user access to it.
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid1) = dao_with_user("owner");
|
||||||
|
let uid2 = second_user(&dao, "intruder");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid1, "journal", 100).unwrap();
|
||||||
|
dao.update_persona_chat(&cx, uid1, &cid, "private", 1, 100)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dao.get_persona_chat(&cx, uid2, &cid).unwrap().is_none(),
|
||||||
|
"another user cannot read it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dao.update_persona_chat(&cx, uid2, &cid, "tampered", 9, 999)
|
||||||
|
.unwrap(),
|
||||||
|
0,
|
||||||
|
"another user cannot write it"
|
||||||
|
);
|
||||||
|
dao.delete_persona_chat(&cx, uid2, &cid).unwrap();
|
||||||
|
|
||||||
|
let row = dao.get_persona_chat(&cx, uid1, &cid).unwrap().unwrap();
|
||||||
|
assert_eq!(row.messages_json, "private", "and cannot delete it");
|
||||||
|
assert!(dao.list_persona_chats(&cx, uid2).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,11 +172,14 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
persona_chat_conversations (user_id, persona_id) {
|
persona_chat_conversations (conversation_id) {
|
||||||
|
conversation_id -> Text,
|
||||||
user_id -> Integer,
|
user_id -> Integer,
|
||||||
persona_id -> Text,
|
persona_id -> Text,
|
||||||
|
title -> Text,
|
||||||
messages_json -> Text,
|
messages_json -> Text,
|
||||||
turn_count -> Integer,
|
turn_count -> Integer,
|
||||||
|
created_at -> BigInt,
|
||||||
updated_at -> BigInt,
|
updated_at -> BigInt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -384,9 +384,14 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(ai::cancel_turn_handler)
|
.service(ai::cancel_turn_handler)
|
||||||
.service(ai::persona_chat_history_handler)
|
.service(ai::persona_chat_history_handler)
|
||||||
.service(ai::persona_chat_turn_handler)
|
.service(ai::persona_chat_turn_handler)
|
||||||
.service(ai::persona_chat_reset_handler)
|
.service(ai::persona_chat_create_conversation_handler)
|
||||||
|
.service(ai::persona_chat_delete_conversation_handler)
|
||||||
.service(ai::persona_turn_replay_handler)
|
.service(ai::persona_turn_replay_handler)
|
||||||
.service(ai::persona_turn_cancel_handler)
|
.service(ai::persona_turn_cancel_handler)
|
||||||
|
.service(ai::persona_chat_rewind_handler)
|
||||||
|
.service(ai::persona_chat_switch_branch_handler)
|
||||||
|
.service(ai::persona_chat_branches_handler)
|
||||||
|
.service(ai::persona_chat_conversations_handler)
|
||||||
.service(ai::rate_insight_handler)
|
.service(ai::rate_insight_handler)
|
||||||
.service(ai::export_training_data_handler)
|
.service(ai::export_training_data_handler)
|
||||||
.service(ai::tts_speech_handler)
|
.service(ai::tts_speech_handler)
|
||||||
|
|||||||
+1
-5
@@ -175,11 +175,7 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<(
|
|||||||
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
||||||
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
||||||
/// to the large-preview cap instead of 200.
|
/// to the large-preview cap instead of 200.
|
||||||
fn generate_large_preview_ffmpeg(
|
fn generate_large_preview_ffmpeg(src: &Path, dest: &Path, orientation: i32) -> std::io::Result<()> {
|
||||||
src: &Path,
|
|
||||||
dest: &Path,
|
|
||||||
orientation: i32,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
// Rotation + scale + colorspace. HEIC sources use Display P3; without
|
// Rotation + scale + colorspace. HEIC sources use Display P3; without
|
||||||
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
|
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
|
||||||
// warm/oversaturated output. The min(iw,cap) trick caps the long edge
|
// warm/oversaturated output. The min(iw,cap) trick caps the long edge
|
||||||
|
|||||||
Reference in New Issue
Block a user