feat(persona-chat): practice mode — play the contact
An anchored conversation now has a mode. 'about' is the existing behaviour (a persona talks with the user about the thread). 'as_contact' has the model play the contact so the user can rehearse the conversation: its own prompt (stay in the contact's voice, don't invent facts, coach on 'out of character'), no persona prompt, and only the thread tools — no memory, library, calendar or other-thread access, and no knowledge writes. The shared persona/file-chat loop now refuses tool calls that weren't in the turn's catalog, so the gates hold even when a model names a tool it wasn't offered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ac574d4d2
commit
3f1bf42f1c
8 files changed
+276
-21
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
DROP INDEX IF EXISTS idx_persona_chat_anchor_contact;
|
DROP INDEX IF EXISTS idx_persona_chat_anchor_contact;
|
||||||
|
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_mode;
|
||||||
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_context;
|
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_context;
|
||||||
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_ts;
|
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_ts;
|
||||||
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_message_id;
|
ALTER TABLE persona_chat_conversations DROP COLUMN anchor_message_id;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
-- prefix byte-stable for KV-cache reuse and keeps the persona's view of the
|
-- prefix byte-stable for KV-cache reuse and keeps the persona's view of the
|
||||||
-- thread fixed even if SMS-API is re-imported underneath it.
|
-- thread fixed even if SMS-API is re-imported underneath it.
|
||||||
--
|
--
|
||||||
|
-- `anchor_mode` is how the chat relates to the thread: 'about' (a persona
|
||||||
|
-- talks with the user about it) or 'as_contact' (the model plays the
|
||||||
|
-- contact so the user can practise the conversation). NULL reads as 'about'.
|
||||||
|
--
|
||||||
-- All nullable: an unanchored (open) persona chat leaves them NULL.
|
-- All nullable: an unanchored (open) persona chat leaves them NULL.
|
||||||
|
|
||||||
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_id BIGINT;
|
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_id BIGINT;
|
||||||
@@ -18,6 +22,7 @@ ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_name TEXT;
|
|||||||
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_message_id BIGINT;
|
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_message_id BIGINT;
|
||||||
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_ts BIGINT;
|
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_ts BIGINT;
|
||||||
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_context TEXT;
|
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_context TEXT;
|
||||||
|
ALTER TABLE persona_chat_conversations ADD COLUMN anchor_mode TEXT;
|
||||||
|
|
||||||
CREATE INDEX idx_persona_chat_anchor_contact
|
CREATE INDEX idx_persona_chat_anchor_contact
|
||||||
ON persona_chat_conversations (user_id, anchor_contact_id, updated_at DESC);
|
ON persona_chat_conversations (user_id, anchor_contact_id, updated_at DESC);
|
||||||
+20
-2
@@ -2466,6 +2466,13 @@ pub async fn run_streaming_agentic_loop_with_entry(
|
|||||||
let mut last_prompt_eval_count: Option<i32> = None;
|
let mut last_prompt_eval_count: Option<i32> = None;
|
||||||
let mut last_eval_count: Option<i32> = None;
|
let mut last_eval_count: Option<i32> = None;
|
||||||
let mut final_content = String::new();
|
let mut final_content = String::new();
|
||||||
|
// Only tools in this turn's catalog may run. The catalog is how every
|
||||||
|
// gate (per-persona corrections, practice mode's narrowed set, absent
|
||||||
|
// backing tables) is expressed, and `execute_tool` dispatches on name
|
||||||
|
// alone — without this a model that names a tool it was never offered
|
||||||
|
// (one it saw in an earlier turn, or simply guessed) would still run it.
|
||||||
|
let offered: std::collections::HashSet<String> =
|
||||||
|
tools.iter().map(|t| t.function.name.clone()).collect();
|
||||||
|
|
||||||
for iteration in 0..max_iterations {
|
for iteration in 0..max_iterations {
|
||||||
if !entry.is_running() {
|
if !entry.is_running() {
|
||||||
@@ -2536,7 +2543,8 @@ pub async fn run_streaming_agentic_loop_with_entry(
|
|||||||
arguments: tool_call.function.arguments.clone(),
|
arguments: tool_call.function.arguments.clone(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let result = generator
|
let result = if offered.contains(&tool_call.function.name) {
|
||||||
|
generator
|
||||||
.execute_tool(
|
.execute_tool(
|
||||||
&tool_call.function.name,
|
&tool_call.function.name,
|
||||||
&tool_call.function.arguments,
|
&tool_call.function.arguments,
|
||||||
@@ -2546,7 +2554,17 @@ pub async fn run_streaming_agentic_loop_with_entry(
|
|||||||
user_id,
|
user_id,
|
||||||
active_persona,
|
active_persona,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"refusing tool '{}': not offered this turn",
|
||||||
|
tool_call.function.name
|
||||||
|
);
|
||||||
|
format!(
|
||||||
|
"Error: tool '{}' is not available here.",
|
||||||
|
tool_call.function.name
|
||||||
|
)
|
||||||
|
};
|
||||||
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
||||||
let _ = entry
|
let _ = entry
|
||||||
.push_event(ChatStreamEvent::ToolResult {
|
.push_event(ChatStreamEvent::ToolResult {
|
||||||
|
|||||||
@@ -242,6 +242,9 @@ pub struct PersonaChatAnchorView {
|
|||||||
pub timestamp: i64,
|
pub timestamp: i64,
|
||||||
/// The anchor message as "Speaker: text", recovered from the snapshot.
|
/// The anchor message as "Speaker: text", recovered from the snapshot.
|
||||||
pub preview: String,
|
pub preview: String,
|
||||||
|
/// `about` (a persona discusses the thread) or `as_contact` (the model
|
||||||
|
/// plays the contact for practice).
|
||||||
|
pub mode: crate::database::AnchorMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView {
|
impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView {
|
||||||
@@ -252,6 +255,7 @@ impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView {
|
|||||||
message_id: a.message_id,
|
message_id: a.message_id,
|
||||||
timestamp: a.timestamp,
|
timestamp: a.timestamp,
|
||||||
preview: crate::ai::sms_anchor::anchor_preview(&a.context),
|
preview: crate::ai::sms_anchor::anchor_preview(&a.context),
|
||||||
|
mode: a.mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,6 +600,7 @@ impl PersonaChatSession {
|
|||||||
persona_id: &str,
|
persona_id: &str,
|
||||||
contact_id: i64,
|
contact_id: i64,
|
||||||
message_id: i64,
|
message_id: i64,
|
||||||
|
mode: crate::database::AnchorMode,
|
||||||
) -> Result<String, PersonaChatError> {
|
) -> Result<String, PersonaChatError> {
|
||||||
let persona_id = persona_id.trim();
|
let persona_id = persona_id.trim();
|
||||||
{
|
{
|
||||||
@@ -630,6 +635,7 @@ impl PersonaChatSession {
|
|||||||
message_id,
|
message_id,
|
||||||
timestamp: window.messages[window.anchor_index].timestamp,
|
timestamp: window.messages[window.anchor_index].timestamp,
|
||||||
context,
|
context,
|
||||||
|
mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
||||||
@@ -935,6 +941,12 @@ impl PersonaChatSession {
|
|||||||
);
|
);
|
||||||
gate_opts.sms_thread_tools = row.anchor.is_some();
|
gate_opts.sms_thread_tools = row.anchor.is_some();
|
||||||
let tools = InsightGenerator::build_tool_definitions(gate_opts);
|
let tools = InsightGenerator::build_tool_definitions(gate_opts);
|
||||||
|
// Practice mode narrows the catalog to the thread itself — no
|
||||||
|
// memory, library or calendar access for a simulated contact.
|
||||||
|
let tools = match &row.anchor {
|
||||||
|
Some(anchor) => crate::ai::sms_anchor::tools_for_mode(tools, anchor.mode),
|
||||||
|
None => tools,
|
||||||
|
};
|
||||||
|
|
||||||
// Append the new user turn + apply the per-turn system-prompt
|
// Append the new user turn + apply the per-turn system-prompt
|
||||||
// override. Per-persona overrides are NOT persisted (the persona
|
// override. Per-persona overrides are NOT persisted (the persona
|
||||||
@@ -1368,6 +1380,9 @@ pub struct PersonaChatCreateRequest {
|
|||||||
pub struct PersonaChatAnchorRequest {
|
pub struct PersonaChatAnchorRequest {
|
||||||
pub contact_id: i64,
|
pub contact_id: i64,
|
||||||
pub message_id: i64,
|
pub message_id: i64,
|
||||||
|
/// `about` (default) or `as_contact`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: crate::database::AnchorMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query params for GET /persona_chat/conversations.
|
/// Query params for GET /persona_chat/conversations.
|
||||||
@@ -1489,6 +1504,7 @@ pub async fn persona_chat_create_conversation_handler(
|
|||||||
&request.persona_id,
|
&request.persona_id,
|
||||||
anchor.contact_id,
|
anchor.contact_id,
|
||||||
anchor.message_id,
|
anchor.message_id,
|
||||||
|
anchor.mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -2018,6 +2034,24 @@ mod tests {
|
|||||||
assert!(tracker.claim("conv-a", "t7").is_ok());
|
assert!(tracker.claim("conv-a", "t7").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_request_anchor_mode_defaults_to_about() {
|
||||||
|
use crate::database::AnchorMode;
|
||||||
|
let req: PersonaChatCreateRequest = serde_json::from_str(
|
||||||
|
r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.anchor.unwrap().mode, AnchorMode::About);
|
||||||
|
let req: PersonaChatCreateRequest = serde_json::from_str(
|
||||||
|
r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.anchor.unwrap().mode, AnchorMode::AsContact);
|
||||||
|
let req: PersonaChatCreateRequest =
|
||||||
|
serde_json::from_str(r#"{"persona_id":"journal"}"#).unwrap();
|
||||||
|
assert!(req.anchor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persona_chat_turn_request_deserializes_snake_case_wire_body() {
|
fn persona_chat_turn_request_deserializes_snake_case_wire_body() {
|
||||||
// Pins the mobile-client contract: snake_case keys, same as the
|
// Pins the mobile-client contract: snake_case keys, same as the
|
||||||
@@ -2273,12 +2307,14 @@ mod tests {
|
|||||||
message_id: 812,
|
message_id: 812,
|
||||||
timestamp: 1_700_000_000,
|
timestamp: 1_700_000_000,
|
||||||
preview: "Sam: see you at 6".to_string(),
|
preview: "Sam: see you at 6".to_string(),
|
||||||
|
mode: crate::database::AnchorMode::AsContact,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&summary).unwrap();
|
let json = serde_json::to_value(&summary).unwrap();
|
||||||
assert_eq!(json["anchor"]["contact_id"], 42);
|
assert_eq!(json["anchor"]["contact_id"], 42);
|
||||||
assert_eq!(json["anchor"]["message_id"], 812);
|
assert_eq!(json["anchor"]["message_id"], 812);
|
||||||
assert_eq!(json["anchor"]["preview"], "Sam: see you at 6");
|
assert_eq!(json["anchor"]["preview"], "Sam: see you at 6");
|
||||||
|
assert_eq!(json["anchor"]["mode"], "as_contact");
|
||||||
assert_eq!(json["conversation_id"], "conv-1");
|
assert_eq!(json["conversation_id"], "conv-1");
|
||||||
assert_eq!(json["title"], "June recap");
|
assert_eq!(json["title"], "June recap");
|
||||||
assert_eq!(json["persona_id"], "journal");
|
assert_eq!(json["persona_id"], "journal");
|
||||||
|
|||||||
+162
-18
@@ -7,7 +7,10 @@
|
|||||||
//! - rendering a slice of the thread as a compact transcript the model can
|
//! - rendering a slice of the thread as a compact transcript the model can
|
||||||
//! read (and that names message ids / attachment indices, so the thread
|
//! read (and that names message ids / attachment indices, so the thread
|
||||||
//! tools can page further back or look at a photo),
|
//! tools can page further back or look at a photo),
|
||||||
//! - the conversational guidance appended to the persona's system prompt.
|
//! - the system prompt for each [`AnchorMode`]: a persona talking with the
|
||||||
|
//! user *about* the thread, or the model playing the contact so the user
|
||||||
|
//! can practise the conversation,
|
||||||
|
//! - which tools each mode is offered.
|
||||||
//!
|
//!
|
||||||
//! The network side (`SmsApiClient::fetch_thread_window`) and the tools
|
//! The network side (`SmsApiClient::fetch_thread_window`) and the tools
|
||||||
//! (`get_thread_messages`, `look_at_message_image`, `get_photos_near_time`,
|
//! (`get_thread_messages`, `look_at_message_image`, `get_photos_near_time`,
|
||||||
@@ -16,8 +19,17 @@
|
|||||||
use chrono::{DateTime, TimeZone};
|
use chrono::{DateTime, TimeZone};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
use crate::ai::ollama::Tool;
|
||||||
use crate::ai::sms_client::{ThreadMessage, ThreadWindow};
|
use crate::ai::sms_client::{ThreadMessage, ThreadWindow};
|
||||||
use crate::database::SmsAnchor;
|
use crate::database::{AnchorMode, SmsAnchor};
|
||||||
|
|
||||||
|
/// Everything a simulated contact may call: the thread they were part of
|
||||||
|
/// and the photos sent in it. Nothing from the user's own life — library,
|
||||||
|
/// memories, calendar, location, other threads — which the real contact
|
||||||
|
/// would never see, and no knowledge writes: facts recorded while the model
|
||||||
|
/// is improvising someone's side of a conversation would be fiction filed
|
||||||
|
/// as memory.
|
||||||
|
const PRACTICE_TOOLS: &[&str] = &["get_thread_messages", "look_at_message_image"];
|
||||||
|
|
||||||
/// Messages of history before the anchor captured into the system prompt.
|
/// Messages of history before the anchor captured into the system prompt.
|
||||||
/// Enough to pick up tone and the running topic; the model can page
|
/// Enough to pick up tone and the running topic; the model can page
|
||||||
@@ -110,22 +122,32 @@ pub fn anchor_preview(context: &str) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The persona's system prompt with the anchored-thread section appended.
|
/// The tool catalog narrowed to what `mode` may use. `About` keeps the full
|
||||||
///
|
/// persona catalog; `AsContact` keeps only [`PRACTICE_TOOLS`].
|
||||||
/// Re-composed every turn from the persona's current prompt and the stored
|
pub fn tools_for_mode(tools: Vec<Tool>, mode: AnchorMode) -> Vec<Tool> {
|
||||||
/// anchor snapshot, so editing a persona still reaches conversations already
|
match mode {
|
||||||
/// in flight (as it does for open chats) while the thread snapshot itself
|
AnchorMode::About => tools,
|
||||||
/// stays byte-stable.
|
AnchorMode::AsContact => tools
|
||||||
pub fn anchored_system_prompt(
|
.into_iter()
|
||||||
persona_prompt: &str,
|
.filter(|t| PRACTICE_TOOLS.contains(&t.function.name.as_str()))
|
||||||
anchor: &SmsAnchor,
|
.collect(),
|
||||||
user_name: &str,
|
}
|
||||||
tz: Tz,
|
}
|
||||||
) -> String {
|
|
||||||
|
/// The values both prompts interpolate.
|
||||||
|
struct PromptParts {
|
||||||
|
contact: String,
|
||||||
|
when: String,
|
||||||
|
thread: String,
|
||||||
|
cid: i64,
|
||||||
|
mid: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_parts(anchor: &SmsAnchor, tz: Tz) -> PromptParts {
|
||||||
let contact = if anchor.contact_name.trim().is_empty() {
|
let contact = if anchor.contact_name.trim().is_empty() {
|
||||||
"someone"
|
"someone".to_string()
|
||||||
} else {
|
} else {
|
||||||
anchor.contact_name.as_str()
|
anchor.contact_name.clone()
|
||||||
};
|
};
|
||||||
let when = DateTime::from_timestamp(anchor.timestamp, 0)
|
let when = DateTime::from_timestamp(anchor.timestamp, 0)
|
||||||
.map(|dt| {
|
.map(|dt| {
|
||||||
@@ -139,9 +161,44 @@ pub fn anchored_system_prompt(
|
|||||||
} else {
|
} else {
|
||||||
anchor.context.clone()
|
anchor.context.clone()
|
||||||
};
|
};
|
||||||
let cid = anchor.contact_id;
|
PromptParts {
|
||||||
let mid = anchor.message_id;
|
contact,
|
||||||
|
when,
|
||||||
|
thread,
|
||||||
|
cid: anchor.contact_id,
|
||||||
|
mid: anchor.message_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The system prompt for an anchored conversation.
|
||||||
|
///
|
||||||
|
/// Re-composed every turn from the persona's current prompt and the stored
|
||||||
|
/// anchor snapshot, so editing a persona still reaches conversations already
|
||||||
|
/// in flight (as it does for open chats) while the thread snapshot itself
|
||||||
|
/// stays byte-stable. Practice mode doesn't use the persona prompt at all:
|
||||||
|
/// the model is the contact, and a persona's voice would bleed into theirs.
|
||||||
|
pub fn anchored_system_prompt(
|
||||||
|
persona_prompt: &str,
|
||||||
|
anchor: &SmsAnchor,
|
||||||
|
user_name: &str,
|
||||||
|
tz: Tz,
|
||||||
|
) -> String {
|
||||||
|
let parts = prompt_parts(anchor, tz);
|
||||||
|
match anchor.mode {
|
||||||
|
AnchorMode::About => about_prompt(persona_prompt, &parts, user_name),
|
||||||
|
AnchorMode::AsContact => practice_prompt(&parts, user_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persona talking with the user about the thread.
|
||||||
|
fn about_prompt(persona_prompt: &str, parts: &PromptParts, user_name: &str) -> String {
|
||||||
|
let PromptParts {
|
||||||
|
contact,
|
||||||
|
when,
|
||||||
|
thread,
|
||||||
|
cid,
|
||||||
|
mid,
|
||||||
|
} = parts;
|
||||||
format!(
|
format!(
|
||||||
"{persona_prompt}
|
"{persona_prompt}
|
||||||
|
|
||||||
@@ -184,6 +241,35 @@ Thread with {contact}:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The model plays the contact so the user can rehearse the conversation.
|
||||||
|
fn practice_prompt(parts: &PromptParts, user_name: &str) -> String {
|
||||||
|
let PromptParts {
|
||||||
|
contact,
|
||||||
|
when,
|
||||||
|
thread,
|
||||||
|
cid,
|
||||||
|
mid,
|
||||||
|
} = parts;
|
||||||
|
format!(
|
||||||
|
"You are playing {contact} in a text conversation with {user_name}, so {user_name} can practise picking the conversation back up. This is a private rehearsal for {user_name}; the real {contact} is not part of it.
|
||||||
|
|
||||||
|
Below is the real thread up to the message marked ▶ (sent {when}). Carry on from there as {contact}: {user_name}'s messages to you are their next texts, and every reply you write is {contact}'s next text.
|
||||||
|
|
||||||
|
How to play it:
|
||||||
|
- Sound like {contact} does in the thread — their message length, slang, emoji, punctuation, humour, how fast they get to the point. Mirror them, not a generic texter.
|
||||||
|
- Write only the text itself. No narration, no stage directions, no 'As {contact}, I\'d say…'. Separate several short texts with a blank line.
|
||||||
|
- Stay true to what {contact} knows and feels in the thread. Don't invent big facts about their life — jobs, relationships, events — that the thread doesn't support; if asked about something they couldn't know, answer vaguely or deflect the way they plausibly would.
|
||||||
|
- React like a real person: it's fine to be busy, short, unconvinced, or to ask something back. Don't be more agreeable than {contact} actually is in the thread.
|
||||||
|
- It is shortly after the ▶ message unless {user_name} says time has passed.
|
||||||
|
- If {user_name} steps out of the rehearsal ('pause', 'out of character', 'how did that sound?'), drop the role and answer briefly as a coach: honest, specific feedback on how their texts are landing. Go back to being {contact} when they carry on.
|
||||||
|
|
||||||
|
If you need to remember an earlier part of the thread, use get_thread_messages {{contact_id: {cid}, message_id: {mid}, direction: \"before\"}}; to see a photo sent in it (`image #N` on a line), use look_at_message_image {{contact_id: {cid}, message_id, media_index}}. Never mention either.
|
||||||
|
|
||||||
|
Thread between {user_name} and {contact}:
|
||||||
|
{thread}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -278,6 +364,7 @@ mod tests {
|
|||||||
message_id: 812,
|
message_id: 812,
|
||||||
timestamp: 1_700_000_000,
|
timestamp: 1_700_000_000,
|
||||||
context: "[#812 · …] Sam: see you there".to_string(),
|
context: "[#812 · …] Sam: see you there".to_string(),
|
||||||
|
mode: AnchorMode::About,
|
||||||
};
|
};
|
||||||
let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC);
|
let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC);
|
||||||
assert!(prompt.starts_with("You are Journal."));
|
assert!(prompt.starts_with("You are Journal."));
|
||||||
@@ -295,9 +382,66 @@ mod tests {
|
|||||||
message_id: 812,
|
message_id: 812,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
context: String::new(),
|
context: String::new(),
|
||||||
|
mode: AnchorMode::About,
|
||||||
};
|
};
|
||||||
let prompt = anchored_system_prompt("P", &anchor, "Cam", Tz::UTC);
|
let prompt = anchored_system_prompt("P", &anchor, "Cam", Tz::UTC);
|
||||||
assert!(prompt.contains("could not be loaded"));
|
assert!(prompt.contains("could not be loaded"));
|
||||||
assert!(prompt.contains("conversation with someone"));
|
assert!(prompt.contains("conversation with someone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn practice_prompt_plays_the_contact_and_drops_the_persona() {
|
||||||
|
let anchor = SmsAnchor {
|
||||||
|
contact_id: 42,
|
||||||
|
contact_name: "Sam".to_string(),
|
||||||
|
message_id: 812,
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
context: "▶ [#812 · …] Sam: see you there".to_string(),
|
||||||
|
mode: AnchorMode::AsContact,
|
||||||
|
};
|
||||||
|
let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC);
|
||||||
|
assert!(prompt.starts_with("You are playing Sam"));
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("You are Journal."),
|
||||||
|
"a persona voice would bleed into the contact's"
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("contact_id: 42"));
|
||||||
|
assert!(prompt.contains("out of character"));
|
||||||
|
assert!(prompt.ends_with("Sam: see you there"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(name: &str) -> Tool {
|
||||||
|
Tool::function(name, "", serde_json::json!({"type": "object"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn practice_mode_keeps_only_the_thread_tools() {
|
||||||
|
let all = || {
|
||||||
|
[
|
||||||
|
"get_thread_messages",
|
||||||
|
"look_at_message_image",
|
||||||
|
"get_photos_near_time",
|
||||||
|
"look_at_photo",
|
||||||
|
"recall_entities",
|
||||||
|
"store_fact",
|
||||||
|
"search_messages",
|
||||||
|
"get_calendar_events",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(tool)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let names = |tools: Vec<Tool>| -> Vec<String> {
|
||||||
|
tools.into_iter().map(|t| t.function.name).collect()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
names(tools_for_mode(all(), AnchorMode::AsContact)),
|
||||||
|
vec!["get_thread_messages", "look_at_message_image"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(tools_for_mode(all(), AnchorMode::About)).len(),
|
||||||
|
8,
|
||||||
|
"talking about the thread keeps the full catalog, memory tools included"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -64,7 +64,9 @@ pub use knowledge_dao::{
|
|||||||
KnowledgeDao, PersonaFilter, RecentActivity, SqliteKnowledgeDao,
|
KnowledgeDao, PersonaFilter, RecentActivity, SqliteKnowledgeDao,
|
||||||
};
|
};
|
||||||
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
|
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
|
||||||
pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao};
|
pub use persona_dao::{
|
||||||
|
AnchorMode, ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao,
|
||||||
|
};
|
||||||
pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao};
|
pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao};
|
||||||
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
||||||
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
||||||
|
|||||||
@@ -47,6 +47,37 @@ pub struct SmsAnchor {
|
|||||||
/// Unix seconds of the anchor message.
|
/// Unix seconds of the anchor message.
|
||||||
pub timestamp: i64,
|
pub timestamp: i64,
|
||||||
pub context: String,
|
pub context: String,
|
||||||
|
pub mode: AnchorMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an anchored conversation relates to its SMS thread.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AnchorMode {
|
||||||
|
/// A persona talks with the user about the thread.
|
||||||
|
#[default]
|
||||||
|
About,
|
||||||
|
/// The model plays the contact so the user can practise the
|
||||||
|
/// conversation.
|
||||||
|
AsContact,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnchorMode {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AnchorMode::About => "about",
|
||||||
|
AnchorMode::AsContact => "as_contact",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stored value → mode. NULL and anything unrecognised read as `About`,
|
||||||
|
/// the behaviour rows had before the column existed.
|
||||||
|
pub fn from_column(value: Option<&str>) -> Self {
|
||||||
|
match value {
|
||||||
|
Some("as_contact") => AnchorMode::AsContact,
|
||||||
|
_ => AnchorMode::About,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The column tuple every persona-chat read selects. Shared by the
|
/// The column tuple every persona-chat read selects. Shared by the
|
||||||
@@ -65,6 +96,7 @@ type PersonaChatColumns = (
|
|||||||
Option<i64>,
|
Option<i64>,
|
||||||
Option<i64>,
|
Option<i64>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
);
|
);
|
||||||
|
|
||||||
macro_rules! persona_chat_select {
|
macro_rules! persona_chat_select {
|
||||||
@@ -82,6 +114,7 @@ macro_rules! persona_chat_select {
|
|||||||
anchor_message_id,
|
anchor_message_id,
|
||||||
anchor_ts,
|
anchor_ts,
|
||||||
anchor_context,
|
anchor_context,
|
||||||
|
anchor_mode,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -101,6 +134,7 @@ fn persona_chat_row(
|
|||||||
anchor_message_id,
|
anchor_message_id,
|
||||||
anchor_ts,
|
anchor_ts,
|
||||||
anchor_context,
|
anchor_context,
|
||||||
|
anchor_mode,
|
||||||
): PersonaChatColumns,
|
): PersonaChatColumns,
|
||||||
) -> PersonaChatRow {
|
) -> PersonaChatRow {
|
||||||
// An anchor needs at least the thread and the message; the rest has a
|
// An anchor needs at least the thread and the message; the rest has a
|
||||||
@@ -112,6 +146,7 @@ fn persona_chat_row(
|
|||||||
message_id,
|
message_id,
|
||||||
timestamp: anchor_ts.unwrap_or(0),
|
timestamp: anchor_ts.unwrap_or(0),
|
||||||
context: anchor_context.unwrap_or_default(),
|
context: anchor_context.unwrap_or_default(),
|
||||||
|
mode: AnchorMode::from_column(anchor_mode.as_deref()),
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -545,6 +580,7 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
anchor_message_id.eq(Some(anchor.message_id)),
|
anchor_message_id.eq(Some(anchor.message_id)),
|
||||||
anchor_ts.eq(Some(anchor.timestamp)),
|
anchor_ts.eq(Some(anchor.timestamp)),
|
||||||
anchor_context.eq(Some(&anchor.context)),
|
anchor_context.eq(Some(&anchor.context)),
|
||||||
|
anchor_mode.eq(Some(anchor.mode.as_str())),
|
||||||
))
|
))
|
||||||
.execute(conn.deref_mut())
|
.execute(conn.deref_mut())
|
||||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||||
@@ -876,6 +912,7 @@ mod tests {
|
|||||||
message_id: 9001,
|
message_id: 9001,
|
||||||
timestamp: 1_700_000_000,
|
timestamp: 1_700_000_000,
|
||||||
context: "[2023-11-14 22:13] Sam: see you there".to_string(),
|
context: "[2023-11-14 22:13] Sam: see you there".to_string(),
|
||||||
|
mode: AnchorMode::AsContact,
|
||||||
};
|
};
|
||||||
let anchored = dao
|
let anchored = dao
|
||||||
.create_anchored_persona_chat(uid, "journal", 100, &anchor)
|
.create_anchored_persona_chat(uid, "journal", 100, &anchor)
|
||||||
@@ -895,6 +932,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchor_mode_reads_null_and_unknown_as_about() {
|
||||||
|
assert_eq!(AnchorMode::from_column(None), AnchorMode::About);
|
||||||
|
assert_eq!(AnchorMode::from_column(Some("weird")), AnchorMode::About);
|
||||||
|
assert_eq!(AnchorMode::from_column(Some("about")), AnchorMode::About);
|
||||||
|
assert_eq!(
|
||||||
|
AnchorMode::from_column(Some(AnchorMode::AsContact.as_str())),
|
||||||
|
AnchorMode::AsContact
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
|
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
|
||||||
let (mut dao, uid) = dao_with_user("p8");
|
let (mut dao, uid) = dao_with_user("p8");
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ diesel::table! {
|
|||||||
anchor_message_id -> Nullable<BigInt>,
|
anchor_message_id -> Nullable<BigInt>,
|
||||||
anchor_ts -> Nullable<BigInt>,
|
anchor_ts -> Nullable<BigInt>,
|
||||||
anchor_context -> Nullable<Text>,
|
anchor_context -> Nullable<Text>,
|
||||||
|
anchor_mode -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user