CG-Feedback-Monitor/src/utils/criterionSuggestionPrompt.ts
Lars 234f87d408 V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
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>
2026-08-08 17:35:05 +02:00

161 lines
6.3 KiB
TypeScript

import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag } from '../db/types'
import { resolveVisibleStructure } from '../db/queries/feedbackStructure'
import { findSimilarTaggedLines } from './criterionSuggestion'
const byOrder = <T extends { order: number }>(rows: T[]) => [...rows].sort((a, b) => a.order - b.order)
/**
* `{{KRITERIEN_STRUKTUR}}` — ID-referenzierte, verschachtelte Liste Dimension → Kategorie
* (Name+Beschreibung) → Kriterium (Name+Beschreibung), gefiltert auf die im Assignment-Typ
* sichtbaren Kriterien über `resolveVisibleStructure()` (dieselbe Logik wie Meeting/Feedback).
*/
export function buildCriterionStructureText(
dimensions: FeedbackDimension[],
allCategories: FeedbackCategory[],
allItems: FeedbackCriterionItem[],
visibleCriteriaIds: number[] | undefined,
): string {
const {
dimensions: visibleDimensions,
categories: visibleCategories,
items: visibleItems,
} = resolveVisibleStructure(dimensions, allCategories, allItems, visibleCriteriaIds)
const text = byOrder(visibleDimensions).map(dim => {
const dimCategories = byOrder(visibleCategories.filter(c => c.dimensionId === dim.id))
const catLines = dimCategories.map(cat => {
const catItems = byOrder(visibleItems.filter(i => i.categoryId === cat.id))
const itemLines = catItems.map(item => {
const label = item.description ? `${item.name}${item.description}` : item.name
return ` [${item.id}] ${label}`
}).join('\n')
const catLabel = cat.description ? `${cat.name}${cat.description}` : cat.name
return ` ${catLabel}\n${itemLines}`
}).join('\n')
return `${dim.name}\n${catLines}`
}).join('\n')
return text || '(keine sichtbaren Kriterien)'
}
export interface CriterionSuggestionLine {
/** Stabiler, nur innerhalb dieser Analyse gültiger Anker — nicht persistiert. */
lineNumber: number
text: string
/** Institutee-Name bei Gesprächsbeiträgen, undefined bei allgemeinen Notizen. */
speakerLabel?: string
}
export interface CriterionSuggestionPromptInput {
assignmentTypeName: string
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
visibleCriteriaIds: number[] | undefined
lines: CriterionSuggestionLine[]
/** Globaler Lern-Korpus, siehe `getGlobalLineTagCorpus()`. */
corpus: ConversationLineTag[]
}
const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig'
export function buildCriterionSuggestionPromptContext(input: CriterionSuggestionPromptInput): Record<string, string> {
const { assignmentTypeName, dimensions, categories, items, visibleCriteriaIds, lines, corpus } = input
const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, visibleCriteriaIds)
const itemName = (id: number) => items.find(i => i.id === id)?.name ?? `#${id}`
const kriterienStruktur = buildCriterionStructureText(dimensions, categories, items, visibleCriteriaIds)
const protokollZeilen = lines
.map(l => `${l.lineNumber}. ${l.speakerLabel ? `[${l.speakerLabel}] ` : ''}${l.text}`)
.join('\n') || '(keine offenen Zeilen)'
const aehnlicheAltZuordnungen = lines.map(l => {
const hints = findSimilarTaggedLines(l.text, corpus)
if (hints.length === 0) return `Zeile ${l.lineNumber}: (keine ähnlichen Alt-Zeilen gefunden)`
const hintLines = hints.map(h => ` - "${h.text}" → [${h.criterionItemId}] ${itemName(h.criterionItemId)} (Ähnlichkeit ${similarityLabel(h.score)})`)
return `Zeile ${l.lineNumber}:\n${hintLines.join('\n')}`
}).join('\n') || '(keine offenen Zeilen)'
const gelernteMuster = visibleItems
.filter(i => i.learnedPatternHints?.trim())
.map(i => ` [${i.id}] ${i.name}: ${i.learnedPatternHints}`)
.join('\n') || '(noch keine gelernten Muster vorhanden)'
return {
'{{ASSIGNMENT_TYP}}': assignmentTypeName,
'{{KRITERIEN_STRUKTUR}}': kriterienStruktur,
'{{GELERNTE_MUSTER}}': gelernteMuster,
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
'{{PROTOKOLL_ZEILEN}}': protokollZeilen,
}
}
export interface ParsedCriterionSuggestionResponse {
/** Zeilennummer → priorisierte Kriterium-ID-Liste (leer = kein Vorschlag). */
lineSuggestions: Map<number, number[]>
/** Kriterium-ID → destilliertes Formulierungsmuster (Nebenprodukt dieses Laufs). */
patternHints: Map<number, string>
}
/**
* Parst das Klartext-Antwortformat (`ZEILE 3: 12, 47, 5` / `ZEILE 9: -`, gefolgt von einem
* `MUSTER:`-Block) — bewusst kein JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse`
* (Codefence-Stripping vorab, tolerant gegenüber fehlenden/kaputten Zeilen, kein Absturz).
*/
export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
const lineSuggestions = new Map<number, number[]>()
const patternHints = new Map<number, string>()
let mode: 'lines' | 'patterns' = 'lines'
let currentPatternId: number | null = null
let patternBuffer: string[] = []
const flushPattern = () => {
if (currentPatternId !== null) {
const text = patternBuffer.join(' ').trim()
if (text) patternHints.set(currentPatternId, text)
}
currentPatternId = null
patternBuffer = []
}
for (const rawLine of lines) {
const line = rawLine.trim()
if (/^MUSTER:\s*$/i.test(line)) {
flushPattern()
mode = 'patterns'
continue
}
if (mode === 'lines') {
const m = line.match(/^ZEILE\s+(\d+)\s*:\s*(.*)$/i)
if (!m) continue
const lineNumber = Number(m[1])
const rest = m[2].trim()
if (rest === '' || rest === '-') {
lineSuggestions.set(lineNumber, [])
} else {
const ids = rest.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
lineSuggestions.set(lineNumber, ids)
}
} else {
const m = line.match(/^(\d+)\s*:\s*(.*)$/)
if (m) {
flushPattern()
currentPatternId = Number(m[1])
patternBuffer = [m[2].trim()]
} else if (currentPatternId !== null && line !== '') {
patternBuffer.push(line)
}
}
}
flushPattern()
return { lineSuggestions, patternHints }
}