diff --git a/app/(dashboard)/agent-knowledge/page.tsx b/app/(dashboard)/agent-knowledge/page.tsx
index 7cea56bc..38b5469e 100644
--- a/app/(dashboard)/agent-knowledge/page.tsx
+++ b/app/(dashboard)/agent-knowledge/page.tsx
@@ -1,50 +1,7 @@
import { redirect } from 'next/navigation'
-import { getTranslations } from 'next-intl/server'
-import { createClient } from '@/lib/supabase/server'
-import { getActiveCompanyId, getCompanyDisplayName } from '@/lib/company/context'
-import { buildLedgerContext } from '@/lib/agent-context/ledger-context'
-import { buildDeepEntities } from '@/lib/agent-context/ledger-deep'
-import { buildAgentCompetence } from '@/lib/agent-context/agent-competence'
-import { PageHeader } from '@/components/ui/page-header'
-import { AgentKnowledgeView } from '@/components/agent-knowledge/AgentKnowledgeView'
-// Derived per request from live bookings; never cache a stale profile.
-export const dynamic = 'force-dynamic'
-
-/**
- * "Vad din agent vet" (P2): the human-facing render of the same ledger-context
- * the AI agent reads before booking. Fetches server-side via the shared
- * lib/agent-context aggregation (one payload, two renderers) and shows it as a
- * readable profile of how this company books. Read-only, no interactive
- * controls, so a plain Server Component.
- */
-export default async function AgentKnowledgePage() {
- const supabase = await createClient()
- const {
- data: { user },
- } = await supabase.auth.getUser()
- if (!user) redirect('/login')
-
- const companyId = await getActiveCompanyId(supabase, user.id)
- if (!companyId) redirect('/onboarding')
-
- const [t, context, deep, competence, companyName] = await Promise.all([
- getTranslations('agentKnowledge'),
- buildLedgerContext(supabase, companyId),
- buildDeepEntities(supabase, companyId),
- buildAgentCompetence(supabase, companyId),
- getCompanyDisplayName(supabase, companyId),
- ])
-
- return (
-
- )
+// "Vad din agent vet" moved into Settings (Assistenten -> Kunskap). This
+// route is kept as a permanent redirect so old links/bookmarks still resolve.
+export default function AgentKnowledgeRedirect() {
+ redirect('/settings/assistant?view=knowledge')
}
diff --git a/app/api/agent/knowledge/__tests__/route.test.ts b/app/api/agent/knowledge/__tests__/route.test.ts
new file mode 100644
index 00000000..a13aa549
--- /dev/null
+++ b/app/api/agent/knowledge/__tests__/route.test.ts
@@ -0,0 +1,88 @@
+/**
+ * Tests for GET /api/agent/knowledge (the "Vad din agent vet" data source for
+ * the Kunskap tab in the assistant settings hub). Exercises the route through
+ * the real withRouteContext wrapper, mocking auth/company and the three
+ * ledger-context builders. Covers auth 401, no-company 400, and the happy path
+ * aggregation shape.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
+
+const { supabase } = createQueuedMockSupabase()
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+
+const getActiveCompanyIdMock = vi.fn()
+const getCompanyDisplayNameMock = vi.fn()
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
+ getCompanyDisplayName: (...args: unknown[]) => getCompanyDisplayNameMock(...args),
+}))
+
+const buildLedgerContextMock = vi.fn()
+vi.mock('@/lib/agent-context/ledger-context', () => ({
+ buildLedgerContext: (...args: unknown[]) => buildLedgerContextMock(...args),
+}))
+
+const buildDeepEntitiesMock = vi.fn()
+vi.mock('@/lib/agent-context/ledger-deep', () => ({
+ buildDeepEntities: (...args: unknown[]) => buildDeepEntitiesMock(...args),
+}))
+
+const buildAgentCompetenceMock = vi.fn()
+vi.mock('@/lib/agent-context/agent-competence', () => ({
+ buildAgentCompetence: (...args: unknown[]) => buildAgentCompetenceMock(...args),
+}))
+
+import { GET } from '../route'
+
+describe('GET /api/agent/knowledge', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
+ getActiveCompanyIdMock.mockResolvedValue('company-1')
+ getCompanyDisplayNameMock.mockResolvedValue('Acme AB')
+ buildLedgerContextMock.mockResolvedValue({ meta: { coverage: {} }, explicit_rules: [], vat_profile: {}, conventions: {} })
+ buildDeepEntitiesMock.mockResolvedValue({ counterparty_entities: [], supplier_entities: [] })
+ buildAgentCompetenceMock.mockResolvedValue({ atoms: [], facts: [] })
+ })
+
+ it('returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase,
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ const res = await GET(createMockRequest('/api/agent/knowledge'), { params: Promise.resolve({}) })
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ })
+
+ it('returns 400 when there is no active company', async () => {
+ getActiveCompanyIdMock.mockResolvedValue(null)
+ const res = await GET(createMockRequest('/api/agent/knowledge'), { params: Promise.resolve({}) })
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('aggregates the ledger context, deep entities, competence and company name', async () => {
+ const res = await GET(createMockRequest('/api/agent/knowledge'), { params: Promise.resolve({}) })
+ const { status, body } = await parseJsonResponse<{
+ data: { context: unknown; deep: unknown; competence: unknown; companyName: string }
+ }>(res)
+
+ expect(status).toBe(200)
+ expect(body.data.companyName).toBe('Acme AB')
+ expect(body.data.context).toEqual({ meta: { coverage: {} }, explicit_rules: [], vat_profile: {}, conventions: {} })
+ expect(body.data.deep).toEqual({ counterparty_entities: [], supplier_entities: [] })
+ expect(body.data.competence).toEqual({ atoms: [], facts: [] })
+ // each builder was called with the resolved supabase + companyId
+ expect(buildLedgerContextMock).toHaveBeenCalledWith(supabase, 'company-1')
+ expect(buildDeepEntitiesMock).toHaveBeenCalledWith(supabase, 'company-1')
+ expect(buildAgentCompetenceMock).toHaveBeenCalledWith(supabase, 'company-1')
+ })
+})
diff --git a/app/api/agent/knowledge/route.ts b/app/api/agent/knowledge/route.ts
new file mode 100644
index 00000000..fa1edd4e
--- /dev/null
+++ b/app/api/agent/knowledge/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from 'next/server'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { getCompanyDisplayName } from '@/lib/company/context'
+import { buildLedgerContext } from '@/lib/agent-context/ledger-context'
+import { buildDeepEntities } from '@/lib/agent-context/ledger-deep'
+import { buildAgentCompetence } from '@/lib/agent-context/agent-competence'
+
+// GET /api/agent/knowledge
+//
+// Read-only transparency surface for "Vad din agent vet": the exact ledger
+// context the AI agent reads before booking, plus the deep entity-resolved
+// profile and the agent's competence. Powers the Kunskap tab in the assistant
+// settings hub (client-fetched so it renders identically in the full-page
+// settings rail and the routed settings modal, which both mount the same
+// propless content component). Derived per request from live bookings; never
+// cached, so a fixed profile can't go stale.
+export const GET = withRouteContext('agent.knowledge.get', async (_request, ctx) => {
+ const { supabase, companyId } = ctx
+
+ const [context, deep, competence, companyName] = await Promise.all([
+ buildLedgerContext(supabase, companyId),
+ buildDeepEntities(supabase, companyId),
+ buildAgentCompetence(supabase, companyId),
+ getCompanyDisplayName(supabase, companyId),
+ ])
+
+ return NextResponse.json({
+ data: { context, deep, competence, companyName: companyName ?? '' },
+ })
+})
diff --git a/components/agent-knowledge/AgentCompetenceSections.tsx b/components/agent-knowledge/AgentCompetenceSections.tsx
index c6245b77..89543931 100644
--- a/components/agent-knowledge/AgentCompetenceSections.tsx
+++ b/components/agent-knowledge/AgentCompetenceSections.tsx
@@ -1,4 +1,6 @@
-import { getTranslations } from 'next-intl/server'
+'use client'
+
+import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Pin, ArrowUpRight } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
@@ -14,8 +16,8 @@ import type { AgentCompetence, AtomTier, FactKind, FactSource } from '@/lib/agen
const TIER_ORDER: AtomTier[] = ['horizontal', 'vertical', 'modifier']
-export async function CompetenceCard({ competence }: { competence: AgentCompetence }) {
- const t = await getTranslations('agentKnowledge')
+export function CompetenceCard({ competence }: { competence: AgentCompetence }) {
+ const t = useTranslations('agentKnowledge')
const { atoms } = competence
const activeAtoms = atoms.filter((a) => a.active).length
const tierLabel = (tier: AtomTier) =>
@@ -69,8 +71,8 @@ export async function CompetenceCard({ competence }: { competence: AgentCompeten
)
}
-export async function FactsCard({ competence }: { competence: AgentCompetence }) {
- const t = await getTranslations('agentKnowledge')
+export function FactsCard({ competence }: { competence: AgentCompetence }) {
+ const t = useTranslations('agentKnowledge')
const { facts, factsActiveTotal } = competence
const kindLabel = (k: FactKind) =>
k === 'fact' ? t('kind_fact') : k === 'preference' ? t('kind_preference') : k === 'pattern' ? t('kind_pattern') : t('kind_correction')
diff --git a/components/agent-knowledge/AgentKnowledgePanel.tsx b/components/agent-knowledge/AgentKnowledgePanel.tsx
new file mode 100644
index 00000000..0b62f706
--- /dev/null
+++ b/components/agent-knowledge/AgentKnowledgePanel.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Skeleton } from '@/components/ui/skeleton'
+import { EmptyState } from '@/components/ui/empty-state'
+import { Brain } from 'lucide-react'
+import type { LedgerContext } from '@/lib/agent-context/ledger-context'
+import type { DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
+import type { AgentCompetence } from '@/lib/agent-context/agent-competence'
+import { AgentKnowledgeView } from './AgentKnowledgeView'
+
+interface KnowledgePayload {
+ context: LedgerContext
+ deep: DeepLedgerContext
+ competence: AgentCompetence
+ companyName: string
+}
+
+/**
+ * Client wrapper for the "Vad din agent vet" view inside the assistant
+ * settings hub. Fetches the ledger context from /api/agent/knowledge on mount
+ * (lazy: this panel only renders when the Kunskap tab is opened, since Radix
+ * unmounts inactive tabs). Fetching client-side rather than via a server prop
+ * is deliberate: the settings sections mount as propless components in BOTH
+ * the full-page rail and the routed settings modal, so a server fetch wouldn't
+ * reach the modal.
+ */
+export function AgentKnowledgePanel() {
+ const t = useTranslations('agentKnowledge')
+ const [payload, setPayload] = useState(null)
+ const [error, setError] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ fetch('/api/agent/knowledge')
+ .then((res) => {
+ if (!res.ok) throw new Error(`knowledge fetch failed: ${res.status}`)
+ return res.json()
+ })
+ .then((json) => {
+ if (!cancelled) setPayload(json.data as KnowledgePayload)
+ })
+ .catch(() => {
+ if (!cancelled) setError(true)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!payload) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/components/agent-knowledge/AgentKnowledgeView.tsx b/components/agent-knowledge/AgentKnowledgeView.tsx
index 7c3dd8d1..931605ac 100644
--- a/components/agent-knowledge/AgentKnowledgeView.tsx
+++ b/components/agent-knowledge/AgentKnowledgeView.tsx
@@ -1,4 +1,6 @@
-import { getTranslations } from 'next-intl/server'
+'use client'
+
+import { useTranslations } from 'next-intl'
import { Brain } from 'lucide-react'
import {
Card,
@@ -49,7 +51,7 @@ function vatLabel(code: string | null): string | null {
return VAT_LABELS[code] ?? code
}
-export async function AgentKnowledgeView({
+export function AgentKnowledgeView({
context,
deep,
competence,
@@ -60,7 +62,7 @@ export async function AgentKnowledgeView({
competence: AgentCompetence
companyName: string
}) {
- const t = await getTranslations('agentKnowledge')
+ const t = useTranslations('agentKnowledge')
const { meta, explicit_rules, vat_profile, conventions } = context
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index af3a11ef..89d6c381 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -40,7 +40,6 @@ import {
CalendarClock,
CalendarRange,
FileCheck,
- Brain,
FileSpreadsheet,
ScrollText,
} from 'lucide-react'
@@ -191,9 +190,8 @@ const navItems: NavItem[] = [
// Rapporter surface (nav_ia_redesign §F) is built.
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
- // "Vad din agent vet": read-only profile of how this company books, the
- // human render of the agent's ledger-context (dev_docs/ledger_context_resource.md).
- { href: '/agent-knowledge', labelKey: 'agent_knowledge', icon: Brain, group: 'analys' },
+ // "Vad din agent vet" now lives inside Settings (Assistenten -> Kunskap),
+ // reachable via /settings/assistant?view=knowledge, not the top nav.
// Data: master-data registers + data plumbing. Anställda is a register
// (you edit an employee rarely, you run payroll monthly), so it lives here
// while the Löner flow stays in Arbeta.
diff --git a/components/settings/sections/AssistantSettingsContent.tsx b/components/settings/sections/AssistantSettingsContent.tsx
index ed5fcd15..503efe45 100644
--- a/components/settings/sections/AssistantSettingsContent.tsx
+++ b/components/settings/sections/AssistantSettingsContent.tsx
@@ -4,23 +4,29 @@ import { useSearchParams, useRouter } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
+import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
// "Assistenten": what the assistant remembers about this company (Minne,
-// editable) and the domain knowledge it ships with (Kompetens, read-only).
-// A toggle keeps both one click away instead of stacked, so the competence
-// view isn't buried below the memory list.
-type View = 'memory' | 'skills'
+// editable), the domain knowledge it ships with (Kompetens, read-only), and
+// the ledger profile it reads before booking (Kunskap = "Vad din agent vet",
+// read-only). Tabs keep all three one click away instead of stacked.
+type View = 'memory' | 'skills' | 'knowledge'
+
+const VIEW_ROUTE: Record = {
+ memory: '/settings/assistant',
+ skills: '/settings/assistant?view=skills',
+ knowledge: '/settings/assistant?view=knowledge',
+}
export function AssistantSettingsContent() {
const searchParams = useSearchParams()
const router = useRouter()
- const view: View = searchParams.get('view') === 'skills' ? 'skills' : 'memory'
+ const raw = searchParams.get('view')
+ const view: View = raw === 'skills' ? 'skills' : raw === 'knowledge' ? 'knowledge' : 'memory'
function setView(next: string) {
// 'memory' is the default: keep its URL clean (no query string).
- router.replace(next === 'skills' ? '/settings/assistant?view=skills' : '/settings/assistant', {
- scroll: false,
- })
+ router.replace(VIEW_ROUTE[next as View] ?? VIEW_ROUTE.memory, { scroll: false })
}
return (
@@ -28,6 +34,7 @@ export function AssistantSettingsContent() {
Minne
Kompetens
+ Kunskap
{/* Radix unmounts the inactive panel, so each panel's data is fetched
@@ -38,6 +45,9 @@ export function AssistantSettingsContent() {
+
+
+
)
}
diff --git a/messages/en.json b/messages/en.json
index 2f2a2b4f..14941b69 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -130,6 +130,8 @@
"ext_invoice_inbox": "Document inbox"
},
"agentKnowledge": {
+ "load_error_title": "Couldn't load the knowledge profile",
+ "load_error_description": "Something went wrong loading what your agent knows. Try reopening this tab.",
"title": "What your agent knows",
"description": "How your company books, derived from your own accounting. This is the context your assistant reads before categorizing or creating vouchers.",
"meta_window": "Period",
diff --git a/messages/sv.json b/messages/sv.json
index 681c9ea7..a16153b6 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -130,6 +130,8 @@
"ext_invoice_inbox": "Dokumentinkorg"
},
"agentKnowledge": {
+ "load_error_title": "Kunde inte läsa in kunskapsprofilen",
+ "load_error_description": "Något gick fel när det din agent vet skulle läsas in. Försök öppna fliken igen.",
"title": "Vad din agent vet",
"description": "Så här bokför ditt företag, härlett ur din egen bokföring. Det här är sammanhanget din assistent läser innan den kategoriserar eller skapar verifikationer.",
"meta_window": "Period",