Files
archivdms/internal/storage/document_date.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

164 lines
4.4 KiB
Go

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<gd>\d{1,2})\.(?P<gm>\d{1,2})\.(?P<gy>\d{4}|\d{2})\b)` +
`|(?:\b(?P<sd>\d{1,2})/(?P<sm>\d{1,2})/(?P<sy>\d{4}|\d{2})\b)` +
`|(?:\b(?P<iy>\d{4})-(?P<im>\d{1,2})-(?P<id>\d{1,2})\b)` +
`|(?:\b(?P<td>\d{1,2})\.?\s+(?P<tmon>[A-Za-zäöüÄÖÜ]+)\.?\s+(?P<ty>\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
}