feat(ai): пилотные метрики ИИ-подсказок — POST /v1/ai/hint-metrics + админ-сводка
Канон 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
This commit is contained in:
@@ -417,4 +417,16 @@
|
||||
-record(schema_migration, {
|
||||
version :: string(),
|
||||
applied_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22): дневные счётчики
|
||||
%% shown/confirm/dismiss по паттерну. Id = {user_id, day, pattern_key, kind}.
|
||||
-record(ai_hint_metric, {
|
||||
id :: {binary(), binary(), binary(), binary()},
|
||||
user_id :: binary(),
|
||||
day :: binary(),
|
||||
pattern_key :: binary(),
|
||||
kind :: binary(),
|
||||
count :: non_neg_integer(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
@@ -111,6 +111,7 @@ start_http() ->
|
||||
{"/v1/geo/suggest", handler_geo, []},
|
||||
{"/v1/geo/geocode", handler_geo, []},
|
||||
{"/v1/geo/reverse", handler_geo, []},
|
||||
{"/v1/ai/hint-metrics", handler_ai_metrics, []},
|
||||
{"/v1/calendars", handler_calendars, []},
|
||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
|
||||
@@ -207,6 +208,8 @@ start_admin_http() ->
|
||||
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
||||
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
||||
% ================== ИИ-МЕТРИКИ ==================
|
||||
{"/v1/admin/ai-metrics/stats", admin_handler_ai_metrics, []},
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
{"/v1/admin/me", admin_handler_me, []},
|
||||
{"/v1/admin/admins", admin_handler_admins, []},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET /v1/admin/ai-metrics/stats — сводка пилота ИИ-подсказок
|
||||
%%% (PRODUCT-AI, Spec#22): CTR-счётчики, гейт-доля активных с >=2
|
||||
%%% confirm за 30 дней, топ паттернов.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_ai_metrics).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_stats(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[ #{
|
||||
path => <<"/v1/admin/ai-metrics/stats">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"AI hint pilot summary (PRODUCT-AI gates)">>,
|
||||
tags => [<<"Statistics">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"AI hint pilot statistics">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
} ].
|
||||
|
||||
get_stats(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
handler_utils:send_json(Req1, 200, logic_ai_metrics:summary());
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -0,0 +1,63 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/ai/hint-metrics — пилотные метрики ИИ-подсказок
|
||||
%%% (PRODUCT-AI, Spec#22). Bearer required.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_ai_metrics).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/ai/hint-metrics">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"AI hint pilot metrics (shown/confirm/dismiss counters). Bearer required.">>,
|
||||
tags => [<<"AI">>],
|
||||
responses => #{
|
||||
202 => #{description => <<"accepted">>},
|
||||
400 => #{description => <<"invalid_body">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
429 => #{description => <<"rate_limited">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
post(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
case decode_body(Body) of
|
||||
{ok, Events} ->
|
||||
case logic_ai_metrics:record_events(UserId, Events) of
|
||||
{ok, Count} ->
|
||||
handler_utils:send_json(Req2, 202, #{<<"accepted">> => Count});
|
||||
{error, invalid_body} ->
|
||||
handler_utils:send_error(Req2, 400, <<"invalid_body">>);
|
||||
{error, rate_limited} ->
|
||||
handler_utils:send_error(Req2, 429, <<"rate_limited">>)
|
||||
end;
|
||||
error ->
|
||||
handler_utils:send_error(Req2, 400, <<"invalid_body">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
decode_body(Body) when is_binary(Body) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"events">> := Events} when is_list(Events) -> {ok, Events};
|
||||
_ -> error
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
@@ -33,7 +33,8 @@
|
||||
'20260815200000_push_subscription',
|
||||
'20260815220000_calendar_share_invite',
|
||||
'20260816180000_auth_session_device',
|
||||
'20260817140000_month_snapshot'
|
||||
'20260817140000_month_snapshot',
|
||||
'20260820180000_ai_hint_metric'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @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.
|
||||
@@ -0,0 +1,37 @@
|
||||
%% @doc Create ai_hint_metric table — пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22).
|
||||
-module('20260820180000_ai_hint_metric').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_table(ai_hint_metric, record_info(fields, ai_hint_metric)),
|
||||
ensure_index(ai_hint_metric, user_id),
|
||||
ensure_index(ai_hint_metric, day),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
_ = mnesia:delete_table(ai_hint_metric),
|
||||
ok.
|
||||
|
||||
ensure_table(Table, Attrs) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_index(Table, Attr) ->
|
||||
case mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||
end.
|
||||
@@ -49,6 +49,8 @@ admin() ->
|
||||
admin_handler_subscriptions,
|
||||
admin_handler_subscription_stats,
|
||||
admin_handler_subscriptions_by_id,
|
||||
% ================== ИИ-МЕТРИКИ ==================
|
||||
admin_handler_ai_metrics,
|
||||
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
||||
admin_handler_moderation,
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
@@ -94,6 +96,7 @@ user() ->
|
||||
handler_reviews,
|
||||
handler_search,
|
||||
handler_geo,
|
||||
handler_ai_metrics,
|
||||
handler_subscription,
|
||||
handler_ticket_by_id,
|
||||
handler_tickets,
|
||||
|
||||
@@ -100,7 +100,8 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
||||
{auth_session, [family_id, subject_id]},
|
||||
{report, [resolved_by]},
|
||||
{ticket, [assigned_to]},
|
||||
{month_snapshot, [calendar_id, year_month]}
|
||||
{month_snapshot, [calendar_id, year_month]},
|
||||
{ai_hint_metric, [user_id, day]}
|
||||
]),
|
||||
ok.
|
||||
|
||||
@@ -207,7 +208,9 @@ table_opts(node_metric) ->
|
||||
table_opts(schema_migration) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
table_opts(month_snapshot) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}].
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
|
||||
table_opts(ai_hint_metric) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, ai_hint_metric)}].
|
||||
|
||||
%%%===================================================================
|
||||
%%% Domain seeds
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% EUnit: пилотные метрики ИИ-подсказок (logic_ai_metrics).
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_ai_metrics_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [ai_hint_metric]).
|
||||
|
||||
setup() ->
|
||||
catch ets:delete(eventhub_ai_metrics_rl),
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
catch ets:delete(eventhub_ai_metrics_rl),
|
||||
ok.
|
||||
|
||||
logic_ai_metrics_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"invalid body rejected", fun test_invalid_body/0},
|
||||
{"unknown kind rejected", fun test_unknown_kind/0},
|
||||
{"counters increment per key", fun test_increment/0},
|
||||
{"batch over limit rejected", fun test_batch_limit/0},
|
||||
{"summary counts kinds and gate ratio", fun test_summary/0}
|
||||
]}.
|
||||
|
||||
event(Kind, Pattern) ->
|
||||
#{<<"kind">> => Kind, <<"pattern">> => Pattern}.
|
||||
|
||||
test_invalid_body() ->
|
||||
?assertEqual({error, invalid_body}, logic_ai_metrics:record_events(<<"u1">>, [])),
|
||||
?assertEqual({error, invalid_body}, logic_ai_metrics:record_events(<<"u1">>, not_a_list)),
|
||||
?assertEqual({error, invalid_body},
|
||||
logic_ai_metrics:record_events(<<"u1">>, [#{<<"kind">> => <<"shown">>}])).
|
||||
|
||||
test_unknown_kind() ->
|
||||
?assertEqual({error, invalid_body},
|
||||
logic_ai_metrics:record_events(<<"u1">>, [event(<<"click">>, <<"p">>)])),
|
||||
?assertEqual({error, invalid_body},
|
||||
logic_ai_metrics:record_events(<<"u1">>, [event(<<"shown">>, <<>>)])).
|
||||
|
||||
test_increment() ->
|
||||
{ok, 2} = logic_ai_metrics:record_events(<<"u1">>,
|
||||
[event(<<"shown">>, <<"cal|2|10">>), event(<<"confirm">>, <<"cal|2|10">>)]),
|
||||
{ok, 1} = logic_ai_metrics:record_events(<<"u1">>, [event(<<"shown">>, <<"cal|2|10">>)]),
|
||||
[Row] = mnesia:dirty_match_object(#ai_hint_metric{
|
||||
id = {<<"u1">>, '_', <<"cal|2|10">>, <<"shown">>}, _ = '_'}),
|
||||
?assertEqual(2, Row#ai_hint_metric.count),
|
||||
[_] = mnesia:dirty_match_object(#ai_hint_metric{
|
||||
id = {<<"u1">>, '_', <<"cal|2|10">>, <<"confirm">>}, _ = '_'}).
|
||||
|
||||
test_batch_limit() ->
|
||||
Events = [event(<<"shown">>, iolist_to_binary(["p", integer_to_list(N)]))
|
||||
|| N <- lists:seq(1, 21)],
|
||||
?assertEqual({error, invalid_body}, logic_ai_metrics:record_events(<<"u1">>, Events)).
|
||||
|
||||
test_summary() ->
|
||||
{ok, _} = logic_ai_metrics:record_events(<<"u1">>,
|
||||
[event(<<"shown">>, <<"a">>), event(<<"confirm">>, <<"a">>),
|
||||
event(<<"confirm">>, <<"a">>), event(<<"dismiss">>, <<"b">>)]),
|
||||
{ok, _} = logic_ai_metrics:record_events(<<"u2">>,
|
||||
[event(<<"shown">>, <<"a">>), event(<<"confirm">>, <<"a">>)]),
|
||||
S = logic_ai_metrics:summary(),
|
||||
?assertEqual(#{<<"shown">> => 2, <<"confirm">> => 3, <<"dismiss">> => 1},
|
||||
maps:from_list(maps:to_list(maps:get(<<"by_kind">>, S)))),
|
||||
?assertEqual(2, maps:get(<<"active_users_30d">>, S)),
|
||||
?assertEqual(1, maps:get(<<"users_with_confirms_ge2_30d">>, S)),
|
||||
?assertEqual(0.5, maps:get(<<"gate_ratio_30d">>, S)),
|
||||
[Top | _] = maps:get(<<"top_patterns_by_confirm">>, S),
|
||||
?assertEqual(<<"a">>, maps:get(<<"pattern">>, Top)),
|
||||
?assertEqual(3, maps:get(<<"confirms">>, Top)).
|
||||
@@ -19,7 +19,8 @@
|
||||
"20260815200000_push_subscription",
|
||||
"20260815220000_calendar_share_invite",
|
||||
"20260816180000_auth_session_device",
|
||||
"20260817140000_month_snapshot"
|
||||
"20260817140000_month_snapshot",
|
||||
"20260820180000_ai_hint_metric"
|
||||
]).
|
||||
|
||||
setup() ->
|
||||
|
||||
Reference in New Issue
Block a user