From dcd33997b7e2ca31a10034d8f62c426791e40324 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:21:08 +0200 Subject: [PATCH] feat(agent): move 'Vad din agent vet' into settings (Assistenten -> Kunskap) (#1008) Relocates the ledger-knowledge surface off the top nav and into the assistant settings hub as a third tab (Minne / Kompetens / Kunskap), per the code's own "minne + kunskap under Assistenten" intent and the #935 flag that this was an easy call to change. Because both settings surfaces (the full-page rail and the intercepting settings modal) mount each section as a propless component via SETTINGS_SECTIONS, the knowledge data must be fetched client-side rather than passed as a server prop: - New GET /api/agent/knowledge aggregates buildLedgerContext + buildDeepEntities + buildAgentCompetence + company name (read-only, company-scoped via withRouteContext). - AgentKnowledgeView + AgentCompetenceSections converted from async server components to client components (getTranslations -> useTranslations; no other server-only usage). - New AgentKnowledgePanel client wrapper lazy-fetches the payload when the Kunskap tab opens (Radix unmounts inactive tabs), with Skeleton and error states matching the memory/skills panels. - Removed the Brain/agent-knowledge entry (and its now-unused import) from the Analys nav group. - /agent-knowledge kept as a redirect to /settings/assistant?view=knowledge so old links/bookmarks resolve. Tests: new route test (auth 401, no-company 400, happy-path aggregation). i18n: load_error_* keys added to both locales (parity kept). Co-authored-by: Claude Fable 5 --- app/(dashboard)/agent-knowledge/page.tsx | 51 +---------- .../agent/knowledge/__tests__/route.test.ts | 88 +++++++++++++++++++ app/api/agent/knowledge/route.ts | 30 +++++++ .../AgentCompetenceSections.tsx | 12 +-- .../agent-knowledge/AgentKnowledgePanel.tsx | 80 +++++++++++++++++ .../agent-knowledge/AgentKnowledgeView.tsx | 8 +- components/dashboard/DashboardNav.tsx | 6 +- .../sections/AssistantSettingsContent.tsx | 26 ++++-- messages/en.json | 2 + messages/sv.json | 2 + 10 files changed, 238 insertions(+), 67 deletions(-) create mode 100644 app/api/agent/knowledge/__tests__/route.test.ts create mode 100644 app/api/agent/knowledge/route.ts create mode 100644 components/agent-knowledge/AgentKnowledgePanel.tsx 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",