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>
161 lines
6.3 KiB
TypeScript
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 }
|
|
}
|