diff --git a/config/config.exs b/config/config.exs index 544976e..1516071 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/config/test.exs b/config/test.exs index 1f6201b..06e0b93 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/loopctl/knowledge.ex b/lib/loopctl/knowledge.ex index e3f1655..a26482d 100644 --- a/lib/loopctl/knowledge.ex +++ b/lib/loopctl/knowledge.ex @@ -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 @@ -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} @@ -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) diff --git a/lib/loopctl/knowledge/claude_merge_synthesizer.ex b/lib/loopctl/knowledge/claude_merge_synthesizer.ex new file mode 100644 index 0000000..cd228e4 --- /dev/null +++ b/lib/loopctl/knowledge/claude_merge_synthesizer.ex @@ -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 diff --git a/lib/loopctl/knowledge/merge_synthesizer_behaviour.ex b/lib/loopctl/knowledge/merge_synthesizer_behaviour.ex new file mode 100644 index 0000000..53cc4cc --- /dev/null +++ b/lib/loopctl/knowledge/merge_synthesizer_behaviour.ex @@ -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 diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index cfb9106..263aace 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -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 diff --git a/mcp-server/index.js b/mcp-server/index.js index 4e48dcd..8182134 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -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: { @@ -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: { @@ -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 " + @@ -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", diff --git a/mcp-server/package.json b/mcp-server/package.json index da5f03e..aa95b9c 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -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", diff --git a/test/loopctl/knowledge/claude_merge_synthesizer_test.exs b/test/loopctl/knowledge/claude_merge_synthesizer_test.exs new file mode 100644 index 0000000..ba5f46e --- /dev/null +++ b/test/loopctl/knowledge/claude_merge_synthesizer_test.exs @@ -0,0 +1,36 @@ +defmodule Loopctl.Knowledge.ClaudeMergeSynthesizerTest do + use ExUnit.Case, async: true + + alias Loopctl.Knowledge.ClaudeMergeSynthesizer, as: Synth + + describe "parse_text/1" do + test "parses a clean JSON object" do + assert {:ok, %{title: "T", body: "B"}} = + Synth.parse_text(~s({"title": "T", "body": "B"})) + end + + test "tolerates markdown fences and surrounding prose" do + text = "Here you go:\n```json\n{\"title\": \"Merged\", \"body\": \"Body text\"}\n```\n" + assert {:ok, %{title: "Merged", body: "Body text"}} = Synth.parse_text(text) + end + + test "rejects empty title or body" do + assert {:error, :unparseable_merge} = Synth.parse_text(~s({"title": "", "body": "B"})) + assert {:error, :unparseable_merge} = Synth.parse_text(~s({"title": "T", "body": " "})) + end + + test "rejects non-JSON / missing keys / nil" do + assert {:error, :unparseable_merge} = Synth.parse_text("not json") + assert {:error, :unparseable_merge} = Synth.parse_text(~s({"title": "T"})) + assert {:error, :unparseable_merge} = Synth.parse_text(nil) + end + end + + describe "synthesize/2 without a configured backend" do + test "returns :not_configured rather than a placeholder" do + # No :anthropic_provider api_key in :test → graceful degrade. + assert {:error, :not_configured} = + Synth.synthesize(%{title: "A", body: "a"}, %{title: "B", body: "b"}) + end + end +end diff --git a/test/loopctl/knowledge/potential_conflicts_test.exs b/test/loopctl/knowledge/potential_conflicts_test.exs index f4fc8ce..132895b 100644 --- a/test/loopctl/knowledge/potential_conflicts_test.exs +++ b/test/loopctl/knowledge/potential_conflicts_test.exs @@ -233,4 +233,80 @@ defmodule Loopctl.Knowledge.PotentialConflictsTest do assert %{authoritative_article_id: _} = errors_on(changeset) end end + + describe "merge executor (#4 step 2)" do + import Mox + + setup do + tenant = fixture(:tenant) + a = published(tenant.id, "XML in Elixir with xmerl") + b = published(tenant.id, "How to parse XML documents in Elixir") + conflict_link(tenant.id, a, b, 0.98) + %{tenant: tenant, a: a, b: b} + end + + test "high-confidence merge synthesizes a DRAFT, links both sources, leaves them intact", + ctx do + %{tenant: t, a: a, b: b} = ctx + + stub(Loopctl.MockMergeSynthesizer, :synthesize, fn _a, _b -> + {:ok, %{title: "Parsing XML in Elixir (xmerl)", body: "Merged body covering both."}} + end) + + {:ok, _} = + Knowledge.annotate_conflict(t.id, %{ + "source_article_id" => a.id, + "target_article_id" => b.id, + "disposition" => "merge", + "authoritative_article_id" => a.id, + "confidence" => "high" + }) + + assert 1 == Knowledge.execute_conflict_resolutions(t.id) + + # A new DRAFT exists, tagged merged, pointing at both sources. + draft = + AdminRepo.get_by(Loopctl.Knowledge.Article, + tenant_id: t.id, + title: "Parsing XML in Elixir (xmerl)" + ) + + assert draft.status == :draft + assert draft.category == a.category + assert draft.metadata["merged_from"] == [min(a.id, b.id), max(a.id, b.id)] + + links = + AdminRepo.all( + from(l in ArticleLink, + where: l.source_article_id == ^draft.id and l.relationship_type == :relates_to, + select: l.target_article_id + ) + ) + + assert Enum.sort(links) == Enum.sort([a.id, b.id]) + + # Sources are untouched (never destroyed by a merge). + assert AdminRepo.get(Loopctl.Knowledge.Article, a.id).status == :published + assert AdminRepo.get(Loopctl.Knowledge.Article, b.id).status == :published + end + + test "merge is NOT executed when the synthesizer has no backend (stays for retry)", ctx do + %{tenant: t, a: a, b: b} = ctx + # Default stub returns {:error, :not_configured}. + + {:ok, _} = + Knowledge.annotate_conflict(t.id, %{ + "source_article_id" => a.id, + "target_article_id" => b.id, + "disposition" => "merge", + "authoritative_article_id" => a.id, + "confidence" => "high" + }) + + assert 0 == Knowledge.execute_conflict_resolutions(t.id) + + row = AdminRepo.get_by(Loopctl.Knowledge.ConflictResolution, tenant_id: t.id) + assert is_nil(row.executed_at) + end + end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 576aa46..7c73fd5 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -198,6 +198,10 @@ defmodule Loopctl.DataCase do %{verdict: :novel, score: nil, neighbors: []} end) + # Merge synthesizer defaults to "no backend" so the conflict executor no-ops on + # :merge rows unless a test opts in with a real merged result. + Mox.stub(Loopctl.MockMergeSynthesizer, :synthesize, fn _a, _b -> {:error, :not_configured} end) + # US-27.3: the DBErrorBackstop test seam (Loopctl.Test.BackstopRouter) is a # REAL plug wired via config/test.exs that delegates to LoopctlWeb.Router for # every request and only raises when an opt-in `x-test-raise-db-error` header diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 9e9704c..af91edc 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -11,6 +11,7 @@ Mox.defmock(Loopctl.MockWebAuthn, for: Loopctl.WebAuthn.Behaviour) Mox.defmock(Loopctl.MockSecrets, for: Loopctl.Secrets.Behaviour) Mox.defmock(Loopctl.MockSuggestLinks, for: Loopctl.Knowledge.SuggestLinksBehaviour) Mox.defmock(Loopctl.MockProposalAssessor, for: Loopctl.Knowledge.ProposalAssessorBehaviour) +Mox.defmock(Loopctl.MockMergeSynthesizer, for: Loopctl.Knowledge.MergeSynthesizerBehaviour) # US-27.15: webhook delivery DI. ScaleAlerts and the webhook worker share the # `:webhook_delivery` key. In :test it resolves to this mock; the DataCase default stub # delegates to Loopctl.Webhooks.ReqDelivery so the existing Req.Test-stub-based webhook