Add AI travel day summaries via OpenRouter for skippers.
Skipper-only proxy with per-entry rate limiting, encrypted payload storage, CSV export, and Plausible tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const MAX_ATTEMPTS_PER_ENTRY = 3
|
||||
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
const FETCH_TIMEOUT_MS = 60_000
|
||||
|
||||
/** Common misconfiguration aliases → valid OpenRouter model IDs */
|
||||
const MODEL_ALIASES: Record<string, string> = {
|
||||
'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku',
|
||||
'claude-haiku-latest': 'anthropic/claude-3.5-haiku'
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
de: 'German',
|
||||
en: 'English',
|
||||
da: 'Danish',
|
||||
nb: 'Norwegian Bokmål',
|
||||
sv: 'Swedish'
|
||||
}
|
||||
|
||||
function resolveOpenRouterApiKey(): string | null {
|
||||
const fromEnv =
|
||||
process.env.OpenRouterAPIKey?.trim() ||
|
||||
process.env.OPENROUTER_API_KEY?.trim()
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
function resolveOpenRouterModel(): string {
|
||||
const configured =
|
||||
process.env.OpenRouterModel?.trim() ||
|
||||
process.env.OPENROUTER_MODEL?.trim() ||
|
||||
DEFAULT_MODEL
|
||||
return MODEL_ALIASES[configured] ?? configured
|
||||
}
|
||||
|
||||
function extractOpenRouterError(data: unknown): string | null {
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
const nested = (data as { error?: { message?: string } }).error
|
||||
if (nested && typeof nested.message === 'string' && nested.message.trim()) {
|
||||
return nested.message.trim()
|
||||
}
|
||||
const topLevel = (data as { error?: string }).error
|
||||
if (typeof topLevel === 'string' && topLevel.trim()) return topLevel.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
async function getLogbookOwner(logbookId: string) {
|
||||
return prisma.logbook.findUnique({
|
||||
where: { id: logbookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
}
|
||||
|
||||
async function getUsageCount(logbookId: string, entryId: string): Promise<number> {
|
||||
const row = await prisma.aiSummaryUsage.findUnique({
|
||||
where: { logbookId_entryId: { logbookId, entryId } },
|
||||
select: { count: true }
|
||||
})
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
function remainingAttempts(used: number): number {
|
||||
return Math.max(0, MAX_ATTEMPTS_PER_ENTRY - used)
|
||||
}
|
||||
|
||||
function resolveLanguageLabel(language: unknown): string {
|
||||
if (typeof language === 'string' && LANGUAGE_LABELS[language]) {
|
||||
return LANGUAGE_LABELS[language]
|
||||
}
|
||||
return LANGUAGE_LABELS.en
|
||||
}
|
||||
|
||||
function buildSystemPrompt(languageLabel: string): string {
|
||||
return [
|
||||
'You are a maritime logbook assistant for sailing yachts.',
|
||||
`Write a concise narrative summary of one travel day in ${languageLabel}.`,
|
||||
'Use 2–4 short paragraphs in plain prose.',
|
||||
'Cover route, sailing conditions, notable events, and tank/fuel highlights when data is present.',
|
||||
'Do not invent facts not supported by the input.',
|
||||
'Do not include coordinates, personal names, or signature metadata.',
|
||||
'Respond with the summary text only — no title, markdown, or JSON.'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
router.get('/usage', async (req: any, res) => {
|
||||
try {
|
||||
const logbookId = String(req.query.logbookId || '')
|
||||
const entryId = String(req.query.entryId || '')
|
||||
if (!logbookId || !entryId) {
|
||||
return res.status(400).json({ error: 'logbookId and entryId are required' })
|
||||
}
|
||||
|
||||
const logbook = await getLogbookOwner(logbookId)
|
||||
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Skipper only' })
|
||||
}
|
||||
|
||||
const used = await getUsageCount(logbookId, entryId)
|
||||
return res.json({ remainingAttempts: remainingAttempts(used), maxAttempts: MAX_ATTEMPTS_PER_ENTRY })
|
||||
} catch (error: unknown) {
|
||||
console.error('AI summary usage lookup failed:', error)
|
||||
return res.status(500).json({ error: 'Failed to load AI summary usage' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/summary', async (req: any, res) => {
|
||||
try {
|
||||
const { logbookId, entryId, language, context } = req.body ?? {}
|
||||
if (!logbookId || !entryId || !context || typeof context !== 'object') {
|
||||
return res.status(400).json({ error: 'logbookId, entryId, and context are required' })
|
||||
}
|
||||
|
||||
const logbook = await getLogbookOwner(String(logbookId))
|
||||
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Skipper only' })
|
||||
}
|
||||
|
||||
const used = await getUsageCount(String(logbookId), String(entryId))
|
||||
if (used >= MAX_ATTEMPTS_PER_ENTRY) {
|
||||
return res.status(429).json({
|
||||
error: 'Rate limit exceeded for this travel day',
|
||||
code: 'RATE_LIMITED',
|
||||
remainingAttempts: 0,
|
||||
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
|
||||
})
|
||||
}
|
||||
|
||||
const apiKey = resolveOpenRouterApiKey()
|
||||
if (!apiKey) {
|
||||
return res.status(503).json({
|
||||
error: 'No OpenRouter API key configured',
|
||||
code: 'NO_KEY'
|
||||
})
|
||||
}
|
||||
|
||||
const languageLabel = resolveLanguageLabel(language)
|
||||
const model = resolveOpenRouterModel()
|
||||
const contextJson = JSON.stringify(context)
|
||||
if (contextJson.length > 100_000) {
|
||||
return res.status(400).json({ error: 'Travel day context is too large' })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
|
||||
let openRouterRes: Response
|
||||
try {
|
||||
openRouterRes = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.ORIGIN || 'https://kapteins-daagbok.eu',
|
||||
'X-Title': 'Kapteins Daagbok'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: buildSystemPrompt(languageLabel) },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Summarize this travel day from the structured log data:\n\n${contextJson}`
|
||||
}
|
||||
],
|
||||
max_tokens: 800,
|
||||
temperature: 0.4
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return res.status(504).json({ error: 'OpenRouter request timed out' })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
const data = await openRouterRes.json().catch(() => ({}))
|
||||
if (!openRouterRes.ok) {
|
||||
const detail = extractOpenRouterError(data)
|
||||
console.error('OpenRouter error:', openRouterRes.status, data)
|
||||
return res.status(502).json({
|
||||
error: detail || 'OpenRouter request failed',
|
||||
code: 'OPENROUTER_ERROR'
|
||||
})
|
||||
}
|
||||
|
||||
const summary =
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
Array.isArray((data as { choices?: unknown[] }).choices) &&
|
||||
(data as { choices: Array<{ message?: { content?: string } }> }).choices[0]?.message?.content
|
||||
? String((data as { choices: Array<{ message?: { content?: string } }> }).choices[0].message?.content).trim()
|
||||
: ''
|
||||
|
||||
if (!summary) {
|
||||
return res.status(502).json({ error: 'OpenRouter returned an empty summary' })
|
||||
}
|
||||
|
||||
const updated = await prisma.aiSummaryUsage.upsert({
|
||||
where: {
|
||||
logbookId_entryId: { logbookId: String(logbookId), entryId: String(entryId) }
|
||||
},
|
||||
create: {
|
||||
logbookId: String(logbookId),
|
||||
entryId: String(entryId),
|
||||
count: 1
|
||||
},
|
||||
update: { count: { increment: 1 } }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
summary,
|
||||
remainingAttempts: remainingAttempts(updated.count),
|
||||
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error('AI summary generation failed:', error)
|
||||
return res.status(500).json({ error: 'Failed to generate AI summary' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user