Notes for developers extending the module or integrating with it.
Events the module listens to
| Identifier | Events | Effect |
|---|---|---|
Omeka\Api\Adapter\ItemAdapter |
api.update.pre, api.delete.pre |
Record the item's visibility and its media's, its sites and its item sets |
Omeka\Api\Adapter\ItemAdapter |
api.create.post, api.update.post (priority -10), api.delete.post |
Queue the item and media added or made public in its form, or delete the chunks of the item (when private, restricted, deleted or taken out of the Item set filter's sets) and of media removed, hidden, restricted or deleted with it. When its sites or item sets changed, update them in its chunks and its media's (TursoService::updateMetadata()) |
Omeka\Api\Adapter\MediaAdapter |
api.create.post, api.update.post (priority -10), api.delete.post |
Queue a media index job, or delete its chunks |
Omeka\Api\Adapter\SitePageAdapter |
same | Queue a site page index job, or delete its chunks |
Omeka\Api\Adapter\SiteAdapter |
api.update.pre, api.delete.pre, api.update.post, api.delete.post |
A site made private or deleted: delete its pages' chunks and those of posts on no other public site. Made public again: queue them |
| The four adapters above | api.execute.pre, api.execute.post |
Mark API writes, so each resource is queued at most once per write |
Blog |
blog.post.create, blog.post.update, blog.post.delete with a post_id parameter |
Queue a published post's index job (the job checks its sites), or delete its chunks |
Omeka\Controller\Admin\Item |
view.browse.after |
Add the re-index options to the batch actions dropdown |
* |
view.layout |
Inject the global widget on public site pages |
Any module that triggers the Blog events with a post_id parameter gets its posts indexed, provided the blog_post and blog_post_site tables exist.
Chatbot\Service\IndexSync holds this logic. Removals and metadata updates run in the request, whatever the Auto-index setting; queuing needs auto-indexing on and Turso reachable. Item and media saves are handled at priority -10, after other modules' listeners at the default priority: the Access module saves access levels and embargoes in its own api.create.post / api.update.post listeners, and IndexSync reads them back.
Jobs
| Job | Arguments | Purpose |
|---|---|---|
Chatbot\Job\IndexItemJob |
action (index or delete), resource_type (item, media, blog_post, site_page), resource_id |
Index or remove one resource |
Chatbot\Job\BulkIndexJob |
item_ids (array, or null for all), resource_types (array of item, media, blog_post, site_page; default ['item', 'media']) |
Rebuild whole content types. Media are reached through their items, so item_ids also selects whose media are re-indexed |
Chatbot\Job\PruneIndexJob |
none | Delete the chunks of resources that are not public (or no longer exist), checked in MySQL; no embeddings. Clears the chatbot_prune_pending setting |
Chatbot\Job\PurgeChatbotLog |
none | Delete log entries older than the retention period |
Dispatch them with Omeka\Job\Dispatcher like any other Omeka job.
Services
All are registered in the service manager under their class names.
| Service | Role |
|---|---|
Chatbot\Service\TursoService |
HTTP client for the Turso pipeline API: insert, delete (one resource, several of one type, or a whole type), metadata update in place (updateMetadata(): json_set() on chosen keys and the URL, text and vectors kept), search, the list of indexed resources (resourceRefs()), table rebuild, chunk count, index health (getIndexProblem()) |
Chatbot\Service\PublicResources |
The visibility rule, read from MySQL: publicKeys() checks any number of [type, id] pairs in one query; also the before/after state of items and sites. With the Access module active (usesAccessModule()), it also reads access_status (see below) |
Chatbot\Service\IndexSync |
Turns API and Blog events into removals and queued index jobs (see above) |
Chatbot\Service\IndexService |
Text extraction, chunking, embedding and storage: indexItem(), indexMedia(), indexBlogPost(), indexSitePage(), deleteResource(). withClearedTypes($types) returns a copy for a run that has just cleared those types (BulkIndexJob): resources it skips send no delete; a resource it indexes is still deleted first, in case a save during the run indexed it again |
Chatbot\Service\ChatService |
chat() and semanticSearch(); both drop retrieved chunks whose resource is not public now, with one PublicResources::publicKeys() query |
Chatbot\Service\ChatbotLogger |
Writes to module_chatbot_log: info(), warning(), error() with an optional context array |
Chatbot\Service\RateLimitService |
Per-IP fixed-window counter in module_chatbot_rate_limit |
Chatbot\Service\EncryptionService |
AES-256-CBC encrypt and decrypt with a key derived from chatbot.encryption_key |
Chatbot\Provider\OpenAiProvider |
Embeddings and chat completions over the Laminas HTTP client; retries once after 5 seconds on HTTP 429 |
The Access module
PublicResourcesFactory asks Omeka's module manager whether the module Access is active; only then does PublicResources read Access's access_status table, which holds each resource's effective level and embargo after inheritance (columns level, embargo_start, embargo_end; no row means free). An item is restricted when its row has a level other than free or an embargo in force; a media when its row or its item's row is. The check is added to the same one query (NOT EXISTS on access_status), so it costs no extra round trip per chunk.
| Check | Embargo counted |
|---|---|
publicKeys($refs): chat, search, Run Test Search |
In force now: embargo_start (if any) is past and embargo_end (if any) is not, as Access\Entity\AccessStatus::isUnderEmbargo() |
publicKeys($refs, true): indexing, PruneIndexJob, IndexSync |
Only an embargo that has started and has no end: one with an end date is indexed in advance and shown once it ends, like a blog post scheduled for later |
Embargo dates are compared with UtcClock::now(): Access stores and compares them in UTC (Omeka's bootstrap sets PHP's zone to UTC). Access's access_embargo_bypass setting drops the embargo test. Changes Access writes straight to the database (item set levels, batch edits, its embargo and rebuild jobs) fire no events the module can hear; chat and search still hide newly restricted content at once.
Provider interfaces
OpenAiProvider implements both interfaces. To add another backend, implement them, register a factory in config/module.config.php under service_manager.factories, and change ChatServiceFactory and IndexServiceFactory to fetch your provider instead of OpenAiProvider.
interface EmbeddingsProviderInterface
{
/** @return float[] */
public function embed(string $text, int $dimensions = 0): array;
public function getName(): string;
}
interface LLMProviderInterface
{
public function complete(array $messages, string $model, float $temperature = 0.2, int $maxTokens = 1024): string;
public function getName(): string;
}
Turso schema
Created automatically on first connection and dropped by Purge All Index Data:
CREATE TABLE chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
chunk_id INTEGER NOT NULL,
chunk_text TEXT NOT NULL,
metadata TEXT, -- JSON: title, resource_type, site_ids, item_set_ids (items and media only);
-- site_ids, item_set_ids and url follow an item's saves (updateMetadata())
url TEXT,
created_at TEXT NOT NULL,
embedding F32_BLOB(3072) -- size follows the Embedding dimensions setting
);
CREATE INDEX chunks_vec_idx ON chunks (libsql_vector_idx(embedding));
Searches use vector_top_k('chunks_vec_idx', vector32('[...]'), k) and order by vector_distance_cos. libSQL keeps the DiskANN graph in the chunks_vec_idx_shadow table and updates it on every insert and delete, so the module never rebuilds the index. On its first query of a request TursoService reads the schema from sqlite_master; getIndexProblem() reports an index that is missing its shadow table or a F32_BLOB size that differs from the Embedding dimensions setting, and indexing, chat and search stop before calling OpenAI until the index is purged.
MySQL tables
module_chatbot_log:id,level,event,message,context(JSON),created_atmodule_chatbot_rate_limit:ip_address,window_start,request_count
created_at, window_start and the Turso chunks' created_at are UTC, like Omeka's own timestamps. Chatbot\Service\UtcClock writes them and computes every cutoff from PHP's clock: MySQL's NOW() follows the connection's time zone, so the module does not use it. Admin pages convert the times to the installation's time zone with UtcClock::display().
Front-end scripts
Both scripts are plain JavaScript with no dependencies, so a theme can mount them in custom markup.
ChatbotWidget.init({ apiUrl, mode: 'widget' | 'inline', container, blockId, heading, siteId, siteSlug, itemSetIds })fromasset/js/chatbot.js.mode: 'widget'appends a floating button and panel to the body;mode: 'inline'renders the panel insidecontainer. An inline chat passes the page block'sblockIdand sends only the question and the block id: the server looks up the block's system prompt, item sets and site and enforces its anonymous-use option (Chatbot\Service\ChatAccess).siteId,siteSluganditemSetIdsapply to the widget only. There is nosystemPromptoption: the prompt is never sent from the browser.SemanticSearch.init({ apiUrl, container, perPage, placeholder, siteId, siteSlug })fromasset/js/semantic-search.js.
Get the endpoint URLs from the chatbot-api-chat and chatbot-api-search routes, for example $this->url('chatbot-api-chat') in a view.
Tests
Unit tests use PHPUnit mocks and need no database or API keys. From the Omeka S root:
vendor/bin/phpunit -c modules/Chatbot/test/phpunit.xml