{"id":49441738,"url":"https://github.com/lopadova/askmydocs","last_synced_at":"2026-06-12T14:01:15.625Z","repository":{"id":351856088,"uuid":"1212731323","full_name":"lopadova/AskMyDocs","owner":"lopadova","description":"AI Hub \u0026 Intelligent Agentic Platform for the Enterprise - self-hostable AI hub for enterprise knowledge.","archived":false,"fork":false,"pushed_at":"2026-06-10T00:14:06.000Z","size":13569,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T01:08:55.805Z","etag":null,"topics":["ai","ai-act","ai-agent","ai-assistant","aihub","chatbot","embeddings","intelligent-agent","kb","laravel","postgresql","rag","rag-chatbot","vector-database"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lopadova.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-16T17:13:58.000Z","updated_at":"2026-06-10T00:13:06.000Z","dependencies_parsed_at":null,"dependency_job_id":"1322523a-a56b-4d2e-b9c6-b7b824ab8795","html_url":"https://github.com/lopadova/AskMyDocs","commit_stats":null,"previous_names":["lopadova/askmydocs"],"tags_count":65,"template":false,"template_full_name":null,"purl":"pkg:github/lopadova/AskMyDocs","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lopadova%2FAskMyDocs","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lopadova%2FAskMyDocs/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lopadova%2FAskMyDocs/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lopadova%2FAskMyDocs/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lopadova","download_url":"https://codeload.github.com/lopadova/AskMyDocs/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lopadova%2FAskMyDocs/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34247461,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-12T02:00:06.859Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai","ai-act","ai-agent","ai-assistant","aihub","chatbot","embeddings","intelligent-agent","kb","laravel","postgresql","rag","rag-chatbot","vector-database"],"created_at":"2026-04-29T20:36:10.690Z","updated_at":"2026-06-12T14:01:15.605Z","avatar_url":"https://github.com/lopadova.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# AskMyDocs — AI Hub \u0026 Intelligent Agentic Platform for the Enterprise\n\n\u003e **Enterprise RAG + Knowledge Graph + Agentic Tool Use, self-hostable, MIT licensed.**\n\nAskMyDocs is a self-hostable AI hub for enterprise knowledge. It fuses\nhybrid retrieval-augmented generation (pgvector + FTS + reranker), a\ntyped canonical knowledge graph with human-gated promotion, a streaming\nchat surface on the Vercel AI SDK, and a full admin operations cockpit\ninto a single Laravel platform. It is the open-source, on-prem alternative\nto Glean / Notion AI / ChatGPT Enterprise — without the per-seat lock-in.\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"resources/cover-AskMyDocs.png\" alt=\"AskMyDocs\" width=\"100%\" /\u003e\n\u003c/p\u003e\n\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"#quick-start-5-minutes\"\u003e\u003cimg src=\"https://img.shields.io/badge/Laravel-13+-FF2D20?style=flat-square\u0026logo=laravel\u0026logoColor=white\" alt=\"Laravel\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/Claude-Compatible-cc785c?style=flat-square\u0026logo=anthropic\u0026logoColor=white\" alt=\"Claude\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/OpenAI-Compatible-412991?style=flat-square\u0026logo=openai\u0026logoColor=white\" alt=\"OpenAI\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/Gemini-Compatible-4285F4?style=flat-square\u0026logo=google\u0026logoColor=white\" alt=\"Gemini\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/OpenRouter-Multi--Model-6366f1?style=flat-square\" alt=\"OpenRouter\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/Regolo.ai-EU-10b981?style=flat-square\" alt=\"Regolo.ai\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/MCP-10%20tools-0ea5e9?style=flat-square\" alt=\"MCP Server\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/Canonical--KB-9%20types-ff7a00?style=flat-square\" alt=\"Canonical KB\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/Knowledge%20Graph-10%20relations-7c3aed?style=flat-square\" alt=\"Knowledge Graph\"\u003e\u003c/a\u003e\n  \u003ca href=\"#features-by-area\"\u003e\u003cimg src=\"https://img.shields.io/badge/Anti--Repetition-%E2%9A%A0%EF%B8%8F%20built--in-dc2626?style=flat-square\" alt=\"Anti-Repetition Memory\"\u003e\u003c/a\u003e\n  \u003ca href=\"#prerequisites\"\u003e\u003cimg src=\"https://img.shields.io/badge/PostgreSQL-pgvector-336791?style=flat-square\u0026logo=postgresql\u0026logoColor=white\" alt=\"PostgreSQL + pgvector\"\u003e\u003c/a\u003e\n  \u003ca href=\"#license\"\u003e\u003cimg src=\"https://img.shields.io/badge/License-MIT-green?style=flat-square\" alt=\"MIT License\"\u003e\u003c/a\u003e\n  \u003ca href=\"#prerequisites\"\u003e\u003cimg src=\"https://img.shields.io/badge/PHP-8.3+-777BB4?style=flat-square\u0026logo=php\u0026logoColor=white\" alt=\"PHP 8.3+\"\u003e\u003c/a\u003e\n  \u003ca href=\"CHANGELOG.md\"\u003e\u003cimg src=\"https://img.shields.io/badge/release-v8.4.0-blueviolet?style=flat-square\" alt=\"Release v8.4.0\"\u003e\u003c/a\u003e\n  \u003ca href=\"#universal-connectors\"\u003e\u003cimg src=\"https://img.shields.io/badge/connectors-7%20native-0ea5e9?style=flat-square\" alt=\"7 Native Connectors\"\u003e\u003c/a\u003e\n  \u003ca href=\"#quality--observability\"\u003e\u003cimg src=\"https://img.shields.io/badge/tests-2063%20PHPUnit%20%2B%20494%20Vitest-brightgreen?style=flat-square\" alt=\"2063 PHPUnit + 494 Vitest\"\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eAsk your docs. Get grounded answers. See the sources. Run agentic tools.\u003c/strong\u003e\n\u003c/p\u003e\n\n\n# CHATBOT UI/UX\n![AskMyDoc - ChatBot.png](resources/screenshots/AskMyDoc%20-%20ChatBot.png)\n\n# DASHBOARD UI/UX\n![AskMyDoc - Dashboard.png](resources/screenshots/AskMyDoc%20-%20Dashboard.png)\n\n---\n\n## Table of Contents\n\n- [What it is](#what-it-is)\n- [Why AskMyDocs — the 5 moats](#why-askmydocs--the-5-moats)\n- [✨ Universal Connectors](#universal-connectors)\n- [✨ Modern Chat Surface (Vercel AI SDK UI)](#modern-chat-surface-vercel-ai-sdk-ui)\n- [✨ KITT — Knowledge Interface Tour Toolkit](#kitt--knowledge-interface-tour-toolkit)\n- [Features by area](#features-by-area)\n  - [Retrieval \u0026 Knowledge](#retrieval--knowledge)\n  - [Chat \u0026 Conversation](#chat--conversation)\n  - [Security \u0026 Compliance](#security--compliance)\n  - [Admin \u0026 Operations](#admin--operations)\n  - [Integrations \u0026 Extensibility](#integrations--extensibility)\n  - [Quality \u0026 Observability](#quality--observability)\n- [Quick start (5 minutes)](#quick-start-5-minutes)\n- [Architecture](#architecture)\n- [Roadmap](#roadmap)\n- [Documentation](#documentation)\n- [Screenshots gallery](#screenshots-gallery)\n- [Sister packages](#sister-packages)\n- [Contributing](#contributing)\n- [License](#license)\n- [Changelog](#changelog)\n\n---\n\n## What it is\n\n**What.** AskMyDocs is an **AI hub for enterprise knowledge** built on\nLaravel 13 + PostgreSQL + pgvector. It ingests markdown, text, PDF and\nDOCX documents into a typed canonical knowledge graph, answers\nquestions over them with streaming RAG, exposes the same knowledge as\nMCP tools for any agentic client (Claude Desktop, Claude Code,\nCursor, custom agents), and ships a full React admin SPA — KPI\ndashboard, canonical KB explorer with inline editor and graph viewer,\nlog viewer (five tabs), whitelisted Artisan maintenance runner, daily\nAI-insights panel — all behind Spatie role-based access control with\naudit trails on every destructive mutation.\n\n**Why.** Most \"RAG over docs\" tools treat your KB as a pile of\ninterchangeable chunks. They re-discover the answer from zero on every\nquery, never persist what your team has *already decided*, and\nre-propose options that were explicitly dismissed three quarters ago.\nSaaS competitors (Glean, ChatGPT Enterprise, Notion AI) either lock\nyou into per-seat contracts and proprietary data residency, or charge\n~$500K/year for the on-prem option. AskMyDocs is MIT-licensed,\nself-hostable, EU-sovereign-feasible, and ships a typed canonical layer\nwith human-gated promotion that no public competitor offers.\n\n**For whom.** Enterprise teams ingesting their architectural decisions\n/ runbooks / standards / incidents / domain concepts into a *navigable*\nKB; operators of regulated-industry RAG (GDPR, AI Act) needing\nfield-level PII redaction at every persistence boundary; engineering\norgs that want LLMs to stop re-proposing rejected approaches; Italian\nsoftware companies filing under `documentazione_idonea` Patent Box;\nand anyone allergic to vendor lock-in.\n\n---\n\n## Why AskMyDocs — the 5 moats\n\nThese five differentiators come from the public competitor audit at\n[`docs/v4-platform/AUDIT-2026-05-11-competitor-comparison.md`](docs/v4-platform/AUDIT-2026-05-11-competitor-comparison.md)\n(Section 3, \"Where AskMyDocs is genuinely AHEAD\"). They are the moats\nno other public RAG platform — open-source or SaaS — currently ships.\n\n| ★ | Moat | One-line |\n|:---:|---|---|\n| ★ | **Human-gated canonical promotion pipeline** (ADR 0003) | Three-stage API (`/suggest` → `/candidates` → `/promote`) holds the LLM at \"draft\"; only humans (git push → GH Action) and operators (`kb:promote` CLI) commit canonical storage. Immutable `kb_canonical_audit` trail. No public competitor splits \"AI proposes\" from \"human writes\" this way. |\n| ★ | **Retrieval-time knowledge graph + rejected-approach injection** | `GraphExpander` walks `kb_edges` 1-hop at every query and folds neighbours into the `SearchResult`. `RejectedApproachInjector` vector-correlates the query against `rejected-approach` canonical docs and surfaces them under a ⚠ marker so the LLM stops re-proposing dismissed options. ChatGPT Enterprise / Glean / Vectara do not do this. |\n| ★ | **PII redaction at 11 persistence boundaries** (default-OFF, granular per touch-point) | `padosoft/laravel-pii-redactor` v1.2 wired at 11 touch-points across observers, middleware, Monolog processor, failed-job listener, Flow payload redactor, insights inspector. EU-GDPR-grade *field-level* redaction inside the app boundary — not just data-residency. Every knob default-OFF so v3 / v4.0 hosts see byte-identical behaviour until they opt in. |\n| ★ | **MIT-licensed, self-hostable, on-prem feasible** (no $500K/yr vendor contract) | Vectara is the only competitor that ships on-prem ($500K/yr public list). Glean / Notion AI / ChatGPT Enterprise / M365 Copilot are SaaS-only. AskMyDocs runs on any Laravel + PostgreSQL + pgvector host with zero vendor lock-in; the entire sister-package stack is MIT and independently reusable. |\n| ★ | **Eval-harness CI gate + nightly LLM-as-judge + adversarial cohorts** | `padosoft/eval-harness` v1.2 RAG regression gate on every PR (4 datasets / 1 baseline + 3 adversarial / 7 metrics including custom `CitationGroundednessMetric` + `CosineGroundednessMetric`); `eval:nightly` Artisan cron at 05:30 UTC with three-fence cost guard, regression detection vs prior baseline, `Log::alert` + sidecar on regression; adversarial-lane nightly opt-in shipped in v4.4. Out-of-the-box eval surface nobody else publicly ships. |\n\n### Plus: a closed-loop **KB Lifecycle Intelligence** suite (v8.7 → v8.8)\n\nBeyond the five moats, the v8.7–v8.8 cycles shipped a closed governance loop most\nRAG tools simply don't have — the exact capabilities the\n[2026 Affine KB Buyer's Guide](docs/v4-platform/AUDIT-2026-06-02-affine-buyers-guide-gap.md)\ntells buyers to demand:\n\n- **Content-gap analytics** — every question the KB *couldn't* answer (sync **and** streaming\n  refusals) is ranked under **Admin → Content Gaps** so editors write the missing article next.\n  The guide names this in three separate sections; few competitors expose it at all.\n- **Obsolescence intelligence on every change *and delete*** — the AI deep-analysis flags which\n  *other* docs a change (or deletion) makes stale or dangling, suggest-only, human-gated.\n- **Synonym expansion + per-query multilingual FTS** — the guide literally lists \"Synonym\n  Expansion: does the AI connect industry terms?\" (shipped v8.7) and multilingual consistency\n  (shipped v8.8).\n- **Review cadence + archival, not deletion** — automated stale-review reminders + the Cloud\n  Time Machine (browse / diff / restore any version) — the guide's \"Review Cadence and Archival\n  Policy\" governance section, shipped.\n- **Graph-native navigation** — a chat-side **Related** panel walks the knowledge graph straight\n  from a grounded answer.\n\n---\n\n## ✨ Universal Connectors\n\n**Plug AskMyDocs into Google Drive, Notion, OneDrive, Evernote, Fabric, Confluence, and Jira with OAuth in one click — every document chunked and cited correctly per source.**\n\nMost \"RAG over docs\" tools either expect a pile of pre-flattened\nmarkdown or ship a single brittle \"Google Drive sync\" feature. AskMyDocs\nv4.5 ships a real **connector framework** + **seven native connectors**\n+ **per-source chunkers** so every external knowledge corpus lands in\nthe canonical KB with its provenance, native IDs, ACL hints, and\nstatus preserved — and gets chunked the way that source actually wants\nto be chunked.\n\n- **7 native connectors live in v4.5** — `google-drive` (OAuth2 + delta-query), `notion` (OAuth2 + block paginator), `evernote` (OAuth + `.enex` bulk import), `fabric` (API-key, OAuth pending upstream), `onedrive` (Microsoft Graph delta-query — supports `text/markdown` / `text/plain` / `application/pdf`; Office formats `.docx` / `.xlsx` / `.pptx` ingestion deferred), `confluence` (Atlassian OAuth 2.0 3LO; `cloud_id` persisted in tenant-scoped `connector_credentials.extra_json.cloud_id`, optionally reused by a Jira install in the same tenant/workspace), `jira` (Atlassian OAuth 2.0 3LO + ADF-to-markdown + injection-safe JQL builder).\n- **Per-source chunkers** — `NotionBlockChunker` / `ConfluencePageChunker` / `OfficeDocChunker` / `AtomicNoteChunker` / `JiraIssueChunker` / `PdfPageChunker` dispatched via `PipelineRegistry::resolveChunker()` (R23 FQCN-validated + `supports()` mutex-checked at boot).\n- **Rich frontmatter capture** — every connector populates document-level metadata (`connector`, `external_id`, `external_url`, native timestamps) plus chunk-level metadata (`source_type`, `search_tags` (top-level in chunk metadata), `recency_bucket`, ACL hint, status, preamble-path). Drives `KbSearchService` facets + `Reranker` Layer-4 signals (tag overlap + recency + status-active + preamble-match).\n- **Admin OAuth flow at `/app/admin/connectors`** — React SPA + Spatie super-admin gate + signed OAuth callback + per-installation `connector_installations` + `connector_credentials` rows + scheduler-driven incremental sync via `App\\Jobs\\ConnectorSyncJob`.\n- **Opt-in live-test recording infrastructure** — `tests/Live/Connectors/` skeleton + per-provider env-var guard + `docs/v4-platform/RUNBOOK-live-fixture-recording.md` junior-proof setup guide. CI runs only `Unit` + `Feature`; operators refresh fixtures explicitly when provider APIs drift.\n\n### How it compares\n\n| Capability | AskMyDocs v4.5 | Glean | Notion AI | ChatGPT Enterprise | M365 Copilot | Mendable | Vectara |\n|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|\n| Self-hostable + connector framework | ✅ MIT | ❌ SaaS | ❌ SaaS | ❌ SaaS | ❌ SaaS | ❌ SaaS | ❌ $500K/yr |\n| Native Google Drive | ✅ | ✅ | ❌ | ✅ | ❌ | partial | ❌ |\n| Native Notion | ✅ | ✅ | ✅ | ✅ | ❌ | partial | ❌ |\n| Native OneDrive | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |\n| Native Evernote | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| Native Confluence | ✅ | ✅ | ❌ | ❌ | partial | partial | ❌ |\n| Native Jira | ✅ | ✅ | ❌ | ❌ | ❌ | partial | ❌ |\n| Source-aware chunking framework | ✅ | private | ❌ | ❌ | ❌ | partial | partial |\n| Plugin/package extensibility | ✅ (v4.6 packages) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |\n\n**Try it.** Read [`docs/connectors/README.md`](docs/connectors/README.md)\nfor the developer guide (10-method `ConnectorInterface` contract +\nauto-discovery + framework reuse pattern), then log in as a\nsuper-admin and navigate to `/app/admin/connectors` to install the first\nconnector.\n\n---\n\n## ✨ Modern Chat Surface (Vercel AI SDK UI)\n\n**Stop / regenerate / branch / inline-edit / token-cost meter — the chat surface every modern AI app should have, with full streaming citations and suggested follow-ups.**\n\nThe chat UX gap against Claude Desktop / ChatGPT Plus / Vercel\nreference apps is what 90% of first-time users notice and 0% of\nself-hostable RAG OSS ships. v4.5 closes that gap on all seven Tier 1\naffordances plus the first Tier 2 win (suggested follow-ups), built on\ntop of the v4.0 Vercel AI SDK v6 `UIMessageChunk` streaming foundation.\n\n- **7 Tier 1 features** — stop-streaming button (`AbortController`-backed), regenerate-last-assistant, branch-from-message endpoint (forks the conversation tree), inline-edit user message, token+cost meter (BE `config('ai.cost_rates')`), enhanced per-message provider+model+timestamp badge, copy-code-block.\n- **Suggested follow-up pills** — `SuggestedFollowupGenerator` derives three follow-up prompts from the assistant's last reply; renders as clickable pill chips under the message; submits via the streaming endpoint when clicked.\n- **Full Vercel AI SDK v6 message-parts integration** — `MessageStreamController` emits canonical `start` / `text-start` / `text-delta` / `text-end` / `source-url` / `data` / `finish` frames over SSE; `useChatStream()` exposes `data-state=\"idle|loading|ready|empty|error\"` for deterministic Playwright waits (SDK `submitted` and `streaming` statuses both map to `loading` via `mapStatusToDataState()` — see `frontend/src/features/chat/map-status-to-data-state.test.ts`).\n- **Canvas-ready architecture (artifact panel deferred to v5.x)** — Tier 2 stretch (tool-result rendering, streaming source-document parts, conversation export, image attachments, artifact panel) is deliberately deferred to a v5.x milestone so it can be designed alongside the MCP **client** tool-result surface and share one storage contract. See ADR 0008 D4.\n- **Zero-config for OpenAI / Anthropic / Gemini / OpenRouter / Regolo** — OpenAI, Anthropic, Gemini, and OpenRouter are called via raw `Http::` (no SDK); Regolo is wired through the `padosoft/laravel-ai-regolo` SDK adapter on `laravel/ai`. `AiManager::chatStream()` synthesises a single-chunk SSE for providers without native streaming via the `FallbackStreaming` trait.\n\n**Try it.** Open `/app/chat` in the React SPA. Start a long answer\nand hit Stop; click Regenerate; hover the assistant message and pick\nBranch (a new conversation forks from that point); pick a follow-up\npill chip to chain into the next prompt; hover any code block for the\nCopy button.\n\n---\n\n## ✨ KITT — Knowledge Interface Tour Toolkit\n\n**KITT (Knowledge Interface Tour Toolkit) is a one-`\u003cscript\u003e` embeddable, page-aware, agentic AI assistant for any website — it answers grounded questions with citations, *reads the page*, and (when allowed) drives it: clicks, types, navigates, submits, and calls your backend tools.**\n\n![AskMyDoc - KITT.jpeg](resources/screenshots/AskMyDoc%20-%20KITT.jpeg)\n\nMost \"chat widget\" products are a stateless text box bolted to a generic\nLLM. **KITT** is the embeddable surface of the *same* AskMyDocs retrieval\nstack — grounded, cited, tenant-isolated — **plus** a bounded ReAct loop\nthat perceives and acts on the host page. A customer pastes a snippet; the\nwidget captures a structured snapshot of the current page (regions, fields,\nactions, messages, outline) and reasons about what's actually on screen.\n\n- **Embed in one snippet** — `\u003cscript\u003ewindow.AskMyDocsWidget = { key: 'pk_…', apiBase: '…' }\u003c/script\u003e` + the async loader. Two layouts: a floating `helper` launcher or an `inline` mounted block. Theme, title, skill all configurable via `window.AskMyDocsWidget` or `data-*` attrs.\n- **Grounded + cited, never a generic bot** — the widget runs the first-party `KbSearchService` + reranker + refusal gate, scoped to the key's tenant + project. The browser **never** names a tenant — tenant/project are resolved server-side from the key (R30); cross-key/cross-tenant session access is `404` (anti-IDOR).\n- **Agentic by design** — the LLM emits tool calls executed in the page DOM (`click` / `type` / `select` / `navigate_to` / `submit_form` / `wait_for` + ~15 more), or server-side via `/exec-tool` (`search_knowledge_base`), in a bounded loop with per-session step + consecutive-error caps. **Skills** (JSON manifests under `resources/widget/skills/*`) declare which tools, what auto-annotation rules, and the run policies.\n- **Host-Tools Protocol (HTP)** — your app can expose its *own* tools to the agent (\"create order\", \"set rate\"), **double-gated** (per key *and* per skill) and **off by default**. The page is annotated with stable, verb-based `data-kitt-*` attributes (`region` / `field` / `action` / `message` / `locale` / `skip`); `data-kitt-sensitive` and `type=password`/`hidden` values are force-nulled server-side so secrets never reach the LLM or the step log.\n- **Secure embedding** — exact-match `Origin` allowlist (browser mode) or `sk_` secret (server-to-server); **single-use, origin-bound session tokens** consumed atomically under a lock (R21, hashed at rest, rate-limit checked *before* burn); snapshot byte + count caps; `javascript:`/`data:`/protocol-relative navigation blocked on both server and client; PII masked on every persisted step (Italian VAT masking is checksum-validated so non-PII codes stay readable).\n- **Full admin surface** — `/app/admin/widget` (super-admin): create / rotate / revoke keys, manage allowed origins + theme, toggle host-tools, copy the ready-made embed snippet, and replay every session step (PII-masked).\n\n**Try it.** As super-admin, open `/app/admin/widget`, create a key (set a\n`project_key` + your site's origin), copy the **Embed code** snippet into\nyour page, and reload. Locally, set `WIDGET_DEMO_ENABLED=true` and open\n`/widget-demo` for a self-contained annotated demo page (add `?mode=inline`\nfor the inline layout). Full developer guide:\n[`docs/kitt/INTEGRATION.md`](docs/kitt/INTEGRATION.md).\n\n\u003e **Security \u0026 embedding.** KITT is a cross-origin embeddable *and* an\n\u003e agentic (page-driving) surface, so before embedding it on anything beyond a\n\u003e public, low-sensitivity page, read the\n\u003e [**Security \u0026 threat model**](docs/kitt/INTEGRATION.md#14-security--threat-model)\n\u003e section of the integration guide — it documents exactly what is enforced and\n\u003e *why* (tenant isolation, exact-match origin allowlist, no-credential CORS, no\n\u003e host-page XSS, credential-field and navigation guards), the residual/inherent\n\u003e risks of a public embeddable agent (public-key abuse, prompt-injection-driven\n\u003e actions, data egress to the LLM), and the **best practices the operator and\n\u003e the host site must follow to mitigate them** — including `data-kitt-skip` to\n\u003e keep sensitive page regions out of the snapshot.\n\n### Database\n```env\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=5432\nDB_DATABASE=askmydocs\nDB_USERNAME=postgres\nDB_PASSWORD=secret\n```\n\n### AI Provider\n\nThe system supports **five providers**. OpenAI, Anthropic, Gemini, and OpenRouter are called via raw `Http::`, which keeps auth, retries, timeouts, and response parsing under our control. Regolo is the exception: it is wired through the `padosoft/laravel-ai-regolo` SDK adapter (built on `Laravel\\Ai`), so chat + embeddings reuse its OpenAI-compatible client.\n\nConfig file: `config/ai.php`\n\n#### Defaults\n\n```env\n# Chat provider. Supported: openai, anthropic, gemini, openrouter, regolo\nAI_PROVIDER=openrouter\n\n# Embeddings provider. Must support embeddings (openai, gemini, regolo, openrouter).\n# Anthropic does NOT offer embeddings. OpenRouter exposes OpenAI-compatible\n# /v1/embeddings (since Oct 2025) routing openai/text-embedding-3-small (default)\n# and qwen/qwen3-embedding-4b. Leave empty to let AiManager reuse AI_PROVIDER\n# when the default chat provider supports embeddings; otherwise it falls back\n# to the first embeddings-capable provider with a configured API key in this order:\n# openai → openrouter → regolo → gemini. The 1536-dim defaults (openai +\n# openrouter) come first so the stock KB_EMBEDDINGS_DIMENSIONS=1536 pgvector\n# schema stays consistent under auto-selection — regolo (4096) and gemini\n# (768) require a pgvector resize in lock-step, set AI_EMBEDDINGS_PROVIDER\n# explicitly to opt in.\nAI_EMBEDDINGS_PROVIDER=openai\n```\n\n#### OpenAI\n\n```env\nAI_PROVIDER=openai\nOPENAI_API_KEY=sk-...\nOPENAI_CHAT_MODEL=gpt-4o\nOPENAI_EMBEDDINGS_MODEL=text-embedding-3-small\nOPENAI_TEMPERATURE=0.2\nOPENAI_MAX_TOKENS=4096\nOPENAI_TIMEOUT=120\n```\n\n#### Anthropic (Claude)\n\nAnthropic has no embeddings endpoint, so pair it with any embeddings-capable provider — OpenAI, OpenRouter, Regolo, or Gemini. If `AI_EMBEDDINGS_PROVIDER` is left empty, `AiManager` auto-selects the first one with a configured API key in this order: openai → openrouter → regolo → gemini. The 1536-dim defaults (OpenAI's `text-embedding-3-small` + OpenRouter routing the same model) come first so a deployment with the stock `KB_EMBEDDINGS_DIMENSIONS=1536` pgvector schema stays consistent under auto-selection; Regolo (4096) and Gemini (768) require a `vector(N)` resize and a matching `KB_EMBEDDINGS_DIMENSIONS` change before use, so set `AI_EMBEDDINGS_PROVIDER=regolo|gemini` explicitly when you've migrated.\n\n```env\nAI_PROVIDER=anthropic\nAI_EMBEDDINGS_PROVIDER=openai\n\nANTHROPIC_API_KEY=sk-ant-...\nANTHROPIC_CHAT_MODEL=claude-sonnet-4-20250514\nOPENAI_API_KEY=sk-...\n```\n\n#### Google Gemini\n\nGemini supports both chat and embeddings. `text-embedding-004` is **768-dim**, so switching embedding providers requires updating `KB_EMBEDDINGS_DIMENSIONS` **and** re-indexing.\n\n```env\nAI_PROVIDER=gemini\nGEMINI_API_KEY=AIza...\nGEMINI_CHAT_MODEL=gemini-2.0-flash\nGEMINI_EMBEDDINGS_MODEL=text-embedding-004\n```\n\n#### OpenRouter (multi-model gateway) — default\n\nOpenRouter proxies hundreds of models. Since Oct 2025 it also exposes an\nOpenAI-compatible `/v1/embeddings` endpoint, so it can serve both chat\nand embeddings from the same gateway. Default embedding model is\n`openai/text-embedding-3-small` (1536 dims — matches the default\n`KB_EMBEDDINGS_DIMENSIONS`, no re-index needed). Alternative\n`qwen/qwen3-embedding-4b` (2560 dims) requires resizing the pgvector\ncolumn on `knowledge_chunks.embedding` + `embedding_cache.embedding`\nand re-indexing. Pair with a separate provider if you prefer.\n\n```env\nAI_PROVIDER=openrouter\nAI_EMBEDDINGS_PROVIDER=openrouter\n\nOPENROUTER_API_KEY=sk-or-...\nOPENROUTER_CHAT_MODEL=openai/gpt-4o-mini\nOPENROUTER_EMBEDDINGS_MODEL=openai/text-embedding-3-small\nOPENROUTER_APP_NAME=\"AskMyDocs\"\nOPENROUTER_SITE_URL=https://kb.example.com\n```\n\n#### Regolo.ai (by Seeweb)\n\nEU-based, GDPR-compliant, **OpenAI-compatible** REST API. Supports chat, streaming, embeddings, and reranking via the [`padosoft/laravel-ai-regolo`](https://github.com/padosoft/laravel-ai-regolo) extension on top of the official `laravel/ai` SDK. Get keys at [dashboard.regolo.ai](https://dashboard.regolo.ai) and see [docs.regolo.ai](https://docs.regolo.ai) for the full model catalogue.\n\n```env\nAI_PROVIDER=regolo\nAI_EMBEDDINGS_PROVIDER=regolo\n\nREGOLO_API_KEY=...\nREGOLO_BASE_URL=https://api.regolo.ai/v1\n\n# Chat models — `cheapest` / `smartest` aliases pick the right model for\n# cost-vs-quality shortcuts (see `Lab::Cheapest` / `Lab::Smartest` in laravel/ai).\nREGOLO_CHAT_MODEL=Llama-3.3-70B-Instruct\nREGOLO_CHAT_MODEL_CHEAPEST=Llama-3.1-8B-Instruct\nREGOLO_CHAT_MODEL_SMARTEST=Llama-3.3-70B-Instruct\n\n# Embeddings — set KB_EMBEDDINGS_DIMENSIONS to the same value below.\nREGOLO_EMBEDDINGS_MODEL=Qwen3-Embedding-8B\nREGOLO_EMBEDDINGS_DIMENSIONS=4096\n\n# Reranker — used when KB_RERANKING_ENABLED=true.\nREGOLO_RERANKING_MODEL=jina-reranker-v2\n\n# Transport + per-call defaults. `REGOLO_MAX_TOKENS` / `REGOLO_TEMPERATURE`\n# are the provider-level fallbacks; per-call `$options['max_tokens']` /\n# `$options['temperature']` (e.g. `ConversationController::generateTitle`\n# capping titles at 60 tokens) take precedence.\nREGOLO_TIMEOUT=120\nREGOLO_MAX_TOKENS=4096\nREGOLO_TEMPERATURE=0.2\n```\n\n#### Embedding dimension gotcha\n\nIf you change the embeddings provider/model (e.g. from OpenAI 1536-dim to Gemini 768-dim):\n\n1. Update `KB_EMBEDDINGS_DIMENSIONS` in `.env`\n2. Create a new migration that resizes the `embedding` `vector(N)` column on `knowledge_chunks` and `embedding_cache`\n3. Flush the cache so stale-dimension vectors don't pollute retrieval — call `app(\\App\\Services\\Kb\\EmbeddingCacheService::class)-\u003eflush()` (or scope by retired provider with `-\u003eflush('openai')`) from a tinker session. `kb:prune-embedding-cache --days=N` only evicts rows older than N days and returns early when `N \u003c= 0`, so it is **not** a full-flush substitute.\n4. Re-index all documents\n\n### Storage (Laravel disks)\n\nKB markdown files are read through a Laravel filesystem disk, so the ingestion pipeline is **storage-agnostic**: local for dev, S3 for production, MinIO for on-prem — no code change needed.\n\nConfig file: `config/filesystems.php`. The dedicated `kb` disk defaults to `storage/app/kb`:\n\n```env\n# Disk used by kb:ingest and DocumentIngestor (see config/filesystems.php)\nKB_FILESYSTEM_DISK=kb\nKB_DISK_DRIVER=local\n# KB_DISK_ROOT=/absolute/path/to/markdown/root\n\n# Optional path prefix prepended to every ingested path\nKB_PATH_PREFIX=\n```\n\n#### Switching to S3\n\nInstall the Flysystem S3 adapter once:\n\n```bash\ncomposer require league/flysystem-aws-s3-v3 \"^3.0\"\n```\n\nThen switch the disk driver and fill the AWS credentials:\n\n```env\nKB_FILESYSTEM_DISK=s3\n\nAWS_ACCESS_KEY_ID=...\nAWS_SECRET_ACCESS_KEY=...\nAWS_DEFAULT_REGION=eu-west-1\nAWS_BUCKET=askmydocs-kb\nAWS_URL=\nAWS_ENDPOINT=              # set for MinIO / R2 / Wasabi\nAWS_USE_PATH_STYLE_ENDPOINT=false\n```\n\n#### Ingesting a document\n\n```bash\n# Reads storage/app/kb/docs/setup.md (local disk)\nphp artisan kb:ingest docs/setup.md --project=erp-core --title=\"Installation Guide\"\n\n# Override the disk ad-hoc\nphp artisan kb:ingest docs/setup.md --project=erp-core --disk=s3\n```\n\n### Chat Logging\n\nChat logging is **off by default**. Enable it to get structured analytics about every Q\u0026A turn.\n\nConfig file: `config/chat-log.php`\n\n```env\nCHAT_LOG_ENABLED=true\nCHAT_LOG_DRIVER=database\nCHAT_LOG_DB_CONNECTION=      # optional: dedicated DB connection\nCHAT_LOG_RETENTION_DAYS=90   # scheduler rotates rows older than N days\n```\n\n#### Fields persisted per interaction\n\n| Field | Description |\n|---|---|\n| `session_id` | Session UUID (from `X-Session-Id` header or auto-generated) |\n| `user_id` | Authenticated user id (nullable) |\n| `question` | User question |\n| `answer` | Assistant response |\n| `project_key` | Project key used as RAG filter |\n| `ai_provider` | openai / anthropic / gemini / openrouter / regolo |\n| `ai_model` | Specific model used |\n| `chunks_count` | Number of retrieved context chunks |\n| `sources` | Source document paths that contributed context |\n| `prompt_tokens` / `completion_tokens` / `total_tokens` | Token usage |\n| `latency_ms` | End-to-end latency |\n| `client_ip` / `user_agent` | Client metadata |\n| `extra` | JSON for custom fields (e.g. `few_shot_count`) |\n\nLogging is wrapped in try/catch — a driver failure never breaks the user response.\n\n### Knowledge Base\n\nConfig file: `config/kb.php`\n\n```env\nKB_EMBEDDINGS_DIMENSIONS=1536\nKB_MIN_SIMILARITY=0.30\nKB_DEFAULT_LIMIT=8\n\n# Chunking\nKB_CHUNK_TARGET_TOKENS=512\nKB_CHUNK_HARD_CAP_TOKENS=1024\nKB_CHUNK_OVERLAP_TOKENS=64\n\n# Embedding cache\nKB_EMBEDDING_CACHE_ENABLED=true\nKB_EMBEDDING_CACHE_RETENTION_DAYS=30\n```\n\n### `GET /api/kb/documents/search` (v3.0+)\n\nDocument title/path autocomplete used by the chat composer's `@mention` popover (T2.7/T2.8). Sanctum-protected.\n\n**Query params:**\n\n- `q` — search string (2-120 chars, escaped for `LIKE` wildcards via `\\` + `ESCAPE '\\\\'` clause per R19; literal `_` and `%` in the query do NOT act as wildcards)\n- `project_keys[]` — optional tenant scope (zero or more)\n\n**Response:** `{ \"data\": [{ \"id\", \"project_key\", \"title\", \"source_path\", \"source_type\", \"canonical_type\" }] }`\n\nUp to 20 results per request. Archived documents are excluded.\n\n### Saved filter presets (v3.0+)\n\nAuthenticated users can save / load / delete personal filter combinations via `RESTful /api/chat-filter-presets` (consumed by the FE FilterBar dropdown — UI work in a follow-up FE PR).\n\n- `GET    /api/chat-filter-presets` — list the user's presets (alphabetical by name).\n- `POST   /api/chat-filter-presets` — create. Required body: `{ \"name\": \"…\", \"filters\": { … } }`. Per-user uniqueness enforced on `name` (422 on duplicate within the same account). Different users may pick the same display name independently.\n- `GET    /api/chat-filter-presets/{id}` — show one. Returns `404` for IDs owned by a different user (deliberate — the API does not leak the existence of other users' presets).\n- `PUT    /api/chat-filter-presets/{id}` — update name + filters; same `404` semantics for non-owned rows.\n- `DELETE /api/chat-filter-presets/{id}` — delete; `204` on success, `404` for non-owned rows.\n\nThe `filters` JSON column carries a serialised RetrievalFilters payload — the same shape the chat controller's `KbChatRequest::toFilters()` consumes. Round-trip is lossless: load preset → POST to `/api/kb/chat` produces identical retrieval scope as if the user had re-selected every filter manually.\n\n### Chat filters (v3.0+)\n\n`POST /api/kb/chat` accepts an optional `filters` object that narrows the retrieval scope BEFORE reranking + graph expansion + rejected-approach injection — filters change the candidate population, not the post-hoc ranking. Every dimension is optional.\n\n```json\n{\n  \"question\": \"What is our cache invalidation policy?\",\n  \"filters\": {\n    \"project_keys\": [\"hr-portal\", \"engineering\"],\n    \"tag_slugs\": [\"policy\", \"security\"],\n    \"source_types\": [\"markdown\", \"pdf\"],\n    \"canonical_types\": [\"decision\", \"runbook\"],\n    \"connector_types\": [\"local\", \"google-drive\"],\n    \"doc_ids\": [42, 99],\n    \"folder_globs\": [\"hr/policies/**\"],\n    \"date_from\": \"2026-01-01\",\n    \"date_to\": \"2026-12-31\",\n    \"languages\": [\"it\", \"en\"]\n  }\n}\n```\n\nField semantics:\n\n- `project_keys` — multi-tenant scope; takes precedence over the legacy `project_key` field when both are sent.\n- `tag_slugs` — match documents tagged with ANY listed slug (T2.3 join, ships in a follow-up).\n- `source_types` — one of `markdown`, `text`, `pdf`, `docx` (validated against `App\\Support\\Kb\\SourceType` so adding a new type extends the validator automatically).\n- `canonical_types` — one of the `App\\Support\\Canonical\\CanonicalType` enum values currently stored on `knowledge_documents.canonical_type`: `decision`, `module-kb`, `runbook`, `standard`, `incident`, `integration`, `domain-concept`, `rejected-approach`, `project-index`. The validator is built from `CanonicalType::cases()` so adding a new case auto-extends the accepted set.\n- `connector_types` — connector identifier strings (for example `local`, `google-drive`, `onedrive`, `notion`, `asana`, `imap`). Accepted in v3.0 but currently a no-op in retrieval until the `connector_type` column is added in v3.1.\n- `doc_ids` — explicit document-id allowlist (used by the `@mention` UI in the chat composer, T2.7).\n- `folder_globs` — path globs against `source_path`. `*` matches a single segment (does NOT cross `/`), `**` matches across segments (e.g. `hr/policies/**` matches `hr/policies/leave.md` AND `hr/policies/inner/leave.md`), `?` matches a single char (not `/`). Applied PHP-side after the SQL pre-filter via `App\\Support\\KbPath::matchesAnyGlob` (PostgreSQL has no native fnmatch and `**` doesn't translate to LIKE cleanly).\n- `date_from` / `date_to` — ISO 8601 date range against `indexed_at`. `date_to` must be after-or-equal to `date_from`.\n- `languages` — ISO 639-1 codes (normalized to lowercase during DTO construction; the validator enforces `size:2`).\n\nPre-T2.2 callers using the legacy `{question, project_key}` payload keep working unchanged — internally `project_key` is wrapped into `filters.project_keys = [project_key]`. The response `meta.filters_selected` echoes the count of user-selected filter dimensions for the FE composer to render \"5 filters selected\".\n\n### Multi-format ingest (v3.0+)\n\n`kb:ingest-folder` now picks up `.md`, `.markdown`, `.txt`, `.pdf`, and `.docx` files automatically (default `--pattern` is the union of every supported extension). Operators who want pre-T1.8 markdown-only behavior pass `--pattern=md,markdown` explicitly.\n\nThe `POST /api/kb/ingest` endpoint accepts an optional `mime_type` field per document (defaults to `text/markdown` for back-compat). Binary formats (`application/pdf`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`) require `documents.*.content` to be **base64-encoded**; the controller decodes-or-422 before writing to disk. Text MIMEs (`text/markdown`, `text/x-markdown`, `text/plain`) keep accepting raw content. Unsupported MIME types return 422 with an actionable error naming the supported set.\n\nThe `App\\Support\\Kb\\SourceType` enum is a typed helper for the markdown/text/pdf/docx domain — `SourceType::fromMime()` and `SourceType::fromExtension()` are the canonical conversions used by the API controller and the folder walker. The actual ingest routing is config-driven via `config/kb-pipeline.php` (`converters` / `chunkers` / `mime_to_source_type`); adding a new format requires updating BOTH `config/kb-pipeline.php` AND `SourceType::fromMime()` / `fromExtension()` / `toMime()` / `supportedMimes()` so the API/CLI surfaces stay consistent with what the registry resolves.\n\n### Extending the Ingestion Pipeline\n\nAskMyDocs v3.0 introduces a pluggable ingestion pipeline driven by `config/kb-pipeline.php`. To add support for a new file format:\n\n1. **Implement** `App\\Services\\Kb\\Contracts\\ConverterInterface` — convert raw bytes to a `ConvertedDocument` (markdown + extraction metadata). Every converter MUST populate `extractionMeta['filename'] = basename($doc-\u003esourcePath)` so the chunker can attribute chunks back to their source file.\n2. **Implement** `App\\Services\\Kb\\Contracts\\ChunkerInterface` — or reuse `MarkdownChunker` if your converter outputs markdown (the default for prose formats).\n3. **Register** in `config/kb-pipeline.php` under `converters` and `chunkers`.\n4. **Map** the MIME type in `mime_to_source_type` so the pipeline can route to the right chunker.\n\nBuilt-in converters (v3.0):\n\n- `MarkdownPassthroughConverter` — `text/markdown`, `text/x-markdown`\n- `TextPassthroughConverter` — `text/plain` (wraps prose in a `# {basename}` header so MarkdownChunker can section it)\n- `PdfConverter` — `application/pdf` (smalot/pdfparser primary; falls back to `pdftotext` from Poppler when smalot rejects the file)\n- `DocxConverter` — `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (parses the `.docx` package via `phpoffice/phpword`; maps `Heading{N}` paragraph styles to `#{×N+1}` markdown headings nested under the basename H1; tables become markdown pipe-tables. Embedded images are NOT extracted in v3.0 — planned for v3.1 with the vision-LLM pipeline.)\n\n**PDF support:** `smalot/pdfparser` is a hard `require` (pure PHP, no system deps). For more robust extraction on complex PDFs (multi-column layouts, certain XFA forms, mixed encodings), install `poppler-utils` on the host (`apt install poppler-utils` on Debian/Ubuntu, `brew install poppler` on macOS) — the `PdfConverter` automatically falls back to the `pdftotext` binary when smalot raises an exception. `extractionMeta.extraction_strategy` records which strategy was used per document so you can audit the rate of fallbacks in production.\n\nBuilt-in chunkers (v3.0):\n\n- `PdfPageChunker` — handles `pdf` source-type. Slices on the `## Page N` heading boundaries emitted by `PdfConverter`; emits one chunk per non-empty page with `heading_path = \"Page N\"` so citations like \"see page N of foo.pdf\" map 1:1 to a single chunk row. Pages exceeding `KB_CHUNK_HARD_CAP_TOKENS` are split intra-page on `\\n\\n` paragraph boundaries; all pieces of the same page share the same `heading_path` so page-level citations still resolve cleanly.\n- `MarkdownChunker` — handles `markdown`, `md`, `text`, `docx` source types (any source whose converter outputs markdown). Uses `section_aware` mode: emits one chunk per ATX heading section with `heading_path` as a `\u003e`-joined breadcrumb of H1-H3 ancestors. Falls back to `paragraph_split` (one chunk per blank-line-separated block) for documents without headings.\n\nThe chunker registry is order-significant — `PdfPageChunker` is listed FIRST in `config/kb-pipeline.php`'s `chunkers` so the first-match-wins resolution prefers it for `pdf` over the markdown fallback.\n\nThe polymorphic entry point is `DocumentIngestor::ingest(string $projectKey, SourceDocument $source, string $title, array $extraMetadata = [])`. The pre-v3 `ingestMarkdown(...)` is now a thin facade that synthesises a `text/markdown` `SourceDocument` and delegates to `ingest()` — IngestDocumentJob and the GitHub Action keep working unchanged.\n\n### Multi-tenant deployment (v4.0)\n\nThe v4.0 cycle adds a **per-request tenant context** that scopes every Eloquent query against tenant-aware tables (R30/R31). Existing v3.x deployments are backward-compatible — every row gets `tenant_id = 'default'` and the resolver returns `'default'` unless explicitly configured otherwise.\n\n**The plumbing**\n\n| Piece | Path | Responsibility |\n|---|---|---|\n| `TenantContext` | `app/Support/TenantContext.php` | Request-scoped singleton; holds the active `tenant_id` for the duration of one HTTP request or one CLI command |\n| `ResolveTenant` middleware | `app/Http/Middleware/ResolveTenant.php` | Reads the tenant from the configured resolver and sets `TenantContext`; runs at the top of the global middleware stack so every controller / job dispatched from the request inherits the context |\n| `BelongsToTenant` trait | `app/Models/Concerns/BelongsToTenant.php` | Auto-fills `tenant_id` on `creating` events from `TenantContext::current()`; provides `forTenant($id)` query scope |\n| `--tenant=X` CLI option | every domain Artisan command | CLI commands (`kb:ingest-folder`, `kb:rebuild-graph`, `kb:promote`, `insights:compute`) accept the option and set the context before running |\n\n**Configuration (`.env`)**\n\n```bash\n# Single-tenant deployment (v3.x backward compatible — DEFAULT)\nTENANT_DEFAULT=default\nTENANT_RESOLVER=default          # always returns 'default'\n\n# Multi-tenant by HTTP header (suitable for B2B SaaS with API gateway routing)\nTENANT_RESOLVER=header\nTENANT_HEADER_NAME=X-Tenant-ID\n\n# Multi-tenant by domain (suitable for subdomain-per-customer deployments)\nTENANT_RESOLVER=domain\nTENANT_DOMAIN_PATTERN='([^.]+)\\\\.example\\\\.com'   # captures tenant slug\n\n# Multi-tenant by authenticated user (suitable for shared-host SaaS)\nTENANT_RESOLVER=auth\nTENANT_USER_COLUMN=tenant_id     # column on the User model that holds the tenant\n```\n\n**What's tenant-scoped (and what isn't)**\n\nThe 20 tenant-aware models (enumerated in `tests/Architecture/TenantIdMandatoryTest::TENANT_AWARE_MODELS` — `KnowledgeDocument`, `KnowledgeChunk`, `ChatLog`, `Conversation`, `Message`, `KbNode`, `KbEdge`, `KbCanonicalAudit`, `ProjectMembership`, `KbTag`, `KnowledgeDocumentAcl`, `AdminCommandAudit`, `AdminCommandNonce`, `AdminInsightsSnapshot`, `ChatFilterPreset`, `ChatLogProvenance`, `TabularReview`, `TabularCell`, `Workflow`, `HiddenWorkflow`) all carry `tenant_id` and use the `BelongsToTenant` trait. The architecture test gates new models on every CI run so this list stays in lock-step with the migrations. Composite tenant-scoped FKs on `kb_edges` make cross-tenant edges **structurally impossible** at the database level.\n\n`embedding_cache` is **intentionally NOT tenant-scoped** — the cache is a cross-tenant reuse layer keyed on `text_hash` UNIQUE alone (provider + model are retrieval-time filters). Sharing embeddings across tenants is a deliberate cost optimisation; eviction goes through `EmbeddingCacheService::flush($provider)` whenever the embedding model changes.\n\n**The 6 v4 cycle rules guard the boundary**\n\n| Rule | What it enforces |\n|---|---|\n| **R30** | Every Eloquent query against a tenant-aware table MUST be scoped to the active tenant via `forTenant()` or explicit `where('tenant_id', $ctx-\u003ecurrent())` — cross-tenant leak is a GDPR catastrophe |\n| **R31** | Every tenant-aware model MUST `use BelongsToTenant;` and list `'tenant_id'` in `$fillable`; `tests/Architecture/TenantIdMandatoryTest.php` enumerates the model list and gates new entries on every CI run |\n| **R36** | Mandatory Copilot review + CI green loop on every PR — caught the v4 PR #98 regression where `embedding_cache` was wrongly tagged tenant-scoped |\n| **R37** | `feature/vX.Y` integration branch + once-per-major merge to main — preserves stable consumers from in-flight major work |\n| **R38** | Heavy work (`migrate:fresh`, big seeders) belongs in CLI workflow steps, not behind `php artisan serve` — keeps E2E reliable |\n| **R39** | Tag `vX.Y.0-rcN` at every Wn weekly milestone closure pinned to the exact closure SHA — gives auditors and downstream consumers serialised milestone visibility |\n---\n\n## Features by area\n\nSix grouped feature tables. Every entry is verifiable against the\ncodebase (see [`CLAUDE.md`](CLAUDE.md) section 3 for the component map,\nthe per-cycle STATUS docs under [`docs/v4-platform/`](docs/v4-platform/),\nand the ADR set under [`docs/adr/`](docs/adr/)).\n\n### Retrieval \u0026 Knowledge\n\n| Feature | Description | Since |\n|---|---|---|\n| Hybrid retrieval (pgvector + FTS + reranker) | Vector top-K (pgvector cosine) fused with full-text top-K (PostgreSQL `to_tsvector` GIN index) via Reciprocal Rank Fusion; `Reranker` runs `0.55·vec + 0.25·kw + 0.05·heading` on top of 3× over-retrieval | v1.0 |\n| Multi-format ingestion pipeline | `markdown`, `text`, **PDF** (`smalot/pdfparser` + Poppler fallback), **DOCX** (`phpoffice/phpword`) — all converge on `DocumentIngestor::ingest(SourceDocument)` via the `PipelineRegistry` (R23: FQCN validated at boot, `supports()` mutex-checked) | v3.0 |\n| Canonical knowledge graph (9 node types / 10 edge types) | `kb_nodes` + `kb_edges` with `decision` / `runbook` / `standard` / `incident` / `integration` / `domain-concept` / `module-kb` / `rejected-approach` / `project-index` nodes and `depends_on` / `uses` / `implements` / `related_to` / `supersedes` / `invalidated_by` / `decision_for` / `documented_by` / `affects` / `owned_by` edges. Tenant-scoped composite FKs make cross-tenant edges structurally impossible | v3.0 |\n| `CanonicalParser` (9 canonical types / 6 statuses) | YAML frontmatter parser via `symfony/yaml`; validates `type`, `status`, `slug`, `retrieval_priority` in `[0, 100]`. Invalid frontmatter degrades gracefully to non-canonical (R4) | v3.0 |\n| `GraphExpander` 1-hop graph expansion at retrieval | Walks `kb_edges` from canonical seed docs at retrieval time, returns best chunk per neighbour; config-gated via `KB_GRAPH_EXPANSION_ENABLED=true` (default); degrades to no-op when no canonical docs exist | v3.0 |\n| `RejectedApproachInjector` anti-repetition memory | Vector-correlates the query against `rejected-approach` canonical docs above `KB_REJECTED_MIN_SIMILARITY` (default 0.45); top-N (default 3) injected into the prompt under `⚠ REJECTED APPROACHES`; the LLM sees dismissed options *before* answering | v3.0 |\n| Promotion pipeline (`suggest` / `candidates` / `promote`) | Three-stage human-gated API (ADR 0003): `/suggest` extracts candidates via LLM (writes nothing), `/candidates` validates a draft (writes nothing), `/promote` writes markdown + dispatches `IngestDocumentJob` (HTTP 202). Only humans and `kb:promote` CLI commit canonical storage | v3.0 |\n| Idempotent SHA-256 ingestion | Composite UNIQUE on `(project_key, source_path, version_hash)`; re-pushing identical bytes is a no-op; a new version archives the prior; `$tries=3` with backoff `[10, 30, 60]` on `IngestDocumentJob` | v1.0 |\n| `MarkdownChunker` section-aware fence-safe FSM | Custom line-based fence-aware state machine: emits one chunk per ATX heading section with `heading_path` breadcrumb (H1\u003eH2\u003eH3); fences (` ``` `, `~~~`) suppress heading detection inside code blocks; falls back to `paragraph_split` on docs without headings | v3.0 |\n| `PdfPageChunker` page-aware PDF chunking | Slices on the `## Page N` boundaries emitted by `PdfConverter`; emits one chunk per non-empty page with `heading_path = \"Page N\"` for page-precise citations; intra-page split on `\\n\\n` when over `KB_CHUNK_HARD_CAP_TOKENS` | v3.0 |\n| Embedding cache (cross-tenant by design) | DB-backed LRU cache keyed on SHA-256(`text`) UNIQUE; eliminates redundant API calls on re-ingestion and repeated queries; `EmbeddingCacheService::flush($provider)` on provider/model change. Conditional approval gate via `KB_EMBEDDING_CACHE_APPROVAL_THRESHOLD` (default 5000) on v4.2+ | v1.0 |\n| Soft delete + retention sweep | `SoftDeletes` on `KnowledgeDocument`; hidden from every read path by default; `kb:prune-deleted` (03:30 daily) hard-deletes after `KB_SOFT_DELETE_RETENTION_DAYS` (default 30); cascades `kb_nodes` + `kb_edges` on final hard delete; immutable `kb_canonical_audit` row survives | v3.0 |\n| MCP server `enterprise-kb` (10 tools) | 5 retrieval tools (`kb.search` / `kb.read_document` / `kb.read_chunk` / `kb.recent_changes` / `kb.search_by_project`) + 5 canonical/promotion tools (`kb.graph.neighbours` / `kb.graph.subgraph` / `kb.documents.by_slug` / `kb.documents.by_type` / `kb.promotion.suggest`) exposed at `/mcp/kb` for Claude Desktop / Claude Code / any MCP-compatible agent | v3.0 |\n| Enterprise chat filters (10 dimensions) | `RetrievalFilters` DTO with `project_keys` / `tag_slugs` / `source_types` / `canonical_types` / `connector_types` / `doc_ids` / `folder_globs` / `date_from` / `date_to` / `languages`. Per-user saved presets with 404-not-403 cross-user isolation; `@mention` doc pinning via cursor-context detection | v3.0 |\n| Reranker canonical boost + status penalty | Reranker applies `priority × 0.003` canonical boost and `superseded −0.4` / `deprecated −0.4` / `archived −0.6` status penalties on top of the vector/keyword/heading fusion; non-canonical chunks get zero adjustment (legacy behaviour preserved) | v3.0 |\n| Source-aware chunkers + rich frontmatter capture | `PipelineRegistry::resolveChunker($sourceType)` dispatches per source (R23 FQCN-validated + `supports()` mutex-checked at boot) to: `NotionBlockChunker` / `ConfluencePageChunker` / `OfficeDocChunker` / `AtomicNoteChunker` / `JiraIssueChunker` / `PdfPageChunker` / `MarkdownChunker`. Document-level metadata carries `connector` + `external_id` + `external_url` + native timestamps; chunk-level metadata carries `source_type` + `search_tags` (top-level) + `recency_bucket` + ACL hint + status + preamble-path | v4.5 |\n| `Reranker` Layer-4 signals (tag overlap, recency, status-active, preamble) | Four additive Layer-4 deltas: `tag_overlap_weight=0.05` + `preamble_match_weight=0.05` + `recency_weight=0.02` + `status_active_weight=0.02`, on top of the base `0.55·vec + 0.25·kw + 0.05·heading`. Max score ~1.44 (documented in code); base 4 signals still sum to 1.0 | v4.5 |\n| `KbSearchService` facets (`source` + `tag`) | `searchWithContext()` accepts optional `facets` param; emits `facets[source]` + `facets[tag]` counts; backed by 2 new GIN-on-`jsonb` indexes (`source_type` + `search_tags`) plus 1 B-tree expression index on `metadata-\u003e\u003e'recency_bucket'` on `knowledge_chunks`, all PostgreSQL-only (SQLite is a no-op) | v4.5 |\n| Synonym Expansion (industry jargon ↔ plain language) | Per-(tenant, project) synonym groups managed under **Admin → Synonyms** (`kb_synonyms`). `SynonymExpander` bidirectionally expands a query — mentioning any group member also searches every other member — enriching the query embedding (all drivers) and OR-expanding the FTS `tsquery` (PostgreSQL, injection-safe). Connects internal acronyms / product codenames the base embedding model has never seen. Toggle via `KB_SYNONYM_EXPANSION_ENABLED` (default on; no-op without groups) + `KB_SYNONYM_CACHE_TTL_SECONDS` (default 300) | v8.7 |\n| AI deep-analysis on document change + **delete** (Doc Insights) | When a document is **ingested, modified, or deleted**, an async job asks the LLM — given the changed doc + its closest semantic neighbours — to (a) suggest how to strengthen it, (b) surface its cross-references, and (c) flag which OTHER docs the change makes obsolete / in need of revision. **On a delete (v8.8)** a pre-delete snapshot drives an obsolescence-impact pass: which remaining docs now have a dangling reference. Results land in `kb_doc_analyses` (`trigger ∈ ingested\\|modified\\|deleted`), notify reviewers, and render under **Admin → Doc Insights** (`/app/admin/kb/insights`). **Suggest-only** — never mutates a doc (ADR 0003). Cost-gated: default ON for canonical docs, opt-in for non-canonical; **v8.8 adds a per-(tenant, project) override** (**Admin → Analysis Gate**, `kb_analysis_settings`) so an operator can turn the analysis on/off per project independently of the change / canonical-split / on-delete knobs; master switch `KB_CHANGE_ANALYSIS_ENABLED` | v8.7 · v8.8 |\n| Per-query multilingual FTS | `QueryLanguageDetector` detects each query's language and stems with the matching PostgreSQL FTS dictionary (`italian` / `english` / …) instead of a single fixed one — a dependency-free, deterministic stopword heuristic that returns a dictionary ONLY on a confident, language-specific signal and otherwise **falls back to the configured default (R14 — never silently stems with the wrong dictionary)**. Default OFF (`KB_FTS_LANGUAGE_DETECTION`); supported set via `KB_FTS_SUPPORTED_LANGUAGES` | v8.8 |\n| Content-gap analytics (Content Gaps) | Every refused chat turn — the deterministic grounding gate **and** the LLM self-refusal sentinel, across the sync **and** streaming chat paths — increments a per-`(tenant, project, normalized query, reason)` rollup in `kb_search_failures` (atomic, never breaks the chat path). **Admin → Content Gaps** (`/app/admin/kb/content-gaps`, API `/api/admin/kb/content-gaps`) ranks the most-asked unanswered questions so editors know what to write next, with a reason filter (options derived from the DB) and a one-click resolve to dismiss a gap once an article covers it. Toggle via `KB_CONTENT_GAPS_ENABLED` (default on) | v8.8 |\n| Cloud Time Machine (version timeline + diff + restore) | Every re-ingest already retains the prior `knowledge_documents` row + its chunks (status `archived`); the Time Machine surfaces that history under **Admin → Time Machine** (`/app/admin/kb/time-machine/{id}`). `GET .../versions` lists the version timeline for a `(tenant, project, source_path)` family; `.../versions/diff?from=\u0026to=` returns an in-house LCS line diff (`App\\Support\\MarkdownDiff`) of the reconstructed content; `POST .../restore-version` re-activates an archived version (transactional status-flip + canonical-identity transfer + `kb_canonical_audit` row) — no re-embedding, reuses retained chunks. `kb:prune-archived-versions` (daily) caps retained archived versions per family at `KB_KEEP_ARCHIVED_VERSIONS` (default 10); the live + soft-deleted rows are never pruned | v8.7 |\n| **Tabular Review** (spreadsheet-style document extraction) | `tabular_reviews` + `tabular_cells` tables; `TabularReviewExtractor` runs ONE multi-column LLM call per document (cost `O(documents)` not `O(documents × columns)`); 17 format types (Mike's 9 + 8 AskMyDocs-new including the LLM-free `json_path` shortcut leveraging v4.5/W5.5 source-aware metadata); R14 loud refusal with red flag + reasoning on no-evidence / LLM error / JSON parse failure; DB-level upsert keyed on the composite UNIQUE `(tenant_id, review_id, document_id, column_index)` prevents duplicate rows under concurrent generate/regenerate. Admin SPA at `/app/admin/tabular-reviews` (list / show / create + grid view with flag-tinted cells + a per-cell flag glyph and inline reasoning text, plus an `aria-label` combining summary + flag + reasoning so AT users get the same context as sighted users — R15); SSE streaming variant `POST /api/admin/tabular-reviews/{id}/generate-stream` is wired end-to-end on the BE and emits per-cell `event: cell` frames, but the v4.7 GA SPA still calls the synchronous `/generate` endpoint — the progressive-paint FE consumer ships in v4.7.x alongside the Glide Data Grid migration (ADR 0010 D1) | v4.7 GA |\n| **Workflows** (reusable prompt templates + AI-suggested catalogue) | `workflows` + `workflow_shares` + `hidden_workflows` tables; `WorkflowService` enforces ownership / share / hide semantics with per-user scope; `WorkflowSuggester` analyzes the tenant's KB (`MetadataPatternAnalyzer` detects recurring practices / projects / column patterns) and proposes up to 5 assistant + tabular workflow drafts via the LLM. 15 system-shipped templates (legal review / GDPR DPIA / DPA review / commercial agreement triage / privacy policy audit / vendor due diligence / employment policy review / regulatory mapping / risk register / litigation timeline / NDA review / IP-licensing review / consent record audit / processor-list extraction / contract-clause comparison). Admin SPA at `/app/admin/workflows` with Mine / Shared / System scope tabs + AI-suggest gallery + create dialog (**assistant type only in GA**; tabular create UI deferred to v4.7.x — tabular workflows ARE accepted by the JSON API and via the AI-suggest gallery's save-this path); email-based share model scales to invitees not yet on the platform | v4.7 GA |\n\n### Chat \u0026 Conversation\n\n| Feature | Description | Since |\n|---|---|---|\n| Vercel AI SDK v6 streaming | `MessageStreamController` emits SDK v6 `UIMessageChunk` frames (`start` / `text-start` / `text-delta` / `text-end` / `source-url` / `data` / `finish`) over SSE; first-token latency dropped from ~2.8 s synchronous to ~400 ms streaming on the Lighthouse baseline | v4.0 |\n| `useChatStream()` React hook | `mapStatusToDataState()` adapter exposes `data-state=\"idle\\|loading\\|ready\\|empty\\|error\"` for deterministic Playwright waits (SDK `submitted` and `streaming` statuses both collapse to `loading` per the R11 comment in `MessageThread.tsx`); unit-tested in `frontend/src/features/chat/map-status-to-data-state.test.ts` | v4.0 |\n| Citations panel | Every assistant reply ships the source documents (`document_id`, `title`, `source_path`, `slug`, `project_key`, `headings`, `chunks_used`); persisted on `messages.metadata.citations`; survives conversation reload | v1.0 |\n| Chat-side **Related** graph panel | A lazy, collapsible panel under each grounded answer shows the **1-hop knowledge-graph neighbours** of the cited canonical docs (both directions — dependencies AND docs that depend on the cited one), so a user can navigate the graph straight from an answer. Backed by `GET /api/kb/related` (`RelatedGraphService` walks `kb_edges`, tenant + project scoped, config-gated by `KB_GRAPH_EXPANSION_ENABLED`, no-op without a canonical graph). **ACL-safe** — a neighbour the user can't access shows its slug but never its title | v8.8 |\n| Conversation history | `conversations` + `messages` tables (user-scoped); inline rename, delete with confirmation, AI-generated title after first turn, full multi-turn history sent to provider on every request | v1.0 |\n| Anonymous (non-persisted) chat | `KB_ANONYMOUS_CHAT_ENABLED` (default **OFF**). \"New anonymous chat\" opens `/app/chat/anonymous`, which posts the stateless `POST /api/kb/chat` with `anonymous:true`: **no `conversations` / `messages` row** (in-memory only, lost on refresh) and **minimal-or-no `chat_logs`** per `CHAT_LOG_ANONYMOUS_LEVEL` (`minimal` keeps by-norm provider / model / token / latency / chunks / project fields under a fresh per-request session id and strips question / answer / sources / user_id / client_ip / user_agent; `none` writes nothing). PII is **force-masked (non-persistent) before** retrieval / LLM / log / content-gap, so the turn is *more* redacted than a normal stateless turn — every other guard (tenant, RBAC, AI-Act, R26 refusal) still applies. Off → BE **422** + a clean SPA disabled landing via `GET /api/kb/chat/anonymous-config` (R43 both-states; a probe error is surfaced, not shown as the off-state — R14) | v8.8.3 |\n| Composite confidence score (0–100) | `ConfidenceCalculator`: `0.40·mean_top_k_sim + 0.20·threshold_margin + 0.20·chunk_diversity + 0.20·citation_density`; renders as `high / moderate / low / refused` tier in the `ConfidenceBadge` | v3.0 |\n| Refusal handling | Two refusal paths: deterministic `no_relevant_context` short-circuit (Mockery `shouldNotReceive('chat')` per R26 proves no LLM call) and `llm_self_refusal` via exact-match-after-trim `__NO_GROUNDED_ANSWER__` sentinel. `RefusalNotice` uses `role=\"status\"` not `alert` (R24) | v3.0 |\n| `@mention` doc pinning | Type `@docname` in the composer → `/api/kb/documents/search` autocomplete → `MentionPopover` with cursor-context detection → pinned `doc_id` forces inclusion in retrieval even when scored below the similarity floor | v3.0 |\n| Filter chips + saved presets | Persistent `FilterBar` with per-dimension removable `FilterChip`s; tabbed `FilterPickerPopover` (Project / Type / Tag / Folder / Date / Language); per-user saved presets at `RESTful /api/chat-filter-presets` (lossless round-trip) | v3.0 |\n| Speech-to-text (Web Speech API) | Browser-native mic input via `webkitSpeechRecognition`; zero external service, zero cost; defaults to `it-IT` (configurable). Chrome / Edge / Safari supported | v1.0 |\n| Few-shot learning loop | Thumbs up/down rating on every assistant message; `FewShotService` retrieves last 3 positively-rated Q\u0026As per user/project and injects as \"Examples of Well-Rated Answers\" in the system prompt | v1.0 |\n| Smart visual artifacts | `~~~chart` JSON blocks render as Chart.js bar/line/pie/doughnut; `~~~actions` JSON renders as copy/download buttons; every code block ships a \"Copy\" button | v1.0 |\n| Multi-provider AI federation | OpenAI / Anthropic / Gemini / OpenRouter via raw `Http::` calls (no SDK); Regolo via the `padosoft/laravel-ai-regolo` SDK adapter on `laravel/ai`; `AiManager::chat()` + `chatStream()` + `embeddings()`; per-provider streaming where supported (all 5 native or via `FallbackStreaming` trait); chat and embeddings providers configured separately | v1.0 |\n| Stateless JSON chat API | `POST /api/kb/chat` synchronous endpoint kept as backward-compat fallback alongside the v4 SSE streaming path; same hybrid retrieval pipeline + refusal short-circuit + confidence score serve both | v1.0 |\n| Stop / regenerate / branch / inline-edit affordances | Vercel AI SDK UI Tier 1 closure: stop-streaming via `AbortController`; regenerate-last-assistant; branch-from-message endpoint (forks the conversation tree); inline-edit user message; copy-code-block. All wired on `MessageStreamController` + the `useChatStream()` hook | v4.5 |\n| Per-message provider/model/cost metadata | Enhanced badge below every assistant message shows `provider`, `model`, `started_at`, prompt + completion tokens, and derived USD cost when `config('ai.cost_rates')` is populated (keyed by `provider → model → {input, output}`); cost is omitted (not zero) when rates are missing. Public lookup at `GET /api/chat/cost-rates` with 1-hour CDN cache | v4.5 |\n| Suggested follow-up pills | `SuggestedFollowupGenerator` derives three follow-up prompts from the assistant's last reply via `AiManager::chat()`; renders as clickable pill chips above the composer; clicking submits via the streaming endpoint. Best-effort — provider error / parse failure / empty response returns `[]` and the row is not rendered. Triggered once on `onFinish` per assistant turn at `POST /conversations/{id}/suggested-followups` | v4.5 |\n\n### Security \u0026 Compliance\n\n| Feature | Description | Since |\n|---|---|---|\n| PII redaction at 11 persistence boundaries | `padosoft/laravel-pii-redactor` v1.2 wired at: (1) chat-message middleware, (2) embedding-cache pre-redact, (3) AI-insights snippet sanitiser, (4) operator detokenize endpoint, (5) Monolog log channel processor, (6) failed-jobs sanitiser via `JobFailed` listener with deterministic UUID match, (7) `Conversation`+`Message` `saving` observers, (8) `ChatLog::creating` observer, (9) `AdminCommandAudit::creating` observer, (10) `AdminInsightsSnapshot::creating` observer (6 JSON columns), (11) Flow `CurrentPayloadRedactorProvider` contract binding (covers run input + step results + audit + webhook outbox + approvals in one wire). All 5 v4.3 env knobs default OFF | v4.3 |\n| Multi-tenant isolation (R30 + R31) | 20 tenant-aware models carry `tenant_id` (enumerated in `tests/Architecture/TenantIdMandatoryTest::TENANT_AWARE_MODELS`); `BelongsToTenant` trait auto-fills from `TenantContext` on `creating`; composite tenant-scoped FK on `kb_edges` makes cross-tenant edges structurally impossible; architecture test `TenantIdMandatoryTest` gates new models | v4.0 |\n| `ResolveTenant` middleware + 4 resolvers | Header (`X-Tenant-ID`), domain regex, authenticated user column, or `'default'` (v3 backward compat); per-request singleton; queue workers re-bind tenant via try/finally restore | v4.0 |\n| Spatie RBAC (5 roles) | `super-admin` / `admin` / `editor` / `viewer` / `dpo` (DPO added in v4.2 for PII admin); permission matrix grouped by dotted-prefix domain; gates wired at controller + route + middleware layer | v3.0 |\n| Sanctum stateful SPA + Bearer tokens | Two transports feed the same guard: cookie-based SPA (`/sanctum/csrf-cookie` + `X-XSRF-TOKEN`) and personal access tokens for API clients / MCP / GitHub Action; `AuthenticateForSse` middleware emits JSON 401 (not HTML redirect) on streaming endpoints | v3.0 |\n| Immutable audit trail | `kb_canonical_audit` records every promote/update/deprecate/hard-delete (no `updated_at`, no FK to docs — survives hard deletes for forensic access); `admin_command_audit` stamps every destructive maintenance run with started/completed/failed timestamps + output/error capture | v3.0 |\n| DB-backed single-use confirm tokens for destructive commands | `AdminCommandNonce` table; signed `confirm_token` issued at preview, consumed inside `DB::transaction` with `lockForUpdate()` + `update()` in the same closure (R21 atomic invariant); composite UNIQUE on `(token_hash, consumed_at)` | v3.0 |\n| 6-gate Artisan whitelist runner | `CommandRunnerService` enforces: (1) whitelist lookup in `config('admin.allowed_commands')`, (2) args_schema validation, (3) confirm_token + single-use nonce, (4) Spatie permission gate (`commands.run` admin / `commands.destructive` super-admin), (5) audit-before-execute, (6) per-user `throttle:10,1` rate limit | v3.0 |\n| 2FA stub | `TwoFactorController` skeleton behind `AUTH_2FA_ENABLED=false` for future TOTP rollout | v3.0 |\n| Operator detokenize endpoint | `POST /api/admin/logs/chat/{id}/detokenize` round-trips a tokenised chat-log row back to original PII text; 422 when strategy ≠ `tokenise`; 403 when caller lacks `kb.pii_redactor.detokenize_permission` (default `pii.detokenize`); every 200/403 writes `admin_command_audit` row | v4.1 |\n| GDPR-aware soft delete + retention | `KB_SOFT_DELETE_ENABLED=true` (default); `KB_SOFT_DELETE_RETENTION_DAYS` (default 30); `kb:prune-deleted` (03:30 daily) hard-deletes file on disk + chunks + audit-trails the deprecation | v3.0 |\n| CSRF + CORS hardening | `SANCTUM_STATEFUL_DOMAINS` + `CORS_ALLOWED_ORIGINS`; wildcard `*` forbidden because `supports_credentials=true`; whitelist-driven origin parsing with whitespace-safe CSV (R19) | v3.0 |\n\n### Admin \u0026 Operations\n\n| Feature | Description | Since |\n|---|---|---|\n| Admin SPA shell (`/app/admin/*`) | React 18+ (React 19 since v4.3) + TypeScript + Vite + TanStack Router/Query + shadcn/ui; dark-first glassmorphism; code-split routes (~400 KB initial gzipped); RBAC-gated via Spatie; sidebar visibility enforced server-side. **Since v8.8.2 a single unified, grouped + collapsible sidebar** (`nav-config.ts` SSOT — 23 sections in 5 groups) replaces the old primary-rail + secondary-`AdminShell`-rail double menu, and every admin surface now renders **center-only with no nested second admin shell** (cross-mounted sister-package admins drop their own sidebar/header into an in-content tab strip; the Flow surface launches its cockpit in a new tab) — so the host's unified rail is the only menu on any `/app/admin/*` page | v3.0 · v8.8.2 |\n| Dashboard KPIs + health | 6 KPIs (docs / chunks / chats / p95 latency / cache hit rate / canonical coverage) + 6 health probes (db / pgvector / queue / kb-disk / embeddings / chat) + 3 code-split recharts cards (chat volume area, token burn stacked, rating donut) + top projects + activity feed; 30s `Cache::remember` layer keyed by kind+project+days | v3.0 |\n| Users + Roles + Memberships | Filterable users table with soft-delete + restore; 3-tab edit drawer (Details / Roles / Memberships with `scope_allowlist` JSON editor); Spatie-backed role CRUD with grouped permission matrix; `project_memberships` rows scope canonical visibility per project | v3.0 |\n| KB Explorer (tree + 5 right-panel tabs) | Memory-safe `chunkById(100)` tree walker with canonical-aware modes (`canonical \\| raw \\| all`, `with_trashed=0\\|1`); right-panel tabs Preview (remark-rendered + frontmatter pills) / Meta (canonical grid + AI tags) / **Source** (CodeMirror 6 editor with PATCH `/raw` → validate → write → audit → re-ingest) / **Graph** (1-hop tenant-scoped subgraph, SVG radial, ≤ 50 nodes) / **History** (paginated `kb_canonical_audit`) | v3.0 |\n| PDF export (Browsershot + Dompdf fallback) | `PdfRenderer` interface with `BrowsershotPdfRenderer` primary (full CSS / fonts / charts) and `DompdfPdfRenderer` fallback (no headless Chromium dependency); A4 print-optimised; renderer chosen at controller level (R23 registry mutex) | v3.0 |\n| Log viewer (5 tabs) | Five deep-linkable tabs (`?tab=chat\\|audit\\|app\\|activity\\|failed`): chat logs with model/project/rating filters; canonical audit trail with event-type/actor filters; reverse-seek `SplFileObject`-powered application log tailer (whitelist regex, 2000-line cap, optional live polling via `?live=1`); Spatie activity log; failed-jobs read-only table | v3.0 |\n| Maintenance command runner | Three-step React wizard (Preview → Confirm with type-in for destructive → Run → Result); whitelist + args_schema + confirm_token + Spatie gates + audit + throttle (see Security row); scheduler widget reports next run of every queued command | v3.0 |\n| AI insights panel | Daily `insights:compute` (05:00 UTC) writes one row to `admin_insights_snapshots`; six widget cards (Promotion Suggestions / Orphan Docs / Suggested Tags / Coverage Gaps / Stale Docs / Quality Report) read from JSON columns; O(1) DB read, zero LLM calls per page load | v3.0 |\n| Per-user notification feed (bell + panel + API) | Top-bar `\u003cNotificationBell /\u003e` polls `/api/notifications/unread-count` every 30s (R11 `data-state` + `aria-busy`); `/app/admin/notifications` full panel with `unread\\|read\\|dismissed\\|all` tabs, BE-derived event-type filter (R18 — `GET /api/notifications/event-types`), pagination, per-row mark-read/dismiss, bulk mark-all-read scoped to the active filter; HMAC-signed one-click email unsubscribe; channels (`in_app`, `email`) ship as part of v8.0/W1.3, joined by **W2.1** external channels `discord` + `slack` + `teams` + generic `webhook` (all default-OFF — opt in by setting the corresponding `NOTIFICATIONS_DISCORD_URL` / `NOTIFICATIONS_SLACK_URL` / `NOTIFICATIONS_TEAMS_URL` / `NOTIFICATIONS_WEBHOOK_URL` env var; the generic webhook channel additionally signs every request with `X-AskMyDocs-Signature: sha256=\u003chmac\u003e` when `NOTIFICATIONS_WEBHOOK_SECRET` is set). External-channel sends route through the queueable `SendExternalNotificationJob` with `[5, 30, 120]s` backoff (R14 — terminal failure recorded on the row's `channel_dispatch_log`); 4xx responses (except 429) are surfaced as `failed` immediately without retry. Per-user `notification_preferences` matrix wired in v8.0/W2; daily `notifications:prune` 04:10 retains rows for `NOTIFICATIONS_RETENTION_DAYS` (default 90, set 0 to disable) — see env block below. R21 atomic mark-read + dismiss (`whereNull('read_at')-\u003eupdate(...)` + COALESCE); R30 cross-tenant isolation enforced on every endpoint including mutations; presenter strips forensic `channel_dispatch_log` + `tenant_id` + `user_id` from the FE feed. | v8.0 |\n| Stale-doc review + weekly digest (KB lifecycle) | `kb:stale-review-sweep` (daily) fires a `kb_doc_stale_review` notification for any document untouched longer than `KB_HEALTH_STALE_REVIEW_MONTHS` (default 6, set 0 to disable) — time-based, every doc type, ACL-scoped to eligible reviewers, idempotent per content version via a `metadata.stale_review_notified_at` marker. `notifications:digest-weekly` (Monday) aggregates the week's `notification_events` per tenant into a `notification_digests` row and emails each email-opted-in user their OWN roundup (`WeeklyDigestMail`), stamping `sent_at` + `recipients_count` — so a user can keep noisy per-event email OFF and still get the Monday digest. Both slots are env-tunable (`SCHEDULE_KB_STALE_REVIEW_SWEEP_*` / `SCHEDULE_NOTIFICATIONS_DIGEST_WEEKLY_*`). | v8.7 |\n| Cross-mounted admin SPAs (3 packages) | `padosoft/laravel-pii-redactor-admin` v1.0.2 at `/admin/pii-redactor` (cross-mount since v4.4/W2) + `padosoft/laravel-flow-admin` v1.0.0 at `/admin/flows` + `padosoft/eval-harness-ui` v1.0.0 at `/admin/eval-harness` non-prod-only (cross-mount since v4.4/W3, 3 fail-closed fences preserved). **Since v8.8.2 each package admin mounts center-only with no nested chrome (the host unified rail is the only menu):** the PII and Eval trees cross-mount their React panels directly; the Flow surface renders a native host panel (KPI probe of `/admin/flows/api/live` + section cards) that links out to the full Flow cockpit in a new tab (`target=\"_blank\"`) — so no Blade+Alpine page is ever nested inside the host chrome. **This new-tab launcher supersedes ADR 0005's \"flow-admin stays iframe-mounted\" assumption** (the cockpit itself remains Blade+Alpine; only the host-side mounting changed) | v4.2 · v8.8.2 |\n| Laravel scheduler (13+ entries) | `kb:prune-embedding-cache` 03:10 / `chat-log:prune` 03:20 / `kb:prune-deleted` 03:30 / `kb:rebuild-graph` 03:40 / `queue:prune-failed` 04:00 / **`notifications:prune` 04:10 (v8.0/W1.5, default 90d retention via `NOTIFICATIONS_RETENTION_DAYS`; set 0 to disable)** / `admin-audit:prune` 04:30 / `kb:prune-orphan-files` 04:40 / `admin-nonces:prune` 04:50 / `insights:compute` 05:00 / `eval:nightly` 05:30 (v4.3+, default OFF) / **`kb:stale-review-sweep` 03:55 + `notifications:digest-weekly` Mon 07:00 (v8.7/W2)**; all `onOneServer()-\u003ewithoutOverlapping()`. **v8.0/W2.4 — every slot's cron + enabled flag is now env-tunable** via the 24 `SCHEDULE_*_CRON` / `SCHEDULE_*_ENABLED` knobs (see `.env.example` Tier-1 scheduler section); defaults preserve the overnight rotation above byte-for-byte. The `GET /api/admin/commands/scheduler-status` widget surfaces the effective cron times after env overrides. | v3.0 |\n| Sidebar gating + R29 testid hierarchy | Sidebar entries always rendered, visibility enforced server-side via per-route fences (RequireRole + middleware `can:` + env `abort(404)`); every actionable element uses `feature-resource-{id}-{action[-substep]}` testid convention for Playwright stability | v3.0 |\n| Connector admin SPA (`/app/admin/connectors`) | React DataTable with per-connector install/uninstall flow; OAuth callback handler at `/app/admin/connectors/$key/callback`; per-installation `connector_installations` + `connector_credentials` rows (encrypted via `OAuthCredentialVault`); scheduler-driven `ConnectorSyncJob`; Spatie `manageConnectors` super-admin gate at controller + route layer | v4.5 |\n| Widget admin SPA (`/app/admin/widget`) | Manage the KITT embeddable widget: key CRUD + rotate (`pk_`/`sk_` returned once) + revoke, allowed-origins editor, theme designer (validated + sanitised), per-key `host_tools_enabled` toggle, copy-ready embed snippet, and a read-only sessions browser with PII-masked step replay. Key management is `manageWidgetKeys` (super-admin); session inspection is `viewWidgetSessions` (admin + super-admin); everything tenant-scoped. Sessions + steps pruned by `widget:prune-sessions` (daily, `WIDGET_SESSION_RETENTION_DAYS` default 90) which also prunes expired session tokens | v8.10 |\n\n### Integrations \u0026 Extensibility\n\n| Feature | Description | Since |\n|---|---|---|\n| MCP server (inward, 10 tools) | `enterprise-kb` server at `/mcp/kb` exposes the KB to Claude Desktop / Claude Code / any MCP-compatible agent (5 retrieval + 5 canonical/promote tools); `auth:sanctum` + `throttle:api` | v3.0 |\n| GitHub composite action `ingest-to-askmydocs` (v2) | Reusable action with diff-mode (every push: `git diff --diff-filter=AMR` ingest + `D`+`R` delete batches via `DELETE /api/kb/documents`) and full-sync mode; canonical-folder aware; max 100 docs / batch; `--rawfile` for ARG_MAX safety (R5) | v3.0 |\n| 9 registered Flow definitions (saga / compensation) | `kb.ingest` (5-step) / `kb.canonical-index` (3-step) / `kb.promote` (4-step approval-gated, first use of `approval-gate` primitive) / `kb.delete` (4-step) / `kb.prune-deleted` / `kb.prune-embedding-cache` (conditional approval gate) / `kb.prune-chat-logs` / `kb.rebuild-graph` / `kb.ingest-folder` (3-step fan-out). Reverse-order compensation chains; persisted to `flow_runs` + `flow_steps` + `flow_audit` + `flow_approvals` + `flow_webhook_outbox` | v4.2 |\n| Multi-AI-provider abstraction | OpenAI / Anthropic / Gemini / OpenRouter via raw `Http::` (no SDK); Regolo via the `padosoft/laravel-ai-regolo` SDK adapter on `laravel/ai`; `FallbackStreaming` trait synthesises single-chunk SSE for providers without native streaming | v1.0 |\n| Pluggable ingestion pipeline | 3 contracts (`ConverterInterface` / `ChunkerInterface` / `EnricherInterface`); `PipelineRegistry` with FQCN-validated-at-boot + `supports()` mutex (R23); add a new format = implement 3 interfaces + register in `config/kb-pipeline.php` | v3.0 |\n| Pluggable chat-log driver | `ChatLogDriverInterface`; `database` driver shipped; BigQuery / CloudWatch are extension points via `ChatLogManager::resolveDriver()` | v1.0 |\n| Sister `padosoft/*` package stack | `laravel-ai-regolo` v1.0 (Regolo provider for `laravel/ai`) + `laravel-pii-redactor` v1.2 (PII detection with EU country packs: Italy + Germany + Spain) + `laravel-pii-redactor-admin` v1.0.2 + `laravel-flow` v1.0 (saga engine + approval gates + webhook outbox + replay) + `laravel-flow-admin` v1.0.0 + `eval-harness` v1.2 (golden datasets + 7 metrics + cohorts + adversarial + LLM-as-judge) + `eval-harness-ui` v1.0.0 — every package MIT, every architecture test enforces standalone-agnostic invariants (zero refs to `KnowledgeDocument` / `kb_*` tables / `lopadova/askmydocs` in `src/`) | v4.2 |\n| External Patent Box dossier tool | `padosoft/laravel-patent-box-tracker` v0.1 generates audit-grade Italian Patent Box dossiers; **deliberately NOT in AskMyDocs `composer.json`** — operators install it in a separate Laravel project (R37 standalone-agnostic) and consume `tools/patent-box/2026.yml` from this repo. Commercialista-validated 2026-05-02 | v4.0 |\n| Connector framework + 7 native connectors | Plugin/package architecture (`ConnectorInterface` 10-method contract + `BaseConnector` + `OAuthCredentialVault` + `ConnectorRegistry` with R23 FQCN-validated discovery via `config/connectors.php::built_in` OR `composer.json::extra.askmydocs.connectors`). 7 native connectors: `google-drive` + `notion` + `evernote` + `fabric` + `onedrive` + `confluence` + `jira` (all inline for v4.5; extracted to `padosoft/askmydocs-connector-*` packages in v4.6 per ADR 0008 D1) | v4.5 |\n| **MCP client framework** | AskMyDocs as MCP **CLIENT** (outward direction) — tenant-scoped `McpServerRegistry` + `McpToolCallingService` orchestrates multi-turn tool-calling loops (max 3 iterations, configurable); `McpToolAuthorizer` gates per-user/per-server/per-tool access; v7.0/W6.3.B retired the v5.0 Node sidecar and now drives JSON-RPC directly over native HTTP / SSE / stdio transports via `padosoft/askmydocs-mcp-pack`; `McpHandshakeService` persists initialize+tools/list under `mcp_servers.handshake_response_json`; immutable audit trail in `mcp_tool_call_audit` (with `transport_error` status when the upstream connection is unreachable but not timing out); admin API for server CRUD + handshake + tool-list management; `AI_AGENTIC_ENABLED` master switch; OpenAI + OpenRouter providers wire tool schemas automatically | v5.0 |\n| **MCP admin web panel** (optional companion) | Standalone Laravel package `padosoft/askmydocs-mcp-pack-admin` ships a React SPA that cross-mounts under `/admin/mcp-pack` and surfaces every MCP-side capability above through 12 routes (Dashboard, Servers list + new-server wizard, per-server detail with 7 tabs, Tools matrix + try-it, Resources tree, Prompts playground, Audit log + drilldown, Circuit breakers, OpenAPI explorer, Settings, Help). **v1.1.0** (shipped 2026-05-18) drives the full live `padosoft/askmydocs-mcp-pack` v1.5+ REST surface end-to-end — 22 typed endpoints, 23 TanStack Query hooks across read+write paths, R21 two-call confirm-token protocol on tool invoke / audit replay / breaker reset, SSE live-feed consumer, 154 Vitest specs covering every binding. Composer-discoverable, RBAC-gated, dark+light themed — see [Optional: mount the MCP admin web panel](#optional-mount-the-mcp-admin-web-panel) | v7.0 |\n| **KITT embeddable agentic widget** | A one-`\u003cscript\u003e` embeddable, page-aware, agentic chat widget served at `/widget/askmydocs-widget.js` and driven by `/api/widget/*` (gated by the `widget.key` middleware — public `pk_` + `Origin` allowlist, `sk_` secret for server-to-server, or single-use origin-bound `wt_` session tokens consumed atomically per R21). Runs the first-party retrieval stack (grounded + cited, tenant/project resolved server-side from the key — R30) inside a bounded ReAct loop: the widget captures a structured page snapshot and the LLM emits tool calls run in the page DOM (~20 FE verbs: `click`/`type`/`select`/`navigate_to`/`submit_form`/`wait_for`/…) or server-side via `/exec-tool` (`search_knowledge_base`). **Skills** are JSON manifests (`resources/widget/skills/*`) declaring `tools_enabled` + `auto_annotation_rules` + `default_policies`; the **Host-Tools Protocol** lets a host app expose its own tools, double-gated per key (`host_tools_enabled`) **and** per skill. Pages annotate with stable verb-based `data-kitt-*` attributes; `data-kitt-sensitive`/`password`/`hidden` values are force-nulled server-side. Tool schemas are sent only to providers in `config('widget.tool_calling_providers')` (default `openai,openrouter,fake`); otherwise it degrades to plain grounded chat. See [`docs/kitt/INTEGRATION.md`](docs/kitt/INTEGRATION.md) | v8.10 |\n\n### Quality \u0026 Observability\n\n| Feature | Description | Since |\n|---|---|---|\n| RAG regression CI gate | `.github/workflows/rag-regression.yml` triggers on every PR touching `app/Services/Kb/**` / `app/Ai/**` / `app/Eval/**` / `tests/Eval/golden/**` / `composer.lock`. Drives the golden Q\u0026A set through the LIVE `KbSearchService` + `GraphExpander` + `RejectedApproachInjector` + `AiManager::chat()` against the seeded `DemoSeeder` corpus; fails the build on regression; 14-day artifact retention | v4.2 |\n| 4 eval datasets × per-lane metric stacks | 1 baseline (42 samples, 4 metrics: `contains` + `cosine-embedding` + custom `CosineGroundednessMetric` + custom `CitationGroundednessMetric`) + 3 adversarial cohorts (12 samples each: out-of-corpus refusal / contradicting claims / rejected-approach trigger — 3 metrics: `contains` + `refusal-quality` + `CitationGroundednessMetric`). Cohorts: `source_type × canonical_type × language × query_complexity` | v4.2 |\n| Custom RAG-specific metrics | `CosineGroundednessMetric` (cosine of answer-vs-cited-chunk-text — catches \"fluent answer that doesn't track its own citations\") and `CitationGroundednessMetric` (every expected `source_path` must appear; phantom citations cap score at 0.5; refusal-with-citations drops to 0) | v4.2 |\n| `eval:nightly` cron with LLM-as-judge | Default-OFF via `EVAL_NIGHTLY_ENABLED`; three-fence cost guard (enable flag + `EVAL_NIGHTLY_LIVE` provider-key check + key presence check inside the command); R26 defense-in-depth test pre-seeds both flags + asserts `Http::assertNothingSent()`; persisted `\u003cdate\u003e.json` + `\u003cdate\u003e.md` artefacts; regression detection vs prior baseline; `Log::alert()` + `\u003cdate\u003e.alert.json` sidecar on regression \u003e `EVAL_NIGHTLY_REGRESSION_THRESHOLD` (default 0.05); auto-prunes beyond `EVAL_NIGHTLY_RETENTION_DAYS` (default 90); 3 ops flags (`--dry-run` / `--status` / `--prune-only`). ADR 0006 | v4.3 |\n| Adversarial nightly opt-in | 2 env knobs (`EVAL_NIGHTLY_ADVERSARIAL` / `EVAL_NIGHTLY_ADVERSARIAL_DATASETS`) default OFF; runs the 3 adversarial datasets after baseline SUCCESS using the `nightly` batch profile; advisory-only summary sidecar; baseline-gates-adversarial alerting policy. ADR 0007 | v4.4 |\n| Regression-detection self-test | `RegressionDetectionTest` proves the gate ACTUALLY catches regressions: runs the metric stack against a canonical SUT (asserts green report) then against a hallucinating SUT (asserts `citation-groundedness-strict` mean AND macro_f1 drop, strict `\u003e` comparison per R16) | v4.2 |\n| Playwright E2E suite | Real Postgres + pgvector in CI; deterministic via `data-state` + `data-testid` contract (R11); happy-path + failure-injection per feature (R12); real data only — `page.route()` reserved for external boundaries (R13) gated by `scripts/verify-e2e-real-data.sh` | v3.0 |\n| Test inventory | **~1695 PHPUnit tests** across PHP 8.3 / 8.4 / 8.5 + **408 Vitest react scenarios** + **18 Vitest legacy** + 39 Playwright spec files + RAG regression workflow — all green as of v5.0.0 GA | v5.0 |\n| Opt-in live-test recording infrastructure | `tests/Live/Connectors/` skeleton + `LiveConnectorTestCase` per-provider env-var guard: each test gates on `CONNECTOR_\u003cPROVIDER\u003e_LIVE=1` (e.g. `CONNECTOR_NOTION_LIVE=1`) and needs the provider credential vars (e.g. `CONNECTOR_NOTION_TOKEN`, `CONNECTOR_CONFLUENCE_TOKEN`+`CONNECTOR_CONFLUENCE_CLOUD_ID`); fixture recording is enabled via `CONNECTOR_RECORD_FIXTURES=1`. Default CI runs `Unit` + `Feature` only (zero provider cost). Manual workflow `.github/workflows/live-recording-nightly.yml` available via `workflow_dispatch`. Junior-proof per-provider setup in [`docs/v4-platform/RUNBOOK-live-fixture-recording.md`](docs/v4-platform/RUNBOOK-live-fixture-recording.md) | v4.5 |\n| Structured chat logging | DB driver (extensible to BigQuery / CloudWatch); `session_id` / `user_id` / `question` / `answer` / `project_key` / `ai_provider` / `ai_model` / `chunks_count` / `sources` / `prompt_tokens` / `completion_tokens` / `total_tokens` / `latency_ms` / `client_ip` / `user_agent` / `extra` columns; try/catch — never propagates failures | v1.0 |\n| 40 codified review rules (R1–R43; R33–R35 reserved) | Distilled from live Copilot findings — R14–R21 alone from ~110 findings catalogued at PR #16 across PRs #16–#31 (`docs/enhancement-plan/COPILOT-FINDINGS.md`), with earlier and later rules appended over the project's PRs; mirrored in `CLAUDE.md` + `.github/copilot-instructions.md` + per-rule `.claude/skills/\u003crule\u003e/`; auto-loaded by Claude Code when trigger conditions match; pre-push agent at `.claude/agents/copilot-review-anticipator.md`. The set grows over time — started at v3.0 (R1–R29); R42/R43 were added in v8.8.1/v8.8.2 | v3.0 · v8.8.2 |\n| ADR set (ADR 0001 → 0010) | Architectural decisions records: 0001 ingestion path, 0002 storage agnostic, 0003 human-gated promotion, 0004 v4.2 sister-package integration, 0005 React 19 host bump + iframe→cross-mount deferral, 0006 nightly eval cron, 0007 adversarial nightly opt-in, 0008 v4.5 universal connectors + source-aware ingestion + modern chat surface, 0009 v4.6 connector package extraction, 0010 v4.7 tabular review + workflows architecture | v3.0 |\n| Retrieval-quality benchmark (`kb:benchmark`) | A 5-doc labelled corpus (markdown + PDF + DOCX, graph-linked + rejected-approach) under `resources/benchmark/` + 14 gold queries scored on **nDCG@k / MRR / precision@k / citation-precision / graph-recall / rejected-recall / refusal-accuracy** via `RetrievalQualityMetrics`. `--stub` runs anywhere (SQLite + PHP-cosine, no key); LIVE uses real embeddings + pgvector. Dated JSON+MD scorecards in `storage/app/kb-benchmark/`. The deterministic `RetrievalPipelineScenarioTest` runs the FULL pipeline (ingest → per-type chunk → embed → graph → search → citations → refusal) in CI with **no mocks** — closing the gap that let search bugs ship green | v8.2 |\n\n---\n\n#### Running the retrieval-quality benchmark\n\nThe benchmark measures the *real* quality of search / vector / rerank /\ncitations / graph / rejected-injection / refusal end-to-end, and produces a\ndated scorecard you can re-run after any retrieval change (or at a milestone\nclose) to catch regressions.\n\n**1. Deterministic (no key, runs anywhere — CI-safe):**\n\n```bash\nphp artisan kb:benchmark --stub\n# SQLite + PHP-cosine + a deterministic embedder. Exercises the full pipeline\n# wiring + lexical ranking. (Also runs as a PHPUnit feature test:\n# vendor/bin/phpunit tests/Feature/Benchmark/)\n```\n\n**2. LIVE (real embeddings + LLM — true semantic quality):**\n\n```bash\n# a) Postgres + pgvector. A throwaway durable container (host port 5433,\n#    leaves your local PostgreSQL untouched):\ndocker run -d --name askmydocs-pgvector --restart unless-stopped \\\n  -e POSTGRES_DB=askmydocs -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=aaaa \\\n  -p 5433:5432 -v askmydocs-pgvector-data:/var/lib/postgresql/data \\\n  pgvector/pgvector:pg16\n# later just: docker start askmydocs-pgvector  /  docker stop askmydocs-pgvector\n\n# b) Point the app at it + an embeddings provider, then migrate + run:\nDB_PORT=5433 php artisan migrate --force\nDB_PORT=5433 php artisan kb:benchmark        # uses AI_EMBEDDINGS_PROVIDER (e.g. openrouter)\n```\n\n`.env` for LIVE: `AI_EMBEDDINGS_PROVIDER=openrouter` (or `openai`) with the\nkey set — **Anthropic has no embeddings API**, so it can drive chat\n(`AI_PROVIDER`) but not the vector side. `text-embedding-3-small` is 1536-dim\n= the stock pgvector column (no migration).\n\n**3. Answer faithfulness (real LLM answers — v8.3):**\n\n```bash\n# Adds answer-faithfulness to the scorecard: per answerable query it\n# generates the REAL chat answer (same kb_rag prompt the app uses) and\n# scores cosine(answer, grounding-text) — catching a fluent answer that\n# drifts from its own grounding.\nDB_PORT=5433 php artisan kb:benchmark --with-answers\n```\n\n`--with-answers` makes LIVE chat **and** embeddings calls (even under\n`--stub`, which only stubs the *retrieval* ranking) — it needs a configured\nchat + embeddings provider; the command warns early if the chat provider has\nno key. Faithfulness embeddings bypass `embedding_cache` so a benchmark never\nmutates production cache state.\n\n**Reading the scorecard.** The command prints a per-query table + an\naggregate block and writes `storage/app/kb-benchmark/\u003ctimestamp\u003e.{json,md}`.\nEnterprise pass thresholds (gate with `--gate`, exit non-zero on miss):\n`nDCG@5 ≥ 0.80`, `MRR ≥ 0.85`, `citation-precision ≥ 0.90`,\n`refusal-accuracy ≥ 0.95` (tunable via `kb.benchmark.*`). When\n`--with-answers` ran, an `answer-faithful.` line is added.\n\n**Validating faithfulness with the eval-harness (live LLM-as-judge).** The\nbenchmark scores faithfulness with embedding cosine; for an independent\njudge-graded read, the `eval:nightly` cron runs the golden Q\u0026A\n(`tests/Eval/golden/`) through the real RAG pipeline and the\n`padosoft/eval-harness` LLM-as-judge + groundedness metrics. To run it LIVE\nagainst a real model (otherwise it uses a deterministic fake):\n\n```bash\n# Point the judge + embeddings metrics at any OpenAI-compatible endpoint\n# (OpenRouter shown) and flip the three live gates:\nEVAL_LIVE_AI=1 EVAL_NIGHTLY_ENABLED=true EVAL_NIGHTLY_LIVE=true \\\nEVAL_HARNESS_JUDGE_ENDPOINT=https://openrouter.ai/api/v1/chat/completions \\\nEVAL_HARNESS_JUDGE_MODEL=openai/gpt-4o-mini EVAL_HARNESS_JUDGE_API_KEY=$OPENROUTER_API_KEY \\\nEVAL_HARNESS_EMBEDDINGS_ENDPOINT=https://openrouter.ai/api/v1/embeddings \\\nEVAL_HARNESS_EMBEDDINGS_MODEL=openai/text-embedding-3-small \\\nEVAL_HARNESS_EMBEDDINGS_API_KEY=$OPENROUTER_API_KEY \\\nDB_PORT=5433 php artisan eval:nightly\n# Reports land in storage/app/eval-harness/nightly/\u003cdate\u003e.{json,md}.\n```\n\nA live run on the seeded corpus scores **citation-groundedness ≈ 0.98** and\ncosine-groundedness ≈ 0.62 (p95 1.0) — the answers track their citations.\n(The `contains` metric reads ~0 by design: it is a verbatim-substring check,\nand a real LLM paraphrases rather than echoing the gold string — that is what\nthe cosine + judge metrics exist to measure.)\n\n**Seeing compliance + PII live in your own runs.** The data-mutating\nobservability features (chat logging + PII redaction) ship **default-OFF** for\nproduction safety; the AI Act disclosure header (`X-AI-Disclosure`, Art. 50)\nand token-level explainability are **on by default** (they add no data\nmutation). To watch the opt-in ones fire locally, flip the relevant flags in\n`.env` (mask strategy needs no salt):\n`CHAT_LOG_ENABLED=true`, `KB_PII_REDACTOR_ENABLED=true` +\n`KB_PII_REDACT_PERSIST=true` + `KB_PII_REDACT_ANSWERS=true` +\n`PII_REDACTOR_ENABLED=true` + `PII_REDACTOR_STRATEGY=mask`. The consolidated\n`KbChatFullStackComplianceTest` proves one\nchat turn fires grounded citations + the disclosure header + a `chat_logs` row\n+ PII answer-redaction together.\n\n**Milestone ritual.** Run `php artisan kb:benchmark --stub` (deterministic)\nat the close of any retrieval-touching milestone, and the LIVE run before\nshipping a retrieval change — if a knob (rerank weights,\n`KB_RERANK_NORMALIZE_SCORES`, `kb.refusal.*`, `kb.mentions.mode`,\n`kb.diversification.*`) moves the scorecard, you'll see it.\n\n---\n\n## Canonical Knowledge Compilation\n\nAskMyDocs's signature differentiator: every retrieved chunk passes through a\n**typed canonical knowledge graph** with **human-gated promotion**. The LLM\nproposes; only humans (or operators via `kb:promote`) commit canonical storage.\n\nThe three-stage promotion API is the architectural boundary between \"AI\ndrafting\" and \"knowledge canon\":\n\n| Stage | Route | Effect |\n|---|---|---|\n| **Suggest** | `POST /api/kb/promotion/suggest` | LLM extracts candidate artefacts from a transcript via `PromotionSuggestService`. **Writes nothing.** |\n| **Validate** | `POST /api/kb/promotion/candidates` | Validates a markdown draft against `CanonicalParser` (9 canonical types / 6 statuses / YAML frontmatter). Returns `{valid, errors}`. **Writes nothing.** |\n| **Promote** | `POST /api/kb/promotion/promote` | `CanonicalWriter` writes markdown to KB disk + dispatches `IngestDocumentJob`. HTTP 202. **Only this stage commits canonical storage.** |\n\nClaude skills + the `suggest` / `candidates` endpoints stop at the validation\nboundary. Only humans (via git push → GitHub Action → ingest) and operators\n(via `kb:promote` CLI) commit canonical storage. Every promotion writes an\nimmutable `kb_canonical_audit` row — promotion is forever traceable.\n\nSee **ADR 0003** for the architectural decision rationale + the\n**Retrieval \u0026 Knowledge** features table above for the surrounding canonical\ninfrastructure (typed parser, knowledge graph, rejected-approach injection).\n\n---\n\n## Quick start (5 minutes)\n\n### Prerequisites\n\n- **PHP** `\u003e= 8.3`\n- **Composer** `2.x`\n- **PostgreSQL** `\u003e= 15` with the **pgvector** extension\n- **Node.js** `\u003e= 20` (Vite SPA build)\n- **npm** (bundled with Node)\n\nFastest PostgreSQL + pgvector setup:\n\n```bash\ndocker run -d --name askmydocs-pg \\\n    -e POSTGRES_USER=askmydocs \\\n    -e POSTGRES_PASSWORD=askmydocs \\\n    -e POSTGRES_DB=askmydocs \\\n    -p 5432:5432 \\\n    pgvector/pgvector:pg16\n```\n\n### Clone → working SPA\n\n```bash\n# 1. Clone + install\ngit clone https://github.com/lopadova/AskMyDocs.git\ncd AskMyDocs\ncomposer install\nnpm ci \u0026\u0026 npm run build\n\n# 2. Configure\ncp .env.example .env\nphp artisan key:generate\n# Edit .env: DB_*, AI_PROVIDER, OPENROUTER_API_KEY (or OPENAI_API_KEY)\n\n# 3. Migrate + seed\nphp artisan migrate\nphp artisan db:seed --class=RbacSeeder      # 5 roles + permission matrix\nphp artisan db:seed --class=DemoSeeder      # 3 demo accounts + canonical KB\n\n# 4. Run\nphp artisan serve\n```\n\nOpen `http://localhost:8000` and log in as `super@demo.local` /\n`password` (DemoSeeder creates the account with the `super-admin` role).\nThe SPA redirects to `/app/chat`; click **Dashboard** in the sidebar\nto land on `/app/admin`.\n\n### Full configuration reference\n\nEvery environment variable is documented inline in\n[`.env.example`](.env.example). Sister-package configs live in\n[`config/ai.php`](config/ai.php), [`config/kb.php`](config/kb.php),\n[`config/kb-pipeline.php`](config/kb-pipeline.php),\n[`config/chat-log.php`](config/chat-log.php),\n[`config/admin.php`](config/admin.php),\n[`config/laravel-flow.php`](config/laravel-flow.php),\n[`config/eval-harness.php`](config/eval-harness.php).\n\n### Optional: mount the MCP admin web panel\n\nThe MCP client framework (v5.0+) is exposed through the parent host\nadmin under `/app/admin/mcp-tools` with a server-list page and chat-time\ntool-call UI. For a richer single-pane-of-glass view dedicated to the\nMCP fleet (12 routes — fleet table, three-step new-server wizard,\nseven-tab per-server detail, tools matrix + try-it, three-pane resource\ntree, prompt playground, audit drilldown, circuit-breaker grid,\nOpenAPI explorer, settings + tour), install the standalone companion\npackage:\n\n```bash\ncomposer require padosoft/askmydocs-mcp-pack-admin:^1.1\n# Service provider auto-discovers via composer.json::extra.laravel.providers\n# Pre-built SPA bundle ships inside vendor/padosoft/askmydocs-mcp-pack-admin/public/vendor/mcp-pack-admin/\n# Publish the assets so Laravel can serve them at /vendor/mcp-pack-admin/:\nphp artisan vendor:publish --tag=mcp-pack-admin-assets --force\n# Optionally override the mount prefix (default: /admin/mcp-pack)\nphp artisan vendor:publish --tag=mcp-pack-admin-config\n```\n\nThen sign in as a `super-admin` and open\n`http://localhost:8000/admin/mcp-pack`.\n\n\u003e **Status note (v1.1.0 GA — shipped 2026-05-18):** the panel drives\n\u003e the full live `padosoft/askmydocs-mcp-pack` v1.5+ REST surface\n\u003e end-to-end: 22 typed endpoints, 23 TanStack Query hooks across\n\u003e read+write paths, R21 two-call confirm-token protocol on tool\n\u003e invoke / audit replay / breaker reset, SSE live-feed consumer\n\u003e replacing the prototype simulator. 154 Vitest specs cover every\n\u003e endpoint binding with loading / error / empty / ready states +\n\u003e R21 happy + failure paths + ValidationError surfacing + SSE\n\u003e consumer behaviour via MSW handlers shaped to the real wire\n\u003e schema. AskMyDocs v7.1+ requires `padosoft/askmydocs-mcp-pack:^1.5`\n\u003e (auto-resolved by composer) so all 22 admin endpoints answer the\n\u003e SPA out of the box.\n\n### Enabling AI Act compliance features (junior-proof)\n\nThe v6.0 GA wires AskMyDocs as **the first Laravel platform AI-Act-ready\nout of the box** — the 9 baseline compliance modules (Disclosure, DSAR,\nRisk Register, Bias Monitoring, Human Review Tracker, Incident, Consent,\nCybersecurity, Attestation) ship configured and active. The v6.1 catch-up\nadds four additional capabilities that **default OFF** so existing\ninstalls see no behavioural change. Turn them on in this order — each\nsection is independently optional.\n\n#### 1. Pluggable bias-metric registry (v1.2 — already active)\n\nThree reference metrics ship and auto-register:\n\n```env\nAI_ACT_BIAS_DEFAULT_METRIC=demographic_parity   # alternatives: equalized_odds | calibration\nAI_ACT_BIAS_DISPARITY_THRESHOLD=0.05            # drift alert threshold (0..1)\n```\n\nSwitch the active metric on the chat path:\n\n```php\napp(\\Padosoft\\AiActCompliance\\BiasMonitoring\\Services\\BiasMonitorService::class)\n    -\u003ecapture([\n        'metric_name' =\u003e 'equalized_odds',\n        'cohort_dimension' =\u003e 'language',\n        // ... domain payload\n    ]);\n```\n\nVerification one-liner:\n\n```bash\nphp artisan tinker --execute='dump(app(Padosoft\\AiActCompliance\\BiasMonitoring\\Services\\MetricRegistry::class)-\u003ehas(\"equalized_odds\"))'\n# expect: true\n```\n\n#### 2. Cohort-drift real-time alerting cascade (v1.3 — opt-in)\n\n```env\nAI_ACT_ALERTING_ENABLED=true\nAI_ACT_ALERT_THROTTLE_MINUTES=60\nAI_ACT_ALERT_CB_FAILURES=5\nAI_ACT_ALERT_CB_COOLDOWN=30\n# Optional click-through link for the DPO email body:\nAI_ACT_ALERT_EVIDENCE_URL_TEMPLATE=\"${APP_URL}/admin/ai-act-compliance/bias?tenant={tenant_id}\u0026metric={metric_name}\"\n```\n\nSeed at least one channel route (Slack / Discord / email):\n\n```php\n\\Padosoft\\AiActCompliance\\Alerting\\Models\\AlertRoute::query()-\u003ecreate([\n    'tenant_id' =\u003e null,                                                  // null = platform-global\n    'channel' =\u003e 'slack',\n    'webhook_url' =\u003e 'https://hooks.slack.com/services/T0/B0/xyz',        // auto-encrypted at rest\n    'enabled' =\u003e true,\n]);\n\\Padosoft\\AiActCompliance\\Alerting\\Models\\AlertRoute::query()-\u003ecreate([\n    'tenant_id' =\u003e null,\n    'channel' =\u003e 'email',\n    'email' =\u003e 'dpo@yourcompany.example',\n    'enabled' =\u003e true,\n]);\n```\n\nMake sure `queue.default` is NOT `sync` for production deployments\n(otherwise alerts fire on the request thread). `database` is fine for\nsmall installs; `redis` for prod-grade.\n\n#### 3. EU AI Act regulatory-feed auto-flagger (v1.4 — opt-in)\n\n```env\nAI_ACT_REGULATORY_FEED_ENABLED=true\nAI_ACT_REGULATORY_FEED_URL=https://eur-lex.europa.eu/EN/legal-content/summaries/AI-act.xml\nAI_ACT_REGULATORY_FEED_MAX_ENTRIES=50\nA","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flopadova%2Faskmydocs","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flopadova%2Faskmydocs","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flopadova%2Faskmydocs/lists"}