ContactHeuristicParser.swift 345 lignes · 15797 octets
import Foundation

/// Deterministic structuring of business-card OCR text.
/// Single structuring source in Card2vcf (no LLM).
enum ContactHeuristicParser {

    /// OCR confidence (0-100) under which a field is flagged « à vérifier ».
    private static let seuilConfiance: Float = 70

    private static let jobKeywords = KotlinRegex(
        #"(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|"# +
            #"manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|"# +
            #"associé|associe|fondateur|founder|head of|chef de|avocat|docteur|dr\.?)\b"#
    )
    private static let addressKeywords = KotlinRegex(
        #"(?i)\b(rue|avenue|av\.?|bd\.?|boulevard|all[ée]e|chemin|place|impasse|"# +
            #"cedex|cs\b|bp\b|boîte postale|france|siège|siege)\b|\b\d{5}\b"#
    )
    private static let companyKeywords = KotlinRegex(
        #"(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|"# +
            #"group|groupe|technologies|tech|consulting)\b"#
    )
    private static let skipLine = KotlinRegex(
        #"(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|"# +
            #"siège social|siege social|linkedin|twitter)\b"#
    )
    private static let emailOrUrlOrPhone = KotlinRegex(
        #"@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d"#,
        options: [.caseInsensitive]
    )
    private static let nonNameChars = KotlinRegex(#"[^\p{L}\s\-']"#)
    private static let whitespaceRun = KotlinRegex(#"\s+"#)
    private static let companySeparators = KotlinRegex(#"[&—–\-_|]+"#)
    private static let camelBoundary = KotlinRegex(#"(?<=[a-z])(?=[A-Z])"#)

    static func parse(_ ocr: OcrResult) -> ContactCard {
        if !ocr.spatialLines.isEmpty { return parseSpatial(ocr) }
        let lines: [String]
        if ocr.lines.isEmpty {
            lines = ocr.rawText.kotlinLines()
                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
                .filter { !$0.isEmpty }
        } else {
            lines = ocr.lines
        }
        var residual = lines

        func takeMatching(_ pred: (String) -> Bool) -> String? {
            guard let idx = residual.firstIndex(where: pred) else { return nil }
            return residual.remove(at: idx)
        }
        func removeFirst(_ element: String) {
            if let idx = residual.firstIndex(of: element) { residual.remove(at: idx) }
        }
        // Kotlin maxByOrNull: first element with the maximum selector value.
        func firstMax(_ candidates: [String], by score: (String) -> Int) -> String? {
            var best: String? = nil
            var bestScore = Int.min
            for c in candidates {
                let s = score(c)
                if s > bestScore {
                    bestScore = s
                    best = c
                }
            }
            return best
        }

        let jobTitle = takeMatching {
            jobKeywords.containsMatchIn($0) && !emailOrUrlOrPhone.containsMatchIn($0)
        }

        let addressCandidates = residual.filter {
            addressKeywords.containsMatchIn($0) && !emailOrUrlOrPhone.containsMatchIn($0)
        }
        let address = firstMax(addressCandidates) { $0.count }
        if let address { removeFirst(address) }

        let personLine = firstMax(residual.filter { looksLikePersonName($0) }) { personScore($0) }
        if let personLine { removeFirst(personLine) }

        let (firstName, lastName, fullName) = splitPersonName(personLine)

        let companyFromEmail = ocr.emails.first.flatMap { companyFromEmailDomain($0) }
        let companyLine = firstMax(residual.filter { looksLikeCompany($0) }) { companyScore($0) }
        if let companyLine { removeFirst(companyLine) }
        let company = cleanCompany(companyLine) ?? companyFromEmail

        let website = ocr.urls.first
        let noteParts = residual.filter { line in
            !skipLine.containsMatchIn(line)
                && !emailOrUrlOrPhone.containsMatchIn(line)
                && line.count > 2
        }

        return ContactCard(
            fullName: fullName,
            firstName: firstName,
            lastName: lastName,
            company: company,
            jobTitle: cleanJob(jobTitle),
            phones: ocr.phones,
            emails: ocr.emails,
            website: website,
            address: cleanAddress(address),
            note: noteParts.prefix(3).joined(separator: " · ").nilIfBlank
        )
    }

    static func looksLikePersonName(_ line: String) -> Bool {
        let t = nonNameChars.replace(line, with: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        guard (4...60).contains(t.count) else { return false }
        if jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t) { return false }
        if addressKeywords.containsMatchIn(t) { return false }
        if emailOrUrlOrPhone.containsMatchIn(line) { return false }
        let parts = whitespaceRun.split(t).filter { $0.isNotBlank }
        guard (2...4).contains(parts.count) else { return false }
        // At least two capitalized / uppercase tokens.
        let caps = parts.filter { $0.first?.isUppercase == true }.count
        return caps >= 2
    }

    static func personScore(_ line: String) -> Int {
        let parts = whitespaceRun.split(
            nonNameChars.replace(line, with: " ").trimmingCharacters(in: .whitespacesAndNewlines)
        )
        var s = parts.count * 10
        if parts.contains(where: { $0.count >= 2 && $0 == $0.uppercased() && $0.contains(where: { $0.isLetter }) }) {
            s += 40
        }
        if (2...3).contains(parts.count) { s += 15 }
        return s
    }

    static func splitPersonName(_ line: String?) -> (firstName: String?, lastName: String?, fullName: String?) {
        guard let line, line.isNotBlank else { return (nil, nil, nil) }
        let cleaned = whitespaceRun.replace(
            nonNameChars.replace(line.replacingOccurrences(of: "!", with: "l"), with: " "), // Pau! → Paul (frequent OCR error)
            with: " "
        ).trimmingCharacters(in: .whitespacesAndNewlines)
        let parts = cleaned.components(separatedBy: " ").filter { $0.isNotBlank }
        if parts.isEmpty { return (nil, nil, nil) }
        let lastCaps = parts.lastIndex {
            $0.count >= 2 && $0 == $0.uppercased() && $0.allSatisfy { c in !c.isLetter || c.isUppercase }
        }
        func titleWord(_ w: String) -> String {
            let lower = w.lowercased()
            guard let f = lower.first else { return lower }
            return String(f).uppercased() + lower.dropFirst()
        }
        if let lastCaps, lastCaps > 0 {
            let last = titleWord(parts[lastCaps])
            // Keep compound first names without forcing odd casing when already mixed;
            // but an all-caps token (« SONIA MARTIN ») goes back to Title Case.
            let firstKeep = parts[0..<lastCaps]
                .map { w in (w.count >= 2 && w == w.uppercased()) ? titleWord(w) : w }
                .joined(separator: " ")
            return (firstKeep, last, "\(firstKeep) \(last)")
        } else if parts.count >= 2 {
            let last = titleWord(parts[parts.count - 1])
            let first = parts.dropLast().joined(separator: " ")
            return (first, last, "\(first) \(last)")
        } else {
            return (nil, nil, cleaned)
        }
    }

    /// Spatial path: exploits boxes/heights/blocks instead of flat text. Same
    /// classification regexes as the legacy path, enriched with layout signals
    /// (font size, grouping, labels).
    private static func parseSpatial(_ ocr: OcrResult) -> ContactCard {
        let blocks = BlockGrouper.group(ocr.spatialLines)
        // « Typical height » = low median: robust to giant logos and 2-line
        // cards where the true median would be pulled upward.
        let heights = ocr.spatialLines.map(\.box.height).sorted()
        let typicalHeight = heights[(heights.count - 1) / 2]

        func isData(_ t: String) -> Bool { emailOrUrlOrPhone.containsMatchIn(t) }
        func isLabel(_ t: String) -> Bool { skipLine.containsMatchIn(t) }
        // Kotlin maxByOrNull: first element with the maximum selector value.
        func firstMax<T>(_ candidates: [T], by score: (T) -> Int) -> T? {
            var best: T? = nil
            var bestScore = Int.min
            for c in candidates {
                let s = score(c)
                if s > bestScore {
                    bestScore = s
                    best = c
                }
            }
            return best
        }

        // Lone label (Tél. : / Mobile…) → the value sits on the block's next line.
        var phones = ocr.phones
        for block in blocks {
            for (label, value) in zip(block.lines, block.lines.dropFirst()) {
                if isLabel(label.text) && !isData(label.text) {
                    for p in OcrPostProcessor.process(value.text).phones where !phones.contains(p) {
                        phones.append(p)
                    }
                }
            }
        }

        // Address: the most « address-like » block, multi-line join.
        func addressLineCount(_ b: OcrBlock) -> Int {
            b.lines.filter { addressKeywords.containsMatchIn($0.text) && !isData($0.text) }.count
        }
        let addressBlock = firstMax(blocks.filter { addressLineCount($0) > 0 }, by: addressLineCount)
        let address = addressBlock?.lines
            .filter { !isData($0.text) && !isLabel($0.text) }
            .map { whitespaceRun.replace($0.text, with: " ").trimmingCharacters(in: .whitespacesAndNewlines) }
            .joined(separator: ", ")
            .nilIfBlank
        let addressLines = addressBlock?.lines ?? []

        let rest = ocr.spatialLines.filter {
            !addressLines.contains($0) && !isData($0.text) && !isLabel($0.text)
        }

        let jobLine = rest.first { jobKeywords.containsMatchIn($0.text) }

        let emailDomain: String? = ocr.emails.first.flatMap { email in
            guard let atIdx = email.firstIndex(of: "@") else { return nil }
            let domain = String(email[email.index(after: atIdx)...].prefix(while: { $0 != "." }))
            return domain.count >= 3 ? domain : nil
        }

        // NB: « all caps » is NOT a company signal — person names are very often
        // capitalized on French business cards.
        func looksCompanySpatial(_ l: OcrLine) -> Bool {
            companyKeywords.containsMatchIn(l.text)
                || (emailDomain != nil
                    && l.text.lowercased().replacingOccurrences(of: " ", with: "").contains(emailDomain!))
        }

        let nameLine = firstMax(
            rest.filter { $0 != jobLine && !looksCompanySpatial($0) && personLikeSpatial($0, typicalHeight: typicalHeight) }
        ) { personScore($0.text) + $0.box.height * 40 / typicalHeight }
        let (firstName, lastName, fullName) = splitPersonName(nameLine?.text)

        let companyLine = firstMax(
            rest.filter { $0 != jobLine && $0 != nameLine && looksCompanySpatial($0) }
        ) { $0.box.height * 1000 - $0.box.top } // large and near the top of the card
        let company = cleanCompany(companyLine?.text)
            ?? ocr.emails.first.flatMap { companyFromEmailDomain($0) }

        let noteParts = rest.filter {
            $0 != jobLine && $0 != nameLine && $0 != companyLine && $0.text.count > 2
        }

        var douteux: Set<String> = []
        if let nameLine, nameLine.confidence < seuilConfiance { douteux.insert("fullName") }
        if let jobLine, jobLine.confidence < seuilConfiance { douteux.insert("jobTitle") }
        if let companyLine, companyLine.confidence < seuilConfiance { douteux.insert("company") }
        if let addressBlock, addressBlock.avgConfidence() < seuilConfiance { douteux.insert("address") }

        return ContactCard(
            fullName: fullName,
            firstName: firstName,
            lastName: lastName,
            company: company,
            jobTitle: cleanJob(jobLine?.text),
            phones: phones,
            emails: ocr.emails,
            website: ocr.urls.first,
            address: address,
            note: noteParts.prefix(3).map(\.text).joined(separator: " · ").nilIfBlank,
            champsDouteux: douteux
        )
    }

    /// Like `looksLikePersonName`, with a capitals waiver for oversized characters.
    private static func personLikeSpatial(_ line: OcrLine, typicalHeight: Int) -> Bool {
        let t = nonNameChars.replace(line.text, with: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        guard (4...60).contains(t.count) else { return false }
        if jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t) { return false }
        if addressKeywords.containsMatchIn(t) { return false }
        let parts = whitespaceRun.split(t).filter { $0.isNotBlank }
        guard (2...4).contains(parts.count) else { return false }
        let caps = parts.filter { $0.first?.isUppercase == true }.count
        return caps >= 2 || Double(line.box.height) >= 1.5 * Double(typicalHeight)
    }

    private static func looksLikeCompany(_ line: String) -> Bool {
        guard (2...50).contains(line.count) else { return false }
        if emailOrUrlOrPhone.containsMatchIn(line) { return false }
        if jobKeywords.containsMatchIn(line) { return false }
        if addressKeywords.containsMatchIn(line) { return false }
        if looksLikePersonName(line) { return false }
        return companyKeywords.containsMatchIn(line)
            || line == line.uppercased()
            || whitespaceRun.split(line).count <= 3
    }

    private static func companyScore(_ line: String) -> Int {
        var s = 0
        if companyKeywords.containsMatchIn(line) { s += 50 }
        if line == line.uppercased() { s += 20 }
        if line.range(of: "software", options: .caseInsensitive) != nil { s += 30 }
        return s + max(40 - line.count, 0)
    }

    private static func cleanCompany(_ line: String?) -> String? {
        guard let line, line.isNotBlank else { return nil }
        return whitespaceRun.replace(companySeparators.replace(line, with: " "), with: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .nilIfBlank
    }

    private static func cleanJob(_ line: String?) -> String? {
        guard let line else { return nil }
        return whitespaceRun.replace(line, with: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .nilIfBlank
    }

    private static func cleanAddress(_ line: String?) -> String? {
        guard let line else { return nil }
        return whitespaceRun.replace(line, with: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .nilIfBlank
    }

    static func companyFromEmailDomain(_ email: String) -> String? {
        let afterAt: Substring
        if let atIdx = email.firstIndex(of: "@") {
            afterAt = email[email.index(after: atIdx)...]
        } else {
            afterAt = ""
        }
        let domain = String(afterAt.prefix(while: { $0 != "." }))
        if domain.count < 3 { return nil }
        // dimosoftware → Dimo Software (best-effort)
        let spaced = camelBoundary.replace(domain, with: " ")
            .replacingOccurrences(of: "software", with: " Software", options: .caseInsensitive)
            .replacingOccurrences(of: "solutions", with: " Solutions", options: .caseInsensitive)
            .trimmingCharacters(in: .whitespacesAndNewlines)
        guard let f = spaced.first else { return nil }
        return (String(f).uppercased() + spaced.dropFirst()).nilIfBlank
    }
}