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, parseNotationPrefix } from './notationParser' import { numberToRating, type TrendResult } from './ratingTrend' const ratingToNum = (r: FeedbackRating | null | undefined): number => r ? (RATING_NUM_MAP[r] ?? 0) : 0 const fullRatingLabel = (r: FeedbackRating): string => RATING_OPTIONS.find(o => o.value === r)?.full ?? r /** * Löst ein Notation-Präfix (`+`/`(+)`/`(!)`/`!`/`>`) am Zeilenanfang zu einem ausgeschriebenen, * mit " — " abgetrennten Label auf. Bewusst KEINE eckigen Klammern als Wrapper — die Notizen * nutzen `[Titel]` bereits für Slide-/Themen-Referenzen (siehe SLIDE_RE in notationParser.tsx), * eckige Klammern um das Label würden damit für die KI ununterscheidbar kollidieren. */ const formatNotationLine = (line: string): string => { const { rating, isQuote, text } = parseNotationPrefix(line) const tag = isQuote ? 'Kundenzitat' : rating ? fullRatingLabel(rating) : null return tag ? `${tag} — ${text}` : text } export interface DimensionPromptInput { institutee: Consultant assignment: Assignment dimension: FeedbackDimension dimCategories: FeedbackCategory[] dimItems: FeedbackCriterionItem[] ratings: FeedbackCategoryRating[] catTrends: Map /** 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 { 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 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), ].map(formatNotationLine) // Kriterien ohne jede Beobachtung (weder Score noch Notiz) auslassen — reines Rauschen für die KI. if (scores.length === 0 && notes.length === 0) return null const avgNum = scores.length > 0 ? scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length : null const avgLabel = avgNum !== null ? `${fullRatingLabel(numberToRating(avgNum))} (${avgNum.toFixed(1)})` : 'nur Notiz, keine Bewertung' const label = c.description ? `${c.name} (${c.description})` : c.name return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}` }).filter((l): l is string => l !== null) // Kategorien ohne jede Beobachtung ganz auslassen — tauchen bereits über KATEGORIEN_STRUKTUR/ // KATEGORIE_BEWERTUNGEN/ENTWICKLUNG_VERLAUF auf, hier nur zusätzliches Rauschen. if (lines.length === 0) return null return ` ${cat.name}:\n${lines.join('\n')}` }).filter((l): l is string => l !== null).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 .map(m => { if (!m.generalNotes || m.generalNotes.trim() === '') return null const lines = splitNoteLines(m.generalNotes).map(line => ` - ${formatNotationLine(line)}`) return ` [${m.date}]\n${lines.join('\n')}` }) .filter((x): x is string => x !== null) .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 => ` - ${formatNotationLine(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 { 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 } }