Appearance
Work order AI analysis
An advisor clicks Analyze with AI on a work order and gets back a list of things a careful second pair of eyes would catch: a part forgotten relative to the work performed, something billed twice, a description that does not match the parts, an abnormal quantity, a billing omission, a spelling mistake. Nothing on the work order changes on its own.
The criteria are the shop's own. Settings → Analysis Rules is a list of plain-sentence rules — "If a brake caliper replacement is billed, verify that brake fluid is billed as well" — that goes straight into the model's instructions.
The flow
POST /{shop}/work-orders/{workOrder}/analyzehitsAnalyzeWorkOrder::asController(app/Actions/WorkOrder/AnalyzeWorkOrder.php), behindthrottle:aiandmodule:ai— a 403 when the shop's plan has noaior its monthly AI quota is used up (see Plans & modules). Theapi.ai.*group inroutes/web.phpcarries the same pair. It answers JSON ({"status": "analyzing"}, or"is analyzing"when a run is already in flight) rather than redirecting, because the drawer calls it throughuseAi()and stays open.- The controller refuses when the work order is already analyzing, stamps
work_orders.ai_analysis_started_at, and dispatches the action as a job with the acting user's locale. - The job builds
WorkOrderAnalystfor the work order's shop, prompts it, writeswork_orders.ai_analysis, and clears the pending timestamp in afinally. - Both writes broadcast, because
WorkOrderopts into broadcasting.Show.vuealready listens onwork_orders.{id}throughuseRealtimeand partially reloads theworkOrderprop, so the drawer fills itself. No event or channel was added for this feature.
Rules
analysis_rules is deliberately not named after work orders: the same table is meant to carry purchase order rules later.
A shop with no rule of its own is analyzed against config('ai.work_order_analysis.default_rules'). The settings screen's Load Defaults button copies those rows into the table so they become editable. Nothing backfills existing shops — the config fallback is what covers them.
Each packaged rule carries a name and a body per locale. AnalysisRule::defaults() resolves them to one locale, falling back to app.fallback_locale when a translation is missing rather than to an empty string, which would reach the model as a blank instruction. Load Defaults inserts in the acting user's locale; the agent's fallback resolves to the locale the analysis was requested in.
Pressing the button twice is harmless, and so is pressing it in a second locale: each default carries every translation of its name, and one whose shop already has a rule under any of them is skipped instead of inserted again in the new language.
Only is_active rules reach the prompt.
The agent
App\Ai\Agents\WorkOrderAnalyst extends BaseAgent (so LogUsage records the spend in ai_usages) and implements HasStructuredOutput. It takes no tools: the work order is serialized into the prompt by AnalyzeWorkOrder::describe(), which is cheaper and more predictable than letting the model fetch it.
That serializer renders a service as a heading over its indented children and prints an amount only on the children. A service line's sub_total is a roll-up of its children (see work order money rules), so listing it beside them would show the model the same money twice.
Each finding carries a severity, a category, a message, an optional work_order_line_id, and — for language findings only — a suggested_text.
The schema is only binding under #[Strict]
Without the Laravel\Ai\Attributes\Strict attribute the driver sends strict: false, and the provider treats required as a suggestion. It will drop fields, and the failure lands far from the cause: a CannotCreateData exception naming a missing constructor parameter.
Strict mode has its own contract, and the framework does not adapt the schema to it: every property must appear in required, or the API rejects the request. A field that is genuinely optional is therefore ->required()->nullable(), which serializes to "type": ["string", "null"] — the model must emit the key and may set it to null. WorkOrderAnalystTest pins all of this.
Even so, AnalyzeWorkOrder::toFinding() discards a finding it cannot build rather than losing the batch. The schema is enforced provider-side, but a model swap or a failover can still hand back a shape this cannot read, and one bad finding should not cost the advisor the other nine.
Language and model
Findings come back in the acting user's locale. The locale is resolved in asController and passed to the job as an argument: there is no authenticated user inside the worker, so reading it there would silently fall back to the app default.
The model is set by the #[Model] attribute on the agent. It is deliberately not the cheapest one — a mini model does not catch a forgotten part — and swapping it is a one-line change.
Pending state
work_orders.ai_analysis_started_at is the whole mechanism. It is server-side, so the spinner survives a refresh and a second advisor on the same work order sees it too.
WorkOrder::$is_analyzing reads it through a window (config('ai.work_order_analysis.timeout'), 2 minutes): a worker killed mid-run can never clear the column, and the window releases the button instead of a cleanup command or a scheduled task. Raise the timeout if real analyses start running longer than it.
Applying a suggestion
Only a language finding is applicable, through ApplyAnalysisSuggestion (PUT /{shop}/work-orders/{workOrder}/{workOrderLine}/analysis-suggestion), which writes the description and nothing else.
It exists instead of reusing WorkOrderLineController@update because that endpoint requires price, quantity and discount to be present: replaying them from a page that may be seconds stale would let a wording fix overwrite money someone else just changed.
Every other finding is read-only: it names its line and the advisor fixes it themselves. An applied finding disappears from the open drawer, which is local state — nothing is persisted per finding.
Testing
WorkOrderAnalyst::fake([['findings' => [...]]]) returns canned findings with no provider call, and assertPrompted inspects the serialized work order. See tests/Feature/WorkOrder/AnalyzeWorkOrderTest.php and tests/Unit/Ai/WorkOrderAnalystTest.php.