← AI Foundation

Data model

ai-foundation · workzone

The data home of the AI domain — the neural-network system prompt, the AI-model registry (providers · models · assignment by function · chat and agent lists · usage) and the tool catalog. The map below is the whole module at a glance, each node leading to its own table. The ORM models live in the shared core (core), Admin provides CRUD and screens; the consumers — Harvester, Knowledge Store, Query Engine, Agent Engine, Auth — read from core, with no cycles. Global platform configuration (AI budgets) lives in Admin Panel.

3Migration
one logical step — creates the registry and seeds the Platform provider with its built-in models
1 System prompt Singleton. Override + layered composition. → Prompt AI screen
prompt_settings neural-network system prompt
singleton · CHECK (id = 1)
id BigInteger PKCHECK always 1 · singleton
safety_text Text NULL safety-prompt override · NULL = built-in default · → Default in code
org_text Text NULL organization-prompt override · NULL = built-in default
updated_by BigInteger FK→usersNULL who edited last · SET NULL · the edit trail is in the Audit Log
created_at DateTime(tz) DEFAULT server_default=now()
updated_at DateTime(tz) DEFAULT now() + trigger
Layered prompt composition → Prompt texts → Prompt AI screen
1 safety prompt admin · safety_text
2 organization prompt admin · org_text
3 engineered function-prompt surface · chat · agent
4 runtime context fragments · history
= the system prompt of every AI call · assembled top to bottom
The first two layers are a single platform layer: the model lives in the ai_foundation/models.py package (code convention — one package per module), CRUD and the screen are in Admin, and it is read by the consumers — Query Engine at the prompt-assembly step and Agent Engine in the agent frame. The third layer is each surface's own (the chat grounding contract, the agent task frame). This way the shared tone and rules are set once, while surface-specific detail stays in its engineered prompt, which the admin can't edit — RAG correctness is protected.
Default in code, the DB stores only the override
The built-in texts of both blocks are seed constants in code, not a row in the database. NULL = take the default (improved by deploys); non-empty text = an admin edit, frozen as written. "Reset" nulls the column — a return to the current default. The default is localized: seed constants exist for every platform locale (platform_settings.locale) — locale=en gives an English prompt, ru a Russian one; NULL takes the default for the current locale. This way customization survives platform updates, and an out-of-the-box installation carries a prompt without manual setup.
Safety and organization — separate; the perimeter is not in the prompt
Two independent fields — the split itself makes editing the defense deliberate: changing the tone in org_text, you won't confuse it with safety_text. The text itself is defense-in-depth on top of the architecture, not the access perimeter; the real boundary is held by the access architecture, not the prompt — the analysis and a v2 tag are in the Prompt texts.
API response — the effective text plus an override flag
GET returns, for each block, the ready-to-show effective text (the override, otherwise the built-in default for the locale) and an is_default flag — whether the column is empty. By that flag the Prompt AI screen tells the default from an edit: it marks "on default" and keeps "Reset" active only when an override is set. This way the front end doesn't guess the source of the text by comparing strings, and a PATCH with null is a reset to the default.
2 AI-model registry Providers → models → assignment and usage. → AI models screen
ai_providers connections to models
key at-rest
id BigInteger PK
name Text NOT NULL display name · "OpenAI" · "Ollama (on-prem)"
kind Text NOT NULLDEFAULTCHECK where it runs · cloud · local
adapter Text NOT NULLCHECK API dialect · determines the SDK client and discovery · openai · anthropic · google · ollama · openai_compatible · → API dialect
base_url Text NULLCHECK endpoint · optional for cloud, required for local (CHECK)
api_key_enc Text NULL write-only · AES-256-GCM · NULL for local ones · → keys
is_system Boolean NOT NULLDEFAULT built-in runtime, seeded by the migration · non-deletable, base_url/adapter managed · → Built-in embedder
status Text NOT NULLDEFAULTCHECK the result of the last check · active · error · unchecked
last_check_at DateTime(tz) NULL NULL = never checked
created_at DateTime(tz) DEFAULT server_default=now()
updated_at DateTime(tz) DEFAULT now() + trigger
ai_models model catalog
FK → ai_providers UNIQUE(provider_id, model_id)
id BigInteger PK
provider_id BigInteger FK→ai_providersIDX CASCADE
model_id Text NOT NULLUNIQUE* the provider's identifier · "gpt-4o"
display_name Text NOT NULL display name · default = model_id
model_type Text NOT NULLCHECK what it outputs · chat · embedding · → the type governs assignment
origin Text NOT NULLCHECK how it entered the catalog · discovered · manual · builtin (seeded with the built-in runtime)
is_enabled Boolean NOT NULLDEFAULT enabled on the platform · DEFAULT false
price_input Numeric NULL price per input token · NULL = not set (local) · → Input/output price
price_output Numeric NULL price per output token · NULL for embedding and local
meta JSONB NULL model intrinsics · for embedding: embedding_dim (sets halfvec(N) in KS), max_input_tokens, instruction_prefix · → Intrinsics
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
model_assignments system functions
FK → ai_models UNIQUE(function)
id BigInteger PK
function Text NOT NULLUNIQUECHECK one row per function · system ones: harvester_embedding · query_rag · → Function vocabulary
model_id BigInteger FK→ai_modelsNULL the assigned model · NULL = awaiting a module · RESTRICT
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
chat_models user chat
FK → ai_models exactly one default
id BigInteger PK
model_id BigInteger FK→ai_modelsUNIQUEIDX an allowed chat model · RESTRICT — in the list = in use
is_default Boolean NOT NULLDEFAULT partial UNIQUE WHERE is_default — exactly one
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
agent_models agent models
FK → ai_models exactly one default
id BigInteger PK — · agents.model_id references this
model_id BigInteger FK→ai_modelsUNIQUEIDX an allowed agent model · chat-type, with tool support · RESTRICT — in the list = in use
is_default Boolean NOT NULLDEFAULT partial UNIQUE WHERE is_default — exactly one · preset in the agent editor
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
model_usage usage · per-day aggregate
FK → ai_models UNIQUE(model_id, function, bucket_date)
id BigInteger PK
model_id BigInteger FK→ai_modelsIDX SET NULL · usage outlives model deletion
function Text NOT NULLCHECK which function consumed it · shared vocabulary · → Function vocabulary
bucket_date Date NOT NULLUNIQUE* the aggregation day
request_count BigInteger NOT NULLDEFAULT DEFAULT 0
input_tokens BigInteger NOT NULLDEFAULT input tokens · DEFAULT 0
output_tokens BigInteger NOT NULLDEFAULT output tokens · 0 for embedding
cost Numeric NULL input·price_input + output·price_output · NULL if the prices aren't set
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · the last increment of the bucket
tools AI tool catalog
secret at-rest UNIQUE(name)
id BigInteger PK
name Text NOT NULLUNIQUE the tool key = the key linking to the type in the registry · web_search · fetch_url · name/description via t(), not in the DB
source Text NOT NULLDEFAULTCHECK where the schema comes from · preset · custom (v1) · mcp · openapi (v2) · → Sources
access Text NOT NULLDEFAULTCHECK read_only (v1) · write (v2)
config JSONB NULL non-secret configuration · provider · base_url · options · not a secret
credential_enc Text NULL write-only secret · AES-256-GCM by Auth's crypto core · only the is_set flag leaves outward · NULL = no key needed
chat_enabled Boolean NOT NULLDEFAULT given to chat · DEFAULT false
agents_allowed Boolean NOT NULLDEFAULT admitted to the agent allowlist · DEFAULT false · the owner selects in the editor
status Text NOT NULLDEFAULTCHECK the result of the health check · active · error · unchecked · as with ai_providers · → Check
last_check_at DateTime(tz) NULL NULL = never checked
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
A tool — the catalog is in Admin, the agent's selection is in Agent Engine → Tool catalog → Agent Engine
A tool is a callable function, given to the model as a tool-schema (not a prompt). The catalog is open — a registry of types (platform presets + the organization's own class, a mirror of the connectors): the list is a live registry, this table holds the state by name (no row = the type is off, defaults), a row is created lazily. v1 seeds two read-only presets — web_search and fetch_url, both off. Admission is two independent surfaces: chat_enabled (visible to chat) and agents_allowed (available for agents to select); a tool is active as long as it is given to at least one. The agent's selection itself is the agent_tools link in Agent Engine; the runtime filters it by the current agents_allowed, and removal → the agent falls back to the KS core (graceful degradation). Non-secret configuration (the web_search provider, options) lives in config, edited by the admin on the screen, not by a config file; the secret is in credential_enc, not in config: like the ai_providers and SMTP keys, only the is_set flag leaves outward. status / last_check_at hold the result of the health check — by the same device as ai_providers.
Models in ai_foundation, CRUD and screens — in Admin → Assignment by function
The ORM models are in the ai_foundation/models.py package (code convention — one package per module), not in admin: the assigned models are read by the AI consumers Harvester (embed), Knowledge Store (re-embedding), Query Engine (RAG) and Agent Engine — four consumers, not just Admin. Admin provides CRUD and screens; the dependencies go toward core — with no cycles.
Keys — Auth's crypto core → Crypto core
api_key_enc is a write-only secret, AES-256-GCM ciphertext by Auth's crypto core; it is not returned outward (API, export), the UI gets only the mask ••••xxxx. Local providers usually have no key.
API dialect ≠ where it runs → Providers screen
Two orthogonal axes. kind (cloud / local) is where the model runs: it determines whether a key is needed and whether base_url is required. adapter is which protocol to speak to it on: it selects the SDK client and the discovery endpoint (openaiGET /v1/models, ollamaGET /api/tags). openai_compatible covers vLLM · llama.cpp · proxies. The protocol can't be inferred from name (the display name) — hence a separate field.
Built-in embedder — out of the box, lazily, not by default → Assignment by function
The platform ships its own local embeddings runtime — a separate docker-compose service (OpenAI-compatible, model-agnostic), brought up on make up alongside the backend; the container is lightweight and doesn't pull ML dependencies into the API image. Weights are not downloaded at install: the runtime pulls them lazily — on model assignment (and immediately warms it up without waiting for the first request), into a cache volume. Until selected, the embedder takes neither disk nor memory, and so it doesn't limit the deploy. The migration seeds the is_system provider Platform (non-deletable, base_url pointing to the internal service, no key) and the catalog of built-in models — selectable (is_enabled=true) but not loaded. The set is defined by the seed, not the screens (the first iteration — bge-m3 and Qwen3-Embedding-0.6B, both 1024-dimensional); the wireframes reference them only as examples. There is no default assignment: the model_assignments.harvester_embedding row does not exist at the start, and Harvester won't start ingesting without an assignment — Admin must assign an embedding model (built-in or their own) before the first ingest. The reason is hard: the choice fixes the dimension N of the chunks.embedding column, and a later change is a mass re-embedding (with a different N — a schema migration). The default is deliberately empty: neither a model nor its weight is imposed by the installation. How this service handles load streams and scales (v1 → v2) is in the embeddings runtime.
Model intrinsics — in the registry, not in the consumer's code → Where parameters live
Parameters set by the model itself — the vector dimension (embedding_dim), the window cap (max_input_tokens), the required prefix (instruction_prefix) — live in the model's meta, not in the consumer's config (no hardcoding): Harvester's chunker reads the cap from there, KS takes the embedding_dim of the assigned embedding model and provisions halfvec(N). The built-in models are seeded with their own intrinsics — bge-m3: { embedding_dim: 1024, max_input_tokens: 8192 }, Qwen3-Embedding-0.6B: { 1024, 32768 }. A cloud model's embedding_dim isn't returned by discovery, so the Admin declares it when adding the model; a model without a declared dimension — or one that doesn't match the provisioned halfvec(N) — is refused at assignment (409), before any re-embedding starts.
The type governs assignment
model_type (chat / embedding) restricts which functions a model can be assigned to: embedding — only to embedding functions, chat — to chat, RAG, agents. Changing the type and disabling a model are blocked while it is assigned to any function or is in the chat or agent list (FK RESTRICT on all three tables): otherwise an assignment would be left with an incompatible or empty type, and a list without a model.
Assignment: system vs lists → Agent Engine
A system function is one model_assignments row (one model). Chat and agents are two lists of the same shape: chat_models (the employee selects the conversation model) and agent_models (the owner selects the agent model), each with exactly one is_default and only chat-type models. The default is protected symmetrically to the system functions: the is_default mark can't be cleared without assigning another, and the default model is held in the list by RESTRICT — the list is never left without a default model. The agent's link to the selection is agents.model_idagent_models (ON DELETE SET NULL): remove a model from the list and the agent's reference is nulled, and the agent stops.
Price and usage — input/output separately
For cloud chat the output token is several times more expensive than the input, so the price is two fields (price_input · price_output), and usage accumulates input_tokens and output_tokens separately. cost = input·price_input + output·price_output. Embedding has no output — price_output and output_tokens are empty / zeros. The currency is single for now (dollars), without a separate field — multi-currency is deferred.
A single function vocabulary
The set of AI functions is defined once (an enum in core): harvester_embedding · query_rag · agent_engine · chat. model_assignments.function is CHECKed against the subset of the two system ones (harvester_embedding · query_rag) — chat and agents are assigned not here but through the chat_models / agent_models lists; model_usage.function accumulates usage across the full set, including chat and agent_engine. One source of truth — divergence is ruled out.
Usage — an aggregate; the budget — an instance setting in Admin → platform_settings
model_usage accumulates requests / tokens / cost by (model · function · day), not an event-log per request. The budget threshold is an instance setting (ai_monthly_budget + ai_budget_alert_enabled) and lives not here but in platform_settings, next to maintenance_mode: the admin enters the amount by hand and turns it on with a toggle (a CHECK prevents enabling the alert without an amount) on the AI usage screen, in the shared home of limits. On reaching the threshold, a Budget notification is raised — the channel is held by the notifications module. The week / month / year usage panorama on the same screen is a derived SUM over the period, without a separate counter table. How the accounting works end to end is in the usage business logic.
3 Migration Creates the registry and populates the built-in runtime.
alembic/versions/NNN_core_ai_models.py
Creates the AI registry in one logical step in dependency order: ai_providersai_modelsmodel_assignments · chat_models · agent_models · model_usage · tools, as well as the singleton prompt_settings. In the same step it INSERTs the platform provider (is_system, name='Platform') and the catalog of built-in models (origin='builtin') — these are registry rows, without weights and without assignments (built-in embedder). The rest of the catalog is populated when the first provider is added. downgrade() drops the tables in reverse order.