diff --git a/lib/loopctl/knowledge.ex b/lib/loopctl/knowledge.ex index a26482d2..35b043b2 100644 --- a/lib/loopctl/knowledge.ex +++ b/lib/loopctl/knowledge.ex @@ -53,6 +53,7 @@ defmodule Loopctl.Knowledge do alias Loopctl.Knowledge.Article alias Loopctl.Knowledge.ArticleLink alias Loopctl.Knowledge.ConflictResolution + alias Loopctl.Knowledge.KbCuration alias Loopctl.Knowledge.VectorSearch alias Loopctl.Projects.Project alias Loopctl.Webhooks.EventGenerator @@ -377,6 +378,7 @@ defmodule Loopctl.Knowledge do defp gate_proposal(tenant_id, attrs, %{verdict: :duplicate} = assessment, opts) do case canonical_neighbor(tenant_id, assessment, opts) do {:ok, existing} -> + log_gate(tenant_id, "gate_duplicate", "rejected duplicate", existing, assessment, opts) {:ok, %{verdict: :duplicate, article: existing, created: false, assessment: assessment}} # The canonical neighbor vanished (deleted/unpublished) between assess and now — @@ -392,6 +394,8 @@ defmodule Loopctl.Knowledge do |> Map.put("status", "draft") |> stamp_proposal_metadata(assessment) + neighbor = List.first(assessment.neighbors) + log_gate(tenant_id, "gate_draft", "drafted (high overlap)", neighbor, assessment, opts) create_proposal(tenant_id, gated_attrs, assessment, opts, :gated_to_draft) end @@ -416,6 +420,31 @@ defmodule Loopctl.Knowledge do end end + # Concise curation-log line for a gate decision (only written when the tenant has + # kb_curation_log on — KbCuration.record no-ops otherwise). + defp log_gate(tenant_id, kind, prefix, neighbor, assessment, opts) do + {nid, ntitle} = + case neighbor do + %Article{id: id, title: title} -> {id, title} + %{id: id, title: title} -> {id, title} + _ -> {nil, nil} + end + + summary = + prefix <> + if(ntitle, do: " of \"#{ntitle}\"", else: "") <> + if(assessment.score, do: " (sim=#{fmt_sim(assessment.score)})", else: "") + + KbCuration.record(tenant_id, kind, summary, + refs: Enum.reject([nid], &is_nil/1), + actor: Keyword.get(opts, :actor_label) || Keyword.get(opts, :actor_id), + metadata: %{"similarity" => assessment.score} + ) + end + + defp fmt_sim(s) when is_float(s), do: :erlang.float_to_binary(s, decimals: 3) + defp fmt_sim(s), do: to_string(s) + defp canonical_neighbor(tenant_id, %{neighbors: [%{id: id} | _]}, opts) do case get_article(tenant_id, id, Keyword.take(opts, [:visibility_agent_id])) do {:ok, article} -> {:ok, article} @@ -3324,23 +3353,36 @@ defmodule Loopctl.Knowledge do %ConflictResolution{tenant_id: tenant_id} |> ConflictResolution.changeset(row_attrs) - AdminRepo.insert(changeset, - on_conflict: - {:replace, - [ - :authoritative_article_id, - :classification, - :disposition, - :confidence, - :evidence, - :annotated_by, - :annotated_at, - :executed_at, - :execution_result, - :updated_at - ]}, - conflict_target: [:tenant_id, :source_article_id, :target_article_id] - ) + result = + AdminRepo.insert(changeset, + on_conflict: + {:replace, + [ + :authoritative_article_id, + :classification, + :disposition, + :confidence, + :evidence, + :annotated_by, + :annotated_at, + :executed_at, + :execution_result, + :updated_at + ]}, + conflict_target: [:tenant_id, :source_article_id, :target_article_id] + ) + + with {:ok, %ConflictResolution{disposition: :dismiss} = res} <- result do + log_resolution( + tenant_id, + res, + "dismiss", + "dismissed as #{res.classification || "not-a-conflict"}", + [res.source_article_id, res.target_article_id] + ) + end + + result end @doc """ @@ -3410,6 +3452,17 @@ defmodule Loopctl.Knowledge do "loser" => loser }) + log_resolution( + tenant_id, + r, + "supersede", + "\"#{title_of(loser)}\" retired for \"#{title_of(winner)}\"", + [ + winner, + loser + ] + ) + true # Already superseded / link exists → the disposition is effectively done; record @@ -3478,6 +3531,13 @@ defmodule Loopctl.Knowledge do end) mark_resolution_executed(r, %{"action" => "merged_draft", "draft_id" => draft.id}) + + log_resolution(tenant_id, r, "merge", "drafted \"#{draft.title}\" from 2 sources", [ + draft.id, + r.source_article_id, + r.target_article_id + ]) + true # A draft with this title already exists (likely a prior run) — stop retrying. @@ -3509,6 +3569,24 @@ defmodule Loopctl.Knowledge do |> AdminRepo.update() end + # Concise curation-log line for a conflict resolution (no-ops unless the tenant has + # kb_curation_log on). `refs` are the article ids involved; actor/confidence come from + # the recorded verdict. + defp log_resolution(tenant_id, %ConflictResolution{} = r, kind, summary, refs) do + KbCuration.record(tenant_id, kind, summary, + refs: refs, + actor: r.annotated_by, + confidence: r.confidence && to_string(r.confidence) + ) + end + + defp title_of(article_id) do + case AdminRepo.get(Article, article_id) do + %Article{title: title} -> title + _ -> article_id + end + end + @doc """ Lists all links for an article (both outgoing and incoming), with linked articles preloaded. diff --git a/lib/loopctl/knowledge/kb_curation.ex b/lib/loopctl/knowledge/kb_curation.ex new file mode 100644 index 00000000..da014baf --- /dev/null +++ b/lib/loopctl/knowledge/kb_curation.ex @@ -0,0 +1,118 @@ +defmodule Loopctl.Knowledge.KbCuration do + @moduledoc """ + Toggleable, concise, human-readable log of KB CURATION adjustments — the "what did the KB + change" feed for analyzing the agents'-KB rollout, distinct from the verbose immutable + `audit_log`. + + **Toggle:** per-tenant, via `tenant.settings["kb_curation_log"]` (default off). Flip it + with the admin tenant API (`PATCH /api/v1/admin/tenants/:id` with + `settings: {"kb_curation_log": true}`); read the current value from + `GET /api/v1/admin/tenants/:id`. When off, `record/4` is a no-op (no rows, no overhead), + so you turn it on for the rollout months and off after. + + Call sites `record/4` at each mutation (gate decisions, conflict supersede/merge/dismiss); + the log is read back via `list/2` (`GET /api/v1/knowledge/curation-log`). + """ + + import Ecto.Query + + alias Loopctl.AdminRepo + alias Loopctl.Knowledge.KbCurationEvent + alias Loopctl.Tenants.Tenant + + @max_summary 500 + + @doc """ + Record one curation adjustment — but ONLY when the tenant has `kb_curation_log` on. + Fire-and-forget: always returns `:ok`, never raises or blocks the caller. + + Opts: `:refs` (article ids), `:actor`, `:confidence`, `:metadata`, `:at`. + """ + @spec record(Ecto.UUID.t() | nil, String.t(), String.t(), keyword()) :: :ok + def record(tenant_id, kind, summary, opts \\ []) + + def record(nil, _kind, _summary, _opts), do: :ok + + def record(tenant_id, kind, summary, opts) when is_binary(tenant_id) do + if enabled?(tenant_id) do + insert_event(tenant_id, kind, summary, opts) + end + + :ok + end + + @doc "Whether curation logging is on for a tenant (its `settings[\"kb_curation_log\"]`)." + @spec enabled?(Ecto.UUID.t()) :: boolean() + def enabled?(tenant_id) do + case AdminRepo.get(Tenant, tenant_id) do + %Tenant{settings: settings} when is_map(settings) -> + Map.get(settings, "kb_curation_log", false) == true + + _ -> + false + end + end + + @doc """ + The curation feed, most recent first. Opts: `:kind` (filter), `:since` (a `Date` or + `DateTime` lower bound), `:limit` (default 50, max 500), `:offset`. Returns + `%{data: [event maps], meta: %{limit, offset, total_count}}`. + """ + @spec list(Ecto.UUID.t(), keyword()) :: %{data: [map()], meta: map()} + def list(tenant_id, opts \\ []) do + limit = opts |> Keyword.get(:limit, 50) |> max(1) |> min(500) + offset = opts |> Keyword.get(:offset, 0) |> max(0) + + base = + from(e in KbCurationEvent, where: e.tenant_id == ^tenant_id) + |> filter_kind(Keyword.get(opts, :kind)) + |> filter_since(Keyword.get(opts, :since)) + + total_count = AdminRepo.aggregate(base, :count, :id) + + data = + from(e in base, + order_by: [desc: e.at, desc: e.id], + limit: ^limit, + offset: ^offset, + select: %{ + at: e.at, + kind: e.kind, + summary: e.summary, + refs: e.refs, + actor: e.actor, + confidence: e.confidence + } + ) + |> AdminRepo.all() + + %{data: data, meta: %{limit: limit, offset: offset, total_count: total_count}} + end + + defp insert_event(tenant_id, kind, summary, opts) do + attrs = %{ + kind: kind, + summary: String.slice(summary, 0, @max_summary), + refs: Keyword.get(opts, :refs, []), + actor: Keyword.get(opts, :actor), + confidence: Keyword.get(opts, :confidence), + metadata: Keyword.get(opts, :metadata, %{}), + at: Keyword.get(opts, :at) || DateTime.utc_now() + } + + %KbCurationEvent{tenant_id: tenant_id} + |> KbCurationEvent.changeset(attrs) + |> AdminRepo.insert() + end + + defp filter_kind(query, nil), do: query + defp filter_kind(query, kind), do: where(query, [e], e.kind == ^kind) + + defp filter_since(query, nil), do: query + + defp filter_since(query, %Date{} = d) do + filter_since(query, DateTime.new!(d, ~T[00:00:00.000000], "Etc/UTC")) + end + + defp filter_since(query, %DateTime{} = dt), do: where(query, [e], e.at >= ^dt) +end diff --git a/lib/loopctl/knowledge/kb_curation_event.ex b/lib/loopctl/knowledge/kb_curation_event.ex new file mode 100644 index 00000000..5b9d13ae --- /dev/null +++ b/lib/loopctl/knowledge/kb_curation_event.ex @@ -0,0 +1,37 @@ +defmodule Loopctl.Knowledge.KbCurationEvent do + @moduledoc """ + One concise, human-readable KB curation adjustment (a novelty-gate decision, a conflict + supersede/merge/dismiss, ...). The skimmable "what did the KB change" feed, distinct from + the verbose immutable `audit_log`. Written only when the tenant has + `settings["kb_curation_log"]` on (rollout observability). `tenant_id` is set + programmatically, never cast. + """ + + use Loopctl.Schema + + @type t :: %__MODULE__{} + + schema "kb_curation_events" do + tenant_field() + + field :kind, :string + field :summary, :string + field :refs, {:array, :binary_id}, default: [] + field :actor, :string + field :confidence, :string + field :metadata, :map, default: %{} + field :at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + @cast_fields [:kind, :summary, :refs, :actor, :confidence, :metadata, :at] + + @doc "Changeset for a curation event. `tenant_id` is set on the struct, not cast." + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(event \\ %__MODULE__{}, attrs) do + event + |> cast(attrs, @cast_fields) + |> validate_required([:kind, :summary, :at]) + end +end diff --git a/lib/loopctl/tenants/tenant.ex b/lib/loopctl/tenants/tenant.ex index f2919c4a..c18e76ab 100644 --- a/lib/loopctl/tenants/tenant.ex +++ b/lib/loopctl/tenants/tenant.ex @@ -163,17 +163,21 @@ defmodule Loopctl.Tenants.Tenant do not is_map(value) or is_struct(value) -> [settings: "must be a map"] - not valid_knowledge_auto_extract?(value) -> + not valid_boolean_setting?(value, "knowledge_auto_extract") -> [settings: "knowledge_auto_extract must be a boolean"] + not valid_boolean_setting?(value, "kb_curation_log") -> + [settings: "kb_curation_log must be a boolean"] + true -> [] end end) end - defp valid_knowledge_auto_extract?(settings) do - case Map.get(settings, "knowledge_auto_extract") do + # A recognized boolean setting is either absent or an actual boolean. + defp valid_boolean_setting?(settings, key) do + case Map.get(settings, key) do nil -> true val when is_boolean(val) -> true _ -> false diff --git a/lib/loopctl_web/controllers/knowledge_analytics_controller.ex b/lib/loopctl_web/controllers/knowledge_analytics_controller.ex index 43f07a03..340bdb5e 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.KbCuration alias Loopctl.Knowledge.RetrievalMetrics action_fallback LoopctlWeb.FallbackController @@ -322,6 +323,61 @@ defmodule LoopctlWeb.KnowledgeAnalyticsController do json(conn, RetrievalMetrics.list_snapshots(tenant_id, opts)) end + operation(:curation_log, + summary: "KB curation adjustment log", + description: + "The concise, human-readable log of KB CURATION adjustments (novelty-gate decisions, " <> + "conflict supersede/merge/dismiss) — the 'what did the KB change' feed for rollout " <> + "analysis. Recorded only while the tenant has `settings.kb_curation_log` on (toggle " <> + "via PATCH /api/v1/admin/tenants/:id). Most recent first. Role: orchestrator+.", + parameters: [ + kind: [ + in: :query, + type: :string, + description: "Filter by kind (gate_duplicate|gate_draft|supersede|merge|dismiss)", + required: false + ], + since: [ + in: :query, + type: :string, + description: "ISO8601 date/datetime lower bound (inclusive)", + required: false + ], + limit: [ + in: :query, + type: :integer, + description: "Events per page (default 50, max 500). Clamped, never rejected.", + required: false + ], + offset: [ + in: :query, + type: :integer, + description: "Events to skip (default 0)", + required: false + ] + ], + responses: %{ + 200 => + {"Curation log", "application/json", + %OpenApiSpex.Schema{type: :object, additionalProperties: true}}, + 429 => {"Rate limit exceeded", "application/json", Schemas.RateLimitError} + } + ) + + @doc "GET /api/v1/knowledge/curation-log" + def curation_log(conn, params) do + tenant_id = conn.assigns.current_api_key.tenant_id + + opts = + [] + |> put_limit(params["limit"], 50, 500) + |> put_offset(params["offset"]) + |> maybe_put(:kind, params["kind"]) + |> maybe_put(:since, parse_since(params["since"])) + + json(conn, KbCuration.list(tenant_id, opts)) + end + # --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- @@ -330,6 +386,27 @@ defmodule LoopctlWeb.KnowledgeAnalyticsController do Keyword.put(opts, :limit, parse_int(value, default) |> max(1) |> min(max_value)) end + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, _key, ""), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + # Accept an ISO8601 datetime OR date for the curation-log `since` lower bound. + defp parse_since(nil), do: nil + defp parse_since(""), do: nil + + defp parse_since(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, dt, _} -> + dt + + _ -> + case Date.from_iso8601(value) do + {:ok, d} -> d + _ -> nil + end + end + end + # Offset enables paging the ranking past the first page — never rejected, so a # caller can enumerate to completeness (the rankings are access-count aggregates, # not vector scans, so deep offset is cheap). diff --git a/lib/loopctl_web/router.ex b/lib/loopctl_web/router.ex index 6da71083..cde810bd 100644 --- a/lib/loopctl_web/router.ex +++ b/lib/loopctl_web/router.ex @@ -352,6 +352,8 @@ defmodule LoopctlWeb.Router do KnowledgeAnalyticsController, :retrieval_metrics + get "/knowledge/curation-log", KnowledgeAnalyticsController, :curation_log + get "/knowledge/analytics/agents/:agent_id", KnowledgeAnalyticsController, :agent_usage diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index 1aab1f3f..359d534a 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -5,6 +5,18 @@ 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.30.0 — 2026-07-01 (toggleable KB curation log) + +### Added + +- **`knowledge_curation_log`** — the concise, human-readable feed of KB curation + adjustments (novelty-gate `gate_duplicate`/`gate_draft`, conflict `supersede`/`merge`/ + `dismiss`), for analyzing the rollout. Recorded ONLY while a tenant has + `settings.kb_curation_log` on — a per-tenant toggle flipped via the admin tenant API + (`PATCH /api/v1/admin/tenants/:id` with `settings:{kb_curation_log:true}`); off by + default = no rows, no overhead. Filter by `kind`/`since`, most recent first. + Orchestrator role. + ## 2.29.0 — 2026-07-01 (retrieval precision metric) ### Added diff --git a/mcp-server/index.js b/mcp-server/index.js index 91a948a0..514fc25f 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -1163,6 +1163,20 @@ async function knowledgeRetrievalMetrics({ limit, offset } = {}) { return toContent(result); } +async function knowledgeCurationLog({ kind, since, limit, offset } = {}) { + const params = new URLSearchParams(); + if (kind) params.set("kind", kind); + if (since) params.set("since", since); + 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/curation-log?${qs}` + : "/api/v1/knowledge/curation-log"; + 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", @@ -3266,6 +3280,39 @@ const TOOLS = [ }, // Knowledge Analytics Tools (orchestrator key) + { + name: "knowledge_curation_log", + description: + "The concise, human-readable log of KB CURATION adjustments — novelty-gate decisions " + + "(gate_duplicate/gate_draft) and conflict resolutions (supersede/merge/dismiss) — for " + + "analyzing the agents'-KB rollout, distinct from the verbose audit log. Each entry is a " + + "one-liner: {at, kind, summary, refs, actor, confidence}. RECORDED ONLY while the tenant " + + "has the toggle on: settings.kb_curation_log (flip via the admin tenant API, " + + "PATCH /api/v1/admin/tenants/:id with settings:{kb_curation_log:true}). Off by default = " + + "no rows. Filter by kind and since (ISO8601). Most recent first. Requires orchestrator role.", + inputSchema: { + type: "object", + properties: { + kind: { + type: "string", + description: + "Optional: filter by kind (gate_duplicate | gate_draft | supersede | merge | dismiss).", + }, + since: { + type: "string", + description: "Optional: ISO8601 date or datetime lower bound (inclusive).", + }, + limit: { + type: "integer", + description: "Events per page (default 50, max 500). Clamped, never rejected.", + minimum: 1, + maximum: 500, + }, + offset: { type: "integer", description: "Events to skip. Default 0.", minimum: 0 }, + }, + required: [], + }, + }, { name: "knowledge_retrieval_metrics", description: @@ -3708,6 +3755,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await knowledgeIngestionJobs(); // Knowledge Analytics Tools + case "knowledge_curation_log": + return await knowledgeCurationLog(args); + case "knowledge_retrieval_metrics": return await knowledgeRetrievalMetrics(args); diff --git a/mcp-server/package.json b/mcp-server/package.json index 921f3786..c2d3e879 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "loopctl-mcp-server", - "version": "2.29.0", + "version": "2.30.0", "description": "MCP server for loopctl — structural trust for AI development loops", "type": "module", "main": "index.js", diff --git a/priv/repo/migrations/20260701140000_create_kb_curation_events.exs b/priv/repo/migrations/20260701140000_create_kb_curation_events.exs new file mode 100644 index 00000000..a2ab075a --- /dev/null +++ b/priv/repo/migrations/20260701140000_create_kb_curation_events.exs @@ -0,0 +1,31 @@ +defmodule Loopctl.Repo.Migrations.CreateKbCurationEvents do + use Ecto.Migration + import Loopctl.Repo.RlsHelpers + + # A concise, human-readable log of KB CURATION adjustments (novelty-gate decisions, + # conflict supersede/merge/dismiss, ...) — the skimmable "what did the KB change" feed, + # distinct from the verbose immutable audit_log. Written only when the + # `:kb_curation_log` flag is on (rollout observability; off = no rows). Analyzed via + # GET /knowledge/curation-log, filterable by kind/date. + def change do + create table(:kb_curation_events, 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 :kind, :string, null: false + add :summary, :text, null: false + add :refs, {:array, :binary_id}, null: false, default: [] + add :actor, :string + add :confidence, :string + add :metadata, :map, null: false, default: %{} + add :at, :utc_datetime_usec, null: false + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create index(:kb_curation_events, [:tenant_id, :at]) + create index(:kb_curation_events, [:tenant_id, :kind, :at]) + + enable_rls(:kb_curation_events) + end +end diff --git a/test/loopctl/knowledge/kb_curation_test.exs b/test/loopctl/knowledge/kb_curation_test.exs new file mode 100644 index 00000000..986cebd5 --- /dev/null +++ b/test/loopctl/knowledge/kb_curation_test.exs @@ -0,0 +1,132 @@ +defmodule Loopctl.Knowledge.KbCurationTest do + use Loopctl.DataCase, async: true + + alias Loopctl.Knowledge.KbCuration + + defp tenant_with_log(enabled?) do + fixture(:tenant, %{settings: %{"kb_curation_log" => enabled?}}) + end + + describe "record/4 respects the per-tenant toggle" do + test "records when kb_curation_log is on" do + t = tenant_with_log(true) + assert :ok = KbCuration.record(t.id, "supersede", "A retired for B", actor: "agent-1") + + %{data: [ev], meta: %{total_count: 1}} = KbCuration.list(t.id) + assert ev.kind == "supersede" + assert ev.summary == "A retired for B" + assert ev.actor == "agent-1" + end + + test "is a no-op when the toggle is off (default)" do + off = tenant_with_log(false) + default = fixture(:tenant) + + assert :ok = KbCuration.record(off.id, "supersede", "x", []) + assert :ok = KbCuration.record(default.id, "supersede", "x", []) + + assert %{meta: %{total_count: 0}} = KbCuration.list(off.id) + assert %{meta: %{total_count: 0}} = KbCuration.list(default.id) + end + + test "nil tenant is a safe no-op" do + assert :ok = KbCuration.record(nil, "supersede", "x", []) + end + end + + describe "enabled?/1" do + test "reflects the tenant setting" do + assert KbCuration.enabled?(tenant_with_log(true).id) + refute KbCuration.enabled?(tenant_with_log(false).id) + refute KbCuration.enabled?(fixture(:tenant).id) + end + end + + describe "list/2" do + setup do + t = tenant_with_log(true) + KbCuration.record(t.id, "supersede", "s1", at: ~U[2026-06-10 10:00:00Z]) + KbCuration.record(t.id, "merge", "m1", at: ~U[2026-06-12 10:00:00Z]) + KbCuration.record(t.id, "supersede", "s2", at: ~U[2026-06-14 10:00:00Z]) + %{t: t} + end + + test "most recent first", %{t: t} do + assert ["s2", "m1", "s1"] = KbCuration.list(t.id).data |> Enum.map(& &1.summary) + end + + test "filters by kind", %{t: t} do + %{data: data, meta: %{total_count: 2}} = KbCuration.list(t.id, kind: "supersede") + assert Enum.map(data, & &1.summary) == ["s2", "s1"] + end + + test "filters by since (date)", %{t: t} do + %{meta: %{total_count: 2}} = KbCuration.list(t.id, since: ~D[2026-06-12]) + end + + test "paginates", %{t: t} do + assert length(KbCuration.list(t.id, limit: 2).data) == 2 + assert length(KbCuration.list(t.id, limit: 2, offset: 2).data) == 1 + end + + test "is tenant-scoped", %{t: t} do + other = tenant_with_log(true) + assert %{meta: %{total_count: 0}} = KbCuration.list(other.id) + assert %{meta: %{total_count: 3}} = KbCuration.list(t.id) + end + end + + describe "integration — resolutions log when enabled" do + alias Loopctl.AdminRepo + alias Loopctl.Knowledge + alias Loopctl.Knowledge.ArticleLink + + test "a supersede execution records a curation event" do + t = tenant_with_log(true) + a = fixture(:article, %{tenant_id: t.id, title: "Winner", status: :published}) + b = fixture(:article, %{tenant_id: t.id, title: "Loser", status: :published}) + + %ArticleLink{tenant_id: t.id} + |> ArticleLink.changeset(%{ + source_article_id: a.id, + target_article_id: b.id, + relationship_type: :potential_conflict, + metadata: %{"similarity_score" => 0.98} + }) + |> AdminRepo.insert!() + + {:ok, _} = + Knowledge.annotate_conflict(t.id, %{ + "source_article_id" => a.id, + "target_article_id" => b.id, + "disposition" => "supersede", + "authoritative_article_id" => a.id, + "confidence" => "high" + }) + + assert 1 == Knowledge.execute_conflict_resolutions(t.id) + + %{data: [ev]} = KbCuration.list(t.id, kind: "supersede") + assert ev.summary =~ "Loser" + assert ev.summary =~ "Winner" + assert ev.confidence == "high" + end + + test "a dismiss records a curation event" do + t = tenant_with_log(true) + a = fixture(:article, %{tenant_id: t.id, status: :published}) + b = fixture(:article, %{tenant_id: t.id, status: :published}) + + {:ok, _} = + Knowledge.annotate_conflict(t.id, %{ + "source_article_id" => a.id, + "target_article_id" => b.id, + "disposition" => "dismiss", + "classification" => "complementary" + }) + + %{data: [ev], meta: %{total_count: 1}} = KbCuration.list(t.id, kind: "dismiss") + assert ev.summary =~ "complementary" + end + end +end diff --git a/test/loopctl/tenants/knowledge_settings_test.exs b/test/loopctl/tenants/knowledge_settings_test.exs index ec59bfac..54c8455c 100644 --- a/test/loopctl/tenants/knowledge_settings_test.exs +++ b/test/loopctl/tenants/knowledge_settings_test.exs @@ -7,6 +7,31 @@ defmodule Loopctl.Tenants.KnowledgeSettingsTest do # --- TC-21.6.6: Update auto_extract setting via tenant settings endpoint --- + describe "update_tenant/2 with kb_curation_log" do + test "allows toggling kb_curation_log on and off" do + tenant = fixture(:tenant) + + assert {:ok, on} = + Tenants.update_tenant(tenant, %{"settings" => %{"kb_curation_log" => true}}) + + assert on.settings["kb_curation_log"] == true + + assert {:ok, off} = + Tenants.update_tenant(on, %{"settings" => %{"kb_curation_log" => false}}) + + assert off.settings["kb_curation_log"] == false + end + + test "rejects a non-boolean kb_curation_log" do + tenant = fixture(:tenant) + + assert {:error, changeset} = + Tenants.update_tenant(tenant, %{"settings" => %{"kb_curation_log" => "on"}}) + + assert errors_on(changeset).settings != [] + end + end + describe "update_tenant/2 with knowledge_auto_extract" do test "allows setting knowledge_auto_extract to false" do tenant = fixture(:tenant) diff --git a/test/loopctl_web/controllers/knowledge_analytics_controller_test.exs b/test/loopctl_web/controllers/knowledge_analytics_controller_test.exs index 00f950e0..95944071 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.KbCuration alias Loopctl.Knowledge.RetrievalMetrics defp auth_conn(conn, raw_key) do @@ -709,6 +710,34 @@ defmodule LoopctlWeb.KnowledgeAnalyticsControllerTest do end end + describe "GET /api/v1/knowledge/curation-log" do + test "orchestrator reads the curation feed (filterable by kind)", %{conn: conn} do + tenant = fixture(:tenant, %{settings: %{"kb_curation_log" => true}}) + {raw_key, _} = fixture(:api_key, %{tenant_id: tenant.id, role: :orchestrator}) + :ok = KbCuration.record(tenant.id, "supersede", "A retired for B") + :ok = KbCuration.record(tenant.id, "dismiss", "not a conflict") + + conn = + conn + |> auth_conn(raw_key) + |> get(~p"/api/v1/knowledge/curation-log?kind=supersede") + + body = json_response(conn, 200) + assert body["meta"]["total_count"] == 1 + assert [ev] = body["data"] + assert ev["kind"] == "supersede" + assert ev["summary"] == "A retired for B" + 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/curation-log") + 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)