CG-Feedback-Monitor/src/config/constants.ts
Lars 234f87d408 V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:35:05 +02:00

229 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 110 — 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<RatingBandKey, { color: string; active: string; hex: string }> = {
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: 12 rot, 35 orange, 68 hellgrün, 910 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 110 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 110; 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: 110 (Trend-Vorschlag beachten — bestätigen oder maximal ±1 abweichen)
- BENEFITS: 13 kurze positive Beobachtungen (Stichpunkte mit "- ")
- CONCERNS: 13 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:
- <Stichpunkt>
CONCERNS:
- <Stichpunkt>`,
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:
- <Hauptpunkt>
DEVELOPMENT_NEEDS:
- <Beobachtung>`,
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 (110)' },
{ 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 <Nummer>: <Kriterium-ID>, <Kriterium-ID>, ...
ZEILE <Nummer>: -
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:
<Kriterium-ID>: <kurze Beschreibung typischer Formulierungen für dieses Kriterium>`,
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 110-Skala zwischen erstem und letztem Meeting, ab dem ein Trend gilt. */
export const RATING_TREND_DELTA_THRESHOLD = 1