factory/assist (0.2.1)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/assist:0.2.1About this package
factory/assist
The fleet's answering engine. A model is the last resort, not the mechanism.
One request walks exactly one ladder and stops at the first rung that answers:
| Rung | What it is | Answers when |
|---|---|---|
| 1. Guard | refusals that must never reach a resolver or a model | the turn asks for a secret, another user's data, or an action the caller lacks the ability for |
| 2. Resolver | a PHP class reading real rows | its matcher claims the turn and it can produce a complete answer |
| 3. Script | authored, non-generative copy | the turn matches an authored entry |
| 4. Fallback | a real model, if configured | nothing above claimed the turn |
| 5. Handoff | an honest refusal plus a route to a human | fallback is disabled, unfunded, or errored |
Rung 5 is a legitimate terminal state, not a bug. A deployment with no model
configured is a fully working deployment — it answers everything its resolvers cover
and says plainly that it cannot answer the rest. LadderTest asserts exactly that.
Two distinctions the package is built around
resolve() returning null is not the same as not claiming. Not claiming means
"not my question". Returning null means "this was my question and I could not answer
it" — and that recorded distinction is the most useful signal here: it names the
questions our own data should have answered and didn't. A claimed-but-unanswered turn
is never handed to the model; the resolver already said it lacks the fact, so a
model answering it would be inventing the very figure the resolver declined to state.
Display and spoken are separate fields. XAF 12 500 is correct on a screen and
unusable in an ear. SpokenFrench renders the spoken form; nothing speaks yet, and it
exists now so voice does not mean revisiting every resolver.
Figures
Resolvers mint every figure through Factory\Kernel\Assist\FigureLedger and the
Answer constructor substitutes at construction, so no Answer can exist in a
half-rendered state.
Model drafts take the other door: Answer::fromModelDraft() runs
ProvenanceGuard::release(), which rejects the draft outright if it contains any
digit, any word numeral, or a token the ledger did not mint. That check is exact
rather than best-effort because a model handed tokens has no legitimate reason to write
a number at all.
Resolver-authored text does not go through the guard — it legitimately carries invoice references and due dates, and a check that flagged those would be a false red on every answer, which trains an operator to route around the guard.
Routes
Mounting registers nothing automatically. The host calls
\Factory\Assist\AssistRoutes::mount(). An engine every product composes must never
take a URI out from under one of them; /api/notifications taught that once.
The surface is OpenAI-compatible on purpose — POST {prefix}/v1/chat/completions,
stream: true included — so Pipecat points its LLM service at our base URL and nothing
else changes. The model field selects a profile (resolver set + persona + fallback
policy), never a vendor model, and an unknown profile is a 400, never a silent
default: guessing a profile is guessing which product's data to read.
The record
Every completed turn writes one audit line through Factory\Kernel\Audit\AuditLog
(assist.answered), carrying resolution, by, reason, grounded_in and
cost_minor. A model call is an external action and is audited separately as
assist.fallback_invoked.
There is deliberately no assist_turns table. The audit chain is the record; a
second write path would be a second version of what the bot said.
Cost per conversation (PROOF-O-2)
Cost\CostLedger::tally() folds the audit rows above into a ConversationCost.
There is no meter and no counter table: the rows written to answer the customer
are the measurement, so the number cannot disagree with the record of what
happened. Cost\AuditCostReader is the thin Eloquent half (forConversation(),
forWindow()); the fold itself takes plain arrays and is tested without a database.
Turn::$conversation is a first-class field, not a $context key, because cost is
a property of a conversation and not of a turn. The OpenAI-shaped endpoint reads it
from conversation_id, falling back to OpenAI's own user field, so a client
attributes cost correctly without learning anything of ours.
The total is the least interesting field. A resolver-first assistant's model
bill is near zero by construction, so a low total is equally consistent with the
resolvers answered everything and with nobody used it. The two fields that decide
D34 are modelShare() — how often our own rows failed and a vendor was paid to try —
and handoffShare() — how often nobody answered at all. A zero bill with a forty
percent handoff rate is not cheap; it is not working, and the report says both
numbers in the same breath so that reading cannot be avoided.
Counted deliberately:
- Free turns are in the denominator. Four resolver answers plus one model call is a cheap conversation; the same model call alone is an expensive one. Counting only paid turns makes those two identical.
- A guard refusal is a turn. It is work the system did and a customer experienced.
- Money comes from
assist.answeredonly.assist.fallback_invokedcarries the samecost_minorand is used to count calls; adding both would double every paid turn. That the two counts should agree is a free consistency check. - A failed model call is counted separately (
model_failures) — the vendor was reached, the turn fell to handoff, and the customer got nothing for it. - Turns with no conversation id are still counted, in
forWindow(), with a null conversation. A report that quietly drops what it cannot attribute reads as complete and is not.
Known cost of a choice made here: the conversation id lives in the audit JSON body rather than in a column, because this package ships no schema and adding one to the chokepoint every product shares would be a migration for a proof. Matching is a JSON predicate — fine at proof volume, and the first thing to revisit if this graduates.
Degraded mode (PROOF-O-3)
The ladder answers questions. Degraded/Intake is responsible for the weaker and far
more important property: no customer message is ever silently dropped.
The two fail differently. The ladder failing means a question went unanswered and was recorded — survivable. This failing means a person messaged a business, nothing happened, and nobody knows: no error, no complaint, no row. That is the failure mode that kills a Tier O product quietly.
The order is the contract:
Inbox::accept()— durable custody, before anything is attempted.Assist::answer()— may fail freely; custody already exists.settle()on success,defer()on failure. Never neither.
Two failures, treated as opposites on purpose:
accept()failing propagates. The caller must answer the channel with a non-2xx so it redelivers, because at that instant the channel holds the only copy. Catching it and returning a polite acknowledgement converts a retriable delivery into a lost one.answer()failing does not. The customer getsIntake::acknowledgement($locale)— honest, in their language, promising only what the Inbox actually guarantees and giving no estimate we cannot keep. The message stays owed. Silence is not an option we offer; neither is a fake answer.
Inbox has no null implementation and no default, and Intake cannot be constructed
without one. "Never silently drop a customer message" is not a rule anyone remembers at
three in the morning; it has to be a thing the code cannot do.
Reconciler::sweep() has two halves and the second is the point. due() retries what
we know we owe. stale() finds what we forgot we owed — a message accepted and then
neither settled nor deferred because the worker died between the two. It is in no retry
queue; nothing knows it is owed; it is invisible precisely because the failure happened
in the code that was supposed to notice. The two queries overlap by design and are
deduplicated by message id, so one failure never burns two attempts.
maxAttempts defaults to 3, and escalation is a first-class success, not a
giving-up: retrying a broken thing twenty times is not persistence, it is twenty more
minutes of a person waiting while we tell no one.
The credential rule
The model API key exists on the server and in no other place — not in a Flutter binary, not in a JSON payload, not on the VPS, not in client-visible config. This is the most tempting package in the fleet to break that in, because the client is already talking to an OpenAI-shaped endpoint.
Receivables/ — the DEV-01 proof (PROOF-O-1)
WhatsApp · French · no voice · resolver-first · no money held.
The package ships no schema. A product implements InvoiceReader (and optionally
ConsentLedger) against its own tables and inherits the answering logic — including the
rule that matters most here:
Never chase the gross. Under the Cameroon withholding regime (arrêté n°
00001/MINFI/DGI du 05 janvier 2026) a designated client withholds VAT and the acompte
at source and remits them to the DGI. Invoice therefore cannot express "the amount
owed" as one number: it exposes gross, withheld and expected() separately, and every
resolver picks one on purpose. A reminder for the gross asks for money the client is
legally required not to pay.
ConsentLedger takes a channel and a purpose, not one boolean — Cameroon requires
consent per channel and per purpose (CM-L25, CM-L26; Loi 2010/021 art. 7(1)). A client
who agreed to delivery notices by WhatsApp has not agreed to payment reminders by
WhatsApp.
ReminderDraftResolver drafts, it does not send. Sending is an external action and
goes through factory/notify, which is where consent is enforced for real; a resolver
that could send would be a second, unreviewed send path.
Registration order in `ReceivablesAssista
Dependencies
Dependencies
| ID | Version |
|---|---|
| factory/kernel | ^0.9 || ^0.10 |
| illuminate/contracts | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/database | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/http | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/routing | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/support | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| php | ^8.2 |
| psr/log | ^1.0 || ^2.0 || ^3.0 |
Development dependencies
| ID | Version |
|---|---|
| orchestra/testbench | ^8.0 || ^9.0 || ^10.0 || ^11.0 |
| phpunit/phpunit | ^10.1 || ^11.0 || ^12.0 |