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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-12 22:21:08 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4b51af3d80
commit dcd33997b7
10 changed files with 238 additions and 67 deletions
@@ -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')
})
})
+30
View File
@@ -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 ?? '' },
})
})