Compare commits
25 Commits
0df7c36fbd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f104cb8cc | |||
| 92281f33bd | |||
| 9a8a8c86e3 | |||
| 18f8f97a43 | |||
| 92e1f3235a | |||
| e46f00c838 | |||
| e367b6ac29 | |||
| 7b99d18f2c | |||
| db8e6cce21 | |||
| d8d62fa371 | |||
| 96eb7ad9b9 | |||
| 6c3786f200 | |||
| 70f09fa9da | |||
| 14699b5f26 | |||
| 1ff5f429c4 | |||
| 82cca38f29 | |||
| f44d998a7f | |||
| d95b8c112d | |||
| 6c3eadc2c3 | |||
| 6ead3d76ec | |||
| fe3f8f14fa | |||
| 06f7df3fe1 | |||
| 0989c06b8f | |||
| 888c5c1543 | |||
| b8c862885e |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1 +1,6 @@
|
|||||||
poc_out
|
poc_out
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
*.json
|
||||||
|
*.log
|
||||||
|
*.txt
|
||||||
333
BuyerSheetParser.ts
Normal file
333
BuyerSheetParser.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
export interface BuyerSheetData {
|
||||||
|
file_name: string; // NEU
|
||||||
|
is_buyer_sheet: boolean;
|
||||||
|
name_company: string | null;
|
||||||
|
prospective_buyer: string | null;
|
||||||
|
company: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
cell: string | null;
|
||||||
|
email: string | null;
|
||||||
|
address: string | null;
|
||||||
|
state: string | null;
|
||||||
|
how_did_you_hear: string | null;
|
||||||
|
interested_in_updates: boolean | null;
|
||||||
|
types_of_business_raw: string | null;
|
||||||
|
background_experience: string | null;
|
||||||
|
total_purchase_price: string | null;
|
||||||
|
down_payment: string | null;
|
||||||
|
down_payment_raw: string | null;
|
||||||
|
date_of_introduction: string | null;
|
||||||
|
_checkbox_pending: boolean;
|
||||||
|
_parser: string;
|
||||||
|
_info_page: number | null;
|
||||||
|
_ca_page: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = [
|
||||||
|
"NAME / COMPANY", "PHONE", "ADDRESS", "EMAIL ADDRESS",
|
||||||
|
"HOW DID YOU HEAR", "ARE YOU INTERESTED", "TYPES OF BUSINESSES",
|
||||||
|
"BACKGROUND", "TOTAL PURCHASE PRICE", "DOWN PAYMENT",
|
||||||
|
"INCOME REQUIREMENTS", "ACCOUNTANT", "ATTORNEY", "BANK",
|
||||||
|
];
|
||||||
|
|
||||||
|
export class DeterministicParser {
|
||||||
|
|
||||||
|
public async parsePdf(filePath: string, fileName: string): Promise<BuyerSheetData | null> {
|
||||||
|
const dataBuffer = fs.readFileSync(filePath);
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(dataBuffer) });
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
|
// Regel: Alles über 10 Seiten wird radikal ignoriert
|
||||||
|
if (pdfDocument.numPages > 10) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let infoPageNum: number | null = null;
|
||||||
|
let caPageNum: number | null = null;
|
||||||
|
let infoPageObj: any = null;
|
||||||
|
let infoPageTextContent: any = null;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. Dynamische Seitensuche (Visuell sortiert & kugelsicher)
|
||||||
|
// ==========================================
|
||||||
|
for (let i = 1; i <= pdfDocument.numPages; i++) {
|
||||||
|
const page = await pdfDocument.getPage(i);
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
|
||||||
|
// Elemente mit Koordinaten versehen und wie ein Mensch lesen (von oben nach unten, links nach rechts)
|
||||||
|
const sortedItems = textContent.items
|
||||||
|
.map((item: any) => ({
|
||||||
|
text: item.str,
|
||||||
|
x: item.transform[4],
|
||||||
|
y: item.transform[5]
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
// Y-Toleranz für Buchstaben auf derselben Zeile
|
||||||
|
if (Math.abs(b.y - a.y) > 5) {
|
||||||
|
return b.y - a.y;
|
||||||
|
}
|
||||||
|
return a.x - b.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wir werfen ALLE Leerzeichen, Striche, Punkte und unsichtbare Artefakte weg.
|
||||||
|
// Übrig bleibt eine reine, unverwüstliche Buchstabenkette.
|
||||||
|
const textRaw = sortedItems.map((i: any) => i.text).join('').toUpperCase().replace(/[^A-Z]/g, '');
|
||||||
|
|
||||||
|
// Anker-Suche in der sauberen Zeichenkette
|
||||||
|
if (textRaw.includes('BUYERINFORMATIONSHEET')) {
|
||||||
|
infoPageNum = i;
|
||||||
|
infoPageObj = page;
|
||||||
|
infoPageTextContent = textContent;
|
||||||
|
}
|
||||||
|
if (textRaw.includes('PROSPECTIVEBUYERAGREESTOKEEPANDHOLDCONFIDENTIAL')) {
|
||||||
|
caPageNum = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. Fallback für Bild/Scan PDFs
|
||||||
|
// ==========================================
|
||||||
|
if (!infoPageNum || !infoPageObj || !infoPageTextContent) {
|
||||||
|
return this.createEmptyFallback(fileName, filePath, infoPageNum, caPageNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. Werte-Extraktion (nur auf der Info-Seite!)
|
||||||
|
// ==========================================
|
||||||
|
const mappedItems = infoPageTextContent.items
|
||||||
|
.filter((item: any) => item.str.trim() !== '')
|
||||||
|
.map((item: any) => ({
|
||||||
|
text: item.str,
|
||||||
|
x: item.transform[4],
|
||||||
|
y: item.transform[5],
|
||||||
|
width: item.width
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => b.y - a.y || a.x - b.x);
|
||||||
|
|
||||||
|
const linesGrouped: { y: number, items: any[] }[] = [];
|
||||||
|
let currentY: number | null = null;
|
||||||
|
let currentItems = [];
|
||||||
|
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
if (currentY === null || Math.abs(item.y - currentY) <= 3) {
|
||||||
|
currentItems.push(item);
|
||||||
|
currentY = currentY === null ? item.y : currentY;
|
||||||
|
} else {
|
||||||
|
linesGrouped.push({ y: currentY, items: currentItems });
|
||||||
|
currentItems = [item];
|
||||||
|
currentY = item.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentItems.length > 0 && currentY !== null) {
|
||||||
|
linesGrouped.push({ y: currentY, items: currentItems });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: { y: number, text: string }[] = [];
|
||||||
|
for (const group of linesGrouped) {
|
||||||
|
group.items.sort((a, b) => a.x - b.x);
|
||||||
|
let lineStr = "";
|
||||||
|
let prevEnd = -1;
|
||||||
|
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (prevEnd !== -1) {
|
||||||
|
const gap = item.x - prevEnd;
|
||||||
|
if (gap > 4) lineStr += " ";
|
||||||
|
}
|
||||||
|
lineStr += item.text;
|
||||||
|
prevEnd = item.x + item.width;
|
||||||
|
}
|
||||||
|
lines.push({ y: group.y, text: lineStr });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawResults: Record<string, string[]> = {};
|
||||||
|
LABELS.forEach(lbl => rawResults[lbl] = []);
|
||||||
|
let currentLabel: string | null = null;
|
||||||
|
const sortedLabels = [...LABELS].sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const textUpper = line.text.toUpperCase().replace(/\s+/g, '');
|
||||||
|
if (textUpper.includes("SELLERMAYREQUIREVERIFICATION")) continue;
|
||||||
|
|
||||||
|
let foundLabel: string | null = null;
|
||||||
|
for (const lbl of sortedLabels) {
|
||||||
|
const lblClean = lbl.toUpperCase().replace(/\s+/g, '');
|
||||||
|
if (textUpper.includes(lblClean)) {
|
||||||
|
foundLabel = lbl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundLabel) {
|
||||||
|
currentLabel = foundLabel;
|
||||||
|
if (line.text.includes(':')) {
|
||||||
|
const parts = line.text.split(':');
|
||||||
|
const val = parts.slice(1).join(':').replace(/_+/g, '').trim();
|
||||||
|
if (val.length > 0) rawResults[currentLabel].push(val);
|
||||||
|
}
|
||||||
|
} else if (currentLabel) {
|
||||||
|
const cleanVal = line.text.replace(/_+/g, '').trim();
|
||||||
|
if (cleanVal.length > 0 && !cleanVal.includes("Doc ID") && !cleanVal.includes("Bizmatch")) {
|
||||||
|
rawResults[currentLabel].push(cleanVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visualCheckboxResult = await this.detectGraphicCheckbox(infoPageObj, mappedItems);
|
||||||
|
return this.mapToTargetStructure(rawResults, filePath, fileName, visualCheckboxResult, infoPageNum, caPageNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEmptyFallback(fileName: string, filePath: string, infoPage: number | null, caPage: number | null): BuyerSheetData {
|
||||||
|
let dateOfIntro = null;
|
||||||
|
const dateMatch = filePath.match(/(\d{2})(\d{2})(\d{2})\.pdf$/);
|
||||||
|
if (dateMatch) dateOfIntro = `20${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
file_name: fileName,
|
||||||
|
is_buyer_sheet: false,
|
||||||
|
name_company: null,
|
||||||
|
prospective_buyer: null,
|
||||||
|
company: null,
|
||||||
|
phone: null,
|
||||||
|
cell: null,
|
||||||
|
email: null,
|
||||||
|
address: null,
|
||||||
|
state: null,
|
||||||
|
how_did_you_hear: null,
|
||||||
|
interested_in_updates: null,
|
||||||
|
types_of_business_raw: null,
|
||||||
|
background_experience: null,
|
||||||
|
total_purchase_price: null,
|
||||||
|
down_payment: null,
|
||||||
|
down_payment_raw: null,
|
||||||
|
date_of_introduction: dateOfIntro,
|
||||||
|
_checkbox_pending: true,
|
||||||
|
_parser: "deterministic", // Signal für den AI-Pass
|
||||||
|
_info_page: infoPage,
|
||||||
|
_ca_page: caPage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToTargetStructure(
|
||||||
|
raw: Record<string, string[]>,
|
||||||
|
filePath: string,
|
||||||
|
fileName: string,
|
||||||
|
visualCheckboxResult: boolean | null,
|
||||||
|
infoPageNum: number | null,
|
||||||
|
caPageNum: number | null
|
||||||
|
): BuyerSheetData {
|
||||||
|
const getVal = (label: string) => raw[label] && raw[label].length > 0 ? raw[label].join(' ') : null;
|
||||||
|
const nameCompany = getVal("NAME / COMPANY");
|
||||||
|
|
||||||
|
let phoneRaw = getVal("PHONE");
|
||||||
|
let phoneClean = null;
|
||||||
|
let cellClean = null;
|
||||||
|
|
||||||
|
if (phoneRaw) {
|
||||||
|
const phoneMatch = phoneRaw.split(/FAX:|CELL:/)[0];
|
||||||
|
phoneClean = phoneMatch ? phoneMatch.trim() : null;
|
||||||
|
const cellMatch = phoneRaw.match(/CELL:\s*(.*)/);
|
||||||
|
cellClean = cellMatch && cellMatch[1] ? cellMatch[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addressClean = getVal("ADDRESS");
|
||||||
|
if (addressClean) {
|
||||||
|
addressClean = addressClean.replace(/PO BOX \/ STREET\s+CITY \/ STATE \/ ZIP/g, '').trim();
|
||||||
|
if (addressClean === '') addressClean = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interestedRaw = getVal("ARE YOU INTERESTED");
|
||||||
|
let interestedInUpdates: boolean | null = null;
|
||||||
|
let checkboxPending = true;
|
||||||
|
|
||||||
|
if (interestedRaw) {
|
||||||
|
if (/(✔|☑|X|✓)\s*YES/i.test(interestedRaw) || /YES\s*(✔|☑|X|✓)/i.test(interestedRaw)) {
|
||||||
|
interestedInUpdates = true;
|
||||||
|
checkboxPending = false;
|
||||||
|
} else if (/(✔|☑|X|✓)\s*NO/i.test(interestedRaw) || /NO\s*(✔|☑|X|✓)/i.test(interestedRaw)) {
|
||||||
|
interestedInUpdates = false;
|
||||||
|
checkboxPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (checkboxPending && visualCheckboxResult !== null) {
|
||||||
|
interestedInUpdates = visualCheckboxResult;
|
||||||
|
checkboxPending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downPaymentRaw = getVal("DOWN PAYMENT")?.replace(/[^0-9,]/g, '') || null;
|
||||||
|
const downPaymentClean = downPaymentRaw ? downPaymentRaw.replace(/,/g, '') : null;
|
||||||
|
|
||||||
|
let dateOfIntro = null;
|
||||||
|
const dateMatch = filePath.match(/(\d{2})(\d{2})(\d{2})\.pdf$/);
|
||||||
|
if (dateMatch) dateOfIntro = `20${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
file_name: fileName,
|
||||||
|
is_buyer_sheet: true,
|
||||||
|
name_company: nameCompany,
|
||||||
|
prospective_buyer: nameCompany ? nameCompany.split('/')[0].trim() : null,
|
||||||
|
company: null,
|
||||||
|
phone: phoneClean,
|
||||||
|
cell: cellClean,
|
||||||
|
email: getVal("EMAIL ADDRESS"),
|
||||||
|
address: addressClean,
|
||||||
|
state: null,
|
||||||
|
how_did_you_hear: getVal("HOW DID YOU HEAR"),
|
||||||
|
interested_in_updates: interestedInUpdates,
|
||||||
|
types_of_business_raw: getVal("TYPES OF BUSINESSES"),
|
||||||
|
background_experience: getVal("BACKGROUND"),
|
||||||
|
total_purchase_price: getVal("TOTAL PURCHASE PRICE"),
|
||||||
|
down_payment: downPaymentClean,
|
||||||
|
down_payment_raw: downPaymentRaw,
|
||||||
|
date_of_introduction: dateOfIntro,
|
||||||
|
_checkbox_pending: checkboxPending,
|
||||||
|
_parser: "deterministic",
|
||||||
|
_info_page: infoPageNum,
|
||||||
|
_ca_page: caPageNum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectGraphicCheckbox(page: any, textItems: any[]): Promise<boolean | null> {
|
||||||
|
let yesX = null, noX = null, targetY = null;
|
||||||
|
|
||||||
|
for (const item of textItems) {
|
||||||
|
const textUpper = item.text.toUpperCase().trim();
|
||||||
|
if (textUpper.includes("YES")) { yesX = item.x; targetY = item.y; }
|
||||||
|
}
|
||||||
|
for (const item of textItems) {
|
||||||
|
const textUpper = item.text.toUpperCase().trim();
|
||||||
|
if (textUpper.includes("NO") && targetY !== null && Math.abs(item.y - targetY) < 5) noX = item.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yesX === null || noX === null || targetY === null) return null;
|
||||||
|
|
||||||
|
const opList = await page.getOperatorList();
|
||||||
|
let currentTransform = [1, 0, 0, 1, 0, 0];
|
||||||
|
|
||||||
|
for (let i = 0; i < opList.fnArray.length; i++) {
|
||||||
|
const fn = opList.fnArray[i];
|
||||||
|
const args = opList.argsArray[i];
|
||||||
|
|
||||||
|
if (fn === pdfjsLib.OPS.transform) currentTransform = args;
|
||||||
|
else if (
|
||||||
|
fn === pdfjsLib.OPS.paintImageXObject ||
|
||||||
|
fn === pdfjsLib.OPS.paintInlineImageXObject ||
|
||||||
|
fn === pdfjsLib.OPS.paintJpegXObject
|
||||||
|
) {
|
||||||
|
const width = Math.abs(currentTransform[0]);
|
||||||
|
const height = Math.abs(currentTransform[3]);
|
||||||
|
const imgX = currentTransform[4];
|
||||||
|
const imgY = currentTransform[5];
|
||||||
|
|
||||||
|
if (width < 40 && height < 40 && Math.abs(imgY - targetY) < 50) {
|
||||||
|
const distToYes = Math.abs(imgX - yesX);
|
||||||
|
const distToNo = Math.abs(imgX - noX);
|
||||||
|
return distToYes < distToNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
add_name_from_filename.ts
Normal file
70
add_name_from_filename.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* add_name_from_filename.ts
|
||||||
|
*
|
||||||
|
* Traegt das Feld "name_from_filename" in eine bestehende buyers_vision.json
|
||||||
|
* nach, OHNE die Vision-Ergebnisse anzufassen und OHNE GPU/Neuverarbeitung.
|
||||||
|
* Nutzt exakt dieselbe Logik wie der vision_runner, damit die Werte
|
||||||
|
* identisch zu kuenftigen Laeufen sind.
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* npx tsx add_name_from_filename.ts out_Z/buyers_vision.json
|
||||||
|
* npx tsx add_name_from_filename.ts out_Z/buyers_vision.json --overwrite
|
||||||
|
*
|
||||||
|
* Ohne --overwrite werden nur Datensaetze ergaenzt, die das Feld noch nicht
|
||||||
|
* (oder null) haben. Ein Backup .bak wird immer angelegt.
|
||||||
|
*/
|
||||||
|
import { promises as fsp } from "node:fs";
|
||||||
|
|
||||||
|
// IDENTISCH zur Funktion im vision_runner.ts
|
||||||
|
function nameFromFilename(fileName: string): string | null {
|
||||||
|
let s = fileName.replace(/\.pdf$/i, "");
|
||||||
|
s = s.replace(/\([^)]*\)/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
const comma = s.indexOf(",");
|
||||||
|
if (comma < 0) {
|
||||||
|
const first = s.replace(/\b\d{4,8}\b.*$/, "").trim();
|
||||||
|
return first.length >= 2 ? first : null;
|
||||||
|
}
|
||||||
|
const last = s.slice(0, comma).trim();
|
||||||
|
const rest = s.slice(comma + 1).trim();
|
||||||
|
const firstName = (rest.match(/^[A-Za-zÀ-ÿ.'-]+/) || [""])[0];
|
||||||
|
if (!last || !firstName) return null;
|
||||||
|
return `${last}, ${firstName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const file = args.find((a) => !a.startsWith("--"));
|
||||||
|
const overwrite = args.includes("--overwrite");
|
||||||
|
if (!file) {
|
||||||
|
console.error("Aufruf: npx tsx add_name_from_filename.ts <buyers_vision.json> [--overwrite]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fsp.readFile(file, "utf8");
|
||||||
|
const records: Array<Record<string, unknown>> = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
await fsp.writeFile(file + ".bak", raw);
|
||||||
|
|
||||||
|
let added = 0, skipped = 0, unchanged = 0;
|
||||||
|
for (const r of records) {
|
||||||
|
const fn = r["file_name"] as string | undefined;
|
||||||
|
if (!fn) { skipped++; continue; }
|
||||||
|
const has = r["name_from_filename"] != null && r["name_from_filename"] !== "";
|
||||||
|
if (has && !overwrite) { unchanged++; continue; }
|
||||||
|
const val = nameFromFilename(fn);
|
||||||
|
if (r["name_from_filename"] === val) { unchanged++; continue; }
|
||||||
|
r["name_from_filename"] = val;
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(file, JSON.stringify(records, null, 2));
|
||||||
|
console.log(`Datensaetze: ${records.length}`);
|
||||||
|
console.log(`Feld gesetzt: ${added}`);
|
||||||
|
console.log(`schon vorhanden: ${unchanged}`);
|
||||||
|
console.log(`ohne file_name: ${skipped}`);
|
||||||
|
console.log(`Backup: ${file}.bak`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
101
batch_runner.ts
Normal file
101
batch_runner.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { DeterministicParser } from './BuyerSheetParser';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. Argument-Check
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
// Verzeichnis Parameter
|
||||||
|
const dirArgIndex = args.indexOf('--dir');
|
||||||
|
if (dirArgIndex === -1 || !args[dirArgIndex + 1]) {
|
||||||
|
console.error("Fehler: Bitte ein Verzeichnis angeben!");
|
||||||
|
console.error("Nutzung: npx tsx batch_runner.ts --dir \"/Pfad/zum/Ordner\" [--limit 10]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dirPath = args[dirArgIndex + 1];
|
||||||
|
|
||||||
|
// Limit Parameter
|
||||||
|
const limitArgIndex = args.indexOf('--limit');
|
||||||
|
let limit = -1; // -1 bedeutet: Kein Limit, alle verarbeiten
|
||||||
|
if (limitArgIndex !== -1 && args[limitArgIndex + 1]) {
|
||||||
|
limit = parseInt(args[limitArgIndex + 1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
|
console.error(`Fehler: Der Pfad "${dirPath}" existiert nicht oder ist kein Verzeichnis.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PDFs finden und Metadaten (für die Sortierung) auslesen
|
||||||
|
const filesWithStats = fs.readdirSync(dirPath)
|
||||||
|
.filter(f => f.toLowerCase().endsWith('.pdf'))
|
||||||
|
.map(file => {
|
||||||
|
const fullPath = path.join(dirPath, file);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
fullPath,
|
||||||
|
// Änderungsdatum der Datei auslesen (in Millisekunden)
|
||||||
|
mtime: fs.statSync(fullPath).mtimeMs
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filesWithStats.length === 0) {
|
||||||
|
console.log("Keine PDFs in diesem Verzeichnis gefunden.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Absteigend sortieren (Neueste zuerst)
|
||||||
|
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
// 4. Limit anwenden (falls gesetzt)
|
||||||
|
const filesToProcess = limit > 0 ? filesWithStats.slice(0, limit) : filesWithStats;
|
||||||
|
|
||||||
|
console.log(`Starte Verarbeitung: ${filesToProcess.length} von ${filesWithStats.length} PDFs ausgewählt (Sortierung: Neueste zuerst)...\n`);
|
||||||
|
|
||||||
|
const parser = new DeterministicParser();
|
||||||
|
const finalResults = [];
|
||||||
|
let skippedCounter = 0;
|
||||||
|
|
||||||
|
// 5. PDFs iterieren
|
||||||
|
for (const fileObj of filesToProcess) {
|
||||||
|
const { file, fullPath } = fileObj;
|
||||||
|
process.stdout.write(`-> Verarbeite: ${file} ... `);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await parser.parsePdf(fullPath, file);
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
|
console.log("ÜBERSPRUNGEN (> 10 Seiten)");
|
||||||
|
skippedCounter++;
|
||||||
|
} else if (data.is_buyer_sheet === false) {
|
||||||
|
console.log("IMAGE SCAN (Zuweisung an Vision AI)");
|
||||||
|
finalResults.push(data);
|
||||||
|
} else {
|
||||||
|
console.log("ERFOLGREICH");
|
||||||
|
finalResults.push(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("FEHLER BEIM PARSEN");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. JSON Export unter ./out/buyers.json
|
||||||
|
const outDir = path.join(process.cwd(), 'out');
|
||||||
|
if (!fs.existsSync(outDir)) {
|
||||||
|
fs.mkdirSync(outDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = path.join(outDir, 'buyers.json');
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(finalResults, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
console.log(`\n=================================================`);
|
||||||
|
console.log(`Zusammenfassung:`);
|
||||||
|
console.log(` Verarbeitet: ${finalResults.length}`);
|
||||||
|
console.log(` Verworfen (>10 Seiten): ${skippedCounter}`);
|
||||||
|
console.log(` Export gespeichert in: ${outPath}`);
|
||||||
|
console.log(`=================================================`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
36
bayarea-ai-server/Dockerfile.cuda
Normal file
36
bayarea-ai-server/Dockerfile.cuda
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# llama-server für Qwen3.6 Vision — NVIDIA RTX PRO 6000 Blackwell (sm_120)
|
||||||
|
#
|
||||||
|
# WICHTIG:
|
||||||
|
# - Muss aus aktuellem Source gebaut werden: Qwen3.6 nutzt ein neues
|
||||||
|
# Rope-Encoding (rope.dimension_sections 3 statt 4), alte Images/Builds
|
||||||
|
# brechen mit "wrong array length".
|
||||||
|
# - Blackwell braucht CUDA >= 12.8. KEIN CUDA 13.2 verwenden — erzeugt
|
||||||
|
# mit Qwen3.6 Gibberish (bekannter NVIDIA-Bug).
|
||||||
|
|
||||||
|
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 AS build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git cmake build-essential libcurl4-openssl-dev ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp /src
|
||||||
|
|
||||||
|
# 120 = Blackwell (RTX PRO 6000). Für andere Karten anpassen.
|
||||||
|
RUN cmake /src -B /build \
|
||||||
|
-DGGML_CUDA=ON \
|
||||||
|
-DCMAKE_CUDA_ARCHITECTURES=120 \
|
||||||
|
-DGGML_CUDA_FORCE_CUBLAS=OFF \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DLLAMA_CURL=ON \
|
||||||
|
&& cmake --build /build --config Release -j --target llama-server
|
||||||
|
|
||||||
|
FROM nvidia/cuda:12.8.1-runtime-ubuntu24.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libcurl4 libgomp1 curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /build/bin/llama-server /usr/local/bin/llama-server
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["llama-server"]
|
||||||
32
bayarea-ai-server/Dockerfile.vulkan
Normal file
32
bayarea-ai-server/Dockerfile.vulkan
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# llama-server für Qwen3.6 Vision — AMD Radeon AI Pro R9700, Vulkan-Backend
|
||||||
|
#
|
||||||
|
# Muss aus aktuellem Source gebaut werden (Qwen3.6-Rope-Änderung, s. Dockerfile.cuda).
|
||||||
|
# Vulkan statt ROCm — hat sich bei Vision als stabiler erwiesen.
|
||||||
|
|
||||||
|
FROM ubuntu:24.04 AS build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git cmake build-essential libcurl4-openssl-dev ca-certificates \
|
||||||
|
libvulkan-dev glslc spirv-headers \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp /src
|
||||||
|
|
||||||
|
RUN cmake /src -B /build \
|
||||||
|
-DGGML_VULKAN=ON \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DLLAMA_CURL=ON \
|
||||||
|
&& cmake --build /build --config Release -j --target llama-server
|
||||||
|
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
# mesa-vulkan-drivers = RADV-Treiber im Container (GPU via /dev/dri durchgereicht)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libvulkan1 mesa-vulkan-drivers vulkan-tools \
|
||||||
|
libcurl4 libgomp1 curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /build/bin/llama-server /usr/local/bin/llama-server
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["llama-server"]
|
||||||
@@ -1,47 +1,142 @@
|
|||||||
# llama.cpp + Gemma-4-12B (Vision) auf CUDA (AWS G7e / RTX PRO 6000).
|
# llama-server für Qwen3.6 Vision — zwei Profile:
|
||||||
|
# docker compose --profile cuda up -d --build (EC2, RTX PRO 6000 Blackwell)
|
||||||
|
# docker compose --profile vulkan up -d --build (inhouse, Radeon AI Pro R9700)
|
||||||
#
|
#
|
||||||
# Bewusst SCHLANK gehalten: keine ROCm/Vulkan-Workarounds. Wir testen, ob
|
# Modelle nach ./models legen (Haupt-GGUF + zugehöriger mmproj aus demselben Repo!):
|
||||||
# CUDA die Instabilitaeten von vornherein vermeidet. Nur die inhaltlich noetigen
|
# CUDA: unsloth/Qwen3.6-27B-GGUF → Qwen3.6-27B-UD-Q4_K_XL.gguf + mmproj-BF16.gguf
|
||||||
# Flags (--reasoning off gegen leeres content) bleiben.
|
# Vulkan: unsloth/Qwen3.6-35B-A3B-GGUF → Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf + mmproj-BF16.gguf
|
||||||
#
|
#
|
||||||
# Voraussetzung: DLAMI mit NVIDIA Container Toolkit (docker --gpus all funktioniert).
|
# Bewusst KEIN MTP: --mmproj + MTP ist laut Unsloth nicht unterstützt und hat
|
||||||
#
|
# offene OOM-/Hänger-Bugs (llama.cpp #23371, #23430). Für Batch-Extraktion
|
||||||
# Start: docker compose -f docker-compose-cuda.yml up -d
|
# irrelevant, da die Zeit im Image-Encoding steckt, nicht in der Generierung.
|
||||||
# Logs: docker compose -f docker-compose-cuda.yml logs -f
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
llamacpp-gemma12b:
|
llama-cuda:
|
||||||
image: ghcr.io/ggml-org/llama.cpp:server-cuda
|
profiles: ["cuda"]
|
||||||
container_name: llamacpp-gemma12b
|
build:
|
||||||
restart: unless-stopped
|
context: .
|
||||||
init: true
|
dockerfile: Dockerfile.cuda
|
||||||
# GPU-Zugriff ueber das NVIDIA Container Toolkit
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./models:/models
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
devices:
|
devices:
|
||||||
- driver: nvidia
|
- driver: nvidia
|
||||||
count: 1
|
count: all
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
ports:
|
|
||||||
- "8000:8080"
|
|
||||||
volumes:
|
|
||||||
- ~/.cache/llama.cpp:/root/.cache/llama.cpp
|
|
||||||
command:
|
command:
|
||||||
- -hf
|
- -m
|
||||||
- unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL
|
- /models/Qwen3.6-27B-UD-Q4_K_XL.gguf
|
||||||
|
- --mmproj
|
||||||
|
- /models/mmproj-BF16.gguf
|
||||||
- --host
|
- --host
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
- --port
|
- --port
|
||||||
- "8080"
|
- "8000"
|
||||||
|
- --alias
|
||||||
|
- qwen3.6
|
||||||
- -ngl
|
- -ngl
|
||||||
- "99"
|
- "99"
|
||||||
- --ctx-size
|
- -fa
|
||||||
- "16384"
|
- "on"
|
||||||
|
- -c
|
||||||
|
- "65536"
|
||||||
- --parallel
|
- --parallel
|
||||||
- "1"
|
- "1"
|
||||||
- --jinja
|
- --jinja
|
||||||
- --reasoning
|
# Erlernte Stabilitäts-Settings (Vision + Checkpoints = OOM-Bug):
|
||||||
- "off"
|
- --ctx-checkpoints
|
||||||
|
- "0"
|
||||||
|
# Qwen-VL braucht min. 1024 Image-Tokens für korrektes Grounding:
|
||||||
|
- --image-min-tokens
|
||||||
|
- "1024"
|
||||||
|
- --image-max-tokens
|
||||||
|
- "4096"
|
||||||
|
# Extraktion: niedrige Temperatur, kein Repeat-Penalty
|
||||||
|
- --temp
|
||||||
|
- "0.1"
|
||||||
|
- --top-p
|
||||||
|
- "0.95"
|
||||||
|
- --top-k
|
||||||
|
- "20"
|
||||||
|
- --repeat-penalty
|
||||||
|
- "1.0"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 40
|
||||||
|
start_period: 60s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
llama-vulkan:
|
||||||
|
profiles: ["vulkan"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vulkan
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./models:/models
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
- /dev/kfd:/dev/kfd
|
||||||
|
# Numerische HOST-GIDs verwenden — Namen wie "render" existieren im
|
||||||
|
# Container-Image nicht. GIDs prüfen mit: getent group video render
|
||||||
|
group_add:
|
||||||
|
- "44" # video (Host-GID ggf. anpassen)
|
||||||
|
- "991" # render (Host-GID ggf. anpassen)
|
||||||
|
security_opt:
|
||||||
|
- seccomp:unconfined
|
||||||
|
command:
|
||||||
|
- -m
|
||||||
|
- /models/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf
|
||||||
|
- --mmproj
|
||||||
|
- /models/mmproj-BF16.gguf
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "8000"
|
||||||
- --alias
|
- --alias
|
||||||
- gemma-4-12b
|
- qwen3.6
|
||||||
|
- -ngl
|
||||||
|
- "99"
|
||||||
|
- -fa
|
||||||
|
- "on"
|
||||||
|
- -c
|
||||||
|
- "40960"
|
||||||
|
- --parallel
|
||||||
|
- "1"
|
||||||
|
# KV-Cache quantisieren — 32GB VRAM, 35B-A3B + mmproj + Bildkontext:
|
||||||
|
- -ctk
|
||||||
|
- q8_0
|
||||||
|
- -ctv
|
||||||
|
- q8_0
|
||||||
|
- --jinja
|
||||||
|
- --ctx-checkpoints
|
||||||
|
- "0"
|
||||||
|
- --image-min-tokens
|
||||||
|
- "1024"
|
||||||
|
- --image-max-tokens
|
||||||
|
- "6144"
|
||||||
|
- --temp
|
||||||
|
- "0.1"
|
||||||
|
- --top-p
|
||||||
|
- "0.95"
|
||||||
|
- --top-k
|
||||||
|
- "20"
|
||||||
|
- --repeat-penalty
|
||||||
|
- "1.0"
|
||||||
|
# NOTFALL-Fallback bei Vision-Hängern/OOM unter RADV — mmproj auf CPU,
|
||||||
|
# Bildverarbeitung wird DEUTLICH langsamer. Nur aktivieren wenn nötig:
|
||||||
|
# - --no-mmproj-offload
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 40
|
||||||
|
start_period: 120s
|
||||||
|
restart: unless-stopped
|
||||||
48
debug_text.ts
Normal file
48
debug_text.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
async function debugText(filePath: string) {
|
||||||
|
console.log(`\nLese PDF für Text-Debugging: ${filePath}`);
|
||||||
|
|
||||||
|
const dataBuffer = fs.readFileSync(filePath);
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(dataBuffer) });
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
|
console.log(`Anzahl Seiten: ${pdfDocument.numPages}`);
|
||||||
|
|
||||||
|
// Wir nehmen uns Seite 1 vor
|
||||||
|
const page = await pdfDocument.getPage(1);
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
|
||||||
|
console.log(`\n--- RAW TEXT ITEMS (Die ersten 40 Fragmente) ---`);
|
||||||
|
for (let i = 0; i < Math.min(40, textContent.items.length); i++) {
|
||||||
|
const item = textContent.items[i] as any;
|
||||||
|
console.log(`Y: ${item.transform[5].toFixed(1).padStart(6)} | X: ${item.transform[4].toFixed(1).padStart(6)} | Text: "${item.str}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsere Sortier- und Bereinigungslogik anwenden
|
||||||
|
const sortedItems = textContent.items
|
||||||
|
.map((item: any) => ({
|
||||||
|
text: item.str,
|
||||||
|
x: item.transform[4],
|
||||||
|
y: item.transform[5]
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
if (Math.abs(b.y - a.y) > 5) return b.y - a.y;
|
||||||
|
return a.x - b.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
const textRaw = sortedItems.map((i: any) => i.text).join('').toUpperCase().replace(/[^A-Z]/g, '');
|
||||||
|
|
||||||
|
console.log(`\n--- BEREINIGTER SUCH-STRING (Die ersten 200 Zeichen) ---`);
|
||||||
|
console.log(textRaw.substring(0, 200));
|
||||||
|
console.log(`\nEnthält 'BUYERINFORMATIONSHEET'? -> ${textRaw.includes('BUYERINFORMATIONSHEET')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const pdfArgIndex = args.indexOf('--pdf');
|
||||||
|
if (pdfArgIndex !== -1 && args[pdfArgIndex + 1]) {
|
||||||
|
debugText(args[pdfArgIndex + 1]).catch(console.error);
|
||||||
|
} else {
|
||||||
|
console.error("Bitte --pdf Parameter angeben.");
|
||||||
|
}
|
||||||
119
enrich_and_anonymize.ts
Normal file
119
enrich_and_anonymize.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* enrich_and_anonymize.ts
|
||||||
|
*
|
||||||
|
* Zwei Aufgaben in einem Durchlauf auf einer buyers_vision.json:
|
||||||
|
* 1) Ergaenzt "name_from_filename" (falls fehlt) UND "_letter"
|
||||||
|
* (der A-Z-Unterordner, in dem das PDF liegt) fuer die PDF-Pfad-Aufloesung.
|
||||||
|
* 2) Optional (--anonymize): ersetzt PII-Felder (phone, cell, email, address)
|
||||||
|
* durch konsistente Faker-Dummydaten, damit das JSON weitergegeben werden
|
||||||
|
* kann. Leere Felder bleiben leer. Gleicher Originalwert -> gleicher Dummy.
|
||||||
|
*
|
||||||
|
* Der _letter wird aus dem Nachnamen (erstes Zeichen von name_from_filename)
|
||||||
|
* abgeleitet - das entspricht der Ordnerstruktur "...Buyers NDA's A-Z/<Letter>/".
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* npx tsx enrich_and_anonymize.ts buyers_vision.json
|
||||||
|
* npx tsx enrich_and_anonymize.ts buyers_vision.json --anonymize
|
||||||
|
* npx tsx enrich_and_anonymize.ts buyers_vision.json --anonymize --overwrite
|
||||||
|
*/
|
||||||
|
import { promises as fsp } from "node:fs";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
|
||||||
|
faker.seed(1234); // reproduzierbar
|
||||||
|
|
||||||
|
// IDENTISCH zur Funktion im vision_runner.ts
|
||||||
|
function nameFromFilename(fileName: string): string | null {
|
||||||
|
let s = fileName.replace(/\.pdf$/i, "");
|
||||||
|
s = s.replace(/\([^)]*\)/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
const comma = s.indexOf(",");
|
||||||
|
if (comma < 0) {
|
||||||
|
const first = s.replace(/\b\d{4,8}\b.*$/, "").trim();
|
||||||
|
return first.length >= 2 ? first : null;
|
||||||
|
}
|
||||||
|
const last = s.slice(0, comma).trim();
|
||||||
|
const rest = s.slice(comma + 1).trim();
|
||||||
|
const firstName = (rest.match(/^[A-Za-zÀ-ÿ.'-]+/) || [""])[0];
|
||||||
|
if (!last || !firstName) return null;
|
||||||
|
return `${last}, ${firstName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A-Z-Unterordner aus dem Nachnamen. Fallback: erstes Zeichen des Dateinamens. */
|
||||||
|
function letterFromName(fileName: string, nameFF: string | null): string | null {
|
||||||
|
const src = (nameFF ?? fileName).trim();
|
||||||
|
const ch = src.charAt(0).toUpperCase();
|
||||||
|
return /[A-Z]/.test(ch) ? ch : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = (v: unknown) => v === null || v === undefined || v === "";
|
||||||
|
|
||||||
|
// Konsistenz-Caches: gleicher Originalwert -> gleicher Dummy
|
||||||
|
const caches: Record<string, Map<string, string>> = {
|
||||||
|
phone: new Map(), cell: new Map(), email: new Map(), address: new Map(),
|
||||||
|
};
|
||||||
|
function fakePII(field: string, value: string): string {
|
||||||
|
const key = value.trim().toLowerCase();
|
||||||
|
const cache = caches[field];
|
||||||
|
if (cache.has(key)) return cache.get(key)!;
|
||||||
|
let dummy: string;
|
||||||
|
switch (field) {
|
||||||
|
case "phone":
|
||||||
|
case "cell": dummy = faker.phone.number(); break;
|
||||||
|
case "email": dummy = faker.internet.email(); break;
|
||||||
|
case "address": dummy = faker.location.streetAddress({ useFullAddress: true }); break;
|
||||||
|
default: dummy = faker.lorem.word();
|
||||||
|
}
|
||||||
|
cache.set(key, dummy);
|
||||||
|
return dummy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const file = args.find((a) => !a.startsWith("--"));
|
||||||
|
const overwrite = args.includes("--overwrite");
|
||||||
|
const anonymize = args.includes("--anonymize");
|
||||||
|
if (!file) {
|
||||||
|
console.error("Aufruf: npx tsx enrich_and_anonymize.ts <buyers_vision.json> [--anonymize] [--overwrite]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fsp.readFile(file, "utf8");
|
||||||
|
const records: Array<Record<string, unknown>> = JSON.parse(raw);
|
||||||
|
await fsp.writeFile(file + ".bak", raw);
|
||||||
|
|
||||||
|
let nameSet = 0, letterSet = 0, anonFields = 0;
|
||||||
|
const PII = ["phone", "cell", "email", "address"];
|
||||||
|
|
||||||
|
for (const r of records) {
|
||||||
|
const fn = r["file_name"] as string | undefined;
|
||||||
|
if (!fn) continue;
|
||||||
|
|
||||||
|
// name_from_filename
|
||||||
|
const hasName = !isEmpty(r["name_from_filename"]);
|
||||||
|
if (!hasName || overwrite) {
|
||||||
|
const val = nameFromFilename(fn);
|
||||||
|
if (r["name_from_filename"] !== val) { r["name_from_filename"] = val; nameSet++; }
|
||||||
|
}
|
||||||
|
// _letter (A-Z-Unterordner)
|
||||||
|
const hasLetter = !isEmpty(r["_letter"]);
|
||||||
|
if (!hasLetter || overwrite) {
|
||||||
|
const lv = letterFromName(fn, (r["name_from_filename"] as string | null) ?? null);
|
||||||
|
if (r["_letter"] !== lv) { r["_letter"] = lv; letterSet++; }
|
||||||
|
}
|
||||||
|
// Anonymisierung
|
||||||
|
if (anonymize) {
|
||||||
|
for (const f of PII) {
|
||||||
|
if (!isEmpty(r[f])) { r[f] = fakePII(f, String(r[f])); anonFields++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(file, JSON.stringify(records, null, 2));
|
||||||
|
console.log(`Datensaetze: ${records.length}`);
|
||||||
|
console.log(`name_from_filename: ${nameSet} gesetzt`);
|
||||||
|
console.log(`_letter: ${letterSet} gesetzt`);
|
||||||
|
if (anonymize) console.log(`PII anonymisiert: ${anonFields} Felder`);
|
||||||
|
console.log(`Backup: ${file}.bak`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
88
find_images.ts
Normal file
88
find_images.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
async function analyzeImages(filePath: string) {
|
||||||
|
console.log(`\nLese PDF für Bild-Analyse: ${filePath}`);
|
||||||
|
|
||||||
|
const dataBuffer = fs.readFileSync(filePath);
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(dataBuffer) });
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
|
// Wir analysieren nur Seite 1
|
||||||
|
const page = await pdfDocument.getPage(1);
|
||||||
|
const viewport = page.getViewport({ scale: 1.0 });
|
||||||
|
console.log(`Seitengröße: Breite = ${viewport.width.toFixed(2)}, Höhe = ${viewport.height.toFixed(2)}\n`);
|
||||||
|
|
||||||
|
const opList = await page.getOperatorList();
|
||||||
|
|
||||||
|
// Um die Koordinaten zu berechnen, müssen wir den Grafik-Status (Stack) verfolgen
|
||||||
|
let transformStack: number[][] = [[1, 0, 0, 1, 0, 0]]; // Standard-Matrix
|
||||||
|
let currentTransform = [1, 0, 0, 1, 0, 0];
|
||||||
|
let imageCount = 0;
|
||||||
|
|
||||||
|
console.log("--- GEFUNDENE BILDER AUF SEITE 1 ---");
|
||||||
|
|
||||||
|
for (let i = 0; i < opList.fnArray.length; i++) {
|
||||||
|
const fn = opList.fnArray[i];
|
||||||
|
const args = opList.argsArray[i];
|
||||||
|
|
||||||
|
// Status speichern (q)
|
||||||
|
if (fn === pdfjsLib.OPS.save) {
|
||||||
|
transformStack.push([...currentTransform]);
|
||||||
|
}
|
||||||
|
// Status wiederherstellen (Q)
|
||||||
|
else if (fn === pdfjsLib.OPS.restore) {
|
||||||
|
if (transformStack.length > 0) {
|
||||||
|
currentTransform = transformStack.pop()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Transformation anwenden (cm) - In PDFs werden Matrizen multipliziert,
|
||||||
|
// für die einfache Bildanalyse reicht oft der letzte Transform-Befehl vor dem Bild,
|
||||||
|
// da dieser die Skalierung (Breite/Höhe) und Position (X/Y) des 1x1 Pixel Objekts setzt.
|
||||||
|
else if (fn === pdfjsLib.OPS.transform) {
|
||||||
|
currentTransform = args;
|
||||||
|
}
|
||||||
|
// Wenn ein Bild gezeichnet wird!
|
||||||
|
else if (
|
||||||
|
fn === pdfjsLib.OPS.paintImageXObject ||
|
||||||
|
fn === pdfjsLib.OPS.paintInlineImageXObject ||
|
||||||
|
fn === pdfjsLib.OPS.paintJpegXObject
|
||||||
|
) {
|
||||||
|
imageCount++;
|
||||||
|
|
||||||
|
// In der PDF-Matrix:
|
||||||
|
// currentTransform[0] = Skalierung X (entspricht der Bild-Breite)
|
||||||
|
// currentTransform[3] = Skalierung Y (entspricht der Bild-Höhe)
|
||||||
|
// currentTransform[4] = Position X
|
||||||
|
// currentTransform[5] = Position Y
|
||||||
|
const width = currentTransform[0];
|
||||||
|
const height = currentTransform[3];
|
||||||
|
const x = currentTransform[4];
|
||||||
|
const y = currentTransform[5];
|
||||||
|
|
||||||
|
const isPageFilling = (width >= viewport.width - 10 && height >= viewport.height - 10);
|
||||||
|
const mark = isPageFilling ? " <-- SEITENFÜLLENDER SCAN / HINTERGRUND" : "";
|
||||||
|
|
||||||
|
console.log(`Bild ${imageCount}:`);
|
||||||
|
console.log(` Position: X = ${x.toFixed(2)}, Y = ${y.toFixed(2)}`);
|
||||||
|
console.log(` Größe: Breite = ${width.toFixed(2)}, Höhe = ${height.toFixed(2)}${mark}`);
|
||||||
|
console.log(` Interner Name: ${args[0]}`);
|
||||||
|
console.log(`--------------------------------------------------`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageCount === 0) {
|
||||||
|
console.log("Keine Bilder gefunden. Das Dokument besteht rein aus Vektoren und Text.");
|
||||||
|
} else {
|
||||||
|
console.log(`\nInsgesamt ${imageCount} Bild(er) gefunden.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufruf mit dem Pfad aus den Parametern
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const pdfArgIndex = args.indexOf('--pdf');
|
||||||
|
if (pdfArgIndex !== -1 && args[pdfArgIndex + 1]) {
|
||||||
|
analyzeImages(args[pdfArgIndex + 1]).catch(console.error);
|
||||||
|
} else {
|
||||||
|
console.error("Bitte --pdf Parameter angeben.");
|
||||||
|
}
|
||||||
138
notes_filter.ts
Normal file
138
notes_filter.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* notes_filter.ts — Notes-PDFs aussortieren und Seed-JSON erzeugen
|
||||||
|
*
|
||||||
|
* 1. Scannt alle PDFs unter --pdf-root rekursiv.
|
||||||
|
* 2. "Notes"-Dateien: prüft, ob eine Hauptdatei desselben Käufers existiert
|
||||||
|
* (Schlüssel: "Nachname, Vorname" — Datum wird ignoriert, da es abweicht).
|
||||||
|
* → mit Gegenstück: überspringen. Ohne Gegenstück: Report (out/notes_orphans.txt).
|
||||||
|
* 3. Schreibt Seed-JSON mit ALLEN Nicht-Notes-PDFs. Vorhandene Einträge aus
|
||||||
|
* --buyers (Gleis A / Vision) werden unverändert übernommen, neue Dateien
|
||||||
|
* bekommen einen Eintrag mit is_buyer_sheet:false (→ vision_runner nimmt sie).
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* npx tsx notes_filter.ts \
|
||||||
|
* --pdf-root "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z" \
|
||||||
|
* --buyers out/buyers.json \
|
||||||
|
* --out out/buyers_seed.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fsp from "node:fs/promises";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
|
||||||
|
interface BuyerRecord { file_name: string; is_buyer_sheet: boolean; [k: string]: unknown }
|
||||||
|
|
||||||
|
function parseArgs() {
|
||||||
|
const a = process.argv.slice(2);
|
||||||
|
const get = (f: string, d: string | null = null) => {
|
||||||
|
const i = a.indexOf(f);
|
||||||
|
return i >= 0 && a[i + 1] !== undefined ? a[i + 1] : d;
|
||||||
|
};
|
||||||
|
const pdfRoot = get("--pdf-root");
|
||||||
|
if (!pdfRoot) { console.error("Fehler: --pdf-root fehlt."); process.exit(1); }
|
||||||
|
return {
|
||||||
|
pdfRoot: pdfRoot.replace(/^~(?=\/)/, os.homedir()),
|
||||||
|
buyers: get("--buyers", "out/buyers.json")!,
|
||||||
|
out: get("--out", "out/buyers_seed.json")!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erkennt "Notes", "Note" und verklebte Varianten ("Notesb102312").
|
||||||
|
* Es wird nur der Teil NACH dem ersten Komma durchsucht, damit Nachnamen
|
||||||
|
* wie "Note, John" nicht fälschlich als Notes-Datei gelten. */
|
||||||
|
function isNotes(base: string): boolean {
|
||||||
|
const comma = base.indexOf(",");
|
||||||
|
const scope = comma >= 0 ? base.slice(comma + 1) : base;
|
||||||
|
return /\bnotes?[a-z]?\d*\b/i.test(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Stone, Mike via Anna Stone 031220 Notes.pdf" → "stone,mike" */
|
||||||
|
function nameKey(base: string): string {
|
||||||
|
let s = base.replace(/\.pdf$/i, "");
|
||||||
|
s = s.replace(/\bnotes?[a-z]?\d*\b/gi, " "); // Note/Notes/Notesb102312 raus
|
||||||
|
s = s.replace(/\b\d{4,8}\b/g, " "); // Datums-Tokens raus
|
||||||
|
s = s.replace(/\s+/g, " ").trim();
|
||||||
|
const comma = s.indexOf(",");
|
||||||
|
if (comma < 0) return s.toLowerCase(); // Fallback: ganzer Rest
|
||||||
|
const last = s.slice(0, comma).trim();
|
||||||
|
const first = (s.slice(comma + 1).trim().split(" ")[0] ?? "");
|
||||||
|
return `${last},${first}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectPdfs(root: string): Promise<string[]> {
|
||||||
|
const result: string[] = [];
|
||||||
|
const stack = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { continue; }
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) stack.push(p);
|
||||||
|
else if (e.isFile() && e.name.toLowerCase().endsWith(".pdf")) result.push(e.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FIELDS = {
|
||||||
|
name_company: null, prospective_buyer: null, company: null, phone: null,
|
||||||
|
cell: null, email: null, address: null, state: null, how_did_you_hear: null,
|
||||||
|
interested_in_updates: null, types_of_business_raw: null,
|
||||||
|
background_experience: null, total_purchase_price: null, down_payment: null,
|
||||||
|
down_payment_raw: null, date_of_introduction: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs();
|
||||||
|
|
||||||
|
console.error(`Scanne ${args.pdfRoot} ...`);
|
||||||
|
const all = await collectPdfs(args.pdfRoot);
|
||||||
|
const notesFiles = all.filter(isNotes);
|
||||||
|
const mainFiles = all.filter((f) => !isNotes(f));
|
||||||
|
console.error(`${all.length} PDFs: ${mainFiles.length} Hauptdateien, ${notesFiles.length} Notes-Dateien`);
|
||||||
|
|
||||||
|
// Notes-Abgleich
|
||||||
|
const mainKeys = new Set(mainFiles.map(nameKey));
|
||||||
|
const skippable: string[] = [];
|
||||||
|
const orphans: string[] = [];
|
||||||
|
for (const n of notesFiles) (mainKeys.has(nameKey(n)) ? skippable : orphans).push(n);
|
||||||
|
console.error(`Notes mit Gegenstück (werden ignoriert): ${skippable.length}`);
|
||||||
|
console.error(`Notes OHNE Gegenstück (bitte prüfen): ${orphans.length}`);
|
||||||
|
if (orphans.length) {
|
||||||
|
const orphanPath = path.join(path.dirname(args.out), "notes_orphans.txt");
|
||||||
|
await fsp.mkdir(path.dirname(orphanPath), { recursive: true });
|
||||||
|
await fsp.writeFile(orphanPath, orphans.sort().join("\n") + "\n");
|
||||||
|
console.error(`→ ${orphanPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorhandene Ergebnisse laden
|
||||||
|
const existing = new Map<string, BuyerRecord>();
|
||||||
|
try {
|
||||||
|
const prev: BuyerRecord[] = JSON.parse(await fsp.readFile(args.buyers, "utf8"));
|
||||||
|
for (const r of prev) existing.set(r.file_name, r);
|
||||||
|
} catch {
|
||||||
|
console.error(`Hinweis: ${args.buyers} nicht gefunden — starte mit leerem Bestand.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In die Extraktion gehen: alle Hauptdateien + Notes OHNE Gegenstück.
|
||||||
|
// Notes MIT Gegenstück fallen weg (auch deren alte Einträge).
|
||||||
|
const includeFiles = [...mainFiles, ...orphans];
|
||||||
|
let seeded = 0, carried = 0;
|
||||||
|
const out: BuyerRecord[] = includeFiles.sort().map((f) => {
|
||||||
|
const prev = existing.get(f);
|
||||||
|
if (prev) { carried++; return prev; }
|
||||||
|
seeded++;
|
||||||
|
return { file_name: f, is_buyer_sheet: false, ...EMPTY_FIELDS, _parser: "seed" };
|
||||||
|
});
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(args.out), { recursive: true });
|
||||||
|
await fsp.writeFile(args.out, JSON.stringify(out, null, 2));
|
||||||
|
console.error(
|
||||||
|
`Seed geschrieben: ${out.length} Einträge (${carried} übernommen, ${seeded} neu, davon ${orphans.length} Orphan-Notes) → ${args.out}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error("Abbruch:", e instanceof Error ? e.message : e); process.exit(1); });
|
||||||
2354
out/buyers.json
Normal file
2354
out/buyers.json
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@faker-js/faker": "^10.5.0",
|
||||||
|
"canvas": "^3.2.3",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
|
"pdfjs-dist": "^3.11.174"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
run_parser.ts
Normal file
28
run_parser.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// run_parser.ts
|
||||||
|
import { DeterministicParser } from './BuyerSheetParser.ts';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Einfaches Auslesen der Kommandozeilenargumente
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const pdfArgIndex = args.indexOf('--pdf');
|
||||||
|
|
||||||
|
if (pdfArgIndex === -1 || !args[pdfArgIndex + 1]) {
|
||||||
|
console.error("Usage: npx ts-node run_parser.ts --pdf <path_to_pdf>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfPath = args[pdfArgIndex + 1];
|
||||||
|
const parser = new DeterministicParser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Lese PDF: ${pdfPath} ...\n`);
|
||||||
|
const result = await parser.parsePdf(pdfPath);
|
||||||
|
|
||||||
|
// JSON formatiert und farbig (optional) ausgeben
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Parsen des PDFs:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
704
vision_runner.ts
Normal file
704
vision_runner.ts
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* vision_runner.ts — Gleis B: Vision-LLM-Extraktion für Buyer Information Sheets
|
||||||
|
*
|
||||||
|
* Liest out/buyers.json (Ergebnis von Gleis A / batch_runner.ts), nimmt alle
|
||||||
|
* Einträge mit is_buyer_sheet === false, rendert die PDF-Seiten via pdftoppm
|
||||||
|
* (poppler-utils) zu PNGs und schickt sie an einen llama-server
|
||||||
|
* (OpenAI-kompatibel, Qwen3.6 + mmproj).
|
||||||
|
*
|
||||||
|
* Prinzip: Das VLM TRANSKRIBIERT nur (Rohwerte, verbatim). Sämtliche
|
||||||
|
* Normalisierung (Leer-Marker, State, down_payment, Datum) passiert
|
||||||
|
* deterministisch hier in TypeScript — identische Regeln wie Gleis A.
|
||||||
|
*
|
||||||
|
* Aufruf (AMD/Vulkan, inhouse):
|
||||||
|
* npx tsx vision_runner.ts \
|
||||||
|
* --input out/buyers.json \
|
||||||
|
* --pdf-root "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z" \
|
||||||
|
* --api http://localhost:8000/v1 --limit 5
|
||||||
|
*
|
||||||
|
* Aufruf (NVIDIA/CUDA, EC2):
|
||||||
|
* npx tsx vision_runner.ts --input out/buyers.json \
|
||||||
|
* --pdf-root ~/data --api http://localhost:8000/v1 --limit 5
|
||||||
|
*
|
||||||
|
* Voraussetzungen: Node >= 18 (fetch), poppler-utils (pdftoppm) installiert.
|
||||||
|
*
|
||||||
|
* Outputs:
|
||||||
|
* out/buyers_vision.json — nur die Vision-Ergebnisse (Resume-Datei)
|
||||||
|
* out/buyers_merged.json — Gleis A + Gleis B zusammengeführt
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import * as fsp from "node:fs/promises";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
|
||||||
|
const execFileP = promisify(execFile);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
pdfRoot: string;
|
||||||
|
api: string;
|
||||||
|
limit: number;
|
||||||
|
only: string | null;
|
||||||
|
dpi: number | "auto";
|
||||||
|
maxPages: number;
|
||||||
|
force: boolean;
|
||||||
|
reprocessMissing: boolean;
|
||||||
|
outDir: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): Args {
|
||||||
|
const a = process.argv.slice(2);
|
||||||
|
const get = (flag: string, def: string | null = null): string | null => {
|
||||||
|
const i = a.indexOf(flag);
|
||||||
|
return i >= 0 && a[i + 1] !== undefined ? a[i + 1] : def;
|
||||||
|
};
|
||||||
|
const pdfRoot = get("--pdf-root");
|
||||||
|
const api = (get("--api", "http://localhost:8000/v1") || "").replace(/\/+$/, "");
|
||||||
|
if (!pdfRoot) {
|
||||||
|
console.error("Fehler: --pdf-root <Verzeichnis> ist erforderlich.");
|
||||||
|
process.exit(1);
|
||||||
|
throw new Error("unreachable"); // hilft dem TS-Narrowing
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pdfRoot: pdfRoot.replace(/^~(?=\/)/, os.homedir()),
|
||||||
|
api,
|
||||||
|
limit: parseInt(get("--limit", "0")!, 10) || 0,
|
||||||
|
only: get("--only"),
|
||||||
|
dpi: ((): number | "auto" => {
|
||||||
|
const v = get("--dpi", "auto")!;
|
||||||
|
return v === "auto" ? "auto" : parseInt(v, 10) || 150;
|
||||||
|
})(),
|
||||||
|
// Nur PDFs mit HOECHSTENS so vielen Seiten werden per Vision gescannt.
|
||||||
|
maxPages: parseInt(get("--max-pages", "10")!, 10) || 10,
|
||||||
|
force: a.includes("--force"),
|
||||||
|
reprocessMissing: a.includes("--reprocess-missing"),
|
||||||
|
outDir: get("--out-dir", "out")!,
|
||||||
|
timeoutMs: (parseInt(get("--timeout", "180")!, 10) || 180) * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface BuyerRecord {
|
||||||
|
file_name: string;
|
||||||
|
is_buyer_sheet: boolean;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rohantwort des VLM — alles verbatim, Normalisierung erfolgt in TS. */
|
||||||
|
interface VisionRaw {
|
||||||
|
doc_type: "buyer_sheet" | "ca_only" | "other";
|
||||||
|
info_sheet_count: number;
|
||||||
|
info_page: number | null;
|
||||||
|
ca_page: number | null;
|
||||||
|
notes_page: number | null;
|
||||||
|
name_company: string | null;
|
||||||
|
prospective_buyer: string | null;
|
||||||
|
company: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
cell: string | null;
|
||||||
|
email: string | null;
|
||||||
|
address: string | null;
|
||||||
|
state: string | null;
|
||||||
|
how_did_you_hear: string | null;
|
||||||
|
interested_in_updates: string | null;
|
||||||
|
types_of_business_raw: string | null;
|
||||||
|
notes_business_raw: string | null;
|
||||||
|
background_experience: string | null;
|
||||||
|
total_purchase_price: string | null;
|
||||||
|
down_payment_raw: string | null;
|
||||||
|
date_of_introduction_raw: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Normalisierung — identisch zu Gleis A halten!
|
||||||
|
// (Falls BuyerSheetParser.ts diese Funktionen exportiert, stattdessen
|
||||||
|
// importieren, damit beide Gleise garantiert dieselbe Logik nutzen.)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NULL_MARKERS = /^(n|na|n\/a|none|nil|x|-+|\.+)$/i;
|
||||||
|
|
||||||
|
function cleanStr(v: string | null | undefined): string | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const t = String(v).replace(/\s+/g, " ").trim();
|
||||||
|
if (!t || NULL_MARKERS.test(t)) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_MAP: Record<string, string> = {
|
||||||
|
alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA",
|
||||||
|
colorado: "CO", connecticut: "CT", delaware: "DE", florida: "FL", georgia: "GA",
|
||||||
|
hawaii: "HI", idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA",
|
||||||
|
kansas: "KS", kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD",
|
||||||
|
massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS",
|
||||||
|
missouri: "MO", montana: "MT", nebraska: "NE", nevada: "NV",
|
||||||
|
"new hampshire": "NH", "new jersey": "NJ", "new mexico": "NM",
|
||||||
|
"new york": "NY", "north carolina": "NC", "north dakota": "ND", ohio: "OH",
|
||||||
|
oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI",
|
||||||
|
"south carolina": "SC", "south dakota": "SD", tennessee: "TN", texas: "TX",
|
||||||
|
utah: "UT", vermont: "VT", virginia: "VA", washington: "WA",
|
||||||
|
"west virginia": "WV", wisconsin: "WI", wyoming: "WY",
|
||||||
|
"district of columbia": "DC", "washington dc": "DC",
|
||||||
|
};
|
||||||
|
const STATE_CODES = new Set(Object.values(STATE_MAP));
|
||||||
|
|
||||||
|
function normState(v: string | null): string | null {
|
||||||
|
const c = cleanStr(v);
|
||||||
|
if (!c) return null;
|
||||||
|
const up = c.toUpperCase().replace(/\./g, "");
|
||||||
|
if (up.length === 2 && STATE_CODES.has(up)) return up;
|
||||||
|
const full = STATE_MAP[c.toLowerCase().replace(/\./g, "")];
|
||||||
|
return full ?? c; // unbekannt: bereinigt durchreichen, nicht raten
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "$350,000" → "350000"; "1.5M" → "1500000"; sonst null + raw. */
|
||||||
|
function normDownPayment(raw: string | null): { value: string | null; raw: string | null } {
|
||||||
|
const c = cleanStr(raw);
|
||||||
|
if (!c) return { value: null, raw: null };
|
||||||
|
const m = c.match(/^\$?\s*(\d[\d,]*(?:\.\d+)?)\s*([kKmM])?\s*$/);
|
||||||
|
if (!m) return { value: null, raw: c };
|
||||||
|
let num = parseFloat(m[1].replace(/,/g, ""));
|
||||||
|
if (m[2]) num *= /k/i.test(m[2]) ? 1_000 : 1_000_000;
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return { value: null, raw: c };
|
||||||
|
return { value: String(Math.round(num)), raw: c };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS: Record<string, number> = {
|
||||||
|
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
||||||
|
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isoDate(y: number, mo: number, d: number): string | null {
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
if (y < 1990 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||||
|
return `${y}-${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** US-Formate: 6/25/26, 06-25-2026, "June 25, 2026" → YYYY-MM-DD. */
|
||||||
|
function normDate(raw: string | null): string | null {
|
||||||
|
let c = cleanStr(raw);
|
||||||
|
if (!c) return null;
|
||||||
|
// Leerzeichen um Trenner entfernen: "09 / 13 / 2021" -> "09/13/2021"
|
||||||
|
c = c.replace(/\s*([\/\-.])\s*/g, "$1").trim();
|
||||||
|
let m = c.match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2}|\d{4})$/);
|
||||||
|
if (m) return isoDate(parseInt(m[3], 10), parseInt(m[1], 10), parseInt(m[2], 10));
|
||||||
|
m = c.match(/^([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{2}|\d{4})$/);
|
||||||
|
if (m) {
|
||||||
|
const mo = MONTHS[m[1].slice(0, 3).toLowerCase()];
|
||||||
|
if (mo) return isoDate(parseInt(m[3], 10), mo, parseInt(m[2], 10));
|
||||||
|
}
|
||||||
|
m = c.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (m) return isoDate(parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PDF-Index (rekursiv, Basename → voller Pfad) + Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildPdfIndex(root: string): Promise<Map<string, string>> {
|
||||||
|
const index = new Map<string, string>();
|
||||||
|
const stack = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) stack.push(p);
|
||||||
|
else if (e.isFile() && e.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
if (!index.has(e.name)) index.set(e.name, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seitenzahl via pdfinfo (poppler-utils). -1 bei Fehler/beschaedigtem PDF. */
|
||||||
|
async function pdfPageCount(pdfPath: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileP("pdfinfo", [pdfPath], { timeout: 30_000 });
|
||||||
|
const m = stdout.match(/^Pages:\s+(\d+)/m);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
} catch {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPdf(pdfPath: string, dpi: number, maxPages: number, tmpDir: string): Promise<string[]> {
|
||||||
|
const prefix = path.join(tmpDir, "page");
|
||||||
|
await execFileP("pdftoppm", ["-png", "-r", String(dpi), "-l", String(maxPages), pdfPath, prefix], {
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
const pageNum = (f: string) => parseInt(f.match(/-(\d+)\.png$/)?.[1] ?? "0", 10);
|
||||||
|
const files = (await fsp.readdir(tmpDir))
|
||||||
|
.filter((f) => f.endsWith(".png"))
|
||||||
|
.sort((a, b) => pageNum(a) - pageNum(b))
|
||||||
|
.map((f) => path.join(tmpDir, f));
|
||||||
|
if (files.length === 0) throw new Error("pdftoppm hat keine Seiten erzeugt");
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hat das PDF eine nennenswerte Textebene? (Hybrid: Werte getippt → 150 DPI
|
||||||
|
* reicht. Reiner Scan: keine Textebene, Handschrift möglich → 200 DPI.) */
|
||||||
|
async function hasTextLayer(pdfPath: string, maxPages: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileP("pdftotext", ["-l", String(maxPages), pdfPath, "-"], {
|
||||||
|
timeout: 30_000, maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return stdout.replace(/\s+/g, "").length > 100;
|
||||||
|
} catch {
|
||||||
|
return false; // im Zweifel als Scan behandeln → hohe Auflösung
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VLM-Aufruf (llama-server, OpenAI-kompatibel, JSON-Schema)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const nullableString = { type: ["string", "null"] };
|
||||||
|
const nullableInt = { type: ["integer", "null"] };
|
||||||
|
|
||||||
|
const RESPONSE_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [
|
||||||
|
"doc_type", "info_sheet_count", "info_page", "ca_page", "notes_page", "name_company",
|
||||||
|
"prospective_buyer", "company", "phone", "cell", "email", "address",
|
||||||
|
"state", "how_did_you_hear", "interested_in_updates",
|
||||||
|
"types_of_business_raw", "notes_business_raw", "background_experience",
|
||||||
|
"total_purchase_price", "down_payment_raw", "date_of_introduction_raw",
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
doc_type: { type: "string", enum: ["buyer_sheet", "ca_only", "other"] },
|
||||||
|
info_sheet_count: { type: "integer" },
|
||||||
|
info_page: nullableInt,
|
||||||
|
ca_page: nullableInt,
|
||||||
|
notes_page: nullableInt,
|
||||||
|
name_company: nullableString,
|
||||||
|
prospective_buyer: nullableString,
|
||||||
|
company: nullableString,
|
||||||
|
phone: nullableString,
|
||||||
|
cell: nullableString,
|
||||||
|
email: nullableString,
|
||||||
|
address: nullableString,
|
||||||
|
state: nullableString,
|
||||||
|
how_did_you_hear: nullableString,
|
||||||
|
interested_in_updates: nullableString,
|
||||||
|
types_of_business_raw: nullableString,
|
||||||
|
notes_business_raw: nullableString,
|
||||||
|
background_experience: nullableString,
|
||||||
|
total_purchase_price: nullableString,
|
||||||
|
down_payment_raw: nullableString,
|
||||||
|
date_of_introduction_raw: nullableString,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
"You are a precise document transcription engine for scanned business forms. " +
|
||||||
|
"You transcribe handwritten and typed form fields EXACTLY as written (verbatim), " +
|
||||||
|
"including typos. You never guess, infer, or invent values. If a field is blank " +
|
||||||
|
"or unreadable, you return null.";
|
||||||
|
|
||||||
|
const USER_PROMPT = `You see all pages of one PDF, in order. Each image is preceded by a text marker "=== PAGE N ===" that tells you its exact page number. Use these markers to assign page numbers — never guess a page number.
|
||||||
|
|
||||||
|
Task: Identify the page types by their headings, then transcribe form fields from a business brokerage "BUYER INFORMATION SHEET" package.
|
||||||
|
|
||||||
|
STEP 1 — Identify each page by its UNIQUE marker text (pages can appear in ANY order):
|
||||||
|
- NOTES page: contains the text "Date NDA Scanned". It is a handwritten cover sheet with "Name:", "Date NDA Scanned:", a two-column table headed "Business Interested In:", and a free-text "Notes" area at the bottom. NOT every package has one.
|
||||||
|
- INFO SHEET page: contains the printed heading "BUYER INFORMATION SHEET" (usually with the BizMatch logo at the top).
|
||||||
|
- CA page: contains "CONFIDENTIALITY AGREEMENT" or "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL".
|
||||||
|
Set notes_page, info_page, ca_page to the respective 1-based page numbers, or null if that page type is absent.
|
||||||
|
EXCLUSIVITY RULE — each page number may be assigned to AT MOST ONE of notes_page / info_page / ca_page. A single page is never two types at once. Decide by marker priority:
|
||||||
|
1. If the page shows "Date NDA Scanned" → it is the NOTES page. It is NEVER the info sheet, even if it is page 1 and even if it also lists businesses.
|
||||||
|
2. Else if it shows "BUYER INFORMATION SHEET" → info sheet.
|
||||||
|
3. Else if it shows the confidentiality wording → CA page.
|
||||||
|
So info_page and notes_page must be DIFFERENT numbers (or one of them null). If you were about to set them equal, you misread one — re-check which page carries "BUYER INFORMATION SHEET" versus "Date NDA Scanned".
|
||||||
|
|
||||||
|
STEP 2 — doc_type:
|
||||||
|
- "buyer_sheet": an INFO SHEET page exists.
|
||||||
|
- "ca_only": no info sheet, but a CA page exists.
|
||||||
|
- "other": none of the above — return null for every field and 0 for info_sheet_count.
|
||||||
|
2. info_sheet_count: how many separate INFO SHEET pages exist (some files contain two). 0 if none. If more than one, transcribe from the FIRST info sheet only.
|
||||||
|
|
||||||
|
STEP 3 — Transcribe VERBATIM (exactly as written, do not normalize or expand abbreviations), each field ONLY from the page type named:
|
||||||
|
5. From the INFO SHEET page (only if info_page is set):
|
||||||
|
- name_company: "Name/Company" line
|
||||||
|
- prospective_buyer: "Prospective Buyer" line
|
||||||
|
- company: separate "Company" line if present
|
||||||
|
- phone, cell, email
|
||||||
|
- address: the COMPLETE address on the "ADDRESS" line. The address field has TWO parts side by side: the left part is the street ("PO BOX / STREET"), the right part is the city/state/ZIP ("CITY / STATE / ZIP"). Transcribe BOTH parts as one full address, left to right (e.g. "2688 Grassina St. #631, San Jose, CA 95136"). Do NOT stop after the street — always include the city/state/ZIP part to the right, even if there is a wide gap between them or the right part is handwritten.
|
||||||
|
- state: the US state from the address (2-letter code if shown, e.g. "CA", "TX"), else null
|
||||||
|
- how_did_you_hear: "How did you hear about us"
|
||||||
|
- interested_in_updates: the marked updates answer/checkbox ("Yes"/"No"), else null
|
||||||
|
- types_of_business_raw: the "Type(s) of business interested in" list FROM THE INFO SHEET ONLY. This is usually a MULTI-LINE list with several entries stacked vertically (e.g. "RETAIL/TRADE", "FUEL STATIONS/CONVENIENCE STORES", "RESTAURANTS/BAKERY/CAFES", "TRANSPORT", "IT TELECOM"). Transcribe EVERY line of the list, not just the first one. Include entries even if they are struck through / crossed out (transcribe them as written). Join all entries with a comma in top-to-bottom order. This must come from the info sheet page, NEVER from the notes page.
|
||||||
|
- background_experience: "Background/Experience" (may span multiple lines)
|
||||||
|
- total_purchase_price: "Total Purchase Price" as written
|
||||||
|
- down_payment_raw: "Down Payment" as written (e.g. "$350,000", "1.5M", "TBD")
|
||||||
|
6. If doc_type is "ca_only": transcribe from the CA page — the prospective buyer's printed/signed name into prospective_buyer, plus address/phone/email if present. Everything else null.
|
||||||
|
7. date_of_introduction_raw: the "Date of Introduction" on the CA page (usually near the buyer's signature), transcribed EXACTLY as written including any spaces or separators (e.g. "09 / 13 / 2021", "6/25/26", "Sept 13 2021"). If a date is visible anywhere labeled "Date of Introduction", always return it verbatim — never leave it null just because the format looks unusual. Only null if truly no such date is present.
|
||||||
|
8. From the NOTES page (only if notes_page is set):
|
||||||
|
- notes_business_raw: the business name(s) written in the "Business Interested In" TABLE of the notes page, verbatim (join multiple with a comma). Take ONLY the table entries — do NOT include the free-text "Notes" area at the bottom of the page. If the table is empty, null.
|
||||||
|
The notes page and the info sheet are INDEPENDENT sources; never copy content from one into the other's field.
|
||||||
|
|
||||||
|
A blank field, "N/A", "n", or an empty line = transcribe it as written; if truly empty, use null.
|
||||||
|
Handwriting rules: Transcribe handwritten values letter by letter — do NOT complete them from context or from other fields. Email addresses are the most reliable spelling source on the page: read the email character by character, and if a handwritten name is ambiguous (e.g. B vs D), prefer the spelling that appears in the email address. Never alter the email itself to match your reading of the name.
|
||||||
|
Return only the JSON object.`;
|
||||||
|
|
||||||
|
async function fetchModelId(api: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${api}/models`);
|
||||||
|
const j = (await r.json()) as { data?: Array<{ id?: string }> };
|
||||||
|
return j?.data?.[0]?.id ?? "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wartet nach einem Server-Neustart (503/Verbindungsfehler), bis /health
|
||||||
|
* wieder OK meldet — Modell-Reload dauert 1-2 Minuten. */
|
||||||
|
async function waitForHealthy(api: string, timeoutMs: number): Promise<void> {
|
||||||
|
const healthUrl = api.replace(/\/v1\/?$/, "") + "/health";
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) });
|
||||||
|
if (r.ok) return;
|
||||||
|
} catch {
|
||||||
|
/* Server noch weg */
|
||||||
|
}
|
||||||
|
await new Promise((res) => setTimeout(res, 5000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callVision(api: string, images: string[], timeoutMs: number): Promise<VisionRaw> {
|
||||||
|
const content: Array<Record<string, unknown>> = [{ type: "text", text: USER_PROMPT }];
|
||||||
|
// Vor jedes Bild einen Seiten-Marker setzen, damit das Modell zweifelsfrei
|
||||||
|
// weiss, welches Bild welche Seitennummer ist (loest Info/Notes-Verwechslung).
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const b64 = await fsp.readFile(images[i], { encoding: "base64" });
|
||||||
|
content.push({ type: "text", text: `=== PAGE ${i + 1} ===` });
|
||||||
|
content.push({ type: "image_url", image_url: { url: `data:image/png;base64,${b64}` } });
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
model: "qwen3.6",
|
||||||
|
temperature: 0.1,
|
||||||
|
top_p: 0.95,
|
||||||
|
max_tokens: 1500,
|
||||||
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: { name: "buyer_sheet", strict: true, schema: RESPONSE_SCHEMA },
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${api}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: ctl.signal,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
||||||
|
const j = (await r.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
const text = j?.choices?.[0]?.message?.content;
|
||||||
|
if (!text) throw new Error("Leere Antwort vom Server");
|
||||||
|
return JSON.parse(text) as VisionRaw;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Merge: deterministischer Datensatz + Vision-Rohwerte → Zielstruktur
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht den Namen deterministisch aus dem Dateinamen (verlaessliche Quelle,
|
||||||
|
* unabhaengig von der Vision-Transkription). Schema: "Nachname, Vorname <datum> [Zusatz].pdf".
|
||||||
|
* Gibt "Nachname, Vorname" zurueck, oder null wenn nicht erkennbar.
|
||||||
|
* Beeinflusst die Bilderkennung NICHT — reine String-Operation.
|
||||||
|
*/
|
||||||
|
function nameFromFilename(fileName: string): string | null {
|
||||||
|
let s = fileName.replace(/\.pdf$/i, "");
|
||||||
|
// Klammer-Zusaetze wie "(Gordon Greve)" / "(JF Lehman)" entfernen
|
||||||
|
s = s.replace(/\([^)]*\)/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
// Schema ist "Nachname, Vorname ...". Nimm den Nachnamen (vor dem Komma)
|
||||||
|
// und aus dem Rest nur das erste Wort als Vorname - alles Weitere
|
||||||
|
// (Zweitnamen, Datum, Notes/Oilfield-Zusaetze) faellt weg.
|
||||||
|
const comma = s.indexOf(",");
|
||||||
|
if (comma < 0) {
|
||||||
|
// kein Komma: erstes Wort als Ganzes nehmen, ohne Datum/Zusatz
|
||||||
|
const first = s.replace(/\b\d{4,8}\b.*$/, "").trim();
|
||||||
|
return first.length >= 2 ? first : null;
|
||||||
|
}
|
||||||
|
const last = s.slice(0, comma).trim();
|
||||||
|
const rest = s.slice(comma + 1).trim();
|
||||||
|
// erstes Token des Rests = Vorname (stoppt vor Datum/Zusatzwort)
|
||||||
|
const firstName = (rest.match(/^[A-Za-zÀ-ÿ.'-]+/) || [""])[0];
|
||||||
|
if (!last || !firstName) return null;
|
||||||
|
return `${last}, ${firstName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mergeRecord(det: BuyerRecord, vis: VisionRaw, model: string): BuyerRecord {
|
||||||
|
const base: BuyerRecord = {
|
||||||
|
...det,
|
||||||
|
_parser: "vision",
|
||||||
|
_vision_model: model,
|
||||||
|
_vision_ts: new Date().toISOString(),
|
||||||
|
name_from_filename: nameFromFilename(det.file_name),
|
||||||
|
_vision_error: undefined,
|
||||||
|
};
|
||||||
|
delete (base as Record<string, unknown>)["_vision_error"];
|
||||||
|
|
||||||
|
if (vis.doc_type === "other") {
|
||||||
|
return { ...base, is_buyer_sheet: false, _doc_type: "other", _info_page: null, _ca_page: null, _notes_page: vis.notes_page ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyer_sheet ODER ca_only: alles übernehmen, was das Dokument hergibt
|
||||||
|
const dp = normDownPayment(vis.down_payment_raw);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
is_buyer_sheet: vis.doc_type === "buyer_sheet",
|
||||||
|
_doc_type: vis.doc_type,
|
||||||
|
_info_sheet_count: vis.info_sheet_count ?? (vis.doc_type === "buyer_sheet" ? 1 : 0),
|
||||||
|
name_company: cleanStr(vis.name_company),
|
||||||
|
prospective_buyer: cleanStr(vis.prospective_buyer),
|
||||||
|
company: cleanStr(vis.company),
|
||||||
|
phone: cleanStr(vis.phone),
|
||||||
|
cell: cleanStr(vis.cell),
|
||||||
|
email: cleanStr(vis.email),
|
||||||
|
address: cleanStr(vis.address),
|
||||||
|
state: normState(vis.state),
|
||||||
|
how_did_you_hear: cleanStr(vis.how_did_you_hear),
|
||||||
|
interested_in_updates: cleanStr(vis.interested_in_updates),
|
||||||
|
types_of_business_raw: cleanStr(vis.types_of_business_raw),
|
||||||
|
notes_business_raw: cleanStr(vis.notes_business_raw),
|
||||||
|
background_experience: cleanStr(vis.background_experience),
|
||||||
|
total_purchase_price: cleanStr(vis.total_purchase_price),
|
||||||
|
down_payment: dp.value,
|
||||||
|
down_payment_raw: dp.raw,
|
||||||
|
// Vision-Datum bevorzugt; Fallback: deterministischer Wert (z.B. aus Dateinamen)
|
||||||
|
// Datum: ISO wenn parsebar, sonst ROHWERT behalten (Mitarbeiter korrigiert
|
||||||
|
// spaeter). Nie verwerfen, nur weil das Format ungewohnt ist.
|
||||||
|
date_of_introduction: normDate(vis.date_of_introduction_raw) ?? cleanStr(vis.date_of_introduction_raw) ?? (det.date_of_introduction as string | null) ?? null,
|
||||||
|
date_of_introduction_raw: cleanStr(vis.date_of_introduction_raw),
|
||||||
|
_info_page: vis.info_page,
|
||||||
|
_ca_page: vis.ca_page,
|
||||||
|
_notes_page: vis.notes_page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RETRIES = 3;
|
||||||
|
|
||||||
|
function progress(line: string): void {
|
||||||
|
// Im Log-Modus (Pipe/tee): vollständige Zeilen mit Timestamp statt \r-Überschreiben
|
||||||
|
if (!process.stderr.isTTY) {
|
||||||
|
process.stderr.write(`[${new Date().toISOString()}] ${line}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cols = process.stderr.columns ?? 120;
|
||||||
|
process.stderr.write("\r" + line.slice(0, cols - 1).padEnd(cols - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs();
|
||||||
|
|
||||||
|
// pdftoppm vorhanden?
|
||||||
|
try {
|
||||||
|
await execFileP("pdftoppm", ["-v"]);
|
||||||
|
} catch {
|
||||||
|
console.error("Fehler: pdftoppm nicht gefunden. Installieren: sudo apt install poppler-utils");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visionPath = path.join(args.outDir, "buyers_vision.json");
|
||||||
|
await fsp.mkdir(args.outDir, { recursive: true });
|
||||||
|
|
||||||
|
// Resume-Stand laden (bereits verarbeitete Dokumente)
|
||||||
|
const done = new Map<string, BuyerRecord>();
|
||||||
|
try {
|
||||||
|
const prev: BuyerRecord[] = JSON.parse(await fsp.readFile(visionPath, "utf8"));
|
||||||
|
for (const r of prev) done.set(r.file_name, r);
|
||||||
|
} catch {
|
||||||
|
/* kein Resume-Stand */
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// PHASE 1 — INDEX: alle PDFs scannen, file_name + _pages_total sofort
|
||||||
|
// ins buyers_vision.json eintragen (auch die zu grossen, dann markiert).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
console.error(`Phase 1: Indexiere PDFs unter ${args.pdfRoot} ...`);
|
||||||
|
const pdfIndex = await buildPdfIndex(args.pdfRoot);
|
||||||
|
const names = [...pdfIndex.keys()].sort();
|
||||||
|
console.error(`${names.length} PDFs gefunden. Ermittle Seitenzahlen ...`);
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
idx++;
|
||||||
|
// schon indexiert (mit gueltiger Seitenzahl)? dann nicht neu zaehlen
|
||||||
|
const existing = done.get(name);
|
||||||
|
if (existing && typeof existing["_pages_total"] === "number" && existing["_pages_total"]! >= 0 && !args.force) {
|
||||||
|
if (idx % 50 === 0) progress(`Index ${idx}/${names.length}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pdfPath = pdfIndex.get(name)!;
|
||||||
|
const pages = await pdfPageCount(pdfPath);
|
||||||
|
const tooMany = pages < 0 ? false : pages > args.maxPages;
|
||||||
|
const prev = done.get(name) ?? { file_name: name, is_buyer_sheet: false };
|
||||||
|
done.set(name, {
|
||||||
|
...prev,
|
||||||
|
file_name: name,
|
||||||
|
_pages_total: pages,
|
||||||
|
...(pages < 0 ? { _index_error: "pdfinfo fehlgeschlagen (beschaedigt?)" } : {}),
|
||||||
|
...(tooMany ? { _skipped_too_many_pages: true } : {}),
|
||||||
|
});
|
||||||
|
if (idx % 25 === 0 || idx === names.length) progress(`Index ${idx}/${names.length}`);
|
||||||
|
}
|
||||||
|
// Index sofort persistieren, bevor die teure Phase 2 startet
|
||||||
|
await fsp.writeFile(visionPath, JSON.stringify([...done.values()], null, 2));
|
||||||
|
process.stderr.write("\n");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// PHASE 2 — INHALT: nur PDFs <= maxPages, noch nicht (fehlerfrei) erledigt.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
let candidates = [...done.values()].filter((r) => {
|
||||||
|
const pages = r["_pages_total"] as number | undefined;
|
||||||
|
return typeof pages === "number" && pages > 0 && pages <= args.maxPages;
|
||||||
|
});
|
||||||
|
if (args.only) candidates = candidates.filter((r) => r.file_name === args.only);
|
||||||
|
|
||||||
|
// Resume-Skip VOR dem Limit: fehlerfrei mit echtem Vision-Ergebnis = fertig.
|
||||||
|
// Mit --reprocess-missing gelten Datensaetze OHNE Datum als unvollstaendig
|
||||||
|
// und werden erneut verarbeitet (fuer gezielte Nachlaeufe, ohne JSON-Editieren).
|
||||||
|
const isIncomplete = (r: Record<string, unknown>): boolean => {
|
||||||
|
if (!args.reprocessMissing) return false;
|
||||||
|
// buyer_sheet/ca_only ohne Datum gilt als unvollstaendig
|
||||||
|
const dt = r["_doc_type"];
|
||||||
|
if (dt === "other") return false;
|
||||||
|
const hasDate = r["date_of_introduction"] != null && r["date_of_introduction"] !== "";
|
||||||
|
return !hasDate;
|
||||||
|
};
|
||||||
|
const pending = candidates.filter((r) => {
|
||||||
|
const prev = done.get(r.file_name);
|
||||||
|
const hasResult = prev && prev["_parser"] === "vision" && !prev["_vision_error"];
|
||||||
|
if (hasResult && prev && isIncomplete(prev)) return true; // unvollstaendig -> neu
|
||||||
|
return !(hasResult && !args.force);
|
||||||
|
});
|
||||||
|
const skipped = candidates.length - pending.length;
|
||||||
|
const targets = args.limit > 0 ? pending.slice(0, args.limit) : pending;
|
||||||
|
|
||||||
|
const tooManyCount = [...done.values()].filter((r) => r["_skipped_too_many_pages"]).length;
|
||||||
|
console.error(
|
||||||
|
`Phase 2: ${candidates.length} Kandidaten (<=${args.maxPages} Seiten), ` +
|
||||||
|
`${tooManyCount} zu gross (uebersprungen), ${skipped} bereits verarbeitet, ` +
|
||||||
|
`${targets.length} in diesem Lauf.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = await fetchModelId(args.api);
|
||||||
|
console.error(`Modell: ${model} @ ${args.api}`);
|
||||||
|
|
||||||
|
let ok = 0, caOnly = 0, notSheet = 0, errors = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
const det = targets[i];
|
||||||
|
const tag = `[${i + 1}/${targets.length}] ${det.file_name}`;
|
||||||
|
|
||||||
|
const pdfPath = pdfIndex.get(det.file_name);
|
||||||
|
if (!pdfPath) {
|
||||||
|
done.set(det.file_name, { ...det, _vision_error: "PDF nicht gefunden", _vision_ts: new Date().toISOString() });
|
||||||
|
errors++;
|
||||||
|
progress(`${tag} … FEHLER: PDF nicht gefunden`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "bvs-"));
|
||||||
|
try {
|
||||||
|
const dpi = args.dpi === "auto"
|
||||||
|
? ((await hasTextLayer(pdfPath, args.maxPages)) ? 150 : 200)
|
||||||
|
: args.dpi;
|
||||||
|
progress(`${tag} … rendere (${dpi} dpi)`);
|
||||||
|
const images = await renderPdf(pdfPath, dpi, args.maxPages, tmpDir);
|
||||||
|
|
||||||
|
let vis: VisionRaw | null = null;
|
||||||
|
let lastErr = "";
|
||||||
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
progress(`${tag} … VLM (${images.length} Seiten, Versuch ${attempt})`);
|
||||||
|
vis = await callVision(args.api, images, args.timeoutMs);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e instanceof Error ? e.message : String(e);
|
||||||
|
if (attempt < RETRIES) {
|
||||||
|
if (/HTTP 50[23]|fetch failed|aborted|ECONNREFUSED|ECONNRESET/i.test(lastErr)) {
|
||||||
|
// Server crasht/lädt neu → auf /health warten (Modell-Reload dauert)
|
||||||
|
progress(`${tag} … Server neu am Laden, warte auf /health`);
|
||||||
|
await waitForHealthy(args.api, 300_000);
|
||||||
|
} else {
|
||||||
|
await new Promise((res) => setTimeout(res, 5000 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vis) {
|
||||||
|
done.set(det.file_name, { ...det, _vision_error: lastErr, _vision_ts: new Date().toISOString() });
|
||||||
|
errors++;
|
||||||
|
progress(`${tag} … FEHLER: ${lastErr}`);
|
||||||
|
} else {
|
||||||
|
const merged = mergeRecord(det, vis, model);
|
||||||
|
done.set(det.file_name, merged);
|
||||||
|
if (merged["_doc_type"] === "ca_only") { caOnly++; progress(`${tag} … CA only`); }
|
||||||
|
else if (merged.is_buyer_sheet) { ok++; progress(`${tag} … OK`); }
|
||||||
|
else { notSheet++; progress(`${tag} … kein Buyer Sheet`); }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
done.set(det.file_name, { ...det, _vision_error: msg, _vision_ts: new Date().toISOString() });
|
||||||
|
errors++;
|
||||||
|
progress(`${tag} … FEHLER: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inkrementell sichern (Resume-fähig)
|
||||||
|
await fsp.writeFile(visionPath, JSON.stringify([...done.values()], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(visionPath, JSON.stringify([...done.values()], null, 2));
|
||||||
|
|
||||||
|
process.stderr.write("\n");
|
||||||
|
console.error(
|
||||||
|
`Fertig. OK: ${ok}, CA only: ${caOnly}, kein Buyer Sheet: ${notSheet}, Fehler: ${errors}, übersprungen: ${skipped}`
|
||||||
|
);
|
||||||
|
console.error(`→ ${visionPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("\nAbbruch:", e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user