factory/assist (0.2.1)

Published 2026-08-16 13:46:56 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/assist:0.2.1

About this package

SaaS Factory answering engine: a five-rung ladder that resolves deterministically against real rows first and treats a model as the last resort. No figure reaches a customer that a resolver did not produce.

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.answered only. assist.fallback_invoked carries the same cost_minor and 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:

  1. Inbox::accept() — durable custody, before anything is attempted.
  2. Assist::answer() — may fail freely; custody already exists.
  3. 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 gets Intake::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
Details
Composer
2026-08-16 13:46:56 +00:00
0
proprietary
61 KiB
Assets (1)
Versions (2) View all
0.2.1 2026-08-16
0.2.0 2026-08-06