Short answer: choose a simple API to summarize multilingual support tickets, emails, and meeting notes only after your Go boundary can preserve source evidence, enforce EU/US regional policy, and switch providers without changing the supplier-invoice workflow; a convenient request format matters, but an auditable result is the real selection criterion.
For a B2B SaaS team extracting fields from supplier invoices, “summarize this text” is deceptively broad. The same intake service may receive a support ticket about a disputed amount, an emailed invoice, and meeting notes that approve a credit. Those documents can be multilingual, can contain personal or financial data, and can contradict one another. A fluent paragraph doesn't settle which tax identifier or due date should enter a ledger. The useful API is therefore the one that fits behind a narrow contract and survives replay, reconciliation, and a region-aware deployment policy.
This is a system boundary, not a compliance certificate. EU and US handling requirements depend on the data, contracts, jurisdictions, retention policy, and deployment details involved; counsel and security reviewers must decide whether a particular arrangement is acceptable. The engineering task is to make those decisions enforceable and reviewable.
What must an API preserve when summarizing support tickets, emails, and meeting notes?
Treat every input as an immutable document and every generated summary as a derived record. Assign the input a stable document ID, preserve its language and region policy, hash the exact normalized bytes, and require the result to cite source spans or page references. For supplier invoices, keep extracted fields separate from narrative summaries: invoice_number, currency, and total belong in a validated structure, while “payment delayed pending approval” belongs in prose. Mixing them makes reconciliation much harder because a later model can rewrite both at once.
The request should carry a client-generated idempotency key. The response should return the provider-neutral model alias actually selected by your policy layer, the schema version, and a stable fingerprint of the input. Persist that envelope before any downstream ledger or CRM action. Exactly-once execution across a network is an aspiration, not a property granted by an API call; durable idempotency and an audit trail are how a backend approximates the business outcome.
Keep the language instruction explicit but modest: summarize in the requested output language, retain names, currencies, invoice identifiers, negation, and uncertainty, and return unknown rather than guessing a missing value. Don't ask the model to translate, classify, extract, summarize, and authorize payment in one prompt. That large operation is difficult to retry selectively and impossible to approve responsibly.
One detail matters more than it first appears: preserve the original text alongside any normalized Unicode form used for hashing or matching. Normalization can be useful for deduplication, but the signed or uploaded artifact remains the evidence. If the two diverge, the audit view must show both.
Keep the evidence.
Encode the evidence boundary in Go
Once the evidence record is defined, encode it as the application contract and write adapters only at the outer edge. The following program is runnable with go run .; its deterministic provider stands in for any remote adapter, so the example demonstrates the boundary without claiming a commercial API route or response shape. A production adapter would use the same interface, send the configured endpoint through net/http, and map the remote response into SummaryResult.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type SummaryRequest struct {
DocumentID string `json:"document_id"`
IdempotencyKey string `json:"idempotency_key"`
SourceLanguage string `json:"source_language"`
OutputLanguage string `json:"output_language"`
RegionPolicy string `json:"region_policy"`
SchemaVersion string `json:"schema_version"`
Text string `json:"text"`
}
type Citation struct {
Start int `json:"start"`
End int `json:"end"`
}
type SummaryResult struct {
DocumentID string `json:"document_id"`
InputSHA256 string `json:"input_sha256"`
Summary string `json:"summary"`
Citations []Citation `json:"citations"`
ProviderReceipt string `json:"provider_receipt"`
SchemaVersion string `json:"schema_version"`
}
type Provider interface {
Summarize(context.Context, SummaryRequest) (SummaryResult, error)
}
type DeterministicProvider struct{}
func (DeterministicProvider) Summarize(_ context.Context, req SummaryRequest) (SummaryResult, error) {
if strings.TrimSpace(req.Text) == "" {
return SummaryResult{}, errors.New("empty source text")
}
sum := sha256.Sum256([]byte(req.Text))
return SummaryResult{
DocumentID: req.DocumentID,
InputSHA256: hex.EncodeToString(sum[:]),
Summary: "Invoice requires review before payment approval.",
Citations: []Citation{{Start: 0, End: len(req.Text)}},
ProviderReceipt: "local-deterministic-evaluation",
SchemaVersion: req.SchemaVersion,
}, nil
}
func validate(req SummaryRequest) error {
if req.DocumentID == "" || req.IdempotencyKey == "" {
return errors.New("document_id and idempotency_key are required")
}
if req.RegionPolicy != "eu" && req.RegionPolicy != "us" {
return errors.New("region_policy must be eu or us")
}
if req.SchemaVersion != "invoice-summary.v1" {
return errors.New("unsupported schema version")
}
return nil
}
func run(ctx context.Context, p Provider, req SummaryRequest) (SummaryResult, error) {
if err := validate(req); err != nil {
return SummaryResult{}, err
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
return p.Summarize(ctx, req)
}
func main() {
req := SummaryRequest{
DocumentID: "supplier-invoice-1842",
IdempotencyKey: "tenant-7:invoice-1842:invoice-summary.v1",
SourceLanguage: "de",
OutputLanguage: "en",
RegionPolicy: "eu",
SchemaVersion: "invoice-summary.v1",
Text: "Rechnung 1842: Zahlung erst nach Freigabe.",
}
result, err := run(context.Background(), DeterministicProvider{}, req)
if err != nil {
panic(err)
}
out, err := json.MarshalIndent(result, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(out))
}
The idempotency key includes tenant, document, operation, and schema version. It deliberately excludes the provider. A retry routed to a different approved adapter must still refer to the same business operation, while the provider receipt records where the execution occurred. Store both successful results and terminal validation failures against that key; otherwise malformed requests can become an unbounded retry loop.
Don't log the raw body by default. Log document ID, tenant-scoped correlation ID, input hash, policy decision, adapter name, model alias, latency bucket, schema version, and outcome. Access to the source artifact and generated text should use a separate audited path with retention controls. OWASP's LLM application guidance is a useful threat-model checklist here, particularly because invoice text is untrusted input rather than an instruction trusted by the system.
Bind regional policy before model execution
The contract is insufficient until policy controls whether it may execute. A clean pipeline has four independently retryable stages: ingest the original artifact, extract candidate fields and evidence locations, generate a bounded narrative summary, then apply deterministic business validation. The model may propose total = 1,240.00 EUR; code must confirm that the value conforms to the versioned schema and that the cited source range exists. Payment authorization remains outside the model call.
Regional routing belongs before the adapter, not inside a prompt. A policy function receives tenant configuration, data classification, requested capability, and deployment region; it either selects an approved adapter or rejects the job. The adapter receives no freedom to move the workload elsewhere. If contractual commitments, retention controls, or processing locations cannot be verified for a deployment, the correct state is “not approved,” even when the API itself is pleasant to use.
This separation also handles support tickets, emails, and meeting notes without pretending that they share one schema. They can reuse the same provider interface and policy layer, but each document type gets its own schema version, validation rules, and evidence model. Meeting notes may cite timestamp ranges; email evidence may cite MIME parts; invoice evidence may cite page coordinates. One gateway is reasonable. One universal payload isn't.
The catch is that a thin gateway does not erase provider differences. Token limits, supported languages, structured-output behavior, regional deployment choices, and retention terms must be checked against current documentation and contracts during selection. I'm not sure any static comparison can stay accurate enough for a regulated workload; a dated evidence packet, owned by the team and refreshed before renewal or migration, resolves that uncertainty better than a timeless scorecard.
For teams with one language, public data, no downstream financial action, and no foreseeable migration, a direct provider integration may be more appropriate because the extra gateway, evidence store, and conformance suite have operating cost. Stick with a self-hosted inference stack when policy prohibits third-party processing or when the team must control the entire runtime. Neither choice removes the need for input validation and access control.
Break the contract with adversarial fixtures
The next phase is destructive testing of the boundary, because feature tables rarely reveal the failures that corrupt state. Build a conformance suite around your contract and run every adapter through it. Include duplicate delivery with the same idempotency key, conflicting delivery with the same key but a different hash, unsupported language, oversized input, deadline cancellation, malformed structured output, missing citations, prompt injection inside invoice text, and a response that changes a currency or drops a negation. A remote 429 is a retryable capacity signal; a schema validation failure is not. Record those categories in your own error vocabulary so an adapter swap cannot silently change retry behavior. Small fixtures are enough to establish the mechanics, but they must include the awkward cases: use an invoice in German, an English support email disputing the total, and French meeting notes that defer approval, then assert preservation of EUR, invoice number 1842, and the approval uncertainty rather than demand one exact sentence. Now replay the same idempotency key with one changed byte and require a conflict, because accepting it as a retry would attach two inputs to one business operation; follow that with a cancelled context and verify that no downstream record is committed. Exact string matching rewards wording stability, not factual fidelity. This runnable Go test covers two non-negotiable properties of the sample boundary: the same bytes produce the same fingerprint, and policy validation happens before provider execution.
package main
import (
"context"
"testing"
)
type countingProvider struct {
calls int
}
func (p *countingProvider) Summarize(_ context.Context, req SummaryRequest) (SummaryResult, error) {
p.calls++
return DeterministicProvider{}.Summarize(context.Background(), req)
}
func validRequest() SummaryRequest {
return SummaryRequest{
DocumentID: "supplier-invoice-1842",
IdempotencyKey: "tenant-7:invoice-1842:invoice-summary.v1",
SourceLanguage: "de",
OutputLanguage: "en",
RegionPolicy: "eu",
SchemaVersion: "invoice-summary.v1",
Text: "Rechnung 1842: Zahlung erst nach Freigabe.",
}
}
func TestStableFingerprint(t *testing.T) {
p := &countingProvider{}
first, err := run(context.Background(), p, validRequest())
if err != nil {
t.Fatal(err)
}
second, err := run(context.Background(), p, validRequest())
if err != nil {
t.Fatal(err)
}
if first.InputSHA256 != second.InputSHA256 {
t.Fatal("identical source bytes produced different fingerprints")
}
}
func TestPolicyRejectsBeforeProviderCall(t *testing.T) {
p := &countingProvider{}
req := validRequest()
req.RegionPolicy = "unreviewed"
if _, err := run(context.Background(), p, req); err == nil {
t.Fatal("expected region policy validation error")
}
if p.calls != 0 {
t.Fatalf("provider called %d times after policy rejection", p.calls)
}
}
Run quality evaluation at two levels. Offline fixtures test schema adherence, evidence coverage, language preservation, and forbidden transformations on every change. A controlled shadow run compares a candidate adapter against the current adapter without sending candidate output downstream. Human reviewers inspect disagreements, especially totals, dates, account identifiers, negation, and approval state. Your mileage may vary by document mix, so publish acceptance thresholds only after labeling a representative tenant-scoped sample; invented universal percentages would be false precision.
Observability must answer a reconciliation question: for document supplier-invoice-1842, which immutable input, policy decision, schema, adapter, and result led to the current downstream record? If the trace cannot answer that without searching raw prompt logs, the design is not audit-ready.
Reject ambiguity.
Migrate by result version, not by vendor name
Begin with one schema and one low-risk document class. Replay a frozen, access-controlled fixture set through the first adapter, store normalized results under a new result version, and prevent them from triggering payments or customer-visible updates. Add the second adapter only after the contract tests are stable; portability is demonstrated by the same fixtures and acceptance rules passing through both adapters, not by drawing two boxes behind an interface.
Then canary a small tenant cohort, reconcile every derived field against its cited source, and define rollback as a routing-policy change rather than a code deployment. Keep old result versions addressable until retention policy removes them. Short summaries can still have long consequences.
The decision rule is compact: select an API only after its current documentation and contract satisfy the required languages, processing regions, retention controls, authentication model, structured-result behavior, and operational limits; select an architecture only after a second adapter can pass the same tests without changing invoice workflow code. If either proof is missing, postpone the migration rather than weakening the audit boundary.
References
- https://owasp.org/www-project-top-10-for-large-language-model-applications/
- https://openrouter.ai/docs
Top comments (0)