package storage import ( "regexp" "strconv" "strings" "time" ) // documentDateMinYear mirrors dateMinYear in internal/api/date_extraction.go. // The whole scoring logic below is duplicated (rather than shared) because the // storage package must not import internal/api (that would create an import // cycle: api already depends on storage). Kept in sync with the api heuristic — // the same duplication pattern as heuristicTitle vs. titleFromOCRText. const ( documentDateMinYear = 1990 documentDateFutureToleranceDays = 2 documentDateWindowRadius = 40 documentDateScoreNoContext = 0.4 ) // documentDateKeyword mirrors dateKeyword in internal/api/date_extraction.go. type documentDateKeyword struct { word string score float64 } var documentDateKeywords = []documentDateKeyword{ {"rechnungsdatum", 0.9}, {"belegdatum", 0.9}, {"ausstellungsdatum", 0.9}, {"rechnung vom", 0.9}, {"beleg vom", 0.9}, {"datum", 0.75}, {"vom", 0.55}, } // documentDateGermanMonths mirrors dateGermanMonths in the api package. var documentDateGermanMonths = map[string]int{ "januar": 1, "jan": 1, "februar": 2, "feb": 2, "märz": 3, "maerz": 3, "mär": 3, "mrz": 3, "april": 4, "apr": 4, "mai": 5, "juni": 6, "jun": 6, "juli": 7, "jul": 7, "august": 8, "aug": 8, "september": 9, "sep": 9, "sept": 9, "oktober": 10, "okt": 10, "november": 11, "nov": 11, "dezember": 12, "dez": 12, } // documentDateCandidateRe mirrors dateCandidateRe in // internal/api/date_extraction.go. Recognised: DD.MM.YYYY, DD.MM.YY, // DD/MM/YYYY, YYYY-MM-DD and spelled-out German month names ("15. März 2026"). var documentDateCandidateRe = regexp.MustCompile( `(?i)(?:\b(?P\d{1,2})\.(?P\d{1,2})\.(?P\d{4}|\d{2})\b)` + `|(?:\b(?P\d{1,2})/(?P\d{1,2})/(?P\d{4}|\d{2})\b)` + `|(?:\b(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})\b)` + `|(?:\b(?P\d{1,2})\.?\s+(?P[A-Za-zäöüÄÖÜ]+)\.?\s+(?P\d{4})\b)`, ) // documentDateScoreForPosition mirrors scoreForDatePosition in the api package. func documentDateScoreForPosition(lowerText string, start int) float64 { lo := start - documentDateWindowRadius if lo < 0 { lo = 0 } hi := start + documentDateWindowRadius if hi > len(lowerText) { hi = len(lowerText) } window := lowerText[lo:hi] for _, kw := range documentDateKeywords { if strings.Contains(window, kw.word) { return kw.score } } return documentDateScoreNoContext } // documentDateParseMatch mirrors parseDateMatch in the api package. func documentDateParseMatch(names, m []string) (time.Time, bool) { now := time.Now() maxDate := now.AddDate(0, 0, documentDateFutureToleranceDays) var day, month, year int var monthName string for i, name := range names { if m[i] == "" { continue } switch name { case "gd", "sd", "id", "td": day, _ = strconv.Atoi(m[i]) case "gm", "sm", "im": month, _ = strconv.Atoi(m[i]) case "gy", "sy": y, _ := strconv.Atoi(m[i]) if len(m[i]) == 2 { y += 2000 } year = y case "iy", "ty": year, _ = strconv.Atoi(m[i]) case "tmon": monthName = m[i] } } if monthName != "" { mn, ok := documentDateGermanMonths[strings.ToLower(monthName)] if !ok { return time.Time{}, false } month = mn } if year < documentDateMinYear { return time.Time{}, false } if month < 1 || month > 12 { return time.Time{}, false } if day < 1 || day > 31 { return time.Time{}, false } d := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) if d.Day() != day || int(d.Month()) != month || d.Year() != year { return time.Time{}, false } if d.After(maxDate) { return time.Time{}, false } return d, true } // documentDateFromTextWithScore mirrors extractDocumentDateWithScore in the api // package. Returns the best belegdatum candidate and its confidence. func documentDateFromTextWithScore(ocrText string) (best time.Time, score float64, found bool) { if ocrText == "" { return time.Time{}, 0, false } lower := strings.ToLower(ocrText) idxMatches := documentDateCandidateRe.FindAllStringSubmatchIndex(ocrText, -1) names := documentDateCandidateRe.SubexpNames() for _, loc := range idxMatches { m := make([]string, len(names)) for g := range names { s, e := loc[2*g], loc[2*g+1] if s >= 0 { m[g] = ocrText[s:e] } } d, ok := documentDateParseMatch(names, m) if !ok { continue } sc := documentDateScoreForPosition(lower, loc[0]) if !found || sc > score { best, score, found = d, sc, true } } return best, score, found }