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:
-
AI service layer —
AiServiceis the "front door". It selects a provider based on configuration, builds prompts viaPromptBuilder, and calls the provider'scomplete()ordescribeImage()method. Providers implementAiProviderInterfaceand are tagged services. TheProviderRegistryresolves them by identifier. -
Audit layer —
AuditServiceis the entry point. It fetches the rendered HTML viaPageFetcher, parses it withDomLoader, and runs every taggedRuleInterfaceagainst it. Results are aggregated into aReport. -
Frontend layer — buildless assets (
a11y-widget.js/.css,tts.js,nt-dark-theme.css) included via the sitepackage set. The widget only writesdata-nt-*attributes on thehtmltag (no DOM rewriting); read-aloud uses Web Speech (local) or calls the PSR-15 endpointTtsMiddleware(/nt-ai/tts), which generates via the provider throughTtsService, caches (ntai_tts) and rate-limits per IP. The inline config script (data-nt-*on the<html>tag) is emitted viapage.jsInlineso 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:
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:
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): intgetScore(): inthasErrors(): booltoArray(): 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¶
The unit tests don't require a TYPO3 instance — they isolate everything behind stubs.