176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
|
|
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 }
|
||
|
|
}
|