import type { Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore, } from '../db/types' import { clampRatingScore, isValidRatingScore } from '../config/constants' import { splitNoteLines, parseNotationPrefix } from './notationParser' import type { TrendResult } from './ratingTrend' /** * Löst ein Notations-Präfix (führende Score-Zahl 1–10 bzw. `>`) am Zeilenanfang auf. * Format bewusst „Score N/10 — Text" bzw. „Kundenzitat — Text" — keine eckigen Klammern * als Wrapper, da `[Titel]` bereits für Slide-/Themen-Referenzen reserviert ist. */ const formatNotationLine = (line: string): string => { const { rating, isQuote, text } = parseNotationPrefix(line) const tag = isQuote ? 'Kundenzitat' : rating !== null ? `Score ${rating}/10` : null return tag ? `${tag} — ${text}` : text } /** Ganze Zeile besteht nur aus "[Titel]" — Abschnittsmarker des Bewerters (Slide-/Themen-Referenz). */ const MARKER_ONLY_RE = /^\[[^\]]*\]$/ /** * Abschnittsmarker wie "[Background]" sind reine Navigationshilfe für den Bewerter (an welcher * Stelle der Präsentation/des Dialogs eine Beobachtung entstand) — für die KI ohne nachfolgenden * Inhalt bedeutungslos und keine Bewertungsgrundlage. Bleibt erhalten, wenn die direkt folgende * Zeile noch Inhalt liefert (dann als Kontext-Header sinnvoll), sonst wird die Marker-Zeile entfernt. */ const dropDanglingMarkers = (lines: string[]): string[] => lines.filter((line, i) => { if (!MARKER_ONLY_RE.test(line.trim())) return true const next = lines[i + 1] return next !== undefined && !MARKER_ONLY_RE.test(next.trim()) }) 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}${c.description ? ` — ${c.description}` : ''}`) .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 && isValidRatingScore(a.score)) const generalNotesForCrit = meetings.flatMap(m => (m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text), ) 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), ...generalNotesForCrit, ].map(formatNotationLine) if (scores.length === 0 && notes.length === 0) return null const avgNum = scores.length > 0 ? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length : null const clamped = avgNum !== null ? clampRatingScore(avgNum) : null const avgLabel = avgNum !== null ? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${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) 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(x => x.feedbackCategoryId === cat.id) const rating: RatingScore | undefined = r?.rating const label = isValidRatingScore(rating) ? `${rating}/10` : 'nicht bewertet' return ` ${cat.name}: ${label}` }).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 rawLines = splitNoteLines(m.generalNotes) .filter(line => !(m.generalNoteTags ?? []).some(t => t.text === line)) .filter(line => !parseNotationPrefix(line).isQuote) const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`) if (lines.length === 0) return null 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 rawLines = meetingEntries .flatMap(e => splitNoteLines(e.note).map(line => ({ line, tagged: (e.lineTags ?? []).some(t => t.text === line), }))) .filter(x => !x.tagged) .filter(x => !parseNotationPrefix(x.line).isQuote) .map(x => x.line) const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(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 const renderPrompt = renderDimensionPrompt export interface CategoryPromptInput { institutee: Consultant assignment: Assignment dimension: FeedbackDimension category: FeedbackCategory catItems: FeedbackCriterionItem[] ratings: FeedbackCategoryRating[] catTrend: TrendResult | undefined instAssessments: Assessment[] instEntries: ConversationEntry[] meetings: MeetingInstance[] } export interface CategoryAiResult { score: number | null benefits: string concerns: string } export interface CategoryCondenseEntry { name: string score: number | null benefits: string concerns: string } export interface DimensionCondenseInput { institutee: Consultant assignment: Assignment dimension: FeedbackDimension categoryResults: CategoryCondenseEntry[] } export interface ParsedDimensionCondenseResponse { achievements: string developmentNeeds: string } /** Stufe A: schmaler Kontext für genau eine Kategorie. */ export function buildCategoryPromptContext(input: CategoryPromptInput): Record { const { institutee, assignment, dimension, category, catItems, ratings, catTrend, instAssessments, instEntries, meetings, } = input const catItemIds = new Set(catItems.map(i => i.id)) const allLineTags = instEntries.flatMap(e => e.lineTags ?? []) const kriterienScores = catItems.map(c => { const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score)) const generalNotesForCrit = meetings.flatMap(m => (m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text), ) 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), ...generalNotesForCrit, ].map(formatNotationLine) if (scores.length === 0 && notes.length === 0) return null const avgNum = scores.length > 0 ? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length : null const clamped = avgNum !== null ? clampRatingScore(avgNum) : null const avgLabel = avgNum !== null ? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${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).join('\n') || '(keine Scores/Notizen)' const r = ratings.find(x => x.feedbackCategoryId === category.id) const manualRating = isValidRatingScore(r?.rating) ? `${r!.rating}/10` : 'nicht bewertet' let entwicklungVerlauf = 'keine Verlaufsdaten' if (catTrend && catTrend.history.length > 0) { const seq = catTrend.history.map(h => h.score.toFixed(1)).join(' → ') const trendLabel = catTrend.trend === 'up' ? 'Verbesserung über die Laufzeit' : catTrend.trend === 'down' ? 'Verschlechterung über die Laufzeit' : catTrend.trend === 'stable' ? 'stabil' : 'nur ein Meeting, kein Trend ableitbar' entwicklungVerlauf = `Verlauf ${seq} (${trendLabel})` } let trendVorschlag = 'Kein Meeting-Trend berechenbar — SCORE nur setzen wenn manuelle Bewertung oder Kriterien-Daten vorliegen.' if (catTrend?.suggestion !== null && catTrend?.suggestion !== undefined) { const s = catTrend.suggestion.toFixed(1) trendVorschlag = `Vorgeschlagener Score aus Meeting-Verlauf: ${s}/10. Bestätige diesen Wert im SCORE-Feld oder passe maximal um ±1 an (nicht frei erfinden).` } const protokollGetaggt = meetings .map(m => { const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id) const rawLines = meetingEntries .flatMap(e => splitNoteLines(e.note).map(line => ({ line, tags: (e.lineTags ?? []).filter(t => t.text === line && catItemIds.has(t.criterionItemId)), }))) .filter(x => x.tags.length > 0) .filter(x => !parseNotationPrefix(x.line).isQuote) .map(x => x.line) const generalTagged = (m.generalNoteTags ?? []) .filter(t => catItemIds.has(t.criterionItemId)) .map(t => t.text) const allLines = [...rawLines, ...generalTagged] const lines = dropDanglingMarkers(allLines).map(line => ` - ${formatNotationLine(line)}`) if (lines.length === 0) return null return ` [${m.date}]\n${lines.join('\n')}` }) .filter((x): x is string => x !== null) .join('\n') || '(keine getaggten Zeilen)' const erlaeuterung = category.description ? `Erläuterung: ${category.description}` : '' return { '{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`, '{{ASSIGNMENT_TITEL}}': assignment.title, '{{ASSIGNMENT_KUNDE}}': assignment.client, '{{DIMENSION_NAME}}': dimension.name, '{{KATEGORIE_NAME}}': category.name, '{{KATEGORIE_ERLAEUTERUNG}}': erlaeuterung, '{{KRITERIEN_SCORES}}': kriterienScores, '{{KATEGORIE_BEWERTUNG}}': manualRating, '{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf, '{{TREND_VORSCHLAG}}': trendVorschlag, '{{PROTOKOLL_GETAGGT}}': protokollGetaggt, } } /** Stufe B: Verdichtung aus Kategorie-Ergebnissen (ohne Rohprotokoll). */ export function buildDimensionCondenseContext(input: DimensionCondenseInput): Record { const { institutee, assignment, dimension, categoryResults } = input const kategorieErgebnisse = categoryResults.map(cr => { const scoreLine = cr.score !== null ? `Score: ${cr.score}/10` : 'Score: nicht gesetzt' const ben = cr.benefits.trim() || '- n/a' const con = cr.concerns.trim() || '- n/a' return `${cr.name}\n${scoreLine}\nBenefits:\n${ben}\nConcerns:\n${con}` }).join('\n\n') || '(keine Kategorie-Ergebnisse)' return { '{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`, '{{ASSIGNMENT_TITEL}}': assignment.title, '{{ASSIGNMENT_KUNDE}}': assignment.client, '{{DIMENSION_NAME}}': dimension.name, '{{KATEGORIE_ERGEBNISSE}}': kategorieErgebnisse, } } export function parseCategoryAiResponse(raw: string): CategoryAiResult { const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim() const lines = cleaned.split('\n') let score: number | null = null let benefits = '' let concerns = '' type Mode = 'none' | 'benefits' | 'concerns' let mode: Mode = 'none' let buffer: string[] = [] const takeBuffer = () => { const t = buffer.join('\n').trim() buffer = [] return t } const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim() for (const line of lines) { const scoreMatch = line.match(SCORE_HDR) const benMatch = line.match(BENEFITS_HDR) const conMatch = line.match(CONCERNS_HDR) if (scoreMatch) { score = Number(scoreMatch[1]) continue } if (benMatch) { const leftover = takeBuffer() if (leftover && mode === 'concerns') concerns = append(concerns, leftover) else if (leftover && mode === 'none') { const split = splitLooseCategoryBody(leftover) benefits = append(benefits, split.benefits) concerns = append(concerns, split.concerns) } mode = 'benefits' if (benMatch[2].trim()) benefits = append(benefits, benMatch[2].trim()) continue } if (conMatch) { const leftover = takeBuffer() if (leftover && mode === 'benefits') benefits = append(benefits, leftover) mode = 'concerns' if (conMatch[2].trim()) concerns = append(concerns, conMatch[2].trim()) continue } buffer.push(line) } const leftover = takeBuffer() if (leftover) { if (mode === 'benefits') benefits = append(benefits, leftover) else if (mode === 'concerns') concerns = append(concerns, leftover) else { const split = splitLooseCategoryBody(leftover) benefits = append(benefits, split.benefits) concerns = append(concerns, split.concerns) } } return { score, benefits, concerns } } export function parseDimensionCondenseResponse(raw: string): ParsedDimensionCondenseResponse { const parsed = parseDimensionAiResponse(raw) return { achievements: parsed.achievements, developmentNeeds: parsed.developmentNeeds, } } export interface ParsedDimensionResponse { categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[] achievements: string developmentNeeds: string } const BENEFITS_HDR = /^(BENEFITS|STÄRKEN|STAERKEN)\s*:\s*(.*)$/i const CONCERNS_HDR = /^(CONCERNS|RISIKEN|SCHWÄCHEN|SCHWAECHEN)\s*:\s*(.*)$/i const ACHIEVEMENTS_HDR = /^ACHIEVEMENTS\s*:\s*(.*)$/i const DEV_NEEDS_HDR = /^DEVELOPMENT_NEEDS\s*:\s*(.*)$/i const SCORE_HDR = /^SCORE\s*:\s*(\d{1,2})\s*$/i /** Lose Stichpunkte ohne BENEFITS/CONCERNS-Marker: nach Score-/Präfix-Hinweisen aufteilen. */ function splitLooseCategoryBody(raw: string): { benefits: string; concerns: string } { const lines = raw.split('\n').map(l => l.trim()).filter(Boolean) if (lines.length === 0) return { benefits: '', concerns: '' } const benefits: string[] = [] const concerns: string[] = [] let bucket: 'b' | 'c' | null = null for (const line of lines) { const benInline = line.match(BENEFITS_HDR) if (benInline) { bucket = 'b' if (benInline[2].trim()) benefits.push(benInline[2].trim()) continue } const conInline = line.match(CONCERNS_HDR) if (conInline) { bucket = 'c' if (conInline[2].trim()) concerns.push(conInline[2].trim()) continue } const bullet = line.replace(/^[-*•]\s*/, '') if (/^(benefit|stärke|staerke|positiv)\b/i.test(bullet)) { benefits.push(line) continue } if (/^(concern|risiko|schwäche|schwaeche|negativ|sollte|empfehl)/i.test(bullet)) { concerns.push(line) continue } const scoreInLine = bullet.match(/\b(?:score|bewertung)?\s*([1-9]|10)\s*(?:\/\s*10)?\b/i) ?? bullet.match(/^([1-9]|10)\s*[:–—-]/) if (scoreInLine) { const n = Number(scoreInLine[1]) if (n >= 6) benefits.push(line) else concerns.push(line) continue } if (bucket === 'b') benefits.push(line) else if (bucket === 'c') concerns.push(line) else concerns.push(line) } return { benefits: benefits.join('\n').trim(), concerns: concerns.join('\n').trim(), } } /** * Marker-basiert (kein JSON). Tolerant gegenüber Text in derselben Zeile wie der Marker * (`BENEFITS: - …`) und deutschen Synonymen. Ohne Untermarker: Heuristik über Score/Präfixe. */ 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; benefits: string; concerns: string; score?: number | null }[] = [] let achievements = '' let developmentNeeds = '' type Mode = 'none' | 'category' | 'catBenefits' | 'catConcerns' | 'achievements' | 'development' let mode: Mode = 'none' let currentName = '' let catBenefits = '' let catConcerns = '' let catScore: number | null = null let buffer: string[] = [] const takeBuffer = () => { const t = buffer.join('\n').trim() buffer = [] return t } const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim() const flushCategory = () => { if (!currentName) { buffer = []; return } const leftover = takeBuffer() let benefits = catBenefits let concerns = catConcerns if (mode === 'catBenefits' && leftover) benefits = append(benefits, leftover) else if (mode === 'catConcerns' && leftover) concerns = append(concerns, leftover) else if (mode === 'category' && leftover) { if (!benefits && !concerns) { const split = splitLooseCategoryBody(leftover) benefits = split.benefits concerns = split.concerns } else { concerns = append(concerns, leftover) } } categoryTexts.push({ name: currentName, benefits, concerns, score: catScore }) currentName = '' catBenefits = '' catConcerns = '' catScore = null } const flush = () => { if (mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns') flushCategory() else if (mode === 'achievements') achievements = takeBuffer() else if (mode === 'development') developmentNeeds = takeBuffer() else buffer = [] } const startDimBenefits = (sameLine: string) => { flush() mode = 'achievements' currentName = '' if (sameLine.trim()) buffer = [sameLine.trim()] } const startDimConcerns = (sameLine: string) => { flush() mode = 'development' currentName = '' if (sameLine.trim()) buffer = [sameLine.trim()] } for (const line of lines) { const catMatch = line.match(/^KATEGORIE:\s*(.+)$/i) const scoreMatch = line.match(SCORE_HDR) const benMatch = line.match(BENEFITS_HDR) const conMatch = line.match(CONCERNS_HDR) const achMatch = line.match(ACHIEVEMENTS_HDR) const devMatch = line.match(DEV_NEEDS_HDR) const inCat = mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns' if (catMatch) { flush() mode = 'category' currentName = catMatch[1].trim() catBenefits = '' catConcerns = '' catScore = null continue } if (inCat && scoreMatch) { catScore = Number(scoreMatch[1]) continue } if (inCat && benMatch) { const leftover = takeBuffer() if (leftover && mode === 'category') { const split = splitLooseCategoryBody(leftover) catBenefits = append(catBenefits, split.benefits) catConcerns = append(catConcerns, split.concerns) } else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover) else if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover) mode = 'catBenefits' if (benMatch[2].trim()) catBenefits = append(catBenefits, benMatch[2].trim()) continue } if (inCat && conMatch) { const leftover = takeBuffer() if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover) else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover) else if (leftover && mode === 'category') { const split = splitLooseCategoryBody(leftover) catBenefits = append(catBenefits, split.benefits) catConcerns = append(catConcerns, split.concerns) } mode = 'catConcerns' if (conMatch[2].trim()) catConcerns = append(catConcerns, conMatch[2].trim()) continue } if (achMatch) { startDimBenefits(achMatch[1] ?? '') continue } if (devMatch) { startDimConcerns(devMatch[1] ?? '') continue } if (!inCat && benMatch) { startDimBenefits(benMatch[2] ?? '') continue } if (!inCat && conMatch) { startDimConcerns(conMatch[2] ?? '') continue } buffer.push(line) } flush() return { categoryTexts, achievements, developmentNeeds } }