defmodule ShootingEventPhx.AI.Gemini do @moduledoc false def generate_score_advice(stage, player_id, image_data, current_score) do with api_key when is_binary(api_key) and api_key != "" <- gemini_api_key(), {:ok, mime_type, raw_base64} <- parse_data_uri(image_data), {:ok, body} <- build_request_body(stage, current_score, mime_type, raw_base64), {:ok, response} <- request_gemini(body, api_key), {:ok, model_response} <- parse_gemini_response(response) do {:ok, %{ "stage" => stage, "playerId" => player_id, "advisedScore" => clamp(model_response["advisedScore"] || 0, 0, 9999), "reason" => normalize_reason(model_response["reason"]), "generatedAt" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() }} else nil -> {:error, :service_unavailable, "gemini api key is not configured"} {:error, message} when is_binary(message) -> {:error, :bad_gateway, message} {:error, status, message} -> {:error, status, message} _ -> {:error, :bad_gateway, "failed to generate score advice"} end end defp gemini_api_key do System.get_env("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc") |> String.trim() |> case do "" -> nil v -> v end end defp gemini_model do System.get_env("GEMINI_MODEL", "gemini-3.1-flash-lite-preview") |> String.trim() |> case do "" -> "gemini-3.1-flash-lite-preview" v -> v end end defp parse_data_uri(data_uri) do value = data_uri |> to_string() |> String.trim() if String.starts_with?(value, "data:") do case String.split(value, ",", parts: 2) do [header, payload] -> payload = String.trim(payload) if payload == "" do {:error, "invalid proof image data: empty payload"} else mime_type = case String.split(header, ";", parts: 2) do ["data:" <> mime, _] when mime != "" -> mime _ -> "image/jpeg" end {:ok, mime_type, payload} end _ -> {:error, "invalid proof image data: invalid data uri format"} end else {:error, "invalid proof image data: expected data uri"} end end defp build_request_body(stage, current_score, mime_type, raw_base64) do request = %{ "contents" => [ %{ "role" => "user", "parts" => [ %{"text" => score_advice_prompt(stage, current_score)}, %{"inline_data" => %{"mime_type" => mime_type, "data" => raw_base64}} ] } ], "generationConfig" => %{ "temperature" => 0, "responseMimeType" => "application/json", "maxOutputTokens" => 100 } } {:ok, Jason.encode!(request)} end defp request_gemini(body, api_key) do endpoint = "https://generativelanguage.googleapis.com/v1beta/models/#{URI.encode(gemini_model())}:generateContent?key=#{URI.encode_www_form(api_key)}" case Req.post(endpoint, headers: [{"content-type", "application/json"}], body: body, receive_timeout: 25_000 ) do {:ok, %Req.Response{status: status, body: response_body}} when status < 300 -> {:ok, response_body} {:ok, %Req.Response{status: status, body: response_body}} -> {:error, "gemini api status #{status}: #{inspect(response_body)}"} {:error, reason} -> {:error, "call gemini api: #{inspect(reason)}"} end end defp parse_gemini_response(response_body) when is_map(response_body) do cond do is_map(response_body["error"]) and is_binary(response_body["error"]["message"]) -> {:error, "gemini api error: #{response_body["error"]["message"]}"} true -> text = get_in(response_body, [ "candidates", Access.at(0), "content", "parts", Access.at(0), "text" ]) |> to_string() |> String.trim() json_text = extract_json_object(text) if json_text == "" do {:error, "gemini response was not valid json"} else case Jason.decode(json_text) do {:ok, parsed} -> {:ok, parsed} {:error, reason} -> {:error, "parse gemini advice json: #{inspect(reason)}"} end end end end defp parse_gemini_response(_), do: {:error, "decode gemini response: unexpected payload"} defp extract_json_object(raw) do text = raw |> String.trim() |> String.trim_leading("```json") |> String.trim_leading("```") |> String.trim_trailing("```") |> String.trim() start_idx = binary_index(text, "{") end_idx = binary_rindex(text, "}") if is_integer(start_idx) and is_integer(end_idx) and end_idx > start_idx do text |> String.slice(start_idx..end_idx) |> String.trim() else "" end end defp score_advice_prompt(stage, current_score) do "Target scoring assistant.\nStage: #{stage}. Current score: #{current_score}.\n" <> "Return STRICT JSON only:\n{\"advisedScore\":,\"reason\":\"\"}\n" <> "Do not add markdown or extra fields." end defp normalize_reason(reason) do value = reason |> to_string() |> String.trim() if value == "", do: "AI estimated the score from visible impacts.", else: value end defp clamp(value, min, _max) when value < min, do: min defp clamp(value, _min, max) when value > max, do: max defp clamp(value, _min, _max), do: value defp binary_index(text, pattern) do case :binary.match(text, pattern) do :nomatch -> nil {idx, _len} -> idx end end defp binary_rindex(text, pattern) do text |> :binary.matches(pattern) |> List.last() |> case do nil -> nil {idx, _len} -> idx end end end