open_chess_server/
vision.rs1use std::sync::OnceLock;
21use std::time::Duration;
22
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25use serde_json::{Value, json};
26
27use crate::config::Config;
28
29const PROMPT: &str = "You are reading a chess position from a photo or screenshot of a board. \
30Reply with only a JSON object, no other text: \
31{\"placement\": the FEN piece placement, ranks 8 down to 1 separated by '/', White's pieces as \
32uppercase letters (KQRBNP) and Black's as lowercase, empty squares as digits, always written from \
33White's point of view so rank 1 is White's back rank whichever way the photo was taken; \
34\"white_at_bottom\": true if White's pieces were at the bottom of the image (nearer the camera), false if \
35Black's were; \"side_to_move\": \"w\" or \"b\" only if the image shows it (a move indicator, a \
36highlighted last move, a clock), otherwise null; \"confidence\": your confidence from 0 to 1; \
37\"unsure_squares\": the squares (like \"e4\") you could not read reliably}. \
38Count each rank's squares carefully so every rank adds up to eight. \
39If the image does not show a chess board, reply {\"error\": \"what it shows instead\"}.";
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct Reading {
44 pub placement: String,
45 #[serde(default = "yes")]
46 pub white_at_bottom: bool,
47 #[serde(default)]
48 pub side_to_move: Option<String>,
49 #[serde(default)]
50 pub confidence: Option<f64>,
51 #[serde(default)]
52 pub unsure_squares: Vec<String>,
53}
54
55fn yes() -> bool {
56 true
57}
58
59pub(crate) fn http() -> &'static reqwest::Client {
60 static C: OnceLock<reqwest::Client> = OnceLock::new();
61 C.get_or_init(|| {
62 reqwest::Client::builder()
63 .timeout(Duration::from_secs(90))
64 .build()
65 .unwrap_or_else(|_| reqwest::Client::new())
66 })
67}
68
69pub fn enabled(cfg: &Config) -> bool {
71 !cfg.vision_endpoint.trim().is_empty()
72}
73
74pub fn load_reader(cfg: &Config) -> Result<Option<chess_vision::Reader>> {
79 let Some(path) = reader_path(cfg) else {
80 return Ok(None);
81 };
82 let path = path.as_str();
83 let bytes = std::fs::read(path).with_context(|| format!("reading CHESS_VISION_LOCAL_MODEL ({path})"))?;
84 let r = chess_vision::Reader::from_safetensors(&bytes)
85 .with_context(|| format!("loading the board reader {path}"))?;
86 Ok(Some(r))
87}
88
89fn reader_path(cfg: &Config) -> Option<String> {
91 let named = cfg.vision_local_model.trim();
92 if !named.is_empty() {
93 return Some(named.to_string());
94 }
95 let shipped = std::path::Path::new(cfg.app_dir.as_deref()?.trim()).join("nets/board-reader.safetensors");
96 shipped.is_file().then(|| shipped.to_string_lossy().into_owned())
97}
98
99pub fn decode_image(image: &str) -> Result<(Vec<u8>, String)> {
102 use base64::Engine as _;
103 let (mime, b64) = match image.strip_prefix("data:") {
104 Some(rest) => {
105 let (head, data) = rest.split_once(',').context("a data: URL without a comma")?;
106 let mime = head.split(';').next().unwrap_or("image/jpeg");
107 if !head.ends_with(";base64") {
108 bail!("the photo's data: URL isn't base64");
109 }
110 (mime.to_owned(), data)
111 }
112 None => ("image/jpeg".to_owned(), image),
113 };
114 if !matches!(mime.as_str(), "image/jpeg" | "image/png" | "image/webp") {
115 bail!("send the photo as JPEG, PNG or WebP (not {mime})");
116 }
117 let bytes = base64::engine::general_purpose::STANDARD
118 .decode(b64.trim())
119 .context("the photo isn't valid base64")?;
120 Ok((bytes, mime))
121}
122
123fn json_in(text: &str) -> Option<Value> {
126 let start = text.find('{')?;
127 let end = text.rfind('}')?;
128 serde_json::from_str(text.get(start..=end)?).ok()
129}
130
131pub fn parse_reply(text: &str) -> Result<Reading> {
133 let v = json_in(text).context("the vision model's reply had no JSON in it")?;
134 if let Some(e) = v.get("error").and_then(Value::as_str) {
135 bail!("no chess board found: {e}");
136 }
137 let r: Reading =
138 serde_json::from_value(v).context("the vision model's reply wasn't in the expected shape")?;
139 if chess_analysis::photo::parse_placement(&r.placement).is_none() {
140 bail!("the board read from the photo doesn't have eight ranks of eight squares ({})", r.placement);
141 }
142 Ok(r)
143}
144
145pub async fn read_board(cfg: &Config, image: &str) -> Result<Reading> {
147 let url = if image.starts_with("data:") {
148 image.to_string()
149 } else {
150 format!("data:image/jpeg;base64,{image}")
151 };
152 let body = json!({
153 "model": cfg.vision_model,
154 "max_tokens": 600,
155 "temperature": 0,
156 "messages": [{
157 "role": "user",
158 "content": [
159 { "type": "image_url", "image_url": { "url": url } },
160 { "type": "text", "text": PROMPT },
161 ],
162 }],
163 });
164 let endpoint = format!("{}/chat/completions", cfg.vision_endpoint.trim_end_matches('/'));
165 let mut req = http().post(&endpoint).json(&body);
166 if let Some(k) = cfg.vision_api_key.as_deref().filter(|k| !k.is_empty()) {
167 req = req.bearer_auth(k);
168 }
169 let resp = req.send().await.context("couldn't reach the vision model")?;
170 let status = resp.status();
171 let v: Value = resp.json().await.context("the vision model's answer wasn't JSON")?;
172 if !status.is_success() {
173 bail!(
174 "the vision model refused the photo ({status}): {}",
175 v["error"]["message"].as_str().unwrap_or("")
176 );
177 }
178 let text = v["choices"][0]["message"]["content"].as_str().unwrap_or_default();
179 parse_reply(text)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn the_apps_reader_unless_one_is_named() {
188 use clap::Parser;
189 #[derive(Parser)]
190 struct Cli {
191 #[command(flatten)]
192 cfg: Config,
193 }
194 if std::env::var_os("CHESS_VISION_LOCAL_MODEL").is_some() {
195 return; }
197 let app = std::env::temp_dir().join(format!("open-chess-app-{}", std::process::id()));
198 let dir = app.to_str().unwrap();
199 let cfg = |extra: &[&str]| {
200 let mut args = vec!["chess-server", "--database-url", "postgres://x", "--app-dir", dir];
201 args.extend_from_slice(extra);
202 Cli::try_parse_from(args).unwrap().cfg
203 };
204 assert_eq!(reader_path(&cfg(&[])), None);
206 std::fs::create_dir_all(app.join("nets")).unwrap();
208 std::fs::write(app.join("nets/board-reader.safetensors"), b"x").unwrap();
209 assert_eq!(
210 reader_path(&cfg(&[])),
211 Some(app.join("nets/board-reader.safetensors").to_string_lossy().into_owned())
212 );
213 assert_eq!(
215 reader_path(&cfg(&["--vision-local-model", "/models/big.safetensors"])).as_deref(),
216 Some("/models/big.safetensors")
217 );
218 std::fs::remove_dir_all(&app).unwrap();
219 }
220
221 #[test]
222 fn replies_are_parsed_and_checked() {
223 let r = parse_reply(
224 "Here you go:\n```json\n{\"placement\": \"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR\", \
225 \"white_at_bottom\": false, \"side_to_move\": \"b\", \"confidence\": 0.8, \"unsure_squares\": [\"e4\"]}\n```",
226 )
227 .unwrap();
228 assert!(!r.white_at_bottom);
229 assert_eq!(r.side_to_move.as_deref(), Some("b"));
230 assert_eq!(r.unsure_squares, vec!["e4"]);
231 assert!(parse_reply("{\"error\": \"a cat\"}").unwrap_err().to_string().contains("a cat"));
232 assert!(parse_reply("{\"placement\": \"8/8/8\"}").is_err());
233 assert!(parse_reply("no idea").is_err());
234 }
235
236 #[test]
237 fn photos_are_decoded() {
238 let (b, m) = decode_image("data:image/png;base64,aGVsbG8=").unwrap();
239 assert_eq!((b.as_slice(), m.as_str()), (&b"hello"[..], "image/png"));
240 assert_eq!(decode_image("aGVsbG8=").unwrap().1, "image/jpeg");
241 assert!(decode_image("data:image/gif;base64,aGVsbG8=").is_err());
242 assert!(decode_image("data:image/png,hello").is_err());
243 assert!(decode_image("data:image/png;base64,***").is_err());
244 }
245}