From 2a4dc42121dcd73b5ef092ffb732fc37d13140ef Mon Sep 17 00:00:00 2001 From: mkreyman Date: Tue, 30 Jun 2026 17:09:26 -0600 Subject: [PATCH] feat(knowledge): surface potential-conflict pairs at retrieval (agents' KB #4, surfacing half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detection half (#224) creates :potential_conflict links; this makes them travel with the search result so the consuming agent — the one with live context — trips over them and resolves them. - get_article JSON: + a flattened, actionable `potential_conflicts` field per article (peer article_id/title/similarity), derived from the already-loaded link graph. An agent reading an article now sees its 'too similar to coexist' peers directly, instead of digging through incoming/outgoing_links. Empty list when none. - Knowledge.list_potential_conflicts/2: the tenant-wide review queue — every flagged pair, highest-overlap first (most likely a true duplicate), paginated, tenant-scoped. - API: GET /api/v1/knowledge/conflicts (agent+), so a consuming agent or human can pull the whole queue and act. - MCP v2.26.0: knowledge_conflicts tool (agent key); knowledge_get now carries potential_conflicts automatically (flows through the API). Also fixed a stale knowledge_drafts doc that still claimed an over-max limit is rejected-400 (it has clamped since the pagination work). The KB only flags; every tool/field repeats that the caller decides redundancy-vs- contradiction. Tests (+6 Elixir, +2 MCP): list ordering/pagination/isolation, the get field (populated + empty), the endpoint, and the MCP plumbing. Full gate green (3048 tests, dialyzer, credo; 52 MCP tests). --- lib/loopctl/knowledge.ex | 53 ++++++++++ lib/loopctl_web/controllers/article_json.ex | 30 ++++++ .../article_workflow_controller.ex | 47 +++++++++ lib/loopctl_web/router.ex | 1 + mcp-server/CHANGELOG.md | 18 ++++ mcp-server/index.js | 60 ++++++++++-- mcp-server/package.json | 2 +- mcp-server/test/knowledge_tools.test.js | 40 ++++++++ .../knowledge/potential_conflicts_test.exs | 96 +++++++++++++++++++ .../article_workflow_controller_test.exs | 26 +++++ 10 files changed, 366 insertions(+), 7 deletions(-) create mode 100644 test/loopctl/knowledge/potential_conflicts_test.exs diff --git a/lib/loopctl/knowledge.ex b/lib/loopctl/knowledge.ex index 04e15ca7..5adf1f68 100644 --- a/lib/loopctl/knowledge.ex +++ b/lib/loopctl/knowledge.ex @@ -3164,6 +3164,59 @@ defmodule Loopctl.Knowledge do end end + @doc """ + Route-the-findings (#4) review surface: every `:potential_conflict` pair in the + tenant — articles flagged "too similar to comfortably coexist" by the linker/lint + sweep, highest-overlap first (most likely a true duplicate). The KB does not decide + redundancy-vs-contradiction; this is the queue a consumer/human resolves. + + Opts: `:limit` (default 50, clamped to the max page size), `:offset` (default 0). + + Returns `%{data: [%{link_id, similarity, articles: [%{id, title, status, category}, + ...]}], meta: %{limit, offset, total_count}}`. + """ + @spec list_potential_conflicts(Ecto.UUID.t(), keyword()) :: %{data: [map()], meta: map()} + def list_potential_conflicts(tenant_id, opts \\ []) do + limit = opts |> Keyword.get(:limit, 50) |> max(1) |> min(@max_page_size) + offset = opts |> Keyword.get(:offset, 0) |> max(0) + + base = + from(l in ArticleLink, + where: l.tenant_id == ^tenant_id, + where: l.relationship_type == :potential_conflict + ) + + total_count = AdminRepo.aggregate(base, :count, :id) + + rows = + from(l in base, + join: s in Article, + on: s.id == l.source_article_id, + join: t in Article, + on: t.id == l.target_article_id, + order_by: [ + desc: fragment("(?->>'similarity_score')::float", l.metadata), + asc: l.id + ], + limit: ^limit, + offset: ^offset, + select: %{ + link_id: l.id, + similarity: fragment("(?->>'similarity_score')::float", l.metadata), + source: %{id: s.id, title: s.title, status: s.status, category: s.category}, + target: %{id: t.id, title: t.title, status: t.status, category: t.category} + } + ) + |> AdminRepo.all() + + data = + Enum.map(rows, fn r -> + %{link_id: r.link_id, similarity: r.similarity, articles: [r.source, r.target]} + end) + + %{data: data, meta: %{limit: limit, offset: offset, total_count: total_count}} + end + @doc """ Lists all links for an article (both outgoing and incoming), with linked articles preloaded. diff --git a/lib/loopctl_web/controllers/article_json.ex b/lib/loopctl_web/controllers/article_json.ex index 88d8e91a..e5dc4322 100644 --- a/lib/loopctl_web/controllers/article_json.ex +++ b/lib/loopctl_web/controllers/article_json.ex @@ -78,6 +78,36 @@ defmodule LoopctlWeb.ArticleJSON do article_data(article) |> Map.put(:outgoing_links, Enum.map(loaded_links(article.outgoing_links), &link_data/1)) |> Map.put(:incoming_links, Enum.map(loaded_links(article.incoming_links), &link_data/1)) + |> Map.put(:potential_conflicts, potential_conflicts(article)) + end + + # Route-the-findings (#4): a flattened, actionable view of the article's + # `:potential_conflict` links (in either direction) — the PEER article + the + # similarity that flagged it. Empty list when none. These also appear in + # incoming/outgoing_links; this surfaces them so an agent reading the article trips + # over "too similar to coexist" pairs and can merge the redundancy or reconcile. + defp potential_conflicts(article) do + out = + article.outgoing_links + |> loaded_links() + |> Enum.filter(&(&1.relationship_type == :potential_conflict)) + |> Enum.map(&conflict_peer(&1, &1.target_article, &1.target_article_id)) + + inc = + article.incoming_links + |> loaded_links() + |> Enum.filter(&(&1.relationship_type == :potential_conflict)) + |> Enum.map(&conflict_peer(&1, &1.source_article, &1.source_article_id)) + + out ++ inc + end + + defp conflict_peer(link, peer_article, peer_id) do + %{ + article_id: peer_id, + title: loaded_title(peer_article), + similarity: get_in(link.metadata || %{}, ["similarity_score"]) + } end defp link_data(link) do diff --git a/lib/loopctl_web/controllers/article_workflow_controller.ex b/lib/loopctl_web/controllers/article_workflow_controller.ex index 6e94613d..af39b470 100644 --- a/lib/loopctl_web/controllers/article_workflow_controller.ex +++ b/lib/loopctl_web/controllers/article_workflow_controller.ex @@ -225,6 +225,37 @@ defmodule LoopctlWeb.ArticleWorkflowController do } ) + operation(:conflicts, + summary: "List potential-conflict article pairs", + description: + "Lists `:potential_conflict` pairs — published articles flagged 'too similar to " <> + "comfortably coexist' by the auto-linker / nightly lint sweep, highest-overlap " <> + "first. The KB only flags; the caller decides whether each is a redundancy to " <> + "merge or a real contradiction to reconcile. Role: agent+.", + parameters: [ + limit: [ + in: :query, + type: :integer, + description: + "Max results per page (default 50, max 1000). A limit above the max is " <> + "clamped to the maximum — never rejected — so pagination stays complete." + ], + offset: [in: :query, type: :integer, description: "Records to skip"] + ], + responses: %{ + 200 => + {"Conflicts list", "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{type: :array}, + meta: %OpenApiSpex.Schema{type: :object} + } + }}, + 429 => {"Rate limit exceeded", "application/json", Schemas.RateLimitError} + } + ) + operation(:bulk_unpublish, summary: "Bulk unpublish articles", description: @@ -611,6 +642,22 @@ defmodule LoopctlWeb.ArticleWorkflowController do end end + @doc "GET /api/v1/knowledge/conflicts" + def conflicts(conn, params) do + tenant_id = conn.assigns.current_api_key.tenant_id + + with {:ok, effective_limit} <- Pagination.validate_limit(params) do + opts = + [] + |> maybe_add_opt(:limit, effective_limit) + |> maybe_add_opt(:offset, parse_int(params["offset"])) + + result = Knowledge.list_potential_conflicts(tenant_id, opts) + + json(conn, result) + end + end + # --- Private helpers --- defp maybe_add_opt(opts, _key, nil), do: opts diff --git a/lib/loopctl_web/router.ex b/lib/loopctl_web/router.ex index 19b028a5..526027a6 100644 --- a/lib/loopctl_web/router.ex +++ b/lib/loopctl_web/router.ex @@ -294,6 +294,7 @@ defmodule LoopctlWeb.Router do post "/knowledge/bulk-unpublish", ArticleWorkflowController, :bulk_unpublish post "/knowledge/bulk-delete", ArticleWorkflowController, :bulk_delete get "/knowledge/drafts", ArticleWorkflowController, :drafts + get "/knowledge/conflicts", ArticleWorkflowController, :conflicts # Knowledge Index (lightweight catalog) get "/knowledge/index", KnowledgeIndexController, :index diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index 092a1996..65e8a4cc 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -5,6 +5,24 @@ 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.26.0 — 2026-06-30 (route-the-findings: conflict review surface) + +### Added + +- **`knowledge_conflicts`** — list `:potential_conflict` article pairs (published + articles flagged "too similar to comfortably coexist" by the auto-linker / nightly + lint sweep), highest-overlap first. The KB only flags; the caller decides + redundancy-vs-contradiction. Paginated (default 50, max 1000, clamped). Agent role. +- **`knowledge_get`** responses now include a `potential_conflicts` array on each + article (peer `article_id`, `title`, `similarity`) — so an agent reading an article + trips over its conflict pairs and can act, instead of digging through the link graph. + +### Fixed + +- `knowledge_drafts` description/comment corrected: an over-max `limit` is **clamped** + to the maximum (never rejected with 400) — the server has clamped since the + pagination work; the tool doc was stale. + ## 2.25.0 — 2026-06-30 (novelty-gated write-back) ### Added diff --git a/mcp-server/index.js b/mcp-server/index.js index 0d78b0c8..10aba1fb 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -1027,9 +1027,9 @@ async function knowledgeBulkDelete({ async function knowledgeDrafts({ limit, offset, project_id }) { const params = new URLSearchParams(); - // Pass `limit` through verbatim (like knowledge_list/index/search) so the - // server honors it up to its max page size and returns 400 above it — rather - // than silently clamping client-side, which would truncate draft enumeration. + // Pass `limit` through verbatim (like knowledge_list/index/search) so the server + // honors it up to its max page size, clamping above it server-side rather than + // silently clamping client-side (which would truncate draft enumeration). if (limit != null) params.set("limit", String(limit)); if (offset != null) params.set("offset", String(offset)); if (project_id) params.set("project_id", project_id); @@ -1038,6 +1038,18 @@ async function knowledgeDrafts({ limit, offset, project_id }) { return toContent(result); } +async function knowledgeConflicts({ 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/conflicts?${qs}` + : "/api/v1/knowledge/conflicts"; + const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY); + return toContent(result); +} + async function knowledgeLint({ project_id, stale_days, min_coverage, max_per_category }) { const params = new URLSearchParams(); if (stale_days != null) params.set("stale_days", String(stale_days)); @@ -2852,8 +2864,8 @@ const TOOLS = [ description: "List draft (unpublished) knowledge articles. Requires orchestrator role. " + "Returns paginated drafts with total_count in meta. Paginate via offset/limit " + - "(limit honored up to 1000; a limit above the max is rejected with 400, not " + - "silently clamped).", + "(limit honored up to 1000; a limit above the max is clamped to the maximum, " + + "never rejected, so pagination stays complete).", inputSchema: { type: "object", properties: { @@ -2861,7 +2873,7 @@ const TOOLS = [ type: "integer", description: "Max drafts per page (default 20, max 1000). A limit above the max is " + - "rejected with 400 — not silently clamped — so offset pagination stays complete.", + "clamped to the maximum — never rejected — so offset pagination stays complete.", default: 20, minimum: 1, maximum: 1000, @@ -2880,6 +2892,39 @@ const TOOLS = [ required: [], }, }, + { + name: "knowledge_conflicts", + description: + "List potential-conflict article pairs — published articles flagged 'too similar " + + "to comfortably coexist' by the auto-linker / nightly lint sweep, highest-overlap " + + "first. The KB only FLAGS the pair (via a mechanical similarity threshold); it does " + + "NOT decide whether it's a redundancy to merge or a real contradiction — that's your " + + "call, with the live context. Each entry has the two articles (id/title/status/" + + "category) and their similarity. Read both, then merge (supersede one, knowledge_create " + + "the merged article, or PATCH) or, if they genuinely disagree, reconcile. Paginated " + + "with total_count in meta. Agent role.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "integer", + description: + "Max pairs per page (default 50, max 1000). A limit above the max is clamped " + + "to the maximum — never rejected — so offset pagination stays complete.", + default: 50, + minimum: 1, + maximum: 1000, + }, + offset: { + type: "integer", + description: "Pagination offset. Default 0.", + default: 0, + minimum: 0, + }, + }, + required: [], + }, + }, { name: "knowledge_lint", description: @@ -3500,6 +3545,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case "knowledge_drafts": return await knowledgeDrafts(args); + case "knowledge_conflicts": + return await knowledgeConflicts(args); + case "knowledge_lint": return await knowledgeLint(args); diff --git a/mcp-server/package.json b/mcp-server/package.json index cebfc749..6a82c92e 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "loopctl-mcp-server", - "version": "2.25.0", + "version": "2.26.0", "description": "MCP server for loopctl — structural trust for AI development loops", "type": "module", "main": "index.js", diff --git a/mcp-server/test/knowledge_tools.test.js b/mcp-server/test/knowledge_tools.test.js index ace3714f..d9a3f015 100644 --- a/mcp-server/test/knowledge_tools.test.js +++ b/mcp-server/test/knowledge_tools.test.js @@ -1121,3 +1121,43 @@ describe("knowledge_create novelty gate (force passthrough)", () => { assert.equal(sent.force, true); }); }); + +async function knowledgeConflicts({ 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/conflicts?${qs}` + : "/api/v1/knowledge/conflicts"; + return await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY); +} + +describe("knowledge_conflicts (route-the-findings review surface)", () => { + test("GETs the conflicts endpoint with the agent key and forwards pagination", async () => { + setupEnv(); + const calls = mockFetch({ data: [], meta: { total_count: 0 } }); + + await knowledgeConflicts({ limit: 10, offset: 20 }); + + assert.equal(calls.length, 1); + const { url, options } = calls[0]; + assert.equal(options.method, "GET"); + assert.equal(options.headers.Authorization, `Bearer ${AGENT_KEY}`); + const parsed = new URL(url); + assert.equal(parsed.pathname, "/api/v1/knowledge/conflicts"); + assert.equal(parsed.searchParams.get("limit"), "10"); + assert.equal(parsed.searchParams.get("offset"), "20"); + }); + + test("omits the query string when no pagination is passed", async () => { + setupEnv(); + const calls = mockFetch({ data: [], meta: { total_count: 0 } }); + + await knowledgeConflicts(); + + const parsed = new URL(calls[0].url); + assert.equal(parsed.pathname, "/api/v1/knowledge/conflicts"); + assert.equal(parsed.search, ""); + }); +}); diff --git a/test/loopctl/knowledge/potential_conflicts_test.exs b/test/loopctl/knowledge/potential_conflicts_test.exs new file mode 100644 index 00000000..2ed59c94 --- /dev/null +++ b/test/loopctl/knowledge/potential_conflicts_test.exs @@ -0,0 +1,96 @@ +defmodule Loopctl.Knowledge.PotentialConflictsTest do + use Loopctl.DataCase, async: true + + alias Loopctl.AdminRepo + alias Loopctl.Knowledge + alias Loopctl.Knowledge.ArticleLink + alias LoopctlWeb.ArticleJSON + + defp published(tenant_id, title) do + fixture(:article, %{tenant_id: tenant_id, title: title, status: :published}) + end + + defp conflict_link(tenant_id, src, tgt, score) do + %ArticleLink{tenant_id: tenant_id} + |> ArticleLink.changeset(%{ + source_article_id: src.id, + target_article_id: tgt.id, + relationship_type: :potential_conflict, + metadata: %{"auto_generated" => true, "similarity_score" => score} + }) + |> AdminRepo.insert!() + end + + describe "list_potential_conflicts/2" do + test "returns flagged pairs, highest overlap first, with both articles" do + tenant = fixture(:tenant) + a = published(tenant.id, "A") + b = published(tenant.id, "B") + c = published(tenant.id, "C") + conflict_link(tenant.id, a, b, 0.94) + conflict_link(tenant.id, a, c, 0.985) + + %{data: data, meta: meta} = Knowledge.list_potential_conflicts(tenant.id) + + assert meta.total_count == 2 + # Highest similarity first. + assert [%{similarity: 0.985}, %{similarity: 0.94}] = data + first = hd(data) + titles = Enum.map(first.articles, & &1.title) |> Enum.sort() + assert titles == ["A", "C"] + end + + test "paginates with limit/offset" do + tenant = fixture(:tenant) + a = published(tenant.id, "A") + + for n <- 1..3 do + peer = published(tenant.id, "peer #{n}") + conflict_link(tenant.id, a, peer, 0.9 + n / 100) + end + + page = Knowledge.list_potential_conflicts(tenant.id, limit: 2, offset: 0) + assert length(page.data) == 2 + assert page.meta.total_count == 3 + + page2 = Knowledge.list_potential_conflicts(tenant.id, limit: 2, offset: 2) + assert length(page2.data) == 1 + end + + test "is tenant-scoped" do + tenant_a = fixture(:tenant) + tenant_b = fixture(:tenant) + a1 = published(tenant_a.id, "A1") + a2 = published(tenant_a.id, "A2") + conflict_link(tenant_a.id, a1, a2, 0.95) + + assert %{meta: %{total_count: 0}, data: []} = + Knowledge.list_potential_conflicts(tenant_b.id) + end + end + + describe "get_article surfaces potential_conflicts" do + test "the JSON view flattens conflict links to the peer + similarity" do + tenant = fixture(:tenant) + a = published(tenant.id, "Main") + peer = published(tenant.id, "Peer") + conflict_link(tenant.id, a, peer, 0.96) + + {:ok, loaded} = Knowledge.get_article(tenant.id, a.id) + json = ArticleJSON.article_data_with_links(loaded) + + assert [%{article_id: pid, title: "Peer", similarity: 0.96}] = json.potential_conflicts + assert pid == peer.id + end + + test "is an empty list when the article has no conflicts" do + tenant = fixture(:tenant) + a = published(tenant.id, "Solo") + + {:ok, loaded} = Knowledge.get_article(tenant.id, a.id) + json = ArticleJSON.article_data_with_links(loaded) + + assert json.potential_conflicts == [] + end + end +end diff --git a/test/loopctl_web/controllers/article_workflow_controller_test.exs b/test/loopctl_web/controllers/article_workflow_controller_test.exs index b0b99e48..45d14f49 100644 --- a/test/loopctl_web/controllers/article_workflow_controller_test.exs +++ b/test/loopctl_web/controllers/article_workflow_controller_test.exs @@ -1263,4 +1263,30 @@ defmodule LoopctlWeb.ArticleWorkflowControllerTest do assert hd(body["data"])["title"] == "A Draft" end end + + describe "GET /api/v1/knowledge/conflicts" do + test "lists potential-conflict pairs (agent role), highest overlap first", %{conn: conn} do + tenant = fixture(:tenant) + {raw_key, _} = fixture(:api_key, %{tenant_id: tenant.id, role: :agent}) + a = fixture(:article, %{tenant_id: tenant.id, title: "A", status: :published}) + b = fixture(:article, %{tenant_id: tenant.id, title: "B", status: :published}) + + %ArticleLink{tenant_id: tenant.id} + |> ArticleLink.changeset(%{ + source_article_id: a.id, + target_article_id: b.id, + relationship_type: :potential_conflict, + metadata: %{"auto_generated" => true, "similarity_score" => 0.97} + }) + |> AdminRepo.insert!() + + conn = conn |> auth_conn(raw_key) |> get(~p"/api/v1/knowledge/conflicts") + + body = json_response(conn, 200) + assert body["meta"]["total_count"] == 1 + assert [pair] = body["data"] + assert pair["similarity"] == 0.97 + assert length(pair["articles"]) == 2 + end + end end