Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
164 lines
4.4 KiB
Go
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
|
|
}
|