diff --git a/.github/workflows/check-mkdocs-build.yml b/.github/workflows/check-mkdocs-build.yml
index c64d51358..a916b42a5 100644
--- a/.github/workflows/check-mkdocs-build.yml
+++ b/.github/workflows/check-mkdocs-build.yml
@@ -34,3 +34,28 @@ jobs:
- name: Build MkDocs site
run: mkdocs build -d site
+
+ - name: Check the AI corpus feed
+ # Validates the real deploy artifact — the feed hooks/ai_feed.py just wrote via
+ # the build above — so this gate can't diverge from what actually ships (same
+ # page set, resolved snippets, and macros env as production).
+ run: |
+ python - <<'EOF'
+ import json, re
+ path = 'site/ai/llms-full.jsonl'
+ raw = open(path, encoding='utf-8').read()
+ rows = [json.loads(l) for l in raw.splitlines() if l.strip()]
+ pages = {r['page_id'] for r in rows}
+ assert len(rows) > 1500, f"chunk count collapsed: {len(rows)}"
+ assert len(pages) > 180, f"page count collapsed: {len(pages)}"
+ required = {"source", "page_id", "url", "anchor", "page_version_hash",
+ "page_title", "title", "text"}
+ missing = required - set(rows[0])
+ assert not missing, f"schema regression, missing: {missing}"
+ assert '--8<--' not in raw, "unresolved --8<-- snippet directives in feed"
+ # leaked mkdocs template filters (e.g. {{ x | fix_url }}); targets the pipe so
+ # legit literals like {{ $labels.instance }} don't false-positive.
+ leaked = re.findall(r'\{\{[^}\n]*\|[^}\n]*\}\}', raw)
+ assert not leaked, f"unresolved template filters leaked into feed: {leaked[:3]}"
+ print(f"feed OK: {len(rows)} chunks / {len(pages)} pages, schema intact, no raw snippets or template leaks")
+ EOF
diff --git a/docs/js/docs-assistant.js b/docs/js/docs-assistant.js
new file mode 100644
index 000000000..89e9f7e09
--- /dev/null
+++ b/docs/js/docs-assistant.js
@@ -0,0 +1,227 @@
+/* Polkadot Docs Assistant widget.
+ Self-hosted chat over the docs corpus — replaces the former kapa.ai embed.
+ Talks to the assistant API (see the service repo): POST /chat, POST /feedback.
+
+ Configured from the script tag in main.html:
+
+ If data-api-url is empty the widget renders nothing (dark launch).
+*/
+
+(function () {
+ 'use strict';
+
+ if (typeof window === 'undefined') return;
+
+ const script = document.currentScript;
+ const API_BASE = (script && script.dataset.apiUrl ? script.dataset.apiUrl : '').replace(/\/$/, '');
+ if (!API_BASE) return; // no endpoint configured — ship dark
+
+ const HISTORY_TURNS = 6; // question+answer pairs sent back for follow-up rewriting
+ const history = [];
+ const sessionId = 'da-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
+
+ // ---------- tiny markdown renderer (no dependencies) ----------
+ function escapeHtml(s) {
+ // Must escape quotes too: the answer is untrusted LLM output rendered via
+ // innerHTML, and mdInline interpolates captured text into href="..." — an
+ // unescaped " would break out of the attribute and inject a handler (XSS).
+ return s.replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function mdInline(s) {
+ return s
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/\*(\S([^*\n]*?\S)?)\*/g, '$1') // require non-space at the *edges* so "2 * 3" isn't emphasis
+ // s is already HTML-escaped, so a real URL has no quote/space; if any escaped
+ // quote survived in the captured URL, refuse to build a link (render literal).
+ .replace(/\[([^\]]+)\]\((https?:(?:[^()\s]|\([^()\s]*\))+)\)/g, function (m, text, url) {
+ return /"|'/.test(url) ? m : '' + text + '';
+ });
+ }
+ function md(src) {
+ let s = escapeHtml(src || '');
+ s = s.replace(/\[[a-z0-9\-]+#[a-z0-9\-]+\]/gi, ''); // citation tokens become source chips
+ const lines = s.split(/\n/);
+ let out = '';
+ let i = 0;
+ const special = /^\s*([-*]\s+|\d+\.\s+|#{1,6}\s+|```)/;
+ while (i < lines.length) {
+ if (/^\s*```/.test(lines[i])) {
+ const code = [];
+ i++;
+ while (i < lines.length && !/^\s*```/.test(lines[i])) { code.push(lines[i]); i++; }
+ i++;
+ out += '
' + code.join('\n') + '';
+ } else if (/^\s*[-*]\s+/.test(lines[i])) {
+ const ul = [];
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { ul.push('' + mdInline(p.join(' ').trim()) + '
'; + } + } + return out; + } + + // ---------- DOM ---------- + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; + } + + // s.url comes from the API (untrusted); only allow it as an href if it's http(s), + // matching the scheme guard mdInline applies to links in the answer body. + function httpUrl(u) { + try { return /^https?:$/i.test(new URL(u, location.href).protocol) ? u : null; } + catch (e) { return null; } + } + + const btn = el('button', 'da-launcher'); + btn.type = 'button'; + btn.setAttribute('aria-label', 'Open the docs assistant'); + btn.innerHTML = + '' + + 'Ask the docs'; + + const panel = el('div', 'da-panel'); + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', 'Polkadot Docs Assistant'); + panel.innerHTML = + '