GenAI Anbindung
This commit is contained in:
parent
9c68c414d8
commit
1a1f3bb33f
|
|
@ -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.<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.
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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<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 updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): number =>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<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 =>
|
||||
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<FeedbackCategory>('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<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 updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
|
||||
updateRow('feedbackCategories', id, patch)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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: <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
|
||||
|
||||
/** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */
|
||||
|
|
|
|||
|
|
@ -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<FeedbackDraft | undefined> =>
|
||||
rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId)
|
||||
|
|
@ -39,3 +41,15 @@ export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>):
|
|||
rpc('assignmentFeedback', 'addFeedbackDimensionText', t)
|
||||
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): Promise<number> =>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<FeedbackCriterionItem, 'id'>):
|
|||
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> =>
|
||||
rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
116
src/pages/AiPromptConfig.tsx
Normal file
116
src/pages/AiPromptConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,22 +9,25 @@ import {
|
|||
type AssignmentFeedback,
|
||||
type FeedbackCategoryRating,
|
||||
type FeedbackDimensionText,
|
||||
type FeedbackCategoryText,
|
||||
type FeedbackRating,
|
||||
type MeetingInstance,
|
||||
type Assessment,
|
||||
type ConversationEntry,
|
||||
getAssignment, getConsultant,
|
||||
loadFeedbackStructure,
|
||||
listMeetingInstancesForAssignment, listDoneMeetingsChronological,
|
||||
listDoneMeetingsChronological,
|
||||
listAssessmentsForMeetings, listConversationEntriesForMeetings,
|
||||
getRatingTrendWeightStep,
|
||||
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
|
||||
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating,
|
||||
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
|
||||
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
|
||||
getFeedbackDimensionPrompt,
|
||||
} 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 { splitNoteLines } from '../utils/notationParser'
|
||||
|
||||
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
||||
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
||||
import { buildDimensionPromptContext, renderDimensionPrompt, parseDimensionAiResponse } from '../utils/aiPrompt'
|
||||
|
||||
const ratingColor = (r: FeedbackRating | undefined) =>
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Prompt-Builder ────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildAiPrompt(
|
||||
institutee: Consultant,
|
||||
assignment: Assignment,
|
||||
dimensions: FeedbackDimension[],
|
||||
categories: FeedbackCategory[],
|
||||
items: FeedbackCriterionItem[],
|
||||
ratings: FeedbackCategoryRating[],
|
||||
catTrends: Map<number, TrendResult>,
|
||||
): Promise<string> {
|
||||
const meetings = await listMeetingInstancesForAssignment(assignment.id!)
|
||||
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)
|
||||
|
||||
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.`
|
||||
/** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */
|
||||
function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2.5 space-y-1.5">
|
||||
<div className="text-xs font-semibold text-blue-700">✦ KI-Vorschlag</div>
|
||||
<div className="text-sm text-blue-900 whitespace-pre-wrap">{text}</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={onAccept}
|
||||
className="text-xs bg-blue-600 text-white px-2 py-1 rounded-lg font-medium hover:bg-blue-700">
|
||||
Übernehmen
|
||||
</button>
|
||||
<button onClick={onDiscard}
|
||||
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
|
||||
Verwerfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Komponente ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -184,13 +86,16 @@ export default function AssignmentFeedbackPage() {
|
|||
const [feedback, setFeedback] = useState<AssignmentFeedback | null>(null)
|
||||
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
|
||||
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
|
||||
const [categoryTexts, setCategoryTexts] = useState<FeedbackCategoryText[]>([])
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
|
||||
const [showAiSettings, setShowAiSettings] = useState(false)
|
||||
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
||||
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [generationProgress, setGenerationProgress] = useState('')
|
||||
const [aiError, setAiError] = useState('')
|
||||
const [dimensionErrors, setDimensionErrors] = useState<Record<number, string>>({})
|
||||
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
|
@ -204,7 +109,7 @@ export default function AssignmentFeedbackPage() {
|
|||
setDimensions(structure.dimensions)
|
||||
setCategories(structure.categories)
|
||||
setItems(structure.items)
|
||||
const { dimensions: dims, categories: cats, items: itms } = structure
|
||||
const { categories: cats, items: itms } = structure
|
||||
|
||||
const meetings = await listDoneMeetingsChronological(assignmentId)
|
||||
const meetingIds = meetings.map(m => m.id!)
|
||||
|
|
@ -228,16 +133,18 @@ export default function AssignmentFeedbackPage() {
|
|||
setFeedback(fb ?? null)
|
||||
|
||||
if (fb?.id) {
|
||||
const [rs, txts] = await Promise.all([
|
||||
const [rs, txts, catTxts] = await Promise.all([
|
||||
listFeedbackCategoryRatings(fb.id),
|
||||
listFeedbackDimensionTexts(fb.id),
|
||||
listFeedbackCategoryTexts(fb.id),
|
||||
])
|
||||
setRatings(rs)
|
||||
setDimTexts(txts)
|
||||
setCategoryTexts(catTxts)
|
||||
}
|
||||
|
||||
// Alle Dimensionen initial expandiert
|
||||
setExpanded(new Set(dims.map(d => d.id!)))
|
||||
setExpanded(new Set(structure.dimensions.map(d => d.id!)))
|
||||
}, [assignmentId, instId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
|
@ -261,7 +168,7 @@ export default function AssignmentFeedbackPage() {
|
|||
const getRating = (categoryId: number): FeedbackRating | undefined =>
|
||||
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating
|
||||
|
||||
// ── Text speichern (debounced) ────────────────────────────────────────────
|
||||
// ── Dimension-Text speichern (debounced) ──────────────────────────────────
|
||||
|
||||
const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => {
|
||||
setDimTexts(prev => {
|
||||
|
|
@ -269,7 +176,7 @@ export default function AssignmentFeedbackPage() {
|
|||
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 }]
|
||||
})
|
||||
const key = `${dimId}-${field}`
|
||||
const key = `dim-${dimId}-${field}`
|
||||
const prev = saveTimer.current.get(key)
|
||||
if (prev) clearTimeout(prev)
|
||||
saveTimer.current.set(key, setTimeout(async () => {
|
||||
|
|
@ -302,56 +209,161 @@ export default function AssignmentFeedbackPage() {
|
|||
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
|
||||
const dimCategories = categories.filter(c => c.dimensionId === dim.id)
|
||||
const dimItems = items.filter(i => dimCategories.some(c => c.id === i.categoryId))
|
||||
|
||||
const promptCfg = await getFeedbackDimensionPrompt(dim.id!)
|
||||
const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${settings.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': window.location.origin,
|
||||
'X-Title': 'Assignment Monitor',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
|
||||
|
||||
const data = await res.json()
|
||||
const text = data.choices?.[0]?.message?.content ?? ''
|
||||
const parsed = parseDimensionAiResponse(text)
|
||||
|
||||
const existingDimText = await getFeedbackDimensionText(feedback.id, dim.id!)
|
||||
if (existingDimText?.id) {
|
||||
await updateFeedbackDimensionText(existingDimText.id, {
|
||||
suggestedAchievements: parsed.achievements,
|
||||
suggestedDevelopmentNeeds: parsed.developmentNeeds,
|
||||
})
|
||||
} else {
|
||||
await addFeedbackDimensionText({
|
||||
assignmentFeedbackId: feedback.id,
|
||||
feedbackDimensionId: dim.id!,
|
||||
achievements: '', 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 prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, catTrends)
|
||||
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)
|
||||
|
||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${settings.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': window.location.origin,
|
||||
'X-Title': 'Assignment Monitor',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`)
|
||||
|
||||
const data = await res.json()
|
||||
const text = data.choices?.[0]?.message?.content ?? ''
|
||||
const parsed = JSON.parse(text) as { dimensions: { name: string; achievements: string; developmentNeeds: string }[] }
|
||||
|
||||
// Texte in DB schreiben
|
||||
for (const item of parsed.dimensions) {
|
||||
const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0]))
|
||||
if (!dim?.id) continue
|
||||
const existing = await getFeedbackDimensionText(feedback.id, dim.id)
|
||||
if (existing?.id) {
|
||||
await updateFeedbackDimensionText(existing.id, {
|
||||
achievements: item.achievements,
|
||||
developmentNeeds: item.developmentNeeds,
|
||||
})
|
||||
} else {
|
||||
await addFeedbackDimensionText({
|
||||
assignmentFeedbackId: feedback.id,
|
||||
feedbackDimensionId: dim.id,
|
||||
achievements: item.achievements,
|
||||
developmentNeeds: item.developmentNeeds,
|
||||
})
|
||||
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() })
|
||||
|
|
@ -360,9 +372,16 @@ export default function AssignmentFeedbackPage() {
|
|||
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
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 () => {
|
||||
if (!feedback?.id) return
|
||||
const next = feedback.status === 'final' ? 'draft' : 'final'
|
||||
|
|
@ -440,14 +459,14 @@ export default function AssignmentFeedbackPage() {
|
|||
{/* KI-Generieren Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={generateWithAi}
|
||||
onClick={generateAllDimensions}
|
||||
disabled={generating}
|
||||
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
|
||||
generating
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-brand text-white hover:bg-brand-dark'
|
||||
}`}>
|
||||
{generating ? 'Generiere Entwurf…' : '✦ KI-Feedback-Entwurf generieren'}
|
||||
{generating ? (generationProgress || 'Generiere…') : '✦ KI-Feedback-Entwurf generieren'}
|
||||
</button>
|
||||
</div>
|
||||
{aiError && (
|
||||
|
|
@ -455,6 +474,18 @@ export default function AssignmentFeedbackPage() {
|
|||
{aiError}
|
||||
</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 */}
|
||||
{dimensions.map(dim => {
|
||||
|
|
@ -499,6 +530,7 @@ export default function AssignmentFeedbackPage() {
|
|||
{dimCats.map(cat => {
|
||||
const catItems = items.filter(i => i.categoryId === cat.id)
|
||||
const currentRating = getRating(cat.id!)
|
||||
const catText = getCategoryText(cat.id!)
|
||||
return (
|
||||
<div key={cat.id} className="space-y-2">
|
||||
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
|
||||
|
|
@ -556,13 +588,30 @@ export default function AssignmentFeedbackPage() {
|
|||
))}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Textfelder */}
|
||||
<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">
|
||||
Achievements
|
||||
</label>
|
||||
|
|
@ -572,8 +621,15 @@ export default function AssignmentFeedbackPage() {
|
|||
value={text?.achievements ?? ''}
|
||||
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 className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
|
||||
Development Needs
|
||||
</label>
|
||||
|
|
@ -583,6 +639,13 @@ export default function AssignmentFeedbackPage() {
|
|||
value={text?.developmentNeeds ?? ''}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { useRef, useState } from 'react'
|
|||
import FeedbackStructureConfig from './FeedbackStructureConfig'
|
||||
import AssignmentTypeConfig from './AssignmentTypeConfig'
|
||||
import RatingWeightConfig from './RatingWeightConfig'
|
||||
import AiPromptConfig from './AiPromptConfig'
|
||||
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
|
||||
|
||||
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 [restoreConfirm, setRestoreConfirm] = 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'}`}>
|
||||
Gewichtung
|
||||
</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')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||
Backup
|
||||
|
|
@ -71,6 +76,7 @@ export default function Configuration() {
|
|||
{tab === 'feedback' && <FeedbackStructureConfig />}
|
||||
{tab === 'types' && <AssignmentTypeConfig />}
|
||||
{tab === 'weighting' && <RatingWeightConfig />}
|
||||
{tab === 'aiPrompts' && <AiPromptConfig />}
|
||||
|
||||
{tab === 'backup' && (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
175
src/utils/aiPrompt.ts
Normal file
175
src/utils/aiPrompt.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -16,5 +16,5 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["server", "src/db/types.ts"]
|
||||
"include": ["server", "src/db/types.ts", "src/config/constants.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user