Skip to content

AI assistants

Namespace Hilms\Ai. A language model writes for editors and never writes to a course: every request becomes a queued row that a person reviews, edits and applies by hand.

The module builds on learning, catalog, blocks and access; nothing imports it but ops. It adds its actions to the catalog editors through EditorActions, so catalog never imports it either.

sparkles icon ──▶ RequestGeneration ──▶ RunGeneration (queue) ──▶ ReviewGeneration ──▶ ApplyGeneration
writes the row prompts the agent a person edits writes the subject
stores the answer │
notifies the author DiscardGeneration

Each step has exactly one owner, and nothing else may do its job:

Class Is the only code that
Actions\RequestGeneration Creates an ai_generations row
Jobs\RunGeneration Prompts an assistant for one
Actions\ApplyGeneration Writes a generation’s output onto its subject
Actions\DiscardGeneration Ends one without writing
Support\AiConfiguration Points the SDK at a provider, a model and a key

RequestGeneration::handle(GenerationKind $kind, Model $subject, User $requestedBy, array $input = [], ?DateTimeInterface $runAt = null): Generation refuses before it queues anything: the assistants must be available and the budget must not be exhausted, so a request that cannot pay is not left waiting a night for nothing.

AiSettings (group ai, edited on the “AI assistants” section of Settings, ManageAiSettings): enabled, provider, model, api_key (encrypted), base_url, monthly_token_budget, window_start, window_end, keep_days. Defaults come from the module’s settings migration, seeded from config/ai.php, so a blank panel value falls back to the environment and an installation may configure everything in either place.

AiConfiguration::apply() is the one bridge: it points ai.default at the chosen provider, writes the model into that provider’s models.text.default, overrides the key and base URL when the panel holds them, and forgets the SDK’s cached instance, so a long-running Horizon worker never keeps a stale key. Exactly two callers: ProbeProvider and RunGeneration.

AiConfiguration::available() answers whether the assistants may run at all — turned on, and the provider has credentials in the panel or in the environment. Ollama and an OpenAI-compatible endpoint need none, because they are named by their URL.

The section never renders the stored key back: a blank field keeps it, typing replaces it, and a “Remove the stored key” toggle clears it. That is the SecretInput pattern every SettingsSection carries (Settings).

Hilms\Ai\Enums\Provider lists the twelve the panel knows: anthropic, azure, bedrock, deepseek, gemini, groq, mistral, ollama, openai, openai-compatible, openrouter, xai. Each names its Laravel\Ai\Enums\Lab case, the environment variable config/ai.php reads its key from, and whether it needs one. Azure and Bedrock take a deployment or an AWS role rather than a key, so selectable() offers them only where the environment has already configured them.

AI_MODEL is always set explicitly, because the SDK’s own per-provider defaults can name a model a provider has retired.

A provider charges for every call, so four things hold it in check:

  1. Economy options. Support\ProviderOptions::economy() is the one table of cost-saving request options — DeepSeek thinking.type = disabled, OpenAI reasoning.effort = low, Gemini thinking_level = low, the floor every Gemini model accepts, everything else nothing — applied to every agent through the EconomyDefaults trait.
  2. A cap per agent. Each declares #[MaxTokens] and #[Timeout].
  3. A cached prefix. An agent’s instructions() are first and unchanging, so a provider that caches a prompt prefix charges for them once; everything about this lesson or course goes in the user prompt.
  4. A capped input. Support\LessonText::of() sends at most 8000 characters and Support\CourseOutline::of() at most 6000. The whole course is never sent.

Support\Budget is the ceiling: monthly_token_budget input plus output tokens per calendar month, zero meaning none. The input counted is what was neither read from nor written to a prompt cache (TextUsage::uncachedInputTokens()): since laravel/ai 1.0 a usage’s input includes the cached tokens, and storing that would have quietly shrunk the budget’s meaning; the cache reads are kept apart in cached_input_tokens, and the output includes any reasoning. It is checked when the request is made and again in the job before the provider is called. Tokens are the unit because a price list changes with every provider and a token count does not.

Support\RunWindow decides when: the moment the editor asked for, else the next start of the preferred window when one is set and now is outside it, else straight away. A window whose end is before its start wraps around midnight.

The two sides of that keep different clocks, deliberately. The preferred window is a rule a worker applies with nobody present, and RunWindow reads it in UTC, so window_start and window_end are pinned to UTC and both say so in their helper text. The run time an editor asks for is typed in front of somebody, so RunAtField follows the panel’s own clock like every other datetime — and measures its own minDate and maxDate by that clock too, because both are compared against a wall clock in the field’s zone rather than an instant in UTC (Time and clocks).

RunGeneration sets $tries = 1 for the same reason: a retry would spend the budget twice on the same request. It records the failure on the row with its reason, tells the author, and then rethrows, so the worker marks the job failed and Horizon shows it. failed() catches a worker that was killed or timed out and never reached the catch; an isFinished() guard keeps anything from being recorded or announced twice. The job carries #[WithoutRelations], so a generator queries its subject with $generation->subject()->firstOrFail().

Assistant Where Agent Sent Produces
Draft from notes Lesson editor, in the sparkles menu beside the blocks LessonDrafter (4000 tokens, 150 s) The lesson title and the editor’s notes (≤ 20000 characters) Markdown, converted to blocks; appended or replacing, as the request asked
Write quiz questions The same menu, once the lesson has prose QuizWriter (structured, 1500 tokens, 120 s) LessonText::of(): headings, rich text, quotes and callouts as plain text, capped at 8000 characters One quiz block appended
Write the summary Course editor, beside the summary field CourseSummariser (300 tokens, 60 s) CourseOutline::of(): the title, the sections, the lesson titles and each lesson’s summary or opening line, capped at 6000 characters courses.summary
Write the description Course editor, beside the description field CourseDescriber (1200 tokens, 120 s) The same outline, the current summary and the editor’s optional notes (≤ 8000 characters) Markdown, rendered to HTML and written to courses.description

Every agent answers in the language of the material it is given, and every prompt says which language that is: Support\WrittenIn opens it with “The course is written in English; write in that language.”, naming the language as the installation named it, because three words of notes are not enough to guess from. A fifth agent, Probe (8 tokens, 30 s), sends one “Ping.” to prove the provider, the key and the model; it is what “Test connection” and hilms:ai:check run.

Each action is built by AssistantAction::make(), which carries the shared ->visible() on AiConfiguration::available() and the shared ->authorize() on Create:Generation and the record’s own update, so nobody writes a draft into a lesson they may not edit. AiServiceProvider registers them into the blocks, summary and description slots of EditorActions; a slot holding one assistant draws a sparkles icon, a slot holding several draws one icon that opens a menu.

  • Markdown becomes blocks only through MarkdownToBlocks and HTML only through Markdown::html() — the shared sanitising CommonMark environment described in Blocks. A draft that converts to nothing fails the generation rather than producing an empty lesson.
  • A quiz goes through Hilms\Learning\Support\QuizData, the same three rules the block editor enforces, and one that does not hold up fails with the validator’s own words rather than being quietly repaired.
  • A summary is flattened onto one line and clamped to Course::SUMMARY_LIMIT before it is stored and again before it is written.

Filament/Resources/Generations is an index and a review page, an item of the main navigation among the everyday work, because it is a queue an editor works through rather than configuration; nothing is created or edited in the panel. The list shows the assistant, the subject linked to its own editor, who asked, the status, the tokens, the model, when it runs and when it finished, filtered by kind and status.

ReviewGeneration renders the output as a form: a block builder limited to the four types MarkdownToBlocks emits for a draft, the quiz block’s own form for a quiz, a counted text area for a summary, a rich editor for a description. The builder is deliberately narrow so no upload field is ever offered a record that owns no media disk — a generation is not a blockable and could answer none.

What Apply writes is the state of that form, never the answer as it arrived, and the stored output is updated to match. Apply on a lesson draft in replace mode warns that the existing blocks and their files go. Only a generation waiting for review may be applied or discarded, so a second click on a stale page cannot write the same draft twice. Apply needs Create:Generation and the subject’s own update; Discard needs Delete:Generation.

Whatever happens, the row ends finished and its author hears the bell. GenerationFinished is a database notification — not queued, because the job that sends it is already on the queue — carrying a Review action. That is why the panel has databaseNotifications() on at all.

Generation is Prunable: a finished generation is dropped keep_days (30) days after it ended, by model:prune --model=Generation scheduled daily from the module’s provider. It logs only its status to the audit trail — a prompt and an answer are not an audit trail; how far it got is.

  1. Add a case to GenerationKind with its label and icon.
  2. Write a migration that rewrites the ai_generations kind check constraint. The constraint spells its values out, so a database migrated before the new kind existed would refuse the row with a check-constraint error — and a fresh test database, built from the current migrations, would never show it. GenerationKindConstraintTest compares the live clause with the enum.
  3. Write the agent in src/Agents with EconomyDefaults, a MaxTokens cap, a Timeout, unchanging instructions(), a capped input and WrittenIn on the prompt, so it answers in the language of the course.
  4. Write the generator in src/Generators implementing Generator, returning a GeneratedOutput, and register it on Generators in AiServiceProvider.
  5. Teach ReviewGeneration how to render and apply the new output, and ApplyGeneration how to write it.
  6. Build the action with AssistantAction::make() and register it into an EditorActions slot.
  7. Add strings to ai::assistants and ai::generations in both languages.

Generators maps a kind to its generator and the provider registers all of them, so a row queued before a kind was withdrawn fails with a message instead of a missing class.

hilms:ai:check prompts the configured provider once and prints the provider, the model, the reply, the tokens and the time it took. It exits 1 when the assistants are off, the provider has no key, or the call fails.

app-modules/ai/tests/Feature: the settings page and its secret handling, provider selection and availability, the budget and the run window, the two clocks around it (GenerationClockTest), RequestGeneration’s refusals, the job’s single attempt and its failure recording, each generator against a faked SDK, the four assistant actions and their authorisation, the review page’s apply and discard including the stale-row guard, the notification, pruning, and the check constraint against the enum. No test ever calls a provider.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.