GenAI Anbindung

This commit is contained in:
Lars 2026-07-07 11:14:00 +02:00
parent 9c68c414d8
commit 1a1f3bb33f
14 changed files with 719 additions and 186 deletions

View File

@ -28,9 +28,11 @@ src/
pages/ pages/
meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx
RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden) 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 ...übrige Pages unverändert am alten Ort
utils/ utils/
ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx) 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/ server/
index.ts ← Express-App, ein Endpunkt POST /api/rpc index.ts ← Express-App, ein Endpunkt POST /api/rpc
database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts 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. **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`. **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. **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 - 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. - **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. - 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). - `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 | | 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 | **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. | — | | 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 | 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 | 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` | | 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, 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.<table>.*`-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. **Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — alle 20 Komponenten/Utils mit vorherigem direktem `db.<table>.*`-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.

View File

@ -10,9 +10,25 @@ mkdirSync(dirname(dbPath), { recursive: true })
export const db = new DatabaseSync(dbPath) 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) { for (const table of TABLE_DEFINITIONS) {
const columnsSql = table.columns.map(c => `"${c.name}" ${c.sqlType}`).join(', ') 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})`) 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}`) console.log(`[db] SQLite bereit unter ${dbPath}`)

View File

@ -1,5 +1,7 @@
import { getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud' 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 => export const getFeedbackDraft = (assignmentId: number, instituteeId: number): FeedbackDraft | undefined =>
getRowsWhereEquals<FeedbackDraft>('feedbackDrafts', 'assignmentId', assignmentId).find(d => d.instituteeId === instituteeId) getRowsWhereEquals<FeedbackDraft>('feedbackDrafts', 'assignmentId', assignmentId).find(d => d.instituteeId === instituteeId)
@ -42,3 +44,15 @@ export const getFeedbackDimensionText = (
export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>): number => insertRow('feedbackDimensionTexts', t) export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>): number => insertRow('feedbackDimensionTexts', t)
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): number => export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): number =>
updateRow('feedbackDimensionTexts', id, patch) updateRow('feedbackDimensionTexts', id, patch)
export const listFeedbackCategoryTexts = (assignmentFeedbackId: number): FeedbackCategoryText[] =>
getRowsWhereEquals<FeedbackCategoryText>('feedbackCategoryTexts', 'assignmentFeedbackId', assignmentFeedbackId)
export const getFeedbackCategoryText = (
assignmentFeedbackId: number,
feedbackCategoryId: number,
): FeedbackCategoryText | undefined =>
getRowsWhereEquals<FeedbackCategoryText>('feedbackCategoryTexts', 'assignmentFeedbackId', assignmentFeedbackId)
.find(t => t.feedbackCategoryId === feedbackCategoryId)
export const addFeedbackCategoryText = (t: Omit<FeedbackCategoryText, 'id'>): number => insertRow('feedbackCategoryTexts', t)
export const updateFeedbackCategoryText = (id: number, patch: Partial<FeedbackCategoryText>): number =>
updateRow('feedbackCategoryTexts', id, patch)

View File

@ -2,7 +2,8 @@ import {
getAllRows, getRowsWhereIn, insertRow, updateRow, getAllRows, getRowsWhereIn, insertRow, updateRow,
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById,
} from '../crud' } 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 { export interface FeedbackStructure {
dimensions: FeedbackDimension[] dimensions: FeedbackDimension[]
@ -27,20 +28,53 @@ export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getA
export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] => export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] =>
getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids) getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids)
export const addFeedbackDimension = (d: Omit<FeedbackDimension, 'id'>): 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<FeedbackDimension, 'id'>): number {
const id = insertRow('feedbackDimensions', d)
insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
return id
}
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number => export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number =>
updateRow('feedbackDimensions', id, patch) updateRow('feedbackDimensions', id, patch)
/** Cascade: Items der Kategorien → Kategorien → Dimension. */ /** Cascade: Items der Kategorien → Kategorien → Prompt-Konfiguration → Dimension. */
export function deleteFeedbackDimension(id: number): void { export function deleteFeedbackDimension(id: number): void {
const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id) const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id)
for (const cat of cats) { for (const cat of cats) {
deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!) deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!)
} }
deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id) deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id)
deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id)
deleteRowById('feedbackDimensions', 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<FeedbackDimension>('feedbackDimensions')
const existingDimIds = new Set(getAllRows<FeedbackDimensionPrompt>('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<FeedbackDimensionPrompt>('feedbackDimensionPrompts')
}
/** Selbstheilend wie `listFeedbackDimensionPrompts` — legt die Zeile bei Bedarf lazy an. */
export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt {
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('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<FeedbackDimensionPrompt>): number =>
updateRow('feedbackDimensionPrompts', id, patch)
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c) export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c)
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number => export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
updateRow('feedbackCategories', id, patch) updateRow('feedbackCategories', id, patch)

View File

@ -142,6 +142,18 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('feedbackDimensionId', 'INTEGER'), col('feedbackDimensionId', 'INTEGER'),
col('achievements', 'TEXT'), col('achievements', 'TEXT'),
col('developmentNeeds', '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: [ { name: 'criterionCategoryMappings', columns: [
col('criterionId', 'INTEGER'), col('criterionId', 'INTEGER'),

View File

@ -1,4 +1,4 @@
import type { FeedbackRating } from '../db' import type { FeedbackRating } from '../db/types'
export const BRAND_COLOR = '#0070AD' export const BRAND_COLOR = '#0070AD'
@ -34,6 +34,58 @@ export const AI_CONFIG = {
localStorageKeyModel: 'ai_model', localStorageKeyModel: 'ai_model',
defaultModel: 'openai/gpt-4o-mini', defaultModel: 'openai/gpt-4o-mini',
openRouterBaseUrl: 'https://openrouter.ai/api/v1', 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: <Name der ersten Kategorie>
<Bewertungstext>
KATEGORIE: <Name der zweiten Kategorie>
<Bewertungstext>
ACHIEVEMENTS:
<Text>
DEVELOPMENT_NEEDS:
<Text>`,
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 } as const
/** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */ /** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */

View File

@ -1,5 +1,7 @@
import { rpc } from '../rpcClient' 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<FeedbackDraft | undefined> => export const getFeedbackDraft = (assignmentId: number, instituteeId: number): Promise<FeedbackDraft | undefined> =>
rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId) rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId)
@ -39,3 +41,15 @@ export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>):
rpc('assignmentFeedback', 'addFeedbackDimensionText', t) rpc('assignmentFeedback', 'addFeedbackDimensionText', t)
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): Promise<number> => export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackDimensionText', id, patch) rpc('assignmentFeedback', 'updateFeedbackDimensionText', id, patch)
export const listFeedbackCategoryTexts = (assignmentFeedbackId: number): Promise<FeedbackCategoryText[]> =>
rpc('assignmentFeedback', 'listFeedbackCategoryTexts', assignmentFeedbackId)
export const getFeedbackCategoryText = (
assignmentFeedbackId: number,
feedbackCategoryId: number,
): Promise<FeedbackCategoryText | undefined> =>
rpc('assignmentFeedback', 'getFeedbackCategoryText', assignmentFeedbackId, feedbackCategoryId)
export const addFeedbackCategoryText = (t: Omit<FeedbackCategoryText, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addFeedbackCategoryText', t)
export const updateFeedbackCategoryText = (id: number, patch: Partial<FeedbackCategoryText>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackCategoryText', id, patch)

View File

@ -1,5 +1,5 @@
import { rpc } from '../rpcClient' import { rpc } from '../rpcClient'
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../types' import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt } from '../types'
export interface FeedbackStructure { export interface FeedbackStructure {
dimensions: FeedbackDimension[] dimensions: FeedbackDimension[]
@ -54,3 +54,11 @@ export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>):
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> => export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch) rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch)
export const deleteFeedbackCriterionItem = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id) export const deleteFeedbackCriterionItem = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id)
export const listFeedbackDimensionPrompts = (): Promise<FeedbackDimensionPrompt[]> =>
rpc('feedbackStructure', 'listFeedbackDimensionPrompts')
/** Selbstheilend — legt serverseitig bei Bedarf eine fehlende Prompt-Zeile mit Default-Template an. */
export const getFeedbackDimensionPrompt = (dimensionId: number): Promise<FeedbackDimensionPrompt> =>
rpc('feedbackStructure', 'getFeedbackDimensionPrompt', dimensionId)
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackDimensionPrompt', id, patch)

View File

@ -190,6 +190,24 @@ export interface FeedbackDimensionText {
feedbackDimensionId: number feedbackDimensionId: number
achievements: string achievements: string
developmentNeeds: 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 ────────────────────────────────────────────────────────── // ─── Legacy-Mappings ──────────────────────────────────────────────────────────

View File

@ -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<FeedbackDimension[]>([])
const [prompts, setPrompts] = useState<FeedbackDimensionPrompt[]>([])
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [savedDimId, setSavedDimId] = useState<number | null>(null)
const saveTimer = useRef<Map<number, ReturnType<typeof setTimeout>>>(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 (
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Prompts je Dimension</h2>
<p className="text-sm text-gray-500 mt-1">
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.
</p>
</div>
{/* Platzhalter-Legende */}
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Verfügbare Platzhalter</div>
<div className="grid gap-1.5">
{AI_CONFIG.dimensionPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
{/* Dimensionen */}
{dimensions.map(dim => {
const isOpen = expanded.has(dim.id!)
const prompt = getPrompt(dim.id!)
return (
<div key={dim.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<button
onClick={() => setExpanded(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between p-3 bg-gray-50 border-b border-gray-100 text-left">
<span className="font-semibold text-sm text-gray-800">{dim.name}</span>
<span className="text-xs text-gray-400">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="p-3 space-y-2">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
value={prompt?.promptTemplate ?? ''}
onChange={e => saveTemplate(dim.id!, e.target.value)}
/>
<div className="flex items-center gap-2">
<button onClick={() => resetToDefault(dim.id!)}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Auf Standard zurücksetzen
</button>
{savedDimId === dim.id && (
<span className="text-xs text-green-600 font-medium">Gespeichert.</span>
)}
</div>
</div>
)}
</div>
)
})}
{dimensions.length === 0 && (
<div className="text-sm text-gray-400 text-center py-6">
Noch keine Dimensionen angelegt siehe Tab Feedback".
</div>
)}
</div>
)
}

View File

@ -9,22 +9,25 @@ import {
type AssignmentFeedback, type AssignmentFeedback,
type FeedbackCategoryRating, type FeedbackCategoryRating,
type FeedbackDimensionText, type FeedbackDimensionText,
type FeedbackCategoryText,
type FeedbackRating, type FeedbackRating,
type MeetingInstance,
type Assessment,
type ConversationEntry,
getAssignment, getConsultant, getAssignment, getConsultant,
loadFeedbackStructure, loadFeedbackStructure,
listMeetingInstancesForAssignment, listDoneMeetingsChronological, listDoneMeetingsChronological,
listAssessmentsForMeetings, listConversationEntriesForMeetings, listAssessmentsForMeetings, listConversationEntriesForMeetings,
getRatingTrendWeightStep, getRatingTrendWeightStep,
getOrCreateAssignmentFeedback, updateAssignmentFeedback, getOrCreateAssignmentFeedback, updateAssignmentFeedback,
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating,
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText, listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
getFeedbackDimensionPrompt,
} from '../db' } from '../db'
import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants' import { RATING_OPTIONS, AI_CONFIG } from '../config/constants'
import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend' import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend'
import { splitNoteLines } from '../utils/notationParser' import { buildDimensionPromptContext, renderDimensionPrompt, parseDimensionAiResponse } from '../utils/aiPrompt'
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
r ? (RATING_NUM_MAP[r] ?? 0) : 0
const ratingColor = (r: FeedbackRating | undefined) => const ratingColor = (r: FeedbackRating | undefined) =>
RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200' RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
@ -47,125 +50,24 @@ function saveAiSettings(apiKey: string, model: string) {
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model) localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
} }
// ── Prompt-Builder ──────────────────────────────────────────────────────────── /** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */
function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) {
async function buildAiPrompt( return (
institutee: Consultant, <div className="bg-blue-50 border border-blue-200 rounded-lg p-2.5 space-y-1.5">
assignment: Assignment, <div className="text-xs font-semibold text-blue-700"> KI-Vorschlag</div>
dimensions: FeedbackDimension[], <div className="text-sm text-blue-900 whitespace-pre-wrap">{text}</div>
categories: FeedbackCategory[], <div className="flex gap-1.5">
items: FeedbackCriterionItem[], <button onClick={onAccept}
ratings: FeedbackCategoryRating[], className="text-xs bg-blue-600 text-white px-2 py-1 rounded-lg font-medium hover:bg-blue-700">
catTrends: Map<number, TrendResult>, Übernehmen
): Promise<string> { </button>
const meetings = await listMeetingInstancesForAssignment(assignment.id!) <button onClick={onDiscard}
const meetingIds = meetings.map(m => m.id!) className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
Verwerfen
const [allAssessments, allEntries] = await Promise.all([ </button>
listAssessmentsForMeetings(meetingIds), </div>
listConversationEntriesForMeetings(meetingIds), </div>
]) )
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const critIds = [...new Set([
...instAssessments.map(a => a.criteriaId),
...allLineTags.map(t => t.criterionItemId),
])]
const crits = items.filter(i => critIds.includes(i.id!))
const scoreLines = crits.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null && a.score !== 'na')
const avg = scores.length > 0
? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1)
: '—'
const notes = [
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
]
return ` ${c.name}: ${avg}/5${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
}).join('\n')
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const protocolLines = instEntries
.flatMap(e => splitNoteLines(e.note).map(line => ({
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
})))
.filter(x => !x.tagged)
.map(x => ` - ${x.line}`)
.join('\n')
// Kategorie-Ratings
const ratingLines = dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const lines = dimCats.map(cat => {
const r = ratings.find(r => r.feedbackCategoryId === cat.id)
const rating = RATING_OPTIONS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet'
return ` ${cat.name}: ${rating}`
}).join('\n')
return ` Dimension "${dim.name}":\n${lines}`
}).join('\n')
// Entwicklung über die Laufzeit (zeitgewichteter Trend pro Kategorie)
const trendLines = dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const lines = dimCats.map(cat => {
const t = catTrends.get(cat.id!)
if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten`
const seq = t.history.map(h => h.score.toFixed(1)).join(' → ')
const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit'
: t.trend === 'down' ? 'Verschlechterung über die Laufzeit'
: t.trend === 'stable' ? 'stabil'
: 'nur ein Meeting, kein Trend ableitbar'
return ` ${cat.name}: Verlauf ${seq} (${trendLabel})`
}).join('\n')
return ` Dimension "${dim.name}":\n${lines}`
}).join('\n')
// Feedback-Struktur für Output
const dimStructure = dimensions.map((dim, i) =>
`${i + 1}. ${dim.name}`
).join('\n')
return `Du bist Führungskraft und Mentor bei Capgemini. Erstelle ein professionelles, konstruktives Performance-Feedback für einen Berater/Trainee auf Basis der folgenden Beobachtungsdaten aus echten Kundenterminen.
BERATER: ${institutee.firstName} ${institutee.lastName}
ASSIGNMENT: ${assignment.title} | Kunde: ${assignment.client}
MEETINGS: ${meetings.length} Kundentermin(e)
FÜLLWÖRTER GESAMT: ${fillerTotal}
BEOBACHTETE KRITERIEN UND SCORES (1-5):
${scoreLines || '(keine Scores erfasst)'}
PROTOKOLL-NOTIZEN, KEINEM KRITERIUM ZUGEORDNET (Notation: + gut, ! negativ, > Kundeninput):
${protocolLines || '(keine nicht zugeordneten Protokolleinträge)'}
KATEGORIE-BEWERTUNGEN:
${ratingLines}
ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologische Kategorie-Scores aus den Meetings, Skala 0-4):
${trendLines}
Generiere für jede der folgenden ${dimensions.length} Dimensionen je einen kurzen Absatz "Achievements" (2-4 Sätze, konkret, anerkennend) und "Development Needs" (2-4 Sätze, konstruktiv, handlungsorientiert). Falls eine Dimension aus den Daten nicht beurteilbar ist, schreibe "n/a". Erwähne eine erkennbare Verbesserung im Achievements-Absatz und eine erkennbare Verschlechterung im Development-Needs-Absatz der jeweiligen Dimension.
Ausgabe als JSON:
{
"dimensions": [
{
"name": "<Dimensionsname>",
"achievements": "<Text>",
"developmentNeeds": "<Text>"
}
]
}
DIMENSIONEN:
${dimStructure}
Wichtig: Beziehe dich auf konkrete Beobachtungen aus den Daten. Schreibe direkt und persönlich (du-Form). Vermeide generische Phrasen.`
} }
// ─── Komponente ─────────────────────────────────────────────────────────────── // ─── Komponente ───────────────────────────────────────────────────────────────
@ -184,13 +86,16 @@ export default function AssignmentFeedbackPage() {
const [feedback, setFeedback] = useState<AssignmentFeedback | null>(null) const [feedback, setFeedback] = useState<AssignmentFeedback | null>(null)
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([]) const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([]) const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
const [categoryTexts, setCategoryTexts] = useState<FeedbackCategoryText[]>([])
const [expanded, setExpanded] = useState<Set<number>>(new Set()) const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map()) const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
const [showAiSettings, setShowAiSettings] = useState(false) const [showAiSettings, setShowAiSettings] = useState(false)
const [aiKey, setAiKey] = useState(getAiSettings().apiKey) const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
const [aiModel, setAiModel] = useState(getAiSettings().model) const [aiModel, setAiModel] = useState(getAiSettings().model)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [generationProgress, setGenerationProgress] = useState('')
const [aiError, setAiError] = useState('') const [aiError, setAiError] = useState('')
const [dimensionErrors, setDimensionErrors] = useState<Record<number, string>>({})
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()) const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const load = useCallback(async () => { const load = useCallback(async () => {
@ -204,7 +109,7 @@ export default function AssignmentFeedbackPage() {
setDimensions(structure.dimensions) setDimensions(structure.dimensions)
setCategories(structure.categories) setCategories(structure.categories)
setItems(structure.items) setItems(structure.items)
const { dimensions: dims, categories: cats, items: itms } = structure const { categories: cats, items: itms } = structure
const meetings = await listDoneMeetingsChronological(assignmentId) const meetings = await listDoneMeetingsChronological(assignmentId)
const meetingIds = meetings.map(m => m.id!) const meetingIds = meetings.map(m => m.id!)
@ -228,16 +133,18 @@ export default function AssignmentFeedbackPage() {
setFeedback(fb ?? null) setFeedback(fb ?? null)
if (fb?.id) { if (fb?.id) {
const [rs, txts] = await Promise.all([ const [rs, txts, catTxts] = await Promise.all([
listFeedbackCategoryRatings(fb.id), listFeedbackCategoryRatings(fb.id),
listFeedbackDimensionTexts(fb.id), listFeedbackDimensionTexts(fb.id),
listFeedbackCategoryTexts(fb.id),
]) ])
setRatings(rs) setRatings(rs)
setDimTexts(txts) setDimTexts(txts)
setCategoryTexts(catTxts)
} }
// Alle Dimensionen initial expandiert // Alle Dimensionen initial expandiert
setExpanded(new Set(dims.map(d => d.id!))) setExpanded(new Set(structure.dimensions.map(d => d.id!)))
}, [assignmentId, instId]) }, [assignmentId, instId])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
@ -261,7 +168,7 @@ export default function AssignmentFeedbackPage() {
const getRating = (categoryId: number): FeedbackRating | undefined => const getRating = (categoryId: number): FeedbackRating | undefined =>
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating ratings.find(r => r.feedbackCategoryId === categoryId)?.rating
// ── Text speichern (debounced) ──────────────────────────────────────────── // ── Dimension-Text speichern (debounced) ──────────────────────────────────
const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => { const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => {
setDimTexts(prev => { setDimTexts(prev => {
@ -269,7 +176,7 @@ export default function AssignmentFeedbackPage() {
if (existing) return prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [field]: value } : t) if (existing) return prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [field]: value } : t)
return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackDimensionId: dimId, achievements: '', developmentNeeds: '', [field]: value }] return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackDimensionId: dimId, achievements: '', developmentNeeds: '', [field]: value }]
}) })
const key = `${dimId}-${field}` const key = `dim-${dimId}-${field}`
const prev = saveTimer.current.get(key) const prev = saveTimer.current.get(key)
if (prev) clearTimeout(prev) if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => { saveTimer.current.set(key, setTimeout(async () => {
@ -302,17 +209,82 @@ export default function AssignmentFeedbackPage() {
saveDimText(dimId, field, existing ? `${existing}\n${sentence}` : sentence) saveDimText(dimId, field, existing ? `${existing}\n${sentence}` : sentence)
} }
// ── KI-Generierung ──────────────────────────────────────────────────────── const acceptDimSuggestion = (dimId: number, field: 'achievements' | 'developmentNeeds') => {
const t = getDimText(dimId)
const sugField = field === 'achievements' ? 'suggestedAchievements' : 'suggestedDevelopmentNeeds'
const value = t?.[sugField]
if (!value) return
saveDimText(dimId, field, value)
discardDimSuggestion(dimId, field)
}
const generateWithAi = async () => { const discardDimSuggestion = async (dimId: number, field: 'achievements' | 'developmentNeeds') => {
const sugField = field === 'achievements' ? 'suggestedAchievements' : 'suggestedDevelopmentNeeds'
setDimTexts(prev => prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [sugField]: '' } : t))
if (!feedback?.id) return
const existing = await getFeedbackDimensionText(feedback.id, dimId)
if (existing?.id) await updateFeedbackDimensionText(existing.id, { [sugField]: '' })
}
// ── Kategorie-Text speichern (debounced) ──────────────────────────────────
const getCategoryText = (catId: number) => categoryTexts.find(t => t.feedbackCategoryId === catId)
const saveCategoryText = (catId: number, value: string) => {
setCategoryTexts(prev => {
const existing = prev.find(t => t.feedbackCategoryId === catId)
if (existing) return prev.map(t => t.feedbackCategoryId === catId ? { ...t, text: value } : t)
return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackCategoryId: catId, text: value }]
})
const key = `cat-${catId}`
const prev = saveTimer.current.get(key)
if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => {
if (!feedback?.id) return
const existing = await getFeedbackCategoryText(feedback.id, catId)
if (existing?.id) {
await updateFeedbackCategoryText(existing.id, { text: value })
} else {
await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: catId, text: value })
}
}, 600))
}
const acceptCategorySuggestion = (catId: number) => {
const value = getCategoryText(catId)?.suggestedText
if (!value) return
saveCategoryText(catId, value)
discardCategorySuggestion(catId)
}
const discardCategorySuggestion = async (catId: number) => {
setCategoryTexts(prev => prev.map(t => t.feedbackCategoryId === catId ? { ...t, suggestedText: '' } : t))
if (!feedback?.id) return
const existing = await getFeedbackCategoryText(feedback.id, catId)
if (existing?.id) await updateFeedbackCategoryText(existing.id, { suggestedText: '' })
}
// ── KI-Generierung: ein Call pro Dimension ────────────────────────────────
const generateOneDimension = async (
dim: FeedbackDimension,
meetings: MeetingInstance[],
instAssessments: Assessment[],
instEntries: ConversationEntry[],
settings: { apiKey: string; model: string },
) => {
if (!institutee || !assignment || !feedback?.id) return if (!institutee || !assignment || !feedback?.id) return
const settings = getAiSettings() const dimCategories = categories.filter(c => c.dimensionId === dim.id)
if (!settings.apiKey) { setShowAiSettings(true); return } const dimItems = items.filter(i => dimCategories.some(c => c.id === i.categoryId))
setGenerating(true) const promptCfg = await getFeedbackDimensionPrompt(dim.id!)
setAiError('') const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
try {
const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, catTrends) const context = buildDimensionPromptContext({
institutee, assignment, dimension: dim, dimCategories, dimItems,
ratings, catTrends, instAssessments, instEntries, meetings,
})
const prompt = renderDimensionPrompt(template, context)
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', method: 'POST',
@ -325,34 +297,74 @@ export default function AssignmentFeedbackPage() {
body: JSON.stringify({ body: JSON.stringify({
model: settings.model, model: settings.model,
messages: [{ role: 'user', content: prompt }], messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}), }),
}) })
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`)
const data = await res.json() const data = await res.json()
const text = data.choices?.[0]?.message?.content ?? '' const text = data.choices?.[0]?.message?.content ?? ''
const parsed = JSON.parse(text) as { dimensions: { name: string; achievements: string; developmentNeeds: string }[] } const parsed = parseDimensionAiResponse(text)
// Texte in DB schreiben const existingDimText = await getFeedbackDimensionText(feedback.id, dim.id!)
for (const item of parsed.dimensions) { if (existingDimText?.id) {
const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0])) await updateFeedbackDimensionText(existingDimText.id, {
if (!dim?.id) continue suggestedAchievements: parsed.achievements,
const existing = await getFeedbackDimensionText(feedback.id, dim.id) suggestedDevelopmentNeeds: parsed.developmentNeeds,
if (existing?.id) {
await updateFeedbackDimensionText(existing.id, {
achievements: item.achievements,
developmentNeeds: item.developmentNeeds,
}) })
} else { } else {
await addFeedbackDimensionText({ await addFeedbackDimensionText({
assignmentFeedbackId: feedback.id, assignmentFeedbackId: feedback.id,
feedbackDimensionId: dim.id, feedbackDimensionId: dim.id!,
achievements: item.achievements, achievements: '', developmentNeeds: '',
developmentNeeds: item.developmentNeeds, suggestedAchievements: parsed.achievements,
suggestedDevelopmentNeeds: parsed.developmentNeeds,
}) })
} }
for (const ct of parsed.categoryTexts) {
const cat = dimCategories.find(c => c.name === ct.name || ct.name.includes(c.name) || c.name.includes(ct.name))
if (!cat?.id) continue
const existing = await getFeedbackCategoryText(feedback.id, cat.id)
if (existing?.id) {
await updateFeedbackCategoryText(existing.id, { suggestedText: ct.text })
} else {
await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: cat.id, text: '', suggestedText: ct.text })
}
}
}
/** Sequenziell mit Fortschrittsanzeige — Fehler einzelner Dimensionen stoppen den Lauf nicht. */
const runDimensionBatch = async (dimsToRun: FeedbackDimension[]) => {
if (!institutee || !assignment || !feedback?.id || dimsToRun.length === 0) return
const settings = getAiSettings()
if (!settings.apiKey) { setShowAiSettings(true); return }
setGenerating(true)
setAiError('')
setDimensionErrors(prev => {
const next = { ...prev }
for (const d of dimsToRun) delete next[d.id!]
return next
})
try {
const meetings = await listDoneMeetingsChronological(assignmentId)
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
listAssessmentsForMeetings(meetingIds),
listConversationEntriesForMeetings(meetingIds),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
for (let i = 0; i < dimsToRun.length; i++) {
const dim = dimsToRun[i]
setGenerationProgress(`Generiere Dimension ${i + 1}/${dimsToRun.length}: ${dim.name}`)
try {
await generateOneDimension(dim, meetings, instAssessments, instEntries, settings)
} catch (e) {
setDimensionErrors(prev => ({ ...prev, [dim.id!]: e instanceof Error ? e.message : 'Unbekannter Fehler' }))
}
} }
await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() }) await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
await load() await load()
@ -360,9 +372,16 @@ export default function AssignmentFeedbackPage() {
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler') setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally { } finally {
setGenerating(false) setGenerating(false)
setGenerationProgress('')
} }
} }
const generateAllDimensions = () => runDimensionBatch(dimensions)
const retryFailedDimensions = () => {
const failedIds = new Set(Object.keys(dimensionErrors).map(Number))
runDimensionBatch(dimensions.filter(d => failedIds.has(d.id!)))
}
const toggleFinal = async () => { const toggleFinal = async () => {
if (!feedback?.id) return if (!feedback?.id) return
const next = feedback.status === 'final' ? 'draft' : 'final' const next = feedback.status === 'final' ? 'draft' : 'final'
@ -440,14 +459,14 @@ export default function AssignmentFeedbackPage() {
{/* KI-Generieren Button */} {/* KI-Generieren Button */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={generateWithAi} onClick={generateAllDimensions}
disabled={generating} disabled={generating}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${ className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
generating generating
? 'bg-gray-200 text-gray-400 cursor-not-allowed' ? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-brand text-white hover:bg-brand-dark' : 'bg-brand text-white hover:bg-brand-dark'
}`}> }`}>
{generating ? 'Generiere Entwurf…' : '✦ KI-Feedback-Entwurf generieren'} {generating ? (generationProgress || 'Generiere…') : '✦ KI-Feedback-Entwurf generieren'}
</button> </button>
</div> </div>
{aiError && ( {aiError && (
@ -455,6 +474,18 @@ export default function AssignmentFeedbackPage() {
{aiError} {aiError}
</div> </div>
)} )}
{!generating && Object.keys(dimensionErrors).length > 0 && (
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
<div className="font-medium">Fehler bei {Object.keys(dimensionErrors).length} Dimension(en):</div>
{Object.entries(dimensionErrors).map(([dimId, msg]) => (
<div key={dimId}> {dimensions.find(d => d.id === Number(dimId))?.name}: {msg}</div>
))}
<button onClick={retryFailedDimensions}
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-lg font-medium hover:bg-red-200">
Fehlgeschlagene Dimensionen erneut versuchen
</button>
</div>
)}
{/* Dimensionen */} {/* Dimensionen */}
{dimensions.map(dim => { {dimensions.map(dim => {
@ -499,6 +530,7 @@ export default function AssignmentFeedbackPage() {
{dimCats.map(cat => { {dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id) const catItems = items.filter(i => i.categoryId === cat.id)
const currentRating = getRating(cat.id!) const currentRating = getRating(cat.id!)
const catText = getCategoryText(cat.id!)
return ( return (
<div key={cat.id} className="space-y-2"> <div key={cat.id} className="space-y-2">
<div className="font-medium text-sm text-gray-700">{cat.name}</div> <div className="font-medium text-sm text-gray-700">{cat.name}</div>
@ -556,13 +588,30 @@ export default function AssignmentFeedbackPage() {
))} ))}
</div> </div>
)} )}
{/* Kategorie-Notiz */}
<div className="space-y-1.5">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[50px]"
placeholder="Kurze Einschätzung zu dieser Kategorie…"
value={catText?.text ?? ''}
onChange={e => saveCategoryText(cat.id!, e.target.value)}
/>
{catText?.suggestedText && (
<AiSuggestionBox
text={catText.suggestedText}
onAccept={() => acceptCategorySuggestion(cat.id!)}
onDiscard={() => discardCategorySuggestion(cat.id!)}
/>
)}
</div>
</div> </div>
) )
})} })}
{/* Textfelder */} {/* Textfelder */}
<div className="space-y-3 pt-2 border-t border-gray-50"> <div className="space-y-3 pt-2 border-t border-gray-50">
<div> <div className="space-y-1.5">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5"> <label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
Achievements Achievements
</label> </label>
@ -572,8 +621,15 @@ export default function AssignmentFeedbackPage() {
value={text?.achievements ?? ''} value={text?.achievements ?? ''}
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)} onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
/> />
{text?.suggestedAchievements && (
<AiSuggestionBox
text={text.suggestedAchievements}
onAccept={() => acceptDimSuggestion(dim.id!, 'achievements')}
onDiscard={() => discardDimSuggestion(dim.id!, 'achievements')}
/>
)}
</div> </div>
<div> <div className="space-y-1.5">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5"> <label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
Development Needs Development Needs
</label> </label>
@ -583,6 +639,13 @@ export default function AssignmentFeedbackPage() {
value={text?.developmentNeeds ?? ''} value={text?.developmentNeeds ?? ''}
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)} onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
/> />
{text?.suggestedDevelopmentNeeds && (
<AiSuggestionBox
text={text.suggestedDevelopmentNeeds}
onAccept={() => acceptDimSuggestion(dim.id!, 'developmentNeeds')}
onDiscard={() => discardDimSuggestion(dim.id!, 'developmentNeeds')}
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,10 +2,11 @@ import { useRef, useState } from 'react'
import FeedbackStructureConfig from './FeedbackStructureConfig' import FeedbackStructureConfig from './FeedbackStructureConfig'
import AssignmentTypeConfig from './AssignmentTypeConfig' import AssignmentTypeConfig from './AssignmentTypeConfig'
import RatingWeightConfig from './RatingWeightConfig' import RatingWeightConfig from './RatingWeightConfig'
import AiPromptConfig from './AiPromptConfig'
import { exportFullBackup, importFullBackup } from '../utils/dbBackup' import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
export default function Configuration() { export default function Configuration() {
const [tab, setTab] = useState<'feedback' | 'types' | 'weighting' | 'backup'>('feedback') const [tab, setTab] = useState<'feedback' | 'types' | 'weighting' | 'aiPrompts' | 'backup'>('feedback')
const [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null) const [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
const [restoreConfirm, setRestoreConfirm] = useState(false) const [restoreConfirm, setRestoreConfirm] = useState(false)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
@ -53,6 +54,10 @@ export default function Configuration() {
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'weighting' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}> className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'weighting' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Gewichtung Gewichtung
</button> </button>
<button onClick={() => setTab('aiPrompts')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'aiPrompts' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
KI-Prompts
</button>
<button onClick={() => setTab('backup')} <button onClick={() => setTab('backup')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}> className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Backup Backup
@ -71,6 +76,7 @@ export default function Configuration() {
{tab === 'feedback' && <FeedbackStructureConfig />} {tab === 'feedback' && <FeedbackStructureConfig />}
{tab === 'types' && <AssignmentTypeConfig />} {tab === 'types' && <AssignmentTypeConfig />}
{tab === 'weighting' && <RatingWeightConfig />} {tab === 'weighting' && <RatingWeightConfig />}
{tab === 'aiPrompts' && <AiPromptConfig />}
{tab === 'backup' && ( {tab === 'backup' && (
<div className="space-y-4"> <div className="space-y-4">

175
src/utils/aiPrompt.ts Normal file
View File

@ -0,0 +1,175 @@
import type {
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, FeedbackRating,
} from '../db/types'
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
import { splitNoteLines } from './notationParser'
import type { TrendResult } from './ratingTrend'
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
r ? (RATING_NUM_MAP[r] ?? 0) : 0
export interface DimensionPromptInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
dimCategories: FeedbackCategory[]
dimItems: FeedbackCriterionItem[]
ratings: FeedbackCategoryRating[]
catTrends: Map<number, TrendResult>
/** Bereits auf diesen Berater gefiltert. */
instAssessments: Assessment[]
/** Bereits auf diesen Berater gefiltert. */
instEntries: ConversationEntry[]
/** Alle `done`-Meetings des Assignments, chronologisch — siehe `listDoneMeetingsChronological`. */
meetings: MeetingInstance[]
}
/**
* Baut die Platzhalter-Werte für EINE Dimension. Kriterien/Kategorien werden vom Aufrufer
* bereits auf die Dimension gefiltert übergeben, siehe CLAUDE.md Mehrstufiges Prompt-System".
*/
export function buildDimensionPromptContext(input: DimensionPromptInput): Record<string, string> {
const {
institutee, assignment, dimension, dimCategories, dimItems,
ratings, catTrends, instAssessments, instEntries, meetings,
} = input
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const kategorienStruktur = dimCategories
.map((c, i) => `${i + 1}. ${c.name}`)
.join('\n') || '(keine Kategorien)'
const kriterienScores = dimCategories.map(cat => {
const catItems = dimItems.filter(i => i.categoryId === cat.id)
const lines = catItems.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null && a.score !== 'na')
const avg = scores.length > 0
? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1)
: '—'
const notes = [
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
]
return ` ${c.name}: ${avg}/4${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
}).join('\n')
return ` ${cat.name}:\n${lines || ' (keine Kriterien)'}`
}).join('\n') || '(keine Scores erfasst)'
const kategorieBewertungen = dimCategories.map(cat => {
const r = ratings.find(r => r.feedbackCategoryId === cat.id)
const rating = RATING_OPTIONS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet'
return ` ${cat.name}: ${rating}`
}).join('\n') || '(keine Kategorien)'
const entwicklungVerlauf = dimCategories.map(cat => {
const t = catTrends.get(cat.id!)
if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten`
const seq = t.history.map(h => h.score.toFixed(1)).join(' → ')
const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit'
: t.trend === 'down' ? 'Verschlechterung über die Laufzeit'
: t.trend === 'stable' ? 'stabil'
: 'nur ein Meeting, kein Trend ableitbar'
return ` ${cat.name}: Verlauf ${seq} (${trendLabel})`
}).join('\n') || '(keine Kategorien)'
const notizenAllgemeinMeeting = meetings
.filter(m => m.generalNotes && m.generalNotes.trim() !== '')
.map(m => ` [${m.date}] ${m.generalNotes}`)
.join('\n') || '(keine allgemeinen Meeting-Notizen)'
const protokollUnzugeordnet = meetings
.map(m => {
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
const lines = meetingEntries
.flatMap(e => splitNoteLines(e.note).map(line => ({
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
})))
.filter(x => !x.tagged)
.map(x => ` - ${x.line}`)
if (lines.length === 0) return null
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine nicht zugeordneten Protokolleinträge)'
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIEN_STRUKTUR}}': kategorienStruktur,
'{{KRITERIEN_SCORES}}': kriterienScores,
'{{KATEGORIE_BEWERTUNGEN}}': kategorieBewertungen,
'{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf,
'{{NOTIZEN_ALLGEMEIN_MEETING}}': notizenAllgemeinMeeting,
'{{PROTOKOLL_UNZUGEORDNET}}': protokollUnzugeordnet,
'{{FUELLWOERTER_GESAMT}}': String(fillerTotal),
}
}
export function renderDimensionPrompt(template: string, context: Record<string, string>): string {
let result = template
for (const [token, value] of Object.entries(context)) {
result = result.split(token).join(value)
}
return result
}
export interface ParsedDimensionResponse {
categoryTexts: { name: string; text: string }[]
achievements: string
developmentNeeds: string
}
/**
* Entfernt vorsorglich Markdown-Codefences (manche Provider ignorieren die Format-Anweisung im
* Prompt trotzdem) und parst dann per Zeilen-Scan auf die festen KATEGORIE:/ACHIEVEMENTS:/
* DEVELOPMENT_NEEDS:-Marker. Siehe CLAUDE.md löst den früheren response_format-Bug strukturell.
*/
export function parseDimensionAiResponse(raw: string): ParsedDimensionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
const categoryTexts: { name: string; text: string }[] = []
let achievements = ''
let developmentNeeds = ''
let mode: 'none' | 'category' | 'achievements' | 'development' = 'none'
let currentName = ''
let buffer: string[] = []
const flush = () => {
const text = buffer.join('\n').trim()
if (mode === 'category' && currentName) categoryTexts.push({ name: currentName, text })
else if (mode === 'achievements') achievements = text
else if (mode === 'development') developmentNeeds = text
buffer = []
}
for (const line of lines) {
const catMatch = line.match(/^KATEGORIE:\s*(.+)$/i)
const achMatch = line.match(/^ACHIEVEMENTS:\s*$/i)
const devMatch = line.match(/^DEVELOPMENT_NEEDS:\s*$/i)
if (catMatch) {
flush()
mode = 'category'
currentName = catMatch[1].trim()
} else if (achMatch) {
flush()
mode = 'achievements'
currentName = ''
} else if (devMatch) {
flush()
mode = 'development'
currentName = ''
} else {
buffer.push(line)
}
}
flush()
return { categoryTexts, achievements, developmentNeeds }
}

View File

@ -16,5 +16,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["server", "src/db/types.ts"] "include": ["server", "src/db/types.ts", "src/config/constants.ts"]
} }