8. Artificial intelligence
Yes, your module can call a language model. No, it cannot call a provider directly.
from kewos.ai import AiMessage, AiRequest, completeThe distinction is not a formality: everything that makes an AI call acceptable in a business lives in the facade, and you inherit it only by going through it.
What the facade does for you
| Activation | If the customer has not switched AI on, the call is refused. No way around it. |
| The allowlist | Only the providers the customer authorised are reachable. |
| Data residency | A provider outside the European Union requires the customer’s explicit consent. Without it, the call is refused. |
| The quota | A budget of calls per time window, per customer. |
| The audit | Every call is traced. |
| The cache | Asking the same question twice is not paid for twice. |
| Model choice | The customer picks their provider; your code names none. |
A module calling a provider’s API directly would lose all seven. That is why it is forbidden, not because we like rules.
A concrete case: suggesting a genre
The librarian pastes the publisher’s blurb. The module proposes a genre and a catalogue sentence; they accept or refuse. Nothing is written first.
_SYSTEM = ( "You classify library books. Answer with a JSON object holding exactly two " "keys: 'genre', which MUST be one of the allowed values given to you, and " "'summary', a single sentence of at most 25 words. No other key, no prose.")
def suggest_for_title(engine, *, title, author, blurb, allowed_genres, secrets): """Propose a genre and a one-line summary, or None.""" request = AiRequest( messages=(AiMessage(role="user", content=_prompt(title, author, blurb, allowed_genres)),), system=_SYSTEM, response_format="json", # constrain the reply to a JSON object max_tokens=300, ) try: response = complete(engine, request, secrets=secrets) except (AiDisabledError, AiQuotaExceededError, AiResidencyError): return None # legitimate refusals: degrade, never break return parse_suggestion(response.text, allowed=allowed_genres)The validation is the real work
Constraining the reply is not enough: it has to be checked against the model’s own list.
def parse_suggestion(text: str, *, allowed: Sequence[str]) -> Suggestion | None: """Read the model's answer, or return None when it is unusable.""" try: payload = json.loads(text) except (ValueError, TypeError): return None if not isinstance(payload, dict): return None genre, summary = payload.get("genre"), payload.get("summary") if not isinstance(genre, str) or not isinstance(summary, str): return None # The list comes from the model's own selection field, so a value outside it # could never be stored anyway. Refused HERE, where it can still be explained. if genre not in allowed: return None if len(summary.split()) > MAX_SUMMARY_WORDS: return None return Suggestion(genre=genre, summary=summary.strip())What it gives, case by case
Run with a stand-in model that answers whatever it is told:
a valid answer -> Suggestion(genre='fiction', summary='A desert planet, a rare spice, and the fall and rise of a noble house.')an invented genre -> Nonea chatty answer -> Nonehalf an answer -> NoneAI switched off -> NoneFour None out of five, and that is the point. A model cannot invent a category: a genre
outside the list is dropped, not corrected, because a plausible wrong value is worse than an
absent one. And half an answer is refused whole, because half a suggestion shown to somebody is
a suggestion they accept without seeing the missing half.
Where to make this call
Not inside an HTTP request. A model call takes seconds and goes out over the network. Put it in a background job.
from kewos.jobs import job
@job(queue="default")def summarise_title(title_id: int) -> None: """Fill in the summary of one catalogue entry, out of band.""" ...The natural trigger is a module event, or a scheduled job processing a batch.
A model that sees
For an image, a dedicated block replaces the text:
from kewos.ai.provider import AiImageBlock, AiTextBlock
message = AiMessage( role="user", content=( AiImageBlock(media_type="image/png", data=base64_image), AiTextBlock(text="Which book cover is this? Give the title and author."), ),)To transcribe a document rather than comment on it, do not do it yourself: go through
kewos/ocr, which handles engine choice and fallback.
Having a model write data
A language model must never write straight into your database. The shape is: propose, then confirm.
- The model produces a structured proposal, constrained by a schema.
- You validate it like any user input.
- You show it to the user.
- The user confirms, and your code does the writing.
An unconfirmed proposal has had no side effect. That is what makes the feature recoverable the day the model is wrong, and it will be.
What does not exist yet
Declaring a prompt in a view or a YAML action. The declarative format knows only two operations today: setting a value and emitting an event. A YAML module therefore cannot call a model without going through code.
It is a known limit, and an architecture decision is being written to lift it. In the meantime, a YAML module needing AI comes with a small code module exposing the call.