Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: After documents are matched into a cluster, run a single LLM call to compare their contents and surface discrepancies (price, quantity, missing items, totals).
Architecture: New document_cluster table replaces bare UUID clustering. Reconciliation runs as the final step of the matching pipeline — after match-document!, before set-matching-status! "succeeded". One Sonnet call per affected cluster. Results stored as JSONB on document_cluster. UI renders cluster-level issues on each document's detail page.
Tech Stack: Clojure, PostgreSQL (Migratus migrations), Malli schemas, HTMX/Hiccup (UI), Claude Sonnet (LLM via com.getorcha.workers.llm/generate)
Design doc: docs/plans/2026-03-02-line-item-reconciliation-design.md
document_cluster table and migrate existing dataFiles:
resources/migrations/<timestamp>-add-document-cluster-table.up.sqlresources/migrations/<timestamp>-add-document-cluster-table.down.sqlUse bb migrate create "add-document-cluster-table" to generate the timestamp.
Step 1: Write the up migration
-- Create the document_cluster table
CREATE TABLE document_cluster (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
reconciliation jsonb,
reconciled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--;;
-- Backfill: create a document_cluster row for each existing distinct cluster_id
INSERT INTO document_cluster (id)
SELECT DISTINCT cluster_id FROM document WHERE cluster_id IS NOT NULL;
--;;
-- Add FK constraint (existing cluster_ids now reference document_cluster rows)
ALTER TABLE document
ADD CONSTRAINT fk_document_cluster
FOREIGN KEY (cluster_id) REFERENCES document_cluster(id) ON DELETE SET NULL;
Step 2: Write the down migration
ALTER TABLE document DROP CONSTRAINT IF EXISTS fk_document_cluster;
--;;
DROP TABLE IF EXISTS document_cluster;
Step 3: Run the migration locally
Run: bb migrate
Expected: Migration applies without errors.
Step 4: Verify in psql
Run: psql -h localhost -U postgres -d orcha -c "\d document_cluster"
Expected: Table with columns id, reconciliation, reconciled_at, created_at, updated_at.
Run: psql -h localhost -U postgres -d orcha -c "SELECT count(*) FROM document_cluster"
Expected: Count matches SELECT count(DISTINCT cluster_id) FROM document WHERE cluster_id IS NOT NULL.
Step 5: Commit
git add resources/migrations/*add-document-cluster-table*
git commit -m "feat: add document_cluster table with FK from document"
Files:
src/com/getorcha/schema/matching.cljStep 1: Write the schemas
Add at the end of src/com/getorcha/schema/matching.clj:
;; Reconciliation Schemas
;; -----------------------------------------------------------------------------
(def ReconciliationIssue
"A single issue found during cluster reconciliation."
[:map
[:severity [:enum "warning" "error"]]
[:category :string]
[:summary :string]
[:document-ids [:vector :string]]
[:details {:optional true}
[:vector
[:map
[:field :string]
[:expected [:maybe :string]]
[:actual [:maybe :string]]]]]])
(def ReconciliationResponse
"LLM response for cluster reconciliation."
[:map
[:status [:enum "reconciled" "discrepancies"]]
[:summary :string]
[:issues [:vector ReconciliationIssue]]])
Step 2: Run linter
Run: clj-kondo --lint src/com/getorcha/schema/matching.clj
Expected: No new warnings.
Step 3: Commit
git add src/com/getorcha/schema/matching.clj
git commit -m "feat: add Malli schemas for reconciliation response"
document_clusterFiles:
src/com/getorcha/db/document_matching.cljStep 1: Write the failing tests
Add to test/com/getorcha/db/document_matching_test.clj:
(deftest create-document-cluster-test
(testing "creates a document_cluster row and returns it"
(let [cluster (db.matching/create-document-cluster! fixtures/*db*)]
(is (uuid? (:document-cluster/id cluster)))
(is (some? (:document-cluster/created-at cluster))))))
(deftest set-reconciliation-test
(testing "stores reconciliation JSONB on a document_cluster"
(let [cluster (db.matching/create-document-cluster! fixtures/*db*)
cluster-id (:document-cluster/id cluster)
result {:status "reconciled"
:summary "All items reconciled"
:issues []}]
(db.matching/set-reconciliation! fixtures/*db* cluster-id result)
(let [updated (db.matching/get-document-cluster fixtures/*db* cluster-id)]
(is (= "reconciled" (get-in updated [:document-cluster/reconciliation :status])))
(is (some? (:document-cluster/reconciled-at updated)))))))
(deftest get-document-cluster-test
(testing "returns nil for non-existent cluster"
(is (nil? (db.matching/get-document-cluster fixtures/*db* (random-uuid))))))
Step 2: Run tests to verify they fail
Run: clj -X:test:silent :nses '[com.getorcha.db.document-matching-test]'
Expected: FAIL — functions don't exist yet.
Step 3: Implement the DB functions
Add to src/com/getorcha/db/document_matching.clj:
(defn create-document-cluster!
"Create a new document_cluster row. Returns the created row."
[db]
(db.sql/execute-one! db
{:insert-into :document-cluster
:values [{}]
:returning [:*]}))
(defn get-document-cluster
"Get a document_cluster by ID. Returns nil if not found."
[db cluster-id]
(db.sql/execute-one! db
{:select [:*]
:from [:document-cluster]
:where [:= :id cluster-id]}))
(defn set-reconciliation!
"Store reconciliation result on a document_cluster."
[db cluster-id result]
(db.sql/execute! db
{:update :document-cluster
:set {:reconciliation [:lift result]
:reconciled-at [:raw "now()"]}
:where [:= :id cluster-id]}))
(defn delete-document-cluster!
"Delete a document_cluster by ID."
[db cluster-id]
(db.sql/execute! db
{:delete-from :document-cluster
:where [:= :id cluster-id]}))
Step 4: Run tests to verify they pass
Run: clj -X:test:silent :nses '[com.getorcha.db.document-matching-test]'
Expected: All pass.
Step 5: Commit
git add src/com/getorcha/db/document_matching.clj test/com/getorcha/db/document_matching_test.clj
git commit -m "feat: add document_cluster DB operations"
assign-cluster! to use document_cluster tableThis is the critical refactor. assign-cluster! in src/com/getorcha/workers/matching/core.clj currently generates bare UUIDs. It must now create/merge document_cluster rows.
Files:
src/com/getorcha/workers/matching/core.cljsrc/com/getorcha/db/document_matching.clj (update set-cluster-id! if needed)Step 1: Update assign-cluster!
Replace the existing assign-cluster! in src/com/getorcha/workers/matching/core.clj:
(defn assign-cluster!
"Assign cluster to matched documents.
Cases:
- Neither has a cluster: create a new document_cluster, set both
- One has a cluster: add the other to it
- Both have different clusters: merge cluster-b into cluster-a, delete cluster-b
- Both share the same cluster: no-op"
[db doc-a-id doc-b-id]
(let [docs (db.matching/get-documents-by-ids db [doc-a-id doc-b-id])
doc-a (first (filter #(= doc-a-id (:document/id %)) docs))
doc-b (first (filter #(= doc-b-id (:document/id %)) docs))
cluster-a (:document/cluster-id doc-a)
cluster-b (:document/cluster-id doc-b)]
(cond
(and cluster-a cluster-b (= cluster-a cluster-b))
nil
(and cluster-a cluster-b)
(let [docs-to-move (db.matching/get-cluster-documents db cluster-b)]
(db.matching/set-cluster-id! db (mapv :document/id docs-to-move) cluster-a)
(db.matching/delete-document-cluster! db cluster-b))
cluster-a
(db.matching/set-cluster-id! db [doc-b-id] cluster-a)
cluster-b
(db.matching/set-cluster-id! db [doc-a-id] cluster-b)
:else
(let [{:document-cluster/keys [id]} (db.matching/create-document-cluster! db)]
(db.matching/set-cluster-id! db [doc-a-id doc-b-id] id)))))
Key changes from the original:
:else branch creates a document_cluster row instead of a bare (random-uuid)document_cluster row after moving docsStep 2: Run existing matching integration tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test]'
Expected: All existing tests pass. The tests check for cluster-id presence and equality, which still works. The new behavior is that cluster_id now references a document_cluster row.
Step 3: Run all matching tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.core-test]'
Expected: All pass.
Step 4: Commit
git add src/com/getorcha/workers/matching/core.clj src/com/getorcha/db/document_matching.clj
git commit -m "feat: assign-cluster! creates document_cluster rows instead of bare UUIDs"
format-document-summary publicFiles:
src/com/getorcha/workers/matching/llm_decision.cljtest/com/getorcha/workers/matching/llm_decision_test.clj (if tests reference the private var)Step 1: Remove ^:private from format-document-summary
In src/com/getorcha/workers/matching/llm_decision.clj line 39, change:
(defn ^:private format-document-summary
to:
(defn format-document-summary
Also make format-line-items public since it's used by format-document-summary:
Line 25, change ^:private to public:
(defn format-line-items
Step 2: Check if tests reference the private var
Look at test/com/getorcha/workers/matching/llm_decision_test.clj. If tests call #'llm-decision/format-document-summary, update them to call llm-decision/format-document-summary directly.
Step 3: Run tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.llm-decision-test]'
Expected: All pass.
Step 4: Run linter
Run: clj-kondo --lint src/com/getorcha/workers/matching/llm_decision.clj
Expected: No new warnings.
Step 5: Commit
git add src/com/getorcha/workers/matching/llm_decision.clj test/com/getorcha/workers/matching/llm_decision_test.clj
git commit -m "refactor: make format-document-summary public for reconciliation reuse"
Files:
src/com/getorcha/workers/matching/reconciliation.cljStep 1: Write the failing test
Create test/com/getorcha/workers/matching/reconciliation_test.clj:
(ns com.getorcha.workers.matching.reconciliation-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.schema.matching :as schema.matching]
[com.getorcha.workers.matching.reconciliation :as reconciliation]
[malli.core :as m]))
(deftest build-reconciliation-prompt-test
(testing "produces system + user prompt with all documents and their IDs"
(let [docs [{:document/id (random-uuid)
:document/type "invoice"
:document/structured-data
{:invoice-number "INV-001"
:issuer {:name "ACME Corp"}
:total 10000
:line-items [{:description "Widget" :quantity 100 :unit-price 100 :amount 10000}]}}
{:document/id (random-uuid)
:document/type "purchase-order"
:document/structured-data
{:po-number "PO-001"
:supplier {:name "ACME Corp"}
:total-value 10000
:line-items [{:description "Widget" :quantity 100 :unit-price 100 :amount 10000}]}}]
{:keys [system user]} (reconciliation/build-reconciliation-prompt docs)]
(is (string? system))
(is (string? user))
;; Both document IDs should appear in the user prompt
(is (re-find (re-pattern (str (:document/id (first docs)))) user))
(is (re-find (re-pattern (str (:document/id (second docs)))) user))
;; Document summaries should appear
(is (re-find #"INV-001" user))
(is (re-find #"PO-001" user)))))
(deftest parse-reconciliation-response-test
(testing "validates a correct reconciliation response"
(let [response "{\"status\": \"discrepancies\", \"summary\": \"1 issue found\", \"issues\": [{\"severity\": \"warning\", \"category\": \"price-discrepancy\", \"summary\": \"Unit price mismatch\", \"document_ids\": [\"abc\", \"def\"]}]}"
parsed (reconciliation/parse-reconciliation-response response)]
(is (m/validate schema.matching/ReconciliationResponse parsed))))
(testing "throws on invalid response"
(is (thrown? Exception
(reconciliation/parse-reconciliation-response "{\"bad\": true}")))))
Step 2: Run test to verify it fails
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.reconciliation-test]'
Expected: FAIL — namespace doesn't exist.
Step 3: Write the reconciliation namespace
Create src/com/getorcha/workers/matching/reconciliation.clj:
(ns com.getorcha.workers.matching.reconciliation
"Cluster-level line-item reconciliation.
After documents are matched into a cluster, this module runs a single LLM call
to compare their contents and surface discrepancies (price, quantity, missing
items, totals). Results are stored on the `document_cluster` row."
(:require [clojure.tools.logging :as log]
[com.getorcha.db.document-matching :as db.matching]
[com.getorcha.schema.matching :as schema.matching]
[com.getorcha.workers.llm :as llm]
[com.getorcha.workers.matching.llm-decision :as llm-decision]
[malli.core :as m]))
(def ^:private reconciliation-system-prompt
"You are a financial document reconciliation assistant.
You will receive a cluster of related financial documents (invoices, purchase orders,
contracts, goods received notes). Your task is to verify that their contents are
consistent with each other.
Check for:
- **Price discrepancies**: Does the invoice charge the same unit prices as the PO?
- **Quantity mismatches**: Does the invoiced quantity match what was ordered (PO) and
what was received (GRN)?
- **Missing or extra line items**: Do all invoice line items have a counterpart on the PO?
- **Total inconsistencies**: Does the invoice total align with contract values?
- **Cross-document discrepancies**: Is the invoice billing for quantities the GRN says
were never delivered?
Rules:
- Use a 0.5% tolerance for amount comparisons (e.g., 100.00 vs 100.40 is acceptable)
- Match line items by article code first, then by description similarity
- When quantities differ, always flag it — even small differences matter for AP teams
- Reference documents by their ID when reporting issues
Return JSON:
{
\"status\": \"reconciled\" or \"discrepancies\",
\"summary\": \"One sentence cluster-level summary\",
\"issues\": [
{
\"severity\": \"warning\" or \"error\",
\"category\": \"price-discrepancy\" | \"quantity-mismatch\" | \"unmatched-item\" | \"total-inconsistency\" | \"other\",
\"summary\": \"Human-readable description referencing specific documents and values\",
\"document_ids\": [\"<uuid>\", \"<uuid>\"],
\"details\": [{\"field\": \"unit-price\", \"expected\": \"100.00\", \"actual\": \"120.00\"}]
}
]
}
If everything checks out, return {\"status\": \"reconciled\", \"summary\": \"...\", \"issues\": []}.
If there are no line items to compare (e.g., only contracts and invoices with just totals),
compare what you can (totals, dates, scope) and report accordingly.")
(defn build-reconciliation-prompt
"Build the LLM prompt for cluster reconciliation.
Takes a seq of document DB rows with `:document/id`, `:document/type`,
and `:document/structured-data`."
[documents]
{:system reconciliation-system-prompt
:user (str "## Documents in this cluster\n\n"
(->> documents
(map (fn [doc]
(str "### Document " (:document/id doc) "\n"
(llm-decision/format-document-summary doc))))
(interpose "\n\n---\n\n")
(apply str))
"\n\n## Task\n"
"Compare these documents and report any discrepancies. "
"Return JSON as specified.")})
(defn parse-reconciliation-response
"Parse and validate LLM JSON response for reconciliation.
Converts snake_case keys to kebab-case for Malli validation.
Throws on malformed JSON or schema validation failure."
[response]
(let [parsed (-> (llm/parse-json-response response)
(update :issues (fn [issues]
(mapv (fn [issue]
(cond-> issue
(:document_ids issue)
(-> (assoc :document-ids (:document_ids issue))
(dissoc :document_ids))))
issues))))]
(when-not (m/validate schema.matching/ReconciliationResponse parsed)
(throw
(ex-info "Reconciliation response failed schema validation"
{:kind ::schema-validation-failure
:errors (m/explain schema.matching/ReconciliationResponse parsed)
:response-preview (subs response 0 (min 200 (count response)))})))
parsed))
(def ^:dynamic *retry-backoff-ms*
"Backoff base in ms between LLM retry attempts. Override in tests."
2000)
(defn ^:private transient-reconciliation-error?
"Returns true if the error is likely transient and worth retrying."
[e]
(let [{:keys [kind status]} (ex-data e)]
(case kind
:com.getorcha.workers.llm/api-error (or (= 429 status) (<= 500 status 599))
:com.getorcha.workers.llm/empty-response true
:com.getorcha.workers.llm/json-parse-error true
::schema-validation-failure true
false)))
(defn ^:private with-retry
"Calls f, retrying up to max-attempts on transient errors."
[f max-attempts]
(loop [attempt 1]
(let [result (try {:ok (f)} (catch Exception e {:error e}))]
(if-let [v (:ok result)]
v
(let [e (:error result)]
(if (or (not (transient-reconciliation-error? e)) (>= attempt max-attempts))
(throw e)
(do
(log/warn e "Reconciliation LLM call failed, retrying"
{:attempt attempt :max max-attempts})
(Thread/sleep ^long (* *retry-backoff-ms* (long (Math/pow 2 (dec attempt)))))
(recur (inc attempt)))))))))
(defn reconcile-cluster!
"Run reconciliation for a cluster. Loads all documents, calls LLM, stores result.
Arguments:
db - Database connection
llm-config - LLM provider config map (the `:matching` value, e.g. Gemini Flash)
cluster-id - UUID of the document_cluster to reconcile"
[db llm-config cluster-id]
(let [documents (db.matching/get-cluster-documents db cluster-id)]
(if (< (count documents) 2)
(log/debug "Cluster has fewer than 2 documents, skipping reconciliation"
{:cluster-id cluster-id :document-count (count documents)})
(let [{:keys [system user]} (build-reconciliation-prompt documents)
prompt (str system "\n\n" user)
{:keys [text input-tokens output-tokens model]} (with-retry #(llm/generate llm-config prompt) 3)
result (parse-reconciliation-response text)]
(log/info "Reconciliation complete"
{:cluster-id cluster-id
:status (:status result)
:issue-count (count (:issues result))
:input-tokens input-tokens
:output-tokens output-tokens
:model model})
(db.matching/set-reconciliation! db cluster-id result)))))
Step 4: Run tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.reconciliation-test]'
Expected: All pass.
Step 5: Run linter
Run: clj-kondo --lint src/com/getorcha/workers/matching/reconciliation.clj
Expected: No warnings.
Step 6: Commit
git add src/com/getorcha/workers/matching/reconciliation.clj test/com/getorcha/workers/matching/reconciliation_test.clj
git commit -m "feat: add reconciliation namespace with LLM prompt and response parsing"
process-document!Files:
src/com/getorcha/workers/matching/worker.cljStep 1: Write the failing test
Add to test/com/getorcha/workers/matching/worker_test.clj:
(deftest process-document-runs-reconciliation-test
(testing "calls reconcile-cluster! for affected clusters after matching"
(let [le-id (helpers/create-legal-entity!)
doc-id (helpers/create-document! le-id)
reconciled (atom [])
fake-cluster-id (random-uuid)
config {:db-pool fixtures/*db*
:llm-config {:matching {:provider :test}}
:search-config {}
:notifications {}}]
(db.sql/execute-one! fixtures/*db*
{:update :document
:set {:type (db.sql/->cast :invoice :document-type)
:structured-data [:lift {:issuer {:name "X"}}]}
:where [:= :id doc-id]})
(with-redefs [com.getorcha.workers.matching.core/match-document!
(fn [db _ _ _]
;; Simulate matching: create a cluster and assign the doc
(let [{:document-cluster/keys [id]} (db.matching/create-document-cluster! db)]
(db.matching/set-cluster-id! db [doc-id] id)))
com.getorcha.workers.matching.reconciliation/reconcile-cluster!
(fn [_ _ cluster-id]
(swap! reconciled conj cluster-id))]
(#'worker/process-document! config doc-id))
(is (= 1 (count @reconciled))
"Should reconcile exactly one cluster")
(is (= "succeeded" (:document/matching-status (get-document-status doc-id)))))))
Step 2: Run test to verify it fails
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.worker-test]'
Expected: FAIL — reconciliation not wired in yet.
Step 3: Update process-document!
In src/com/getorcha/workers/matching/worker.clj:
Add to requires:
[com.getorcha.workers.matching.reconciliation :as reconciliation]
Replace the process-document! function:
(defn ^:private process-document!
"Processes a single document through the matching pipeline.
Sets matching_status to in-progress at start and succeeded on completion.
Runs reconciliation on all affected clusters after matching."
[{:keys [db-pool llm-config search-config notifications aws] :as _context} document-id]
(let [doc (fetch-document db-pool document-id)]
(when (nil? doc)
(throw (ex-info "Document not found for matching"
{:document-id document-id
:kind ::document-not-found})))
(when (nil? (:document/structured-data doc))
(throw (ex-info "Document missing structured data, skipping matching"
{:document-id document-id
:kind ::missing-structured-data})))
(log/info "Processing document for matching"
{:document-id document-id
:document-type (:document/type doc)
:legal-entity-id (:document/legal-entity-id doc)})
(mdc/put! :legal-entity-id (:document/legal-entity-id doc))
(xray/add-annotation! "legal_entity_id" (str (:document/legal-entity-id doc)))
(db.matching/set-matching-status! db-pool document-id
{:status "in-progress"
:increment-attempts true})
(let [cluster-before (:document/cluster-id doc)
_ (matching/match-document! db-pool search-config (:matching llm-config) doc)
doc-after (fetch-document db-pool document-id)
cluster-after (:document/cluster-id doc-after)
affected (disj (cond-> #{}
cluster-before (conj cluster-before)
cluster-after (conj cluster-after))
nil)]
(doseq [cluster-id affected]
(try
(reconciliation/reconcile-cluster! db-pool (:matching llm-config) cluster-id)
(catch Exception e
(log/warn e "Reconciliation failed for cluster, continuing"
{:cluster-id cluster-id
:document-id document-id})
(try
(notifications/notify-admins!
{:db-pool db-pool
:aws aws
:notifications notifications}
{:legal-entity/id (:document/legal-entity-id doc)}
{:kind :reconciliation/failure
:title (str "Reconciliation failed for cluster " cluster-id)
:body (str "Document: " document-id
"\nCluster: " cluster-id
"\nError: " (ex-message e))})
(catch Exception notify-err
(log/warn notify-err "Failed to send reconciliation failure notification")))))))
(db.matching/set-matching-status! db-pool document-id {:status "succeeded"})
(log/info "Document matching complete" {:document-id document-id})))
Step 4: Run all worker tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.worker-test]'
Expected: All pass (including the new test and existing tests).
Step 5: Run matching integration tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test]'
Expected: All pass. Integration tests don't provide LLM config so reconcile-cluster! will be called with nil — need to handle this. Check: if llm-config is nil, reconcile-cluster! should skip (the tests pass nil for llm-config and (:matching nil) = nil). Add a guard to reconcile-cluster!:
;; At the top of reconcile-cluster!
(when (nil? llm-config)
(log/debug "No LLM config provided, skipping reconciliation" {:cluster-id cluster-id})
(return))
Actually, use an early return pattern:
(defn reconcile-cluster!
[db llm-config cluster-id]
(if (nil? llm-config)
(log/debug "No LLM config, skipping reconciliation" {:cluster-id cluster-id})
(let [documents (db.matching/get-cluster-documents db cluster-id)]
...)))
Step 6: Run full matching test suite
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test com.getorcha.workers.matching.worker-test com.getorcha.workers.matching.core-test]'
Expected: All pass.
Step 7: Commit
git add src/com/getorcha/workers/matching/worker.clj test/com/getorcha/workers/matching/worker_test.clj src/com/getorcha/workers/matching/reconciliation.clj
git commit -m "feat: wire reconciliation into matching pipeline after match-document!"
Files:
test/com/getorcha/workers/matching/reconciliation_integration_test.cljThis tests the full flow: create documents, match them, verify reconciliation is stored on the document_cluster.
Step 1: Write the integration test
(ns com.getorcha.workers.matching.reconciliation-integration-test
"Integration test for reconciliation — verifies that after matching,
document_cluster.reconciliation is populated."
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.db.document-matching :as db.matching]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.search :as search]
[com.getorcha.test.fixtures :as fixtures]
[com.getorcha.test.notification-helpers :as helpers]
[com.getorcha.workers.matching.reconciliation :as reconciliation]))
(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)
(defn ^:private create-document-with-type!
[legal-entity-id type structured-data]
(let [doc-id (helpers/create-document! legal-entity-id)]
(db.sql/execute-one!
fixtures/*db*
{:update :document
:set {:type (db.sql/->cast type :document-type)
:structured-data [:lift structured-data]}
:where [:= :id doc-id]})
doc-id))
(defn ^:private get-document [doc-id]
(db.sql/execute-one! fixtures/*db*
{:select [:*] :from [:document] :where [:= :id doc-id]}))
(deftest reconcile-cluster-with-matching-documents-test
(testing "reconciliation stores result on document_cluster after LLM call"
(let [le-id (helpers/create-legal-entity!)
inv-id (create-document-with-type!
le-id :invoice
{:invoice-number "INV-REC-001"
:issuer {:name "ACME Corp" :vat-id "DE123"}
:total 10000
:line-items [{:description "Widget" :quantity 100
:unit-price 100 :amount 10000}]})
po-id (create-document-with-type!
le-id :purchase-order
{:po-number "PO-REC-001"
:supplier {:name "ACME Corp" :vat-id "DE123"}
:total-value 10000
:line-items [{:description "Widget" :quantity 100
:unit-price 100 :amount 10000}]})
;; Create a cluster and assign both docs
{:document-cluster/keys [id]} (db.matching/create-document-cluster! fixtures/*db*)]
(db.matching/set-cluster-id! fixtures/*db* [inv-id po-id] id)
;; Stub the LLM to return a clean reconciliation
(with-redefs [com.getorcha.workers.llm/generate
(fn [_ _]
{:text "{\"status\": \"reconciled\", \"summary\": \"All items match\", \"issues\": []}"
:input-tokens 100
:output-tokens 50
:model "test"})]
(reconciliation/reconcile-cluster! fixtures/*db* {:provider :test} id))
;; Verify reconciliation was stored
(let [cluster (db.matching/get-document-cluster fixtures/*db* id)]
(is (= "reconciled" (get-in cluster [:document-cluster/reconciliation :status])))
(is (some? (:document-cluster/reconciled-at cluster)))))))
(deftest reconcile-cluster-with-discrepancies-test
(testing "reconciliation stores discrepancy issues from LLM"
(let [le-id (helpers/create-legal-entity!)
inv-id (create-document-with-type!
le-id :invoice
{:invoice-number "INV-DISC-001"
:issuer {:name "ACME Corp"}
:total 12000
:line-items [{:description "Widget" :quantity 100
:unit-price 120 :amount 12000}]})
po-id (create-document-with-type!
le-id :purchase-order
{:po-number "PO-DISC-001"
:supplier {:name "ACME Corp"}
:total-value 10000
:line-items [{:description "Widget" :quantity 100
:unit-price 100 :amount 10000}]})
{:document-cluster/keys [id]} (db.matching/create-document-cluster! fixtures/*db*)
llm-response (str "{\"status\": \"discrepancies\","
"\"summary\": \"Price discrepancy found\","
"\"issues\": [{\"severity\": \"error\","
"\"category\": \"price-discrepancy\","
"\"summary\": \"Invoice charges 120/unit vs PO 100/unit\","
"\"document_ids\": [\"" inv-id "\", \"" po-id "\"],"
"\"details\": [{\"field\": \"unit-price\","
"\"expected\": \"100.00\", \"actual\": \"120.00\"}]}]}")]
(db.matching/set-cluster-id! fixtures/*db* [inv-id po-id] id)
(with-redefs [com.getorcha.workers.llm/generate
(fn [_ _] {:text llm-response :input-tokens 200 :output-tokens 100 :model "test"})]
(reconciliation/reconcile-cluster! fixtures/*db* {:provider :test} id))
(let [cluster (db.matching/get-document-cluster fixtures/*db* id)
recon (:document-cluster/reconciliation cluster)]
(is (= "discrepancies" (:status recon)))
(is (= 1 (count (:issues recon))))
(is (= "error" (-> recon :issues first :severity)))))))
(deftest reconcile-cluster-skips-single-document-test
(testing "clusters with fewer than 2 documents are skipped"
(let [le-id (helpers/create-legal-entity!)
doc-id (create-document-with-type!
le-id :invoice {:invoice-number "SOLO" :issuer {:name "X"}})
{:document-cluster/keys [id]} (db.matching/create-document-cluster! fixtures/*db*)]
(db.matching/set-cluster-id! fixtures/*db* [doc-id] id)
;; Should not throw, should not call LLM
(reconciliation/reconcile-cluster! fixtures/*db* {:provider :test} id)
;; Reconciliation should remain nil
(let [cluster (db.matching/get-document-cluster fixtures/*db* id)]
(is (nil? (:document-cluster/reconciliation cluster)))))))
Step 2: Run the tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.reconciliation-integration-test]'
Expected: All pass.
Step 3: Commit
git add test/com/getorcha/workers/matching/reconciliation_integration_test.clj
git commit -m "test: add reconciliation integration tests"
get-matched-documents to include reconciliation dataThe UI needs reconciliation data when rendering the matches section. The existing get-matched-documents query needs to join document_cluster to fetch reconciliation.
Files:
src/com/getorcha/db/document_matching.cljStep 1: Add a function to get cluster reconciliation for a document
Add to src/com/getorcha/db/document_matching.clj:
(defn get-cluster-reconciliation
"Get the reconciliation data for a document's cluster.
Returns nil if the document has no cluster or no reconciliation."
[db document-id]
(db.sql/execute-one! db
{:select [:document-cluster.reconciliation
:document-cluster.reconciled-at]
:from [:document]
:join [[:document-cluster] [:= :document.cluster-id :document-cluster.id]]
:where [:= :document.id document-id]}))
Step 2: Run linter
Run: clj-kondo --lint src/com/getorcha/db/document_matching.clj
Expected: No warnings.
Step 3: Commit
git add src/com/getorcha/db/document_matching.clj
git commit -m "feat: add get-cluster-reconciliation query"
Files:
src/com/getorcha/erp/http/documents/view/shared.cljresources/erp/public/css/style.cssStep 1: Add reconciliation status badge component
In src/com/getorcha/erp/http/documents/view/shared.clj, add a helper function near the match card functions (around line 200):
(defn ^:private reconciliation-badge
"Renders the reconciliation status badge for the matches section."
[reconciliation]
(when reconciliation
(let [{:keys [status issues]} reconciliation
issue-count (count issues)]
(case status
"reconciled"
[:span.reconciliation-badge.reconciliation-ok
[:iconify-icon {:icon "lucide:check-circle" :style "font-size: 14px;"}]
"Reconciled"]
"discrepancies"
[:span.reconciliation-badge.reconciliation-warning
[:iconify-icon {:icon "lucide:alert-triangle" :style "font-size: 14px;"}]
(str issue-count " issue" (when (not= 1 issue-count) "s") " found")]
nil))))
Step 2: Add issue rendering functions
(defn ^:private document-reference
"Renders a document reference — link for other docs, plain text for current doc."
[router current-doc-id doc-id doc-type-label]
(if (= (str current-doc-id) (str doc-id))
[:span.reconciliation-self-ref (str "this " doc-type-label)]
[:a {:href (erp.http.routes/path-for router
:com.getorcha.erp.http.documents.view/detail
{:document-id doc-id})}
doc-type-label]))
(defn ^:private reconciliation-issue
"Renders a single reconciliation issue."
[router current-doc-id cluster-documents issue]
(let [{:keys [severity summary details]} issue]
[:div.reconciliation-issue
[:div.reconciliation-issue-header
[:iconify-icon {:icon (if (= severity "error") "lucide:x-circle" "lucide:alert-triangle")
:class (str "reconciliation-severity-" severity)
:style "font-size: 14px;"}]
[:span summary]]
(when (seq details)
[:div.reconciliation-issue-details
[:table.reconciliation-table
[:thead
[:tr [:th "Field"] [:th "Expected"] [:th "Actual"]]]
[:tbody
(for [{:keys [field expected actual]} details]
[:tr
[:td field]
[:td (or expected "—")]
[:td (or actual "—")]])]]])]))
(defn ^:private reconciliation-section
"Renders the reconciliation issues list below match cards."
[router current-doc-id cluster-documents reconciliation]
(when-let [{:keys [issues]} reconciliation]
(when (seq issues)
[:div.reconciliation-issues
(for [issue issues]
(reconciliation-issue router current-doc-id cluster-documents issue))])))
Step 3: Update matches-section to include reconciliation
Modify the matches-section function to accept and render reconciliation data. The function currently has signature:
(defn ^:private matches-section
[router document-id doc-type matches matching-status]
Change to:
(defn ^:private matches-section
[router document-id doc-type matches matching-status reconciliation]
Inside the collapsible-section call, change the title from "Matches" to include the badge:
(erp.ui.components/collapsible-section
(list "Matches" (reconciliation-badge reconciliation))
"section-matches"
(list
;; ... existing SSE + match cards ...
(reconciliation-section router document-id nil reconciliation)))
Step 4: Update all callers of matches-section
Find all places that call matches-section and add the reconciliation parameter. This includes:
detail-page-content — needs to receive reconciliation from the handlermatching-complete event handler — needs to fetch reconciliationIn the SSE handler (around line 940), update:
:matching
(let [document (db.sql/execute-one! db-pool
{:select [:type] :from [:document] :where [:= :id document-id]})
doc-type (some-> (:document/type document) keyword)
matches (db.matching/get-matched-documents db-pool document-id)
reconciliation (some-> (db.matching/get-cluster-reconciliation db-pool document-id)
:document-cluster/reconciliation)]
(when doc-type
{:event "matching-complete"
:data (hiccup/html
(matches-section router document-id doc-type matches nil reconciliation))}))
Step 5: Add CSS
Add to resources/erp/public/css/style.css:
/* Reconciliation */
.reconciliation-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
margin-left: 8px;
}
.reconciliation-ok {
color: #3fb950;
background: rgba(63, 185, 80, 0.1);
}
.reconciliation-warning {
color: #d29922;
background: rgba(210, 153, 34, 0.1);
}
.reconciliation-issues {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.reconciliation-issue {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
}
.reconciliation-issue-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.reconciliation-severity-error {
color: #f85149;
}
.reconciliation-severity-warning {
color: #d29922;
}
.reconciliation-issue-details {
margin-top: 8px;
}
.reconciliation-table {
width: 100%;
font-size: 12px;
border-collapse: collapse;
}
.reconciliation-table th {
text-align: left;
color: #8b949e;
padding: 4px 8px;
border-bottom: 1px solid #30363d;
}
.reconciliation-table td {
padding: 4px 8px;
border-bottom: 1px solid #21262d;
}
.reconciliation-self-ref {
font-style: italic;
color: #8b949e;
}
Step 6: Verify manually
Start the dev server, navigate to a document detail page that has matches. The reconciliation badge should appear (or not, if no reconciliation has run). To test with data, either:
Step 7: Run linter
Run: clj-kondo --lint src/com/getorcha/erp/http/documents/view/shared.clj
Expected: No warnings.
Step 8: Commit
git add src/com/getorcha/erp/http/documents/view/shared.clj resources/erp/public/css/style.css src/com/getorcha/db/document_matching.clj
git commit -m "feat: add reconciliation badge and issues list to matches section UI"
llm-config key for reconciliationThe matching worker currently uses :fast (Gemini Flash) for LLM match decisions. Reconciliation should use :main (Claude Sonnet) as specified in the design doc. The config needs a :reconciliation key alongside :matching.
Files:
resources/com/getorcha/config.ednsrc/com/getorcha/workers/matching/worker.cljStep 1: Add reconciliation LLM config
In resources/com/getorcha/config.edn, in the matching orchestrator config, update llm-config:
:llm-config {:matching #ref [:com.getorcha/llm :fast]
:reconciliation #ref [:com.getorcha/llm :main]}
Step 2: Update process-document! to use reconciliation config
In worker.clj, change the reconcile-cluster! call to use :reconciliation instead of :matching:
(reconciliation/reconcile-cluster! db-pool (:reconciliation llm-config) cluster-id)
Step 3: Run all tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.worker-test com.getorcha.workers.matching.integration-test]'
Expected: All pass. Test config should also have the :reconciliation key or tests pass nil which triggers the skip guard.
Step 4: Commit
git add resources/com/getorcha/config.edn src/com/getorcha/workers/matching/worker.clj
git commit -m "feat: add separate Sonnet LLM config for reconciliation"
Step 1: Run linter on all source
Run: clj-kondo --lint src test dev
Expected: No new warnings.
Step 2: Run all matching-related tests
Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test com.getorcha.workers.matching.worker-test com.getorcha.workers.matching.core-test com.getorcha.workers.matching.reconciliation-test com.getorcha.workers.matching.reconciliation-integration-test com.getorcha.db.document-matching-test]'
Expected: All pass.
Step 3: Run full test suite
Run: clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
Expected: All tests pass, no regressions.
Step 4: Commit any fixes if needed
New files:
resources/migrations/<ts>-add-document-cluster-table.up.sqlresources/migrations/<ts>-add-document-cluster-table.down.sqlsrc/com/getorcha/workers/matching/reconciliation.cljtest/com/getorcha/workers/matching/reconciliation_test.cljtest/com/getorcha/workers/matching/reconciliation_integration_test.cljModified files:
src/com/getorcha/schema/matching.clj — add reconciliation schemassrc/com/getorcha/db/document_matching.clj — add document_cluster CRUD + reconciliation querysrc/com/getorcha/workers/matching/core.clj — assign-cluster! uses document_cluster rowssrc/com/getorcha/workers/matching/llm_decision.clj — make format-document-summary publicsrc/com/getorcha/workers/matching/worker.clj — wire reconciliation into process-document!src/com/getorcha/erp/http/documents/view/shared.clj — reconciliation badge + issues UIresources/erp/public/css/style.css — reconciliation stylesresources/com/getorcha/config.edn — add reconciliation LLM configtest/com/getorcha/db/document_matching_test.clj — tests for new DB functionstest/com/getorcha/workers/matching/worker_test.clj — test reconciliation wiring