diff --git a/CLAUDE.md b/CLAUDE.md index ec10616..dbcb6b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,9 +28,11 @@ src/ pages/ meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden) + AiPromptConfig.tsx ← Settings-Tab „KI-Prompts" — ein editierbares Prompt-Template je Dimension, Default-Vorbelegung + Reset (in Configuration.tsx eingebunden, seit 2026-07-07) ...übrige Pages unverändert am alten Ort utils/ ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx) + aiPrompt.ts ← Prompt-Engine für die KI-Feedback-Generierung (seit 2026-07-07): Platzhalter-Kontext pro Dimension, Rendering, Antwort-Parsing — siehe eigener Abschnitt „KI-Feedback-Generierung" unten server/ index.ts ← Express-App, ein Endpunkt POST /api/rpc database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts @@ -48,6 +50,8 @@ Grund: Mehrere Browser (Chrome, Edge, ...) auf demselben Laptop sollen dieselben **Abweichung vom ursprünglich gewählten Stack:** `better-sqlite3` scheiterte lokal an einer fehlenden/kaputten Python-Installation (node-gyp-Kompilierung). `node:sqlite` bietet dieselbe synchrone API (`DatabaseSync`, `.prepare().run()/.get()/.all()`), braucht keine native Kompilierung und ist ab dieser Node-Version (25.x) ohne Flag nutzbar (Node meldet es noch als "experimental" — rein informativ, funktioniert stabil). Falls auf einem anderen Rechner/Server `node:sqlite` fehlt (ältere Node-Version), wäre `better-sqlite3` dort nachzuholen, sofern eine funktionierende Build-Toolchain (Python + C++-Compiler) vorhanden ist. +**Spalten-Migration bei bestehenden Tabellen (seit 2026-07-07):** `database.ts` legt fehlende Tabellen per `CREATE TABLE IF NOT EXISTS` an — das migriert aber **nie** eine bereits existierende Tabelle. Wird `tableRegistry.ts` um eine neue Spalte einer bereits vorhandenen Tabelle ergänzt (z.B. `suggestedAchievements` auf `feedbackDimensionTexts`), gleicht `database.ts` das zusätzlich per `PRAGMA table_info` ab und zieht fehlende Spalten per `ALTER TABLE ... ADD COLUMN` automatisch nach. Ohne das bleibt die alte Spaltenmenge bestehen und jeder Zugriff auf die neue Spalte scheitert erst zur Laufzeit mit „no column named ..." — genau das ist beim Einführen des KI-Prompt-Systems passiert und wurde dabei gefixt. + **RPC statt REST:** Ein einziger Endpunkt `POST /api/rpc` mit Body `{ module, fn, args }` statt ~80 einzelnen Routen — Client (`src/db/rpcClient.ts`) und Server (`server/rpcModules.ts`) sind darüber 1:1 gekoppelt: jede Funktion aus `src/db/queries/*.ts` hat ein gleichnamiges Pendant in `server/db/queries/*.ts`. **Dexie/IndexedDB ist toter Code, aber noch nicht entfernt:** `src/db/schema.ts` und `src/db/seeds/*.ts` bleiben unverändert im Repo liegen, werden aber von nichts mehr gelesen (Seed-Aufrufe in `main.tsx` wurden entfernt). Vollständiges Entfernen ist eine spätere, bewusste Entscheidung. @@ -121,6 +125,7 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` - Meeting-Filter bei Aggregationen über mehrere Meetings: immer `!m.deletedAt && m.status === 'done'` — sonst fließen Papierkorb-/unfertige Meetings mit ein - **Weichmacher/Füllwörter in Formulierungen** (z.B. "ein bisschen") werden bewusst **nicht** über eine eigene Text-Erkennungslogik behandelt — stattdessen legt man dafür ein eigenes, niedrig gewichtetes Kriterium an (z.B. "Sprachliche Präzision", Gewicht < 1) und taggt/notiert entsprechende Zeilen ganz normal (meist mit `(!)`). Die bestehende Tagging- + Notation-Vorschlag-Mechanik deckt das automatisch ab. - Der `äh`-Zähler (`ConversationEntry.fillerCount`) bleibt bewusst **rein informativ** — keine automatische Schwellenwert-Bewertung. Würde eine eigene Design-Entscheidung brauchen (ab wann gilt die Zahl als "zu hoch"), aktuell reicht die reine Anzeige als Grundlage für die manuelle Bewertung. +- **Cross-Boundary-Imports von `src/` nach `server/`**: Dateien, die (wie `src/db/types.ts`) sowohl vom Client als auch vom Server importiert werden, müssen frei von Laufzeit-Abhängigkeiten auf Browser-Only-Code sein — auch `import type` reicht nicht als Schutz, wenn die importierte Datei selbst wieder aus dem `'../db'`-Barrel importiert (der zieht Dexie/rpcClient/Seeds mit, siehe `src/config/constants.ts`-Fix vom 2026-07-07: Import auf `'../db/types'` direkt umgestellt). Jede neue Datei dieser Art muss zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen werden (sonst „File is not listed within the file list of project" bzw. sie wird stillschweigend nicht mitkompiliert) — Muster: `src/db/types.ts`, `src/config/constants.ts`. - `ConversationEntry` entspricht weiterhin **einem ganzen Sprecher-Turn** (ein zusammenhängender, mehrzeiliger Text-Block bis "✓ Schließen") — bewusst so belassen, ein Zwischenstand mit "ein Entry pro Zeile" wurde am 2026-07-02 wieder verworfen, weil er die Lesbarkeit des Protokolls zerstört hat. Kriterium-Zuordnung passiert stattdessen **pro Zeile innerhalb** eines Entry über `ConversationEntry.lineTags` (Matching per exaktem Zeilentext, nicht per Index — robust gegenüber nachträglichem Einfügen/Löschen von Zeilen). Zeilen splitten immer über `splitNoteLines()` in `src/utils/notationParser.tsx`, nicht erneut `note.split('\n')` inline schreiben (sonst laufen Trim-/Filter-Verhalten auseinander). --- @@ -138,14 +143,14 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` | Prio | Aufgabe | Datei | |---|---|---| -| H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` | -| H | **Größeres Vorhaben (noch nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem — Ablösung des aktuellen Einzel-Prompts in `buildAiPrompt`. Braucht eigene Design-Session. | `src/pages/AssignmentFeedbackPage.tsx` | | M | **Selbstlernendes Tagging (Idee, noch nicht gescoped):** Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten (ähnliche Formulierungen → gleiches Kriterium). Braucht eigene Design-Session (Ähnlichkeits-/Lernmechanik, Datenbasis, UI). | `src/pages/meeting/CriterionTagPicker.tsx` | | M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — | | L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` | | L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) | | L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` | +**Erledigt (2026-07-07):** KI-Feedback-Generierung neu konzipiert und umgesetzt — löst den zuvor diagnostizierten `response_format`/Markdown-Codefence-Bug strukturell statt punktuell. Statt einem Monolith-Call für alle Dimensionen macht `generateAllDimensions()` (`AssignmentFeedbackPage.tsx`) jetzt **sequenziell einen Call pro Dimension**, mit Fortschrittsanzeige und Teil-Fehlerbehandlung (fehlgeschlagene Dimensionen einzeln erneut versuchbar, `dimensionErrors`). Jede Dimension hat einen **vollständig frei editierbaren Prompt** (`FeedbackDimensionPrompt`, neuer Admin-Tab „KI-Prompts" → `AiPromptConfig.tsx`), der bei `addFeedbackDimension()` automatisch mit einem Default-Template vorbelegt wird (`AI_CONFIG.defaultDimensionPromptTemplate`) — neue Dimensionen funktionieren dadurch ohne Zutun. Antwortformat ist jetzt einfacher Text statt JSON (`KATEGORIE:`/`ACHIEVEMENTS:`/`DEVELOPMENT_NEEDS:`-Marker, geparst in `src/utils/aiPrompt.ts::parseDimensionAiResponse`), da pro Call ohnehin nur eine Dimension behandelt wird — macht den JSON/Codefence-Bug strukturell unmöglich statt ihn nur zu patchen. KI-generierte Texte landen zunächst als **Vorschlag** (`suggestedAchievements`/`suggestedDevelopmentNeeds` auf `FeedbackDimensionText`, `suggestedText` auf neuer Tabelle `FeedbackCategoryText`) und werden erst durch expliziten „Übernehmen"-Klick in die echten Felder kopiert (gleiches Muster wie die bestehende Rating-Vorschlags-Badge) — persistiert, übersteht also einen Seitenwechsel. Neu: kurzer KI-Text zusätzlich auf Kategorie-Ebene (`FeedbackCategoryText`), nicht nur auf Dimension-Ebene. Cross-Boundary-Falle dabei gefunden: `src/config/constants.ts` importierte bisher `FeedbackRating` aus dem Barrel `'../db'` (re-exportiert Dexie/rpcClient/Seeds) statt direkt aus `'../db/types'` — beim Import von `AI_CONFIG` in `server/db/queries/feedbackStructure.ts` hätte das transitiv Browser-only-Code (`fetch`, `localStorage`) in den Server-TS-Scope gezogen; auf direkten Typ-Import umgestellt und `src/config/constants.ts` zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen (gleiches Muster wie das dort schon gelistete `src/db/types.ts`). + **Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment. **Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — alle 20 Komponenten/Utils mit vorherigem direktem `db..*`-Zugriff nutzen jetzt benannte Funktionen aus `src/db/queries/*.ts` (siehe eigener Abschnitt oben), reine Extraktion ohne beabsichtigte Verhaltensänderung. Dabei bewusst mitkonsolidiert: die drei unabhängig implementierten Varianten der gewichteten-Durchschnitt-Logik (`ratingTrend.ts`, `AssessmentTab.tsx`, `AssignmentDetail.tsx`) laufen jetzt über eine einzige Funktion `weightedAverageScore()`/`weightedAverageRating()` in `ratingTrend.ts` — dabei einen fehlenden `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt (identische Bug-Klasse wie die zweite Bug-Klasse oben, dort war er noch nicht behoben). Ebenfalls konsolidiert: die "AssignmentType.criteriaIds schränkt sichtbare Kriterien ein"-Filterlogik (vorher dupliziert in `MeetingView.tsx`/`AssignmentDetail.tsx`, jetzt `filterVisibleFeedbackStructure()`) und die doppelten Datenabfragen in `meetingExport.ts`s Markdown-/JSON-Export (jetzt `getMeetingExportData()` in `meetings.ts`). `dbBackup.ts` und Seed-Code bleiben bewusst bei direktem `db`-Zugriff (siehe Abschnitt oben). `npx tsc --noEmit` läuft fehlerfrei. diff --git a/server/database.ts b/server/database.ts index db47569..197cee3 100644 --- a/server/database.ts +++ b/server/database.ts @@ -10,9 +10,25 @@ mkdirSync(dirname(dbPath), { recursive: true }) export const db = new DatabaseSync(dbPath) +/** + * `CREATE TABLE IF NOT EXISTS` legt eine fehlende Tabelle vollständig an, migriert aber niemals eine + * bereits bestehende — neue Spalten, die tableRegistry.ts für eine vorhandene Tabelle ergänzt (z.B. + * suggestedAchievements auf feedbackDimensionTexts, 2026-07-07), fehlen dort sonst dauerhaft und + * führen erst zur Laufzeit zu "no column named ..."-Fehlern. Deshalb zusätzlich per PRAGMA table_info + * abgleichen und fehlende Spalten per ALTER TABLE nachziehen. + */ for (const table of TABLE_DEFINITIONS) { const columnsSql = table.columns.map(c => `"${c.name}" ${c.sqlType}`).join(', ') db.exec(`CREATE TABLE IF NOT EXISTS "${table.name}" (id INTEGER PRIMARY KEY AUTOINCREMENT, ${columnsSql})`) + + const existingColumns = new Set( + (db.prepare(`PRAGMA table_info("${table.name}")`).all() as { name: string }[]).map(c => c.name), + ) + for (const col of table.columns) { + if (!existingColumns.has(col.name)) { + db.exec(`ALTER TABLE "${table.name}" ADD COLUMN "${col.name}" ${col.sqlType}`) + } + } } console.log(`[db] SQLite bereit unter ${dbPath}`) diff --git a/server/db/queries/assignmentFeedback.ts b/server/db/queries/assignmentFeedback.ts index 9ab9856..4217990 100644 --- a/server/db/queries/assignmentFeedback.ts +++ b/server/db/queries/assignmentFeedback.ts @@ -1,5 +1,7 @@ import { getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud' -import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText } from '../../../src/db/types' +import type { + FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText, +} from '../../../src/db/types' export const getFeedbackDraft = (assignmentId: number, instituteeId: number): FeedbackDraft | undefined => getRowsWhereEquals('feedbackDrafts', 'assignmentId', assignmentId).find(d => d.instituteeId === instituteeId) @@ -42,3 +44,15 @@ export const getFeedbackDimensionText = ( export const addFeedbackDimensionText = (t: Omit): number => insertRow('feedbackDimensionTexts', t) export const updateFeedbackDimensionText = (id: number, patch: Partial): number => updateRow('feedbackDimensionTexts', id, patch) + +export const listFeedbackCategoryTexts = (assignmentFeedbackId: number): FeedbackCategoryText[] => + getRowsWhereEquals('feedbackCategoryTexts', 'assignmentFeedbackId', assignmentFeedbackId) +export const getFeedbackCategoryText = ( + assignmentFeedbackId: number, + feedbackCategoryId: number, +): FeedbackCategoryText | undefined => + getRowsWhereEquals('feedbackCategoryTexts', 'assignmentFeedbackId', assignmentFeedbackId) + .find(t => t.feedbackCategoryId === feedbackCategoryId) +export const addFeedbackCategoryText = (t: Omit): number => insertRow('feedbackCategoryTexts', t) +export const updateFeedbackCategoryText = (id: number, patch: Partial): number => + updateRow('feedbackCategoryTexts', id, patch) diff --git a/server/db/queries/feedbackStructure.ts b/server/db/queries/feedbackStructure.ts index 6a1cd38..5f64bbd 100644 --- a/server/db/queries/feedbackStructure.ts +++ b/server/db/queries/feedbackStructure.ts @@ -2,7 +2,8 @@ import { getAllRows, getRowsWhereIn, insertRow, updateRow, getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, } from '../crud' -import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../../../src/db/types' +import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt } from '../../../src/db/types' +import { AI_CONFIG } from '../../../src/config/constants' export interface FeedbackStructure { dimensions: FeedbackDimension[] @@ -27,20 +28,53 @@ export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getA export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] => getRowsWhereIn('feedbackCriterionItems', 'id', ids) -export const addFeedbackDimension = (d: Omit): number => insertRow('feedbackDimensions', d) +/** Legt zusätzlich einen Default-KI-Prompt für die neue Dimension an, siehe CLAUDE.md „Mehrstufiges Prompt-System". */ +export function addFeedbackDimension(d: Omit): number { + const id = insertRow('feedbackDimensions', d) + insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate }) + return id +} export const updateFeedbackDimension = (id: number, patch: Partial): number => updateRow('feedbackDimensions', id, patch) -/** Cascade: Items der Kategorien → Kategorien → Dimension. */ +/** Cascade: Items der Kategorien → Kategorien → Prompt-Konfiguration → Dimension. */ export function deleteFeedbackDimension(id: number): void { const cats = getRowsWhereEquals('feedbackCategories', 'dimensionId', id) for (const cat of cats) { deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!) } deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id) + deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id) deleteRowById('feedbackDimensions', id) } +/** + * Selbstheilend: Dimensionen, die vor Einführung des Prompt-Systems (2026-07-07) angelegt wurden, + * haben noch keine `feedbackDimensionPrompts`-Zeile — wird hier per Backfill nachgezogen, statt eine + * separate Migration zu brauchen. + */ +export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] { + const dims = getAllRows('feedbackDimensions') + const existingDimIds = new Set(getAllRows('feedbackDimensionPrompts').map(p => p.dimensionId)) + for (const dim of dims) { + if (!existingDimIds.has(dim.id!)) { + insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate }) + } + } + return getAllRows('feedbackDimensionPrompts') +} + +/** Selbstheilend wie `listFeedbackDimensionPrompts` — legt die Zeile bei Bedarf lazy an. */ +export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt { + const existing = getRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', dimensionId)[0] + if (existing) return existing + const id = insertRow('feedbackDimensionPrompts', { dimensionId, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate }) + return { id, dimensionId, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate } +} + +export const updateFeedbackDimensionPrompt = (id: number, patch: Partial): number => + updateRow('feedbackDimensionPrompts', id, patch) + export const addFeedbackCategory = (c: Omit): number => insertRow('feedbackCategories', c) export const updateFeedbackCategory = (id: number, patch: Partial): number => updateRow('feedbackCategories', id, patch) diff --git a/server/db/tableRegistry.ts b/server/db/tableRegistry.ts index c901e97..dd7fd72 100644 --- a/server/db/tableRegistry.ts +++ b/server/db/tableRegistry.ts @@ -142,6 +142,18 @@ export const TABLE_DEFINITIONS: TableDef[] = [ col('feedbackDimensionId', 'INTEGER'), col('achievements', 'TEXT'), col('developmentNeeds', 'TEXT'), + col('suggestedAchievements', 'TEXT'), + col('suggestedDevelopmentNeeds', 'TEXT'), + ]}, + { name: 'feedbackDimensionPrompts', columns: [ + col('dimensionId', 'INTEGER'), + col('promptTemplate', 'TEXT'), + ]}, + { name: 'feedbackCategoryTexts', columns: [ + col('assignmentFeedbackId', 'INTEGER'), + col('feedbackCategoryId', 'INTEGER'), + col('text', 'TEXT'), + col('suggestedText', 'TEXT'), ]}, { name: 'criterionCategoryMappings', columns: [ col('criterionId', 'INTEGER'), diff --git a/src/config/constants.ts b/src/config/constants.ts index ac97f0f..e62fce6 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,4 +1,4 @@ -import type { FeedbackRating } from '../db' +import type { FeedbackRating } from '../db/types' export const BRAND_COLOR = '#0070AD' @@ -34,6 +34,58 @@ export const AI_CONFIG = { localStorageKeyModel: 'ai_model', defaultModel: 'openai/gpt-4o-mini', openRouterBaseUrl: 'https://openrouter.ai/api/v1', + defaultDimensionPromptTemplate: `Du bist Führungskraft und Mentor bei Capgemini. Erstelle einen professionellen, konstruktiven Beitrag zum Performance-Feedback für einen Berater/Trainee, beschränkt auf die Dimension "{{DIMENSION_NAME}}", auf Basis der folgenden Beobachtungsdaten aus echten Kundenterminen. + +BERATER: {{BERATER_NAME}} +ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}} +FÜLLWÖRTER GESAMT: {{FUELLWOERTER_GESAMT}} + +KATEGORIEN DIESER DIMENSION: +{{KATEGORIEN_STRUKTUR}} + +BEOBACHTETE KRITERIEN UND SCORES (0-4): +{{KRITERIEN_SCORES}} + +MANUELLE KATEGORIE-BEWERTUNGEN: +{{KATEGORIE_BEWERTUNGEN}} + +ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologisch je Kategorie): +{{ENTWICKLUNG_VERLAUF}} + +ALLGEMEINE MEETING-NOTIZEN (Berater-unabhängig, chronologisch): +{{NOTIZEN_ALLGEMEIN_MEETING}} + +NICHT EINEM KRITERIUM ZUGEORDNETE PROTOKOLL-NOTIZEN DIESES BERATERS (chronologisch, Notation: + gut, ! negativ, > Kundeninput): +{{PROTOKOLL_UNZUGEORDNET}} + +Schreibe für JEDE der oben gelisteten Kategorien einen kurzen Bewertungstext (2-3 Sätze, konkret, auf Beobachtungen gestützt) sowie abschließend einen Achievements-Absatz (2-4 Sätze, anerkennend) und einen Development-Needs-Absatz (2-4 Sätze, konstruktiv, handlungsorientiert) für die gesamte Dimension. Falls eine Kategorie aus den Daten nicht beurteilbar ist, schreibe "n/a". Beziehe erkennbare Verbesserungen/Verschlechterungen aus der Entwicklung über die Laufzeit ein. Schreibe direkt und persönlich (du-Form), vermeide generische Phrasen. + +Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung: + +KATEGORIE: + + +KATEGORIE: + + +ACHIEVEMENTS: + + +DEVELOPMENT_NEEDS: +`, + 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: '{{KATEGORIEN_STRUKTUR}}', description: 'Liste der Kategorienamen dieser Dimension' }, + { token: '{{KRITERIEN_SCORES}}', description: 'Kriterien mit Ø-Score und zusammengeführten Notizen, gruppiert nach Kategorie' }, + { token: '{{KATEGORIE_BEWERTUNGEN}}', description: 'Manuell gesetzte Kategorie-Bewertungen (Not/Partially/Nearly/Fully)' }, + { token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Chronologischer Bewertungs-Trend je Kategorie über die Meetings' }, + { token: '{{NOTIZEN_ALLGEMEIN_MEETING}}', description: 'Allgemeine, Berater-unabhängige Notizen je Meeting, chronologisch' }, + { token: '{{PROTOKOLL_UNZUGEORDNET}}', description: 'Nicht getaggte Protokollzeilen dieses Beraters, chronologisch je Meeting' }, + { token: '{{FUELLWOERTER_GESAMT}}', description: 'Füllwörter-Summe dieses Beraters über alle Meetings' }, + ], } as const /** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */ diff --git a/src/db/queries/assignmentFeedback.ts b/src/db/queries/assignmentFeedback.ts index 2ef02f5..2df0d55 100644 --- a/src/db/queries/assignmentFeedback.ts +++ b/src/db/queries/assignmentFeedback.ts @@ -1,5 +1,7 @@ import { rpc } from '../rpcClient' -import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText } from '../types' +import type { + FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText, +} from '../types' export const getFeedbackDraft = (assignmentId: number, instituteeId: number): Promise => rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId) @@ -39,3 +41,15 @@ export const addFeedbackDimensionText = (t: Omit): rpc('assignmentFeedback', 'addFeedbackDimensionText', t) export const updateFeedbackDimensionText = (id: number, patch: Partial): Promise => rpc('assignmentFeedback', 'updateFeedbackDimensionText', id, patch) + +export const listFeedbackCategoryTexts = (assignmentFeedbackId: number): Promise => + rpc('assignmentFeedback', 'listFeedbackCategoryTexts', assignmentFeedbackId) +export const getFeedbackCategoryText = ( + assignmentFeedbackId: number, + feedbackCategoryId: number, +): Promise => + rpc('assignmentFeedback', 'getFeedbackCategoryText', assignmentFeedbackId, feedbackCategoryId) +export const addFeedbackCategoryText = (t: Omit): Promise => + rpc('assignmentFeedback', 'addFeedbackCategoryText', t) +export const updateFeedbackCategoryText = (id: number, patch: Partial): Promise => + rpc('assignmentFeedback', 'updateFeedbackCategoryText', id, patch) diff --git a/src/db/queries/feedbackStructure.ts b/src/db/queries/feedbackStructure.ts index fa5ec0b..c566b47 100644 --- a/src/db/queries/feedbackStructure.ts +++ b/src/db/queries/feedbackStructure.ts @@ -1,5 +1,5 @@ import { rpc } from '../rpcClient' -import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../types' +import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt } from '../types' export interface FeedbackStructure { dimensions: FeedbackDimension[] @@ -54,3 +54,11 @@ export const addFeedbackCriterionItem = (i: Omit): export const updateFeedbackCriterionItem = (id: number, patch: Partial): Promise => rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch) export const deleteFeedbackCriterionItem = (id: number): Promise => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id) + +export const listFeedbackDimensionPrompts = (): Promise => + rpc('feedbackStructure', 'listFeedbackDimensionPrompts') +/** Selbstheilend — legt serverseitig bei Bedarf eine fehlende Prompt-Zeile mit Default-Template an. */ +export const getFeedbackDimensionPrompt = (dimensionId: number): Promise => + rpc('feedbackStructure', 'getFeedbackDimensionPrompt', dimensionId) +export const updateFeedbackDimensionPrompt = (id: number, patch: Partial): Promise => + rpc('feedbackStructure', 'updateFeedbackDimensionPrompt', id, patch) diff --git a/src/db/types.ts b/src/db/types.ts index 5986120..ff781fd 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -190,6 +190,24 @@ export interface FeedbackDimensionText { feedbackDimensionId: number achievements: string developmentNeeds: string + suggestedAchievements?: string + suggestedDevelopmentNeeds?: string +} + +// ─── KI-Prompt-Konfiguration & Kategorie-Texte ──────────────────────────────── + +export interface FeedbackDimensionPrompt { + id?: number + dimensionId: number + promptTemplate: string +} + +export interface FeedbackCategoryText { + id?: number + assignmentFeedbackId: number + feedbackCategoryId: number + text: string + suggestedText?: string } // ─── Legacy-Mappings ────────────────────────────────────────────────────────── diff --git a/src/pages/AiPromptConfig.tsx b/src/pages/AiPromptConfig.tsx new file mode 100644 index 0000000..0de3c10 --- /dev/null +++ b/src/pages/AiPromptConfig.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react' +import { + type FeedbackDimension, type FeedbackDimensionPrompt, + loadFeedbackStructure, listFeedbackDimensionPrompts, updateFeedbackDimensionPrompt, +} from '../db' +import { AI_CONFIG } from '../config/constants' + +export default function AiPromptConfig() { + const [dimensions, setDimensions] = useState([]) + const [prompts, setPrompts] = useState([]) + const [expanded, setExpanded] = useState>(new Set()) + const [savedDimId, setSavedDimId] = useState(null) + const saveTimer = useRef>>(new Map()) + + const load = async () => { + const [structure, promptRows] = await Promise.all([ + loadFeedbackStructure(), + listFeedbackDimensionPrompts(), + ]) + setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order)) + setPrompts(promptRows) + } + + useEffect(() => { load() }, []) + + const getPrompt = (dimId: number) => prompts.find(p => p.dimensionId === dimId) + + const saveTemplate = (dimId: number, value: string) => { + setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: value } : p)) + const prevTimer = saveTimer.current.get(dimId) + if (prevTimer) clearTimeout(prevTimer) + saveTimer.current.set(dimId, setTimeout(async () => { + const existing = getPrompt(dimId) + if (!existing?.id) return + await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: value }) + setSavedDimId(dimId) + setTimeout(() => setSavedDimId(null), 2000) + }, 600)) + } + + const resetToDefault = async (dimId: number) => { + const existing = getPrompt(dimId) + if (!existing?.id) return + await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate }) + setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate } : p)) + } + + return ( +
+
+

KI-Prompts je Dimension

+

+ Jede Dimension bekommt bei der KI-Feedback-Generierung einen eigenen, separaten Call. Der Prompt-Text + ist frei editierbar und wird bei neuen Dimensionen automatisch mit einem Standard-Prompt vorbelegt. +

+
+ + {/* Platzhalter-Legende */} +
+
Verfügbare Platzhalter
+
+ {AI_CONFIG.dimensionPromptPlaceholders.map(p => ( +
+ {p.token} + {p.description} +
+ ))} +
+
+ + {/* Dimensionen */} + {dimensions.map(dim => { + const isOpen = expanded.has(dim.id!) + const prompt = getPrompt(dim.id!) + return ( +
+ + + {isOpen && ( +
+