feat: Add .env.example configuration file and integrate Redis service into Docker Compose for improved task management. Update desktop application to support document indexing and extraction workflows, enhancing user experience with a new extraction wizard and stepper components. Revise README and documentation for clarity on setup and usage.

This commit is contained in:
erik
2026-07-05 22:39:07 -05:00
parent 9aca926988
commit d95f8f6ad8
70 changed files with 9807 additions and 499 deletions
@@ -0,0 +1,53 @@
package api
import (
"context"
"github.com/pys/pdf-count/internal/pipeline"
"github.com/pys/pdf-count/internal/store"
)
// FilePayload holds a PDF ready for inline job processing.
type FilePayload struct {
Filename string
Data []byte
}
// JobRunner processes uploaded job files in-process using the API OCR pool.
type JobRunner struct {
store *store.Store
newProc func(includeRejected bool) *pipeline.Processor
sem chan struct{}
}
func NewJobRunner(st *store.Store, newProc func(includeRejected bool) *pipeline.Processor, concurrency int) *JobRunner {
if concurrency < 1 {
concurrency = 1
}
return &JobRunner{
store: st,
newProc: newProc,
sem: make(chan struct{}, concurrency),
}
}
// ProcessOne schedules a single PDF for inline OCR (non-blocking).
func (jr *JobRunner) ProcessOne(jobID string, file FilePayload, includeRejected bool) {
go func() {
jr.sem <- struct{}{}
defer func() { <-jr.sem }()
ctx := context.Background()
proc := jr.newProc(includeRejected)
result := proc.ProcessPDF(file.Filename, file.Data)
_ = jr.store.SaveResult(ctx, jobID, result)
_ = jr.store.TryCompleteJob(ctx, jobID)
}()
}
// ProcessFiles runs OCR in background goroutines and updates job progress in Redis.
func (jr *JobRunner) ProcessFiles(jobID string, files []FilePayload, includeRejected bool) {
for _, file := range files {
jr.ProcessOne(jobID, file, includeRejected)
}
}
@@ -0,0 +1,591 @@
package api
import (
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/pys/pdf-count/internal/config"
"github.com/pys/pdf-count/internal/domain"
"github.com/pys/pdf-count/internal/model"
"github.com/pys/pdf-count/internal/ocr"
"github.com/pys/pdf-count/internal/pipeline"
"github.com/pys/pdf-count/internal/queue"
"github.com/pys/pdf-count/internal/storage"
"github.com/pys/pdf-count/internal/store"
"github.com/pys/pdf-count/web"
"gopkg.in/yaml.v3"
)
type Server struct {
cfg config.Config
predictor *model.Predictor
ocrEngine *ocr.Engine
store *store.Store
storage *storage.Client
publisher *queue.Publisher
jobRunner *JobRunner
openAPI []byte
ready bool
readyMu sync.RWMutex
}
func NewServer(cfg config.Config, predictor *model.Predictor, ocrEngine *ocr.Engine, st *store.Store, stg *storage.Client, pub *queue.Publisher, openAPI []byte) *Server {
srv := &Server{
cfg: cfg,
predictor: predictor,
ocrEngine: ocrEngine,
store: st,
storage: stg,
publisher: pub,
openAPI: openAPI,
ready: true,
}
srv.jobRunner = NewJobRunner(st, srv.newProcessor, srv.ocrConcurrency())
return srv
}
func (s *Server) processConcurrency() int {
c := s.cfg.SyncProcessConcurrency
if c <= 0 {
c = s.cfg.OCRPoolSize
}
if c <= 0 {
c = 4
}
return c
}
func (s *Server) ocrConcurrency() int {
c := s.processConcurrency()
pool := s.cfg.OCRPoolSize
if pool <= 0 {
pool = 4
}
if c > pool {
c = pool
}
return c
}
func (s *Server) SetReady(v bool) {
s.readyMu.Lock()
s.ready = v
s.readyMu.Unlock()
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Get("/health", s.health)
r.Get("/ready", s.readyHandler)
r.Get("/openapi.json", s.openAPIHandler)
r.Get("/demo", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/demo/", http.StatusMovedPermanently)
})
r.Handle("/demo/*", http.StripPrefix("/demo/", http.FileServer(http.FS(web.FS))))
r.Route("/v1", func(r chi.Router) {
r.Post("/count", s.countSync)
r.Post("/jobs", s.createJob)
r.Post("/jobs/{jobID}/upload", s.uploadChunk)
r.Get("/jobs/{jobID}", s.getJob)
r.Get("/jobs/{jobID}/results", s.getJobResults)
r.Delete("/jobs/{jobID}", s.cancelJob)
})
return r
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
s.readyMu.RLock()
ok := s.ready
s.readyMu.RUnlock()
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (s *Server) openAPIHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(s.openAPI)
}
func (s *Server) countSync(w http.ResponseWriter, r *http.Request) {
includeRejected := r.URL.Query().Get("include_rejected") == "true"
maxBytes := int64(s.cfg.MaxPDFSizeMB) * 1024 * 1024
if err := r.ParseMultipartForm(maxBytes * int64(s.cfg.SyncMaxFiles)); err != nil {
httpError(w, http.StatusBadRequest, "invalid multipart form")
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
httpError(w, http.StatusBadRequest, "field 'files' required")
return
}
if len(files) > s.cfg.SyncMaxFiles {
httpError(w, http.StatusBadRequest, "too many files")
return
}
proc := s.newProcessor(includeRejected)
type fileInput struct {
filename string
data []byte
errRes *domain.PDFResult
}
inputs := make([]fileInput, 0, len(files))
for _, fh := range files {
f, err := fh.Open()
if err != nil {
errRes := domain.PDFResult{
Filename: filepath.Base(fh.Filename),
Status: domain.StatusError,
Error: err.Error(),
}
inputs = append(inputs, fileInput{errRes: &errRes})
continue
}
data, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
_ = f.Close()
if err != nil {
errRes := domain.PDFResult{
Filename: filepath.Base(fh.Filename),
Status: domain.StatusError,
Error: err.Error(),
}
inputs = append(inputs, fileInput{errRes: &errRes})
continue
}
if int64(len(data)) > maxBytes {
errRes := domain.PDFResult{
Filename: filepath.Base(fh.Filename),
Status: domain.StatusError,
Error: "file too large",
}
inputs = append(inputs, fileInput{errRes: &errRes})
continue
}
inputs = append(inputs, fileInput{
filename: filepath.Base(fh.Filename),
data: data,
})
}
results := make([]domain.PDFResult, len(inputs))
failed := 0
sem := make(chan struct{}, s.ocrConcurrency())
var wg sync.WaitGroup
var mu sync.Mutex
for i, in := range inputs {
if in.errRes != nil {
results[i] = *in.errRes
failed++
continue
}
wg.Add(1)
go func(idx int, in fileInput) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
res := proc.ProcessPDF(in.filename, in.data)
mu.Lock()
results[idx] = res
if res.Status == domain.StatusError {
failed++
}
mu.Unlock()
}(i, in)
}
wg.Wait()
writeJSON(w, http.StatusOK, domain.CountResponse{
Results: results,
Processed: len(results) - failed,
Failed: failed,
})
}
type createJobRequest struct {
Files []string `json:"files"`
Prefix string `json:"prefix"`
IncludeRejected bool `json:"include_rejected"`
CallbackURL string `json:"callback_url"`
}
func (s *Server) createJob(w http.ResponseWriter, r *http.Request) {
var req createJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid json body")
return
}
ctx := r.Context()
pending, err := s.store.PendingCount(ctx)
if err == nil && int(pending) > s.cfg.QueueMaxPending {
httpError(w, http.StatusTooManyRequests, "queue full")
return
}
jobID := uuid.NewString()
var objectKeys []string
var filenames []string
if req.Prefix != "" {
prefix := strings.TrimSuffix(req.Prefix, "/")
keys, err := s.storage.ListByPrefix(ctx, prefix)
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
objectKeys = keys
for _, k := range keys {
filenames = append(filenames, filepath.Base(k))
}
} else if len(req.Files) > 0 {
for _, f := range req.Files {
name := filepath.Base(f)
objectKeys = append(objectKeys, storage.JobObjectKey(jobID, name))
filenames = append(filenames, name)
}
} else {
// Empty job shell for chunked upload flow.
if err := s.store.CreateJob(ctx, jobID, 0, req.IncludeRejected); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusAccepted, domain.JobStatus{
JobID: jobID,
Status: domain.JobQueued,
Total: 0,
})
return
}
if len(objectKeys) > s.cfg.JobMaxFiles {
httpError(w, http.StatusBadRequest, "too many files in job")
return
}
if err := s.store.CreateJob(ctx, jobID, len(objectKeys), req.IncludeRejected); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.store.AddJobFiles(ctx, jobID, filenames); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.store.MarkRunning(ctx, jobID)
for i, key := range objectKeys {
msg := domain.QueueMessage{
JobID: jobID,
ObjectKey: key,
Filename: filenames[i],
IncludeRejected: req.IncludeRejected,
}
if err := s.publisher.Publish(ctx, msg); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusAccepted, domain.JobStatus{
JobID: jobID,
Status: domain.JobRunning,
Total: len(objectKeys),
})
}
func (s *Server) uploadChunk(w http.ResponseWriter, r *http.Request) {
jobID := chi.URLParam(r, "jobID")
includeRejected := r.URL.Query().Get("include_rejected") == "true"
maxBytes := int64(s.cfg.MaxPDFSizeMB) * 1024 * 1024
if err := r.ParseMultipartForm(maxBytes * 500); err != nil {
httpError(w, http.StatusBadRequest, "invalid multipart form")
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
httpError(w, http.StatusBadRequest, "field 'files' required")
return
}
if len(files) > 500 {
httpError(w, http.StatusBadRequest, "max 500 files per chunk")
return
}
ctx := r.Context()
status, err := s.store.GetJob(ctx, jobID)
if err != nil {
_ = s.store.CreateJob(ctx, jobID, 0, includeRejected)
status = domain.JobStatus{JobID: jobID, Status: domain.JobQueued}
}
if status.Status == domain.JobCancelled {
httpError(w, http.StatusConflict, "job cancelled")
return
}
var filenames []string
if s.cfg.JobInlineProcessing {
_ = s.store.MarkRunning(ctx, jobID)
for _, fh := range files {
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
_ = f.Close()
if err != nil || int64(len(data)) > maxBytes {
continue
}
name := filepath.Base(fh.Filename)
if err := s.store.AddJobFiles(ctx, jobID, []string{name}); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.store.IncrementTotal(ctx, jobID, 1); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
filenames = append(filenames, name)
s.jobRunner.ProcessOne(jobID, FilePayload{Filename: name, Data: data}, includeRejected)
}
} else {
var err error
filenames, err = s.uploadChunkMinIO(ctx, jobID, files, includeRejected, maxBytes)
if err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
}
if len(filenames) == 0 {
httpError(w, http.StatusBadRequest, "no valid files uploaded")
return
}
writeJSON(w, http.StatusAccepted, map[string]interface{}{
"job_id": jobID,
"uploaded": len(filenames),
})
}
func (s *Server) uploadChunkMinIO(ctx context.Context, jobID string, files []*multipart.FileHeader, includeRejected bool, maxBytes int64) ([]string, error) {
filenames := make([]string, 0, len(files))
for _, fh := range files {
filenames = append(filenames, filepath.Base(fh.Filename))
}
if len(filenames) == 0 {
return nil, nil
}
if err := s.store.AddJobFiles(ctx, jobID, filenames); err != nil {
return nil, err
}
if err := s.store.IncrementTotal(ctx, jobID, len(filenames)); err != nil {
return nil, err
}
if err := s.store.MarkRunning(ctx, jobID); err != nil {
return nil, err
}
ioLimit := 8
if len(files) < ioLimit {
ioLimit = len(files)
}
ioSem := make(chan struct{}, ioLimit)
var wg sync.WaitGroup
var mu sync.Mutex
uploadedNames := make([]string, 0, len(files))
for _, fh := range files {
wg.Add(1)
go func(fh *multipart.FileHeader) {
defer wg.Done()
ioSem <- struct{}{}
defer func() { <-ioSem }()
name := filepath.Base(fh.Filename)
f, err := fh.Open()
if err != nil {
s.recordUploadError(ctx, jobID, name, err.Error())
return
}
data, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
_ = f.Close()
if err != nil {
s.recordUploadError(ctx, jobID, name, err.Error())
return
}
if int64(len(data)) > maxBytes {
s.recordUploadError(ctx, jobID, name, "file too large")
return
}
key := storage.JobObjectKey(jobID, name)
if err := s.storage.Put(ctx, key, data, "application/pdf"); err != nil {
s.recordUploadError(ctx, jobID, name, err.Error())
return
}
msg := domain.QueueMessage{
JobID: jobID,
ObjectKey: key,
Filename: name,
IncludeRejected: includeRejected,
}
if err := s.publisher.Publish(ctx, msg); err != nil {
s.recordUploadError(ctx, jobID, name, err.Error())
return
}
mu.Lock()
uploadedNames = append(uploadedNames, name)
mu.Unlock()
}(fh)
}
wg.Wait()
if len(uploadedNames) == 0 {
return nil, nil
}
return uploadedNames, nil
}
func (s *Server) recordUploadError(ctx context.Context, jobID, filename, errMsg string) {
_ = s.store.SaveResult(ctx, jobID, domain.PDFResult{
Filename: filename,
Status: domain.StatusError,
Error: errMsg,
})
_ = s.store.TryCompleteJob(ctx, jobID)
}
func (s *Server) getJob(w http.ResponseWriter, r *http.Request) {
jobID := chi.URLParam(r, "jobID")
waitMs, _ := strconv.Atoi(r.URL.Query().Get("wait_ms"))
if waitMs > 30000 {
waitMs = 30000
}
deadline := time.Now()
if waitMs > 0 {
deadline = deadline.Add(time.Duration(waitMs) * time.Millisecond)
}
for {
status, err := s.store.GetJob(r.Context(), jobID)
if err != nil {
httpError(w, http.StatusNotFound, "job not found")
return
}
if isTerminalJobStatus(status.Status) || waitMs <= 0 || !time.Now().Before(deadline) {
writeJSON(w, http.StatusOK, status)
return
}
time.Sleep(100 * time.Millisecond)
}
}
func isTerminalJobStatus(status string) bool {
return status == domain.JobCompleted || status == domain.JobCancelled
}
func (s *Server) getJobResults(w http.ResponseWriter, r *http.Request) {
jobID := chi.URLParam(r, "jobID")
cursor := r.URL.Query().Get("cursor")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
page, err := s.store.GetResultsPage(r.Context(), jobID, cursor, limit)
if err != nil {
httpError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, page)
}
func (s *Server) cancelJob(w http.ResponseWriter, r *http.Request) {
jobID := chi.URLParam(r, "jobID")
if err := s.store.CancelJob(r.Context(), jobID); err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"job_id": jobID, "status": domain.JobCancelled})
}
func (s *Server) newProcessor(includeRejected bool) *pipeline.Processor {
return pipeline.NewProcessor(s.predictor, s.ocrEngine, pipeline.Options{
DPI: s.cfg.OCRDPI,
Lang: s.cfg.OCRLang,
DBSCANEps: s.cfg.DBSCANEps,
DBSCANMinSamples: s.cfg.DBSCANMinSamples,
IncludeRejected: includeRejected,
LogPipelineTiming: s.cfg.LogPipelineTiming,
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func httpError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func LoadOpenAPI(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
return []byte(`{"openapi":"3.1.0","info":{"title":"pdf-count","version":"1.0.0"}}`)
}
if strings.HasSuffix(strings.ToLower(path), ".yaml") || strings.HasSuffix(strings.ToLower(path), ".yml") {
var doc interface{}
if err := yaml.Unmarshal(data, &doc); err == nil {
if j, err := json.Marshal(doc); err == nil {
return j
}
}
}
return data
}
func ListenAndServe(ctx context.Context, addr string, handler http.Handler) error {
srv := &http.Server{Addr: addr, Handler: handler}
go func() {
<-ctx.Done()
_ = srv.Shutdown(context.Background())
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
@@ -0,0 +1,105 @@
package config
import (
"os"
"strconv"
"time"
)
type Config struct {
Port string
RedisURL string
NATSURL string
MinIOEndpoint string
MinIOAccessKey string
MinIOSecretKey string
MinIOBucket string
MinIOUseSSL bool
ModelPath string
InferencePath string
OCRDPI int
OCRLang string
DBSCANEps float64
DBSCANMinSamples int
SyncMaxFiles int
JobMaxFiles int
QueueMaxPending int
WorkerConcurrency int
MaxPDFSizeMB int
PDFProcessTimeout time.Duration
JobResultTTL time.Duration
StreamName string
SubjectPrefix string
OCRPoolSize int
LogPipelineTiming bool
NATSFetchMaxWait time.Duration
JobInlineProcessing bool
SyncProcessConcurrency int
}
func Load() Config {
return Config{
Port: getenv("PORT", "8080"),
RedisURL: getenv("REDIS_URL", "redis://redis:6379/0"),
NATSURL: getenv("NATS_URL", "nats://nats:4222"),
MinIOEndpoint: getenv("MINIO_ENDPOINT", "minio:9000"),
MinIOAccessKey: getenv("MINIO_ACCESS_KEY", "minioadmin"),
MinIOSecretKey: getenv("MINIO_SECRET_KEY", "minioadmin"),
MinIOBucket: getenv("MINIO_BUCKET", "pdf-count"),
MinIOUseSSL: getenvBool("MINIO_USE_SSL", false),
ModelPath: getenv("MODEL_PATH", "/app/models/model.onnx"),
InferencePath: getenv("INFERENCE_CONFIG", "/app/models/inference.json"),
OCRDPI: getenvInt("OCR_DPI", 300),
OCRLang: getenv("OCR_LANG", "spa"),
DBSCANEps: getenvFloat("DBSCAN_EPS", 10),
DBSCANMinSamples: getenvInt("DBSCAN_MIN_SAMPLES", 1),
SyncMaxFiles: getenvInt("SYNC_MAX_FILES", 50),
JobMaxFiles: getenvInt("JOB_MAX_FILES", 50000),
QueueMaxPending: getenvInt("QUEUE_MAX_PENDING", 100000),
WorkerConcurrency: getenvInt("WORKER_CONCURRENCY", 4),
MaxPDFSizeMB: getenvInt("MAX_PDF_SIZE_MB", 20),
PDFProcessTimeout: time.Duration(getenvInt("PDF_PROCESS_TIMEOUT_SEC", 120)) * time.Second,
JobResultTTL: time.Duration(getenvInt("JOB_RESULT_TTL_HOURS", 72)) * time.Hour,
StreamName: getenv("NATS_STREAM", "PDF_JOBS"),
SubjectPrefix: getenv("NATS_SUBJECT_PREFIX", "pdf.process"),
OCRPoolSize: getenvInt("OCR_POOL_SIZE", 0),
LogPipelineTiming: getenvBool("LOG_PIPELINE_TIMING", false),
NATSFetchMaxWait: time.Duration(getenvInt("NATS_FETCH_MAX_WAIT_MS", 300)) * time.Millisecond,
JobInlineProcessing: getenvBool("JOB_INLINE_PROCESSING", false),
SyncProcessConcurrency: getenvInt("SYNC_PROCESS_CONCURRENCY", 0),
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getenvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func getenvFloat(key string, fallback float64) float64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
}
return fallback
}
func getenvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
@@ -0,0 +1,59 @@
package domain
// ServiceRow is one detected invoice line.
type ServiceRow struct {
Text string `json:"text"`
Probability float64 `json:"probability"`
IsService *bool `json:"is_service,omitempty"`
}
// PDFResult is the processing outcome for a single PDF.
type PDFResult struct {
Filename string `json:"filename"`
ServiceCount int `json:"service_count"`
Status string `json:"status"`
Services []ServiceRow `json:"services,omitempty"`
Error string `json:"error,omitempty"`
}
// CountResponse wraps sync batch results.
type CountResponse struct {
Results []PDFResult `json:"results"`
Processed int `json:"processed"`
Failed int `json:"failed"`
}
// JobStatus tracks async job progress.
type JobStatus struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Total int `json:"total"`
Processed int `json:"processed"`
Failed int `json:"failed"`
}
// JobResultsPage is a paginated slice of PDF results.
type JobResultsPage struct {
JobID string `json:"job_id"`
Results []PDFResult `json:"results"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// QueueMessage is published per PDF for workers.
type QueueMessage struct {
JobID string `json:"job_id"`
ObjectKey string `json:"object_key"`
Filename string `json:"filename"`
IncludeRejected bool `json:"include_rejected"`
}
const (
StatusOK = "ok"
StatusError = "error"
JobQueued = "queued"
JobRunning = "running"
JobCompleted = "completed"
JobCancelled = "cancelled"
)
@@ -0,0 +1,110 @@
package model
import (
"encoding/json"
"fmt"
"math"
"os"
"sync"
ort "github.com/yalue/onnxruntime_go"
)
type InferenceMeta struct {
Threshold float64 `json:"threshold"`
FeatureColumns []string `json:"feature_columns"`
}
type Predictor struct {
session *ort.DynamicAdvancedSession
threshold float64
mu sync.Mutex
}
func NewPredictor(modelPath, metaPath string) (*Predictor, error) {
if lib := os.Getenv("ONNXRUNTIME_SHARED_LIBRARY"); lib != "" {
ort.SetSharedLibraryPath(lib)
}
if err := ort.InitializeEnvironment(); err != nil {
return nil, fmt.Errorf("onnx init: %w", err)
}
metaBytes, err := os.ReadFile(metaPath)
if err != nil {
return nil, fmt.Errorf("read inference meta: %w", err)
}
var meta InferenceMeta
if err := json.Unmarshal(metaBytes, &meta); err != nil {
return nil, fmt.Errorf("parse inference meta: %w", err)
}
if meta.Threshold == 0 {
meta.Threshold = 0.5
}
session, err := ort.NewDynamicAdvancedSession(
modelPath,
[]string{"float_input"},
[]string{"probabilities"},
nil,
)
if err != nil {
return nil, fmt.Errorf("onnx session: %w", err)
}
return &Predictor{session: session, threshold: meta.Threshold}, nil
}
func (p *Predictor) Threshold() float64 {
return p.threshold
}
func (p *Predictor) Close() error {
if p.session != nil {
p.session.Destroy()
}
return ort.DestroyEnvironment()
}
func (p *Predictor) PredictProba(features [][8]float32) ([]float64, error) {
if len(features) == 0 {
return nil, nil
}
n := len(features)
flat := make([]float32, 0, n*8)
for _, row := range features {
flat = append(flat, row[:]...)
}
inputShape := ort.NewShape(int64(n), 8)
inputTensor, err := ort.NewTensor(inputShape, flat)
if err != nil {
return nil, err
}
defer inputTensor.Destroy()
outputShape := ort.NewShape(int64(n), 2)
outputTensor, err := ort.NewEmptyTensor[float32](outputShape)
if err != nil {
return nil, err
}
defer outputTensor.Destroy()
p.mu.Lock()
err = p.session.Run([]ort.Value{inputTensor}, []ort.Value{outputTensor})
p.mu.Unlock()
if err != nil {
return nil, err
}
data := outputTensor.GetData()
probs := make([]float64, n)
for i := 0; i < n; i++ {
probs[i] = float64(data[i*2+1])
}
return probs, nil
}
func RoundProb(p float64) float64 {
return math.Round(p*10000) / 10000
}
@@ -0,0 +1,99 @@
package ocr
import (
"bytes"
"fmt"
"image"
"image/png"
"github.com/gen2brain/go-fitz"
"github.com/otiai10/gosseract/v2"
)
type Box struct {
Text string
X int
Y int
W int
H int
}
// RenderPage rasterizes the first page of a PDF.
func RenderPage(pdfData []byte, dpi int) (image.Image, int, int, error) {
doc, err := fitz.NewFromMemory(pdfData)
if err != nil {
return nil, 0, 0, fmt.Errorf("open pdf: %w", err)
}
defer doc.Close()
if doc.NumPage() == 0 {
return nil, 0, 0, fmt.Errorf("pdf has no pages")
}
img, err := doc.ImageDPI(0, float64(dpi))
if err != nil {
return nil, 0, 0, fmt.Errorf("render page: %w", err)
}
bounds := img.Bounds()
return img, bounds.Dx(), bounds.Dy(), nil
}
// OCRImage runs Tesseract word-level OCR on a rendered page image.
func (e *Engine) OCRImage(img image.Image) ([]Box, error) {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
client := e.Acquire()
defer e.Release(client)
if err := client.SetImageFromBytes(buf.Bytes()); err != nil {
return nil, fmt.Errorf("tesseract image: %w", err)
}
boxes, err := client.GetBoundingBoxes(gosseract.RIL_WORD)
if err != nil {
return nil, fmt.Errorf("tesseract ocr: %w", err)
}
out := make([]Box, 0, len(boxes))
for _, b := range boxes {
text := trim(b.Word)
if text == "" || b.Confidence <= 0 {
continue
}
out = append(out, Box{
Text: text,
X: b.Box.Min.X,
Y: b.Box.Min.Y,
W: b.Box.Dx(),
H: b.Box.Dy(),
})
}
return out, nil
}
// ExtractBoxes renders the first PDF page and runs OCR with a pooled Tesseract client.
func (e *Engine) ExtractBoxes(pdfData []byte, dpi int) ([]Box, int, int, error) {
img, pageW, pageH, err := RenderPage(pdfData, dpi)
if err != nil {
return nil, 0, 0, err
}
boxes, err := e.OCRImage(img)
if err != nil {
return nil, 0, 0, err
}
return boxes, pageW, pageH, nil
}
func trim(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n') {
end--
}
return s[start:end]
}
@@ -0,0 +1,69 @@
package ocr
import (
"fmt"
"github.com/otiai10/gosseract/v2"
)
// Engine holds a pool of reusable Tesseract clients.
type Engine struct {
pool chan *gosseract.Client
clients []*gosseract.Client
lang string
}
// NewEngine warms up poolSize Tesseract clients for the given language.
func NewEngine(lang string, poolSize int) (*Engine, error) {
if poolSize < 1 {
poolSize = 1
}
e := &Engine{
pool: make(chan *gosseract.Client, poolSize),
clients: make([]*gosseract.Client, 0, poolSize),
lang: lang,
}
for i := 0; i < poolSize; i++ {
client, err := newPooledClient(lang)
if err != nil {
e.Close()
return nil, fmt.Errorf("tesseract client %d: %w", i, err)
}
e.clients = append(e.clients, client)
e.pool <- client
}
return e, nil
}
func newPooledClient(lang string) (*gosseract.Client, error) {
client := gosseract.NewClient()
if err := client.SetLanguage(lang); err != nil {
if err2 := client.SetLanguage("eng"); err2 != nil {
_ = client.Close()
return nil, fmt.Errorf("tesseract lang: %w", err)
}
}
return client, nil
}
// Acquire returns a Tesseract client from the pool.
func (e *Engine) Acquire() *gosseract.Client {
return <-e.pool
}
// Release returns a client to the pool.
func (e *Engine) Release(client *gosseract.Client) {
e.pool <- client
}
// Close shuts down all pooled clients.
func (e *Engine) Close() error {
for _, client := range e.clients {
_ = client.Close()
}
e.clients = nil
return nil
}
@@ -0,0 +1,112 @@
package pipeline
import (
"math"
"sort"
)
// ClusterRows runs 1D DBSCAN on Y coordinates (parity with sklearn DBSCAN).
func ClusterRows(boxes []indexedBox, eps float64, minSamples int) {
if len(boxes) == 0 {
return
}
ys := make([]float64, len(boxes))
for i, b := range boxes {
ys[i] = float64(b.Y)
}
labels := dbscan1D(ys, eps, minSamples)
for i := range boxes {
boxes[i].Cluster = labels[i]
}
}
func dbscan1D(points []float64, eps float64, minSamples int) []int {
n := len(points)
labels := make([]int, n)
for i := range labels {
labels[i] = -1
}
clusterID := 0
visited := make([]bool, n)
for i := 0; i < n; i++ {
if visited[i] {
continue
}
visited[i] = true
neighbors := regionQuery(points, i, eps)
if len(neighbors) < minSamples {
continue
}
labels[i] = clusterID
seed := append([]int(nil), neighbors...)
for j := 0; j < len(seed); j++ {
q := seed[j]
if !visited[q] {
visited[q] = true
neighborsQ := regionQuery(points, q, eps)
if len(neighborsQ) >= minSamples {
seed = appendUnique(seed, neighborsQ)
}
}
if labels[q] == -1 {
labels[q] = clusterID
}
}
clusterID++
}
return labels
}
func regionQuery(points []float64, idx int, eps float64) []int {
var neighbors []int
for i, p := range points {
if math.Abs(p-points[idx]) <= eps {
neighbors = append(neighbors, i)
}
}
return neighbors
}
func appendUnique(seed, add []int) []int {
inSeed := make(map[int]bool, len(seed))
for _, v := range seed {
inSeed[v] = true
}
for _, v := range add {
if !inSeed[v] {
seed = append(seed, v)
inSeed[v] = true
}
}
return seed
}
type indexedBox struct {
Text string
X int
Y int
W int
H int
Cluster int
}
func groupByCluster(boxes []indexedBox) map[int][]indexedBox {
groups := make(map[int][]indexedBox)
for _, b := range boxes {
if b.Cluster < 0 {
continue
}
groups[b.Cluster] = append(groups[b.Cluster], b)
}
return groups
}
func sortClusters(groups map[int][]indexedBox) []int {
keys := make([]int, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}
@@ -0,0 +1,122 @@
package pipeline
import (
"regexp"
"sort"
"strings"
"unicode"
)
var (
codePattern = regexp.MustCompile(`^\d{4,6}$`)
currencyPattern = regexp.MustCompile(`^\d{1,3}(,\d{3})+(\.\d+)?$`)
)
type rowFeatures struct {
Features [8]float32
TextoFila string
Cluster int
}
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 := 0.0
if pageW > 0 {
xSpanNorm = float64(xMax-xMin) / float64(pageW)
}
yNorm := 0.0
if pageH > 0 {
yNorm = float64(ySum/len(group)) / float64(pageH)
}
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
}
return rowFeatures{
Features: [8]float32{
float32(firstIsCode),
float32(currencyCount),
float32(integerCount),
float32(xSpanNorm),
float32(yNorm),
float32(len(texts)),
float32(special) / float32(totalChars),
float32(digits) / float32(totalChars),
},
TextoFila: textoFila,
Cluster: group[0].Cluster,
}
}
func isDigits(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
func buildFeatureRows(boxes []indexedBox, pageW, pageH int) []rowFeatures {
groups := groupByCluster(boxes)
order := sortClusters(groups)
rows := make([]rowFeatures, 0, len(groups))
for _, cid := range order {
rows = append(rows, extractRowFeatures(groups[cid], pageW, pageH))
}
return rows
}
@@ -0,0 +1,128 @@
package pipeline
import (
"fmt"
"log"
"time"
"github.com/pys/pdf-count/internal/domain"
"github.com/pys/pdf-count/internal/model"
"github.com/pys/pdf-count/internal/ocr"
)
type Options struct {
DPI int
Lang string
DBSCANEps float64
DBSCANMinSamples int
IncludeRejected bool
LogPipelineTiming bool
}
type Processor struct {
predictor *model.Predictor
ocrEngine *ocr.Engine
opts Options
}
func NewProcessor(predictor *model.Predictor, ocrEngine *ocr.Engine, opts Options) *Processor {
return &Processor{predictor: predictor, ocrEngine: ocrEngine, opts: opts}
}
func (p *Processor) ProcessPDF(filename string, pdfData []byte) domain.PDFResult {
start := time.Now()
result := domain.PDFResult{Filename: filename, Status: domain.StatusOK}
tRender := time.Now()
img, pageW, pageH, err := ocr.RenderPage(pdfData, p.opts.DPI)
renderDur := time.Since(tRender)
if err != nil {
result.Status = domain.StatusError
result.Error = err.Error()
return result
}
tOCR := time.Now()
boxes, err := p.ocrEngine.OCRImage(img)
ocrDur := time.Since(tOCR)
if err != nil {
result.Status = domain.StatusError
result.Error = err.Error()
return result
}
if len(boxes) == 0 {
result.ServiceCount = 0
result.Services = []domain.ServiceRow{}
p.logTiming(filename, renderDur, ocrDur, 0, 0, time.Since(start))
return result
}
tCluster := time.Now()
indexed := make([]indexedBox, len(boxes))
for i, b := range boxes {
indexed[i] = indexedBox{Text: b.Text, X: b.X, Y: b.Y, W: b.W, H: b.H}
}
ClusterRows(indexed, p.opts.DBSCANEps, p.opts.DBSCANMinSamples)
rows := buildFeatureRows(indexed, pageW, pageH)
clusterDur := time.Since(tCluster)
if len(rows) == 0 {
result.ServiceCount = 0
result.Services = []domain.ServiceRow{}
p.logTiming(filename, renderDur, ocrDur, clusterDur, 0, time.Since(start))
return result
}
features := make([][8]float32, len(rows))
for i, r := range rows {
features[i] = r.Features
}
tInfer := time.Now()
probs, err := p.predictor.PredictProba(features)
inferDur := time.Since(tInfer)
if err != nil {
result.Status = domain.StatusError
result.Error = fmt.Sprintf("inference: %v", err)
return result
}
threshold := p.predictor.Threshold()
services := make([]domain.ServiceRow, 0)
serviceCount := 0
for i, row := range rows {
prob := model.RoundProb(probs[i])
isService := prob >= threshold
if isService {
serviceCount++
}
if p.opts.IncludeRejected {
v := isService
services = append(services, domain.ServiceRow{
Text: row.TextoFila,
Probability: prob,
IsService: &v,
})
} else if isService {
services = append(services, domain.ServiceRow{
Text: row.TextoFila,
Probability: prob,
})
}
}
result.Services = services
result.ServiceCount = serviceCount
p.logTiming(filename, renderDur, ocrDur, clusterDur, inferDur, time.Since(start))
return result
}
func (p *Processor) logTiming(filename string, render, ocr, cluster, infer, total time.Duration) {
if !p.opts.LogPipelineTiming {
return
}
log.Printf(
"pipeline filename=%s ocr_render=%s ocr_tesseract=%s cluster=%s infer=%s total=%s",
filename, render, ocr, cluster, infer, total,
)
}
@@ -0,0 +1,109 @@
package queue
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/pys/pdf-count/internal/domain"
)
type Publisher struct {
js jetstream.JetStream
stream string
subject string
}
func NewPublisher(natsURL, streamName, subjectPrefix string) (*Publisher, error) {
nc, err := nats.Connect(natsURL)
if err != nil {
return nil, fmt.Errorf("nats connect: %w", err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: streamName,
Subjects: []string{subjectPrefix + ".>"},
Retention: jetstream.WorkQueuePolicy,
MaxMsgs: 200000,
})
if err != nil {
nc.Close()
return nil, fmt.Errorf("create stream: %w", err)
}
return &Publisher{js: js, stream: streamName, subject: subjectPrefix}, nil
}
func (p *Publisher) Publish(ctx context.Context, msg domain.QueueMessage) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
subject := fmt.Sprintf("%s.%s", p.subject, msg.JobID)
_, err = p.js.Publish(ctx, subject, data)
return err
}
type Consumer struct {
js jetstream.JetStream
subject string
consumerName string
}
func NewConsumer(natsURL, streamName, subjectPrefix, consumerName string) (*Consumer, jetstream.Consumer, *nats.Conn, error) {
nc, err := nats.Connect(natsURL)
if err != nil {
return nil, nil, nil, err
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: streamName,
Subjects: []string{subjectPrefix + ".>"},
Retention: jetstream.WorkQueuePolicy,
MaxMsgs: 200000,
})
if err != nil {
nc.Close()
return nil, nil, nil, err
}
cons, err := js.CreateOrUpdateConsumer(ctx, streamName, jetstream.ConsumerConfig{
Name: consumerName,
FilterSubject: subjectPrefix + ".>",
AckPolicy: jetstream.AckExplicitPolicy,
MaxDeliver: 3,
AckWait: 2 * time.Minute,
MaxAckPending: 256,
})
if err != nil {
nc.Close()
return nil, nil, nil, err
}
return &Consumer{js: js, subject: subjectPrefix, consumerName: consumerName}, cons, nc, nil
}
func DecodeMessage(data []byte) (domain.QueueMessage, error) {
var msg domain.QueueMessage
err := json.Unmarshal(data, &msg)
return msg, err
}
@@ -0,0 +1,86 @@
package storage
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Client struct {
client *minio.Client
bucket string
}
func New(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*Client, error) {
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: useSSL,
})
if err != nil {
return nil, err
}
return &Client{client: client, bucket: bucket}, nil
}
func (c *Client) EnsureBucket(ctx context.Context) error {
exists, err := c.client.BucketExists(ctx, c.bucket)
if err != nil {
return err
}
if exists {
return nil
}
err = c.client.MakeBucket(ctx, c.bucket, minio.MakeBucketOptions{})
if err == nil {
return nil
}
resp := minio.ToErrorResponse(err)
if resp.Code == "BucketAlreadyOwnedByYou" || resp.Code == "BucketAlreadyExists" {
return nil
}
return err
}
func (c *Client) Put(ctx context.Context, objectKey string, data []byte, contentType string) error {
if contentType == "" {
contentType = "application/pdf"
}
_, err := c.client.PutObject(ctx, c.bucket, objectKey, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{
ContentType: contentType,
})
return err
}
func (c *Client) Get(ctx context.Context, objectKey string) ([]byte, error) {
obj, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
defer obj.Close()
return io.ReadAll(obj)
}
func (c *Client) ListByPrefix(ctx context.Context, prefix string) ([]string, error) {
var keys []string
for obj := range c.client.ListObjects(ctx, c.bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
}) {
if obj.Err != nil {
return nil, obj.Err
}
if strings.HasSuffix(strings.ToLower(obj.Key), ".pdf") {
keys = append(keys, obj.Key)
}
}
return keys, nil
}
func JobObjectKey(jobID, filename string) string {
return fmt.Sprintf("jobs/%s/%s", jobID, filename)
}
@@ -0,0 +1,276 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/pys/pdf-count/internal/domain"
"github.com/redis/go-redis/v9"
)
type Store struct {
client *redis.Client
ttl time.Duration
}
func New(redisURL string, ttl time.Duration) (*Store, error) {
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("redis ping: %w", err)
}
return &Store{client: client, ttl: ttl}, nil
}
func (s *Store) Close() error {
return s.client.Close()
}
func metaKey(jobID string) string { return fmt.Sprintf("job:%s:meta", jobID) }
func filesKey(jobID string) string { return fmt.Sprintf("job:%s:files", jobID) }
func resultsKey(jobID string) string {
return fmt.Sprintf("job:%s:results", jobID)
}
func (s *Store) CreateJob(ctx context.Context, jobID string, total int, includeRejected bool) error {
pipe := s.client.Pipeline()
pipe.HSet(ctx, metaKey(jobID), map[string]interface{}{
"status": domain.JobQueued,
"total": total,
"processed": 0,
"failed": 0,
"include_rejected": boolStr(includeRejected),
})
pipe.Expire(ctx, metaKey(jobID), s.ttl)
_, err := pipe.Exec(ctx)
return err
}
func (s *Store) AddJobFiles(ctx context.Context, jobID string, filenames []string) error {
if len(filenames) == 0 {
return nil
}
members := make([]interface{}, len(filenames))
for i, f := range filenames {
members[i] = f
}
pipe := s.client.Pipeline()
pipe.RPush(ctx, filesKey(jobID), members...)
pipe.Expire(ctx, filesKey(jobID), s.ttl)
_, err := pipe.Exec(ctx)
return err
}
func (s *Store) MarkRunning(ctx context.Context, jobID string) error {
return s.client.HSet(ctx, metaKey(jobID), "status", domain.JobRunning).Err()
}
var saveResultScript = redis.NewScript(`
local meta = KEYS[1]
local results = KEYS[2]
redis.call('HSET', results, ARGV[1], ARGV[2])
local processed = redis.call('HINCRBY', meta, 'processed', 1)
if ARGV[3] == '1' then
redis.call('HINCRBY', meta, 'failed', 1)
end
redis.call('EXPIRE', results, ARGV[4])
local total = tonumber(redis.call('HGET', meta, 'total') or '0')
local status = redis.call('HGET', meta, 'status')
if status ~= 'cancelled' and status ~= 'completed' and total > 0 and processed >= total then
redis.call('HSET', meta, 'status', 'completed')
end
return processed
`)
func (s *Store) SaveResult(ctx context.Context, jobID string, result domain.PDFResult) error {
data, err := json.Marshal(result)
if err != nil {
return err
}
failFlag := "0"
if result.Status == domain.StatusError {
failFlag = "1"
}
ttlSec := int64(s.ttl.Seconds())
if ttlSec <= 0 {
ttlSec = 72 * 3600
}
_, err = saveResultScript.Run(ctx, s.client,
[]string{metaKey(jobID), resultsKey(jobID)},
result.Filename, string(data), failFlag, ttlSec,
).Result()
return err
}
func (s *Store) TryCompleteJob(ctx context.Context, jobID string) error {
meta, err := s.client.HGetAll(ctx, metaKey(jobID)).Result()
if err != nil {
return err
}
_, err = s.maybeCompleteJob(ctx, jobID, meta)
return err
}
func (s *Store) maybeCompleteJob(ctx context.Context, jobID string, meta map[string]string) (string, error) {
status := meta["status"]
if status == domain.JobCancelled || status == domain.JobCompleted {
return status, nil
}
total, _ := strconv.Atoi(meta["total"])
processed, _ := strconv.Atoi(meta["processed"])
if processed >= total && total > 0 {
if err := s.client.HSet(ctx, metaKey(jobID), "status", domain.JobCompleted).Err(); err != nil {
return status, err
}
return domain.JobCompleted, nil
}
return status, nil
}
func (s *Store) GetJob(ctx context.Context, jobID string) (domain.JobStatus, error) {
meta, err := s.client.HGetAll(ctx, metaKey(jobID)).Result()
if err != nil {
return domain.JobStatus{}, err
}
if len(meta) == 0 {
return domain.JobStatus{}, fmt.Errorf("job not found")
}
status, err := s.maybeCompleteJob(ctx, jobID, meta)
if err != nil {
return domain.JobStatus{}, err
}
if status == domain.JobCompleted {
meta["status"] = domain.JobCompleted
}
total, _ := strconv.Atoi(meta["total"])
processed, _ := strconv.Atoi(meta["processed"])
failed, _ := strconv.Atoi(meta["failed"])
return domain.JobStatus{
JobID: jobID,
Status: status,
Total: total,
Processed: processed,
Failed: failed,
}, nil
}
func (s *Store) GetResultsPage(ctx context.Context, jobID, cursor string, limit int) (domain.JobResultsPage, error) {
if limit <= 0 || limit > 1000 {
limit = 500
}
allFiles, err := s.client.LRange(ctx, filesKey(jobID), 0, -1).Result()
if err != nil {
return domain.JobResultsPage{}, err
}
start := 0
if cursor != "" {
for i, f := range allFiles {
if f == cursor {
start = i + 1
break
}
}
}
end := start + limit
if end > len(allFiles) {
end = len(allFiles)
}
pageFiles := allFiles[start:end]
results := make([]domain.PDFResult, 0, len(pageFiles))
if len(pageFiles) > 0 {
raw, err := s.client.HMGet(ctx, resultsKey(jobID), pageFiles...).Result()
if err != nil {
return domain.JobResultsPage{}, err
}
for i, v := range raw {
if v == nil {
results = append(results, domain.PDFResult{
Filename: pageFiles[i],
Status: "pending",
})
continue
}
var r domain.PDFResult
if err := json.Unmarshal([]byte(v.(string)), &r); err != nil {
continue
}
results = append(results, r)
}
}
page := domain.JobResultsPage{
JobID: jobID,
Results: results,
HasMore: end < len(allFiles),
}
if page.HasMore && len(pageFiles) > 0 {
page.NextCursor = pageFiles[len(pageFiles)-1]
}
return page, nil
}
func (s *Store) CancelJob(ctx context.Context, jobID string) error {
return s.client.HSet(ctx, metaKey(jobID), "status", domain.JobCancelled).Err()
}
func (s *Store) IsCancelled(ctx context.Context, jobID string) (bool, error) {
status, err := s.client.HGet(ctx, metaKey(jobID), "status").Result()
if err != nil {
return false, err
}
return status == domain.JobCancelled, nil
}
func (s *Store) PendingCount(ctx context.Context) (int64, error) {
// Approximate: sum of (total - processed) for running jobs
var cursor uint64
var pending int64
for {
keys, next, err := s.client.Scan(ctx, cursor, "job:*:meta", 100).Result()
if err != nil {
return pending, err
}
for _, key := range keys {
meta, err := s.client.HGetAll(ctx, key).Result()
if err != nil {
continue
}
if meta["status"] != domain.JobQueued && meta["status"] != domain.JobRunning {
continue
}
total, _ := strconv.Atoi(meta["total"])
processed, _ := strconv.Atoi(meta["processed"])
pending += int64(total - processed)
}
cursor = next
if cursor == 0 {
break
}
}
return pending, nil
}
func (s *Store) IncrementTotal(ctx context.Context, jobID string, delta int) error {
pipe := s.client.Pipeline()
pipe.HIncrBy(ctx, metaKey(jobID), "total", int64(delta))
pipe.Expire(ctx, metaKey(jobID), s.ttl)
_, err := pipe.Exec(ctx)
return err
}
func boolStr(v bool) string {
if v {
return "true"
}
return "false"
}
@@ -0,0 +1,212 @@
package worker
import (
"context"
"log"
"os/signal"
"sync"
"syscall"
"time"
"github.com/nats-io/nats.go/jetstream"
"github.com/pys/pdf-count/internal/config"
"github.com/pys/pdf-count/internal/domain"
"github.com/pys/pdf-count/internal/model"
"github.com/pys/pdf-count/internal/ocr"
"github.com/pys/pdf-count/internal/pipeline"
"github.com/pys/pdf-count/internal/queue"
"github.com/pys/pdf-count/internal/storage"
"github.com/pys/pdf-count/internal/store"
)
type Runner struct {
cfg config.Config
predictor *model.Predictor
ocrEngine *ocr.Engine
store *store.Store
storage *storage.Client
consumer jetstream.Consumer
ncClose func()
}
func NewRunner(cfg config.Config) (*Runner, error) {
predictor, err := model.NewPredictor(cfg.ModelPath, cfg.InferencePath)
if err != nil {
return nil, err
}
poolSize := cfg.OCRPoolSize
if poolSize <= 0 {
poolSize = cfg.WorkerConcurrency
}
ocrEngine, err := ocr.NewEngine(cfg.OCRLang, poolSize)
if err != nil {
predictor.Close()
return nil, err
}
st, err := store.New(cfg.RedisURL, cfg.JobResultTTL)
if err != nil {
ocrEngine.Close()
predictor.Close()
return nil, err
}
stg, err := storage.New(cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL)
if err != nil {
ocrEngine.Close()
st.Close()
predictor.Close()
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := stg.EnsureBucket(ctx); err != nil {
ocrEngine.Close()
st.Close()
predictor.Close()
return nil, err
}
_, cons, nc, err := queue.NewConsumer(cfg.NATSURL, cfg.StreamName, cfg.SubjectPrefix, "pdf-workers")
if err != nil {
ocrEngine.Close()
st.Close()
predictor.Close()
return nil, err
}
return &Runner{
cfg: cfg,
predictor: predictor,
ocrEngine: ocrEngine,
store: st,
storage: stg,
consumer: cons,
ncClose: func() { nc.Close() },
}, nil
}
func (r *Runner) Close() {
r.ncClose()
_ = r.ocrEngine.Close()
_ = r.store.Close()
_ = r.predictor.Close()
}
func (r *Runner) processSlots() int {
c := r.cfg.WorkerConcurrency
pool := r.cfg.OCRPoolSize
if pool <= 0 {
pool = r.cfg.WorkerConcurrency
}
if pool <= 0 {
pool = 4
}
if c > pool {
c = pool
}
if c <= 0 {
c = 4
}
return c
}
func (r *Runner) Run(ctx context.Context) error {
slots := r.processSlots()
sem := make(chan struct{}, slots)
var wg sync.WaitGroup
go func() {
<-ctx.Done()
wg.Wait()
}()
for {
select {
case <-ctx.Done():
return nil
default:
}
msgs, err := r.consumer.Fetch(slots, jetstream.FetchMaxWait(r.cfg.NATSFetchMaxWait))
if err != nil {
continue
}
for msg := range msgs.Messages() {
sem <- struct{}{}
wg.Add(1)
m := msg
go func() {
defer func() {
<-sem
wg.Done()
}()
r.handleMessage(ctx, m)
}()
}
_ = msgs.Error()
}
}
func (r *Runner) handleMessage(ctx context.Context, msg jetstream.Msg) {
processCtx, cancel := context.WithTimeout(ctx, r.cfg.PDFProcessTimeout)
defer cancel()
qmsg, err := queue.DecodeMessage(msg.Data())
if err != nil {
_ = msg.Term()
return
}
cancelled, err := r.store.IsCancelled(processCtx, qmsg.JobID)
if err != nil || cancelled {
_ = msg.Ack()
return
}
data, err := r.storage.Get(processCtx, qmsg.ObjectKey)
if err != nil {
_ = r.store.SaveResult(processCtx, qmsg.JobID, domain.PDFResult{
Filename: qmsg.Filename,
Status: domain.StatusError,
Error: err.Error(),
})
_ = r.store.TryCompleteJob(processCtx, qmsg.JobID)
_ = msg.Ack()
return
}
proc := pipeline.NewProcessor(r.predictor, r.ocrEngine, pipeline.Options{
DPI: r.cfg.OCRDPI,
Lang: r.cfg.OCRLang,
DBSCANEps: r.cfg.DBSCANEps,
DBSCANMinSamples: r.cfg.DBSCANMinSamples,
IncludeRejected: qmsg.IncludeRejected,
LogPipelineTiming: r.cfg.LogPipelineTiming,
})
result := proc.ProcessPDF(qmsg.Filename, data)
data = nil
if err := r.store.SaveResult(processCtx, qmsg.JobID, result); err != nil {
_ = msg.NakWithDelay(5 * time.Second)
return
}
_ = r.store.TryCompleteJob(processCtx, qmsg.JobID)
_ = msg.Ack()
}
func RunMain() {
cfg := config.Load()
runner, err := NewRunner(cfg)
if err != nil {
log.Fatalf("worker init: %v", err)
}
defer runner.Close()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
log.Printf("worker started slots=%d", runner.processSlots())
if err := runner.Run(ctx); err != nil {
log.Fatalf("worker: %v", err)
}
log.Println("worker stopped")
}