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
4 changes: 4 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ config :loopctl, :knowledge_proposal_overlap_threshold, 0.88
# link for the consuming agent to resolve (merge a redundancy / reconcile a real
# contradiction). The KB only flags; it never judges which it is.
config :loopctl, :knowledge_conflict_threshold, 0.93

# Merge synthesizer (#4 step 2): the LLM that combines two articles a grounded agent
# marked `:merge` into ONE draft. Reuses the shared :anthropic_provider key; drafts only.
config :loopctl, :merge_synthesizer, Loopctl.Knowledge.ClaudeMergeSynthesizer
# Max `:relates_to`→`:potential_conflict` promotions the nightly lint sweep does per
# tenant per run (bounds the existing-corpus backfill; it cycles over nights).
config :loopctl, :knowledge_lint_max_conflict_promotions, 500
Expand Down
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ config :loopctl, :knowledge_suggest_links, Loopctl.MockSuggestLinks
# tests choose the verdict deterministically. DataCase default-stubs it to `:novel`
# (gate is a no-op); ProposalGate's own tests call it directly with MockEmbeddingClient.
config :loopctl, :proposal_assessor, Loopctl.MockProposalAssessor
# Merge synthesizer (#4 step 2) — mock so the conflict executor's :merge path is
# deterministic and makes no Anthropic calls.
config :loopctl, :merge_synthesizer, Loopctl.MockMergeSynthesizer

# DI (US-27.3): the router wrapped by LoopctlWeb.Plugs.DBErrorBackstop. A thin
# REAL plug (Loopctl.Test.BackstopRouter) that delegates to LoopctlWeb.Router for
Expand Down
109 changes: 102 additions & 7 deletions lib/loopctl/knowledge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3344,11 +3344,17 @@ defmodule Loopctl.Knowledge do
end

@doc """
Nightly executor for `:supersede` resolutions (route-the-findings #4, step 1). Applies
only high-confidence, not-yet-executed supersedes — reversible and audited: reuses
`create_link/3` to create a `supersedes` link (winner → loser) and transition the loser
to `:superseded`. Bounded per run via `opts[:limit]`. `:dismiss` needs no execution
(recorded complete); `:merge` is left for the LLM step. Returns the count applied.
Nightly executor for conflict resolutions (route-the-findings #4). Applies only
high-confidence, not-yet-executed rows — all reversible/non-destructive and audited:

* `:supersede` — reuses `create_link/3` to create a `supersedes` link (winner → loser)
and transition the loser to `:superseded` (reversible).
* `:merge` — the LLM synthesizes a merged article (step 2); it lands as a **draft**
(never auto-published) with both sources preserved and `merged_from` metadata, plus
`relates_to` links to the sources. On synthesis failure the row is left for retry.

`:dismiss` needs no execution (recorded complete on annotate). Bounded per run via
`opts[:limit]`. Returns the count applied.
"""
@spec execute_conflict_resolutions(Ecto.UUID.t(), keyword()) :: non_neg_integer()
def execute_conflict_resolutions(tenant_id, opts \\ []) do
Expand All @@ -3358,17 +3364,23 @@ defmodule Loopctl.Knowledge do
from(r in ConflictResolution,
where: r.tenant_id == ^tenant_id,
where: is_nil(r.executed_at),
where: r.disposition == :supersede,
where: r.disposition in [:supersede, :merge],
where: r.confidence == :high,
limit: ^limit
)
|> AdminRepo.all()

Enum.reduce(rows, 0, fn r, count ->
if apply_supersede(tenant_id, r), do: count + 1, else: count
if apply_resolution(tenant_id, r), do: count + 1, else: count
end)
end

defp apply_resolution(tenant_id, %ConflictResolution{disposition: :supersede} = r),
do: apply_supersede(tenant_id, r)

defp apply_resolution(tenant_id, %ConflictResolution{disposition: :merge} = r),
do: apply_merge(tenant_id, r)

# The conflict pair is unordered; store it canonically (source <= target by UUID
# string) so one row covers (A,B) and (B,A).
defp canonical_pair(a, b) when is_binary(a) and is_binary(b) and a > b, do: {b, a}
Expand Down Expand Up @@ -3408,6 +3420,89 @@ defmodule Loopctl.Knowledge do
end
end

defp apply_merge(tenant_id, %ConflictResolution{} = r) do
a = AdminRepo.get_by(Article, id: r.source_article_id, tenant_id: tenant_id)
b = AdminRepo.get_by(Article, id: r.target_article_id, tenant_id: tenant_id)

if is_nil(a) or is_nil(b) do
mark_resolution_executed(r, %{"action" => "noop", "reason" => "source missing"})
false
else
do_merge(tenant_id, r, a, b)
end
end

defp do_merge(tenant_id, r, a, b) do
case merge_synthesizer().synthesize(
%{title: a.title, body: a.body},
%{title: b.title, body: b.body}
) do
{:ok, %{title: title, body: body}} ->
create_merged_draft(tenant_id, r, a, title, body)

# No backend / unparseable → leave unexecuted so a later run (or config) retries;
# NEVER draft a placeholder.
{:error, reason} ->
Logger.warning("ConflictExecutor: merge synthesis failed for #{r.id}: #{inspect(reason)}")
false
end
end

defp create_merged_draft(tenant_id, r, source_a, title, body) do
attrs = %{
title: title,
body: body,
category: source_a.category,
status: :draft,
tags: ["merged"],
metadata: %{
"merged_from" => [r.source_article_id, r.target_article_id],
"conflict_resolution_id" => r.id
}
}

opts = [actor_type: "system", actor_label: "worker:conflict_executor"]

case create_article(tenant_id, attrs, opts) do
{:ok, draft} ->
Enum.each([r.source_article_id, r.target_article_id], fn src ->
create_link(
tenant_id,
%{
source_article_id: draft.id,
target_article_id: src,
relationship_type: "relates_to"
},
opts
)
end)

mark_resolution_executed(r, %{"action" => "merged_draft", "draft_id" => draft.id})
true

# A draft with this title already exists (likely a prior run) — stop retrying.
{:error, :duplicate_title, existing} ->
mark_resolution_executed(r, %{
"action" => "noop",
"reason" => "title_exists",
"existing" => existing.id
})

false

other ->
Logger.warning(
"ConflictExecutor: merge draft create failed for #{r.id}: #{inspect(other)}"
)

false
end
end

defp merge_synthesizer do
Application.get_env(:loopctl, :merge_synthesizer, Loopctl.Knowledge.ClaudeMergeSynthesizer)
end

defp mark_resolution_executed(%ConflictResolution{} = r, execution_result) do
r
|> Ecto.Changeset.change(executed_at: DateTime.utc_now(), execution_result: execution_result)
Expand Down
103 changes: 103 additions & 0 deletions lib/loopctl/knowledge/claude_merge_synthesizer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
defmodule Loopctl.Knowledge.ClaudeMergeSynthesizer do
@moduledoc """
Anthropic Claude-backed `MergeSynthesizerBehaviour`. Combines two overlapping
knowledge articles into one, preserving every distinct fact and reconciling wording.

Reuses the shared `:anthropic_provider` config (same key as the classifier/extractors).
With no API key it returns `{:error, :not_configured}`, so the merge executor degrades
gracefully (leaves the `:merge` resolution unexecuted) rather than drafting a placeholder.
"""

@behaviour Loopctl.Knowledge.MergeSynthesizerBehaviour

require Logger

@system_prompt """
You merge TWO overlapping knowledge-base articles into ONE. Preserve every distinct \
fact, caveat, and example from both — do NOT drop information, and do NOT invent \
anything that isn't in the sources. If the two genuinely disagree on a point, keep both \
claims and note the disagreement rather than silently picking one. Prefer the clearer \
wording. Reply with a single compact JSON object with exactly two keys: "title" (a \
concise, problem-first title) and "body" (markdown). Output nothing except that JSON.\
"""

@max_body_chars 12_000

@impl true
def synthesize(a, b) do
config = Application.get_env(:loopctl, :anthropic_provider, %{})
api_key = config[:api_key] || ""

if api_key == "" do
{:error, :not_configured}
else
call_anthropic(a, b, config)
end
end

defp call_anthropic(a, b, config) do
base_url = config[:base_url] || "https://api.anthropic.com/v1"

model =
Application.get_env(:loopctl, :knowledge_merge_model) ||
config[:model] || "claude-haiku-4-5-20251001"

user_content =
"ARTICLE A\nTitle: #{a.title}\n\nBody:\n#{clip(a.body)}\n\n" <>
"ARTICLE B\nTitle: #{b.title}\n\nBody:\n#{clip(b.body)}"

req_body = %{
model: model,
max_tokens: 2000,
system: @system_prompt,
messages: [%{role: "user", content: user_content}]
}

case Req.post("#{base_url}/messages",
json: req_body,
headers: [
{"x-api-key", config[:api_key]},
{"anthropic-version", "2023-06-01"}
]
) do
{:ok, %{status: 200, body: resp}} ->
parse_text(get_in(resp, ["content", Access.at(0), "text"]))

{:ok, %{status: status}} ->
{:error, {:http_status, status}}

{:error, reason} ->
{:error, reason}
end
end

@doc """
Parses an LLM response into a merged article. Tolerant of markdown fences / prose by
extracting the first `{...}` object. Returns `{:error, :unparseable_merge}` for
anything without non-empty `title` and `body`. Public so it's unit-testable without HTTP.
"""
@spec parse_text(String.t() | nil) ::
{:ok, Loopctl.Knowledge.MergeSynthesizerBehaviour.article()}
| {:error, :unparseable_merge}
def parse_text(text) when is_binary(text) do
with {:ok, json} <- extract_json_object(text),
%{"title" => title, "body" => body} <- json,
true <- is_binary(title) and String.trim(title) != "",
true <- is_binary(body) and String.trim(body) != "" do
{:ok, %{title: title, body: body}}
else
_ -> {:error, :unparseable_merge}
end
end

def parse_text(_), do: {:error, :unparseable_merge}

defp extract_json_object(text) do
case Regex.run(~r/\{.*\}/s, String.trim(text)) do
[json] -> Jason.decode(json)
_ -> :error
end
end

defp clip(body), do: String.slice(body || "", 0, @max_body_chars)
end
29 changes: 29 additions & 0 deletions lib/loopctl/knowledge/merge_synthesizer_behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Loopctl.Knowledge.MergeSynthesizerBehaviour do
@moduledoc """
Behaviour for synthesizing ONE merged article from two overlapping ones — the
clerical half of a `:merge` conflict resolution (route-the-findings #4, step 2).

This is the only place an LLM touches conflict resolution, and it is deliberately an
EXECUTOR, not a judge: the merge only runs because a grounded agent already recorded
a `:merge` verdict on the pair. The synthesizer combines the two texts; it does not
decide whether they should be merged. Its output always lands as a DRAFT for review —
never auto-published — with both sources preserved, so a bad synthesis is harmless.

## Config-based DI

`config/test.exs` swaps in `Loopctl.MockMergeSynthesizer`; the default is
`Loopctl.Knowledge.ClaudeMergeSynthesizer`, resolved at call time via
`Application.get_env(:loopctl, :merge_synthesizer, ...)`.
"""

@type article :: %{title: String.t(), body: String.t()}

@doc """
Synthesize a merged article from two sources. Returns `{:ok, %{title, body}}` or
`{:error, term}` — implementations MUST return an error (never a placeholder) when the
backend is unavailable, so the executor leaves the resolution for retry rather than
drafting garbage.
"""
@callback synthesize(a :: article(), b :: article()) ::
{:ok, article()} | {:error, term()}
end
9 changes: 9 additions & 0 deletions mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ 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.28.0 — 2026-07-01 (route-the-findings: conflict merge)

### Changed

- **`knowledge_resolve_conflict`** — the `merge` disposition is now live: at
`confidence: "high"` the nightly executor has an LLM synthesize the two articles into
ONE new **draft** (both sources preserved, never auto-published, for human review).
Description updated to reflect that (previously "recorded for the later merge step").

## 2.27.0 — 2026-06-30 (route-the-findings: conflict resolution)

### Added
Expand Down
18 changes: 14 additions & 4 deletions mcp-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2467,7 +2467,9 @@ const TOOLS = [
"a keyword and a semantic sub-search, each capped at 100, so up to ~200), or filtered_set " +
"(list mode: the full set). Do NOT use a relevance-mode total_count to size the wiki — use " +
"list mode or knowledge_stats. " +
"Pass story_id when working on a loopctl story so reads attribute correctly.",
"Pass story_id when working on a loopctl story so reads attribute correctly. " +
"When you knowledge_get a result and it carries `potential_conflicts`, resolve it if it's " +
"material to your task (see knowledge_get / the conflict-resolution wiki playbook).",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -2520,7 +2522,12 @@ const TOOLS = [
name: "knowledge_get",
description:
"Get full article content by ID. Use after search to read an article in detail. " +
"Pass story_id when working on a loopctl story so reads attribute correctly.",
"Pass story_id when working on a loopctl story so reads attribute correctly. " +
"If the response carries a non-empty `potential_conflicts` array AND the conflict is " +
"material to your current task, act on it: read the peer, judge redundant/complementary/" +
"contradictory against the live system, and knowledge_resolve_conflict (dismiss a false " +
"positive, supersede when one clearly wins, merge when both should combine). If you can't " +
"tell which is right, leave it. See the 'Resolving knowledge conflicts' wiki playbook.",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -2957,7 +2964,9 @@ const TOOLS = [
"the two don't actually conflict; drops out of the queue immediately); 'supersede' " +
"(one article wins — pass authoritative_article_id, the winner; the nightly executor " +
"creates a supersedes link and retires the loser, but ONLY at confidence:\"high\" — " +
"reversible and audited); 'merge' (recorded for the later merge step). Non-destructive " +
"reversible and audited); 'merge' (at confidence:\"high\" the nightly executor has an LLM " +
"synthesize the two into ONE new DRAFT — both sources preserved, never auto-published, " +
"for you/a human to review and publish). Non-destructive " +
"at agent role — you record intent; the privileged nightly job executes it. " +
"Last-write-wins per pair, so re-recording with fresher ground truth overrides. " +
"Resolve only conflicts material to your current task; adjudicate against the actual " +
Expand All @@ -2979,7 +2988,8 @@ const TOOLS = [
enum: ["dismiss", "supersede", "merge"],
description:
"dismiss = false positive; supersede = one wins (set authoritative_article_id); " +
"merge = combine (recorded for the later merge step).",
"merge = combine both into one new DRAFT (LLM-synthesized by the nightly executor " +
"at high confidence; sources preserved, never auto-published).",
},
authoritative_article_id: {
type: "string",
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.27.0",
"version": "2.28.0",
"description": "MCP server for loopctl — structural trust for AI development loops",
"type": "module",
"main": "index.js",
Expand Down
Loading
Loading