Skip to main content

unparser_shared/ai/
image.rs

1//! VLM-based image understanding — a single call that judges whether an image is a
2//! text/table document scan (structured extraction) or a general image (a
3//! context-aware description), and returns the matching branch.
4//!
5//! See the crate's `ai` module docs for why this is one call rather than a
6//! classify-then-extract pair, and why the structured branch is JSON rather than a
7//! free markdown string (GFM tables cannot express a merged cell).
8
9use serde::Deserialize;
10
11use super::config::AiConfig;
12use super::error::Error;
13use super::http::{self, ChatMessage, ContentPart, ImageUrlPart};
14
15/// Text surrounding the image, for [`understand_image`]'s description branch — a
16/// caption-less photo described with "a bar chart" is far less useful than one
17/// described with the surrounding paragraph's subject folded in.
18///
19/// Both fields are optional and independent: a page boundary may have preceding
20/// text but no following text (last image on a page) or vice versa.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct ImageContext<'a> {
23    /// Text immediately before the image (same page, or the previous page's last
24    /// paragraph at a page boundary).
25    pub preceding_text: Option<&'a str>,
26    /// Text immediately after the image (same page, or the next page's first
27    /// paragraph at a page boundary).
28    pub following_text: Option<&'a str>,
29}
30
31/// What [`understand_image`] judged the image to be.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub enum ImageUnderstanding {
35    /// Judged a text/table document scan — structured extraction.
36    Structured(Vec<ContentBlock>),
37    /// Judged a general image (photo, chart, diagram) — a context-aware description.
38    Description(String),
39}
40
41/// One block of content extracted from a [`ImageUnderstanding::Structured`] image.
42///
43/// Defined by this module, not borrowed from a consumer's document model — see the
44/// crate's `ai` module docs on why the dependency direction runs consumer → this
45/// module and not the other way around.
46#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub enum ContentBlock {
49    /// A paragraph of text.
50    Paragraph(String),
51    /// A table, with merged-cell structure preserved.
52    Table(TableBlock),
53}
54
55/// A table extracted from a [`ImageUnderstanding::Structured`] image.
56#[derive(Debug, Clone, PartialEq)]
57#[non_exhaustive]
58pub struct TableBlock {
59    /// Rows of cells, in document order.
60    pub rows: Vec<Vec<TableCellBlock>>,
61    /// How many leading rows are header rows.
62    pub header_rows: u8,
63}
64
65/// One cell of a [`TableBlock`].
66#[derive(Debug, Clone, PartialEq)]
67#[non_exhaustive]
68pub struct TableCellBlock {
69    /// The cell's text.
70    pub text: String,
71    /// How many rows this cell spans.
72    pub rowspan: u8,
73    /// How many columns this cell spans.
74    pub colspan: u8,
75}
76
77/// Judges `image` as a document scan or a general image, and extracts or describes
78/// it accordingly, in a single call.
79///
80/// `mime_type` is the image's own MIME type (e.g. `"image/png"`) — passed through
81/// unmodified into the vision message's `data:` URI.
82pub fn understand_image(
83    cfg: &AiConfig,
84    image: &[u8],
85    mime_type: &str,
86    context: ImageContext<'_>,
87) -> Result<ImageUnderstanding, Error> {
88    let messages = vec![
89        ChatMessage::text(
90            "system",
91            "You are given one image. First decide: is it primarily a scan of a \
92             text or table document, or a general image (a photo, chart, or \
93             diagram)? Then respond with exactly one JSON object and nothing else.\n\n\
94             If it is a document scan, respond with:\n\
95             {\"kind\":\"structured\",\"blocks\":[\n\
96               {\"type\":\"paragraph\",\"text\":\"...\"},\n\
97               {\"type\":\"table\",\"header_rows\":1,\"rows\":[[{\"text\":\"...\",\
98             \"rowspan\":1,\"colspan\":1}]]}\n\
99             ]}\n\
100             Preserve reading order, heading levels as plain text, and — this is the \
101             point of asking for JSON instead of markdown — every merged cell's true \
102             rowspan/colspan.\n\n\
103             If it is a general image, respond with:\n\
104             {\"kind\":\"description\",\"text\":\"...\"}\n\
105             Write the description using the surrounding text as context where it \
106             helps identify what the image shows.",
107        ),
108        vision_message(image, mime_type, context),
109    ];
110
111    let content = http::call(cfg, messages, true)?;
112    let dto: UnderstandingDto = serde_json::from_str(&content)
113        .map_err(|e| Error::MalformedResponse(format!("image understanding: {e}")))?;
114    Ok(dto.into())
115}
116
117fn vision_message(image: &[u8], mime_type: &str, context: ImageContext<'_>) -> ChatMessage {
118    let mut parts = Vec::new();
119    if let Some(preceding) = context.preceding_text {
120        parts.push(ContentPart::Text {
121            text: format!("Text immediately before the image:\n{preceding}"),
122        });
123    }
124    parts.push(ContentPart::ImageUrl {
125        image_url: ImageUrlPart {
126            url: http::data_uri(image, mime_type),
127        },
128    });
129    if let Some(following) = context.following_text {
130        parts.push(ContentPart::Text {
131            text: format!("Text immediately after the image:\n{following}"),
132        });
133    }
134    ChatMessage {
135        role: "user",
136        content: parts,
137    }
138}
139
140#[derive(Deserialize)]
141#[serde(tag = "kind", rename_all = "snake_case")]
142enum UnderstandingDto {
143    Structured { blocks: Vec<ContentBlockDto> },
144    Description { text: String },
145}
146
147#[derive(Deserialize)]
148#[serde(tag = "type", rename_all = "snake_case")]
149enum ContentBlockDto {
150    Paragraph {
151        text: String,
152    },
153    Table {
154        header_rows: u8,
155        rows: Vec<Vec<TableCellDto>>,
156    },
157}
158
159#[derive(Deserialize)]
160struct TableCellDto {
161    text: String,
162    #[serde(default = "one")]
163    rowspan: u8,
164    #[serde(default = "one")]
165    colspan: u8,
166}
167
168fn one() -> u8 {
169    1
170}
171
172impl From<UnderstandingDto> for ImageUnderstanding {
173    fn from(dto: UnderstandingDto) -> Self {
174        match dto {
175            UnderstandingDto::Description { text } => ImageUnderstanding::Description(text),
176            UnderstandingDto::Structured { blocks } => {
177                ImageUnderstanding::Structured(blocks.into_iter().map(ContentBlock::from).collect())
178            }
179        }
180    }
181}
182
183impl From<ContentBlockDto> for ContentBlock {
184    fn from(dto: ContentBlockDto) -> Self {
185        match dto {
186            ContentBlockDto::Paragraph { text } => ContentBlock::Paragraph(text),
187            ContentBlockDto::Table { header_rows, rows } => ContentBlock::Table(TableBlock {
188                header_rows,
189                rows: rows
190                    .into_iter()
191                    .map(|row| row.into_iter().map(TableCellBlock::from).collect())
192                    .collect(),
193            }),
194        }
195    }
196}
197
198impl From<TableCellDto> for TableCellBlock {
199    fn from(dto: TableCellDto) -> Self {
200        TableCellBlock {
201            text: dto.text,
202            rowspan: dto.rowspan,
203            colspan: dto.colspan,
204        }
205    }
206}