Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions lib/loopctl/knowledge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions lib/loopctl_web/controllers/article_json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions lib/loopctl_web/controllers/article_workflow_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/loopctl_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 54 additions & 6 deletions mcp-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -2852,16 +2864,16 @@ 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: {
limit: {
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,
Expand All @@ -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:
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 40 additions & 0 deletions mcp-server/test/knowledge_tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
});
});
Loading
Loading