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:
@@ -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