Skip to content

feat: classification review UI with bulk-accept guardrails#88

Merged
chitcommit merged 3 commits intomainfrom
feat/classification-review-ui
Apr 10, 2026
Merged

feat: classification review UI with bulk-accept guardrails#88
chitcommit merged 3 commits intomainfrom
feat/classification-review-ui

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented Apr 10, 2026

Summary

Frontend page for the trust-path classification workflow built in #86 and #87.

What's included

  • New page /classification — unclassified transaction queue with per-row classify/reconcile actions
  • Three bulk actions: Keyword Suggest (L1), AI Suggest (L1 via GPT-4o-mini), Accept High-Confidence (L2)
  • Stats cards: total, classified %, reconciled, unclassified
  • Sort modes: most recent, largest amount, lowest/highest confidence

bulkAcceptCandidates guardrails (defense in depth)

Layer Rule Why
Confidence floor >= 0.80 Matches the AI "success" threshold from classification-ai.ts
Semantic exclusion coaCode !== '9010' Suspense code means "needs a human" — bulk-accepting defeats the point
Dollar cap abs(amount) <= $500 Large-dollar errors carry outsized audit exposure regardless of confidence

All three must pass. Large rent payments, tax bills, and ambiguous charges always require explicit click.

Architecture

  • TanStack Query hooks scoped by tenantId — cache invalidates automatically on tenant switch
  • Mutations invalidate both /api/classification/stats and /api/classification/unclassified so the UI stays in sync after any action
  • No new dependencies — uses existing @/lib/queryClient, @/contexts/TenantContext, and Tailwind theme

Test plan

  • TypeScript check passes (0 errors)
  • 221 tests pass
  • Manual: visit /classification → verify queue loads
  • Manual: click AI Suggest → verify stats update
  • Manual: click Accept High-Confidence with no qualifying rows → verify no-op message
  • Manual: classify a row manually → verify it disappears from queue
  • Manual: reconcile a classified row → verify lock icon + read-only state

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a new Classification page accessible to finance roles (CFO, accountant, bookkeeper) for managing unclassified transactions
    • Integrated AI-powered suggestions to recommend chart of accounts codes for transactions
    • Enabled bulk acceptance of high-confidence suggestions and manual transaction classification
    • Added sorting and filtering options to organize transaction queues by date, amount, and confidence level
    • Added sidebar navigation link to the Classification section

chitcommit and others added 3 commits April 10, 2026 20:03
Adds a new POST /api/classification/ai-suggest endpoint that batches
up to 25 unclassified transactions through GPT-4o-mini with structured
JSON output, falling back to keyword matching on any failure.

Library (server/lib/classification-ai.ts):
- classifyBatchWithAI() — per-request OpenAI client (Workers-safe)
- Validates AI-returned codes against the tenant's COA (rejects hallucinations)
- Clamps confidence to [0, 1]
- Always returns one suggestion per input (gap-fills with keyword fallback)
- Respects CF AI Gateway via AI_GATEWAY_ENDPOINT env

Route:
- Only processes transactions without an existing suggested_coa_code
  (respects prior human/auto work)
- Writes via L1 suggest (never authoritative coa_code)
- Returns {processed, suggested, aiCount, keywordCount, aiAvailable}

Tests (9 new):
- Empty input, missing key, API error, malformed JSON, missing choices
- Confidence clamping, hallucinated-code rejection, gap-filling
- Batch size guard (MAX_BATCH=25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Docstring now correctly describes gpt-4o-mini default (was: GPT-4o)
- Remove unused `date` field from ClassifiableTransaction interface
- ai-suggest only swallows ClassificationError.reconciled_locked;
  DB/runtime failures now propagate to the shared error handler
  instead of being silently skipped
- `processed` field is now consistent: all branches return txns.length
  (count of transactions fetched and considered), matching the semantics
  of the early-return paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Frontend for the trust-path classification workflow added in #86/#87.

New: client/src/pages/Classification.tsx
- Stats cards (total, classified, reconciled, unclassified + %)
- Unclassified queue sorted by date/amount/confidence (ascending or
  descending)
- Per-row actions: accept suggested code (one-click), classify as any
  COA code (dropdown), reconcile (L3 lock)
- Three bulk actions:
  - Keyword Suggest: runs /api/classification/batch-suggest (L1)
  - AI Suggest (GPT): runs /api/classification/ai-suggest (L1, 25 at a time)
  - Accept High-Confidence: writes authoritative coa_code (L2) for rows
    passing the bulkAcceptCandidates guardrails

bulkAcceptCandidates rules (defense in depth):
- Confidence >= 0.8 (AI "success" threshold)
- suggestedCoaCode !== '9010' (suspense means "needs a human")
- |amount| <= $500 (large-dollar → manual review regardless of confidence)

New: client/src/hooks/use-classification.ts
- TanStack Query hooks scoped by tenantId for automatic cache invalidation
  on tenant switch
- Queries: coa, stats, unclassified, audit trail
- Mutations: classify (L2), suggest (L1 manual), reconcile (L3),
  batch-suggest (L1 keyword), ai-suggest (L1 GPT)

Wiring:
- /classification route added to App.tsx
- Sidebar link added (cfo, accountant, bookkeeper roles)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 10, 2026 22:15
@chitcommit chitcommit enabled auto-merge (squash) April 10, 2026 22:15
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

A new classification feature is introduced with routing, navigation sidebar updates, client-side React Query hooks for classification workflows, a Classification page component with sorting and bulk acceptance controls, server-side AI-powered batch classification logic using OpenAI, an API route for AI suggestions, and comprehensive test coverage for the AI classifier.

Changes

Cohort / File(s) Summary
Routing & Navigation
client/src/App.tsx, client/src/components/layout/Sidebar.tsx
Added /classification route registration and navigation entry with Tags icon, restricted to cfo/accountant/bookkeeper roles.
Classification Hooks
client/src/hooks/use-classification.ts
Introduced React Query hooks for classification data fetching (chart of accounts, stats, unclassified transactions, audit history) and mutations (classify, suggest, reconcile, batch suggestion operations) with automatic cache invalidation on success.
Classification UI Page
client/src/pages/Classification.tsx
Created new Classification page with transaction queue sorting, bulk AI suggestion acceptance (filtered by confidence ≥0.80 and amount ≤500), manual classification, reconciliation, and result messaging. Includes StatCard and TransactionRow subcomponents with conditional UI rendering.
Server AI Classification Logic
server/lib/classification-ai.ts
Implemented classifyBatchWithAI function for batch AI-powered COA classification via OpenAI, with keyword-based fallback when API unavailable, response validation, confidence clamping, and structured suggestion output.
Classification API Route
server/routes/classification.ts
Added POST /api/classification/ai-suggest endpoint for batch AI suggestions, validates transaction limits (1–25), skips previously suggested transactions, writes suggestions to storage with appropriate actor tracking, and emits audit logs.
Classification Tests
server/__tests__/classification-ai.test.ts
Test suite for classifyBatchWithAI covering empty input, missing API key, API errors, valid responses, invalid COA codes, batch size limits, malformed JSON, and fallback scenarios.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant UI as Classification<br/>Page UI
    participant Hook as useAiSuggest<br/>Hook
    participant API as POST<br/>/ai-suggest
    participant OpenAI as OpenAI<br/>API
    participant DB as Database
    
    User->>UI: Click "Suggest All"
    UI->>Hook: trigger mutation
    activate Hook
    Hook->>API: POST with unclassified txns
    activate API
    
    API->>DB: fetch transactions (limit)
    DB-->>API: unclassified data
    API->>DB: fetch chart of accounts
    DB-->>API: COA list
    
    API->>OpenAI: send batch + COA options<br/>(with model, gateway)
    activate OpenAI
    OpenAI-->>API: JSON suggestions
    deactivate OpenAI
    
    Note over API: validate COA codes<br/>filter valid results<br/>apply fallback
    
    loop For each suggestion
        API->>DB: write classification<br/>(with confidence/reason)
    end
    
    API-->>Hook: { processed, suggested,<br/>aiCount, keywordCount }
    deactivate API
    
    Hook-->>UI: update cache +<br/>mutation result
    deactivate Hook
    
    UI->>User: display summary
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #86: Extends classification routes/workflow and directly modifies the same server/routes/classification.ts file for classification functionality.
  • PR #87: Introduces the identical AI-assisted COA classification feature including server/lib/classification-ai.ts, the AI suggestion route, and matching test coverage.
  • PR #84: Adds database schema changes (chart_of_accounts table, COA-related transaction columns, classification_audit table) that this PR's classification logic depends on.

Suggested reviewers

  • Copilot

Poem

🐰 hops with glee

With whiskers twitching, I present with pride,
AI suggestions hopping far and wide!
Classifications sorted, batches blessed,
Our fuzzy logic puts accounts to rest. 🏦✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding a classification review UI with bulk-accept safety guardrails, which aligns with the primary additions across all files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/classification-review-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chitcommit chitcommit merged commit 515aad1 into main Apr 10, 2026
11 of 12 checks passed
@chitcommit chitcommit deleted the feat/classification-review-ui branch April 10, 2026 22:17
@claude
Copy link
Copy Markdown

claude bot commented Apr 10, 2026

Code Review — Classification Review UI (#88)

Overall this is solid work. The trust-path guardrails are well-reasoned, the AI fallback strategy is defensive, and the test coverage on classifyBatchWithAI is thorough (8 cases covering empty input, missing key, API errors, hallucinated codes, gap-filling, and malformed JSON). A few things to address before merge:


Bug: handleAcceptAll fires mutations in parallel, not sequentially

client/src/pages/Classification.tsxhandleAcceptAll

The comment says "Fire mutations sequentially to avoid overloading the classify endpoint" but the loop dispatches all classify.mutate() calls synchronously in one tick — they all run in parallel. This is also problematic because TanStack Query's useMutation hook tracks a single pending state; calling mutate() multiple times on the same instance replaces the previous result, making classify.isPending unreliable for the button's disabled guard during the bulk operation.

Two options:

  • Fire-and-forget in parallel (remove the misleading comment, accept the load), or
  • Use Promise.all / chain with async reduce if true serialization is needed

Also: there is no onError callback on the per-row classify.mutate() calls here. If any mutation fails, done never reaches candidates.length and the success message is never shown. At minimum, track failures so the final result message is accurate.


TypeScript: test fixtures have an extra date field not in the interface

server/__tests__/classification-ai.test.ts / server/lib/classification-ai.ts

ClassifiableTransaction is defined as:

export interface ClassifiableTransaction {
  id: string;
  description: string;
  amount: string;
  category?: string | null;
}

But the test fixtures declare:

const TX_REPAIR: ClassifiableTransaction = {
  id: 'tx-1',
  // ...
  date: '2026-01-15', // ← not in the interface
};

TypeScript's excess property checking should flag date as an error on an object literal assigned to a typed variable. If the npm run check is passing, either the test tsconfig is looser than the main one or this is being missed. Either add date to the interface (it's useful context for the AI prompt anyway) or remove it from the fixtures.


No error state rendering in the Classification component

client/src/pages/Classification.tsx

The three useQuery hooks return an error property that is never checked. If getUnclassifiedTransactions or getChartOfAccounts fail (expired auth, network error), the user sees nothing — no loading indicator, no message. At minimum, check isError from useUnclassifiedTransactions and render a brief error state alongside the empty state.


data: any cast loses type safety on batchSuggest success

client/src/pages/Classification.tsxhandleBatchKeyword

onSuccess: (data: any) => setLastResult(`Keyword batch: ${data.suggested}/${data.processed} suggested`),

The useBatchSuggest mutation already returns a typed result from the route ({ processed, suggested }). Remove the any cast and use the inferred type.


Minor: classifiedPct may render with many decimal places

client/src/pages/Classification.tsxStatCard for "Classified"

${stats.classifiedPct}% renders the raw number. If the backend returns 33.333333..., users see 33.333333333333336%. Cap it: ${Math.round(stats.classifiedPct)}% or use .toFixed(1).


Minor: emoji in reconciled row

client/src/pages/Classification.tsxTransactionRow

{tx.reconciled && <span className="ml-2 text-yellow-400">🔒 Reconciled</span>}

Per the project conventions (CLAUDE.md), avoid emojis unless explicitly requested. Use a Lucide Lock icon (already imported elsewhere in the codebase) or plain text.


Informational: unused hook

useClassificationAudit is exported from use-classification.ts but not used anywhere in this PR. That's fine if it's planned for a follow-up audit drawer, but worth noting in case it was supposed to be wired up here.


Things done well

  • The three-layer bulkAcceptCandidates guard (confidence floor + suspense exclusion + dollar cap) is the right call for this trust boundary.
  • AI fallback to keyword match on every failure path (empty key, API error, malformed JSON, hallucinated code, skipped transaction) is robust.
  • isSuggestion: true in the route ensures L1 trust level is enforced server-side — the UI can't accidentally promote a suggestion to an authoritative classification.
  • Query keys scoped by tenantId ensure cache isolation on tenant switch.
  • The MAX_BATCH = 25 guard in classifyBatchWithAI is well-placed and tested.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 910dd0e9dc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

export function useUnclassifiedTransactions(limit = 50) {
const tenantId = useTenantId();
return useQuery<UnclassifiedTransaction[]>({
queryKey: ['/api/classification/unclassified', tenantId, limit],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include limit in unclassified fetch URL

useUnclassifiedTransactions(limit) never sends limit to the backend because the default query function only requests queryKey[0] as the URL. Here limit is only placed in the query key tuple, so calls like useUnclassifiedTransactions(100) still hit /api/classification/unclassified without ?limit=100 and silently fall back to the server default page size.

Useful? React with 👍 / 👎.


export function useClassificationAudit(transactionId: string | null) {
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', transactionId],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build audit query URL with transaction ID

useClassificationAudit is wired to /api/classification/audit even though the route is /api/classification/audit/:transactionId. Because the shared query function only fetches queryKey[0], putting transactionId in the second key slot does not affect the request URL, so this hook will consistently request a non-existent endpoint when used.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new end-to-end UI surface for the trust-path transaction classification workflow, including bulk actions and an AI-backed suggestion path, wiring it into the existing tenant-scoped classification APIs.

Changes:

  • Adds /classification page with queue, stats, sorting, and per-transaction classify/reconcile actions.
  • Adds bulk actions (keyword suggest, AI suggest, accept high-confidence) and tenant-scoped React Query hooks to keep stats/queue in sync.
  • Introduces POST /api/classification/ai-suggest plus server/lib/classification-ai.ts (OpenAI structured JSON + keyword fallback) with unit tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
server/routes/classification.ts Adds AI batch-suggest endpoint that writes L1 suggestions and logs batch activity.
server/lib/classification-ai.ts Implements GPT-4o-mini batch classification with strict code validation + keyword fallback.
server/tests/classification-ai.test.ts Adds unit tests for AI parsing/fallback behavior (currently has TS + mocking issues).
client/src/pages/Classification.tsx New classification queue page with bulk actions and guardrails for bulk accept.
client/src/hooks/use-classification.ts New tenant-scoped queries/mutations for COA + classification workflow.
client/src/components/layout/Sidebar.tsx Adds nav entry for the new Classification page.
client/src/App.tsx Registers the /classification route.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +18 to +24
const TX_REPAIR: ClassifiableTransaction = {
id: 'tx-1',
description: 'Home Depot #1234 — plumbing supplies',
amount: '-125.40',
category: 'Repairs',
date: '2026-01-15',
};
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TX_* fixtures include a date property, but ClassifiableTransaction in server/lib/classification-ai.ts does not define date. Because these are object literals typed as ClassifiableTransaction, this will fail TypeScript excess-property checks. Either remove the date fields from the fixtures or extend ClassifiableTransaction to include them (and ensure the production code actually uses it).

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +10
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { classifyBatchWithAI } from '../lib/classification-ai';
import type { ClassifiableTransaction, CoaOption } from '../lib/classification-ai';

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vi.mock('openai', ...) is declared after statically importing classifyBatchWithAI. In ESM/Vitest this can prevent the mock from applying before server/lib/classification-ai.ts imports openai, causing the real SDK to be used and the tests to fail/flap. Move the OpenAI mock setup before importing the module under test, or switch to dynamic await import(...) after vi.mock/vi.resetModules() so the mock is guaranteed to intercept the import.

Copilot uses AI. Check for mistakes.
Comment on lines +130 to +149
function handleAcceptAll() {
const candidates = bulkAcceptCandidates(sortedTxns, coa);
if (candidates.length === 0) {
setLastResult('No candidates qualified for bulk accept');
return;
}
// Fire mutations sequentially to avoid overloading the classify endpoint
let done = 0;
for (const tx of candidates) {
if (!tx.suggestedCoaCode) continue;
classify.mutate(
{ transactionId: tx.id, coaCode: tx.suggestedCoaCode, reason: 'Bulk accept: high-confidence suggestion' },
{
onSuccess: () => {
done++;
if (done === candidates.length) setLastResult(`Bulk accepted ${done} transactions`);
},
},
);
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleAcceptAll says it fires mutations sequentially, but the loop calls classify.mutate(...) repeatedly without awaiting, which will dispatch requests concurrently. This can overload the endpoint and also leaves lastResult unset if any mutation fails (since done only increments on success). Consider using mutateAsync with await (or chaining) and add error handling so the UI reports partial/failed bulk accepts deterministically.

Suggested change
function handleAcceptAll() {
const candidates = bulkAcceptCandidates(sortedTxns, coa);
if (candidates.length === 0) {
setLastResult('No candidates qualified for bulk accept');
return;
}
// Fire mutations sequentially to avoid overloading the classify endpoint
let done = 0;
for (const tx of candidates) {
if (!tx.suggestedCoaCode) continue;
classify.mutate(
{ transactionId: tx.id, coaCode: tx.suggestedCoaCode, reason: 'Bulk accept: high-confidence suggestion' },
{
onSuccess: () => {
done++;
if (done === candidates.length) setLastResult(`Bulk accepted ${done} transactions`);
},
},
);
}
async function handleAcceptAll() {
const candidates = bulkAcceptCandidates(sortedTxns, coa);
const actionableCandidates = candidates.filter((tx) => !!tx.suggestedCoaCode);
if (actionableCandidates.length === 0) {
setLastResult('No candidates qualified for bulk accept');
return;
}
// Fire mutations sequentially to avoid overloading the classify endpoint
let succeeded = 0;
let failed = 0;
for (const tx of actionableCandidates) {
try {
await classify.mutateAsync({
transactionId: tx.id,
coaCode: tx.suggestedCoaCode!,
reason: 'Bulk accept: high-confidence suggestion',
});
succeeded++;
} catch {
failed++;
}
}
if (failed === 0) {
setLastResult(`Bulk accepted ${succeeded} transactions`);
return;
}
setLastResult(`Bulk accepted ${succeeded} transactions; ${failed} failed`);

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +94
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', transactionId],
enabled: !!transactionId,
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useClassificationAudit's queryKey is not scoped by tenantId, while the rest of the classification hooks are. If a user switches tenants, React Query can serve cached audit data keyed only by transactionId. Include tenantId in the key (and gate enabled on both tenantId + transactionId) to keep caching behavior consistent with the stated tenant-scoping approach.

Suggested change
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', transactionId],
enabled: !!transactionId,
const tenantId = useTenantId();
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', tenantId, transactionId],
enabled: !!tenantId && !!transactionId,

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants