From f4d89a262cfbbe75e8b768bf0e788d531770a4bd Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Thu, 20 Aug 2026 18:35:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=D0=BF=D0=B8=D0=BB=D0=BE=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=98=D0=98-=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BA=20=E2=80=94=20POST=20/v1/ai/hint-metrics=20+=20?= =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D1=81=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канон 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 --- include/records.hrl | 12 ++ src/eventhub_app.erl | 3 + .../admin/admin_handler_ai_metrics.erl | 38 ++++ src/handlers/handler_ai_metrics.erl | 63 ++++++ src/infra/migration_engine.erl | 3 +- src/logic/logic_ai_metrics.erl | 201 ++++++++++++++++++ .../20260820180000_ai_hint_metric.erl | 37 ++++ src/swagger/eventhub_trails.erl | 3 + test/unit/eh_test_support.erl | 7 +- test/unit/logic_ai_metrics_tests.erl | 75 +++++++ test/unit/migration_engine_tests.erl | 3 +- 11 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 src/handlers/admin/admin_handler_ai_metrics.erl create mode 100644 src/handlers/handler_ai_metrics.erl create mode 100644 src/logic/logic_ai_metrics.erl create mode 100644 src/migrations/20260820180000_ai_hint_metric.erl create mode 100644 test/unit/logic_ai_metrics_tests.erl diff --git a/include/records.hrl b/include/records.hrl index 3a3e32f..fd99c91 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -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() }). \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 6aea888..38575bc 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -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, []}, diff --git a/src/handlers/admin/admin_handler_ai_metrics.erl b/src/handlers/admin/admin_handler_ai_metrics.erl new file mode 100644 index 0000000..bbfe199 --- /dev/null +++ b/src/handlers/admin/admin_handler_ai_metrics.erl @@ -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. diff --git a/src/handlers/handler_ai_metrics.erl b/src/handlers/handler_ai_metrics.erl new file mode 100644 index 0000000..fafb1dc --- /dev/null +++ b/src/handlers/handler_ai_metrics.erl @@ -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. diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 4b03ef7..07a39f2 100755 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -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' ]). %% ------------------------------ diff --git a/src/logic/logic_ai_metrics.erl b/src/logic/logic_ai_metrics.erl new file mode 100644 index 0000000..13f37a4 --- /dev/null +++ b/src/logic/logic_ai_metrics.erl @@ -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) -> + <> = 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. diff --git a/src/migrations/20260820180000_ai_hint_metric.erl b/src/migrations/20260820180000_ai_hint_metric.erl new file mode 100644 index 0000000..7405546 --- /dev/null +++ b/src/migrations/20260820180000_ai_hint_metric.erl @@ -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. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 87b6c83..c27306b 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -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, diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index 2ac51e8..3d4d8ed 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -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 diff --git a/test/unit/logic_ai_metrics_tests.erl b/test/unit/logic_ai_metrics_tests.erl new file mode 100644 index 0000000..1ac9187 --- /dev/null +++ b/test/unit/logic_ai_metrics_tests.erl @@ -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)). diff --git a/test/unit/migration_engine_tests.erl b/test/unit/migration_engine_tests.erl index 2bd02c9..2aa6f16 100755 --- a/test/unit/migration_engine_tests.erl +++ b/test/unit/migration_engine_tests.erl @@ -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() ->