From fc029f9795ade12aed38de09964b52403868ffff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:48:10 +0000 Subject: [PATCH 01/10] Plan AI/ML upgrade with optional Python sidecar Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- package-lock.json | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6bc7b309..cb399e94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -477,7 +477,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -494,7 +493,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -511,7 +509,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -528,7 +525,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -545,7 +541,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -562,7 +557,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -579,7 +573,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -596,7 +589,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -613,7 +605,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -630,7 +621,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -647,7 +637,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -664,7 +653,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -681,7 +669,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -698,7 +685,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -715,7 +701,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -732,7 +717,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -749,7 +733,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -766,7 +749,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -783,7 +765,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -800,7 +781,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -817,7 +797,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -834,7 +813,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -851,7 +829,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -868,7 +845,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -885,7 +861,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ From 2b2c7d40b53e3c6b270ce9b5da115ef9a6cbb97b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:49:46 +0000 Subject: [PATCH 02/10] Add AI post-processing and optional Python enrichment hook Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- ai/analyze.ts | 16 ++- ai/enrichment.ts | 199 +++++++++++++++++++++++++++ components/unsorted/analyze-form.tsx | 25 ++++ 3 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 ai/enrichment.ts diff --git a/ai/analyze.ts b/ai/analyze.ts index bb2399a1..2131d490 100644 --- a/ai/analyze.ts +++ b/ai/analyze.ts @@ -4,11 +4,15 @@ import { ActionState } from "@/lib/actions" import { updateFile } from "@/models/files" import { getLLMSettings, getSettings } from "@/models/settings" import { AnalyzeAttachment } from "./attachments" +import { enrichAnalysisOutput } from "./enrichment" import { requestLLM } from "./providers/llmProvider" export type AnalysisResult = { output: Record tokensUsed: number + confidence: number + warnings: string[] + usedPythonEnricher: boolean } export async function analyzeTransaction( @@ -32,19 +36,23 @@ export async function analyzeTransaction( throw new Error(response.error) } - const result = response.output + const result = response.output as Record const tokensUsed = response.tokensUsed || 0 + const enriched = await enrichAnalysisOutput(result) - console.log("LLM response:", result) + console.log("LLM response:", enriched.output) console.log("LLM tokens used:", tokensUsed) - await updateFile(fileId, userId, { cachedParseResult: result }) + await updateFile(fileId, userId, { cachedParseResult: enriched.output }) return { success: true, data: { - output: result, + output: enriched.output as Record, tokensUsed: tokensUsed, + confidence: enriched.confidence, + warnings: enriched.warnings, + usedPythonEnricher: enriched.usedPythonEnricher, }, } } catch (error) { diff --git a/ai/enrichment.ts b/ai/enrichment.ts new file mode 100644 index 00000000..398fa59d --- /dev/null +++ b/ai/enrichment.ts @@ -0,0 +1,199 @@ +"use server" + +import { spawn } from "child_process" + +export type AnalysisEnrichmentResult = { + output: Record + warnings: string[] + confidence: number + usedPythonEnricher: boolean +} + +type PythonEnricherResponse = { + output?: Record + warnings?: string[] + confidenceDelta?: number +} + +const CURRENCY_CODE_REGEX = /^[A-Z]{3,5}$/ + +export async function enrichAnalysisOutput(output: Record): Promise { + const normalized = normalizeOutput(output) + let warnings = [...normalized.warnings] + let confidence = estimateConfidence(normalized.output, warnings) + let usedPythonEnricher = false + + const pythonEnriched = await runPythonEnricher({ + output: normalized.output, + warnings, + confidence, + }) + + if (pythonEnriched) { + usedPythonEnricher = true + if (pythonEnriched.output && typeof pythonEnriched.output === "object") { + normalized.output = { + ...normalized.output, + ...pythonEnriched.output, + } + } + if (Array.isArray(pythonEnriched.warnings) && pythonEnriched.warnings.length > 0) { + warnings = [...warnings, ...pythonEnriched.warnings.filter((warning) => typeof warning === "string")] + } + if (typeof pythonEnriched.confidenceDelta === "number") { + confidence = clampConfidence(confidence + pythonEnriched.confidenceDelta) + } else { + confidence = estimateConfidence(normalized.output, warnings) + } + } + + return { + output: normalized.output, + warnings: Array.from(new Set(warnings)), + confidence, + usedPythonEnricher, + } +} + +function normalizeOutput(output: Record): { output: Record; warnings: string[] } { + const normalized: Record = { ...output } + const warnings: string[] = [] + + normalized.currencyCode = normalizeCurrencyCode(normalized.currencyCode, "currencyCode", warnings) + normalized.convertedCurrencyCode = normalizeCurrencyCode( + normalized.convertedCurrencyCode, + "convertedCurrencyCode", + warnings + ) + + normalized.total = normalizeMoneyValue(normalized.total) + normalized.convertedTotal = normalizeMoneyValue(normalized.convertedTotal) + + if (normalized.issuedAt) { + const parsedDate = new Date(String(normalized.issuedAt)) + if (!Number.isNaN(parsedDate.getTime())) { + normalized.issuedAt = parsedDate.toISOString().split("T")[0] + } else { + warnings.push("Detected date is invalid and may need manual correction.") + } + } + + if (Array.isArray(normalized.items)) { + normalized.items = normalized.items.map((item) => { + if (!item || typeof item !== "object") { + return item + } + const itemRecord = item as Record + return { + ...itemRecord, + total: normalizeMoneyValue(itemRecord.total), + currencyCode: normalizeCurrencyCode(itemRecord.currencyCode, "items.currencyCode", warnings), + } + }) + } + + return { output: normalized, warnings } +} + +function normalizeMoneyValue(value: unknown): unknown { + if (value == null || value === "") return value + if (typeof value === "number") return Number(value.toFixed(2)) + const parsed = Number.parseFloat(String(value).replace(",", ".")) + if (Number.isNaN(parsed)) return value + return Number(parsed.toFixed(2)) +} + +function normalizeCurrencyCode(value: unknown, fieldName: string, warnings: string[]): unknown { + if (typeof value !== "string") return value + const normalized = value.trim().toUpperCase() + if (!normalized) return value + if (!CURRENCY_CODE_REGEX.test(normalized)) { + warnings.push(`Detected ${fieldName} looks unusual: ${value}.`) + } + return normalized +} + +function estimateConfidence(output: Record, warnings: string[]): number { + let confidence = 1 + const hasName = typeof output.name === "string" && output.name.trim().length > 1 + const hasMerchant = typeof output.merchant === "string" && output.merchant.trim().length > 1 + const hasIssuedAt = typeof output.issuedAt === "string" && output.issuedAt.trim().length > 0 + const totalValue = typeof output.total === "number" ? output.total : Number.parseFloat(String(output.total || "")) + + if (!hasName && !hasMerchant) confidence -= 0.25 + if (!hasIssuedAt) confidence -= 0.1 + if (Number.isNaN(totalValue)) confidence -= 0.2 + else if (totalValue <= 0) confidence -= 0.15 + + const currencyCode = + typeof output.currencyCode === "string" ? output.currencyCode.trim().toUpperCase() : String(output.currencyCode || "") + if (!currencyCode || !CURRENCY_CODE_REGEX.test(currencyCode)) confidence -= 0.1 + + confidence -= Math.min(warnings.length * 0.05, 0.25) + return clampConfidence(confidence) +} + +function clampConfidence(value: number): number { + return Math.max(0, Math.min(1, Number(value.toFixed(2)))) +} + +async function runPythonEnricher(payload: { + output: Record + warnings: string[] + confidence: number +}): Promise { + const commandText = process.env.TAXHACKER_PYTHON_ENRICHER_CMD?.trim() + if (!commandText) return null + + const [command, ...args] = commandText.split(/\s+/).filter(Boolean) + if (!command) return null + + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }) + const timeout = setTimeout(() => { + child.kill("SIGKILL") + resolve(null) + }, 900) + + let stdout = "" + let stderr = "" + + child.stdout.on("data", (chunk: Buffer | string) => { + if (stdout.length < 20_000) { + stdout += chunk.toString() + } + }) + + child.stderr.on("data", (chunk: Buffer | string) => { + if (stderr.length < 4_000) { + stderr += chunk.toString() + } + }) + + child.on("error", () => { + clearTimeout(timeout) + resolve(null) + }) + + child.on("close", (code) => { + clearTimeout(timeout) + if (code !== 0 || !stdout.trim()) { + if (stderr) console.warn("Python enricher stderr:", stderr) + resolve(null) + return + } + try { + const parsed = JSON.parse(stdout) as PythonEnricherResponse + resolve(parsed) + } catch { + resolve(null) + } + }) + + child.stdin.write(JSON.stringify(payload)) + child.stdin.end() + }) +} diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx index ebef80d6..169917d5 100644 --- a/components/unsorted/analyze-form.tsx +++ b/components/unsorted/analyze-form.tsx @@ -40,6 +40,9 @@ export default function AnalyzeForm({ const [deleteState, deleteAction, isDeleting] = useActionState(deleteUnsortedFileAction, null) const [isSaving, setIsSaving] = useState(false) const [saveError, setSaveError] = useState("") + const [analysisWarnings, setAnalysisWarnings] = useState([]) + const [analysisConfidence, setAnalysisConfidence] = useState(null) + const [usedPythonEnricher, setUsedPythonEnricher] = useState(false) const fieldMap = useMemo(() => { return fields.reduce( @@ -117,6 +120,9 @@ export default function AnalyzeForm({ const startAnalyze = async () => { setIsAnalyzing(true) setAnalyzeError("") + setAnalysisWarnings([]) + setAnalysisConfidence(null) + setUsedPythonEnricher(false) try { setAnalyzeStep("Analyzing...") const results = await analyzeFileAction(file, settings, fields, categories, projects) @@ -126,6 +132,10 @@ export default function AnalyzeForm({ if (!results.success) { setAnalyzeError(results.error ? results.error : "Something went wrong...") } else { + setAnalysisWarnings(results.data?.warnings || []) + setAnalysisConfidence(typeof results.data?.confidence === "number" ? results.data.confidence : null) + setUsedPythonEnricher(Boolean(results.data?.usedPythonEnricher)) + const nonEmptyFields = Object.fromEntries( Object.entries(results.data?.output || {}).filter( ([_, value]) => value !== null && value !== undefined && value !== "" @@ -165,6 +175,21 @@ export default function AnalyzeForm({ )}
{analyzeError && {analyzeError}}
+ {analysisConfidence !== null && ( +
+ = 0.75 ? "secondary" : "outline"}> + AI confidence: {Math.round(analysisConfidence * 100)}% + + {usedPythonEnricher && Python ML enrichment enabled} +
+ )} + {analysisWarnings.length > 0 && ( +
+ {analysisWarnings.map((warning) => ( + {warning} + ))} +
+ )}
From 0c2da47fb4fa40fa99cdbb2223c6a811b494979f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:53:32 +0000 Subject: [PATCH 03/10] Document optional Python ML enrichment and finalize upgrade Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- .env.example | 6 ++++++ README.md | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.env.example b/.env.example index 14dc456e..d1802c8e 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,12 @@ GOOGLE_API_KEY="" MISTRAL_MODEL_NAME="mistral-medium-latest" MISTRAL_API_KEY="" +# Optional: command for local Python AI/ML enrichment post-processor. +# TaxHacker writes JSON to STDIN and expects JSON on STDOUT. +# Keep this fast (< 1s) because it runs in request path. +# Example: "python3 /app/python/enricher.py" +TAXHACKER_PYTHON_ENRICHER_CMD="" + # Auth Config BETTER_AUTH_SECRET="random-secret-key" # please use any long random string here diff --git a/README.md b/README.md index a5445973..7becce3e 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,23 @@ You can also configure LLM provider settings in the application or via environme - **OpenAI**: `OPENAI_MODEL_NAME` and `OPENAI_API_KEY` - **Google Gemini**: `GOOGLE_MODEL_NAME` and `GOOGLE_API_KEY` - **Mistral**: `MISTRAL_MODEL_NAME` and `MISTRAL_API_KEY` +- **Optional Python post-enrichment hook**: `TAXHACKER_PYTHON_ENRICHER_CMD` + +### Optional Python AI/ML post-enrichment + +For teams that want lightweight Python-based heuristics or ML enrichment without changing the core extraction flow, TaxHacker can call an optional local Python command after LLM extraction. + +- Configure `TAXHACKER_PYTHON_ENRICHER_CMD` (for example: `python3 /app/python/enricher.py`) +- TaxHacker sends JSON payload to `stdin`: + - `output`: extracted fields + - `warnings`: current warnings list + - `confidence`: current confidence score (0..1) +- The Python command may return JSON on `stdout`: + - `output` (partial field overrides/normalization) + - `warnings` (additional warnings) + - `confidenceDelta` (number to add to confidence) + +If the command is not configured, fails, times out, or returns invalid JSON, TaxHacker safely ignores it and continues with standard extraction behavior. ## ⌨️ Local Development From 098858c85f3cb29b5cd9d825c710bc571add7f82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:54:21 +0000 Subject: [PATCH 04/10] Address review feedback for enrichment constants Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- .env.example | 2 +- ai/enrichment.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index d1802c8e..570462e0 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ MISTRAL_API_KEY="" # Optional: command for local Python AI/ML enrichment post-processor. # TaxHacker writes JSON to STDIN and expects JSON on STDOUT. -# Keep this fast (< 1s) because it runs in request path. +# Keep this fast (< 900ms) because it runs in request path. # Example: "python3 /app/python/enricher.py" TAXHACKER_PYTHON_ENRICHER_CMD="" diff --git a/ai/enrichment.ts b/ai/enrichment.ts index 398fa59d..007b2e71 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -16,6 +16,9 @@ type PythonEnricherResponse = { } const CURRENCY_CODE_REGEX = /^[A-Z]{3,5}$/ +const PYTHON_ENRICHER_TIMEOUT_MS = 900 +const MAX_STDOUT_BUFFER_SIZE = 20_000 +const MAX_STDERR_BUFFER_SIZE = 4_000 export async function enrichAnalysisOutput(output: Record): Promise { const normalized = normalizeOutput(output) @@ -156,19 +159,19 @@ async function runPythonEnricher(payload: { const timeout = setTimeout(() => { child.kill("SIGKILL") resolve(null) - }, 900) + }, PYTHON_ENRICHER_TIMEOUT_MS) let stdout = "" let stderr = "" child.stdout.on("data", (chunk: Buffer | string) => { - if (stdout.length < 20_000) { + if (stdout.length < MAX_STDOUT_BUFFER_SIZE) { stdout += chunk.toString() } }) child.stderr.on("data", (chunk: Buffer | string) => { - if (stderr.length < 4_000) { + if (stderr.length < MAX_STDERR_BUFFER_SIZE) { stderr += chunk.toString() } }) From 60379e79c1ba753a78cffba49d7ab1ba021fc4fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:55:32 +0000 Subject: [PATCH 05/10] Harden enrichment timeout and warning handling Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- ai/enrichment.ts | 25 +++++++++++++++++++------ components/unsorted/analyze-form.tsx | 4 ++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/ai/enrichment.ts b/ai/enrichment.ts index 007b2e71..d1e8df13 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -15,6 +15,7 @@ type PythonEnricherResponse = { confidenceDelta?: number } +// Keep 3-5 symbols to support common fiat (ISO-4217) and user-defined/crypto tickers used in the app. const CURRENCY_CODE_REGEX = /^[A-Z]{3,5}$/ const PYTHON_ENRICHER_TIMEOUT_MS = 900 const MAX_STDOUT_BUFFER_SIZE = 20_000 @@ -76,6 +77,9 @@ function normalizeOutput(output: Record): { output: Record Date.now()) { + warnings.push("Detected date is in the future and may need manual correction.") + } } else { warnings.push("Detected date is invalid and may need manual correction.") } @@ -156,9 +160,18 @@ async function runPythonEnricher(payload: { stdio: ["pipe", "pipe", "pipe"], shell: false, }) + let didResolve = false + + const safeResolve = (value: PythonEnricherResponse | null) => { + if (didResolve) return + didResolve = true + resolve(value) + } + const timeout = setTimeout(() => { - child.kill("SIGKILL") - resolve(null) + child.kill("SIGTERM") + setTimeout(() => child.kill("SIGKILL"), 100) + safeResolve(null) }, PYTHON_ENRICHER_TIMEOUT_MS) let stdout = "" @@ -178,21 +191,21 @@ async function runPythonEnricher(payload: { child.on("error", () => { clearTimeout(timeout) - resolve(null) + safeResolve(null) }) child.on("close", (code) => { clearTimeout(timeout) if (code !== 0 || !stdout.trim()) { if (stderr) console.warn("Python enricher stderr:", stderr) - resolve(null) + safeResolve(null) return } try { const parsed = JSON.parse(stdout) as PythonEnricherResponse - resolve(parsed) + safeResolve(parsed) } catch { - resolve(null) + safeResolve(null) } }) diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx index 169917d5..e69de0bd 100644 --- a/components/unsorted/analyze-form.tsx +++ b/components/unsorted/analyze-form.tsx @@ -185,8 +185,8 @@ export default function AnalyzeForm({ )} {analysisWarnings.length > 0 && (
- {analysisWarnings.map((warning) => ( - {warning} + {analysisWarnings.map((warning, index) => ( + {warning} ))}
)} From c29b25e823194e2b42b00e5b6ba5d1f76e7dd3a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:58:37 +0000 Subject: [PATCH 06/10] Refine warnings UX and enrich process robustness Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- ai/enrichment.ts | 21 +++++++++++++++------ components/forms/warning.tsx | 18 ++++++++++++++++++ components/unsorted/analyze-form.tsx | 3 ++- 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 components/forms/warning.tsx diff --git a/ai/enrichment.ts b/ai/enrichment.ts index d1e8df13..6af68b36 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -171,21 +171,28 @@ async function runPythonEnricher(payload: { const timeout = setTimeout(() => { child.kill("SIGTERM") setTimeout(() => child.kill("SIGKILL"), 100) + console.warn("Python enricher timed out and was terminated") safeResolve(null) }, PYTHON_ENRICHER_TIMEOUT_MS) - let stdout = "" - let stderr = "" + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + let stdoutSize = 0 + let stderrSize = 0 child.stdout.on("data", (chunk: Buffer | string) => { - if (stdout.length < MAX_STDOUT_BUFFER_SIZE) { - stdout += chunk.toString() + if (stdoutSize < MAX_STDOUT_BUFFER_SIZE) { + const chunkText = chunk.toString() + stdoutChunks.push(chunkText) + stdoutSize += chunkText.length } }) child.stderr.on("data", (chunk: Buffer | string) => { - if (stderr.length < MAX_STDERR_BUFFER_SIZE) { - stderr += chunk.toString() + if (stderrSize < MAX_STDERR_BUFFER_SIZE) { + const chunkText = chunk.toString() + stderrChunks.push(chunkText) + stderrSize += chunkText.length } }) @@ -196,6 +203,8 @@ async function runPythonEnricher(payload: { child.on("close", (code) => { clearTimeout(timeout) + const stdout = stdoutChunks.join("") + const stderr = stderrChunks.join("") if (code !== 0 || !stdout.trim()) { if (stderr) console.warn("Python enricher stderr:", stderr) safeResolve(null) diff --git a/components/forms/warning.tsx b/components/forms/warning.tsx new file mode 100644 index 00000000..17fe241e --- /dev/null +++ b/components/forms/warning.tsx @@ -0,0 +1,18 @@ +import { cn } from "@/lib/utils" +import { AlertTriangle } from "lucide-react" + +export function FormWarning({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ +

{children}

+
+ ) +} diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx index e69de0bd..64eeb34d 100644 --- a/components/unsorted/analyze-form.tsx +++ b/components/unsorted/analyze-form.tsx @@ -6,6 +6,7 @@ import { CurrencyConverterTool } from "@/components/agents/currency-converter" import { ItemsDetectTool } from "@/components/agents/items-detect" import ToolWindow from "@/components/agents/tool-window" import { FormError } from "@/components/forms/error" +import { FormWarning } from "@/components/forms/warning" import { FormSelectCategory } from "@/components/forms/select-category" import { FormSelectCurrency } from "@/components/forms/select-currency" import { FormSelectProject } from "@/components/forms/select-project" @@ -186,7 +187,7 @@ export default function AnalyzeForm({ {analysisWarnings.length > 0 && (
{analysisWarnings.map((warning, index) => ( - {warning} + {warning} ))}
)} From 90235815156eb7a2b515b34276fd7d0b88e002ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:00:11 +0000 Subject: [PATCH 07/10] Handle stdin safety and simplify warning keys Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- ai/enrichment.ts | 12 ++++++++++-- components/unsorted/analyze-form.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ai/enrichment.ts b/ai/enrichment.ts index 6af68b36..e9e62860 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -218,7 +218,15 @@ async function runPythonEnricher(payload: { } }) - child.stdin.write(JSON.stringify(payload)) - child.stdin.end() + try { + if (!child.stdin.writable) { + safeResolve(null) + return + } + child.stdin.write(JSON.stringify(payload)) + child.stdin.end() + } catch { + safeResolve(null) + } }) } diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx index 64eeb34d..b2dcf549 100644 --- a/components/unsorted/analyze-form.tsx +++ b/components/unsorted/analyze-form.tsx @@ -187,7 +187,7 @@ export default function AnalyzeForm({ {analysisWarnings.length > 0 && (
{analysisWarnings.map((warning, index) => ( - {warning} + {warning} ))}
)} From e90df3937a9fa833d554055b0765ed172f60fd02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:02:34 +0000 Subject: [PATCH 08/10] Improve enrichment parsing and warning accessibility Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- .env.example | 2 +- ai/enrichment.ts | 34 ++++++++++++++++++++++++++++++---- components/forms/warning.tsx | 2 +- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 570462e0..7c6bcb86 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ MISTRAL_API_KEY="" # Optional: command for local Python AI/ML enrichment post-processor. # TaxHacker writes JSON to STDIN and expects JSON on STDOUT. -# Keep this fast (< 900ms) because it runs in request path. +# Keep this fast because it runs in request path. # Example: "python3 /app/python/enricher.py" TAXHACKER_PYTHON_ENRICHER_CMD="" diff --git a/ai/enrichment.ts b/ai/enrichment.ts index e9e62860..0c50f27d 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -17,7 +17,8 @@ type PythonEnricherResponse = { // Keep 3-5 symbols to support common fiat (ISO-4217) and user-defined/crypto tickers used in the app. const CURRENCY_CODE_REGEX = /^[A-Z]{3,5}$/ -const PYTHON_ENRICHER_TIMEOUT_MS = 900 +const PYTHON_ENRICHER_TIMEOUT_MS = 300 +const SIGTERM_TO_SIGKILL_DELAY_MS = 100 const MAX_STDOUT_BUFFER_SIZE = 20_000 const MAX_STDERR_BUFFER_SIZE = 4_000 @@ -105,7 +106,18 @@ function normalizeOutput(output: Record): { output: Record { @@ -170,7 +183,7 @@ async function runPythonEnricher(payload: { const timeout = setTimeout(() => { child.kill("SIGTERM") - setTimeout(() => child.kill("SIGKILL"), 100) + setTimeout(() => child.kill("SIGKILL"), SIGTERM_TO_SIGKILL_DELAY_MS) console.warn("Python enricher timed out and was terminated") safeResolve(null) }, PYTHON_ENRICHER_TIMEOUT_MS) @@ -230,3 +243,16 @@ async function runPythonEnricher(payload: { } }) } + +function parseCommand(commandText: string): string[] { + const result: string[] = [] + const regex = /[^\s"'`]+|"([^"]*)"|'([^']*)'/g + let match: RegExpExecArray | null = null + + while ((match = regex.exec(commandText)) !== null) { + const token = match[1] ?? match[2] ?? match[0] + if (token) result.push(token) + } + + return result +} diff --git a/components/forms/warning.tsx b/components/forms/warning.tsx index 17fe241e..0e6d4cb5 100644 --- a/components/forms/warning.tsx +++ b/components/forms/warning.tsx @@ -7,7 +7,7 @@ export function FormWarning({ children, className }: { children: React.ReactNode role="status" aria-live="polite" className={cn( - "inline-flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-amber-800 border border-amber-200", + "inline-flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-amber-900 border border-amber-300", className )} > From 950a952a8e082eed25e6b1be566237c3773bc851 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:05:38 +0000 Subject: [PATCH 09/10] Make Python enrichment configurable and safer Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- .env.example | 6 ++++- README.md | 7 +++++- ai/enrichment.ts | 48 +++++++++++++++++++++++++++--------- components/forms/warning.tsx | 4 +-- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 7c6bcb86..a224d08d 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,12 @@ MISTRAL_API_KEY="" # Optional: command for local Python AI/ML enrichment post-processor. # TaxHacker writes JSON to STDIN and expects JSON on STDOUT. # Keep this fast because it runs in request path. -# Example: "python3 /app/python/enricher.py" +# Example: +# TAXHACKER_PYTHON_ENRICHER_CMD="python3" +# TAXHACKER_PYTHON_ENRICHER_ARGS='["/app/python/enricher.py"]' TAXHACKER_PYTHON_ENRICHER_CMD="" +TAXHACKER_PYTHON_ENRICHER_ARGS="" +TAXHACKER_PYTHON_ENRICHER_TIMEOUT_MS="1200" # Auth Config BETTER_AUTH_SECRET="random-secret-key" # please use any long random string here diff --git a/README.md b/README.md index 7becce3e..00e96300 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,10 @@ You can also configure LLM provider settings in the application or via environme For teams that want lightweight Python-based heuristics or ML enrichment without changing the core extraction flow, TaxHacker can call an optional local Python command after LLM extraction. -- Configure `TAXHACKER_PYTHON_ENRICHER_CMD` (for example: `python3 /app/python/enricher.py`) +- Configure: + - `TAXHACKER_PYTHON_ENRICHER_CMD` (for example: `python3`) + - `TAXHACKER_PYTHON_ENRICHER_ARGS` (JSON array of args, for example: `["/app/python/enricher.py"]`) + - `TAXHACKER_PYTHON_ENRICHER_TIMEOUT_MS` (optional, default `1200`, clamped to 100..5000) - TaxHacker sends JSON payload to `stdin`: - `output`: extracted fields - `warnings`: current warnings list @@ -183,6 +186,8 @@ For teams that want lightweight Python-based heuristics or ML enrichment without If the command is not configured, fails, times out, or returns invalid JSON, TaxHacker safely ignores it and continues with standard extraction behavior. +> For security and reliability, command execution runs with shell disabled. Set these variables only by trusted administrators. + ## ⌨️ Local Development We use: diff --git a/ai/enrichment.ts b/ai/enrichment.ts index 0c50f27d..881ed4bb 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -17,7 +17,7 @@ type PythonEnricherResponse = { // Keep 3-5 symbols to support common fiat (ISO-4217) and user-defined/crypto tickers used in the app. const CURRENCY_CODE_REGEX = /^[A-Z]{3,5}$/ -const PYTHON_ENRICHER_TIMEOUT_MS = 300 +const DEFAULT_PYTHON_ENRICHER_TIMEOUT_MS = 1200 const SIGTERM_TO_SIGKILL_DELAY_MS = 100 const MAX_STDOUT_BUFFER_SIZE = 20_000 const MAX_STDERR_BUFFER_SIZE = 4_000 @@ -162,15 +162,19 @@ async function runPythonEnricher(payload: { confidence: number }): Promise { const commandText = process.env.TAXHACKER_PYTHON_ENRICHER_CMD?.trim() + const argsText = process.env.TAXHACKER_PYTHON_ENRICHER_ARGS?.trim() if (!commandText) return null - const parsedCommand = parseCommand(commandText) + const parsedCommand = parseCommand(commandText, argsText) const [command, ...args] = parsedCommand if (!command) return null + const timeoutMs = getPythonEnricherTimeoutMs() + return new Promise((resolve) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], + // Keep shell disabled so arguments are never interpreted by a shell. shell: false, }) let didResolve = false @@ -186,7 +190,7 @@ async function runPythonEnricher(payload: { setTimeout(() => child.kill("SIGKILL"), SIGTERM_TO_SIGKILL_DELAY_MS) console.warn("Python enricher timed out and was terminated") safeResolve(null) - }, PYTHON_ENRICHER_TIMEOUT_MS) + }, timeoutMs) const stdoutChunks: string[] = [] const stderrChunks: string[] = [] @@ -244,15 +248,37 @@ async function runPythonEnricher(payload: { }) } -function parseCommand(commandText: string): string[] { - const result: string[] = [] - const regex = /[^\s"'`]+|"([^"]*)"|'([^']*)'/g - let match: RegExpExecArray | null = null +function parseCommand(commandText: string, argsText?: string): string[] { + if (!argsText) { + const legacyTokens = commandText.split(/\s+/).filter(Boolean) + if (legacyTokens.length > 1) { + console.warn( + "Legacy TAXHACKER_PYTHON_ENRICHER_CMD with inline args is deprecated; use TAXHACKER_PYTHON_ENRICHER_ARGS JSON array" + ) + return legacyTokens + } + return [commandText] + } - while ((match = regex.exec(commandText)) !== null) { - const token = match[1] ?? match[2] ?? match[0] - if (token) result.push(token) + try { + const parsedArgs = JSON.parse(argsText) + if (Array.isArray(parsedArgs) && parsedArgs.every((arg) => typeof arg === "string")) { + return [commandText, ...parsedArgs] + } + } catch { + // ignore malformed args config } - return result + return [commandText] +} + +function getPythonEnricherTimeoutMs(): number { + const timeoutRaw = process.env.TAXHACKER_PYTHON_ENRICHER_TIMEOUT_MS?.trim() + if (!timeoutRaw) return DEFAULT_PYTHON_ENRICHER_TIMEOUT_MS + + const parsed = Number.parseInt(timeoutRaw, 10) + if (Number.isNaN(parsed)) return DEFAULT_PYTHON_ENRICHER_TIMEOUT_MS + if (parsed < 100) return 100 + if (parsed > 5000) return 5000 + return parsed } diff --git a/components/forms/warning.tsx b/components/forms/warning.tsx index 0e6d4cb5..d98425e4 100644 --- a/components/forms/warning.tsx +++ b/components/forms/warning.tsx @@ -4,8 +4,8 @@ import { AlertTriangle } from "lucide-react" export function FormWarning({ children, className }: { children: React.ReactNode; className?: string }) { return (
Date: Tue, 31 Mar 2026 15:07:12 +0000 Subject: [PATCH 10/10] Polish enrichment logging and accessibility semantics Agent-Logs-Url: https://github.com/Subhamsinghania18/TaxHacker/sessions/835b4d78-bd7d-4c75-a250-5e7ebfa307d0 Co-authored-by: Subhamsinghania18 <147546813+Subhamsinghania18@users.noreply.github.com> --- ai/enrichment.ts | 7 ++++--- components/forms/warning.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ai/enrichment.ts b/ai/enrichment.ts index 881ed4bb..a32cb488 100644 --- a/ai/enrichment.ts +++ b/ai/enrichment.ts @@ -174,7 +174,7 @@ async function runPythonEnricher(payload: { return new Promise((resolve) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], - // Keep shell disabled so arguments are never interpreted by a shell. + // Keep shell disabled to prevent shell injection from command/args configuration. shell: false, }) let didResolve = false @@ -213,8 +213,9 @@ async function runPythonEnricher(payload: { } }) - child.on("error", () => { + child.on("error", (error: Error) => { clearTimeout(timeout) + console.warn("Python enricher failed to start:", error.message) safeResolve(null) }) @@ -253,7 +254,7 @@ function parseCommand(commandText: string, argsText?: string): string[] { const legacyTokens = commandText.split(/\s+/).filter(Boolean) if (legacyTokens.length > 1) { console.warn( - "Legacy TAXHACKER_PYTHON_ENRICHER_CMD with inline args is deprecated; use TAXHACKER_PYTHON_ENRICHER_ARGS JSON array" + "Legacy TAXHACKER_PYTHON_ENRICHER_CMD with inline args is deprecated and will be removed in v0.7.0; use TAXHACKER_PYTHON_ENRICHER_ARGS JSON array" ) return legacyTokens } diff --git a/components/forms/warning.tsx b/components/forms/warning.tsx index d98425e4..811ba729 100644 --- a/components/forms/warning.tsx +++ b/components/forms/warning.tsx @@ -5,7 +5,7 @@ export function FormWarning({ children, className }: { children: React.ReactNode return (