2026-07-07 11:14:00 +02:00
import type {
Consultant , Assignment , FeedbackDimension , FeedbackCategory , FeedbackCriterionItem ,
2026-08-08 17:35:05 +02:00
FeedbackCategoryRating , Assessment , ConversationEntry , MeetingInstance , RatingScore ,
2026-07-07 11:14:00 +02:00
} from '../db/types'
2026-08-08 17:35:05 +02:00
import { clampRatingScore , isValidRatingScore } from '../config/constants'
2026-07-08 09:35:08 +02:00
import { splitNoteLines , parseNotationPrefix } from './notationParser'
2026-08-08 17:35:05 +02:00
import type { TrendResult } from './ratingTrend'
2026-07-08 09:35:08 +02:00
/ * *
2026-08-08 17:35:05 +02:00
* 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 - / T h e m e n - R e f e r e n z e n r e s e r v i e r t i s t .
2026-07-08 09:35:08 +02:00
* /
const formatNotationLine = ( line : string ) : string = > {
const { rating , isQuote , text } = parseNotationPrefix ( line )
2026-08-08 17:35:05 +02:00
const tag = isQuote ? 'Kundenzitat' : rating !== null ? ` Score ${ rating } /10 ` : null
2026-07-08 09:35:08 +02:00
return tag ? ` ${ tag } — ${ text } ` : text
}
2026-08-08 17:35:05 +02:00
/** 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 ( ) )
} )
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
2026-08-08 17:35:05 +02:00
. map ( ( c , i ) = > ` ${ i + 1 } . ${ c . name } ${ c . description ? ` — ${ c . description } ` : '' } ` )
2026-07-07 11:14:00 +02:00
. join ( '\n' ) || '(keine Kategorien)'
const kriterienScores = dimCategories . map ( cat = > {
const catItems = dimItems . filter ( i = > i . categoryId === cat . id )
const lines = catItems . map ( c = > {
2026-08-08 17:35:05 +02:00
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 ) ,
)
2026-07-07 11:14:00 +02:00
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-08-08 17:35:05 +02:00
. . . generalNotesForCrit ,
2026-07-08 09:35:08 +02:00
] . map ( formatNotationLine )
if ( scores . length === 0 && notes . length === 0 ) return null
const avgNum = scores . length > 0
2026-08-08 17:35:05 +02:00
? scores . reduce ( ( s , a ) = > s + ( a . score as number ) , 0 ) / scores . length
2026-07-08 09:35:08 +02:00
: null
2026-08-08 17:35:05 +02:00
const clamped = avgNum !== null ? clampRatingScore ( avgNum ) : null
const avgLabel = avgNum !== null
? ` ${ clamped ? ? avgNum . toFixed ( 1 ) } /10 (Ø ${ avgNum . toFixed ( 1 ) } ) `
: 'nur Notiz, keine Bewertung'
2026-07-08 09:35:08 +02:00
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)'
2026-07-07 11:14:00 +02:00
const kategorieBewertungen = dimCategories . map ( cat = > {
2026-08-08 17:35:05 +02:00
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 } `
2026-07-07 11:14:00 +02:00
} ) . 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
2026-08-08 17:35:05 +02:00
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
2026-07-08 09:35:08 +02:00
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 )
2026-08-08 17:35:05 +02:00
const rawLines = meetingEntries
2026-07-07 11:14:00 +02:00
. flatMap ( e = > splitNoteLines ( e . note ) . map ( line = > ( {
line , tagged : ( e . lineTags ? ? [ ] ) . some ( t = > t . text === line ) ,
} ) ) )
. filter ( x = > ! x . tagged )
2026-08-08 17:35:05 +02:00
. filter ( x = > ! parseNotationPrefix ( x . line ) . isQuote )
. map ( x = > x . line )
const lines = dropDanglingMarkers ( rawLines ) . map ( line = > ` - ${ formatNotationLine ( 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
}
2026-08-08 17:35:05 +02:00
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 ,
}
}
2026-07-07 11:14:00 +02:00
export interface ParsedDimensionResponse {
2026-08-08 17:35:05 +02:00
categoryTexts : { name : string ; benefits : string ; concerns : string ; score? : number | null } [ ]
2026-07-07 11:14:00 +02:00
achievements : string
developmentNeeds : string
}
2026-08-08 17:35:05 +02:00
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 ( ) ,
}
}
2026-07-07 11:14:00 +02:00
/ * *
2026-08-08 17:35:05 +02:00
* 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 .
2026-07-07 11:14:00 +02:00
* /
export function parseDimensionAiResponse ( raw : string ) : ParsedDimensionResponse {
const cleaned = raw . trim ( ) . replace ( /^```[a-z]*\n?/i , '' ) . replace ( /```$/ , '' ) . trim ( )
const lines = cleaned . split ( '\n' )
2026-08-08 17:35:05 +02:00
const categoryTexts : { name : string ; benefits : string ; concerns : string ; score? : number | null } [ ] = [ ]
2026-07-07 11:14:00 +02:00
let achievements = ''
let developmentNeeds = ''
2026-08-08 17:35:05 +02:00
type Mode = 'none' | 'category' | 'catBenefits' | 'catConcerns' | 'achievements' | 'development'
let mode : Mode = 'none'
2026-07-07 11:14:00 +02:00
let currentName = ''
2026-08-08 17:35:05 +02:00
let catBenefits = ''
let catConcerns = ''
let catScore : number | null = null
2026-07-07 11:14:00 +02:00
let buffer : string [ ] = [ ]
2026-08-08 17:35:05 +02:00
const takeBuffer = ( ) = > {
const t = buffer . join ( '\n' ) . trim ( )
2026-07-07 11:14:00 +02:00
buffer = [ ]
2026-08-08 17:35:05 +02:00
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 ( ) ]
2026-07-07 11:14:00 +02:00
}
for ( const line of lines ) {
const catMatch = line . match ( /^KATEGORIE:\s*(.+)$/i )
2026-08-08 17:35:05 +02:00
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'
2026-07-07 11:14:00 +02:00
if ( catMatch ) {
flush ( )
mode = 'category'
currentName = catMatch [ 1 ] . trim ( )
2026-08-08 17:35:05 +02:00
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
2026-07-07 11:14:00 +02:00
}
2026-08-08 17:35:05 +02:00
if ( devMatch ) {
startDimConcerns ( devMatch [ 1 ] ? ? '' )
continue
}
if ( ! inCat && benMatch ) {
startDimBenefits ( benMatch [ 2 ] ? ? '' )
continue
}
if ( ! inCat && conMatch ) {
startDimConcerns ( conMatch [ 2 ] ? ? '' )
continue
}
buffer . push ( line )
2026-07-07 11:14:00 +02:00
}
flush ( )
return { categoryTexts , achievements , developmentNeeds }
}