Reference Omeka S module

Developer Reference

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_at
  • module_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 }) from asset/js/chatbot.js. mode: 'widget' appends a floating button and panel to the body; mode: 'inline' renders the panel inside container. An inline chat passes the page block's blockId and 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, siteSlug and itemSetIds apply to the widget only. There is no systemPrompt option: the prompt is never sent from the browser.
  • SemanticSearch.init({ apiUrl, container, perPage, placeholder, siteId, siteSlug }) from asset/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
Log in for Support