# 5. AI layer

## 5.1 Principles

1. **Human in the loop.** Every AI outcome that could change operations is stored as an `ai_proposal`. Nothing is created, moved, emailed or re-slotted until a user with `ai.decide` accepts it. Acceptance runs through the same domain services and validation as manual work (an ASN draft becomes an ASN via `AsnService.create`), so the AI cannot bypass business rules.
2. **Deterministic first.** Anomaly detection, slotting and the workload forecast are SQL over the tenant's own data. The model only writes a short narrative on top; if the model is unavailable the analysis still runs.
3. **Read-only tools, never raw SQL.** The copilot answers questions by calling a fixed catalogue of tools that wrap existing services (dashboard, inventory, ASNs, orders, tasks, expiry, aging, productivity, charges, SKU lookup). Every tool enforces the caller's warehouse and storer scope and the normal RLS.
4. **Provider agnostic.** `LlmProvider` (`apps/api/src/modules/ai/llm/llm.provider.ts`) has three implementations: `AnthropicProvider` (Claude via the official SDK, tool use and JSON output), `MockProvider` (deterministic, for development and tests, refused in production) and `DisabledProvider`. Another vendor or a self-hosted model is one class.
5. **Governed.** Feature flags per tenant (`ai.document_intake`, `ai.anomaly_detection`, `ai.nl_query`, `ai.slotting`, `ai.forecast`), monthly token budgets from the plan (`aiTokensPerMonth`, `429 PLAN_LIMIT` when exhausted), `AI_TOKENS` usage metering, model and token counts recorded on every proposal and message, full audit of decisions, automatic expiry of stale proposals.
6. **Private by construction.** Prompts contain only the data needed (e.g. the storer's catalogue for intake). Nothing is used to train models; prompts and completions are not stored except the structured results and the audit trail.

## 5.2 Use cases

| Feature | Input | Output (proposal type) | On accept |
|---------|-------|------------------------|-----------|
| Document intake | PO / packing list / customer order as text, TXT, XLSX or PDF (text layer) | `ASN_FROM_DOCUMENT` or `ORDER_FROM_DOCUMENT`: draft header + lines, per-line SKU match (code, barcode, normalised code, description similarity), confidence, warnings | ASN or order created (`source = AI`); unmatched lines block acceptance until edited |
| Supplier discrepancy email | closed ASN with discrepancies, tone, language (EN/AR) | `DISCREPANCY_EMAIL`: recipients (supplier contacts), subject, HTML body with discrepancy table | Email queued in the outbox |
| Receiving anomalies | window | `RECEIVING_ANOMALY`: supplier discrepancy rate vs baseline, dock-to-stock outliers, repeated damage per SKU, overdue ASNs | Acknowledge |
| Inventory anomalies | window | `INVENTORY_ANOMALY`: allocated > on hand (integrity), stock idle in dock/staging > 24 h, adjustment hot spots, zone count accuracy, dead stock, stale holds, expired stock still AVAILABLE | Acknowledge |
| Billing anomalies | window | `BILLING_ANOMALY`: active storers without contract, missing storage snapshots, zero-amount charges, duplicate charges, invoice drops | Acknowledge |
| Slotting | pick velocity window | `SLOTTING_RECOMMENDATION`: ABC classes from cumulative pick share, fast movers without a pick face matched to free pick faces, min/max from daily velocity and case qty, idle pick faces | Slotting rows created per recommendation (apply toggle per row) |
| Workload forecast | horizon | `WORKLOAD_FORECAST`: per-day labour hours by activity from known ASNs/orders plus weekday baselines, observed productivity rates (fallback defaults), FTE, peak and backlog findings | Acknowledge |
| Copilot | natural-language question, optional conversation | answer text, tools used, tokens; conversation persisted (`ai_conversations`, `ai_messages`) | n/a |

## 5.3 Flow of an LLM call

```
Controller -> service -> LlmService.structured()/complete()
   1. assertAllowed(feature): provider enabled? feature on for tenant? monthly budget left?
   2. provider.complete(request)   (system prompt, messages, tools, JSON mode)
   3. meter tokens (usage_records AI_TOKENS, per tenant per day)
   4. structured(): parse JSON, validate with zod; on failure send the errors back once; then fail with AI_INVALID_OUTPUT
```

Copilot loop: at most 6 tool rounds; tool inputs are validated with zod; tool results are truncated to 12 kB; errors are passed back to the model as tool errors; the trace (tool, input, ok, ms) is stored with the answer.

## 5.4 Configuration

| Variable | Values | Notes |
|----------|--------|-------|
| `AI_PROVIDER` | `anthropic`, `mock`, `disabled` (default) | `mock` is refused when `NODE_ENV=production` |
| `ANTHROPIC_API_KEY` | key | required for `anthropic` |
| `AI_MODEL` | model id (default `claude-sonnet-4-5`) | per deployment; the proposal records the model that produced it |
| `AI_MAX_TOKENS` | integer | default output cap |

Tenant level: feature flags (platform console or plan features) and `aiTokensPerMonth` in the plan limits (0 = unlimited).

## 5.5 Operating the AI inbox

- Supervisors review `AI > Proposals inbox` daily; the sidebar badge shows open proposals; the control tower shows high-severity counts.
- ASN/order drafts: fix unmatched lines (pick the SKU), adjust quantities, accept. The proposal keeps the original extraction (`evidence.extracted`) and the edited payload for audit.
- Findings: acknowledge (ACCEPT) or reject with notes; the notes help tune thresholds later.
- Slotting: review target locations, untick rows you do not want, accept; replenishment picks up the new min/max automatically.
- Expired proposals (default 14 days, forecast 3 days, intake 7 days) are closed by the nightly job; re-run the analysis for fresh numbers.

## 5.6 Extending

- New analysis: add a kind to `RunAnalysisSchema`, a method in `AnalyticsService` returning `{ title, summary, findings, payload }`, a proposal type in `AI_PROPOSAL_TYPE` (+ migration for the enum) and a renderer in `apps/web/src/features/ai/proposal-views.tsx`.
- New copilot tool: append to `CopilotService.tools()` with a zod input schema and a call into an existing read-only service. Do not expose write operations.
- New provider: implement `LlmProvider.complete()` mapping text, tool calls and usage; register it in `ai.module.ts`.
- Testing without a model: `AI_PROVIDER=mock` drives intake and copilot deterministically (see `test/e2e/ai.e2e-spec.ts`).

## 5.7 Risks and mitigations

| Risk | Mitigation |
|------|------------|
| Wrong extraction creates wrong documents | proposals + validation + SKU matching evidence + warnings; unmatched lines cannot be accepted |
| Prompt injection via uploaded documents | documents are data in a delimited block; the model's only effect is a JSON draft that a human reviews; no tool with write access |
| Data exposure to the provider | only the needed fields are sent; provider choice per deployment; `disabled` for sites that forbid external AI |
| Cost runaway | monthly budget per plan, per-call `maxTokens`, metering visible in the platform console |
| Model unavailable | deterministic analytics continue; UI shows the disabled banner; nothing in operations depends on the model |
