unparser_shared/ai/
image.rs1use serde::Deserialize;
10
11use super::config::AiConfig;
12use super::error::Error;
13use super::http::{self, ChatMessage, ContentPart, ImageUrlPart};
14
15#[derive(Debug, Clone, Copy, Default)]
22pub struct ImageContext<'a> {
23 pub preceding_text: Option<&'a str>,
26 pub following_text: Option<&'a str>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub enum ImageUnderstanding {
35 Structured(Vec<ContentBlock>),
37 Description(String),
39}
40
41#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub enum ContentBlock {
49 Paragraph(String),
51 Table(TableBlock),
53}
54
55#[derive(Debug, Clone, PartialEq)]
57#[non_exhaustive]
58pub struct TableBlock {
59 pub rows: Vec<Vec<TableCellBlock>>,
61 pub header_rows: u8,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67#[non_exhaustive]
68pub struct TableCellBlock {
69 pub text: String,
71 pub rowspan: u8,
73 pub colspan: u8,
75}
76
77pub 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}