Files
archivdms/internal/storage/ocr_words.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

161 lines
6.4 KiB
Go

package storage
import (
"context"
"fmt"
)
// OCRWord is one word-level bounding box persisted for a document, mirroring
// internal/ocr.WordBox (Phase 1 of the OCR text-highlight/overlay feature —
// see project memory project_ocr_textmarkierung_overlay.md). Coordinates are
// already in the original, undoctored file's coordinate space (see
// internal/ocr/coords.go package doc comment), not tesseract's
// post-preprocessing space.
type OCRWord struct {
ID int64
DocumentID int64
Page int
Block int
Par int
Line int
Word string
Left int
Top int
Width int
Height int
Confidence float64
}
// initOCRWordsSchema creates the ocr_words table (see
// migrations/024_ocr_words.sql). Wired into Store.initSchema after documents
// exists (FK ON DELETE CASCADE — word boxes have no independent GoBD
// retention meaning of their own; they are a derived index over a document's
// ocr_text/original file, not a document themselves, so cascading their
// deletion when the parent document row is hard-deleted from the trash
// workflow is the correct WORM behaviour here, unlike documents.storage_path
// or content_hash which must never be touched).
//
// No tenant_id column: access is always mediated through document_id, and
// every caller (bulk insert, delete-before-reprocess, and the future read
// endpoint in Phase 3) must join/verify against documents(tenant_id) rather
// than filter ocr_words directly, so there is no tenant-scan risk from
// omitting it — but see the README entry for this migration if that
// assumption ever changes.
func (s *Store) initOCRWordsSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS ocr_words (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page INTEGER NOT NULL DEFAULT 1,
block INTEGER NOT NULL DEFAULT 0,
par INTEGER NOT NULL DEFAULT 0,
line INTEGER NOT NULL DEFAULT 0,
word_text TEXT NOT NULL,
"left" INTEGER NOT NULL,
top INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
confidence DOUBLE PRECISION NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ocr_words_document ON ocr_words (document_id);
-- Plain btree on word_text: cheap prefilter for an eventual highlight
-- feature doing exact/prefix matching against search terms already
-- tokenized elsewhere. Deliberately NOT a pg_trgm GIN index — pg_trgm
-- requires "CREATE EXTENSION pg_trgm", which needs DB-superuser
-- privileges initSchema cannot assume it has on 192.168.1.204 (see
-- other Store init* methods: none of them create extensions). If
-- fuzzy/substring prefiltering turns out to be needed for the Phase 3
-- read endpoint, add the extension out-of-band on the server first,
-- then switch this index.
CREATE INDEX IF NOT EXISTS idx_ocr_words_word_text ON ocr_words (word_text);
`)
if err != nil {
return fmt.Errorf("storage: create ocr_words table: %w", err)
}
return nil
}
// ReplaceOCRWords atomically deletes any previously stored word boxes for a
// document and bulk-inserts the new set, so reprocess/re-OCR runs never pile
// up duplicate word rows alongside stale ones. A nil/empty words slice is
// valid (e.g. OCR found no words, or Result.Words extraction failed
// best-effort) and simply leaves the document with zero rows.
func (s *Store) ReplaceOCRWords(ctx context.Context, documentID int64, words []OCRWord) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("storage: begin replace ocr_words: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM ocr_words WHERE document_id = $1`, documentID); err != nil {
return fmt.Errorf("storage: delete ocr_words: %w", err)
}
for _, w := range words {
_, err := tx.Exec(ctx, `
INSERT INTO ocr_words
(document_id, page, block, par, line, word_text, "left", top, width, height, confidence)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, documentID, w.Page, w.Block, w.Par, w.Line, w.Word, w.Left, w.Top, w.Width, w.Height, w.Confidence)
if err != nil {
return fmt.Errorf("storage: insert ocr_word: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("storage: commit replace ocr_words: %w", err)
}
return nil
}
// ListOCRWords returns all persisted word boxes of a document in natural
// reading order (page, block, par, line, then insertion order via id), which is
// the order the overlay renderer needs to group words back into lines.
//
// NOTE (tenant isolation): ocr_words has no tenant_id column by design (see
// initOCRWordsSchema), so this function is deliberately NOT tenant-scoped. Every
// caller MUST have verified document ownership beforehand — the API handler
// does so via GetDocument(id, tenantID), exactly like the audit/notes/file
// sub-routes.
//
// Always returns a non-nil slice so JSON encoding yields [] rather than null.
func (s *Store) ListOCRWords(ctx context.Context, documentID int64) ([]OCRWord, error) {
rows, err := s.db.Query(ctx, `
SELECT id, document_id, page, block, par, line, word_text, "left", top, width, height, confidence
FROM ocr_words
WHERE document_id = $1
ORDER BY page, block, par, line, id
`, documentID)
if err != nil {
return nil, fmt.Errorf("storage: list ocr_words: %w", err)
}
defer rows.Close()
out := make([]OCRWord, 0)
for rows.Next() {
var w OCRWord
if err := rows.Scan(&w.ID, &w.DocumentID, &w.Page, &w.Block, &w.Par, &w.Line, &w.Word,
&w.Left, &w.Top, &w.Width, &w.Height, &w.Confidence); err != nil {
return nil, fmt.Errorf("storage: scan ocr_word: %w", err)
}
out = append(out, w)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("storage: iterate ocr_words: %w", err)
}
return out, nil
}
// DeleteOCRWords removes all stored word boxes for a document. Exposed
// separately from ReplaceOCRWords for callers (e.g. a future hard-delete
// path) that need to clear word boxes without immediately re-inserting new
// ones; ON DELETE CASCADE already handles the case where the documents row
// itself is removed, so this is only needed when the document row survives
// but its word boxes must be cleared independently.
func (s *Store) DeleteOCRWords(ctx context.Context, documentID int64) error {
if _, err := s.db.Exec(ctx, `DELETE FROM ocr_words WHERE document_id = $1`, documentID); err != nil {
return fmt.Errorf("storage: delete ocr_words: %w", err)
}
return nil
}