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
+94
-66
@@ -1891,6 +1891,7 @@ Return ONLY the summary, nothing else."#,
|
||||
file_path: &str,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
thread_contact: Option<i64>,
|
||||
) -> String {
|
||||
let model = backend.model();
|
||||
let backend_label = backend.kind.as_str();
|
||||
@@ -1940,9 +1941,12 @@ Return ONLY the summary, nothing else."#,
|
||||
"date_info" => {
|
||||
crate::ai::date_info::date_info(arguments, Utc::now(), self.display_zone())
|
||||
}
|
||||
"get_thread_messages" => self.tool_get_thread_messages(arguments).await,
|
||||
"get_thread_messages" => {
|
||||
self.tool_get_thread_messages(arguments, thread_contact)
|
||||
.await
|
||||
}
|
||||
"look_at_message_image" => {
|
||||
self.tool_look_at_message_image(arguments, backend.local())
|
||||
self.tool_look_at_message_image(arguments, thread_contact, backend.local())
|
||||
.await
|
||||
}
|
||||
"get_photos_near_time" => self.tool_get_photos_near_time(arguments).await,
|
||||
@@ -2411,9 +2415,14 @@ Return ONLY the summary, nothing else."#,
|
||||
/// Tool: get_thread_messages — page the anchored SMS thread from a
|
||||
/// message id. Lines are rendered exactly like the anchor snapshot so
|
||||
/// the model can keep paging from any id it sees.
|
||||
async fn tool_get_thread_messages(&self, args: &serde_json::Value) -> String {
|
||||
let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'contact_id'".to_string();
|
||||
async fn tool_get_thread_messages(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
thread_contact: Option<i64>,
|
||||
) -> String {
|
||||
let contact_id = match Self::bound_thread(args, thread_contact) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'message_id'".to_string();
|
||||
@@ -2461,10 +2470,12 @@ Return ONLY the summary, nothing else."#,
|
||||
async fn tool_look_at_message_image(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
thread_contact: Option<i64>,
|
||||
local: &dyn LlmClient,
|
||||
) -> String {
|
||||
let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'contact_id'".to_string();
|
||||
let contact_id = match Self::bound_thread(args, thread_contact) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'message_id'".to_string();
|
||||
@@ -2476,11 +2487,15 @@ Return ONLY the summary, nothing else."#,
|
||||
|
||||
let bytes = match self
|
||||
.sms_client
|
||||
.fetch_mms_media(contact_id, message_id, media_index)
|
||||
.fetch_mms_image(contact_id, message_id, media_index)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error fetching attachment: {}", e),
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"Error: couldn't look at that attachment ({e}); only photos can be viewed."
|
||||
);
|
||||
}
|
||||
};
|
||||
let image_base64 = match Self::encode_image_bytes_for_vision(&bytes) {
|
||||
Ok(b64) => b64,
|
||||
@@ -2501,7 +2516,12 @@ Return ONLY the summary, nothing else."#,
|
||||
async fn tool_get_photos_near_time(&self, args: &serde_json::Value) -> String {
|
||||
let zone = self.display_zone();
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
let Some(center) = Self::parse_moment(args, zone) else {
|
||||
let center_arg = args
|
||||
.get("timestamp")
|
||||
.filter(|v| !v.is_null())
|
||||
.or_else(|| args.get("datetime"));
|
||||
let Some(center) = center_arg.and_then(|v| crate::ai::date_info::parse_center(v, zone))
|
||||
else {
|
||||
return "Error: pass 'timestamp' (unix seconds) or 'datetime' (YYYY-MM-DD HH:MM)"
|
||||
.to_string();
|
||||
};
|
||||
@@ -2553,11 +2573,22 @@ Return ONLY the summary, nothing else."#,
|
||||
let when = crate::date_view::row_display_wall(&row, zone, server_zone)
|
||||
.map(|w| w.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "unknown time".to_string());
|
||||
// Tags follow the bytes: union over every path holding the same
|
||||
// content, so a copy in another library shows its tags too.
|
||||
let sibling_paths = {
|
||||
let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
dao.get_rel_paths_sharing_content(row.library_id, &row.file_path)
|
||||
.unwrap_or_else(|_| vec![row.file_path.clone()])
|
||||
};
|
||||
let tags: Vec<String> = {
|
||||
let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao");
|
||||
dao.get_tags_for_path(&row.file_path)
|
||||
let mut names: Vec<String> = dao
|
||||
.get_tags_for_paths(&sibling_paths)
|
||||
.map(|t| t.into_iter().map(|t| t.name).collect())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
};
|
||||
let people: Vec<String> = match &row.content_hash {
|
||||
Some(hash) => {
|
||||
@@ -2602,6 +2633,14 @@ Return ONLY the summary, nothing else."#,
|
||||
if crate::file_types::is_video_file(std::path::Path::new(&file_path)) {
|
||||
return "Error: that's a video — only photos can be looked at.".to_string();
|
||||
}
|
||||
// Only photos the library has indexed: excluded folders (EXCLUDED_DIRS,
|
||||
// e.g. `private`) are never indexed, so a guessed path into one is
|
||||
// refused rather than read off disk.
|
||||
if self.fetch_exif(&file_path).is_none() {
|
||||
return "Error: that isn't a photo in the library — use a file_path from \
|
||||
get_photos_near_time."
|
||||
.to_string();
|
||||
}
|
||||
let image_base64 = match self.load_image_as_base64(&file_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error loading photo: {}", e),
|
||||
@@ -2612,6 +2651,26 @@ Return ONLY the summary, nothing else."#,
|
||||
}
|
||||
}
|
||||
|
||||
/// The thread a thread tool may read: the one the chat was started from,
|
||||
/// fixed server-side. A `contact_id` the model passes is only checked
|
||||
/// against it — never trusted — so a chat (practice mode especially) can
|
||||
/// never be talked into reading another conversation.
|
||||
fn bound_thread(args: &serde_json::Value, thread_contact: Option<i64>) -> Result<i64, String> {
|
||||
let Some(bound) = thread_contact else {
|
||||
return Err(
|
||||
"Error: thread tools are only available in a chat started from a \
|
||||
text thread."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
match args.get("contact_id").and_then(|v| v.as_i64()) {
|
||||
Some(asked) if asked != bound => {
|
||||
Err("Error: only this chat's own text thread can be read here.".to_string())
|
||||
}
|
||||
_ => Ok(bound),
|
||||
}
|
||||
}
|
||||
|
||||
/// A model-supplied path is joined onto each library root, so refuse
|
||||
/// anything that could climb out of it.
|
||||
fn photo_path_is_safe(path: &str) -> bool {
|
||||
@@ -2622,28 +2681,6 @@ Return ONLY the summary, nothing else."#,
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
}
|
||||
|
||||
/// A tool's centre moment: `timestamp` (unix seconds) wins, else
|
||||
/// `datetime` read as a wall clock in the owner's display zone. A bare
|
||||
/// date centres on midday.
|
||||
fn parse_moment(args: &serde_json::Value, zone: chrono_tz::Tz) -> Option<i64> {
|
||||
if let Some(ts) = args.get("timestamp").and_then(|v| v.as_i64()) {
|
||||
return Some(ts);
|
||||
}
|
||||
let raw = args.get("datetime").and_then(|v| v.as_str())?.trim();
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S"))
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
NaiveDate::parse_from_str(raw, "%Y-%m-%d")
|
||||
.ok()
|
||||
.and_then(|d| d.and_hms_opt(12, 0, 0))
|
||||
})?;
|
||||
zone.from_local_datetime(&naive)
|
||||
.earliest()
|
||||
.map(|dt| dt.timestamp())
|
||||
}
|
||||
|
||||
/// Decode arbitrary image bytes (an MMS attachment), shrink to the
|
||||
/// vision models' 1024px working size and re-encode as base64 JPEG —
|
||||
/// the same shape `load_image_as_base64` produces for library files.
|
||||
@@ -4062,12 +4099,11 @@ Return ONLY the summary, nothing else."#,
|
||||
thread (the #numbers in the transcript) — `before` for older messages, `after` for what \
|
||||
came next, `around` for both sides. Lines carry their own #id, so call again from the \
|
||||
oldest/newest id to keep paging. \
|
||||
Example: `{contact_id: 42, message_id: 812, direction: \"before\", limit: 30}`.",
|
||||
Example: `{message_id: 812, direction: \"before\", limit: 30}`.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["contact_id", "message_id"],
|
||||
"required": ["message_id"],
|
||||
"properties": {
|
||||
"contact_id": { "type": "integer", "description": "The thread's contact_id (given in the system prompt)." },
|
||||
"message_id": { "type": "integer", "description": "Message id to page from." },
|
||||
"direction": { "type": "string", "enum": ["before", "after", "around"], "description": "Which side of message_id to read (default before)." },
|
||||
"limit": { "type": "integer", "description": "Max messages (default 30, max 80)." }
|
||||
@@ -4080,12 +4116,11 @@ Return ONLY the summary, nothing else."#,
|
||||
"Look at a photo that was sent in the SMS thread, using the local vision model. Returns a \
|
||||
visual description. Attachments appear in the transcript as `image #N` on a message line — \
|
||||
pass that message's id and N as media_index. Videos and audio can't be viewed. \
|
||||
Example: `{contact_id: 42, message_id: 815, media_index: 0}`.",
|
||||
Example: `{message_id: 815, media_index: 0}`.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["contact_id", "message_id"],
|
||||
"required": ["message_id"],
|
||||
"properties": {
|
||||
"contact_id": { "type": "integer", "description": "The thread's contact_id." },
|
||||
"message_id": { "type": "integer", "description": "Id of the message carrying the photo." },
|
||||
"media_index": { "type": "integer", "description": "Which attachment on that message (default 0)." }
|
||||
}
|
||||
@@ -4843,6 +4878,7 @@ Return ONLY the summary, nothing else."#,
|
||||
&file_path,
|
||||
user_id,
|
||||
&persona_id,
|
||||
None,
|
||||
)
|
||||
.instrument(loop_span.clone())
|
||||
.await;
|
||||
@@ -4895,6 +4931,7 @@ Return ONLY the summary, nothing else."#,
|
||||
&file_path,
|
||||
user_id,
|
||||
&persona_id,
|
||||
None,
|
||||
)
|
||||
.instrument(loop_span.clone())
|
||||
.await;
|
||||
@@ -5083,6 +5120,7 @@ Return ONLY the summary, nothing else."#,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
@@ -5124,6 +5162,7 @@ Return ONLY the summary, nothing else."#,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
@@ -5294,6 +5333,21 @@ mod tests {
|
||||
assert!(names.contains(&"look_at_photo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_tools_only_read_the_chats_own_thread() {
|
||||
use serde_json::json;
|
||||
// The bound thread is used; the model needn't (and can't) choose.
|
||||
assert_eq!(InsightGenerator::bound_thread(&json!({}), Some(42)), Ok(42));
|
||||
assert_eq!(
|
||||
InsightGenerator::bound_thread(&json!({"contact_id": 42}), Some(42)),
|
||||
Ok(42)
|
||||
);
|
||||
// Another thread is refused, even if the model names it.
|
||||
assert!(InsightGenerator::bound_thread(&json!({"contact_id": 7}), Some(42)).is_err());
|
||||
// No bound thread (not a thread chat): nothing to read.
|
||||
assert!(InsightGenerator::bound_thread(&json!({"contact_id": 42}), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_path_is_safe_rejects_escapes() {
|
||||
assert!(InsightGenerator::photo_path_is_safe("2019/06/IMG_1.jpg"));
|
||||
@@ -5303,32 +5357,6 @@ mod tests {
|
||||
assert!(!InsightGenerator::photo_path_is_safe(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_moment_accepts_unix_and_local_wall_clock() {
|
||||
let tz = chrono_tz::Tz::America__New_York;
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(&serde_json::json!({"timestamp": 1_700_000_000}), tz),
|
||||
Some(1_700_000_000)
|
||||
);
|
||||
// 2023-11-14 17:13 in New York (EST) = 22:13 UTC.
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(
|
||||
&serde_json::json!({"datetime": "2023-11-14 17:13"}),
|
||||
tz
|
||||
),
|
||||
Some(1_699_999_980)
|
||||
);
|
||||
// A bare date means midday, the least-wrong centre for a whole day.
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(&serde_json::json!({"datetime": "2023-11-14"}), tz),
|
||||
Some(1_699_981_200)
|
||||
);
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(&serde_json::json!({}), tz),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn place(name: &str, description: &str) -> ApolloPlace {
|
||||
ApolloPlace {
|
||||
id: 1,
|
||||
|
||||
Reference in new issue
Block a user