f4d89a262c
Канон PRODUCT-AI (Spec#22): пилотные метрики L1-подсказок для расчёта
голосового гейта L2 (>=20% активных пользователей с >=2 подтверждённых
ghost-бронирований в месяц). Локальные счётчики в Mnesia, без LLM.
- таблица ai_hint_metric (миграция 20260820180000): счётчики
{user, день UTC, pattern, kind}; ленивый prune при >500 строк на
пользователя, retention 92 дня
- POST /v1/ai/hint-metrics (Bearer): батч <=20 событий shown/confirm/dismiss,
202 {accepted:N}; ETS rate limit 30 req/min на пользователя
- GET /v1/admin/ai-metrics/stats (админ-порт): by_kind, active_users_30d,
users_with_confirms_ge2_30d, gate_ratio_30d, top_patterns_by_confirm
- eunit logic_ai_metrics_tests (5/5); синхронизация списка миграций
в migration_engine_tests
Refs EventHub/EventHubBack#78
202 lines
7.1 KiB
Erlang
202 lines
7.1 KiB
Erlang
%%%-------------------------------------------------------------------
|
|
%%% @doc Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22).
|
|
%%%
|
|
%%% Front присылает счётчики shown/confirm/dismiss по паттерну подсказки;
|
|
%%% Back агрегирует их по дням (таблица ai_hint_metric) и отдаёт админам
|
|
%%% сводку для решения о старте L2 (гейты канона: hint CTR и доля
|
|
%%% активных с >=2 подтверждёнными ghost-записями за месяц).
|
|
%%%
|
|
%%% Данные привязаны к opt-in: клиент шлёт метрики только при включённых
|
|
%%% подсказках (preferences.ai_hints).
|
|
%%% @end
|
|
%%%-------------------------------------------------------------------
|
|
-module(logic_ai_metrics).
|
|
|
|
-export([record_events/2, summary/0]).
|
|
|
|
-include("records.hrl").
|
|
|
|
-define(TABLE, ai_hint_metric).
|
|
-define(MAX_BATCH, 20).
|
|
-define(MAX_PATTERN, 96).
|
|
-define(RETENTION_DAYS, 92).
|
|
-define(PRUNE_ROWS_PER_USER, 500).
|
|
-define(RL_TABLE, eventhub_ai_metrics_rl).
|
|
-define(RL_LIMIT, 30). % запросов на пользователя
|
|
-define(RL_WINDOW_MS, 60000).
|
|
|
|
%%%===================================================================
|
|
%%% Запись событий
|
|
%%%===================================================================
|
|
|
|
-spec record_events(binary(), [map()]) ->
|
|
{ok, non_neg_integer()} | {error, invalid_body | rate_limited}.
|
|
record_events(UserId, Events) when is_binary(UserId), is_list(Events),
|
|
Events =/= [], length(Events) =< ?MAX_BATCH ->
|
|
case allow(UserId) of
|
|
false ->
|
|
{error, rate_limited};
|
|
true ->
|
|
Day = today(),
|
|
case normalize_events(Events, []) of
|
|
{ok, Norm} ->
|
|
{atomic, Count} = mnesia:transaction(fun() ->
|
|
lists:foreach(fun({Kind, Pattern}) ->
|
|
increment(UserId, Day, Pattern, Kind)
|
|
end, Norm),
|
|
maybe_prune(UserId, Day),
|
|
length(Norm)
|
|
end),
|
|
{ok, Count};
|
|
error ->
|
|
{error, invalid_body}
|
|
end
|
|
end;
|
|
record_events(_UserId, _Events) ->
|
|
{error, invalid_body}.
|
|
|
|
increment(UserId, Day, Pattern, Kind) ->
|
|
Key = {UserId, Day, Pattern, Kind},
|
|
Row = case mnesia:read(?TABLE, Key, write) of
|
|
[Existing] -> Existing;
|
|
[] -> #ai_hint_metric{id = Key, user_id = UserId, day = Day,
|
|
pattern_key = Pattern, kind = Kind, count = 0}
|
|
end,
|
|
mnesia:write(Row#ai_hint_metric{
|
|
count = Row#ai_hint_metric.count + 1,
|
|
updated_at = calendar:universal_time()
|
|
}).
|
|
|
|
%% Ленивая чистка: если у пользователя накопилось много строк,
|
|
%% удаляем всё старше RETENTION_DAYS.
|
|
maybe_prune(UserId, Day) ->
|
|
Rows = mnesia:index_read(?TABLE, UserId, user_id),
|
|
case length(Rows) > ?PRUNE_ROWS_PER_USER of
|
|
true ->
|
|
Cutoff = shift_day(Day, -?RETENTION_DAYS),
|
|
lists:foreach(fun(#ai_hint_metric{day = D} = R) when D < Cutoff ->
|
|
mnesia:delete_object(R);
|
|
(_) -> ok
|
|
end, Rows);
|
|
false -> ok
|
|
end.
|
|
|
|
normalize_events([], Acc) ->
|
|
{ok, lists:reverse(Acc)};
|
|
normalize_events([E | Rest], Acc) when is_map(E) ->
|
|
Kind = maps:get(<<"kind">>, E, undefined),
|
|
Pattern = maps:get(<<"pattern">>, E, <<>>),
|
|
case valid_kind(Kind) andalso valid_pattern(Pattern) of
|
|
true -> normalize_events(Rest, [{Kind, Pattern} | Acc]);
|
|
false -> error
|
|
end;
|
|
normalize_events(_, _) ->
|
|
error.
|
|
|
|
valid_kind(<<"shown">>) -> true;
|
|
valid_kind(<<"confirm">>) -> true;
|
|
valid_kind(<<"dismiss">>) -> true;
|
|
valid_kind(_) -> false.
|
|
|
|
valid_pattern(P) when is_binary(P), byte_size(P) > 0, byte_size(P) =< ?MAX_PATTERN -> true;
|
|
valid_pattern(_) -> false.
|
|
|
|
%%%===================================================================
|
|
%%% Сводка для админов
|
|
%%%===================================================================
|
|
|
|
-spec summary() -> map().
|
|
summary() ->
|
|
Today = today(),
|
|
MonthAgo = shift_day(Today, -30),
|
|
Init = #{
|
|
by_kind => #{},
|
|
active_30d => sets:new(),
|
|
confirms_by_user_30d => #{},
|
|
confirms_by_pattern => #{}
|
|
},
|
|
{atomic, Acc} = mnesia:transaction(fun() ->
|
|
mnesia:foldl(fun(R, A) -> fold_row(R, MonthAgo, A) end, Init, ?TABLE)
|
|
end),
|
|
ByKind = maps:get(by_kind, Acc),
|
|
Active = sets:size(maps:get(active_30d, Acc)),
|
|
ConfirmsByUser = maps:get(confirms_by_user_30d, Acc),
|
|
UsersGe2 = maps:size(maps:filter(fun(_, N) -> N >= 2 end, ConfirmsByUser)),
|
|
TopPatterns = lists:sublist(
|
|
lists:reverse(lists:sort(maps:to_list(maps:get(confirms_by_pattern, Acc)))), 10),
|
|
#{
|
|
<<"by_kind">> => ByKind,
|
|
<<"active_users_30d">> => Active,
|
|
<<"users_with_confirms_ge2_30d">> => UsersGe2,
|
|
<<"gate_ratio_30d">> => ratio(UsersGe2, Active),
|
|
<<"top_patterns_by_confirm">> =>
|
|
[#{<<"pattern">> => P, <<"confirms">> => N} || {P, N} <- TopPatterns],
|
|
<<"retention_days">> => ?RETENTION_DAYS
|
|
}.
|
|
|
|
fold_row(#ai_hint_metric{user_id = U, day = D, pattern_key = P, kind = K, count = N},
|
|
MonthAgo, Acc) ->
|
|
ByKind = maps:get(by_kind, Acc),
|
|
Acc1 = Acc#{by_kind => maps:update_with(K, fun(V) -> V + N end, N, ByKind)},
|
|
case D >= MonthAgo of
|
|
false -> Acc1;
|
|
true ->
|
|
Active = sets:add_element(U, maps:get(active_30d, Acc1)),
|
|
Acc2 = Acc1#{active_30d => Active},
|
|
Acc3 = case K of
|
|
<<"confirm">> ->
|
|
Cbu = maps:get(confirms_by_user_30d, Acc2),
|
|
Acc2#{confirms_by_user_30d => maps:update_with(U, fun(V) -> V + N end, N, Cbu)};
|
|
_ -> Acc2
|
|
end,
|
|
case K of
|
|
<<"confirm">> ->
|
|
Cbp = maps:get(confirms_by_pattern, Acc3),
|
|
Acc3#{confirms_by_pattern => maps:update_with(P, fun(V) -> V + N end, N, Cbp)};
|
|
_ -> Acc3
|
|
end
|
|
end.
|
|
|
|
ratio(_, 0) -> 0.0;
|
|
ratio(Part, Total) -> Part / Total.
|
|
|
|
%%%===================================================================
|
|
%%% Даты и rate-limit
|
|
%%%===================================================================
|
|
|
|
today() ->
|
|
{{Y, M, D}, _} = calendar:universal_time(),
|
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])).
|
|
|
|
shift_day(DayBin, Delta) ->
|
|
<<Y:4/binary, "-", M:2/binary, "-", D:2/binary>> = DayBin,
|
|
Date = {binary_to_integer(Y), binary_to_integer(M), binary_to_integer(D)},
|
|
Sec = calendar:datetime_to_gregorian_seconds({Date, {0, 0, 0}}) + Delta * 86400,
|
|
{{Y2, M2, D2}, _} = calendar:gregorian_seconds_to_datetime(Sec),
|
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y2, M2, D2])).
|
|
|
|
allow(UserId) ->
|
|
ensure_rl(),
|
|
Now = erlang:system_time(millisecond),
|
|
Key = {rl, UserId},
|
|
Result = case ets:lookup(?RL_TABLE, Key) of
|
|
[{_, WindowStart, Count}] when Now - WindowStart < ?RL_WINDOW_MS ->
|
|
Count < ?RL_LIMIT;
|
|
_ ->
|
|
true
|
|
end,
|
|
case Result of
|
|
true ->
|
|
ets:update_counter(?RL_TABLE, Key, {3, 1}, {Key, Now, 0}),
|
|
true;
|
|
false ->
|
|
false
|
|
end.
|
|
|
|
ensure_rl() ->
|
|
case ets:info(?RL_TABLE) of
|
|
undefined -> ets:new(?RL_TABLE, [named_table, public, set]);
|
|
_ -> ok
|
|
end,
|
|
ok.
|