Skip to main content

open_chess_server/http/
lab.rs

1//! open-chess additions to the Lichess API, used by the chess
2//! training app:
3//!
4//! - `POST /api/board/game/{id}/assist`: the app declares that its engine
5//!   help was switched on or off during one of our games (ground truth for
6//!   the fair-play model);
7//! - `GET /api/esl/position?fen=`: the position dictionary — the deepest
8//!   stored evaluation of a position, with win/draw/loss odds, and the
9//!   deepest analysis a training app sent in (a hint);
10//! - `POST /api/esl/position`: send in the app's own analysis of a position;
11//! - `GET /api/esl/position/moves?fen=`: every legal move with its value,
12//!   where the dictionary knows it;
13//! - `POST /api/esl/recognise`: read a board from a photo;
14//! - `POST /api/esl/photos/{id}/confirm`: say what a photo kept for review
15//!   showed.
16//! - `GET /api/esl/tablebase?fen=`: the exact result of a position with up to
17//!   seven pieces, from the tablebase server, kept once fetched.
18
19use 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
32/// The dictionary key for a position (see `chess_analysis::dictionary`).
33pub 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
69/// White's win/draw/loss odds for a score from the side to move's view.
70fn 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
80/// A stored evaluation is engine help: not while you're playing here.
81async 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        // Confirmed from a photo, waiting for the worker.
125        return Ok(Json(
126            json!({ "key": key, "known": false, "queued": true, "photos": photos, "hint": hint }),
127        )
128        .into_response());
129    };
130    // Stored from the side to move's view; odds are given from White's.
131    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/// One move's value as the app sends it.
147#[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    /// From the side to move's view.
161    score_cp: i32,
162    depth: i32,
163    #[serde(default)]
164    engine: Option<String>,
165    /// Every move it scored (from the side to move's view).
166    #[serde(default)]
167    moves: Vec<HintMove>,
168}
169
170/// `POST /api/esl/position`: the training app's analysis of a position, a
171/// hint for others looking it up. One per player and position (the deepest
172/// kept); never mixed with the worker's evaluations. Refused while you're
173/// playing a game here.
174pub 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
207/// `GET /api/esl/position/moves?fen=`: every legal move, each with the
208/// best value the dictionary has for it, from the mover's view, and
209/// White's odds after it: the worker's scoring of this position's moves
210/// first, then the evaluation stored for the position the move leads to,
211/// then an app's hint. `null` where nothing is known.
212pub 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    // (uci, the position after it)
223    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                // Stored for the side to move after it: the other side.
269                (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
283/// `GET /api/esl/tablebase?fen=`: Lichess's tablebase answer (category,
284/// distance to zeroing and to mate, and every move's result), cached.
285/// Like the position dictionary, refused while you're playing here.
286pub 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    // The tablebase wants a full FEN; the key's counters don't matter.
316    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/// The photo licence's version (docs/photo-licence.md). A photo is kept
344/// for review, and trained on, only under terms its sender accepted.
345pub const PHOTO_TERMS: &str = "open-chess-photo-licence-1";
346
347/// The photo terms a player accepted, if the current ones: (terms, public).
348pub(crate) async fn accepted_terms(ctx: &Ctx, player: i64) -> anyhow::Result<Option<bool>> {
349    let c = ctx.db.conn().await?;
350    let r = c
351        .query_opt(
352            &ctx.db.sql("SELECT photo_terms, photo_public FROM chess.players WHERE id = $1"),
353            &[&player],
354        )
355        .await?;
356    Ok(r.and_then(|r| (r.get::<_, Option<String>>(0).as_deref() == Some(PHOTO_TERMS)).then(|| r.get(1))))
357}
358
359#[derive(Debug, Deserialize)]
360pub struct TermsBody {
361    /// Accept the photo licence (false withdraws, for photos sent later).
362    accept: bool,
363    /// Also release my photos as CC0 for a public dataset.
364    #[serde(default)]
365    public: bool,
366}
367
368fn terms_json(accepted: Option<bool>) -> serde_json::Value {
369    json!({
370        "terms": PHOTO_TERMS,
371        "accepted": accepted.is_some(),
372        "public": accepted.unwrap_or(false),
373        "text": "/docs/photo-licence",
374    })
375}
376
377/// `GET /api/esl/photo-terms`: the photo licence, and whether I accepted it.
378pub async fn photo_terms(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
379    Ok(Json(terms_json(accepted_terms(&ctx, who.0.player).await?)).into_response())
380}
381
382/// `POST /api/esl/photo-terms` (JSON `{accept, public}`): accept (or
383/// withdraw from) the photo licence for photos I send from now on, and
384/// whether they're also CC0.
385pub async fn set_photo_terms(
386    State(ctx): State<Ctx>,
387    who: Who,
388    Json(b): Json<TermsBody>,
389) -> ApiResult<Response> {
390    let c = ctx.db.conn().await?;
391    let terms = b.accept.then_some(PHOTO_TERMS);
392    c.execute(
393        &ctx.db.sql(
394            "UPDATE chess.players SET photo_terms = $2, photo_terms_at = now(), photo_public = $3 WHERE id = $1",
395        ),
396        &[&who.0.player, &terms, &(b.accept && b.public)],
397    )
398    .await?;
399    Ok(Json(terms_json(b.accept.then_some(b.public))).into_response())
400}
401
402#[derive(Deserialize)]
403pub struct RecogniseBody {
404    /// A `data:image/...;base64,` URL or bare base64.
405    image: String,
406}
407
408/// Read a board from a photo: with our board reader if there is one,
409/// asking the vision language model only when it isn't sure (or there is
410/// no reader). A photo that wasn't read for sure is kept for the Review
411/// page (`photoId` in the answer) unless CHESS_VISION_REVIEW is off.
412pub async fn recognise(
413    State(ctx): State<Ctx>,
414    who: Who,
415    Json(b): Json<RecogniseBody>,
416) -> ApiResult<Response> {
417    let me = who.0.player;
418    if ctx.reader.is_none() && !vision::enabled(&ctx.cfg) {
419        return Err(ApiError::new(
420            axum::http::StatusCode::SERVICE_UNAVAILABLE,
421            "photo reading isn't set up on this server (CHESS_VISION_LOCAL_MODEL or CHESS_VISION_ENDPOINT)",
422        ));
423    }
424    if b.image.len() > 8 * 1024 * 1024 {
425        return Err(ApiError::bad("the photo is too large: send it at 1600 pixels or less"));
426    }
427    let (bytes, mime) = vision::decode_image(&b.image).map_err(|e| ApiError::bad(format!("{e:#}")))?;
428
429    // Our reader first.
430    let local = match ctx.reader.clone() {
431        Some(reader) => {
432            let photo = bytes.clone();
433            let started = std::time::Instant::now();
434            let r = tokio::task::spawn_blocking(move || reader.read_encoded(&photo))
435                .await
436                .map_err(|e| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
437                .map_err(|e| ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?;
438            tracing::debug!(
439                ms = started.elapsed().as_millis() as u64,
440                confidence = r.confidence,
441                "photo read"
442            );
443            Some(r)
444        }
445        None => None,
446    };
447    let model_name = ctx.reader.as_ref().map(|r| r.card().name.clone()).filter(|n| !n.is_empty());
448    if let Some(r) = &local
449        && r.is_sure(ctx.cfg.vision_min_confidence)
450    {
451        return Ok(Json(local_answer(r, model_name.as_deref(), None)).into_response());
452    }
453
454    // Not sure (or no reader): the vision language model's opinion.
455    let llm =
456        if vision::enabled(&ctx.cfg) { Some(vision::read_board(&ctx.cfg, &b.image).await) } else { None };
457    let reason = match &local {
458        None => "no_model",
459        Some(r) if !r.plausible => "implausible",
460        Some(_) => "unsure",
461    };
462    // Kept for review (and training) only under the photo licence.
463    let terms = if ctx.cfg.vision_review { accepted_terms(&ctx, me).await? } else { None };
464    let terms_needed = ctx.cfg.vision_review && terms.is_none();
465    let photo_id = match terms {
466        Some(public) => {
467            let r = local.as_ref();
468            keep_photo(&ctx, me, &bytes, &mime, reason, r, model_name.as_deref(), llm.as_ref(), public)
469                .await
470                .unwrap_or_else(|e| {
471                    tracing::warn!("keeping a photo for review: {e:#}");
472                    None
473                })
474        }
475        None => None,
476    };
477    let llm_answer = |l: &vision::Reading| {
478        let mut v = llm_json(l, &ctx.cfg.vision_model);
479        v["source"] = json!("llm");
480        v["photoId"] = json!(photo_id);
481        v["review"] = json!(photo_id.is_some());
482        v["termsNeeded"] = json!(terms_needed);
483        v
484    };
485    match (local, llm) {
486        // The reader's reading can't be a game position but the model's
487        // can: give the model's.
488        (Some(r), Some(Ok(l))) if !r.plausible => Ok(Json(llm_answer(&l)).into_response()),
489        // Otherwise the reader's (if unsure), with the model's alongside.
490        (Some(r), llm) => {
491            let mut v = local_answer(&r, model_name.as_deref(), photo_id);
492            v["termsNeeded"] = json!(terms_needed);
493            if let Some(Ok(l)) = llm {
494                v["llm"] = llm_json(&l, &ctx.cfg.vision_model);
495            }
496            Ok(Json(v).into_response())
497        }
498        (None, Some(Ok(l))) => Ok(Json(llm_answer(&l)).into_response()),
499        (None, Some(Err(e))) => {
500            Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))
501        }
502        (None, None) => {
503            Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, "the photo couldn't be read"))
504        }
505    }
506}
507
508fn local_answer(
509    r: &chess_vision::BoardReading,
510    model: Option<&str>,
511    photo_id: Option<i64>,
512) -> serde_json::Value {
513    json!({
514        "source": "model",
515        "placement": r.placement,
516        // The reader always reads from White's side; it doesn't say where
517        // the camera was.
518        "whiteAtBottom": null,
519        "sideToMove": null,
520        "confidence": r.confidence,
521        "unsureSquares": r.unsure_squares,
522        "plausible": r.plausible,
523        "squares": r.squares,
524        "model": model,
525        "photoId": photo_id,
526        "review": photo_id.is_some(),
527    })
528}
529
530fn llm_json(l: &vision::Reading, model: &str) -> serde_json::Value {
531    json!({
532        "placement": l.placement,
533        "whiteAtBottom": l.white_at_bottom,
534        "sideToMove": l.side_to_move,
535        "confidence": l.confidence,
536        "unsureSquares": l.unsure_squares,
537        "model": model,
538    })
539}
540
541/// Keep a photo for the Review page; its id, or `None` when the review
542/// queue is full.
543#[allow(clippy::too_many_arguments)]
544async fn keep_photo(
545    ctx: &Ctx,
546    player: i64,
547    bytes: &[u8],
548    mime: &str,
549    reason: &str,
550    local: Option<&chess_vision::BoardReading>,
551    model: Option<&str>,
552    llm: Option<&anyhow::Result<vision::Reading>>,
553    public: bool,
554) -> anyhow::Result<Option<i64>> {
555    let c = ctx.db.conn().await?;
556    let waiting: i64 = c
557        .query_one(
558            &ctx.db.sql("SELECT count(*) FROM chess.board_photos WHERE label IS NULL AND NOT unreadable"),
559            &[],
560        )
561        .await?
562        .get(0);
563    if waiting >= ctx.cfg.vision_review_max {
564        return Ok(None);
565    }
566    let reading = local.map(|r| {
567        json!({"placement": r.placement, "confidence": r.confidence, "unsure_squares": r.unsure_squares,
568               "plausible": r.plausible, "model": model})
569    });
570    let (suggestion, failed) = match llm {
571        Some(Ok(l)) => (
572            Some(json!({"placement": l.placement, "white_at_bottom": l.white_at_bottom,
573            "side_to_move": l.side_to_move, "confidence": l.confidence, "unsure_squares": l.unsure_squares,
574            "model": ctx.cfg.vision_model, "at": chrono::Utc::now()})),
575            None,
576        ),
577        Some(Err(e)) => (None, Some(format!("{e:#}").chars().take(300).collect::<String>())),
578        None => (None, None),
579    };
580    let row = c
581        .query_one(
582            &ctx.db.sql(
583                "INSERT INTO chess.board_photos (player_id, image, mime, reason, reading, suggestion, suggestion_failed, terms, public)
584                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
585            ),
586            &[&player, &bytes, &mime, &reason, &reading, &suggestion, &failed, &PHOTO_TERMS, &public],
587        )
588        .await?;
589    Ok(Some(row.get(0)))
590}
591
592#[derive(Deserialize)]
593pub struct ConfirmBody {
594    /// The position the photo shows, as FEN (only the placement is used,
595    /// from White's side).
596    placement: String,
597    /// The full position, when the app knows it (side to move, castling):
598    /// what goes into the position dictionary. Without it, both sides to
599    /// move are queued.
600    #[serde(default)]
601    fen: Option<String>,
602}
603
604/// Queue a confirmed photo's position(s) in the dictionary for the
605/// fair-play worker to evaluate (counting the photos of each).
606pub async fn queue_photo_positions(
607    db: &crate::db::Db,
608    c: &impl tokio_postgres::GenericClient,
609    games: &[Game],
610) -> anyhow::Result<()> {
611    let sql = db.sql(
612        "INSERT INTO chess.positions (key, score_cp, depth, engine, seen, photos) VALUES ($1, NULL, 0, NULL, 0, 1)
613         ON CONFLICT (key) DO UPDATE SET photos = chess.positions.photos + 1",
614    );
615    for g in games.iter().filter(|g| g.outcome().is_none()) {
616        c.execute(&sql, &[&position_key(g)]).await?;
617    }
618    Ok(())
619}
620
621/// `POST /api/esl/photos/{id}/confirm`: the player who sent a photo says
622/// what it showed (they picked the move it matched, or finished setting the
623/// position up in the editor). A label for training, marked as the
624/// player's: a reviewer's label always wins.
625pub async fn confirm_photo(
626    State(ctx): State<Ctx>,
627    who: Who,
628    Path(id): Path<i64>,
629    Json(b): Json<ConfirmBody>,
630) -> ApiResult<Response> {
631    let grid = chess_analysis::photo::parse_placement(&b.placement)
632        .ok_or_else(|| ApiError::bad("that isn't a board: eight ranks of eight squares"))?;
633    let placement = chess_analysis::photo::placement(&grid);
634    let c = ctx.db.conn().await?;
635    let n = c
636        .execute(
637            &ctx.db.sql(
638                "UPDATE chess.board_photos SET label = $3, label_source = 'player', labelled_by = $2, labelled_at = now()
639                  WHERE id = $1 AND player_id = $2 AND NOT unreadable AND coalesce(label_source, 'player') = 'player'",
640            ),
641            &[&id, &who.0.player, &placement],
642        )
643        .await?;
644    if n == 0 {
645        return Err(ApiError::not_found("photo"));
646    }
647    // The sender's word is one vote: another person agreeing labels it.
648    vote(&ctx, &*c, id, who.0.player, Some(&placement), false).await?;
649    // The position joins the dictionary: the given one if it matches the
650    // placement, else each side to move.
651    let given = b
652        .fen
653        .as_deref()
654        .and_then(|f| Game::new(Some(f.trim())).ok())
655        .filter(|g| g.fen().split_whitespace().next() == Some(placement.as_str()));
656    let games = match given {
657        Some(g) => vec![g],
658        None => chess_analysis::dictionary::photo_positions(&grid, None),
659    };
660    if let Err(e) = queue_photo_positions(&ctx.db, &*c, &games).await {
661        tracing::warn!("queueing a photo's position: {e:#}");
662    }
663    Ok(ok())
664}
665
666// ─── photo review (reviewers: `chess:fairplay`, managers and admins) ───
667
668/// Admins and managers: their label is final, and they see every photo.
669fn staff(who: &Who) -> bool {
670    who.0.admin || who.0.manager
671}
672
673/// Record what `player` says photo `id` shows, then settle it: two people
674/// agreeing (the sender counts, by confirming it) label it, unless an
675/// admin already has.
676async fn vote(
677    ctx: &Ctx,
678    c: &impl tokio_postgres::GenericClient,
679    id: i64,
680    player: i64,
681    placement: Option<&str>,
682    unreadable: bool,
683) -> anyhow::Result<()> {
684    c.execute(
685        &ctx.db.sql(
686            "INSERT INTO chess.photo_votes (photo_id, player_id, placement, unreadable) VALUES ($1, $2, $3, $4)
687             ON CONFLICT (photo_id, player_id) DO UPDATE SET placement = EXCLUDED.placement,
688                 unreadable = EXCLUDED.unreadable, voted_at = now()",
689        ),
690        &[&id, &player, &placement, &unreadable],
691    )
692    .await?;
693    // The answer two or more people gave, if any.
694    let agreed = c
695        .query_opt(
696            &ctx.db.sql(
697                "SELECT placement, unreadable FROM chess.photo_votes WHERE photo_id = $1
698                  GROUP BY placement, unreadable HAVING count(*) >= 2 ORDER BY count(*) DESC LIMIT 1",
699            ),
700            &[&id],
701        )
702        .await?;
703    if let Some(r) = agreed {
704        let (placement, unreadable): (Option<String>, bool) = (r.get(0), r.get(1));
705        c.execute(
706            &ctx.db.sql(
707                "UPDATE chess.board_photos SET label = $2, unreadable = $3, label_source = CASE WHEN $3 THEN NULL ELSE 'consensus' END,
708                        labelled_by = NULL, labelled_at = now()
709                  WHERE id = $1 AND coalesce(label_source, '') NOT IN ('admin', 'reviewer')
710                    AND NOT (unreadable AND labelled_by IS NOT NULL)",
711            ),
712            &[&id, &placement, &unreadable],
713        )
714        .await?;
715    }
716    Ok(())
717}
718
719#[derive(Debug, Deserialize)]
720pub struct PhotoList {
721    #[serde(default)]
722    all: bool,
723}
724
725/// `GET /api/esl/photos`: board photos waiting for people to say what they
726/// show, for anyone signed in: not their own, not ones they've answered,
727/// not ones already settled (two agreeing, or an admin), newest first,
728/// without the image bytes. Admins and managers see disputed photos
729/// first, and with `?all=true` every photo.
730pub async fn photos(State(ctx): State<Ctx>, who: Who, Query(q): Query<PhotoList>) -> ApiResult<Response> {
731    let me = who.0.player;
732    let staff = staff(&who);
733    let all = q.all && staff;
734    let c = ctx.db.conn().await?;
735    let rows = c
736        .query(
737            &ctx.db.sql(
738                "SELECT p.id, p.created_at, p.reason, p.reading, p.suggestion, p.suggestion_failed, p.label, p.label_source,
739                        p.unreadable, p.labelled_at, p.public, p.player_id = $2,
740                        (SELECT count(*) FROM chess.photo_votes v WHERE v.photo_id = p.id),
741                        (SELECT row_to_json(v) FROM (SELECT placement, unreadable FROM chess.photo_votes
742                          WHERE photo_id = p.id AND player_id = $2) v)
743                   FROM chess.board_photos p
744                  WHERE $1 OR (
745                        coalesce(p.label_source, 'player') = 'player' AND NOT p.unreadable
746                        AND ($3 OR (p.terms IS NOT NULL AND coalesce(p.player_id, 0) <> $2
747                                    AND NOT EXISTS (SELECT 1 FROM chess.photo_votes v WHERE v.photo_id = p.id AND v.player_id = $2))))
748                  ORDER BY CASE WHEN $3 THEN (SELECT count(*) FROM chess.photo_votes v WHERE v.photo_id = p.id) ELSE 0 END DESC, p.id DESC
749                  LIMIT 200",
750            ),
751            &[&all, &me, &staff],
752        )
753        .await?;
754    let list: Vec<serde_json::Value> = rows
755        .iter()
756        .map(|r| {
757            json!({
758                "id": r.get::<_, i64>(0),
759                "at": r.get::<_, chrono::DateTime<chrono::Utc>>(1),
760                "reason": r.get::<_, String>(2),
761                "reading": r.get::<_, Option<serde_json::Value>>(3),
762                "suggestion": r.get::<_, Option<serde_json::Value>>(4),
763                "suggestion_failed": r.get::<_, Option<String>>(5),
764                "label": r.get::<_, Option<String>>(6),
765                "label_source": r.get::<_, Option<String>>(7),
766                "unreadable": r.get::<_, bool>(8),
767                "labelled_at": r.get::<_, Option<chrono::DateTime<chrono::Utc>>>(9),
768                "public": r.get::<_, bool>(10),
769                "mine": r.get::<_, Option<bool>>(11).unwrap_or(false),
770                "votes": r.get::<_, i64>(12),
771                "my_vote": r.get::<_, Option<serde_json::Value>>(13),
772                // Three or more people, and still no two agree: for an admin.
773                "disputed": r.get::<_, i64>(12) >= 3 && r.get::<_, Option<String>>(7).as_deref() != Some("consensus"),
774            })
775        })
776        .collect();
777    Ok(Json(json!({ "photos": list, "staff": staff, "terms": PHOTO_TERMS })).into_response())
778}
779
780/// `GET /api/esl/photos/{id}/image`: a photo's bytes.
781pub async fn photo_image(State(ctx): State<Ctx>, _who: Who, Path(id): Path<i64>) -> ApiResult<Response> {
782    let c = ctx.db.conn().await?;
783    let r = c
784        .query_opt(&ctx.db.sql("SELECT image, mime FROM chess.board_photos WHERE id = $1"), &[&id])
785        .await?
786        .ok_or_else(|| ApiError::not_found("photo"))?;
787    let (bytes, mime): (Vec<u8>, String) = (r.get(0), r.get(1));
788    Ok((
789        [
790            (axum::http::header::CONTENT_TYPE, mime),
791            (axum::http::header::CACHE_CONTROL, "private, max-age=3600".into()),
792        ],
793        bytes,
794    )
795        .into_response())
796}
797
798#[derive(Debug, Deserialize)]
799pub struct PhotoLabel {
800    /// FEN piece placement from White's side (the first field is used).
801    #[serde(default)]
802    placement: Option<String>,
803    /// Not a board, or not one anybody could read.
804    #[serde(default)]
805    unreadable: bool,
806}
807
808/// `POST /api/esl/photos/{id}/label` (JSON `{placement}` or
809/// `{unreadable: true}`): what I say the photo shows. Anyone signed in may
810/// answer; two agreeing answers label it. An admin's or manager's answer is
811/// the label at once, whatever others said. A labelled position joins the
812/// dictionary (each side to move: a photo doesn't say).
813pub async fn label_photo(
814    State(ctx): State<Ctx>,
815    who: Who,
816    Path(id): Path<i64>,
817    Json(b): Json<PhotoLabel>,
818) -> ApiResult<Response> {
819    let player = who.0.player;
820    let grid = match (&b.placement, b.unreadable) {
821        (_, true) => None,
822        (Some(p), false) => Some(
823            chess_analysis::photo::parse_placement(p.split_whitespace().next().unwrap_or(""))
824                .ok_or_else(|| ApiError::bad("that isn't a board: eight ranks of eight squares"))?,
825        ),
826        (None, false) => return Err(ApiError::bad("say what the board shows, or mark it unreadable")),
827    };
828    let placement = grid.as_ref().map(chess_analysis::photo::placement);
829    let c = ctx.db.conn().await?;
830    let exists = c.query_opt(&ctx.db.sql("SELECT 1 FROM chess.board_photos WHERE id = $1"), &[&id]).await?;
831    if exists.is_none() {
832        return Err(ApiError::not_found("photo"));
833    }
834    vote(&ctx, &*c, id, player, placement.as_deref(), b.unreadable).await?;
835    if staff(&who) {
836        c.execute(
837            &ctx.db.sql(
838                "UPDATE chess.board_photos SET label = $2, unreadable = $3, labelled_by = $4, labelled_at = now(),
839                        label_source = CASE WHEN $3 THEN NULL ELSE 'admin' END
840                  WHERE id = $1",
841            ),
842            &[&id, &placement, &b.unreadable, &player],
843        )
844        .await?;
845    }
846    let row = c
847        .query_one(
848            &ctx.db.sql("SELECT label, label_source, unreadable FROM chess.board_photos WHERE id = $1"),
849            &[&id],
850        )
851        .await?;
852    let settled =
853        matches!(row.get::<_, Option<String>>(1).as_deref(), Some("consensus" | "admin" | "reviewer"))
854            || row.get::<_, bool>(2);
855    if let (Some(grid), true) = (&grid, settled && row.get::<_, Option<String>>(0) == placement) {
856        let games = chess_analysis::dictionary::photo_positions(grid, None);
857        if let Err(e) = queue_photo_positions(&ctx.db, &*c, &games).await {
858            tracing::warn!("queueing a photo's position: {e:#}");
859        }
860    }
861    Ok(Json(json!({ "ok": true, "settled": settled, "label": row.get::<_, Option<String>>(0) }))
862        .into_response())
863}