ContactHeuristicParser.kt 285 lignes · 13099 octets
package fr.ebii.card2vcf.contact

import fr.ebii.card2vcf.ocr.BlockGrouper
import fr.ebii.card2vcf.ocr.OcrLine
import fr.ebii.card2vcf.ocr.OcrPostProcessor
import fr.ebii.card2vcf.ocr.OcrResult

/**
 * Structuration déterministe d'un texte OCR de carte de visite.
 * Source unique de structuration dans Card2vcf (pas de LLM).
 */
object ContactHeuristicParser {

    /** Confiance Tesseract (0-100) sous laquelle un champ est marqué « à vérifier ». */
    private const val SEUIL_CONFIANCE = 70f

    private val jobKeywords = Regex(
        """(?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 val addressKeywords = Regex(
        """(?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 val companyKeywords = Regex(
        """(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|""" +
            """group|groupe|technologies|tech|consulting)\b""",
    )
    private val skipLine = Regex(
        """(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|""" +
            """siège social|siege social|linkedin|twitter)\b""",
    )
    private val emailOrUrlOrPhone = Regex(
        """@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d""",
        RegexOption.IGNORE_CASE,
    )

    fun parse(ocr: OcrResult): ContactCard {
        if (ocr.spatialLines.isNotEmpty()) return parseSpatial(ocr)
        val lines = ocr.lines.ifEmpty { ocr.rawText.lines().map { it.trim() }.filter { it.isNotEmpty() } }
        val residual = lines.toMutableList()

        fun takeMatching(pred: (String) -> Boolean): String? {
            val idx = residual.indexOfFirst(pred)
            if (idx < 0) return null
            return residual.removeAt(idx)
        }

        val jobTitle = takeMatching { jobKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) }
        val address = residual
            .filter { addressKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) }
            .maxByOrNull { it.length }
            ?.also { residual.remove(it) }

        val personLine = residual
            .asSequence()
            .filter { looksLikePersonName(it) }
            .maxByOrNull { personScore(it) }
            ?.also { residual.remove(it) }

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

        val companyFromEmail = ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) }
        val companyLine = residual
            .asSequence()
            .filter { looksLikeCompany(it) }
            .maxByOrNull { companyScore(it) }
            ?.also { residual.remove(it) }
        val company = cleanCompany(companyLine) ?: companyFromEmail

        val website = ocr.urls.firstOrNull()
        val noteParts = residual.filter { line ->
            !skipLine.containsMatchIn(line)
                && !emailOrUrlOrPhone.containsMatchIn(line)
                && line.length > 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.take(3).joinToString(" · ").ifBlank { null },
        )
    }

    internal fun looksLikePersonName(line: String): Boolean {
        val t = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim()
        if (t.length !in 4..60) return false
        if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false
        if (addressKeywords.containsMatchIn(t)) return false
        if (emailOrUrlOrPhone.containsMatchIn(line)) return false
        val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() }
        if (parts.size !in 2..4) return false
        // Au moins un token capitalisé / majuscules
        val caps = parts.count { it.first().isUpperCase() }
        return caps >= 2
    }

    internal fun personScore(line: String): Int {
        val parts = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim().split(Regex("""\s+"""))
        var s = parts.size * 10
        if (parts.any { it.length >= 2 && it == it.uppercase() && it.any { c -> c.isLetter() } }) s += 40
        if (parts.size in 2..3) s += 15
        return s
    }

    internal fun splitPersonName(line: String?): Triple<String?, String?, String?> {
        if (line.isNullOrBlank()) return Triple(null, null, null)
        val cleaned = line
            .replace('!', 'l') // Pau! → Paul (erreur OCR fréquente)
            .replace(Regex("""[^\p{L}\s\-']"""), " ")
            .replace(Regex("""\s+"""), " ")
            .trim()
        val parts = cleaned.split(' ').filter { it.isNotBlank() }
        if (parts.isEmpty()) return Triple(null, null, null)
        val lastCaps = parts.indexOfLast { it.length >= 2 && it == it.uppercase() && it.all { c -> !c.isLetter() || c.isUpperCase() } }
        fun titleWord(w: String) = w.lowercase().replaceFirstChar { it.titlecase() }
        return if (lastCaps > 0) {
            val last = titleWord(parts[lastCaps])
            val first = parts.take(lastCaps).joinToString(" ") { titleWord(it) }
            // Conserve les prénoms composés sans forcer une casse bizarre si déjà mixtes ;
            // mais un token tout en capitales (« SONIA MARTIN ») est remis en Title Case.
            val firstKeep = parts.take(lastCaps).joinToString(" ") { w ->
                if (w.length >= 2 && w == w.uppercase()) titleWord(w) else w
            }
            Triple(firstKeep, last, "$firstKeep $last")
        } else if (parts.size >= 2) {
            val last = titleWord(parts.last())
            val first = parts.dropLast(1).joinToString(" ")
            Triple(first, last, "$first $last")
        } else {
            Triple(null, null, cleaned)
        }
    }

    private fun looksLikeCompany(line: String): Boolean {
        if (line.length !in 2..50) 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.uppercase()
            || line.split(Regex("""\s+""")).size <= 3
    }

    private fun companyScore(line: String): Int {
        var s = 0
        if (companyKeywords.containsMatchIn(line)) s += 50
        if (line == line.uppercase()) s += 20
        if (line.contains("software", ignoreCase = true)) s += 30
        return s + (40 - line.length).coerceAtLeast(0)
    }

    private fun cleanCompany(line: String?): String? {
        if (line.isNullOrBlank()) return null
        return line
            .replace(Regex("""[&—–\-_|]+"""), " ")
            .replace(Regex("""\s+"""), " ")
            .trim()
            .ifBlank { null }
    }

    private fun cleanJob(line: String?): String? =
        line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null }

    private fun cleanAddress(line: String?): String? =
        line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null }

    /**
     * Chemin spatial : exploite boîtes/hauteurs/blocs (ResultIterator) au lieu du
     * texte plat. Mêmes regex de classification que le chemin legacy, enrichies
     * des signaux de mise en page (taille de police, regroupement, libellés).
     */
    private fun parseSpatial(ocr: OcrResult): ContactCard {
        val blocks = BlockGrouper.group(ocr.spatialLines)
        // « Hauteur typique » = médiane basse : robuste aux logos géants et aux
        // cartes à 2 lignes où la médiane vraie serait tirée vers le haut.
        val typicalHeight = ocr.spatialLines.map { it.box.height }.sorted()
            .let { it[(it.size - 1) / 2] }

        fun isData(t: String) = emailOrUrlOrPhone.containsMatchIn(t)
        fun isLabel(t: String) = skipLine.containsMatchIn(t)

        // Libellé seul (Tél. : / Mobile…) → la valeur est sur la ligne suivante du bloc.
        val phones = ocr.phones.toMutableList()
        for (block in blocks) {
            block.lines.zipWithNext { label, value ->
                if (isLabel(label.text) && !isData(label.text)) {
                    OcrPostProcessor.process(value.text).phones.forEach { p ->
                        if (p !in phones) phones += p
                    }
                }
            }
        }

        // Adresse : le bloc le plus « adresse », jointure multi-lignes.
        val addressBlock = blocks
            .filter { b -> b.lines.any { addressKeywords.containsMatchIn(it.text) && !isData(it.text) } }
            .maxByOrNull { b -> b.lines.count { addressKeywords.containsMatchIn(it.text) && !isData(it.text) } }
        val address = addressBlock?.lines
            ?.filter { !isData(it.text) && !isLabel(it.text) }
            ?.joinToString(", ") { it.text.replace(Regex("""\s+"""), " ").trim() }
            ?.ifBlank { null }
        val addressLines = addressBlock?.lines?.toSet().orEmpty()

        val rest = ocr.spatialLines.filter { it !in addressLines && !isData(it.text) && !isLabel(it.text) }

        val jobLine = rest.firstOrNull { jobKeywords.containsMatchIn(it.text) }

        val emailDomain = ocr.emails.firstOrNull()
            ?.substringAfter('@', "")?.substringBefore('.')?.takeIf { it.length >= 3 }

        // NB : « tout en majuscules » n'est PAS un critère société — les noms de
        // personnes sont très souvent en capitales sur les cartes françaises.
        fun looksCompanySpatial(l: OcrLine) = companyKeywords.containsMatchIn(l.text)
            || (emailDomain != null && l.text.lowercase().replace(" ", "").contains(emailDomain))

        val nameLine = rest
            .filter { it !== jobLine && !looksCompanySpatial(it) && personLikeSpatial(it, typicalHeight) }
            .maxByOrNull { personScore(it.text) + it.box.height * 40 / typicalHeight }
        val (firstName, lastName, fullName) = splitPersonName(nameLine?.text)

        val companyLine = rest
            .filter { it !== jobLine && it !== nameLine && looksCompanySpatial(it) }
            .maxByOrNull { it.box.height * 1000 - it.box.top } // grand et en haut de carte
        val company = cleanCompany(companyLine?.text)
            ?: ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) }

        val noteParts = rest
            .filter { it !== jobLine && it !== nameLine && it !== companyLine && it.text.length > 2 }

        val douteux = buildSet {
            if (nameLine != null && nameLine.confidence < SEUIL_CONFIANCE) add("fullName")
            if (jobLine != null && jobLine.confidence < SEUIL_CONFIANCE) add("jobTitle")
            if (companyLine != null && companyLine.confidence < SEUIL_CONFIANCE) add("company")
            if (addressBlock != null && addressBlock.avgConfidence() < SEUIL_CONFIANCE) add("address")
        }

        return ContactCard(
            fullName = fullName,
            firstName = firstName,
            lastName = lastName,
            company = company,
            jobTitle = cleanJob(jobLine?.text),
            phones = phones,
            emails = ocr.emails,
            website = ocr.urls.firstOrNull(),
            address = address,
            note = noteParts.take(3).joinToString(" · ") { it.text }.ifBlank { null },
            champsDouteux = douteux,
        )
    }

    /** Comme [looksLikePersonName], avec dispense de majuscules pour les gros caractères. */
    private fun personLikeSpatial(line: OcrLine, typicalHeight: Int): Boolean {
        val t = line.text.replace(Regex("""[^\p{L}\s\-']"""), " ").trim()
        if (t.length !in 4..60) return false
        if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false
        if (addressKeywords.containsMatchIn(t)) return false
        val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() }
        if (parts.size !in 2..4) return false
        val caps = parts.count { it.first().isUpperCase() }
        return caps >= 2 || line.box.height >= 1.5 * typicalHeight
    }

    internal fun companyFromEmailDomain(email: String): String? {
        val domain = email.substringAfter('@', "").substringBefore('.')
        if (domain.length < 3) return null
        // dimosoftware → Dimo Software (best-effort)
        val spaced = domain
            .replace(Regex("""(?<=[a-z])(?=[A-Z])"""), " ")
            .replace("software", " Software", ignoreCase = true)
            .replace("solutions", " Solutions", ignoreCase = true)
            .trim()
        return spaced.replaceFirstChar { it.titlecase() }.ifBlank { null }
    }
}