1use axum::Json;
20use axum::extract::{Path, Query, State};
21use axum::response::{IntoResponse, Response};
22use bytes::Bytes;
23use chess_core::Game;
24use serde::Deserialize;
25use serde_json::json;
26
27use crate::app::{Ctx, Who};
28use crate::error::{ApiError, ApiResult};
29use crate::http::{form, ok};
30use crate::{model, vision};
31
32pub fn position_key(game: &Game) -> String {
34 chess_analysis::dictionary::position_key(game)
35}
36
37pub async fn assist(
38 State(ctx): State<Ctx>,
39 who: Who,
40 Path(id): Path<String>,
41 body: Bytes,
42) -> ApiResult<Response> {
43 let me = who.need(&["board:play"])?.player;
44 let f = form(&body);
45 let on = match f.get("on").map(String::as_str) {
46 Some("true" | "1" | "yes") => true,
47 Some("false" | "0" | "no") => false,
48 _ => return Err(ApiError::bad("say on=true or on=false")),
49 };
50 let c = ctx.db.conn().await?;
51 let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
52 if row.white.player_id != me && row.black.player_id != me {
53 return Err(ApiError::forbidden("not your game"));
54 }
55 c.execute(
56 &ctx.db
57 .sql("INSERT INTO chess.assist_marks (game_id, player_id, ply, assist) VALUES ($1, $2, $3, $4)"),
58 &[&id, &me, &row.ply, &on],
59 )
60 .await?;
61 Ok(ok())
62}
63
64#[derive(Deserialize)]
65pub struct PositionQuery {
66 fen: String,
67}
68
69fn wdl_for(game: &Game, cp: i32) -> chess_analysis::Wdl {
71 let white_cp = if game.turn() == chess_core::Side::White { cp } else { -cp };
72 let phase = chess_analysis::eval::phase(game.board());
73 chess_analysis::Wdl::from_cp(white_cp, chess_analysis::wdl::Params::for_phase(phase))
74}
75
76fn parse_fen(fen: &str) -> Result<Game, ApiError> {
77 Game::new(Some(fen.trim())).map_err(|e| ApiError::bad(format!("bad FEN: {e:?}")))
78}
79
80async fn not_playing(ctx: &Ctx, c: &impl tokio_postgres::GenericClient, player: i64) -> ApiResult<()> {
82 if !model::ongoing_games(&ctx.db, c, player).await?.is_empty() {
83 return Err(ApiError::forbidden("position lookups are off while you have a game in progress"));
84 }
85 Ok(())
86}
87
88pub async fn position(
89 State(ctx): State<Ctx>,
90 who: Who,
91 Query(q): Query<PositionQuery>,
92) -> ApiResult<Response> {
93 let game = parse_fen(&q.fen)?;
94 let c = ctx.db.conn().await?;
95 not_playing(&ctx, &*c, who.0.player).await?;
96 let key = position_key(&game);
97 let row = c
98 .query_opt(
99 &ctx.db.sql(
100 "SELECT best, score_cp, depth, engine, seen, photos FROM chess.positions WHERE key = $1",
101 ),
102 &[&key],
103 )
104 .await?;
105 let hint = c
106 .query_opt(
107 &ctx.db.sql(
108 "SELECT best, score_cp, depth, engine FROM chess.position_hints WHERE key = $1
109 ORDER BY depth DESC, at DESC LIMIT 1",
110 ),
111 &[&key],
112 )
113 .await?
114 .map(|h| {
115 let cp: i32 = h.get(1);
116 json!({"best": h.get::<_, Option<String>>(0), "scoreCp": cp, "depth": h.get::<_, i32>(2),
117 "engine": h.get::<_, String>(3), "wdl": wdl_for(&game, cp)})
118 });
119 let Some(r) = row else {
120 return Ok(Json(json!({ "key": key, "known": false, "hint": hint })).into_response());
121 };
122 let photos: i32 = r.get(5);
123 let Some(score) = r.get::<_, Option<i32>>(1) else {
124 return Ok(Json(
126 json!({ "key": key, "known": false, "queued": true, "photos": photos, "hint": hint }),
127 )
128 .into_response());
129 };
130 Ok(Json(json!({
132 "key": key,
133 "known": true,
134 "best": r.get::<_, Option<String>>(0),
135 "scoreCp": score,
136 "depth": r.get::<_, i32>(2),
137 "engine": r.get::<_, Option<String>>(3),
138 "seen": r.get::<_, i32>(4),
139 "photos": photos,
140 "wdl": wdl_for(&game, score),
141 "hint": hint,
142 }))
143 .into_response())
144}
145
146#[derive(Deserialize, serde::Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct HintMove {
150 uci: String,
151 score_cp: i32,
152}
153
154#[derive(Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct HintBody {
157 fen: String,
158 #[serde(default)]
159 best: Option<String>,
160 score_cp: i32,
162 depth: i32,
163 #[serde(default)]
164 engine: Option<String>,
165 #[serde(default)]
167 moves: Vec<HintMove>,
168}
169
170pub async fn position_hint(State(ctx): State<Ctx>, who: Who, Json(b): Json<HintBody>) -> ApiResult<Response> {
175 let me = who.0.player;
176 let game = parse_fen(&b.fen)?;
177 if game.outcome().is_some() {
178 return Err(ApiError::bad("the game is over in that position"));
179 }
180 if !(1..=99).contains(&b.depth) || b.score_cp.abs() > 32_000 || b.moves.len() > 256 {
181 return Err(ApiError::bad("depth is 1–99, scores within ±32000, at most 256 moves"));
182 }
183 let legal = game.legal_moves();
184 if b.best.as_ref().is_some_and(|m| !legal.contains(m)) || b.moves.iter().any(|m| !legal.contains(&m.uci))
185 {
186 return Err(ApiError::bad("a move that isn't legal there"));
187 }
188 let engine: String = b.engine.as_deref().unwrap_or("open-chess app").chars().take(60).collect();
189 let moves =
190 if b.moves.is_empty() { None } else { Some(serde_json::to_value(&b.moves).unwrap_or_default()) };
191 let c = ctx.db.conn().await?;
192 not_playing(&ctx, &*c, me).await?;
193 c.execute(
194 &ctx.db.sql(
195 "INSERT INTO chess.position_hints (key, player_id, best, score_cp, depth, engine, moves)
196 VALUES ($1, $2, $3, $4, $5, $6, $7)
197 ON CONFLICT (key, player_id) DO UPDATE SET best = EXCLUDED.best, score_cp = EXCLUDED.score_cp,
198 depth = EXCLUDED.depth, engine = EXCLUDED.engine, moves = EXCLUDED.moves, at = now()
199 WHERE EXCLUDED.depth >= chess.position_hints.depth",
200 ),
201 &[&position_key(&game), &me, &b.best, &b.score_cp, &b.depth, &engine, &moves],
202 )
203 .await?;
204 Ok(ok())
205}
206
207pub async fn position_moves(
213 State(ctx): State<Ctx>,
214 who: Who,
215 Query(q): Query<PositionQuery>,
216) -> ApiResult<Response> {
217 let game = parse_fen(&q.fen)?;
218 let c = ctx.db.conn().await?;
219 not_playing(&ctx, &*c, who.0.player).await?;
220 let key = position_key(&game);
221 let legal = game.legal_moves();
222 let mut children = Vec::with_capacity(legal.len());
224 for m in &legal {
225 let mut g = game.clone();
226 if g.play_uci(m).is_ok() {
227 children.push((m.clone(), g));
228 }
229 }
230 let child_keys: Vec<String> = children.iter().map(|(_, g)| position_key(g)).collect();
231 let mut known: std::collections::HashMap<String, (i32, i32)> = std::collections::HashMap::new();
232 for r in c
233 .query(
234 &ctx.db.sql("SELECT key, score_cp, depth FROM chess.positions WHERE key = ANY($1) AND score_cp IS NOT NULL"),
235 &[&child_keys],
236 )
237 .await?
238 {
239 known.insert(r.get(0), (r.get(1), r.get(2)));
240 }
241 let moves_of = |row: Option<tokio_postgres::Row>| {
242 row.and_then(|r| r.get::<_, Option<serde_json::Value>>(0))
243 .and_then(|v| v.as_array().cloned())
244 .unwrap_or_default()
245 };
246 let worker = moves_of(
247 c.query_opt(&ctx.db.sql("SELECT moves FROM chess.positions WHERE key = $1"), &[&key]).await?,
248 );
249 let hints = moves_of(
250 c.query_opt(
251 &ctx.db.sql(
252 "SELECT moves FROM chess.position_hints WHERE key = $1 AND moves IS NOT NULL ORDER BY depth DESC LIMIT 1",
253 ),
254 &[&key],
255 )
256 .await?,
257 );
258 let find = |list: &[serde_json::Value], uci: &str, field: &str| {
259 list.iter().find(|m| m["uci"] == uci).and_then(|m| m[field].as_i64()).map(|v| v as i32)
260 };
261 let moves: Vec<serde_json::Value> = children
262 .iter()
263 .zip(&child_keys)
264 .map(|((uci, after), ck)| {
265 let (cp, depth, source) = if let Some(cp) = find(&worker, uci, "cp") {
266 (Some(cp), find(&worker, uci, "depth"), "engine")
267 } else if let Some(&(cp, d)) = known.get(ck) {
268 (Some(-cp), Some(d), "after")
270 } else if let Some(cp) = find(&hints, uci, "scoreCp") {
271 (Some(cp), None, "hint")
272 } else {
273 (None, None, "unknown")
274 };
275 let wdl = cp.map(|v| wdl_for(after, -v));
276 json!({"uci": uci, "san": chess_analysis::explain_san(game.board(), uci).map(|e| e.san), "scoreCp": cp,
277 "depth": depth, "source": source, "wdl": wdl})
278 })
279 .collect();
280 Ok(Json(json!({"key": key, "moves": moves})).into_response())
281}
282
283pub async fn tablebase(
287 State(ctx): State<Ctx>,
288 who: Who,
289 Query(q): Query<PositionQuery>,
290) -> ApiResult<Response> {
291 let game = Game::new(Some(q.fen.trim())).map_err(|e| ApiError::bad(format!("bad FEN: {e:?}")))?;
292 let pieces = game.board().occupied().len();
293 if pieces > 7 {
294 return Err(ApiError::bad("tablebases cover positions with seven pieces or fewer"));
295 }
296 let base = ctx.cfg.tablebase_url.trim().trim_end_matches('/');
297 if base.is_empty() {
298 return Err(ApiError::new(
299 axum::http::StatusCode::SERVICE_UNAVAILABLE,
300 "tablebases are off on this server",
301 ));
302 }
303 let c = ctx.db.conn().await?;
304 if !model::ongoing_games(&ctx.db, &*c, who.0.player).await?.is_empty() {
305 return Err(ApiError::forbidden("tablebase lookups are off while you have a game in progress"));
306 }
307 let key = position_key(&game);
308 if let Some(r) =
309 c.query_opt(&ctx.db.sql("SELECT answer FROM chess.tablebase WHERE key = $1"), &[&key]).await?
310 {
311 let mut v: serde_json::Value = r.get(0);
312 v["cached"] = true.into();
313 return Ok(Json(v).into_response());
314 }
315 let fen = format!("{key} 0 1");
317 let resp = crate::vision::http()
318 .get(format!("{base}/standard"))
319 .query(&[("fen", fen.as_str())])
320 .timeout(std::time::Duration::from_secs(10))
321 .send()
322 .await
323 .map_err(|e| {
324 ApiError::new(axum::http::StatusCode::BAD_GATEWAY, format!("tablebase unreachable: {e}"))
325 })?;
326 if !resp.status().is_success() {
327 return Err(ApiError::new(
328 axum::http::StatusCode::BAD_GATEWAY,
329 format!("tablebase answered {}", resp.status()),
330 ));
331 }
332 let answer: serde_json::Value = resp.json().await.map_err(|e| {
333 ApiError::new(axum::http::StatusCode::BAD_GATEWAY, format!("tablebase answer unreadable: {e}"))
334 })?;
335 c.execute(
336 &ctx.db.sql("INSERT INTO chess.tablebase (key, answer) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING"),
337 &[&key, &answer],
338 )
339 .await?;
340 Ok(Json(answer).into_response())
341}
342
343#[derive(Deserialize)]
344pub struct RecogniseBody {
345 image: String,
347}
348
349pub async fn recognise(
354 State(ctx): State<Ctx>,
355 who: Who,
356 Json(b): Json<RecogniseBody>,
357) -> ApiResult<Response> {
358 let me = who.0.player;
359 if ctx.reader.is_none() && !vision::enabled(&ctx.cfg) {
360 return Err(ApiError::new(
361 axum::http::StatusCode::SERVICE_UNAVAILABLE,
362 "photo reading isn't set up on this server (CHESS_VISION_LOCAL_MODEL or CHESS_VISION_ENDPOINT)",
363 ));
364 }
365 if b.image.len() > 8 * 1024 * 1024 {
366 return Err(ApiError::bad("the photo is too large: send it at 1600 pixels or less"));
367 }
368 let (bytes, mime) = vision::decode_image(&b.image).map_err(|e| ApiError::bad(format!("{e:#}")))?;
369
370 let local = match ctx.reader.clone() {
372 Some(reader) => {
373 let photo = bytes.clone();
374 let started = std::time::Instant::now();
375 let r = tokio::task::spawn_blocking(move || reader.read_encoded(&photo))
376 .await
377 .map_err(|e| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
378 .map_err(|e| ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?;
379 tracing::debug!(
380 ms = started.elapsed().as_millis() as u64,
381 confidence = r.confidence,
382 "photo read"
383 );
384 Some(r)
385 }
386 None => None,
387 };
388 let model_name = ctx.reader.as_ref().map(|r| r.card().name.clone()).filter(|n| !n.is_empty());
389 if let Some(r) = &local
390 && r.is_sure(ctx.cfg.vision_min_confidence)
391 {
392 return Ok(Json(local_answer(r, model_name.as_deref(), None)).into_response());
393 }
394
395 let llm =
397 if vision::enabled(&ctx.cfg) { Some(vision::read_board(&ctx.cfg, &b.image).await) } else { None };
398 let reason = match &local {
399 None => "no_model",
400 Some(r) if !r.plausible => "implausible",
401 Some(_) => "unsure",
402 };
403 let photo_id = if ctx.cfg.vision_review {
404 keep_photo(&ctx, me, &bytes, &mime, reason, local.as_ref(), model_name.as_deref(), llm.as_ref())
405 .await
406 .unwrap_or_else(|e| {
407 tracing::warn!("keeping a photo for review: {e:#}");
408 None
409 })
410 } else {
411 None
412 };
413 let llm_answer = |l: &vision::Reading| {
414 let mut v = llm_json(l, &ctx.cfg.vision_model);
415 v["source"] = json!("llm");
416 v["photoId"] = json!(photo_id);
417 v["review"] = json!(photo_id.is_some());
418 v
419 };
420 match (local, llm) {
421 (Some(r), Some(Ok(l))) if !r.plausible => Ok(Json(llm_answer(&l)).into_response()),
424 (Some(r), llm) => {
426 let mut v = local_answer(&r, model_name.as_deref(), photo_id);
427 if let Some(Ok(l)) = llm {
428 v["llm"] = llm_json(&l, &ctx.cfg.vision_model);
429 }
430 Ok(Json(v).into_response())
431 }
432 (None, Some(Ok(l))) => Ok(Json(llm_answer(&l)).into_response()),
433 (None, Some(Err(e))) => {
434 Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))
435 }
436 (None, None) => {
437 Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, "the photo couldn't be read"))
438 }
439 }
440}
441
442fn local_answer(
443 r: &chess_vision::BoardReading,
444 model: Option<&str>,
445 photo_id: Option<i64>,
446) -> serde_json::Value {
447 json!({
448 "source": "model",
449 "placement": r.placement,
450 "whiteAtBottom": null,
453 "sideToMove": null,
454 "confidence": r.confidence,
455 "unsureSquares": r.unsure_squares,
456 "plausible": r.plausible,
457 "squares": r.squares,
458 "model": model,
459 "photoId": photo_id,
460 "review": photo_id.is_some(),
461 })
462}
463
464fn llm_json(l: &vision::Reading, model: &str) -> serde_json::Value {
465 json!({
466 "placement": l.placement,
467 "whiteAtBottom": l.white_at_bottom,
468 "sideToMove": l.side_to_move,
469 "confidence": l.confidence,
470 "unsureSquares": l.unsure_squares,
471 "model": model,
472 })
473}
474
475#[allow(clippy::too_many_arguments)]
478async fn keep_photo(
479 ctx: &Ctx,
480 player: i64,
481 bytes: &[u8],
482 mime: &str,
483 reason: &str,
484 local: Option<&chess_vision::BoardReading>,
485 model: Option<&str>,
486 llm: Option<&anyhow::Result<vision::Reading>>,
487) -> anyhow::Result<Option<i64>> {
488 let c = ctx.db.conn().await?;
489 let waiting: i64 = c
490 .query_one(
491 &ctx.db.sql("SELECT count(*) FROM chess.board_photos WHERE label IS NULL AND NOT unreadable"),
492 &[],
493 )
494 .await?
495 .get(0);
496 if waiting >= ctx.cfg.vision_review_max {
497 return Ok(None);
498 }
499 let reading = local.map(|r| {
500 json!({"placement": r.placement, "confidence": r.confidence, "unsure_squares": r.unsure_squares,
501 "plausible": r.plausible, "model": model})
502 });
503 let (suggestion, failed) = match llm {
504 Some(Ok(l)) => (
505 Some(json!({"placement": l.placement, "white_at_bottom": l.white_at_bottom,
506 "side_to_move": l.side_to_move, "confidence": l.confidence, "unsure_squares": l.unsure_squares,
507 "model": ctx.cfg.vision_model, "at": chrono::Utc::now()})),
508 None,
509 ),
510 Some(Err(e)) => (None, Some(format!("{e:#}").chars().take(300).collect::<String>())),
511 None => (None, None),
512 };
513 let row = c
514 .query_one(
515 &ctx.db.sql(
516 "INSERT INTO chess.board_photos (player_id, image, mime, reason, reading, suggestion, suggestion_failed)
517 VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id",
518 ),
519 &[&player, &bytes, &mime, &reason, &reading, &suggestion, &failed],
520 )
521 .await?;
522 Ok(Some(row.get(0)))
523}
524
525#[derive(Deserialize)]
526pub struct ConfirmBody {
527 placement: String,
530 #[serde(default)]
534 fen: Option<String>,
535}
536
537pub async fn queue_photo_positions(
540 db: &crate::db::Db,
541 c: &impl tokio_postgres::GenericClient,
542 games: &[Game],
543) -> anyhow::Result<()> {
544 let sql = db.sql(
545 "INSERT INTO chess.positions (key, score_cp, depth, engine, seen, photos) VALUES ($1, NULL, 0, NULL, 0, 1)
546 ON CONFLICT (key) DO UPDATE SET photos = chess.positions.photos + 1",
547 );
548 for g in games.iter().filter(|g| g.outcome().is_none()) {
549 c.execute(&sql, &[&position_key(g)]).await?;
550 }
551 Ok(())
552}
553
554pub async fn confirm_photo(
559 State(ctx): State<Ctx>,
560 who: Who,
561 Path(id): Path<i64>,
562 Json(b): Json<ConfirmBody>,
563) -> ApiResult<Response> {
564 let grid = chess_analysis::photo::parse_placement(&b.placement)
565 .ok_or_else(|| ApiError::bad("that isn't a board: eight ranks of eight squares"))?;
566 let placement = chess_analysis::photo::placement(&grid);
567 let c = ctx.db.conn().await?;
568 let n = c
569 .execute(
570 &ctx.db.sql(
571 "UPDATE chess.board_photos SET label = $3, label_source = 'player', labelled_by = $2, labelled_at = now()
572 WHERE id = $1 AND player_id = $2 AND NOT unreadable AND coalesce(label_source, 'player') = 'player'",
573 ),
574 &[&id, &who.0.player, &placement],
575 )
576 .await?;
577 if n == 0 {
578 return Err(ApiError::not_found("photo"));
579 }
580 let given = b
583 .fen
584 .as_deref()
585 .and_then(|f| Game::new(Some(f.trim())).ok())
586 .filter(|g| g.fen().split_whitespace().next() == Some(placement.as_str()));
587 let games = match given {
588 Some(g) => vec![g],
589 None => chess_analysis::dictionary::photo_positions(&grid, None),
590 };
591 if let Err(e) = queue_photo_positions(&ctx.db, &*c, &games).await {
592 tracing::warn!("queueing a photo's position: {e:#}");
593 }
594 Ok(ok())
595}