Compare commits

..

2 Commits

Author SHA1 Message Date
07f9f304a4 fix(wiki-migration): pdftotext als primären PDF-Extraktor nutzen
pdfparser scheitert an "secured" PDFs (Kopier-Schutz ohne Passwort).
pdftotext aus poppler-utils umgeht diesen Schutz und extrahiert den
eingebetteten Text korrekt. Falls pdftotext nicht verfügbar ist oder
nichts liefert, bleibt pdfparser als Fallback erhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:40:47 +02:00
9817fde8c4 fix(wiki-migration): PDF-Extraktion über alle filegallery-Sitepart-Typen
get_article_files() filterte nur sitepart_id=5, obwohl PDFs auch über
sitepart_id=6 und 10 verknüpft sind. Direkter JOIN auf filegallery_line
mit IN (5,6,10) findet jetzt alle Anhänge korrekt.

Außerdem: Token-sparsamere generate_summary-Logik (Teaser priorisiert,
Text auf 1500 Z. gecappt, nur 1 PDF á 800 Z., maxOutputTokens=150),
--summaries-only-Flag für Nachläufe, sleep(4) für Free-Tier-Rate-Limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:33:56 +02:00

View File

@@ -143,28 +143,37 @@ function assemble_content(int $cid): array {
} }
function get_article_files(int $cid): array { function get_article_files(int $cid): array {
// sitepart_id=5 verweist auf filegallery_header // sitepart_id 5, 6, 10 sind filegallery-Typen; sitepart_id=1 ist textcontent_header (nicht joinen!)
$sql = "SELECT mcl.main_sitepart_header_id $sql = "SELECT DISTINCT fl.filename, fl.extension, fl.description
FROM main_collection_link mcl FROM main_collection_link mcl
WHERE mcl.main_collection_id = $cid AND mcl.main_sitepart_id = 5"; JOIN filegallery_line fl ON fl.header_id = mcl.main_sitepart_header_id
WHERE mcl.main_collection_id = $cid
AND mcl.main_sitepart_id IN (5, 6, 10)
AND mcl.main_sitepart_header_id > 0
AND fl.filename != ''
ORDER BY fl.sorting";
$res = mysqli_query($GLOBALS['mysql_con'], $sql); $res = mysqli_query($GLOBALS['mysql_con'], $sql);
$files = []; $files = [];
while ($row = mysqli_fetch_assoc($res)) { while ($row = mysqli_fetch_assoc($res)) {
$hid = (int)$row['main_sitepart_header_id']; $files[] = $row;
if ($hid === 0) continue;
$fres = mysqli_query($GLOBALS['mysql_con'],
"SELECT filename, extension, description FROM filegallery_line WHERE header_id = $hid ORDER BY sorting");
while ($frow = mysqli_fetch_assoc($fres)) {
if (trim($frow['filename']) !== '') {
$files[] = $frow;
}
}
} }
return $files; return $files;
} }
function extract_pdf_text(string $filePath): string { function extract_pdf_text(string $filePath): string {
if (!file_exists($filePath) || filesize($filePath) === 0) return ''; if (!file_exists($filePath) || filesize($filePath) === 0) return '';
// pdftotext ist robuster bei gesicherten PDFs (nur Kopier-Schutz, kein Passwort)
$pdftotext = trim(shell_exec('which pdftotext 2>/dev/null') ?? '');
if ($pdftotext !== '') {
$escaped = escapeshellarg($filePath);
$out = shell_exec("$pdftotext -nopgbrk $escaped - 2>/dev/null");
if ($out !== null && strlen(trim($out)) > 10) {
return preg_replace('/\s+/', ' ', trim($out));
}
}
// Fallback: pdfparser
try { try {
$parser = new \Smalot\PdfParser\Parser(); $parser = new \Smalot\PdfParser\Parser();
$pdf = $parser->parseFile($filePath); $pdf = $parser->parseFile($filePath);
@@ -175,23 +184,31 @@ function extract_pdf_text(string $filePath): string {
} }
} }
function generate_summary(string $htmlContent, array $files, string $userdataDir, string $apiKey, string $title): ?string { function generate_summary(string $htmlContent, array $files, string $userdataDir, string $apiKey, string $title, string $teaser = ''): ?string {
// Fließtext: HTML vollständig entfernen, keine Zeichenbegrenzung
$plainText = html_entity_decode(strip_tags($htmlContent), ENT_HTML5 | ENT_QUOTES, 'UTF-8');
$plainText = trim(preg_replace('/\s+/', ' ', $plainText));
$parts = []; $parts = [];
if (strlen($plainText) > 30) {
$parts[] = $plainText; // Teaser bevorzugen: bereits plain text, sehr kurz → spart Input-Tokens
if (strlen($teaser) > 30) {
$parts[] = $teaser;
} }
// PDFs: Text extrahieren, auf 1500 Zeichen begrenzen (bei sehr langen PDFs) // Fließtext: HTML entfernen, auf 1500 Zeichen cappen
foreach ($files as $file) { $plainText = html_entity_decode(strip_tags($htmlContent), ENT_HTML5 | ENT_QUOTES, 'UTF-8');
if (strtolower($file['extension']) !== 'pdf') continue; $plainText = trim(preg_replace('/\s+/', ' ', $plainText));
$pdfText = extract_pdf_text($userdataDir . '/' . $file['filename']); if (strlen($plainText) > 30) {
if (strlen($pdfText) > 50) { $parts[] = mb_substr($plainText, 0, 1500);
$label = $file['description'] ?: basename($file['filename']); }
$parts[] = 'Anhang "' . $label . '": ' . mb_substr($pdfText, 0, 1500);
// PDFs nur wenn sonst kein Inhalt vorhanden, auf 800 Zeichen begrenzen
if (empty($parts)) {
foreach ($files as $file) {
if (strtolower($file['extension']) !== 'pdf') continue;
$pdfText = extract_pdf_text($userdataDir . '/' . $file['filename']);
if (strlen($pdfText) > 50) {
$label = $file['description'] ?: basename($file['filename']);
$parts[] = 'Anhang "' . $label . '": ' . mb_substr($pdfText, 0, 800);
break; // ein PDF reicht
}
} }
} }
@@ -199,19 +216,17 @@ function generate_summary(string $htmlContent, array $files, string $userdataDir
return null; return null;
} }
// Kompakter Prompt: Titel + vollständiger Inhalt $prompt = "Fasse in 2-3 deutschen Sätzen zusammen. Nur Zusammenfassung.\nTitel: $title\n\n"
$prompt = "Fasse diesen AWO-Wiki-Beitrag in 2-3 deutschen Sätzen zusammen. Nur Zusammenfassung, kein Intro.\n"
. "Titel: $title\n\n"
. implode("\n\n", $parts); . implode("\n\n", $parts);
logmsg(" PROMPT (" . strlen($prompt) . " Zeichen):\n" . $prompt . "\n--- PROMPT END ---", 'PROMPT'); logmsg(" PROMPT (" . strlen($prompt) . " Zeichen)", 'PROMPT');
$payload = json_encode([ $payload = json_encode([
'contents' => [['parts' => [['text' => $prompt]]]], 'contents' => [['parts' => [['text' => $prompt]]]],
'generationConfig' => [ 'generationConfig' => [
'maxOutputTokens' => 300, 'maxOutputTokens' => 150,
'temperature' => 0.2, 'temperature' => 0.2,
'thinkingConfig' => ['thinkingBudget' => 0], // kein Thinking = alle Token für Antwort 'thinkingConfig' => ['thinkingBudget' => 0],
], ],
]); ]);
@@ -279,7 +294,7 @@ if ($opts['summaries-only']) {
if (!$apiKey) { fwrite(STDERR, "--summaries-only benötigt --api-key\n"); exit(2); } if (!$apiKey) { fwrite(STDERR, "--summaries-only benötigt --api-key\n"); exit(2); }
$sumSql = "SELECT p.id as post_id, p.legacy_collection_id, pv.id as version_id, pv.title, $sumSql = "SELECT p.id as post_id, p.legacy_collection_id, pv.id as version_id, pv.title,
pv.content pv.teaser, pv.content
FROM knowledgecenter_posts p FROM knowledgecenter_posts p
JOIN knowledgecenter_post_versions pv ON pv.post_id = p.id AND pv.version_number = 1 JOIN knowledgecenter_post_versions pv ON pv.post_id = p.id AND pv.version_number = 1
WHERE (pv.summary IS NULL OR pv.summary = '') WHERE (pv.summary IS NULL OR pv.summary = '')
@@ -301,8 +316,9 @@ if ($opts['summaries-only']) {
$versionId = (int)$sumRow['version_id']; $versionId = (int)$sumRow['version_id'];
$title = $sumRow['title']; $title = $sumRow['title'];
$files = get_article_files($collId); $files = get_article_files($collId);
$summary = generate_summary($sumRow['content'], $files, $userdataDir, $apiKey, $title) ?? ''; $teaser = $sumRow['teaser'] ?? '';
$summary = generate_summary($sumRow['content'], $files, $userdataDir, $apiKey, $title, $teaser) ?? '';
if ($summary === '') { if ($summary === '') {
logmsg(" [FAIL] Post $postId \"$title\": leere Summary", 'WARN'); logmsg(" [FAIL] Post $postId \"$title\": leere Summary", 'WARN');
@@ -314,7 +330,7 @@ if ($opts['summaries-only']) {
logmsg("[OK ] Post $postId \"$title\": " . mb_substr($summary, 0, 80), 'OK'); logmsg("[OK ] Post $postId \"$title\": " . mb_substr($summary, 0, 80), 'OK');
$sumStats['ok']++; $sumStats['ok']++;
} }
usleep(200000); sleep(4); // 15 RPM Free-Tier: mind. 4 Sek. zwischen Requests
} }
logmsg(sprintf("Summaries fertig. ok=%d fail=%d", $sumStats['ok'], $sumStats['fail']), 'HEAD'); logmsg(sprintf("Summaries fertig. ok=%d fail=%d", $sumStats['ok'], $sumStats['fail']), 'HEAD');
@@ -380,7 +396,7 @@ while ($row = mysqli_fetch_assoc($result)) {
$generated = generate_summary($fullContent, $files, $userdataDir, $apiKey, $title); $generated = generate_summary($fullContent, $files, $userdataDir, $apiKey, $title);
$summary = $generated ?? ''; $summary = $generated ?? '';
logmsg(" Summary: " . (strlen($summary) > 0 ? '"' . mb_substr($summary, 0, 100) . '"' : '(leer)')); logmsg(" Summary: " . (strlen($summary) > 0 ? '"' . mb_substr($summary, 0, 100) . '"' : '(leer)'));
usleep(200000); // 200ms Pause für API rate limiting sleep(4); // 15 RPM Free-Tier: mind. 4 Sek. zwischen Requests
} }
// HTML-Entities dekodieren für saubere UTF-8 Speicherung // HTML-Entities dekodieren für saubere UTF-8 Speicherung