Initial commit: Visigine (Vite client + Express/SQLite backend)
Container-ready via docker/ compose (frontend nginx + backend Node). Compose adjusted for Coolify on the prod server: frontend uses expose:80 (no host binding — host 8080 is taken by the Coolify proxy; Traefik routes visigine.de), backend ALLOWED_ORIGINS=https://visigine.de. Secrets stay in server/.env (git-ignored); see server/.env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
38
server/lib/monitoring/detect-mention.js
Normal file
38
server/lib/monitoring/detect-mention.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Case-insensitive substring search for brand identifiers in LLM output.
|
||||
// Returns the first match's position and a ~200-char surrounding snippet.
|
||||
export function detectMention(text, client) {
|
||||
let aliases = []
|
||||
try { aliases = JSON.parse(client.brand_aliases || '[]') } catch { /* keep [] */ }
|
||||
|
||||
const candidates = [
|
||||
client.name,
|
||||
client.hostname?.split('.')[0],
|
||||
...aliases,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((s) => String(s).toLowerCase().trim())
|
||||
.filter((s) => s.length >= 3)
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
|
||||
if (!text || candidates.length === 0) {
|
||||
return { mentioned: false, position: null, snippet: null }
|
||||
}
|
||||
|
||||
const lower = text.toLowerCase()
|
||||
let best = null
|
||||
for (const candidate of candidates) {
|
||||
const idx = lower.indexOf(candidate)
|
||||
if (idx >= 0 && (best === null || idx < best.position)) {
|
||||
best = { position: idx, candidate }
|
||||
}
|
||||
}
|
||||
if (!best) return { mentioned: false, position: null, snippet: null }
|
||||
|
||||
const start = Math.max(0, best.position - 100)
|
||||
const end = Math.min(text.length, best.position + best.candidate.length + 100)
|
||||
let snippet = text.slice(start, end).trim()
|
||||
if (start > 0) snippet = '…' + snippet
|
||||
if (end < text.length) snippet = snippet + '…'
|
||||
|
||||
return { mentioned: true, position: best.position, snippet }
|
||||
}
|
||||
88
server/lib/monitoring/generate-queries.js
Normal file
88
server/lib/monitoring/generate-queries.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Generate ~10 German search queries that simulate how a potential customer
|
||||
// would ask an AI assistant. Brand name is intentionally excluded so the
|
||||
// monitoring run can measure whether the AI surfaces the brand without bias.
|
||||
const MISTRAL_ENDPOINT = 'https://api.mistral.ai/v1/chat/completions'
|
||||
const MISTRAL_MODEL = 'mistral-large-latest'
|
||||
|
||||
function buildPrompt(siteData) {
|
||||
const name = siteData?.name || 'Unbekanntes Unternehmen'
|
||||
const description = siteData?.description || '[keine Beschreibung verfügbar]'
|
||||
const url = siteData?.url || siteData?.hostname || ''
|
||||
return (
|
||||
`Du bist ein GEO-Analyst (Generative Engine Optimization). Generiere genau 10 deutschsprachige Suchanfragen, die ein echter Nutzer in einer KI-Suchmaschine wie ChatGPT, Perplexity oder Claude eingeben würde, um Unternehmen wie das untenstehende zu finden.
|
||||
|
||||
Kontext:
|
||||
- Firma: ${name}
|
||||
- Beschreibung: ${description}
|
||||
- Domain: ${url}
|
||||
|
||||
Anforderungen:
|
||||
- Genau 10 Anfragen, eine pro Zeile, nummeriert "1. ... 2. ... 3. ..."
|
||||
- Mischung aus: Service-spezifischen Anfragen, Branchen-Anfragen, regionalen Anfragen (DACH falls passend), Vergleichs-/Empfehlungs-Anfragen
|
||||
- ⚠ KEINE Erwähnung des Firmennamens "${name}" in den Anfragen — der Test prüft, ob die KI das Unternehmen ohne Bias nennt
|
||||
- Realistisch formuliert, wie ein normaler Nutzer schreiben würde (nicht zu förmlich)
|
||||
- Auf Deutsch, mit Du-Form oder neutral
|
||||
|
||||
Ausgabe: nur die nummerierte Liste, keine Erklärungen, keine Einleitung.`
|
||||
)
|
||||
}
|
||||
|
||||
async function callMistral(prompt, { timeoutMs = 30000, maxTokens = 800 } = {}) {
|
||||
const apiKey = process.env.MISTRAL_KEY
|
||||
if (!apiKey) throw new Error('NO_MISTRAL_KEY')
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch(MISTRAL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MISTRAL_MODEL,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 0.7,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`MISTRAL_${res.status}`)
|
||||
const data = await res.json()
|
||||
return data?.choices?.[0]?.message?.content || ''
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// Tolerates slight format drift: optional leading whitespace, number followed
|
||||
// by `.` or `)`, optional trailing punctuation. Filters obvious noise.
|
||||
function parseList(text) {
|
||||
return text.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.map((l) => l.replace(/^\d+\s*[.)\-:]\s*/, ''))
|
||||
.map((l) => l.replace(/^[•\-*]\s*/, ''))
|
||||
.filter((l) => l.length >= 10 && l.length <= 300)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
export async function generateQueries(siteData) {
|
||||
const prompt = buildPrompt(siteData)
|
||||
let text
|
||||
try {
|
||||
text = await callMistral(prompt)
|
||||
} catch (err) {
|
||||
console.warn('[monitoring/generate-queries]', err?.message || err)
|
||||
return { queries: [], warning: 'GENERATION_FAILED' }
|
||||
}
|
||||
const queries = parseList(text)
|
||||
// Soft filter: drop any query that still contains the brand name (case-insensitive).
|
||||
const brandLower = (siteData?.name || '').toLowerCase().trim()
|
||||
const filtered = brandLower
|
||||
? queries.filter((q) => !q.toLowerCase().includes(brandLower))
|
||||
: queries
|
||||
if (filtered.length < 5) {
|
||||
return { queries: filtered, warning: 'fewer than 10 generated' }
|
||||
}
|
||||
return { queries: filtered }
|
||||
}
|
||||
97
server/lib/monitoring/run.js
Normal file
97
server/lib/monitoring/run.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// One full run executes every active query against every available provider.
|
||||
// Sequential within (query, provider) pairs to be polite with rate limits;
|
||||
// total wall-clock ~= queries × providers × ~2s.
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getProviders } from '../providers/index.js'
|
||||
import { detectMention } from './detect-mention.js'
|
||||
import * as repo from '../../db/repo.js'
|
||||
|
||||
const INTER_REQUEST_MS = 200
|
||||
|
||||
export class RunError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message || code)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMonitoring(clientId) {
|
||||
const client = repo.getClient(clientId)
|
||||
if (!client) throw new RunError('CLIENT_NOT_FOUND')
|
||||
if (client.status === 'paused' || client.status === 'archived') {
|
||||
throw new RunError('CLIENT_NOT_ACTIVE')
|
||||
}
|
||||
const queries = repo.listActiveQueries(clientId)
|
||||
if (queries.length === 0) throw new RunError('NO_ACTIVE_QUERIES')
|
||||
|
||||
const providers = getProviders()
|
||||
const providerKeys = Object.keys(providers)
|
||||
|
||||
const summary = {
|
||||
runId: randomUUID().slice(0, 8),
|
||||
client_id: clientId,
|
||||
hostname: client.hostname,
|
||||
started_at: new Date().toISOString(),
|
||||
providers: providerKeys,
|
||||
queries: queries.length,
|
||||
totals: { runs: 0, mentions: 0, errors: 0, cost_usd: 0 },
|
||||
}
|
||||
|
||||
const wallStart = Date.now()
|
||||
|
||||
for (const query of queries) {
|
||||
for (const key of providerKeys) {
|
||||
const t0 = Date.now()
|
||||
let result
|
||||
try {
|
||||
result = await providers[key].query(query.text, { brandHint: client.name })
|
||||
} catch (e) {
|
||||
result = {
|
||||
provider: key,
|
||||
content: '',
|
||||
ms: Date.now() - t0,
|
||||
costUsd: 0,
|
||||
error: e?.code || e?.message || 'UNKNOWN',
|
||||
}
|
||||
}
|
||||
|
||||
const mention = result.error
|
||||
? { mentioned: false, position: null, snippet: null }
|
||||
: detectMention(result.content, client)
|
||||
|
||||
repo.insertRun({
|
||||
client_id: clientId,
|
||||
query_id: query.id,
|
||||
provider: result.provider || key,
|
||||
mentioned: mention.mentioned,
|
||||
position: mention.position,
|
||||
snippet: mention.snippet,
|
||||
response_full: result.content || null,
|
||||
ms: result.ms || (Date.now() - t0),
|
||||
cost_usd: result.costUsd || 0,
|
||||
error: result.error || null,
|
||||
})
|
||||
|
||||
summary.totals.runs++
|
||||
if (mention.mentioned) summary.totals.mentions++
|
||||
if (result.error) summary.totals.errors++
|
||||
summary.totals.cost_usd += result.costUsd || 0
|
||||
|
||||
await new Promise((r) => setTimeout(r, INTER_REQUEST_MS))
|
||||
}
|
||||
}
|
||||
|
||||
repo.touchClientRun(clientId)
|
||||
summary.finished_at = new Date().toISOString()
|
||||
summary.totals.cost_usd = Number(summary.totals.cost_usd.toFixed(6))
|
||||
|
||||
const ms = Date.now() - wallStart
|
||||
console.log(
|
||||
`[monitoring] runId=${summary.runId} client=${client.hostname} queries=${queries.length} ` +
|
||||
`providers=${providerKeys.length} runs=${summary.totals.runs} ` +
|
||||
`mentions=${summary.totals.mentions} errors=${summary.totals.errors} ` +
|
||||
`cost_usd=${summary.totals.cost_usd} ms=${ms}`
|
||||
)
|
||||
summary.ms = ms
|
||||
return summary
|
||||
}
|
||||
Reference in New Issue
Block a user