normalizeLineEndings($text); // Reihenfolge: erst Headings, dann Listen (stabiler fürs Chunking) $text = $this->detectHeadings($text); $text = $this->detectSimpleLists($text); return $text; } private function normalizeLineEndings(string $text): string { return str_replace(["\r\n", "\r"], "\n", $text); } // ========================================================= // HEADINGS // ========================================================= private function detectHeadings(string $text): string { $lines = explode("\n", $text); $out = []; $total = count($lines); for ($i = 0; $i < $total; $i++) { $line = $lines[$i]; $trim = trim($line); if ($this->isHeadingCandidate($trim, $lines, $i)) { $out[] = '## ' . $trim; continue; } $out[] = $line; } return implode("\n", $out); } private function isHeadingCandidate(string $line, array $lines, int $index): bool { if ($line === '') { return false; } // Schon Markdown-Heading? Dann nicht anfassen. if (preg_match('/^#{1,6}\s+/u', $line)) { return false; } if (mb_strlen($line) > self::MAX_HEADING_LEN) { return false; } // Heading soll kein "Satz" sein if (preg_match('/[.!?]\s*$/u', $line)) { return false; } // Keine typischen Satz-Kommas (zu risky) if (str_contains($line, ',')) { return false; } // Nummerierte Kapitel "1.2" / "2.3.4" nicht zwangs-heading-en if (preg_match('/\b\d+\.\d+(\.\d+)*\b/u', $line)) { return false; } // Muss "isoliert" stehen (leerzeile davor und danach) $prev = $lines[$index - 1] ?? ''; $next = $lines[$index + 1] ?? ''; if (trim($prev) !== '' || trim($next) !== '') { return false; } // Guardrail: mindestens ein Buchstabe if (!preg_match('/\p{L}/u', $line)) { return false; } // Klassiker: UPPERCASE oder Title Case $uppercaseRatio = $this->uppercaseRatio($line); if ($uppercaseRatio >= 0.65) { return true; } return $this->isTitleCase($line); } private function uppercaseRatio(string $line): float { $letters = preg_replace('/[^\p{L}]/u', '', $line); if ($letters === '' || $letters === null) { return 0.0; } $upper = preg_replace('/[^\p{Lu}]/u', '', $letters); if ($upper === null) { return 0.0; } $lettersLen = mb_strlen($letters); if ($lettersLen === 0) { return 0.0; } return mb_strlen($upper) / $lettersLen; } private function isTitleCase(string $line): bool { $words = preg_split('/\s+/u', trim($line)); if (!$words) { return false; } $wordCount = 0; $capCount = 0; foreach ($words as $word) { $word = trim($word); if ($word === '') { continue; } // Wörter ohne Buchstaben ignorieren if (!preg_match('/\p{L}/u', $word)) { continue; } $wordCount++; $first = mb_substr($word, 0, 1); if ($first !== '' && mb_strtoupper($first) === $first) { $capCount++; } } if ($wordCount === 0) { return false; } // mindestens die Hälfte der Wörter beginnt groß return $capCount >= max(1, intdiv($wordCount + 1, 2)); } // ========================================================= // LISTS // ========================================================= private function detectSimpleLists(string $text): string { $lines = explode("\n", $text); $out = []; $buffer = []; foreach ($lines as $line) { $trim = trim($line); // Bereits echte Liste? → nicht anfassen if (preg_match('/^-\s+/u', $trim) || preg_match('/^\d+\.\s+/u', $trim)) { $this->flushListBuffer($buffer, $out); $out[] = $line; continue; } if ($this->isListCandidate($trim)) { $buffer[] = $trim; continue; } $this->flushListBuffer($buffer, $out); $out[] = $line; } $this->flushListBuffer($buffer, $out); return implode("\n", $out); } private function flushListBuffer(array &$buffer, array &$out): void { if ($buffer === []) { return; } if (count($buffer) >= self::MIN_LIST_RUN) { foreach ($buffer as $item) { $out[] = '- ' . $item; } } else { // single line: unverändert lassen (kein "erraten"!) foreach ($buffer as $item) { $out[] = $item; } } $buffer = []; } private function isListCandidate(string $line): bool { if ($line === '') { return false; } // zu lang = ziemlich sicher Absatz/Satz if (mb_strlen($line) > self::MAX_LIST_ITEM_LEN) { return false; } // wenn es wie ein Satz endet, nicht als Liste if (preg_match('/[.!?]\s*$/u', $line)) { return false; } // "Key: Value" ist typischerweise keine Liste if (str_contains($line, ':')) { return false; } // Wenn es ein kompletter Satz sein könnte (Verb/Artikel), nicht raten: // -> minimaler Guardrail: beginnt mit Großbuchstabe UND enthält mindestens 5 Wörter => eher Satz/Absatz $words = preg_split('/\s+/u', trim($line)); if ($words && count($words) >= 5) { $first = mb_substr($line, 0, 1); if ($first !== '' && mb_strtoupper($first) === $first) { return false; } } // nur "kurze, stichpunktartige" Zeilen als Kandidat akzeptieren return true; } }