feat(persona-chat): continue an SMS thread with a persona
Persona conversations can now be anchored on a message in an SMS thread. Creating one snapshots the thread up to that message (via SMS-API) into the row; every turn appends it to the persona prompt with guidance to talk like a text thread rather than write an insight. Anchored turns get four tools: get_thread_messages (page the thread), look_at_message_image (local vision on an MMS photo), get_photos_near_time (library photos with tags and people) and look_at_photo (local vision on a library photo). The conversation list filters by contact_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdbcc75391
commit
4ac574d4d2
11 files changed
+1389
-40
No files matched your search
+160
-31
@@ -29,21 +29,92 @@ pub struct PersonaChatRow {
|
||||
pub turn_count: i32,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
/// The SMS thread position this conversation grew out of, for chats
|
||||
/// started via "Continue with persona". None for an open chat.
|
||||
pub anchor: Option<SmsAnchor>,
|
||||
}
|
||||
|
||||
/// Column tuple → `PersonaChatRow`. Shared by the single-row and list
|
||||
/// queries so their `select(...)` orders can never drift apart.
|
||||
/// Where in an SMS thread an anchored persona conversation picks up.
|
||||
///
|
||||
/// `contact_id` / `message_id` are SMS-API ids. `context` is the rendered
|
||||
/// snapshot of the thread leading up to the anchor, captured once at
|
||||
/// creation and spliced into the system prompt on every turn.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SmsAnchor {
|
||||
pub contact_id: i64,
|
||||
pub contact_name: String,
|
||||
pub message_id: i64,
|
||||
/// Unix seconds of the anchor message.
|
||||
pub timestamp: i64,
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
/// The column tuple every persona-chat read selects. Shared by the
|
||||
/// single-row and list queries so their `select(...)` orders can never
|
||||
/// drift apart.
|
||||
type PersonaChatColumns = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i32,
|
||||
i64,
|
||||
i64,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
macro_rules! persona_chat_select {
|
||||
() => {
|
||||
(
|
||||
conversation_id,
|
||||
persona_id,
|
||||
title,
|
||||
messages_json,
|
||||
turn_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
anchor_contact_id,
|
||||
anchor_contact_name,
|
||||
anchor_message_id,
|
||||
anchor_ts,
|
||||
anchor_context,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Column tuple → `PersonaChatRow`.
|
||||
fn persona_chat_row(
|
||||
(conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i32,
|
||||
i64,
|
||||
i64,
|
||||
),
|
||||
(
|
||||
conversation_id,
|
||||
persona_id,
|
||||
title,
|
||||
messages_json,
|
||||
turn_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
anchor_contact_id,
|
||||
anchor_contact_name,
|
||||
anchor_message_id,
|
||||
anchor_ts,
|
||||
anchor_context,
|
||||
): PersonaChatColumns,
|
||||
) -> PersonaChatRow {
|
||||
// An anchor needs at least the thread and the message; the rest has a
|
||||
// usable default if a row was written half-way by hand.
|
||||
let anchor = match (anchor_contact_id, anchor_message_id) {
|
||||
(Some(contact_id), Some(message_id)) => Some(SmsAnchor {
|
||||
contact_id,
|
||||
contact_name: anchor_contact_name.unwrap_or_default(),
|
||||
message_id,
|
||||
timestamp: anchor_ts.unwrap_or(0),
|
||||
context: anchor_context.unwrap_or_default(),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
PersonaChatRow {
|
||||
conversation_id,
|
||||
persona_id,
|
||||
@@ -52,6 +123,7 @@ fn persona_chat_row(
|
||||
turn_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
anchor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +207,17 @@ pub trait PersonaDao: Sync + Send {
|
||||
created_at: i64,
|
||||
) -> Result<String, DbError>;
|
||||
|
||||
/// Start a new conversation anchored on a point in an SMS thread
|
||||
/// ("Continue with persona"). Same as `create_persona_chat` plus the
|
||||
/// anchor columns.
|
||||
fn create_anchored_persona_chat(
|
||||
&mut self,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
created_at: i64,
|
||||
anchor: &SmsAnchor,
|
||||
) -> 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
|
||||
@@ -382,16 +465,8 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
// 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))
|
||||
.select((
|
||||
conversation_id,
|
||||
persona_id,
|
||||
title,
|
||||
messages_json,
|
||||
turn_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
))
|
||||
.first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
||||
.select(persona_chat_select!())
|
||||
.first::<PersonaChatColumns>(conn.deref_mut())
|
||||
.optional()
|
||||
.map(|opt| opt.map(persona_chat_row))
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
@@ -406,16 +481,8 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
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())
|
||||
.select(persona_chat_select!())
|
||||
.load::<PersonaChatColumns>(conn.deref_mut())
|
||||
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
@@ -452,6 +519,40 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
}
|
||||
|
||||
fn create_anchored_persona_chat(
|
||||
&mut self,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
created: i64,
|
||||
anchor: &SmsAnchor,
|
||||
) -> Result<String, DbError> {
|
||||
trace_db_call("insert", "create_anchored_persona_chat", |_span| {
|
||||
use schema::persona_chat_conversations::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
let new_id = uuid::Uuid::new_v4().to_string();
|
||||
diesel::insert_into(persona_chat_conversations)
|
||||
.values((
|
||||
conversation_id.eq(&new_id),
|
||||
user_id.eq(uid),
|
||||
persona_id.eq(pid),
|
||||
title.eq(""),
|
||||
messages_json.eq("[]"),
|
||||
turn_count.eq(0),
|
||||
created_at.eq(created),
|
||||
updated_at.eq(created),
|
||||
anchor_contact_id.eq(Some(anchor.contact_id)),
|
||||
anchor_contact_name.eq(Some(&anchor.contact_name)),
|
||||
anchor_message_id.eq(Some(anchor.message_id)),
|
||||
anchor_ts.eq(Some(anchor.timestamp)),
|
||||
anchor_context.eq(Some(&anchor.context)),
|
||||
))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||
Ok(new_id)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
}
|
||||
|
||||
fn update_persona_chat(
|
||||
&mut self,
|
||||
uid: i32,
|
||||
@@ -766,6 +867,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_chat_anchor_round_trips_and_open_chats_have_none() {
|
||||
let (mut dao, uid) = dao_with_user("p10");
|
||||
let anchor = SmsAnchor {
|
||||
contact_id: 42,
|
||||
contact_name: "Sam".to_string(),
|
||||
message_id: 9001,
|
||||
timestamp: 1_700_000_000,
|
||||
context: "[2023-11-14 22:13] Sam: see you there".to_string(),
|
||||
};
|
||||
let anchored = dao
|
||||
.create_anchored_persona_chat(uid, "journal", 100, &anchor)
|
||||
.unwrap();
|
||||
let open = dao.create_persona_chat(uid, "journal", 200).unwrap();
|
||||
|
||||
let row = dao.get_persona_chat(uid, &anchored).unwrap().unwrap();
|
||||
assert_eq!(row.anchor, Some(anchor));
|
||||
let row = dao.get_persona_chat(uid, &open).unwrap().unwrap();
|
||||
assert_eq!(row.anchor, None);
|
||||
assert!(
|
||||
dao.list_persona_chats(uid)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|r| r.anchor.is_some()),
|
||||
"the list query carries the anchor too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
|
||||
let (mut dao, uid) = dao_with_user("p8");
|
||||
|
||||
Reference in new issue
Block a user