An agent request, end to end
Follow one 'ask an agent' request from the Express entry point, through route registration and system-prompt selection, to the provider call.
apps/api/src/routes/agents.ts309 lines · buildSystemPrompt L292–306
Outline 6 symbols
- router const
- getSupabase function
- pathFromUrl function
- getMatterDocumentContext function
- sanitiseError function
- buildSystemPrompt function
1import { Router } from 'express'
2import { prisma } from '@law-oss/db'
3import { callAI } from '@law-oss/ai'
4import { requireAuth, AuthRequest } from '../middleware/auth'
5import { getUserApiKey } from './apiKeys'
6import { createClient } from '@supabase/supabase-js'
7import type { Message } from '@law-oss/types'
8// @ts-ignore
9import pdfParse from 'pdf-parse'
10// @ts-ignore
11import mammoth from 'mammoth'
12
13const router = Router()
14
15function getSupabase() {
16 return createClient(
17 process.env.SUPABASE_URL!,
18 process.env.SUPABASE_SERVICE_ROLE_KEY!
19 )
20}
21
22function pathFromUrl(publicUrl: string): string {
23 const marker = '/storage/v1/object/public/'
24 const idx = publicUrl.indexOf(marker)
25 if (idx === -1) return publicUrl
26 const after = publicUrl.slice(idx + marker.length)
27 const [, ...rest] = after.split('/')
28 return rest.join('/')
29}
30
31async function getMatterDocumentContext(matterId: string): Promise<string> {
32 try {
33 const docs = await prisma.matterDocument.findMany({
34 where: { matterId },
35 take: 5,
36 orderBy: { createdAt: 'desc' },
37 })
38 if (!docs.length) return ''
39
40 const supabase = getSupabase()
41 let context = '\n\nDOCUMENTS IN THIS MATTER:\n'
42 let added = 0
43
44 for (const doc of docs) {
45 try {
46 const storagePath = pathFromUrl(doc.storageUrl)
47 const bucket = doc.storageUrl.includes('/contracts/') ? 'contracts' : 'matter-documents'
48 const { data, error } = await supabase.storage.from(bucket).download(storagePath)
49 if (error || !data) continue
50
51 const buffer = Buffer.from(await data.arrayBuffer())
52 let text = ''
53 const mime = doc.mimeType || ''
54 const name = doc.filename || ''
55
56 if (mime.includes('pdf') || name.toLowerCase().endsWith('.pdf')) {
57 const parsed = await pdfParse(buffer, { max: 0 })
58 text = (parsed.text || '').slice(0, 6000)
59 } else if (mime.includes('wordprocessingml') || name.toLowerCase().endsWith('.docx')) {
60 const result = await mammoth.extractRawText({ buffer })
61 text = (result.value || '').slice(0, 6000)
62 }
63
64 if (text.trim().length > 20) {
65 context += `\n[${doc.filename}]\n${text}\n---\n`
66 added++
67 }
68 } catch {
69 // skip, continue with other docs
70 }
71 }
72 return added > 0 ? context : ''
73 } catch {
74 return ''
75 }
76}
77
78// Sanitise error messages — never leak API keys
79function sanitiseError(msg: string): string {
80 return msg
81 .replace(/sk-ant-[A-Za-z0-9\-_]+/g, '[redacted]')
82 .replace(/sk-[A-Za-z0-9\-_]+/g, '[redacted]')
83 .replace(/AIza[A-Za-z0-9\-_]+/g, '[redacted]')
84 .replace(/Bearer [A-Za-z0-9\-_.]+/g, 'Bearer [redacted]')
85}
86
87// ─── Streaming chat ───────────────────────────────────────────────────────────
88// API key is ALWAYS fetched from the database — never accepted from the client.
89router.post('/chat/stream', requireAuth, async (req: AuthRequest, res) => {
90 const send = (data: object) => {
91 try { res.write(`data: ${JSON.stringify(data)}\n\n`) } catch {}
92 }
93 const end = () => { try { res.end() } catch {} }
94
95 // Fetch key server-side — client never sends the raw key
96 const aiCfg = await getUserApiKey(req.user!.id)
97 if (!aiCfg) {
98 res.status(400).json({ error: 'NO_API_KEY' })
99 return
100 }
101
102 res.setHeader('Content-Type', 'text/event-stream')
103 res.setHeader('Cache-Control', 'no-cache')
104 res.setHeader('Connection', 'keep-alive')
105 res.setHeader('X-Accel-Buffering', 'no')
106 res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*')
107 res.flushHeaders()
108
109 // Abort handling — save partial response on client disconnect
110 let partialResponse = ''
111 req.on('close', () => {
112 if (partialResponse) {
113 // Persist partial to session so it's not lost
114 const { agentId = 'general' } = req.body
115 const sessionId = `${req.user!.id}-${agentId}`
116 prisma.agentSession.upsert({
117 where: { id: sessionId },
118 create: { id: sessionId, userId: req.user!.id, agentId, messages: [] },
119 update: {},
120 }).catch(() => {})
121 }
122 })
123
124 try {
125 const { agentId = 'general', message, history = [], matterId = null, systemPrompt } = req.body
126
127 let sys = systemPrompt || buildSystemPrompt(agentId)
128 if (matterId) {
129 const docCtx = await getMatterDocumentContext(matterId as string)
130 if (docCtx) sys += docCtx
131 }
132
133 const messages: { role: 'user' | 'assistant'; content: string }[] = [
134 ...(Array.isArray(history) ? history : []).map((h: any) => ({
135 role: h.role as 'user' | 'assistant',
136 content: String(h.content),
137 })),
138 { role: 'user' as const, content: String(message) },
139 ]
140
141 const { key: apiKey, provider } = aiCfg
142
143 if (provider === 'claude' || provider === 'anthropic') {
144 const response = await fetch('https://api.anthropic.com/v1/messages', {
145 method: 'POST',
146 headers: {
147 'Content-Type': 'application/json',
148 'x-api-key': apiKey,
149 'anthropic-version': '2023-06-01',
150 },
151 body: JSON.stringify({
152 model: 'claude-sonnet-4-6',
153 max_tokens: 4000,
154 stream: true,
155 system: sys,
156 messages,
157 }),
158 })
159
160 if (!response.ok) {
161 const err = await response.json().catch(() => ({})) as any
162 send({ error: sanitiseError(err.error?.message || `Error ${response.status}`) })
163 end(); return
164 }
165
166 const reader = response.body!.getReader()
167 const decoder = new TextDecoder()
168 let buf = ''
169
170 while (true) {
171 const { done, value } = await reader.read()
172 if (done) break
173 buf += decoder.decode(value, { stream: true })
174 const lines = buf.split('\n')
175 buf = lines.pop() || ''
176 for (const line of lines) {
177 if (!line.startsWith('data: ')) continue
178 const raw = line.slice(6).trim()
179 if (!raw || raw === '[DONE]') continue
180 try {
181 const parsed = JSON.parse(raw)
182 if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
183 partialResponse += parsed.delta.text
184 send({ token: parsed.delta.text })
185 }
186 } catch {}
187 }
188 }
189 send({ done: true })
190 end()
191
192 } else {
193 // Gemini
194 const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?key=${apiKey}&alt=sse`
195 const response = await fetch(url, {
196 method: 'POST',
197 headers: { 'Content-Type': 'application/json' },
198 body: JSON.stringify({
199 system_instruction: { parts: [{ text: sys }] },
200 contents: messages.map(m => ({
201 role: m.role === 'assistant' ? 'model' : 'user',
202 parts: [{ text: m.content }],
203 })),
204 generationConfig: { maxOutputTokens: 4000, temperature: 0.3 },
205 }),
206 })
207
208 const reader = response.body!.getReader()
209 const decoder = new TextDecoder()
210 let buf = ''
211
212 while (true) {
213 const { done, value } = await reader.read()
214 if (done) break
215 buf += decoder.decode(value, { stream: true })
216 const lines = buf.split('\n')
217 buf = lines.pop() || ''
218 for (const line of lines) {
219 if (!line.startsWith('data: ')) continue
220 const raw = line.slice(6).trim()
221 if (!raw || raw === '[DONE]') continue
222 try {
223 const parsed = JSON.parse(raw)
224 const token = parsed.candidates?.[0]?.content?.parts?.[0]?.text
225 if (token) {
226 partialResponse += token
227 send({ token })
228 }
229 } catch {}
230 }
231 }
232 send({ done: true })
233 end()
234 }
235
236 } catch (error: any) {
237 console.error('Agent stream error:', sanitiseError(error.message || ''))
238 send({ error: sanitiseError(error.message || 'Server error') })
239 end()
240 }
241})
242
243// ─── Non-streaming fallback ───────────────────────────────────────────────────
244router.post('/chat', requireAuth, async (req: AuthRequest, res, next) => {
245 try {
246 const { agentId, message, history = [], systemPrompt, matterId } = req.body
247 const aiCfg = await getUserApiKey(req.user!.id)
248 if (!aiCfg) throw new Error('NO_API_KEY')
249
250 let sys = systemPrompt || buildSystemPrompt(agentId)
251 if (matterId) {
252 const docCtx = await getMatterDocumentContext(matterId)
253 if (docCtx) sys += docCtx
254 }
255
256 const messages: Message[] = [...(history as Message[]), { role: 'user', content: message }]
257 const response = await callAI(aiCfg.key, aiCfg.provider, messages, sys)
258 res.json({ response, provider: aiCfg.provider })
259 } catch (err) {
260 next(err)
261 }
262})
263
264// ─── Sessions ─────────────────────────────────────────────────────────────────
265router.post('/sessions', requireAuth, async (req: AuthRequest, res, next) => {
266 try {
267 const { agentId, messages } = req.body
268 const sessionId = `${req.user!.id}-${agentId}`
269 const session = await prisma.agentSession.upsert({
270 where: { id: sessionId },
271 create: { id: sessionId, userId: req.user!.id, agentId, messages },
272 update: { messages },
273 })
274 res.json(session)
275 } catch (err) {
276 next(err)
277 }
278})
279
280router.get('/sessions/:agentId', requireAuth, async (req: AuthRequest, res, next) => {
281 try {
282 const session = await prisma.agentSession.findFirst({
283 where: { userId: req.user!.id, agentId: req.params.agentId },
284 })
285 res.json(session)
286 } catch (err) {
287 next(err)
288 }
289})
290
291// ─── System prompts ───────────────────────────────────────────────────────────
292function buildSystemPrompt(agentId: string): string {
293 const base = `You are Law OSS AI, an expert legal assistant. Apply the governing law relevant to the user's matter. If the user specifies a jurisdiction, apply that law; otherwise apply general common law principles. Be precise, professional and cite real legal authorities. Do not use emojis or decorative symbols.`
294 const prompts: Record<string, string> = {
295 research: `${base} Specialise in legal research. Find relevant cases, statutes, and regulations. Always cite specific authorities with proper citation format. Never fabricate citations.`,
296 drafting: `${base} Specialise in legal document drafting. Create precise, enforceable legal language. Mark client-specific gaps as [PLACEHOLDER]. Flag ambiguities.`,
297 contract: `${base} Specialise in contract analysis. Identify risks [CRITICAL/HIGH/MEDIUM/LOW], unusual clauses, and missing provisions. Be structured.`,
298 litigation: `${base} Specialise in litigation strategy. Analyse merits, identify key issues, suggest case strategy, and assess risk with probability estimates.`,
299 compliance: `${base} Specialise in regulatory compliance. Identify applicable regulations by name and provision, analyse gaps, and recommend remediation.`,
300 dd: `${base} Specialise in due diligence. Systematically analyse corporate, financial, and legal risks. Format findings with priority levels.`,
301 client: `${base} Specialise in client communication. Draft clear, professional letters explaining legal concepts in plain language.`,
302 billing: `${base} Specialise in legal billing. Review time entries, draft billing narratives, identify potential write-offs. Be concise.`,
303 general: base,
304 }
305 return prompts[agentId] || base
306}
307
308export default router
309