diff --git a/config/config.exs b/config/config.exs index 1516071..6903bdb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -239,6 +239,7 @@ config :loopctl, Oban, {"0 3 * * 0", Loopctl.Workers.TokenDataArchivalWorker}, {"0 4 * * *", Loopctl.Workers.KnowledgeLintWorker, args: %{"mode" => "all_tenants"}}, {"0 5 * * 0", Loopctl.Workers.KnowledgeMocWorker, args: %{"mode" => "all_tenants"}}, + {"30 4 * * *", Loopctl.Workers.RetrievalMetricsWorker, args: %{"mode" => "all_tenants"}}, {"*/5 * * * *", Loopctl.Workers.PendingEnrollmentCleanupWorker}, {"* * * * *", Loopctl.Workers.ComputeSthWorker, args: %{"mode" => "all_tenants"}}, {"* * * * *", Loopctl.Workers.RevokeExpiredDispatchesWorker} diff --git a/lib/loopctl/knowledge/retrieval_metric_snapshot.ex b/lib/loopctl/knowledge/retrieval_metric_snapshot.ex new file mode 100644 index 0000000..0cdad50 --- /dev/null +++ b/lib/loopctl/knowledge/retrieval_metric_snapshot.ex @@ -0,0 +1,46 @@ +defmodule Loopctl.Knowledge.RetrievalMetricSnapshot do + @moduledoc """ + A daily retrieval-precision snapshot (agents' KB #3). `precision` is the share of a + day's search results the agent then opened (search → get/context within + `window_seconds`) — a mechanical proxy for retrieval quality, tracked over time. + + `tenant_id` is set programmatically, never cast. + """ + + use Loopctl.Schema + + @type t :: %__MODULE__{} + + schema "retrieval_metric_snapshots" do + tenant_field() + + field :day, :date + field :window_seconds, :integer + field :searched, :integer, default: 0 + field :followed_through, :integer, default: 0 + field :precision, :float, default: 0.0 + field :computed_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + @cast_fields [:day, :window_seconds, :searched, :followed_through, :precision, :computed_at] + + @doc "Changeset for a snapshot. `tenant_id` is set on the struct, not cast." + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(snapshot \\ %__MODULE__{}, attrs) do + snapshot + |> cast(attrs, @cast_fields) + |> validate_required([ + :day, + :window_seconds, + :searched, + :followed_through, + :precision, + :computed_at + ]) + |> unique_constraint([:tenant_id, :day, :window_seconds], + name: :retrieval_metric_snapshots_tenant_day_window_index + ) + end +end diff --git a/lib/loopctl/knowledge/retrieval_metrics.ex b/lib/loopctl/knowledge/retrieval_metrics.ex new file mode 100644 index 0000000..1939ba6 --- /dev/null +++ b/lib/loopctl/knowledge/retrieval_metrics.ex @@ -0,0 +1,135 @@ +defmodule Loopctl.Knowledge.RetrievalMetrics do + @moduledoc """ + Retrieval-precision metric (agents' KB #3) — closes the loop on whether retrieval is + actually improving. + + The signal: of the articles a search SURFACED on a given day, how many did the agent + then OPEN (a `get`/`context` on the same article, by the same api_key, within a + follow-through window)? That share is `precision`. It's a mechanical proxy, computed + purely from `article_access_events` — no LLM, no labels — and it should trend UP as + dedup (#1), navigation (#5) and conflict resolution (#4) make the corpus cleaner and + the top results more on-target. + + Honest caveat: it measures search → *open*, not search → *useful*. An agent that uses + a snippet without opening the article counts as a miss, so the absolute number + undercounts precision. The bias is consistent, so the TREND is the meaningful thing. + """ + + import Ecto.Query + + alias Loopctl.AdminRepo + alias Loopctl.Knowledge.ArticleAccessEvent + alias Loopctl.Knowledge.RetrievalMetricSnapshot + + @default_window_seconds 1800 + + @doc """ + Compute precision for a single `day` (a `Date`) and follow-through `window_seconds`. + Returns `%{searched, followed_through, precision, day, window_seconds}`. + """ + @spec compute(Ecto.UUID.t(), Date.t(), pos_integer()) :: map() + def compute(tenant_id, %Date{} = day, window_seconds \\ @default_window_seconds) do + day_start = DateTime.new!(day, ~T[00:00:00.000000], "Etc/UTC") + day_end = DateTime.add(day_start, 1, :day) + + searched_q = + from(s in ArticleAccessEvent, + as: :s, + where: s.tenant_id == ^tenant_id, + where: s.access_type == "search", + where: s.accessed_at >= ^day_start and s.accessed_at < ^day_end + ) + + searched = AdminRepo.aggregate(searched_q, :count, :id) + + followed = + searched_q + |> where( + [s], + exists( + from(o in ArticleAccessEvent, + where: + o.tenant_id == parent_as(:s).tenant_id and + o.api_key_id == parent_as(:s).api_key_id and + o.article_id == parent_as(:s).article_id and + o.access_type in ["get", "context"] and + o.accessed_at > parent_as(:s).accessed_at and + fragment( + "? <= ? + (? * interval '1 second')", + o.accessed_at, + parent_as(:s).accessed_at, + ^window_seconds + ) + ) + ) + ) + |> AdminRepo.aggregate(:count, :id) + + precision = if searched > 0, do: followed / searched, else: 0.0 + + %{ + day: day, + window_seconds: window_seconds, + searched: searched, + followed_through: followed, + precision: precision + } + end + + @doc """ + Compute a day's precision and upsert the snapshot (idempotent per tenant/day/window). + Returns `{:ok, %RetrievalMetricSnapshot{}}`. + """ + @spec snapshot(Ecto.UUID.t(), Date.t(), pos_integer()) :: + {:ok, RetrievalMetricSnapshot.t()} | {:error, Ecto.Changeset.t()} + def snapshot(tenant_id, %Date{} = day, window_seconds \\ @default_window_seconds) do + m = compute(tenant_id, day, window_seconds) + + attrs = %{ + day: m.day, + window_seconds: m.window_seconds, + searched: m.searched, + followed_through: m.followed_through, + precision: m.precision, + computed_at: DateTime.utc_now() + } + + %RetrievalMetricSnapshot{tenant_id: tenant_id} + |> RetrievalMetricSnapshot.changeset(attrs) + |> AdminRepo.insert( + on_conflict: + {:replace, [:searched, :followed_through, :precision, :computed_at, :updated_at]}, + conflict_target: [:tenant_id, :day, :window_seconds] + ) + end + + @doc """ + The precision time series, most recent day first. Opts: `:limit` (default 30), + `:offset`. Returns `%{data: [snapshot maps], meta: %{limit, offset, total_count}}`. + """ + @spec list_snapshots(Ecto.UUID.t(), keyword()) :: %{data: [map()], meta: map()} + def list_snapshots(tenant_id, opts \\ []) do + limit = opts |> Keyword.get(:limit, 30) |> max(1) |> min(365) + offset = opts |> Keyword.get(:offset, 0) |> max(0) + + base = from(s in RetrievalMetricSnapshot, where: s.tenant_id == ^tenant_id) + total_count = AdminRepo.aggregate(base, :count, :id) + + data = + from(s in base, + order_by: [desc: s.day, desc: s.window_seconds], + limit: ^limit, + offset: ^offset, + select: %{ + day: s.day, + window_seconds: s.window_seconds, + searched: s.searched, + followed_through: s.followed_through, + precision: s.precision + } + ) + |> AdminRepo.all() + + %{data: data, meta: %{limit: limit, offset: offset, total_count: total_count}} + end +end diff --git a/lib/loopctl/workers/retrieval_metrics_worker.ex b/lib/loopctl/workers/retrieval_metrics_worker.ex new file mode 100644 index 0000000..824f94e --- /dev/null +++ b/lib/loopctl/workers/retrieval_metrics_worker.ex @@ -0,0 +1,65 @@ +defmodule Loopctl.Workers.RetrievalMetricsWorker do + @moduledoc """ + Daily snapshot of retrieval precision (agents' KB #3). Fans out over active tenants and + records yesterday's `RetrievalMetrics.snapshot/3` — the share of surfaced search results + the agent then opened. Additive/idempotent (upsert per tenant/day/window); computes the + previous FULL day so the window is complete. + + Scheduled daily via the Oban Cron plugin. + """ + + use Oban.Worker, + queue: :knowledge, + max_attempts: 3, + unique: [fields: [:worker, :args], period: 300] + + require Logger + + import Ecto.Query + + alias Loopctl.AdminRepo + alias Loopctl.Knowledge.RetrievalMetrics + alias Loopctl.Tenants.Tenant + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"mode" => "all_tenants"}}) do + from(t in Tenant, where: t.status == :active, select: t.id) + |> AdminRepo.all() + |> Enum.each(fn tenant_id -> + %{"tenant_id" => tenant_id} |> __MODULE__.new() |> Oban.insert() + end) + + :ok + end + + def perform(%Oban.Job{args: %{"tenant_id" => tenant_id} = args}) do + day = day_arg(args) + + case RetrievalMetrics.snapshot(tenant_id, day) do + {:ok, snap} -> + Logger.info( + "RetrievalMetricsWorker: tenant=#{tenant_id} day=#{day} " <> + "searched=#{snap.searched} followed=#{snap.followed_through} " <> + "precision=#{Float.round(snap.precision, 3)}" + ) + + :ok + + {:error, reason} -> + {:error, reason} + end + end + + # Default: yesterday (the last complete UTC day). An explicit "day" arg (ISO8601) + # allows backfilling a specific day. + defp day_arg(%{"day" => iso}) when is_binary(iso) do + case Date.from_iso8601(iso) do + {:ok, d} -> d + _ -> yesterday() + end + end + + defp day_arg(_), do: yesterday() + + defp yesterday, do: Date.add(DateTime.utc_now() |> DateTime.to_date(), -1) +end diff --git a/lib/loopctl_web/controllers/knowledge_analytics_controller.ex b/lib/loopctl_web/controllers/knowledge_analytics_controller.ex index f44e63e..43f07a0 100644 --- a/lib/loopctl_web/controllers/knowledge_analytics_controller.ex +++ b/lib/loopctl_web/controllers/knowledge_analytics_controller.ex @@ -17,6 +17,7 @@ defmodule LoopctlWeb.KnowledgeAnalyticsController do alias Loopctl.ApiSpec.Schemas alias Loopctl.Knowledge + alias Loopctl.Knowledge.RetrievalMetrics action_fallback LoopctlWeb.FallbackController @@ -280,6 +281,47 @@ defmodule LoopctlWeb.KnowledgeAnalyticsController do json(conn, LoopctlWeb.KnowledgeAnalyticsJSON.unused_articles(rows, opts)) end + operation(:retrieval_metrics, + summary: "Retrieval precision time series", + description: + "Daily retrieval PRECISION (agents' KB #3): the share of a day's search results the " <> + "agent then opened (search → get/context within a window). A proxy for retrieval " <> + "quality that trends up as the corpus is de-duplicated and better navigated. Most " <> + "recent day first. Role: orchestrator+.", + parameters: [ + limit: [ + in: :query, + type: :integer, + description: "Days per page (default 30, max 365). Clamped, never rejected.", + required: false + ], + offset: [ + in: :query, + type: :integer, + description: "Days to skip (default 0)", + required: false + ] + ], + responses: %{ + 200 => + {"Retrieval metrics", "application/json", + %OpenApiSpex.Schema{type: :object, additionalProperties: true}}, + 429 => {"Rate limit exceeded", "application/json", Schemas.RateLimitError} + } + ) + + @doc "GET /api/v1/knowledge/analytics/retrieval-metrics" + def retrieval_metrics(conn, params) do + tenant_id = conn.assigns.current_api_key.tenant_id + + opts = + [] + |> put_limit(params["limit"], 30, 365) + |> put_offset(params["offset"]) + + json(conn, RetrievalMetrics.list_snapshots(tenant_id, opts)) + end + # --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- diff --git a/lib/loopctl_web/router.ex b/lib/loopctl_web/router.ex index ac81627..6da7108 100644 --- a/lib/loopctl_web/router.ex +++ b/lib/loopctl_web/router.ex @@ -348,6 +348,10 @@ defmodule LoopctlWeb.Router do KnowledgeAnalyticsController, :unused_articles + get "/knowledge/analytics/retrieval-metrics", + KnowledgeAnalyticsController, + :retrieval_metrics + get "/knowledge/analytics/agents/:agent_id", KnowledgeAnalyticsController, :agent_usage diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index 263aace..1aab1f3 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to `loopctl-mcp-server` are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## 2.29.0 — 2026-07-01 (retrieval precision metric) + +### Added + +- **`knowledge_retrieval_metrics`** — the daily retrieval-precision time series (agents' + KB #3): per day, the share of search results the agent then opened (search → + get/context within a window). A mechanical proxy for whether retrieval is improving; + trends up as the corpus is de-duplicated, better navigated, and conflict-resolved. + Orchestrator role. + ## 2.28.0 — 2026-07-01 (route-the-findings: conflict merge) ### Changed diff --git a/mcp-server/index.js b/mcp-server/index.js index 8182134..91a948a 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -1151,6 +1151,18 @@ async function knowledgeAnalyticsTop({ limit, offset, since_days, access_type } return toContent(result); } +async function knowledgeRetrievalMetrics({ limit, offset } = {}) { + const params = new URLSearchParams(); + if (limit != null) params.set("limit", String(limit)); + if (offset != null) params.set("offset", String(offset)); + const qs = params.toString(); + const path = qs + ? `/api/v1/knowledge/analytics/retrieval-metrics?${qs}` + : "/api/v1/knowledge/analytics/retrieval-metrics"; + const result = await apiCall("GET", path, null, process.env.LOOPCTL_ORCH_KEY); + return toContent(result); +} + async function knowledgeArticleStats({ article_id }) { const result = await apiCall( "GET", @@ -3254,6 +3266,32 @@ const TOOLS = [ }, // Knowledge Analytics Tools (orchestrator key) + { + name: "knowledge_retrieval_metrics", + description: + "Return the daily retrieval-PRECISION time series (agents' KB #3): for each day, the " + + "share of search results the agent then opened (search → get/context within a window). " + + "A proxy for whether retrieval is improving — watch it trend up as the corpus is " + + "de-duplicated, better navigated (MOCs), and conflict-resolved. Most recent day first. " + + "Requires orchestrator role.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "integer", + description: "Days per page (default 30, max 365). Clamped, never rejected.", + minimum: 1, + maximum: 365, + }, + offset: { + type: "integer", + description: "Days to skip. Default 0.", + minimum: 0, + }, + }, + required: [], + }, + }, { name: "knowledge_analytics_top", description: @@ -3670,6 +3708,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await knowledgeIngestionJobs(); // Knowledge Analytics Tools + case "knowledge_retrieval_metrics": + return await knowledgeRetrievalMetrics(args); + case "knowledge_analytics_top": return await knowledgeAnalyticsTop(args); diff --git a/mcp-server/package.json b/mcp-server/package.json index aa95b9c..921f378 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "loopctl-mcp-server", - "version": "2.28.0", + "version": "2.29.0", "description": "MCP server for loopctl — structural trust for AI development loops", "type": "module", "main": "index.js", diff --git a/priv/repo/migrations/20260701120000_create_retrieval_metric_snapshots.exs b/priv/repo/migrations/20260701120000_create_retrieval_metric_snapshots.exs new file mode 100644 index 0000000..e6e0d21 --- /dev/null +++ b/priv/repo/migrations/20260701120000_create_retrieval_metric_snapshots.exs @@ -0,0 +1,33 @@ +defmodule Loopctl.Repo.Migrations.CreateRetrievalMetricSnapshots do + use Ecto.Migration + import Loopctl.Repo.RlsHelpers + + # Agents' KB #3: a daily time series of retrieval PRECISION — of the articles a search + # surfaced, how many the agent then actually opened (search -> get/context within a + # window). A proxy for "is retrieval getting better" that should trend up as dedup (#1), + # navigation (#5) and conflict resolution (#4) clean the corpus. + def change do + create table(:retrieval_metric_snapshots, primary_key: false) do + add :id, :binary_id, primary_key: true + add :tenant_id, references(:tenants, type: :binary_id, on_delete: :delete_all), null: false + + add :day, :date, null: false + add :window_seconds, :integer, null: false + add :searched, :integer, null: false, default: 0 + add :followed_through, :integer, null: false, default: 0 + add :precision, :float, null: false, default: 0.0 + add :computed_at, :utc_datetime_usec, null: false + + timestamps(type: :utc_datetime_usec) + end + + # One snapshot per (tenant, day, window). Recompute upserts. + create unique_index(:retrieval_metric_snapshots, [:tenant_id, :day, :window_seconds], + name: :retrieval_metric_snapshots_tenant_day_window_index + ) + + create index(:retrieval_metric_snapshots, [:tenant_id, :day]) + + enable_rls(:retrieval_metric_snapshots) + end +end diff --git a/test/loopctl/knowledge/retrieval_metrics_test.exs b/test/loopctl/knowledge/retrieval_metrics_test.exs new file mode 100644 index 0000000..d84b566 --- /dev/null +++ b/test/loopctl/knowledge/retrieval_metrics_test.exs @@ -0,0 +1,113 @@ +defmodule Loopctl.Knowledge.RetrievalMetricsTest do + use Loopctl.DataCase, async: true + + alias Loopctl.AdminRepo + alias Loopctl.Knowledge.RetrievalMetrics + alias Loopctl.Knowledge.RetrievalMetricSnapshot + + @day ~D[2026-06-15] + + defp at(time), do: DateTime.new!(@day, time, "Etc/UTC") + + defp event(tenant_id, api_key_id, article_id, type, time) do + fixture(:article_access_event, %{ + tenant_id: tenant_id, + api_key_id: api_key_id, + article_id: article_id, + access_type: type, + accessed_at: at(time) + }) + end + + setup do + tenant = fixture(:tenant) + {_raw, key} = fixture(:api_key, %{tenant_id: tenant.id, role: :agent}) + x = fixture(:article, %{tenant_id: tenant.id, status: :published}) + y = fixture(:article, %{tenant_id: tenant.id, status: :published}) + %{tenant: tenant, key: key, x: x, y: y} + end + + describe "compute/3" do + test "precision = searched results that were opened within the window", ctx do + %{tenant: t, key: k, x: x, y: y} = ctx + # X: searched then opened 10 min later (within the 30-min window) → follow-through. + event(t.id, k.id, x.id, "search", ~T[12:00:00]) + event(t.id, k.id, x.id, "get", ~T[12:10:00]) + # Y: searched, never opened → miss. + event(t.id, k.id, y.id, "search", ~T[12:00:00]) + + m = RetrievalMetrics.compute(t.id, @day, 1800) + assert m.searched == 2 + assert m.followed_through == 1 + assert m.precision == 0.5 + end + + test "an open OUTSIDE the window does not count", ctx do + %{tenant: t, key: k, x: x} = ctx + event(t.id, k.id, x.id, "search", ~T[12:00:00]) + event(t.id, k.id, x.id, "get", ~T[13:00:00]) + + m = RetrievalMetrics.compute(t.id, @day, 1800) + assert m.searched == 1 + assert m.followed_through == 0 + end + + test "an open by a DIFFERENT api_key does not count", ctx do + %{tenant: t, key: k, x: x} = ctx + {_raw, other} = fixture(:api_key, %{tenant_id: t.id, role: :agent}) + event(t.id, k.id, x.id, "search", ~T[12:00:00]) + event(t.id, other.id, x.id, "get", ~T[12:05:00]) + + assert RetrievalMetrics.compute(t.id, @day, 1800).followed_through == 0 + end + + test "context access also counts as a follow-through", ctx do + %{tenant: t, key: k, x: x} = ctx + event(t.id, k.id, x.id, "search", ~T[12:00:00]) + event(t.id, k.id, x.id, "context", ~T[12:05:00]) + + assert RetrievalMetrics.compute(t.id, @day, 1800).followed_through == 1 + end + + test "no searches → precision 0.0, no error", ctx do + %{tenant: t} = ctx + m = RetrievalMetrics.compute(t.id, @day, 1800) + + assert m == %{ + day: @day, + window_seconds: 1800, + searched: 0, + followed_through: 0, + precision: 0.0 + } + end + end + + describe "snapshot/3 + list_snapshots/2" do + test "records a snapshot and is idempotent per tenant/day/window", ctx do + %{tenant: t, key: k, x: x} = ctx + event(t.id, k.id, x.id, "search", ~T[12:00:00]) + event(t.id, k.id, x.id, "get", ~T[12:05:00]) + + assert {:ok, snap} = RetrievalMetrics.snapshot(t.id, @day, 1800) + assert snap.precision == 1.0 + + # Re-run upserts the same row (no duplicate). + assert {:ok, _} = RetrievalMetrics.snapshot(t.id, @day, 1800) + assert 1 == AdminRepo.aggregate(RetrievalMetricSnapshot, :count, :id) + + %{data: [row], meta: %{total_count: 1}} = RetrievalMetrics.list_snapshots(t.id) + assert row.day == @day + assert row.precision == 1.0 + end + + test "is tenant-scoped", ctx do + %{tenant: t, key: k, x: x} = ctx + other = fixture(:tenant) + event(t.id, k.id, x.id, "search", ~T[12:00:00]) + {:ok, _} = RetrievalMetrics.snapshot(t.id, @day, 1800) + + assert %{meta: %{total_count: 0}} = RetrievalMetrics.list_snapshots(other.id) + end + end +end diff --git a/test/loopctl/workers/retrieval_metrics_worker_test.exs b/test/loopctl/workers/retrieval_metrics_worker_test.exs new file mode 100644 index 0000000..5d3a6d5 --- /dev/null +++ b/test/loopctl/workers/retrieval_metrics_worker_test.exs @@ -0,0 +1,58 @@ +defmodule Loopctl.Workers.RetrievalMetricsWorkerTest do + use Loopctl.DataCase, async: true + use Oban.Testing, repo: Loopctl.Repo + + alias Loopctl.AdminRepo + alias Loopctl.Knowledge.RetrievalMetricSnapshot + alias Loopctl.Workers.RetrievalMetricsWorker + + test "all_tenants mode snapshots each active tenant (yesterday), skipping suspended" do + # Oban runs :inline in test, so the fanned-out per-tenant jobs execute synchronously. + active = fixture(:tenant) + suspended = fixture(:tenant, %{status: :suspended}) + yesterday = Date.add(DateTime.utc_now() |> DateTime.to_date(), -1) + + for tenant <- [active, suspended] do + {_raw, key} = fixture(:api_key, %{tenant_id: tenant.id, role: :agent}) + article = fixture(:article, %{tenant_id: tenant.id, status: :published}) + + fixture(:article_access_event, %{ + tenant_id: tenant.id, + api_key_id: key.id, + article_id: article.id, + access_type: "search", + accessed_at: DateTime.new!(yesterday, ~T[12:00:00], "Etc/UTC") + }) + end + + assert :ok = RetrievalMetricsWorker.perform(%Oban.Job{args: %{"mode" => "all_tenants"}}) + + assert AdminRepo.get_by(RetrievalMetricSnapshot, tenant_id: active.id, day: yesterday) + refute AdminRepo.get_by(RetrievalMetricSnapshot, tenant_id: suspended.id, day: yesterday) + end + + test "per-tenant mode records a snapshot for the given day" do + tenant = fixture(:tenant) + {_raw, key} = fixture(:api_key, %{tenant_id: tenant.id, role: :agent}) + article = fixture(:article, %{tenant_id: tenant.id, status: :published}) + + day = ~D[2026-06-15] + + fixture(:article_access_event, %{ + tenant_id: tenant.id, + api_key_id: key.id, + article_id: article.id, + access_type: "search", + accessed_at: DateTime.new!(day, ~T[12:00:00], "Etc/UTC") + }) + + assert :ok = + RetrievalMetricsWorker.perform(%Oban.Job{ + args: %{"tenant_id" => tenant.id, "day" => "2026-06-15"} + }) + + snap = AdminRepo.get_by(RetrievalMetricSnapshot, tenant_id: tenant.id, day: day) + assert snap.searched == 1 + assert snap.followed_through == 0 + end +end diff --git a/test/loopctl_web/controllers/knowledge_analytics_controller_test.exs b/test/loopctl_web/controllers/knowledge_analytics_controller_test.exs index aef48ab..00f950e 100644 --- a/test/loopctl_web/controllers/knowledge_analytics_controller_test.exs +++ b/test/loopctl_web/controllers/knowledge_analytics_controller_test.exs @@ -4,6 +4,7 @@ defmodule LoopctlWeb.KnowledgeAnalyticsControllerTest do setup :verify_on_exit! alias Loopctl.Knowledge + alias Loopctl.Knowledge.RetrievalMetrics defp auth_conn(conn, raw_key) do put_req_header(conn, "authorization", "Bearer #{raw_key}") @@ -707,4 +708,35 @@ defmodule LoopctlWeb.KnowledgeAnalyticsControllerTest do assert json_response(conn, 403) end end + + describe "GET /api/v1/knowledge/analytics/retrieval-metrics" do + test "orchestrator gets the precision time series", %{conn: conn} do + tenant = fixture(:tenant) + {raw_key, _} = fixture(:api_key, %{tenant_id: tenant.id, role: :orchestrator}) + {:ok, _} = RetrievalMetrics.snapshot(tenant.id, ~D[2026-06-15], 1800) + + conn = + conn + |> auth_conn(raw_key) + |> get(~p"/api/v1/knowledge/analytics/retrieval-metrics") + + body = json_response(conn, 200) + assert body["meta"]["total_count"] == 1 + assert [row] = body["data"] + assert row["day"] == "2026-06-15" + assert Map.has_key?(row, "precision") + end + + test "agent role is rejected (orchestrator+ required)", %{conn: conn} do + tenant = fixture(:tenant) + {raw_key, _} = fixture(:api_key, %{tenant_id: tenant.id, role: :agent}) + + conn = + conn + |> auth_conn(raw_key) + |> get(~p"/api/v1/knowledge/analytics/retrieval-metrics") + + assert json_response(conn, 403) + end + end end