Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea. Co-authored-by: Cursor <cursoragent@cursor.com>
605 lines
22 KiB
TypeScript
605 lines
22 KiB
TypeScript
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<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}${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, string>): 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<string, string> {
|
||
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<string, string> {
|
||
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 }
|
||
}
|