From a purchase-register upload to a risk-scored reconciliation report. Eight stages, each calling a specific module, this is the control flow for POST /reconcile.
A single reconciliation run touches both services, watsonx.ai (optional), COS, and all 8 AI modules. Each stage below shows inputs, outputs, and which file does the work.
User uploads a purchase register (Excel / CSV), a GSTR-2B JSON from the GSTN portal, or PDF/image invoices.
server.ts → POST /api/sessions/:id/uploadstorage/storage.service.ts puts the file in COS under a generated keydb/repositories/document.repo.ts creates a documents row (status=pending)Format-aware parsers turn the raw files into a unified ParsedInvoice shape.
excel_parser/format_detectors.py identifies Tally / Zoho / genericexcel_parser/column_mapper.py fuzzy-matches headers to canonical fieldsexcel_parser/parser.py runs per-row validation and produces a ParseReportgstr_parser/section_extractor.py pulls the B2B block from the GSTN envelopegstr_parser/gstr2b_parser.py handles both post-Oct-2024 and legacy itms[] shapesgstr_parser/deduplication.py assigns inv_uid and a record_hashpdfplumber extracts text from native PDFs (free, no API cost for text extraction). The extracted text is then sent to watsonx.ai Mistral model which parses structured invoice fields (invoice #, GSTIN, amounts, line items). Only LLM token cost applies (covered by existing watsonx credits).POST /parse/pdf?ocr_mode=pdfplumber|watsonx, accepts multipart file upload. Both modes return the same rows[] format used by the Excel parser, so downstream reconciliation and validation work identically.WATSONX_COS_CONNECTION_ID in the AI service's environment.watsonx.ai Mistral model turns raw invoice text into structured fields. Called automatically as part of the PDF/image parse pipeline (Stage 2), or standalone via the AI Extraction page.
watsonx/client.py returns a singleton ModelInferencewatsonx/extraction.py builds the prompt and calls Mistral with temperature=0.1signal.alarm, returns ExtractionResponseextract_invoice_fields(text) → InvoiceFieldsextract_invoice_fields(text) → InvoiceFieldsrows[] format matching the Excel parser for downstream compatibilityEnd-to-end OCR extraction takes ~35-40 seconds. Here is where the time goes. The LLM does NOT see the image, it only sees the text that OCR already produced.
Step 1 Upload file to COS ~1-2s # boto3 PutObject Step 2 Get/create COS connection asset ~1-2s # cached after first call Step 3 watsonx OCR job ~20-30s # THE BIG ONE # Single job outputs both markdown AND tables_json simultaneously. # Adding TABLES_JSON to result_formats doesn't launch a second job #, it just tells the same job to also output table data. Step 4 Polling loop # polls every 3s until complete (included in step 3) Step 5 Read results from COS ~1-2s # download markdown + tables_json Step 6 LLM field extraction (Mistral on text) ~5-10s # watsonx.ai inference Step 7 Post-processing (merge + validate + score) <1ms # pure Python, no API calls -------- Total ~35-40s
# The LLM parses already-extracted text into structured JSON. # It does NOT see the image. For this task, model size has diminishing returns. mistral-small-3.1-24b (current) 24B ~5-10s # Good at structured JSON extraction meta-llama/llama-3-3-70b-instruct 70B ~10-20s # Better reasoning, but 2x slower ibm/granite-3-8b-instruct 8B ~2-3s # Faster, but may miss edge cases
# The real quality bottleneck is the OCR step (Step 3). # If OCR garbles a digit, no LLM can fix it. # # The tables_json cross-referencing (Step 7) is more # valuable than upgrading the LLM, it catches OCR # digit errors that no model size can fix. # # A 70B model might handle messy/ambiguous text slightly # better (e.g. garbled columns), but adds ~10s to an # already slow pipeline. Not worth the trade-off.
Every parsed invoice is checked against GST rules. Failing rows are still included but flagged, reviewer sees them in the final report.
gstin.py, 15-char structure + Luhn Mod 36 checksumhsn_sac.py, 4 / 6 / 8-digit pattern, turnover-band warningstax_math.py, qty×rate = taxable, taxable×rate = tax, totals reconciletax_type.py, CGST+SGST for intra-state, IGST for inter-stateinvoice_number.py, alphanumeric, ≤ 16 chars (Rule 46)ValidationReport per invoicesupplier.gstin)Strip format noise from invoice numbers so that INV-00123/FY24 and inv 123 / fy24 reconcile in Pass 2.
INV-, INV., TAX-- / .)Deterministic matching in descending strictness. See the dedicated Reconciliation Engine page for the full algorithm.
duplicate_detector.py removes exact dups, flags near-dupsauto_resolver.py promotes trivial mismatches back to MATCHEDcross_period.py retries residue against adjacent periodsconfidence.py final per-entry confidence adjustmentmatch_category · MATCHED / MISMATCH / MISSING_IN_GSTR2B / MISSING_IN_BOOKS / PROBABLEmatch_pass · which pass produced the matchmatch_confidence · 0.0-1.0mismatch_types[] · TAXABLE_VALUE / TAX_AMOUNT / DATE / ...auto_resolved · booleanTurn reconciliation gaps into a prioritized action queue for the finance team.
itc_risk/scorer.py adds points per risk factor (missing in 2B, tax mismatch, cross-period, etc.)itc_risk/aggregator.py sums ITC at risk (INR)Combined reconciliation + risk payload returned to the API caller; persisted to the document's metadata.
documents.status → completeddocuments.processed_at → now()documents.metadata (JSONB) ← full report for auditA handful of Pydantic models carry state across the pipeline. Every stage reads or extends these.
# gstr_parser / excel_parser output supplier_gstin: str # 15 chars recipient_gstin: str invoice_number: str invoice_date: str # DD-MM-YYYY invoice_value: float taxable_value: float igst/cgst/sgst/cess: float irn: str | None # e-invoice only inv_uid: str # deterministic record_hash: str # change tracking
# reconciliation.engine output (per pair) supplier_gstin: str invoice_number: str books_value: float | None gstr2b_value: float | None difference: float | None match_category: MatchCategory match_confidence: float match_pass: str mismatch_types: list[MismatchType] auto_resolved: bool books_record: dict | None gstr2b_record: dict | None
# itc_risk.scorer output supplier_gstin: str invoice_number: str invoice_value: float itc_amount: float risk_score: int # 0-100 risk_factors: list[str] priority: RiskLevel # CRITICAL | HIGH | MEDIUM | LOW sla_days: int # 3 / 7 / 15 / 30
# invoice_validator output is_valid: bool errors: dict[str, list[str]] # field -> error messages warnings: list[str] # Errors are keyed by dotted field path: # "supplier.gstin", "line_items[0].hsn_sac_code"...
# reconcile() final payload total_books_entries: int total_gstr2b_entries: int matched: int mismatched: int missing_in_gstr2b: int missing_in_books: int probable_matches: int auto_resolved: int duplicates_detected: int entries: list[ReconciliationEntry]
# Node API postgres schema id: UUID # PK filename: VARCHAR(255) storage_uri: TEXT | null doc_type: VARCHAR(50) status: VARCHAR(20) # pending | processing | completed | error metadata: JSONB # full reconciliation report here uploaded_at: TIMESTAMPTZ processed_at: TIMESTAMPTZ | null
The system boots with missing backing services. Every boundary has a documented failure envelope.
Any /api/documents/* route returns 503 { error, detail }. The rest of the API still works. Health shows components.database = unavailable.
/api/storage/* returns 503. Reconciliation paths that bypass upload (direct JSON) still work.
main.py._do_extract maps IBM errors to explicit HTTP codes. Timeout is enforced by signal.alarm at 30 s / 60 s.
format_detectors.detect_format falls back to generic; column mapper produces a best-effort mapping. User sees unmapped columns in the ParseReport.
Invoice is kept but flagged. Reconciliation still runs against the value it was given, the invalid GSTIN becomes a visible error in the reviewer UI.
Entry falls through to MISSING_IN_GSTR2B / MISSING_IN_BOOKS. Risk scorer flags it CRITICAL (+80). Reviewer queue sorts it to the top.
pdfplumber extracts <50 chars, returns 422 with message "Scanned or image-only PDF detected". UI shows a "Retry with watsonx OCR" button so user can explicitly opt in to the paid OCR path.
If WATSONX_COS_CONNECTION_ID is not set, OCR mode returns 422 with setup instructions. Image uploads still show in the UI but cannot be processed until setup is complete.
A key IBM differentiator for the GST Co-Pilot. Two extraction modes provide cost-optimised document processing with enterprise OCR fallback.
Text extraction: FREE # pdfplumber, local, no API call LLM parsing: ~token cost # watsonx Mistral, already covered by credits Scanned PDFs: NOT SUPPORTED # falls back to OCR mode Image files: NOT SUPPORTED # pdfplumber is PDF-only # Pipeline: .pdf → pdfplumber (text) → watsonx LLM (fields) → rows[] # Used by: POST /parse/pdf?ocr_mode=pdfplumber
OCR extraction: FREE (100 docs/mo) # then $0.038/page LLM parsing: ~token cost # same watsonx Mistral model Scanned PDFs: SUPPORTED # full OCR with IBM document extraction Image files: SUPPORTED # JPG, PNG, TIFF # Pipeline: file → COS upload → watsonx OCR job → extracted text → LLM → rows[] # Requires: COS connection asset in watsonx project # Used by: POST /parse/pdf?ocr_mode=watsonx
# 1. PDFs always try pdfplumber first (free) # 2. If pdfplumber fails (<50 chars = scanned), # UI shows "Retry with watsonx OCR" button # 3. User explicitly opts in to paid OCR path # 4. Images (.jpg/.png/.tif) go directly to watsonx OCR # (pdfplumber cannot process them) # 5. No automatic OCR for PDFs = zero surprise costs
1. Upload sample-invoice-tcs.pdf to a session 2. Auto-detected as "PDF Invoice", pdfplumber mode 3. Click Upload & Parse → status transitions to parsed 4. Expand → see extracted fields (INV-2024-001234, TCS, etc.) 5. Upload unknown file (.txt) → manual type dropdown appears 6. Upload image (.jpg) → auto-detected as "Image Invoice" # If watsonx COS configured: 7. Image parses via watsonx OCR → see pdf_ocr format 8. Force-fail a scanned PDF → "Retry with OCR" button appears
# In the IBM watsonx.ai console: 1. Add a connection to your object storage bucket 2. Copy the resulting connection asset id 3. Point the AI service at that connection # The service reads storage endpoint, credentials and bucket # from its environment. Nothing is baked into the image.