Files
EventHubBack/test/unit/logic_ai_metrics_tests.erl
aleksey f4d89a262c
CI / test (push) Successful in 7m53s
CI / deploy-ift (push) Successful in 2m41s
CI / e2e-ift (push) Successful in 1m20s
CI / deploy-stage (push) Successful in 2m16s
CI / e2e-stage (push) Successful in 1m13s
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
2026-08-20 18:35:29 +03:00

76 lines
3.3 KiB
Erlang

%%%-------------------------------------------------------------------
%%% 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)).