feat(4636): rule pull-through per surface, and per-rule usage with confirmed lessons on list_rules
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 22s

retrieval_telemetry gains rule_usage.by_source, the twin of usage.by_source:
distinct rules each surface showed, how many an agent opened, and a ratio
that is null on a bulk delivery. list_rules(with_usage=True, days=N) adds
each rule's usage, windowed, and how many lessons are confirmed instances
of it, so linked and unlinked rules can be compared through MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 19:20:32 -04:00
co-authored by Claude Opus 5.5
parent 71260a28e2
commit d05d4f3632
9 changed files with 287 additions and 9 deletions
+12 -2
View File
@@ -59,8 +59,9 @@ as short and as slow-moving as possible.
from __future__ import annotations
import logging
from datetime import datetime
from sqlalchemy import case, func, select
from sqlalchemy import case, func, select, true
from grimoire.models import async_session
from grimoire.models.base import iso
@@ -333,13 +334,18 @@ def empty_rule_usage() -> dict:
}
async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
async def usage_for_rules(
rule_ids: list[int], since: datetime | None = None,
) -> dict[int, dict]:
"""Aggregate usage for a set of rules: {rule_id: {counts + timestamps}}.
One GROUP BY for the whole page rather than a query per row — this feeds a
list view, so the per-row shape would be N+1 by construction. Rules with no
events come back with `empty_rule_usage()`, so the caller never has to tell
"no events" from "not in the result".
`since` limits the counts to events at or after it, so two windows can be
compared; without it they cover everything recorded.
"""
ids = [int(r) for r in rule_ids]
out: dict[int, dict] = {rid: empty_rule_usage() for rid in ids}
@@ -370,6 +376,10 @@ async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
ambient,
)
.where(RuleUsageEvent.rule_id.in_(ids))
.where(
RuleUsageEvent.created_at >= since
if since is not None else true()
)
.group_by(
RuleUsageEvent.rule_id,
RuleUsageEvent.event,