import type { RatingBandKey, RatingColorBand, RatingScore } from '../db/types' export const BRAND_COLOR = '#0070AD' export const RATING_MIN = 1 export const RATING_MAX = 10 /** Kanonische Scores 1–10 — einzige Quelle für Picker/Selects. */ export const RATING_SCORES: readonly RatingScore[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] /** * Abwärtskompatible Options-Liste (short/label/full = Zahl als Text). * Farben kommen dynamisch über `resolveScoreStyle` / Farbbänder — `color`/`active` hier nur Fallback. */ export const RATING_OPTIONS: ReadonlyArray<{ value: RatingScore short: string label: string full: string color: string active: string }> = RATING_SCORES.map(n => ({ value: n, short: String(n), label: String(n), full: String(n), color: 'bg-gray-100 text-gray-600 border-gray-300', active: 'bg-brand text-white border-brand', })) export const BAND_STYLES: Record = { red: { color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500', hex: '#ef4444' }, orange: { color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500', hex: '#f97316' }, // Unselected: light lime vs saturated green — both used bg-green-100 before and looked identical. lightGreen: { color: 'bg-lime-50 text-lime-700 border-lime-300', active: 'bg-lime-400 text-white border-lime-400', hex: '#a3e635' }, darkGreen: { color: 'bg-green-200 text-green-900 border-green-700', active: 'bg-green-700 text-white border-green-700', hex: '#15803d' }, } /** Default: 1–2 rot, 3–5 orange, 6–8 hellgrün, 9–10 dunkelgrün (siehe docs/SCALE_1_TO_10.md). */ export const DEFAULT_RATING_COLOR_BANDS: RatingColorBand[] = [ { max: 2, key: 'red' }, { max: 5, key: 'orange' }, { max: 8, key: 'lightGreen' }, { max: 10, key: 'darkGreen' }, ] export function isValidRatingScore(value: unknown): value is RatingScore { const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN return Number.isInteger(n) && n >= RATING_MIN && n <= RATING_MAX } /** Normalisiert DB-/UI-Werte auf 1–10 oder null (SQLite kann INTEGER-Spalten als String liefern). */ export function normalizeRatingScore(value: unknown): RatingScore | null { if (value === null || value === undefined || value === '') return null const n = typeof value === 'number' ? value : Number(value) if (!Number.isInteger(n) || n < RATING_MIN || n > RATING_MAX) return null return n } /** Rundet Mittelwerte auf 1–10; außerhalb / NaN → null. */ export function clampRatingScore(avg: number): RatingScore | null { if (!Number.isFinite(avg)) return null const rounded = Math.round(avg) if (rounded < RATING_MIN || rounded > RATING_MAX) return null return rounded } export function normalizeRatingColorBands(bands: RatingColorBand[] | null | undefined): RatingColorBand[] { if (!bands?.length) return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })) const sorted = [...bands].sort((a, b) => a.max - b.max) if (sorted[sorted.length - 1]?.max !== RATING_MAX) return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })) return sorted } export function bandForScore(score: RatingScore, bands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS): RatingColorBand { const normalized = normalizeRatingColorBands(bands) return normalized.find(b => score <= b.max) ?? normalized[normalized.length - 1] } export function resolveScoreStyle( score: RatingScore | null | undefined, bands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS, ): { value: RatingScore | null; short: string; color: string; active: string; hex: string } { const normalized = normalizeRatingScore(score) if (normalized === null) { return { value: null, short: '–', color: 'bg-gray-100 text-gray-500 border-gray-300', active: 'bg-gray-400 text-white border-gray-400', hex: '#9ca3af', } } const band = bandForScore(normalized, bands) const style = BAND_STYLES[band.key] return { value: normalized, short: String(normalized), color: style.color, active: style.active, hex: style.hex } } /** Farb-Schemata für Teilnehmer-Badges (Index = Reihenfolge im Assignment). */ export const INST_COLORS = [ { bg: 'bg-blue-50', border: 'border-blue-300', tag: 'bg-blue-500', text: 'text-blue-700' }, { bg: 'bg-purple-50', border: 'border-purple-300', tag: 'bg-purple-500', text: 'text-purple-700' }, { bg: 'bg-emerald-50', border: 'border-emerald-300', tag: 'bg-emerald-500', text: 'text-emerald-700' }, { bg: 'bg-orange-50', border: 'border-orange-300', tag: 'bg-orange-500', text: 'text-orange-700' }, ] as const export const AI_CONFIG = { localStorageKeyApiKey: 'ai_api_key', localStorageKeyModel: 'ai_model', defaultModel: 'openai/gpt-4o-mini', openRouterBaseUrl: 'https://openrouter.ai/api/v1', /** * Seit 2026-07-14 nur noch einmaliger Erstbefüllungs-Wert für `appSettings.defaultDimensionPromptTemplate` * (siehe `getOrSeedDefaultDimensionPromptTemplate()` in server/db/queries/appSettings.ts) — der tatsächlich * aktive Standard-Prompt lebt danach in der DB und ist im Admin-Tab „KI-Prompts" editierbar, nicht mehr hier. */ defaultCategoryPromptTemplate: `Du bist Führungskraft bei Capgemini. Erstelle Feedback-Inhalte für EINE Kategorie auf Basis der Beobachtungsdaten aus Kundenterminen. BERATER: {{BERATER_NAME}} ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}} DIMENSION: {{DIMENSION_NAME}} KATEGORIE: {{KATEGORIE_NAME}} {{KATEGORIE_ERLAEUTERUNG}} KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie): {{KRITERIEN_SCORES}} MANUELLE KATEGORIE-BEWERTUNG: {{KATEGORIE_BEWERTUNG}} ENTWICKLUNG ÜBER MEETINGS: {{ENTWICKLUNG_VERLAUF}} {{TREND_VORSCHLAG}} GETAGGTE PROTOKOLLZEILEN (dieser Kategorie zugeordnet): {{PROTOKOLL_GETAGGT}} Schreibe: - SCORE: 1–10 (Trend-Vorschlag beachten — bestätigen oder maximal ±1 abweichen) - BENEFITS: 1–3 kurze positive Beobachtungen (Stichpunkte mit "- ") - CONCERNS: 1–3 kurze kritische Beobachtungen (Stichpunkte mit "- "). Concerns sind Feststellungen, KEINE Handlungsempfehlungen. Nutze ausschließlich die oben genannten Daten. Reicht die Datenlage nicht: SCORE weglassen, BENEFITS/CONCERNS mit "- n/a". Antwortformat (Marker in eigener Zeile oder mit Text in derselben Zeile), ohne Markdown-Codefences: SCORE: <1-10> BENEFITS: - CONCERNS: - `, defaultDimensionPromptTemplate: `Du bist Führungskraft bei Capgemini. Verdichte die folgenden Kategorie-Feedbacks zu übergreifenden Dimension-Hauptpunkten — ohne Rohprotokoll, nur auf Basis der Kategorie-Ergebnisse. BERATER: {{BERATER_NAME}} ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}} DIMENSION: {{DIMENSION_NAME}} KATEGORIE-ERGEBNISSE: {{KATEGORIE_ERGEBNISSE}} Schreibe für die gesamte Dimension: - ACHIEVEMENTS: genau 3 bis 5 übergreifende Benefits (keine bloße Wiederholung einzelner Kategorien) - DEVELOPMENT_NEEDS: genau 3 bis 5 übergreifende Concerns als Beobachtungen (keine Empfehlungsformeln wie "Sollte …") Nutze ausschließlich die Kategorie-Ergebnisse oben. Antwortformat: ACHIEVEMENTS: - DEVELOPMENT_NEEDS: - `, categoryPromptPlaceholders: [ { token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' }, { token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' }, { token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' }, { token: '{{DIMENSION_NAME}}', description: 'Name der übergeordneten Dimension' }, { token: '{{KATEGORIE_NAME}}', description: 'Name der aktuellen Kategorie' }, { token: '{{KATEGORIE_ERLAEUTERUNG}}', description: 'Optional: Erläuterung der Kategorie' }, { token: '{{KRITERIEN_SCORES}}', description: 'Kriterien dieser Kategorie mit Ø-Score und Notizen' }, { token: '{{KATEGORIE_BEWERTUNG}}', description: 'Manuelle Kategorie-Bewertung (1–10)' }, { token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Meeting-Verlauf dieser Kategorie' }, { token: '{{TREND_VORSCHLAG}}', description: 'Vorgeschlagener Score aus Meeting-Trend (±1 Regel)' }, { token: '{{PROTOKOLL_GETAGGT}}', description: 'Protokollzeilen, die Kriterien dieser Kategorie zugeordnet sind' }, ], dimensionPromptPlaceholders: [ { token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' }, { token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' }, { token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' }, { token: '{{DIMENSION_NAME}}', description: 'Name der aktuellen Dimension' }, { token: '{{KATEGORIE_ERGEBNISSE}}', description: 'Scores + Benefits/Concerns aller Kategorien (Stufe A)' }, ], defaultCriterionTaggingPromptTemplate: `Du bist Assistent bei der Auswertung von Kundentermin-Protokollen für Capgemini-Trainees (Institutees). Analysiere die folgenden noch nicht einem Bewertungskriterium zugeordneten Protokollzeilen und schlage pro Zeile passende Kriterien vor. ASSIGNMENT-TYP: {{ASSIGNMENT_TYP}} VERFÜGBARE KRITERIEN (Dimension → Kategorie → Kriterium, referenziert über die Zahl in eckigen Klammern): {{KRITERIEN_STRUKTUR}} BEREITS BEKANNTE, TYPISCHE FORMULIERUNGSMUSTER PRO KRITERIUM (aus früheren Analyse-Läufen, sofern vorhanden): {{GELERNTE_MUSTER}} ÄHNLICHE, BEREITS ZUGEORDNETE ALT-ZEILEN (Kontext-Hinweis, keine feste Vorgabe): {{AEHNLICHE_ALT_ZUORDNUNGEN}} NOCH NICHT ZUGEORDNETE PROTOKOLLZEILEN (nummeriert): {{PROTOKOLL_ZEILEN}} Ordne jede Zeile 0 bis 3 Kriterien zu, priorisiert nach Passung (wahrscheinlichstes zuerst). Nutze ausschließlich die oben gelisteten Kriterium-IDs. Wenn keine sinnvolle Zuordnung erkennbar ist, gib "-" an. Erfinde keine Kriterien und keine Beobachtungen, die nicht im Zeilentext stehen. Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung: ZEILE : , , ... ZEILE : - Ergänze danach, nur für Kriterien mit mindestens einer Zuordnung in dieser Analyse, kurze generalisierte Formulierungsmuster (1-2 Sätze, keine Zitate einzelner Zeilen): MUSTER: : `, criterionTaggingPromptPlaceholders: [ { token: '{{ASSIGNMENT_TYP}}', description: 'Name des Assignment-Typs des aktuellen Meetings' }, { token: '{{KRITERIEN_STRUKTUR}}', description: 'ID-referenzierte Struktur Dimension → Kategorie → Kriterium, gefiltert auf die im Assignment-Typ sichtbaren Kriterien, inkl. aller Beschreibungen' }, { token: '{{GELERNTE_MUSTER}}', description: 'KI-destillierte Formulierungsmuster pro Kriterium aus früheren Analyse-Läufen (Nebenprodukt, siehe FeedbackCriterionItem.learnedPatternHints)' }, { token: '{{AEHNLICHE_ALT_ZUORDNUNGEN}}', description: 'Lokal (ohne KI) ermittelte, ähnlichste bereits getaggte Alt-Zeilen je noch offener Zeile' }, { token: '{{PROTOKOLL_ZEILEN}}', description: 'Nummerierte, noch nicht zugeordnete Zeilen des gesamten offenen Protokolls (Gesprächsbeiträge + allgemeine Notizen)' }, ], } as const /** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */ export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5 /** Mindest-Punktedelta auf der 1–10-Skala zwischen erstem und letztem Meeting, ab dem ein Trend gilt. */ export const RATING_TREND_DELTA_THRESHOLD = 1