Files
IPSExtractor/services/services-count-facturas-medicas-serving/internal/pipeline/features.go
T

205 lines
4.2 KiB
Go

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
}