Skip to content

Developer Guide

This chapter is written for integrators and developers extending or embedding nt-ai.

Architecture overview

flowchart TB
    subgraph BE["TYPO3 backend"]
        MOD["Backend modules<br/>Audit · Alt-text · Page score · Lighthouse"]
        QG["Quality-gate hook"]
        DW["Dashboard widgets<br/>tokens / cost"]
        CFG["ConfigurationService"]
        AIS["AiService"]
        AUD["AuditService"]
    end

    subgraph PROV["AI providers (external)"]
        OPENAI["OpenAI"]
        OTHER["Anthropic · Gemini · …"]
    end

    subgraph FE["Website frontend"]
        SET["Site settings"]
        INL["Inline config script<br/>data-nt-* on the html tag"]
        WID["a11y-widget.js<br/>launcher + panel"]
        CSS["CSS feature layers<br/>+ nt-dark-theme.css"]
        TTS["tts.js<br/>read aloud"]
        BR["Browser voices<br/>Web Speech, local"]
    end

    subgraph EP["nt-ai frontend endpoints (PSR-15)"]
        TM["TtsMiddleware<br/>/nt-ai/tts"]
        TS["TtsService<br/>rate limit + cache"]
        APM["AuditPreviewMiddleware"]
    end

    subgraph STORE["Storage"]
        DB[("tx_ntai_* tables")]
        CT[("Cache: ntai_tts")]
    end

    MOD --> AIS --> PROV
    AUD --> PROV
    AIS --> CFG
    MOD --> AUD
    MOD --> DB
    DW --> DB
    QG --> AUD

    SET --> INL --> WID
    INL --> TTS
    WID --> CSS
    TTS -->|"Web Speech"| BR
    TTS -->|"Cloud"| TM --> TS --> OPENAI
    TS --> CT
    TS --> DB

nt-ai has two largely independent layers:

  1. AI service layerAiService is the "front door". It selects a provider based on configuration, builds prompts via PromptBuilder, and calls the provider's complete() or describeImage() method. Providers implement AiProviderInterface and are tagged services. The ProviderRegistry resolves them by identifier.

  2. Audit layerAuditService is the entry point. It fetches the rendered HTML via PageFetcher, parses it with DomLoader, and runs every tagged RuleInterface against it. Results are aggregated into a Report.

  3. Frontend layer — buildless assets (a11y-widget.js/.css, tts.js, nt-dark-theme.css) included via the sitepackage set. The widget only writes data-nt-* attributes on the html tag (no DOM rewriting); read-aloud uses Web Speech (local) or calls the PSR-15 endpoint TtsMiddleware (/nt-ai/tts), which generates via the provider through TtsService, caches (ntai_tts) and rate-limits per IP. The inline config script (data-nt-* on the <html> tag) is emitted via page.jsInline so it automatically receives the request nonce under an active Content-Security-Policy and is not blocked.

These layers can be used independently from PHP, CLI, AJAX, the frontend or event listeners.

Using AiService

Inject Netthinks\NtAi\Service\AiService and call its high-level methods:

use Netthinks\NtAi\Service\AiService;

final class WelcomeMessageController
{
    public function __construct(private readonly AiService $ai) {}

    public function action(): string
    {
        $response = $this->ai->generate(
            userPrompt: 'Write a friendly welcome message for new visitors.',
            language: 'de',
        );
        return $response->text;
    }
}

Available methods

Method Description
generate(string $prompt, ?string $providerId = null, ?string $language = null) Free-form text generation.
translate(string $text, string $targetLanguage, ?string $sourceLanguage = null, ?string $providerId = null) Translates $text into $targetLanguage. Preserves formatting and placeholders.
summarize(string $text, ?string $language = null, ?string $providerId = null) Returns a shorter version of $text.
generateAltText(string $absoluteImagePath, ?string $language = null, ?string $providerId = null, array $optionOverrides = []) Vision-based alt-text generation. Applies the configured alt-text options, with optional per-call overrides.
generateAltTextFromText(string $documentText, string $title = '', ?string $language = null, ?string $providerId = null, array $optionOverrides = []) Text-based alt-text generation (e.g. PDF): summarises the given document text into an alt text. Shares options and post-processing with generateAltText(). Extract PDF text with Netthinks\NtAi\Pdf\PdfTextExtractor::extract().
describeImage(string $absoluteImagePath, ?string $language = null, ?string $providerId = null) Vision-based longer description / caption.

AiResponse DTO

All methods return AiResult (immutable DTO):

$r->text;              // string — the generated content
$r->provider;          // 'anthropic' | 'openai' | 'ollama' | ...
$r->model;             // concrete model name used
$r->promptTokens;      // int (0 if unknown)
$r->completionTokens;  // int (0 if unknown)
$r->getTotalTokens();  // int

Overriding alt-text options per call

$response = $this->ai->generateAltText(
    absoluteImagePath: $path,
    language: 'de',
    optionOverrides: [
        'maxLength' => 250,
        'style' => 'detailed',
        'customInstructions' => ['Always mention the camera angle.'],
    ],
);

Per-call overrides take precedence over the global configuration but do not modify it.

Picking a specific provider

Type-hinting Netthinks\NtAi\Provider\AiProviderInterface gives you whichever provider is configured as the default. When you need a named provider — most commonly to build a fallback chain — inject Netthinks\NtAi\Provider\AiProviderLocatorInterface instead:

use Netthinks\NtAi\Provider\AiProviderLocatorInterface;

final class SummaryService
{
    public function __construct(private readonly AiProviderLocatorInterface $providers) {}

    public function summarize(string $text): string
    {
        foreach (['openai', 'anthropic'] as $name) {
            if (!in_array($name, $this->providers->getAvailableProviders(), true)) {
                continue; // not configured, e.g. no API key
            }

            try {
                return $this->providers->get($name)->complete('Summarize in 3 sentences.', $text)->getText();
            } catch (\Throwable) {
                continue; // try the next provider in the chain
            }
        }

        throw new \RuntimeException('No AI provider available.');
    }
}

The objects returned by get() implement the same AiProviderInterface, so the calling code is identical for default and pinned providers. They stay bound to the requested provider regardless of configuration changes — that is what makes a chain deterministic. getAvailableProviders() lists providers that are registered and configured; getAllProviders() lists every registered one.

Adding a custom AI provider

Implement Netthinks\NtAi\Service\Provider\AiProviderInterface — note this is the internal provider interface, not the public Netthinks\NtAi\Provider\AiProviderInterface that consumers inject:

namespace MyVendor\MyExt\Service;

use Netthinks\NtAi\Service\Provider\AiProviderInterface;

final class MyCustomProvider implements AiProviderInterface
{
    public function getIdentifier(): string { return 'my_custom'; }
    public function isAvailable(): bool { /* check config */ }
    public function complete(AiRequest $request): AiResponse { /* call API */ }
    public function describeImage(AiRequest $request, array $imagePaths): AiResponse
    {
        // throw UnsupportedCapabilityException if vision is not supported
    }
}

Tag it in your Configuration/Services.yaml:

services:
  MyVendor\MyExt\Service\MyCustomProvider:
    tags: ['nt_ai.provider']

It will be picked up automatically by ProviderRegistry.

Adding a custom audit rule

Extend Netthinks\NtAi\Audit\Rule\AbstractRule:

namespace MyVendor\MyExt\Audit;

use Netthinks\NtAi\Audit\Result\Finding;
use Netthinks\NtAi\Audit\Result\Severity;
use Netthinks\NtAi\Audit\Rule\AbstractRule;
use Netthinks\NtAi\Audit\Rule\RuleContext;

final class MyCustomRule extends AbstractRule
{
    public function getId(): string { return 'my.custom.rule'; }
    public function getLabel(): string { return 'Custom rule'; }

    public function check(\DOMDocument $dom, RuleContext $context): array
    {
        $findings = [];
        foreach ($this->xpath($dom, '//figure[not(figcaption)]') as $figure) {
            $findings[] = new Finding(
                ruleId: $this->getId(),
                severity: Severity::Notice,
                message: '<figure> without <figcaption>.',
                suggestion: 'Add a figcaption to provide context.',
                wcagReference: '1.3.1 Info and Relationships',
                snippet: $this->snippet($figure),
                selector: $this->selector($figure),
            );
        }
        return $findings;
    }
}

Tag it in your Configuration/Services.yaml:

services:
  MyVendor\MyExt\Audit\MyCustomRule:
    tags: ['nt_ai.audit.rule']

AbstractRule provides helpers (xpath, snippet, selector, visibleText) so you rarely need to touch DOM APIs directly.

Built-in rules

Rule ID AI? What it checks
img.alt.missing No Missing / empty / placeholder alt attributes (WCAG 1.1.1).
heading.hierarchy No Missing h1, level skips, empty headings (WCAG 1.3.1, 2.4.6).
link.text No Empty links, generic ("here", "click here", "hier klicken"), raw URL as text (WCAG 2.4.4).
lang.attribute No <html lang> presence and validity (WCAG 3.1.1).
form.label No Form controls without associated labels (WCAG 3.3.2).
page.title No Missing, empty, or too-short <title> (WCAG 2.4.2).
contrast.inline No Inline style="color:...;background:..." contrast against WCAG AA (1.4.3).
iframe.title No <iframe> without a title attribute (WCAG 4.1.2).
button.text No Buttons with empty or icon-only text (WCAG 2.4.4).
duplicate.id No Duplicate id attributes (WCAG 4.1.1).
document.landmark No Missing landmark regions (WCAG 1.3.1 / 2.4.1).
table.header No Data tables without <th> headers (WCAG 1.3.1).
focus.outline No Elements with outline: none or outline: 0 (WCAG 2.4.7).
aria.hidden.focus No Focusable elements inside aria-hidden containers (WCAG 4.1.2).
video.captions No <video> elements without a <track kind="captions"> (WCAG 1.2.2).
meta.refresh No <meta http-equiv="refresh"> with a timeout (WCAG 2.2.1).
readability.sentence No Overly long sentences (WCAG 3.1.5 AAA).
readability.paragraph No Overly long paragraphs (WCAG 3.1.5 AAA).
readability.subheading No Large text blocks without subheadings (WCAG 3.1.5 AAA).
seo.title.length No SEO title too short or too long (WCAG 2.4.2 / SEO).
seo.meta.description.length No Meta description too short or too long (SEO).
seo.keyphrase No Focus keyphrase presence in title, headings and first paragraph (SEO).
link.text.ai Yes AI-generated, context-aware suggestions for generic links.
content.readability.ai Yes Reading-level estimate with concrete improvements (WCAG 3.1.5 AAA).
ai.alt.text.quality Yes Quality of existing alt texts (generic, filename-based, nonsensical) — up to 20 images.
ai.heading.quality Yes Descriptiveness of headings — up to 15 headings.
ai.meta.description Yes Accuracy and clickability of meta description.

Using AuditService directly

use Netthinks\NtAi\Audit\AuditService;

final class MyController
{
    public function __construct(private readonly AuditService $audit) {}

    public function action(): array
    {
        // Audit a TYPO3 page.
        $report = $this->audit->auditPage(pageUid: 42, languageUid: 0);

        // Or audit a raw HTML string (e.g. content element preview).
        $report = $this->audit->auditHtml(
            html: '<html><body>...</body></html>',
            options: ['pageLanguageCode' => 'de'],
        );

        return $report->toArray();
    }
}

Report provides:

  • getFindings(): Finding[]
  • getFindingsBySeverity(Severity $s): Finding[]
  • countBySeverity(Severity $s): int
  • getScore(): int
  • hasErrors(): bool
  • toArray(): array

Disabling rules per call

$report = $this->audit->auditPage(
    pageUid: 42,
    options: ['disabledRules' => ['contrast.inline', 'content.readability.ai']],
);

AJAX endpoints

All endpoints sit under TYPO3.settings.ajaxUrls.*. They expect JSON bodies and require a valid backend user session.

Endpoint Request / Response
nt_ai_generate { prompt, provider?, language? }{ success, text, provider, model, tokens }
nt_ai_summarize { text, provider?, language? }{ success, text, ... }
nt_ai_alt_text { fileUid, language?, provider? }{ success, altText }
nt_ai_describe_image { fileUid, language?, provider? }{ success, description }
nt_ai_form_alt_text { metaUid, hmac }{ success, altText }
nt_ai_form_image_description { metaUid, hmac }{ success, description }
nt_ai_form_text { table, field, uid, hmac }{ success, text } (pages: abstract, seo_title, description, tx_ntai_focus_keyphrase)
nt_ai_seo_assist_generate { pageUid, languageUid, field, hmac }{ success, value }
nt_ai_seo_assist_save { pageUid, languageUid, field, hmac, value }{ success, value }
nt_ai_audit_run { pageUid, languageUid? }{ success, reportUid, report }
nt_ai_audit_export_csv GET ?pageUid=...&languageUid=... → CSV download
nt_ai_score_analyze POST { pageUid, languageUid }{ success, scores, suggestions }
nt_ai_score_latest GET ?pageUid=...&languageUid=...{ success, scores, history }

Errors come back as { success: false, error: 'message' } with HTTP 4xx / 5xx.

Events

nt-ai dispatches and listens to PSR-14 events:

AfterTokenUsageRecordedEvent — fired after every AI call is saved to tx_ntai_token_usage. Use it for custom cost-alerting or analytics integrations.

ModifyUpdateArrayEvent — fired before saving page-score or alt-text results. Allows third-party code to modify or reject the values.

ShouldExcludeAltTextEvent — fired before the auto-generate-on-upload listener runs. Return true to skip alt-text generation for a specific file.

nt-ai itself listens to AfterDatabaseOperationsEvent (status-filtered) to trigger auto-audits on page save. The listener identifier is nt-ai-auto-audit.

Database schema

CREATE TABLE tx_ntai_audit_report (
    uid           int(11) NOT NULL auto_increment,
    pid           int(11) DEFAULT '0' NOT NULL,
    tstamp        int(11) unsigned DEFAULT '0' NOT NULL,
    crdate        int(11) unsigned DEFAULT '0' NOT NULL,
    deleted       tinyint(4) unsigned DEFAULT '0' NOT NULL,
    page_uid      int(11) DEFAULT '0' NOT NULL,
    language_uid  int(11) DEFAULT '0' NOT NULL,
    url           varchar(2048) DEFAULT '' NOT NULL,
    score         int(11) DEFAULT '0' NOT NULL,
    errors        int(11) DEFAULT '0' NOT NULL,
    warnings      int(11) DEFAULT '0' NOT NULL,
    notices       int(11) DEFAULT '0' NOT NULL,
    findings      mediumtext,
    triggered_by  varchar(32) DEFAULT 'manual' NOT NULL,
    PRIMARY KEY (uid),
    KEY parent (pid),
    KEY page (page_uid, language_uid),
    KEY recent (tstamp)
);

The findings column is JSON. Use Netthinks\NtAi\Audit\ReportRepository rather than raw SQL whenever possible.

Other tables created by the extension:

  • tx_ntai_page_score — append-only AI content quality score history per page + language.
  • tx_ntai_lighthouse_report — append-only Lighthouse / PSI score history per page + language + strategy.
  • tx_ntai_token_usage — per-call AI token usage log (provider, model, input/output tokens, context, backend user).

Running the test suite

composer install
vendor/bin/phpunit --testsuite=Unit
vendor/bin/phpstan analyse

The unit tests don't require a TYPO3 instance — they isolate everything behind stubs.