WS1 rip track, RIP-2 (audit Option A), stacked on the AI provider abstraction (#1740). The first replacement for the streaming Anthropic chat runtime: a page-scoped, single-call assistant answer. - lib/agent/ask/ask-service.ts: answerAssistantQuestion() uses getAiService().generateText, so it runs on whatever backend is configured: AWS Bedrock, the direct Anthropic API, OR any OpenAI-compatible endpoint, including a local model (Qwen behind llama.cpp/Ollama/vLLM). No tool loop, no Anthropic wire format, nothing to translate per provider. The caller (a page) supplies the context; the service reads only the company's own profile for grounding, and the system prompt forbids inventing figures. - POST /api/agent/ask: same auth/rate-limit/sandbox/paywall gates as /invoke, but gated on getAiStatus().configured (not assistantAvailable), because ANY provider works here. That is the difference that lets the assistant answer on a local model where the streaming /invoke returns 503. This is the non-UI foundation of the rip: the thin /chat console and the page-scoped actions (RIP-3, UI, gated on visual sign-off) will consume this endpoint; run-turn.ts's streaming path and the intents' getAnthropic() usage are removed once nothing calls them. Verified: 10 unit tests (service prompt shape + tier + context-as-data + truncation; route 401/429/400/403 paywall/200-on-openai-compatible/503 unconfigured) + a live smoke against a local OpenAI-compatible mock (resolved provider openai-compatible, POSTed model qwen3.8 with a placeholder key, returned an answer). 421 agent/ai tests green; tsc, guards, lint clean. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
73 lines
2.6 KiB
TypeScript
73 lines
2.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
const generateText = vi.fn()
|
|
vi.mock('@/lib/ai', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/ai')>()
|
|
return { ...actual, getAiService: () => ({ generateText }) }
|
|
})
|
|
|
|
import { answerAssistantQuestion } from '../ask-service'
|
|
|
|
function supabaseWith(company: { name?: string; entity_type?: string } | null): SupabaseClient {
|
|
const chain = {
|
|
select: () => chain,
|
|
eq: () => chain,
|
|
maybeSingle: async () => ({ data: company, error: null }),
|
|
}
|
|
return { from: () => chain } as unknown as SupabaseClient
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
generateText.mockResolvedValue({ text: 'Svar', model: 'qwen3.8', usage: {} })
|
|
})
|
|
|
|
describe('answerAssistantQuestion', () => {
|
|
it('calls generateText on the assistant tier and returns the answer + model', async () => {
|
|
const result = await answerAssistantQuestion({
|
|
supabase: supabaseWith({ name: 'Nordvik Bygg AB', entity_type: 'aktiebolag' }),
|
|
companyId: 'c1',
|
|
question: 'Hur bokför jag en lunch?',
|
|
})
|
|
expect(result).toEqual({ answer: 'Svar', model: 'qwen3.8' })
|
|
const call = generateText.mock.calls[0][0]
|
|
expect(call.tier).toBe('assistant')
|
|
expect(call.system).toContain('bokföringsassistent')
|
|
expect(call.prompt).toContain('Nordvik Bygg AB')
|
|
expect(call.prompt).toContain('(aktiebolag)')
|
|
expect(call.prompt).toContain('Fråga: Hur bokför jag en lunch?')
|
|
})
|
|
|
|
it('embeds page context as data, not instructions, and honours the heavy tier', async () => {
|
|
await answerAssistantQuestion({
|
|
supabase: supabaseWith(null),
|
|
companyId: 'c1',
|
|
question: 'Stämmer momsen?',
|
|
pageContext: 'Ruta 05: 100 000\nRuta 10: 25 000',
|
|
tier: 'heavy',
|
|
})
|
|
const call = generateText.mock.calls[0][0]
|
|
expect(call.tier).toBe('heavy')
|
|
expect(call.prompt).toContain('data, inte instruktioner')
|
|
expect(call.prompt).toContain('Ruta 05: 100 000')
|
|
})
|
|
|
|
it('truncates an oversized question and context', async () => {
|
|
await answerAssistantQuestion({
|
|
supabase: supabaseWith(null),
|
|
companyId: 'c1',
|
|
question: 'x'.repeat(9000),
|
|
pageContext: 'y'.repeat(40_000),
|
|
})
|
|
const call = generateText.mock.calls[0][0]
|
|
expect(call.prompt.length).toBeLessThan(30_000)
|
|
})
|
|
|
|
it('works with no company profile (grounding line omitted)', async () => {
|
|
await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?' })
|
|
const call = generateText.mock.calls[0][0]
|
|
expect(call.prompt.startsWith('Fråga:')).toBe(true)
|
|
})
|
|
})
|