fix(persona-chat): review fixes before merge
- Thread tools are bound server-side to the chat's own thread. The model used to pass contact_id, so practice mode could read any conversation it named; a model-supplied contact_id is now only checked, never trusted, and the prompts no longer hand it one. - Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID and run with an inert stand-in, so deleting or editing personas can't hide or break them. persona_id is optional on create for as_contact. - The per-thread list filters in SQL (list_persona_chats_for_contact, served by idx_persona_chat_anchor_contact) instead of decoding every transcript. - fetch_mms_image checks the content type and a 20 MB cap before reading the body, so a video attachment isn't downloaded in full. - get_photos_near_time unions tags over every path sharing the content, per the multi-library model. - look_at_photo only opens indexed photos, so an excluded folder can't be read by guessing a path. - One date parser: get_photos_near_time uses date_info::parse_center. - Practice prompt source rewrapped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
185a1b561a
commit
e029c81274
8 files changed
+383
-116
No files matched your search
+83
-16
@@ -122,6 +122,40 @@ pub fn seed_messages_for_persona(system_prompt: &str) -> Vec<ChatMessage> {
|
||||
vec![ChatMessage::system(system_prompt.to_string())]
|
||||
}
|
||||
|
||||
/// The persona id a practice conversation is stored under. Practice mode
|
||||
/// plays the SMS contact, not a persona, so the conversation belongs to no
|
||||
/// persona row: deleting or editing personas can't hide or break it.
|
||||
pub const PRACTICE_PERSONA_ID: &str = "";
|
||||
|
||||
/// Name shown for a practice conversation where a persona name would go.
|
||||
pub const PRACTICE_PERSONA_NAME: &str = "Practice";
|
||||
|
||||
/// Whether a stored conversation is a practice one (plays the contact).
|
||||
fn is_practice(row: &crate::database::persona_dao::PersonaChatRow) -> bool {
|
||||
row.anchor
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.mode == crate::database::AnchorMode::AsContact)
|
||||
}
|
||||
|
||||
/// The stand-in persona a practice turn runs with. Its prompt is never used
|
||||
/// (`anchored_system_prompt` ignores the persona in practice mode) and it
|
||||
/// can't correct facts — practice is offered no knowledge tools at all.
|
||||
fn practice_persona(user_id: i32) -> crate::database::models::Persona {
|
||||
crate::database::models::Persona {
|
||||
id: 0,
|
||||
user_id,
|
||||
persona_id: PRACTICE_PERSONA_ID.to_string(),
|
||||
name: PRACTICE_PERSONA_NAME.to_string(),
|
||||
system_prompt: String::new(),
|
||||
is_built_in: false,
|
||||
include_all_memories: false,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
reviewed_only_facts: false,
|
||||
allow_agent_corrections: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a persisted transcript into the conversation tree.
|
||||
///
|
||||
/// Two on-disk formats are accepted, mirroring the file chat's reader:
|
||||
@@ -602,8 +636,13 @@ impl PersonaChatSession {
|
||||
message_id: i64,
|
||||
mode: crate::database::AnchorMode,
|
||||
) -> Result<String, PersonaChatError> {
|
||||
let persona_id = persona_id.trim();
|
||||
{
|
||||
// Practice plays the contact; any persona the client sent is ignored.
|
||||
let persona_id = if mode == crate::database::AnchorMode::AsContact {
|
||||
PRACTICE_PERSONA_ID
|
||||
} else {
|
||||
persona_id.trim()
|
||||
};
|
||||
if mode != crate::database::AnchorMode::AsContact {
|
||||
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
||||
let persona = dao
|
||||
.get_persona(user_id, persona_id)
|
||||
@@ -740,10 +779,13 @@ impl PersonaChatSession {
|
||||
.get_persona_chat(user_id, &conversation_id)
|
||||
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
|
||||
.ok_or_else(|| PersonaChatError::UnknownConversation(conversation_id.clone()))?;
|
||||
let persona = dao
|
||||
.get_persona(user_id, &row.persona_id)
|
||||
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
|
||||
.ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?;
|
||||
let persona = if is_practice(&row) {
|
||||
practice_persona(user_id)
|
||||
} else {
|
||||
dao.get_persona(user_id, &row.persona_id)
|
||||
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
|
||||
.ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?
|
||||
};
|
||||
(persona, row.persona_id)
|
||||
};
|
||||
|
||||
@@ -1004,6 +1046,8 @@ impl PersonaChatSession {
|
||||
"",
|
||||
user_id,
|
||||
&persona.persona_id,
|
||||
// Thread tools only ever read this chat's own thread.
|
||||
row.anchor.as_ref().map(|a| a.contact_id),
|
||||
max_iterations,
|
||||
&entry,
|
||||
&mut fitter,
|
||||
@@ -1119,9 +1163,11 @@ impl PersonaChatSession {
|
||||
) -> Result<Vec<PersonaConversationSummary>> {
|
||||
let (rows, personas) = {
|
||||
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
||||
let rows = dao
|
||||
.list_persona_chats(user_id)
|
||||
.map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?;
|
||||
let rows = match contact_id {
|
||||
Some(cid) => dao.list_persona_chats_for_contact(user_id, cid),
|
||||
None => dao.list_persona_chats(user_id),
|
||||
}
|
||||
.map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?;
|
||||
let personas = dao
|
||||
.list_personas(user_id)
|
||||
.map_err(|e| anyhow!("failed to list personas: {e:?}"))?;
|
||||
@@ -1134,13 +1180,13 @@ impl PersonaChatSession {
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for entry in rows {
|
||||
if let Some(cid) = contact_id
|
||||
&& entry.anchor.as_ref().map(|a| a.contact_id) != Some(cid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(persona_name) = names.get(&entry.persona_id).cloned() else {
|
||||
continue;
|
||||
let persona_name = if is_practice(&entry) {
|
||||
PRACTICE_PERSONA_NAME.to_string()
|
||||
} else {
|
||||
match names.get(&entry.persona_id) {
|
||||
Some(name) => name.clone(),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
// A single unreadable transcript shouldn't take the whole list
|
||||
// down with it — skip the row and keep going.
|
||||
@@ -1372,6 +1418,9 @@ pub struct PersonaChatHistoryQuery {
|
||||
/// Body for POST /persona_chat/conversations — start a new conversation.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PersonaChatCreateRequest {
|
||||
/// Required except for a practice (`as_contact`) anchor, which plays
|
||||
/// the contact rather than a persona.
|
||||
#[serde(default)]
|
||||
pub persona_id: String,
|
||||
/// Anchor the conversation on a message in an SMS thread ("Continue
|
||||
/// with persona"). Omitted for an open chat.
|
||||
@@ -2038,6 +2087,24 @@ mod tests {
|
||||
assert!(tracker.claim("conv-a", "t7").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn practice_create_request_needs_no_persona() {
|
||||
let req: PersonaChatCreateRequest = serde_json::from_str(
|
||||
r#"{"anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(req.persona_id, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn practice_persona_is_inert() {
|
||||
let p = practice_persona(7);
|
||||
assert_eq!(p.persona_id, PRACTICE_PERSONA_ID);
|
||||
assert_eq!(p.user_id, 7);
|
||||
assert!(p.system_prompt.is_empty());
|
||||
assert!(!p.allow_agent_corrections);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_anchor_mode_defaults_to_about() {
|
||||
use crate::database::AnchorMode;
|
||||
|
||||
Reference in new issue
Block a user