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