package pipeline import ( "regexp" "sort" "strings" "unicode" ) // FeatureCount debe coincidir con models v1.1.0 (services-count-facturas-medicas-ml). const FeatureCount = 14 var ( codePattern = regexp.MustCompile(`^\d{4,6}$`) serviceCodePattern = regexp.MustCompile(`(?i)^(\d{4,8}|\d+-\d+|[A-Z]{2,}\d{2,})$`) currencyPattern = regexp.MustCompile(`^\d{1,3}(,\d{3})+(\.\d+)?$`) headerKeywordPattern = regexp.MustCompile(`(?i)\b(FECHA|VALOR|SUBTOTAL|CUFE|RESOLUCION|CONTRATO|FACTURA|NIT|TELEFONO|TOTAL)\b`) ) type rowFeatures struct { Features [FeatureCount]float32 TextoFila string Cluster int yNorm float32 // índices usados en el paso de continuación hasServiceTablePattern int currencyCount int } func isServiceCode(token string) bool { return serviceCodePattern.MatchString(token) } func hasHeaderKeyword(text string) bool { return headerKeywordPattern.MatchString(text) } func firstTokenOf(text string) string { parts := strings.Fields(text) if len(parts) == 0 { return "" } return parts[0] } func extractRowFeatures(group []indexedBox, pageW, pageH int) rowFeatures { sort.Slice(group, func(i, j int) bool { return group[i].X < group[j].X }) texts := make([]string, len(group)) for i, b := range group { texts[i] = b.Text } textoFila := strings.Join(texts, " ") firstToken := "" if len(texts) > 0 { firstToken = texts[0] } currencyCount := 0 integerCount := 0 for _, t := range texts { if currencyPattern.MatchString(t) { currencyCount++ } if isDigits(t) { integerCount++ } } xMin, xMax := group[0].X, group[0].X ySum := 0 for _, b := range group { if b.X < xMin { xMin = b.X } if b.X > xMax { xMax = b.X } ySum += b.Y } xSpanNorm := float32(0) if pageW > 0 { xSpanNorm = float32(xMax-xMin) / float32(pageW) } yNorm := float32(0) if pageH > 0 { yNorm = float32(ySum/len(group)) / float32(pageH) } firstTokenXNorm := float32(0) if pageW > 0 { firstTokenXNorm = float32(xMin) / float32(pageW) } tailStart := 0 if len(texts) >= 3 { tailStart = len(texts) - 3 } currencyAtRowEnd := 0 for _, t := range texts[tailStart:] { if currencyPattern.MatchString(t) { currencyAtRowEnd = 1 break } } totalChars := len(textoFila) if totalChars == 0 { totalChars = 1 } special := 0 digits := 0 for _, c := range textoFila { if !unicode.IsLetter(c) && !unicode.IsNumber(c) && !unicode.IsSpace(c) { special++ } if unicode.IsDigit(c) { digits++ } } firstIsCode := 0 if codePattern.MatchString(firstToken) { firstIsCode = 1 } hasTablePattern := 0 if currencyCount >= 2 && integerCount >= 1 { hasTablePattern = 1 } headerKW := 0 if hasHeaderKeyword(textoFila) { headerKW = 1 } return rowFeatures{ Features: [FeatureCount]float32{ float32(firstIsCode), boolToFloat(isServiceCode(firstToken)), float32(hasTablePattern), float32(headerKW), float32(currencyAtRowEnd), firstTokenXNorm, 0, // is_likely_continuation — segundo paso float32(currencyCount), float32(integerCount), xSpanNorm, yNorm, float32(len(texts)), float32(special) / float32(totalChars), float32(digits) / float32(totalChars), }, TextoFila: textoFila, Cluster: group[0].Cluster, yNorm: yNorm, hasServiceTablePattern: hasTablePattern, currencyCount: currencyCount, } } func boolToFloat(v bool) float32 { if v { return 1 } return 0 } func buildFeatureRows(boxes []indexedBox, pageW, pageH int) []rowFeatures { groups := groupByCluster(boxes) rows := make([]rowFeatures, 0, len(groups)) for _, group := range groups { rows = append(rows, extractRowFeatures(group, pageW, pageH)) } if len(rows) == 0 { return rows } sort.Slice(rows, func(i, j int) bool { return rows[i].yNorm < rows[j].yNorm }) for i := 1; i < len(rows); i++ { prev := rows[i-1] cur := &rows[i] firstToken := firstTokenOf(cur.TextoFila) if prev.hasServiceTablePattern == 1 && cur.currencyCount == 0 && !isServiceCode(firstToken) && !codePattern.MatchString(firstToken) { cur.Features[6] = 1 } } return rows } func isDigits(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true }