CG-Feedback-Monitor/src/utils/aiPrompt.ts

201 lines
8.4 KiB
TypeScript
Raw Normal View History

2026-07-07 11:14:00 +02:00
import type {
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, FeedbackRating,
} from '../db/types'
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
2026-07-08 09:35:08 +02:00
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import { numberToRating, type TrendResult } from './ratingTrend'
2026-07-07 11:14:00 +02:00
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
r ? (RATING_NUM_MAP[r] ?? 0) : 0
2026-07-08 09:35:08 +02:00
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
}
2026-07-07 11:14:00 +02:00
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 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),
2026-07-08 09:35:08 +02:00
].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)'
2026-07-07 11:14:00 +02:00
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
2026-07-08 09:35:08 +02:00
.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)
2026-07-07 11:14:00 +02:00
.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)
2026-07-08 09:35:08 +02:00
.map(x => ` - ${formatNotationLine(x.line)}`)
2026-07-07 11:14:00 +02:00
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 }
}