200 lines
5.9 KiB
Elixir
200 lines
5.9 KiB
Elixir
|
|
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\":<int 0..9999>,\"reason\":\"<max 18 words>\"}\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
|