Persona chats from SMS threads (talk about / practice), date_info, photos never sent to OpenRouter #186
No files matched your search
@@ -900,9 +900,18 @@ endpoint.
|
|||||||
a deploy-time decision, not a request-time one).
|
a deploy-time decision, not a request-time one).
|
||||||
- `hybrid → hybrid` allowed.
|
- `hybrid → hybrid` allowed.
|
||||||
- `hybrid → local` allowed (the inlined description replays as text).
|
- `hybrid → local` allowed (the inlined description replays as text).
|
||||||
- `local → hybrid` rejected — the stored transcript has raw images in the
|
- `local → hybrid` allowed. The stored transcript carries the raw photo in
|
||||||
first user message and OpenRouter providers don't accept that shape
|
its first user message, so each hybrid turn swaps it for a local vision
|
||||||
consistently. Regenerate the insight in hybrid mode instead.
|
description (cached per photo) in the copy sent to OpenRouter, then puts
|
||||||
|
the photo back before saving (`InsightChatService::inline_images_as_text`).
|
||||||
|
If the local model can't describe it, the turn fails. A stored model id is
|
||||||
|
only reused on the same backend (`continuation_model`).
|
||||||
|
|
||||||
|
**Photos never go to OpenRouter.** `OpenRouterClient` refuses any request
|
||||||
|
carrying image data (`IMAGES_STAY_LOCAL`) and its `describe_image` always
|
||||||
|
errors — the client is the choke point, so a caller bug fails the turn
|
||||||
|
instead of uploading a photo. Image work in hybrid mode goes through
|
||||||
|
`ResolvedBackend::local()`.
|
||||||
|
|
||||||
**Insight Chat Continuation:**
|
**Insight Chat Continuation:**
|
||||||
|
|
||||||
@@ -933,9 +942,9 @@ clients whether chat is available for a given insight.
|
|||||||
|
|
||||||
Backend routing rules (matches agentic-insight generation):
|
Backend routing rules (matches agentic-insight generation):
|
||||||
- Stored `backend` on the insight row is authoritative by default.
|
- Stored `backend` on the insight row is authoritative by default.
|
||||||
- `request.backend` may override per-turn. `local -> hybrid` is rejected in
|
- `request.backend` may override per-turn, in either direction (see the
|
||||||
v1 (would require on-the-fly visual-description rewrite); `hybrid -> local`
|
cross-replay matrix above); `hybrid -> local` replays verbatim since the
|
||||||
replays verbatim since the description is already inlined as text.
|
description is already inlined as text.
|
||||||
- `request.model` overrides the chat model (an Ollama id in local mode, an
|
- `request.model` overrides the chat model (an Ollama id in local mode, an
|
||||||
OpenRouter id in hybrid mode).
|
OpenRouter id in hybrid mode).
|
||||||
|
|
||||||
|
|||||||
+277
-37
@@ -119,8 +119,16 @@ pub struct InsightChatService {
|
|||||||
generator: Arc<InsightGenerator>,
|
generator: Arc<InsightGenerator>,
|
||||||
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
insight_dao: Arc<Mutex<Box<dyn InsightDao>>>,
|
||||||
chat_locks: ChatLockMap,
|
chat_locks: ChatLockMap,
|
||||||
|
/// Local vision descriptions of transcript images, keyed by a hash of
|
||||||
|
/// the image data, so a local-generated chat continued on a text-only
|
||||||
|
/// (hybrid) model describes its photo once rather than every turn.
|
||||||
|
image_descriptions: Arc<Mutex<HashMap<u64, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap on cached image descriptions; the cache is cleared when it fills.
|
||||||
|
/// One entry per photo chatted about since the server started.
|
||||||
|
const IMAGE_DESCRIPTION_CACHE_MAX: usize = 256;
|
||||||
|
|
||||||
impl InsightChatService {
|
impl InsightChatService {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
generator: Arc<InsightGenerator>,
|
generator: Arc<InsightGenerator>,
|
||||||
@@ -131,9 +139,62 @@ impl InsightChatService {
|
|||||||
generator,
|
generator,
|
||||||
insight_dao,
|
insight_dao,
|
||||||
chat_locks,
|
chat_locks,
|
||||||
|
image_descriptions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prepare a replayed transcript for a backend that can't take images.
|
||||||
|
///
|
||||||
|
/// A chat generated on the local backend carries the photo itself in its
|
||||||
|
/// first user message. Continuing it on hybrid would send that photo to
|
||||||
|
/// OpenRouter — which the OpenRouter client refuses — so each image is
|
||||||
|
/// described by the local vision model and the text takes its place in
|
||||||
|
/// the copy sent to the model. The returned stash puts the images back
|
||||||
|
/// before the transcript is saved, so the stored chat keeps its photo and
|
||||||
|
/// a later local turn still sees it.
|
||||||
|
///
|
||||||
|
/// A no-op for backends that take images inline. If the local model
|
||||||
|
/// can't describe an image the turn fails: going ahead without the
|
||||||
|
/// photo would quietly change what the chat is about.
|
||||||
|
async fn inline_images_as_text(
|
||||||
|
&self,
|
||||||
|
backend: &ResolvedBackend,
|
||||||
|
messages: &mut [ChatMessage],
|
||||||
|
) -> Result<ImageStash> {
|
||||||
|
if backend.images_inline {
|
||||||
|
return Ok(ImageStash::default());
|
||||||
|
}
|
||||||
|
let mut descriptions: HashMap<String, String> = HashMap::new();
|
||||||
|
for image in transcript_images(messages) {
|
||||||
|
let key = image_cache_key(&image);
|
||||||
|
let cached = self
|
||||||
|
.image_descriptions
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.get(&key).cloned());
|
||||||
|
let description = match cached {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
let d = backend.local().describe_image(&image).await.map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"couldn't describe the photo with the local vision model, so this \
|
||||||
|
chat can't continue on a text-only model: {e}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Ok(mut cache) = self.image_descriptions.lock() {
|
||||||
|
if cache.len() >= IMAGE_DESCRIPTION_CACHE_MAX {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
cache.insert(key, d.clone());
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
};
|
||||||
|
descriptions.insert(image, description);
|
||||||
|
}
|
||||||
|
Ok(apply_image_descriptions(messages, &descriptions))
|
||||||
|
}
|
||||||
|
|
||||||
/// Accessor for the insight DAO (used by async job completion).
|
/// Accessor for the insight DAO (used by async job completion).
|
||||||
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
|
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
|
||||||
&self.insight_dao
|
&self.insight_dao
|
||||||
@@ -285,11 +346,12 @@ impl InsightChatService {
|
|||||||
|
|
||||||
let stored_model = insight.model_version.clone();
|
let stored_model = insight.model_version.clone();
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
model: req
|
model: continuation_model(
|
||||||
.model
|
req.model.clone(),
|
||||||
.clone()
|
&stored_model,
|
||||||
.or_else(|| Some(stored_model.clone()))
|
&stored_backend,
|
||||||
.filter(|m| !m.is_empty()),
|
kind.as_str(),
|
||||||
|
),
|
||||||
num_ctx: req.num_ctx,
|
num_ctx: req.num_ctx,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
top_p: req.top_p,
|
top_p: req.top_p,
|
||||||
@@ -298,6 +360,9 @@ impl InsightChatService {
|
|||||||
enable_thinking: req.enable_thinking,
|
enable_thinking: req.enable_thinking,
|
||||||
};
|
};
|
||||||
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
||||||
|
// Text-only backend (hybrid) on a transcript holding the photo:
|
||||||
|
// swap it for a local description. Restored before saving.
|
||||||
|
let image_stash = self.inline_images_as_text(&backend, &mut messages).await?;
|
||||||
let model_used = backend.model().to_string();
|
let model_used = backend.model().to_string();
|
||||||
span.set_attribute("model", model_used.clone());
|
span.set_attribute("model", model_used.clone());
|
||||||
|
|
||||||
@@ -449,6 +514,8 @@ impl InsightChatService {
|
|||||||
// Drop the per-turn iteration-budget note from the system message
|
// Drop the per-turn iteration-budget note from the system message
|
||||||
// before we persist so it doesn't snowball on each subsequent turn.
|
// before we persist so it doesn't snowball on each subsequent turn.
|
||||||
restore_system_content(&mut messages, original_system_content);
|
restore_system_content(&mut messages, original_system_content);
|
||||||
|
// Put the photo back so the stored transcript keeps it.
|
||||||
|
image_stash.restore(&mut messages);
|
||||||
|
|
||||||
// Append mode: undo the per-turn system-prompt override so the
|
// Append mode: undo the per-turn system-prompt override so the
|
||||||
// stored transcript keeps the original baked persona. Amend mode:
|
// stored transcript keeps the original baked persona. Amend mode:
|
||||||
@@ -979,11 +1046,12 @@ impl InsightChatService {
|
|||||||
|
|
||||||
let stored_model = insight.model_version.clone();
|
let stored_model = insight.model_version.clone();
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
model: req
|
model: continuation_model(
|
||||||
.model
|
req.model.clone(),
|
||||||
.clone()
|
&stored_model,
|
||||||
.or_else(|| Some(stored_model.clone()))
|
&stored_backend,
|
||||||
.filter(|m| !m.is_empty()),
|
kind.as_str(),
|
||||||
|
),
|
||||||
num_ctx: req.num_ctx,
|
num_ctx: req.num_ctx,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
top_p: req.top_p,
|
top_p: req.top_p,
|
||||||
@@ -992,6 +1060,9 @@ impl InsightChatService {
|
|||||||
enable_thinking: req.enable_thinking,
|
enable_thinking: req.enable_thinking,
|
||||||
};
|
};
|
||||||
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
||||||
|
// Text-only backend (hybrid) on a transcript holding the photo:
|
||||||
|
// swap it for a local description. Restored before saving.
|
||||||
|
let image_stash = self.inline_images_as_text(&backend, &mut messages).await?;
|
||||||
let model_used = backend.model().to_string();
|
let model_used = backend.model().to_string();
|
||||||
// llm.* attributes on the turn-level span (issue #130): clients can't
|
// llm.* attributes on the turn-level span (issue #130): clients can't
|
||||||
// know local-vs-hybrid, but the turn knows its resolved backend.
|
// know local-vs-hybrid, but the turn knows its resolved backend.
|
||||||
@@ -1064,6 +1135,8 @@ impl InsightChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restore_system_content(&mut messages, original_system_content);
|
restore_system_content(&mut messages, original_system_content);
|
||||||
|
// Put the photo back so the stored transcript keeps it.
|
||||||
|
image_stash.restore(&mut messages);
|
||||||
|
|
||||||
if !req.amend {
|
if !req.amend {
|
||||||
restore_system_prompt_override(&mut messages, override_stash);
|
restore_system_prompt_override(&mut messages, override_stash);
|
||||||
@@ -1455,11 +1528,12 @@ impl InsightChatService {
|
|||||||
|
|
||||||
let stored_model = insight.model_version.clone();
|
let stored_model = insight.model_version.clone();
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
model: req
|
model: continuation_model(
|
||||||
.model
|
req.model.clone(),
|
||||||
.clone()
|
&stored_model,
|
||||||
.or_else(|| Some(stored_model.clone()))
|
&stored_backend,
|
||||||
.filter(|m| !m.is_empty()),
|
kind.as_str(),
|
||||||
|
),
|
||||||
num_ctx: req.num_ctx,
|
num_ctx: req.num_ctx,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
top_p: req.top_p,
|
top_p: req.top_p,
|
||||||
@@ -1468,6 +1542,9 @@ impl InsightChatService {
|
|||||||
enable_thinking: req.enable_thinking,
|
enable_thinking: req.enable_thinking,
|
||||||
};
|
};
|
||||||
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
||||||
|
// Text-only backend (hybrid) on a transcript holding the photo:
|
||||||
|
// swap it for a local description. Restored before saving.
|
||||||
|
let image_stash = self.inline_images_as_text(&backend, &mut messages).await?;
|
||||||
let model_used = backend.model().to_string();
|
let model_used = backend.model().to_string();
|
||||||
|
|
||||||
// Tool set — images_inline mode + first user turn carries an image →
|
// Tool set — images_inline mode + first user turn carries an image →
|
||||||
@@ -1533,6 +1610,8 @@ impl InsightChatService {
|
|||||||
// Drop the per-turn iteration-budget note before persisting so it
|
// Drop the per-turn iteration-budget note before persisting so it
|
||||||
// doesn't snowball on subsequent turns.
|
// doesn't snowball on subsequent turns.
|
||||||
restore_system_content(&mut messages, original_system_content);
|
restore_system_content(&mut messages, original_system_content);
|
||||||
|
// Put the photo back so the stored transcript keeps it.
|
||||||
|
image_stash.restore(&mut messages);
|
||||||
|
|
||||||
// Append mode: undo the per-turn system-prompt override.
|
// Append mode: undo the per-turn system-prompt override.
|
||||||
// Amend mode: keep it — it becomes the new row's system message.
|
// Amend mode: keep it — it becomes the new row's system message.
|
||||||
@@ -2071,34 +2150,122 @@ fn resolve_date_taken_for_context(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a stored→effective backend transition for a chat continuation.
|
/// Validate the backend a chat continuation is switching to.
|
||||||
/// Continuation runs against a transcript that was generated with a specific
|
///
|
||||||
/// backend; the only blocked transition is `local → hybrid`, because the
|
/// Every transition is allowed now. `local → hybrid` used to be refused
|
||||||
/// stored transcript has images embedded in the first user message and the
|
/// because a local transcript carries the raw photo; the turn now replaces
|
||||||
/// hybrid path (OpenRouter chat with describe-then-inline) can't replay
|
/// it with a local vision description before anything reaches OpenRouter
|
||||||
/// raw image bytes through OpenRouter consistently across providers.
|
/// (`InsightChatService::inline_images_as_text`), and the OpenRouter client
|
||||||
/// `hybrid → local` is allowed (the inlined description replays verbatim
|
/// refuses image data outright as a backstop. `hybrid → local` replays the
|
||||||
/// as text).
|
/// inlined description as text. `stored` is kept for the call sites'
|
||||||
|
/// symmetry and future rules.
|
||||||
///
|
///
|
||||||
/// Whether "local" routes through Ollama or llama-swap is decided at
|
/// Whether "local" routes through Ollama or llama-swap is decided at
|
||||||
/// startup by `LLM_BACKEND`; both share the same transcript shape from
|
/// startup by `LLM_BACKEND`; both share the same transcript shape from
|
||||||
/// the chat-replay perspective.
|
/// the chat-replay perspective.
|
||||||
fn validate_cross_replay(stored: &str, effective: &str) -> Result<()> {
|
fn validate_cross_replay(_stored: &str, effective: &str) -> Result<()> {
|
||||||
if !matches!(effective, "local" | "hybrid") {
|
if !matches!(effective, "local" | "hybrid") {
|
||||||
bail!(
|
bail!(
|
||||||
"unknown backend '{}'; expected 'local' or 'hybrid'",
|
"unknown backend '{}'; expected 'local' or 'hybrid'",
|
||||||
effective
|
effective
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if stored == "local" && effective == "hybrid" {
|
|
||||||
bail!(
|
|
||||||
"switching from local to hybrid mid-chat isn't supported; \
|
|
||||||
regenerate the insight in hybrid mode if you want OpenRouter chat"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The model to continue a chat with when the request names none: the one
|
||||||
|
/// the insight was generated with, but only on the same backend. After a
|
||||||
|
/// switch, the stored id belongs to the other backend (a llama-swap slot is
|
||||||
|
/// not an OpenRouter model), so the new backend's default is used instead.
|
||||||
|
fn continuation_model(
|
||||||
|
requested: Option<String>,
|
||||||
|
stored_model: &str,
|
||||||
|
stored_backend: &str,
|
||||||
|
effective_backend: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
requested
|
||||||
|
.or_else(|| (stored_backend == effective_backend).then(|| stored_model.to_string()))
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every distinct image in a transcript, in order of first appearance.
|
||||||
|
fn transcript_images(messages: &[ChatMessage]) -> Vec<String> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|m| m.images.iter().flatten())
|
||||||
|
.filter(|img| seen.insert((*img).clone()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_cache_key(image: &str) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
image.hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `apply_image_descriptions` took out of a transcript, so it can be
|
||||||
|
/// put back before the transcript is saved.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ImageStash {
|
||||||
|
entries: Vec<StashedImages>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct StashedImages {
|
||||||
|
index: usize,
|
||||||
|
original_content: String,
|
||||||
|
rewritten_content: String,
|
||||||
|
images: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageStash {
|
||||||
|
/// Put the original content and images back. Each entry is matched by
|
||||||
|
/// index *and* by still carrying the rewritten text, so a turn that
|
||||||
|
/// reshaped the transcript can't get the wrong message restored.
|
||||||
|
fn restore(self, messages: &mut [ChatMessage]) {
|
||||||
|
for e in self.entries {
|
||||||
|
if let Some(m) = messages.get_mut(e.index)
|
||||||
|
&& m.content == e.rewritten_content
|
||||||
|
{
|
||||||
|
m.content = e.original_content;
|
||||||
|
m.images = e.images;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace each image in `messages` with its description, appended to the
|
||||||
|
/// message text. Returns what was taken out, for `ImageStash::restore`.
|
||||||
|
fn apply_image_descriptions(
|
||||||
|
messages: &mut [ChatMessage],
|
||||||
|
descriptions: &HashMap<String, String>,
|
||||||
|
) -> ImageStash {
|
||||||
|
let mut stash = ImageStash::default();
|
||||||
|
for (idx, m) in messages.iter_mut().enumerate() {
|
||||||
|
let Some(images) = m.images.as_ref().filter(|i| !i.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut content = m.content.clone();
|
||||||
|
for img in images {
|
||||||
|
if let Some(desc) = descriptions.get(img) {
|
||||||
|
content.push_str(&format!(
|
||||||
|
"\n\nVisual description of the photo (from the local vision model):\n{desc}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stash.entries.push(StashedImages {
|
||||||
|
index: idx,
|
||||||
|
original_content: std::mem::replace(&mut m.content, content.clone()),
|
||||||
|
rewritten_content: content,
|
||||||
|
images: m.images.take(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
stash
|
||||||
|
}
|
||||||
|
|
||||||
/// Pick the backend label for bootstrap. Bootstrap has no stored insight
|
/// Pick the backend label for bootstrap. Bootstrap has no stored insight
|
||||||
/// to defer to (that's continuation's behaviour), so the default is
|
/// to defer to (that's continuation's behaviour), so the default is
|
||||||
/// `"local"`. Returns an error if the supplied label is non-empty but
|
/// `"local"`. Returns an error if the supplied label is non-empty but
|
||||||
@@ -3672,17 +3839,90 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cross_replay_rejects_local_to_hybrid() {
|
fn cross_replay_allows_every_known_transition() {
|
||||||
let err = validate_cross_replay("local", "hybrid").unwrap_err();
|
|
||||||
assert!(format!("{}", err).contains("local to hybrid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cross_replay_allows_supported_transitions() {
|
|
||||||
assert!(validate_cross_replay("local", "local").is_ok());
|
assert!(validate_cross_replay("local", "local").is_ok());
|
||||||
assert!(validate_cross_replay("hybrid", "hybrid").is_ok());
|
assert!(validate_cross_replay("hybrid", "hybrid").is_ok());
|
||||||
// Hybrid → local replays the inlined description as plain text.
|
// Hybrid → local replays the inlined description as plain text.
|
||||||
assert!(validate_cross_replay("hybrid", "local").is_ok());
|
assert!(validate_cross_replay("hybrid", "local").is_ok());
|
||||||
|
// Local → hybrid swaps the photo for a local description first.
|
||||||
|
assert!(validate_cross_replay("local", "hybrid").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuation_model_drops_the_stored_model_across_a_switch() {
|
||||||
|
// Same backend: keep the model the insight was generated with.
|
||||||
|
assert_eq!(
|
||||||
|
continuation_model(None, "gemma4-31", "local", "local").as_deref(),
|
||||||
|
Some("gemma4-31")
|
||||||
|
);
|
||||||
|
// Switched: a llama-swap slot name is not an OpenRouter model id.
|
||||||
|
assert_eq!(
|
||||||
|
continuation_model(None, "gemma4-31", "local", "hybrid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// An explicit request always wins.
|
||||||
|
assert_eq!(
|
||||||
|
continuation_model(Some("x/y".into()), "gemma4-31", "local", "hybrid").as_deref(),
|
||||||
|
Some("x/y")
|
||||||
|
);
|
||||||
|
assert_eq!(continuation_model(None, "", "local", "local"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_image(text: &str, img: &str) -> ChatMessage {
|
||||||
|
let mut m = ChatMessage::user(text.to_string());
|
||||||
|
m.images = Some(vec![img.to_string()]);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_images_lists_each_image_once() {
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::system("sys"),
|
||||||
|
with_image("describe", "IMG_A"),
|
||||||
|
ChatMessage::user("more"),
|
||||||
|
with_image("again", "IMG_A"),
|
||||||
|
];
|
||||||
|
assert_eq!(transcript_images(&msgs), vec!["IMG_A".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn photo_is_swapped_for_text_then_restored_exactly() {
|
||||||
|
let original = vec![
|
||||||
|
ChatMessage::system("sys"),
|
||||||
|
with_image("What's in this photo?", "IMG_A"),
|
||||||
|
assistant_text("A beach."),
|
||||||
|
ChatMessage::user("who's there?"),
|
||||||
|
];
|
||||||
|
let mut msgs = original.clone();
|
||||||
|
let descriptions =
|
||||||
|
HashMap::from([("IMG_A".to_string(), "Two people on sand.".to_string())]);
|
||||||
|
|
||||||
|
let stash = apply_image_descriptions(&mut msgs, &descriptions);
|
||||||
|
// Nothing the text-only model receives carries image data.
|
||||||
|
assert!(msgs.iter().all(|m| m.images.is_none()));
|
||||||
|
assert!(msgs[1].content.starts_with("What's in this photo?"));
|
||||||
|
assert!(msgs[1].content.contains("Two people on sand."));
|
||||||
|
|
||||||
|
// The turn appends its exchange, then the stash is restored before
|
||||||
|
// saving: the stored transcript keeps the photo, unchanged.
|
||||||
|
msgs.push(assistant_text("Two friends."));
|
||||||
|
stash.restore(&mut msgs);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&msgs[..original.len()]).unwrap(),
|
||||||
|
serde_json::to_string(&original).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(msgs[1].images, Some(vec!["IMG_A".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_leaves_a_message_the_turn_changed() {
|
||||||
|
let mut msgs = vec![with_image("q", "IMG_A")];
|
||||||
|
let descriptions = HashMap::from([("IMG_A".to_string(), "desc".to_string())]);
|
||||||
|
let stash = apply_image_descriptions(&mut msgs, &descriptions);
|
||||||
|
msgs[0].content = "something else entirely".into();
|
||||||
|
stash.restore(&mut msgs);
|
||||||
|
assert_eq!(msgs[0].content, "something else entirely");
|
||||||
|
assert!(msgs[0].images.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user