use serde::Deserialize;
use super::config::AiConfig;
use super::error::Error;
use super::http::{self, ChatMessage, ContentPart, ImageUrlPart};
#[derive(Debug, Clone, Copy, Default)]
pub struct ImageContext<'a> {
pub preceding_text: Option<&'a str>,
pub following_text: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ImageUnderstanding {
Structured(Vec<ContentBlock>),
Description(String),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ContentBlock {
Paragraph(String),
Table(TableBlock),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct TableBlock {
pub rows: Vec<Vec<TableCellBlock>>,
pub header_rows: u8,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct TableCellBlock {
pub text: String,
pub rowspan: u8,
pub colspan: u8,
}
pub fn understand_image(
cfg: &AiConfig,
image: &[u8],
mime_type: &str,
context: ImageContext<'_>,
) -> Result<ImageUnderstanding, Error> {
let messages = vec![
ChatMessage::text(
"system",
"You are given one image. First decide: is it primarily a scan of a \
text or table document, or a general image (a photo, chart, or \
diagram)? Then respond with exactly one JSON object and nothing else.\n\n\
If it is a document scan, respond with:\n\
{\"kind\":\"structured\",\"blocks\":[\n\
{\"type\":\"paragraph\",\"text\":\"...\"},\n\
{\"type\":\"table\",\"header_rows\":1,\"rows\":[[{\"text\":\"...\",\
\"rowspan\":1,\"colspan\":1}]]}\n\
]}\n\
Preserve reading order, heading levels as plain text, and — this is the \
point of asking for JSON instead of markdown — every merged cell's true \
rowspan/colspan.\n\n\
If it is a general image, respond with:\n\
{\"kind\":\"description\",\"text\":\"...\"}\n\
Write the description using the surrounding text as context where it \
helps identify what the image shows.",
),
vision_message(image, mime_type, context),
];
let content = http::call(cfg, messages, true)?;
let dto: UnderstandingDto = serde_json::from_str(&content)
.map_err(|e| Error::MalformedResponse(format!("image understanding: {e}")))?;
Ok(dto.into())
}
fn vision_message(image: &[u8], mime_type: &str, context: ImageContext<'_>) -> ChatMessage {
let mut parts = Vec::new();
if let Some(preceding) = context.preceding_text {
parts.push(ContentPart::Text {
text: format!("Text immediately before the image:\n{preceding}"),
});
}
parts.push(ContentPart::ImageUrl {
image_url: ImageUrlPart {
url: http::data_uri(image, mime_type),
},
});
if let Some(following) = context.following_text {
parts.push(ContentPart::Text {
text: format!("Text immediately after the image:\n{following}"),
});
}
ChatMessage {
role: "user",
content: parts,
}
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum UnderstandingDto {
Structured { blocks: Vec<ContentBlockDto> },
Description { text: String },
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentBlockDto {
Paragraph {
text: String,
},
Table {
header_rows: u8,
rows: Vec<Vec<TableCellDto>>,
},
}
#[derive(Deserialize)]
struct TableCellDto {
text: String,
#[serde(default = "one")]
rowspan: u8,
#[serde(default = "one")]
colspan: u8,
}
fn one() -> u8 {
1
}
impl From<UnderstandingDto> for ImageUnderstanding {
fn from(dto: UnderstandingDto) -> Self {
match dto {
UnderstandingDto::Description { text } => ImageUnderstanding::Description(text),
UnderstandingDto::Structured { blocks } => {
ImageUnderstanding::Structured(blocks.into_iter().map(ContentBlock::from).collect())
}
}
}
}
impl From<ContentBlockDto> for ContentBlock {
fn from(dto: ContentBlockDto) -> Self {
match dto {
ContentBlockDto::Paragraph { text } => ContentBlock::Paragraph(text),
ContentBlockDto::Table { header_rows, rows } => ContentBlock::Table(TableBlock {
header_rows,
rows: rows
.into_iter()
.map(|row| row.into_iter().map(TableCellBlock::from).collect())
.collect(),
}),
}
}
}
impl From<TableCellDto> for TableCellBlock {
fn from(dto: TableCellDto) -> Self {
TableCellBlock {
text: dto.text,
rowspan: dto.rowspan,
colspan: dto.colspan,
}
}
}