Skip to content

Commit 7fccee6

Browse files
Merge pull request #172 from appunite/feat/training-csv-export
feat: training CSV export + classifier page rename
2 parents c3de4ef + a7c624d commit 7fccee6

14 files changed

Lines changed: 837 additions & 64 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
name: Classifier Deployment Strategy
3+
description: Exploration of multi-tenant ML model serving strategies — managed training with flexible deployment (self-hosted or managed).
4+
tags: [classifier, ml, multi-tenant, architecture, deployment]
5+
author: emil
6+
date: 2026-04-24
7+
status: Proposed
8+
---
9+
10+
# 0050. Classifier Deployment Strategy
11+
12+
Date: 2026-04-24
13+
14+
## Status
15+
16+
Proposed — open for discussion, not yet decided.
17+
18+
## Context
19+
20+
KSeF Hub uses three sidecar services:
21+
22+
| Service | Coupling | Tenant-specific? |
23+
|---------|----------|-----------------|
24+
| **pdf-renderer** | Tightly coupled to the app (FA(3) XML → PDF) | No — same logic for all tenants |
25+
| **invoice-extractor** | Tightly coupled to the app (PDF → structured JSON) | No — generic extraction |
26+
| **invoice-classifier** | Loosely coupled, depends on tenant data | **Yes** — categories, tags, and classification patterns are per-company |
27+
28+
The classifier is fundamentally different: its accuracy depends on the tenant's own data. A model trained on one company's invoices (e.g., a media agency) will mis-classify for another (e.g., a construction firm). This means multi-tenant SaaS needs per-tenant models.
29+
30+
### Current state
31+
32+
- Single classifier service behind a per-company config (ADR 0049)
33+
- Each company can override the classifier URL, token, and confidence thresholds
34+
- One shared model serves all companies — predictions degrade as company profiles diverge
35+
- Training data export exists (CSV with extended columns from the Services tab)
36+
37+
### Problem
38+
39+
As we scale to more tenants, a shared model won't work. We need a strategy for:
40+
1. **Training** — how per-tenant models are created and updated
41+
2. **Serving** — how predictions are served at inference time
42+
3. **Cost** — who pays for compute (us or the tenant)
43+
4. **Ops** — how much operational burden falls on us vs. the tenant
44+
45+
## Options Considered
46+
47+
### Option A — Managed multi-tenant model serving
48+
49+
We own both training and serving. A model-management sidecar loads per-tenant models on demand.
50+
51+
**How it works:**
52+
- Tenant clicks "retrain" in the UI (or we auto-retrain on a schedule)
53+
- We train the model from their approved invoices using our training pipeline
54+
- A model-serving sidecar keeps recently-used models in memory, evicts cold ones
55+
- Prediction requests are routed to the correct tenant model
56+
57+
**Pros:**
58+
- Best UX — tenant never touches infrastructure
59+
- We control model quality, versioning, rollback
60+
- Can optimize hardware (shared GPU/CPU, model caching)
61+
62+
**Cons:**
63+
- Operational complexity — cache eviction, cold starts, memory pressure, OOM risk
64+
- We bear infra cost per model (though classification models are small, ~MBs)
65+
- Single point of failure — our serving layer goes down, all tenants lose classification
66+
67+
### Option B — Dedicated self-hosted service per tenant
68+
69+
We open-source the classifier. Each tenant trains and deploys their own instance.
70+
71+
**How it works:**
72+
- We provide training tools and a Docker image
73+
- Tenant deploys to their own cloud (GCP Cloud Run, AWS ECS, etc.)
74+
- Tenant configures their classifier URL in KSeF Hub (already supported via ADR 0049)
75+
76+
**Pros:**
77+
- Zero infra cost for us
78+
- Tenant data never leaves their environment (compliance/GDPR win)
79+
- Tenant has full control over model, hardware, scaling
80+
81+
**Cons:**
82+
- Terrible UX — requires cloud knowledge, deployment pipeline, monitoring
83+
- Support burden when tenant's service breaks
84+
- Fragmented versions — tenants may run stale images
85+
86+
### Option C — Managed training + flexible deployment (preferred direction)
87+
88+
We own the training pipeline. The tenant chooses where to serve: our managed infrastructure or their own cloud.
89+
90+
**How it works:**
91+
- **Training**: We provide a managed training pipeline. Tenant's approved invoices feed into it. We produce a model artifact (e.g., ONNX, TensorFlow SavedModel, scikit-learn pickle).
92+
- **Deployment option 1 — Managed**: We host the model in our serving layer (same as Option A). Included in the subscription or as a paid tier.
93+
- **Deployment option 2 — Self-hosted on cloud ML platforms**: Tenant deploys the model artifact to a managed ML service:
94+
- **Google Cloud**: Vertex AI Endpoints (upload model → get prediction API), Cloud Run (containerized)
95+
- **AWS**: SageMaker Endpoints (upload model → get prediction API), Lambda (lightweight)
96+
- **Azure**: Azure ML Endpoints
97+
- We provide clear documentation/tooling for each cloud platform deployment.
98+
- Tenant points their classifier URL to their deployed endpoint (ADR 0049 already supports this).
99+
100+
**Pros:**
101+
- We own the hardest part (training pipeline, data prep, feature engineering)
102+
- Tenant gets a simple choice: "we host it" or "you host it on [GCP/AWS/Azure]"
103+
- Cloud ML platforms handle scaling, monitoring, versioning natively
104+
- Self-hosted option satisfies compliance-sensitive tenants
105+
- Model artifact is portable — not locked to our infrastructure
106+
107+
**Cons:**
108+
- Need to support multiple model export formats (or standardize on one like ONNX)
109+
- Documentation/guides needed for each cloud platform
110+
- Two code paths for model delivery (managed vs. self-hosted)
111+
112+
## Open Questions
113+
114+
1. **Model format**: What format do we standardize on? ONNX is portable but may lose framework-specific optimizations. scikit-learn pickle is simple but Python-only.
115+
2. **Retraining frequency**: On-demand (tenant clicks button), scheduled (weekly), or continuous (on every N new approved invoices)?
116+
3. **Minimum training data**: How many approved invoices does a company need before a per-tenant model outperforms the shared default model?
117+
4. **Cold start**: For new tenants with no data, do we use a shared "bootstrap" model trained on anonymized cross-tenant data?
118+
5. **Pricing**: Is managed serving included in the base plan, or a paid add-on?
119+
6. **Model versioning**: How do we handle A/B testing between model versions? Rollback on accuracy regression?
120+
121+
## Decision
122+
123+
Not yet decided. This ADR captures the current thinking to be revisited when we approach multi-tenant scaling.
124+
125+
**Leaning toward Option C** — managed training with flexible deployment. It gives us the best of both worlds: great UX for tenants who want "it just works", and full control for tenants with compliance requirements or existing cloud infrastructure.
126+
127+
## Next Steps
128+
129+
- [ ] Research Vertex AI / SageMaker model deployment APIs — what's the minimal integration?
130+
- [ ] Prototype: export a trained model artifact, deploy to Vertex AI, verify prediction API compatibility
131+
- [ ] Define minimum viable training pipeline (how many invoices, what features, what accuracy threshold)
132+
- [ ] Estimate serving costs per tenant for the managed option
133+
- [ ] Decide on model format standard
134+
135+
## Consequences
136+
137+
Deferring this decision is acceptable for now — the current shared model + per-company config (ADR 0049) works for early tenants. But this becomes blocking when:
138+
- A second tenant with a very different business profile onboards
139+
- Classification accuracy drops below the confidence thresholds for any company
140+
- A tenant asks "can I train my own model?"

docs/architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Quick reference for developers. Before touching a feature area:
1313
| Feature | Key files |
1414
|---------|-----------|
1515
| Invoice export (ZIP + CSV) | `lib/ksef_hub/exports.ex`, `exports/csv_builder.ex`, `exports/export_batch.ex`, `live/export_live/index.ex` |
16+
| Training CSV export | `lib/ksef_hub/exports.ex` (`list_training_invoices/3`), `exports/csv_builder.ex` (`extended: true`), `controllers/training_csv_controller.ex` |
1617
| KSeF sync | `lib/ksef_hub/sync/sync_worker.ex`, `sync/invoice_fetcher.ex`, `ksef_client/` |
1718
| FA(3) XML parsing | `invoices/parser.ex`, `docs/fa3-xml.md` (field mapping reference) |
1819
| Invoice CRUD & business logic | `lib/ksef_hub/invoices.ex` (facade) + `invoices/` sub-modules |
@@ -109,3 +110,4 @@ Read only the ADR(s) relevant to your task — the summaries below tell you whic
109110
| 0047-approver-analyst-roles.md | Approver and Analyst Roles | Accepted | Rename `:reviewer``:approver` and `:viewer``:analyst`; analyst has same data scope as approver (access grants required for restricted invoices) |
110111
| 0048-public-landing-page.md | Public Landing Page as Standalone Astro Project | Accepted | Ship marketing site as separate Astro 5 + Tailwind v4 project in `landing/`, deployed to GitHub Pages; zero runtime coupling with Phoenix |
111112
| 0049-per-company-classifier-config.md | Per-Company Classifier Configuration | Accepted | Company-scoped classifier settings in `classifier_configs` table with env-var fallback; only classifier supports per-company overrides |
113+
| 0050-classifier-deployment-strategy.md | Classifier Deployment Strategy | Proposed | Multi-tenant ML model serving — managed training with flexible deployment (self-hosted on Vertex AI/SageMaker or our managed infra) |

lib/ksef_hub/exports.ex

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,34 @@ defmodule KsefHub.Exports do
9494
|> Repo.all()
9595
end
9696

97+
@doc "Builds an extended training CSV binary from a list of invoices."
98+
@spec build_training_csv([Invoice.t()], keyword()) :: binary()
99+
defdelegate build_training_csv(invoices, opts \\ []), to: CsvBuilder, as: :build
100+
101+
@doc """
102+
Lists invoices for training CSV export with extended preloads.
103+
104+
Unlike `list_exportable_invoices/1`, this takes a company_id and date range
105+
directly — no ExportBatch required, no download tracking. Includes all invoice
106+
types with no date range cap (CSV generation is fast, unlike PDF rendering).
107+
"""
108+
@spec list_training_invoices(Ecto.UUID.t(), Date.t(), Date.t()) :: [Invoice.t()]
109+
def list_training_invoices(company_id, date_from, date_to) do
110+
%{
111+
company_id: company_id,
112+
date_from: date_from,
113+
date_to: date_to,
114+
invoice_type: nil,
115+
only_new: false,
116+
user_id: nil,
117+
category_id: nil
118+
}
119+
|> exportable_invoices_query()
120+
|> order_by([i], asc: i.issue_date, asc: i.invoice_number)
121+
|> preload([:category, :payment_requests, :created_by, :inbound_email])
122+
|> Repo.all()
123+
end
124+
97125
@doc "Counts invoices matching the given export filters without loading them."
98126
@spec count_exportable_invoices(Ecto.UUID.t(), map()) :: non_neg_integer()
99127
def count_exportable_invoices(company_id, filters) do

lib/ksef_hub/exports/csv_builder.ex

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule KsefHub.Exports.CsvBuilder do
33

44
alias KsefHub.Invoices.Invoice
55

6-
@headers [
6+
@standard_headers [
77
"Invoice Number",
88
"Issue Date",
99
"Sales Date",
@@ -38,21 +38,41 @@ defmodule KsefHub.Exports.CsvBuilder do
3838
"Correction Reason"
3939
]
4040

41+
@extended_headers [
42+
"Invoice ID",
43+
"Company ID",
44+
"Cost Line",
45+
"Project Tag",
46+
"Is Excluded",
47+
"Access Restricted",
48+
"Payment Status",
49+
"Payment Date",
50+
"Category Identifier",
51+
"Prediction Status",
52+
"Predicted Category",
53+
"Predicted Tag",
54+
"Category Confidence %",
55+
"Tag Confidence %",
56+
"Extraction Status"
57+
]
58+
4159
@doc "Builds a CSV binary (UTF-8 with BOM) from a list of invoices."
42-
@spec build([Invoice.t()]) :: binary()
43-
def build(invoices) do
44-
rows = Enum.map(invoices, &invoice_to_row/1)
60+
@spec build([Invoice.t()], keyword()) :: binary()
61+
def build(invoices, opts \\ []) do
62+
extended = Keyword.get(opts, :extended, false)
63+
headers = if extended, do: @standard_headers ++ @extended_headers, else: @standard_headers
64+
rows = Enum.map(invoices, &invoice_to_row(&1, extended))
4565

4666
csv =
47-
[@headers | rows]
67+
[headers | rows]
4868
|> Enum.map_join("\r\n", &encode_row/1)
4969

5070
<<0xEF, 0xBB, 0xBF>> <> csv <> "\r\n"
5171
end
5272

53-
@spec invoice_to_row(Invoice.t()) :: [String.t()]
54-
defp invoice_to_row(invoice) do
55-
[
73+
@spec invoice_to_row(Invoice.t(), boolean()) :: [String.t()]
74+
defp invoice_to_row(invoice, extended) do
75+
standard = [
5676
s(invoice.invoice_number),
5777
format_date(invoice.issue_date),
5878
format_date(invoice.sales_date),
@@ -86,8 +106,74 @@ defmodule KsefHub.Exports.CsvBuilder do
86106
s(invoice.corrected_invoice_ksef_number),
87107
s(invoice.correction_reason)
88108
]
109+
110+
if extended do
111+
standard ++ extended_fields(invoice)
112+
else
113+
standard
114+
end
115+
end
116+
117+
@spec extended_fields(Invoice.t()) :: [String.t()]
118+
defp extended_fields(invoice) do
119+
{payment_status, payment_date} = extract_payment_info(invoice)
120+
121+
[
122+
s(invoice.id),
123+
s(invoice.company_id),
124+
s(invoice.expense_cost_line),
125+
s(invoice.project_tag),
126+
format_boolean(invoice.is_excluded),
127+
format_boolean(invoice.access_restricted),
128+
payment_status,
129+
payment_date,
130+
format_category_identifier(invoice),
131+
s(invoice.prediction_status),
132+
s(invoice.prediction_expense_category_name),
133+
s(invoice.prediction_expense_tag_name),
134+
format_confidence(invoice.prediction_expense_category_confidence),
135+
format_confidence(invoice.prediction_expense_tag_confidence),
136+
s(invoice.extraction_status)
137+
]
89138
end
90139

140+
@spec extract_payment_info(Invoice.t()) :: {String.t(), String.t()}
141+
defp extract_payment_info(%{payment_requests: prs}) when is_list(prs) do
142+
case Enum.find(prs, &(&1.status == :paid)) do
143+
%{status: status, paid_at: paid_at} ->
144+
{s(status), format_datetime(paid_at)}
145+
146+
nil ->
147+
case List.first(prs) do
148+
nil -> {"", ""}
149+
%{status: status} -> {s(status), ""}
150+
end
151+
end
152+
end
153+
154+
defp extract_payment_info(_), do: {"", ""}
155+
156+
@spec format_category_identifier(Invoice.t()) :: String.t()
157+
defp format_category_identifier(%{category: %{identifier: id}}) when is_binary(id), do: id
158+
defp format_category_identifier(_), do: ""
159+
160+
@spec format_boolean(boolean() | nil) :: String.t()
161+
defp format_boolean(true), do: "true"
162+
defp format_boolean(false), do: "false"
163+
defp format_boolean(nil), do: ""
164+
165+
@spec format_confidence(float() | nil) :: String.t()
166+
defp format_confidence(nil), do: ""
167+
168+
defp format_confidence(value) when is_float(value) do
169+
value
170+
|> Kernel.*(100)
171+
|> Float.round(1)
172+
|> Float.to_string()
173+
end
174+
175+
# --- Shared formatting helpers ---
176+
91177
@spec s(term()) :: String.t()
92178
defp s(nil), do: ""
93179
defp s(value), do: to_string(value)

lib/ksef_hub/invoices/invoice.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ defmodule KsefHub.Invoices.Invoice do
8888
belongs_to :pdf_file, KsefHub.Files.File
8989
field :tags, {:array, :string}, default: []
9090
has_many :comments, KsefHub.Invoices.InvoiceComment
91+
has_many :payment_requests, KsefHub.PaymentRequests.PaymentRequest
9192
belongs_to :created_by, KsefHub.Accounts.User
9293
has_one :inbound_email, KsefHub.InboundEmail.InboundEmail
9394
has_many :access_grants, KsefHub.Invoices.InvoiceAccessGrant

lib/ksef_hub_web/components/settings_components.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ defmodule KsefHubWeb.SettingsComponents do
7676
{:manage_certificates, "Certificates", ~p"/c/#{id}/settings/certificates",
7777
"hero-shield-check"},
7878
{:manage_team, "Activity Log", ~p"/c/#{id}/settings/activity-log", "hero-clock"},
79-
{:manage_services, "Services", ~p"/c/#{id}/settings/services", "hero-server-stack"}
79+
{:manage_services, "Classifier", ~p"/c/#{id}/settings/services", "hero-cpu-chip"}
8080
]
8181
|> Enum.filter(fn {perm, _label, _path, _icon} ->
8282
is_nil(perm) or Authorization.can?(role, perm)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule KsefHubWeb.TrainingCsvController do
2+
@moduledoc "Controller for downloading training CSV files with extended invoice data."
3+
4+
use KsefHubWeb, :controller
5+
6+
import KsefHubWeb.AuthHelpers, only: [resolve_role: 2]
7+
import KsefHubWeb.FilenameHelpers, only: [send_attachment: 4]
8+
9+
alias KsefHub.Authorization
10+
alias KsefHub.Exports
11+
12+
@doc "Downloads an extended CSV of invoices for ML training."
13+
@spec download(Plug.Conn.t(), map()) :: Plug.Conn.t()
14+
def download(conn, %{"company_id" => company_id, "date_from" => from, "date_to" => to}) do
15+
user_id = conn.assigns.current_user.id
16+
role = resolve_role(user_id, company_id)
17+
18+
if Authorization.can?(role, :manage_services) do
19+
do_download(conn, company_id, from, to)
20+
else
21+
conn
22+
|> put_flash(:error, "You don't have permission to export training data.")
23+
|> redirect(to: ~p"/c/#{company_id}/settings/services")
24+
end
25+
end
26+
27+
def download(conn, %{"company_id" => company_id}) do
28+
conn
29+
|> put_flash(:error, "Date range is required.")
30+
|> redirect(to: ~p"/c/#{company_id}/settings/services")
31+
end
32+
33+
@spec do_download(Plug.Conn.t(), String.t(), String.t(), String.t()) :: Plug.Conn.t()
34+
defp do_download(conn, company_id, date_from_str, date_to_str) do
35+
with {:ok, date_from} <- Date.from_iso8601(date_from_str),
36+
{:ok, date_to} <- Date.from_iso8601(date_to_str),
37+
:ok <- validate_date_order(date_from, date_to) do
38+
invoices = Exports.list_training_invoices(company_id, date_from, date_to)
39+
csv_binary = Exports.build_training_csv(invoices, extended: true)
40+
filename = "training_#{date_from}_#{date_to}.csv"
41+
send_attachment(conn, "text/csv", filename, csv_binary)
42+
else
43+
_ ->
44+
conn
45+
|> put_flash(:error, "Invalid date range.")
46+
|> redirect(to: ~p"/c/#{company_id}/settings/services")
47+
end
48+
end
49+
50+
@spec validate_date_order(Date.t(), Date.t()) :: :ok | :error
51+
defp validate_date_order(from, to) do
52+
if Date.compare(to, from) != :lt, do: :ok, else: :error
53+
end
54+
end

0 commit comments

Comments
 (0)