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 /** 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 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 { 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 } }