Files
accounted/app/api/agent/memory/route.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

136 lines
4.6 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
// GET /api/agent/memory
//
// Lists memory entries for the active company, ordered by pin > relevance >
// recency. Powers /settings/agent-memory (transparency UI per plan §11).
//
// Query params:
// include_dismissed: 'true' includes is_active=false rows (audit view).
// kind: filter by kind.
// limit: 1..200, default 200 (matches the storage cap).
//
// POST /api/agent/memory
//
// Inserts a memory entry for the company. Used by Phase B's free-text seed
// memory field ("Lägg till om ditt företag (valfritt)") to capture
// foundational facts as `kind=fact`, `source=user_taught` with an elevated
// relevance score so they land in the top-30 prompt block.
//
// Also usable from /settings/agent-memory ("Lägg till minne" affordance) and
// post-Phase 4 explicit "Kom ihåg det här" surfaces.
const KIND = ['fact', 'preference', 'pattern', 'correction'] as const
const SOURCE = ['composer', 'user_taught', 'agent_learned', 'derived'] as const
const MEMORY_COLUMNS =
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at'
const ListQuerySchema = z.object({
include_dismissed: z.enum(['true', 'false']).optional(),
kind: z.enum(KIND).optional(),
limit: z.coerce.number().int().min(1).max(200).default(200),
})
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
content: z.string().min(2).max(2000),
kind: z.enum(KIND).default('fact'),
source: z.enum(SOURCE).default('user_taught'),
source_ref: z.string().max(200).optional(),
// Default 1.0 keeps user-taught entries above composer-derived (0.5) by
// default; conversation-time captures can land anywhere in [0, 1].
relevance_score: z.number().min(0).max(1).default(1.0),
})
export const GET = withRouteContext('agent.memory.list', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const validated = validateQuery(request, ListQuerySchema, {
log,
operation: 'agent.memory.list',
})
if (!validated.success) return validated.response
const { include_dismissed, kind, limit } = validated.data
let query = supabase
.from('agent_memory')
.select(MEMORY_COLUMNS)
.eq('company_id', companyId)
if (include_dismissed !== 'true') query = query.eq('is_active', true)
if (kind) query = query.eq('kind', kind)
query = query
.order('is_active', { ascending: false })
.order('is_pinned', { ascending: false })
.order('relevance_score', { ascending: false })
.order('last_accessed_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
.limit(limit)
const { data, error } = await query
if (error) throw error
return NextResponse.json({ data: data ?? [] })
})
export const POST = withRouteContext(
'agent.memory.create',
async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user, log } = ctx
const validation = await validateBody(request, BodySchema, {
log,
operation: 'agent.memory.create',
})
if (!validation.success) return validation.response
const body = validation.data
const companyId = body.company_id ?? activeCompanyId
// requireWrite (wrapper option) checks the *active* company's role; if
// the caller passes a different company_id in the body, re-check
// membership + non-viewer role for THAT company specifically.
const { data: bodyMembership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!bodyMembership || bodyMembership.role === 'viewer') {
return NextResponse.json(
{
error: {
code: 'WRITE_PERMISSION_REQUIRED',
message: 'Du har endast läsbehörighet i detta företag.',
message_en: 'You only have read access in this company.',
},
},
{ status: 403 },
)
}
const { data, error } = await supabase
.from('agent_memory')
.insert({
company_id: companyId,
kind: body.kind,
content: body.content,
source: body.source,
source_ref: body.source_ref ?? null,
relevance_score: body.relevance_score,
is_active: true,
created_by_user_id: user.id,
})
.select(MEMORY_COLUMNS)
.single()
if (error) throw error
return NextResponse.json({ data })
},
{ requireWrite: true },
)