336 lines
8.9 KiB
Go
336 lines
8.9 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/goko/jingtian-tracker/clients"
|
|
"github.com/goko/jingtian-tracker/config"
|
|
"github.com/goko/jingtian-tracker/db"
|
|
"github.com/goko/jingtian-tracker/watcher"
|
|
)
|
|
|
|
// TrackerColumns defines the tracker spreadsheet columns
|
|
var TrackerColumns = []string{
|
|
"Document Path",
|
|
"Document Type",
|
|
"Client Name",
|
|
"Matter Reference",
|
|
"TM Number",
|
|
"Trademark Name",
|
|
"Trademark Class",
|
|
"Filing Date",
|
|
"Response Deadline",
|
|
"Hearing Date",
|
|
"Amount",
|
|
"Currency",
|
|
"Status",
|
|
"First Processed",
|
|
"Last Updated",
|
|
"Update Source",
|
|
"Processing Notes",
|
|
}
|
|
|
|
type Pipeline struct {
|
|
cfg *config.Config
|
|
db *db.DB
|
|
watcher *watcher.Watcher
|
|
docling *clients.DoclingClient
|
|
ollama *clients.OllamaClient
|
|
tools *clients.ToolsClient
|
|
errorLog *os.File
|
|
}
|
|
|
|
func New(cfg *config.Config, database *db.DB, w *watcher.Watcher) (*Pipeline, error) {
|
|
// Open error log file
|
|
var errorLog *os.File
|
|
if cfg.Logging.ErrorFile != "" {
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(cfg.Logging.ErrorFile)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
|
}
|
|
|
|
f, err := os.OpenFile(cfg.Logging.ErrorFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open error log: %w", err)
|
|
}
|
|
errorLog = f
|
|
}
|
|
|
|
return &Pipeline{
|
|
cfg: cfg,
|
|
db: database,
|
|
watcher: w,
|
|
docling: clients.NewDoclingClient(cfg.Services.Docling),
|
|
ollama: clients.NewOllamaClient(cfg.Services.Ollama, cfg.Ollama.Model, cfg.Ollama.Temperature),
|
|
tools: clients.NewToolsClient(cfg.Services.Tools),
|
|
errorLog: errorLog,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Pipeline) Close() {
|
|
if p.errorLog != nil {
|
|
p.errorLog.Close()
|
|
}
|
|
}
|
|
|
|
func (p *Pipeline) Run() error {
|
|
// Check service health
|
|
if err := p.checkServices(); err != nil {
|
|
return fmt.Errorf("service health check failed: %w", err)
|
|
}
|
|
|
|
// Ensure tracker exists
|
|
if err := p.ensureTracker(); err != nil {
|
|
log.Printf("[pipeline] Warning: could not ensure tracker: %v", err)
|
|
// Don't fail - tracker might be created manually
|
|
}
|
|
|
|
// Start watcher
|
|
if err := p.watcher.Start(); err != nil {
|
|
return fmt.Errorf("failed to start watcher: %w", err)
|
|
}
|
|
|
|
log.Println("[pipeline] Started processing loop")
|
|
|
|
// Process events
|
|
for event := range p.watcher.Events {
|
|
p.processFile(event)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Pipeline) checkServices() error {
|
|
log.Println("[pipeline] Checking service health...")
|
|
|
|
if err := p.docling.Health(); err != nil {
|
|
return fmt.Errorf("docling: %w", err)
|
|
}
|
|
log.Println("[pipeline] Docling: OK")
|
|
|
|
if err := p.ollama.Health(); err != nil {
|
|
return fmt.Errorf("ollama: %w", err)
|
|
}
|
|
log.Println("[pipeline] Ollama: OK")
|
|
|
|
if err := p.tools.Health(); err != nil {
|
|
return fmt.Errorf("tools: %w", err)
|
|
}
|
|
log.Println("[pipeline] Tools: OK")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Pipeline) ensureTracker() error {
|
|
// Check if tracker exists by trying to read it
|
|
_, err := p.tools.ReadExcel(p.cfg.Tracker.Path, p.cfg.Tracker.Sheet, p.cfg.Tracker.HeaderRow)
|
|
if err != nil {
|
|
// Try to create it
|
|
log.Printf("[pipeline] Creating tracker: %s", p.cfg.Tracker.Path)
|
|
if createErr := p.tools.CreateTracker(p.cfg.Tracker.Path, p.cfg.Tracker.Sheet, TrackerColumns); createErr != nil {
|
|
return fmt.Errorf("failed to create tracker: %w", createErr)
|
|
}
|
|
log.Printf("[pipeline] Tracker created successfully")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Pipeline) processFile(event watcher.FileEvent) {
|
|
// Get relative path
|
|
relPath, err := filepath.Rel(p.cfg.Watch.Root, event.Path)
|
|
if err != nil {
|
|
relPath = event.Path
|
|
}
|
|
|
|
log.Printf("[pipeline] Processing: %s", relPath)
|
|
|
|
// Check if processing needed
|
|
needsProcessing, err := p.db.NeedsProcessing(relPath, event.Hash)
|
|
if err != nil {
|
|
p.logError(relPath, fmt.Sprintf("failed to check processing status: %v", err))
|
|
return
|
|
}
|
|
|
|
if !needsProcessing {
|
|
log.Printf("[pipeline] Skipping (already processed with same hash): %s", relPath)
|
|
return
|
|
}
|
|
|
|
// Upsert document record
|
|
doc, err := p.db.UpsertDocument(relPath, event.Hash, event.Size, event.ModTime)
|
|
if err != nil {
|
|
p.logError(relPath, fmt.Sprintf("failed to upsert document: %v", err))
|
|
return
|
|
}
|
|
|
|
// Update status to processing
|
|
p.db.UpdateStatus(doc.ID, "processing")
|
|
|
|
// Process based on file type
|
|
ext := strings.ToLower(filepath.Ext(event.Path))
|
|
var extracted map[string]interface{}
|
|
|
|
switch ext {
|
|
case ".xlsx", ".xls":
|
|
// For Excel files, we don't OCR, just log that we saw it
|
|
log.Printf("[pipeline] Excel file detected, skipping OCR: %s", relPath)
|
|
p.db.LogAction(doc.ID, "skipped", "Excel files are not processed through OCR")
|
|
p.db.MarkProcessed(doc.ID)
|
|
return
|
|
|
|
default:
|
|
// Send to Docling for OCR/extraction
|
|
content, doclingErr := p.docling.Convert(event.Path)
|
|
if doclingErr != nil {
|
|
p.logError(relPath, fmt.Sprintf("Docling failed: %v", doclingErr))
|
|
p.db.UpdateStatus(doc.ID, "failed")
|
|
p.db.LogAction(doc.ID, "failed", fmt.Sprintf("Docling error: %v", doclingErr))
|
|
return
|
|
}
|
|
|
|
if content == "" {
|
|
p.logError(relPath, "Docling returned empty content")
|
|
p.db.UpdateStatus(doc.ID, "failed")
|
|
p.db.LogAction(doc.ID, "failed", "Empty content from Docling")
|
|
return
|
|
}
|
|
|
|
// Detect document type from extension
|
|
docType := detectDocType(ext)
|
|
|
|
// Send to Ollama for extraction
|
|
extracted, err = p.ollama.Extract(content, docType)
|
|
if err != nil {
|
|
p.logError(relPath, fmt.Sprintf("Ollama failed: %v", err))
|
|
p.db.UpdateStatus(doc.ID, "failed")
|
|
p.db.LogAction(doc.ID, "failed", fmt.Sprintf("Ollama error: %v", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Save extraction to database
|
|
if err := p.db.SaveExtraction(doc.ID, extracted); err != nil {
|
|
p.logError(relPath, fmt.Sprintf("failed to save extraction: %v", err))
|
|
}
|
|
|
|
// Write to tracker
|
|
if err := p.writeToTracker(relPath, extracted); err != nil {
|
|
p.logError(relPath, fmt.Sprintf("failed to write to tracker: %v", err))
|
|
p.db.UpdateStatus(doc.ID, "failed")
|
|
p.db.LogAction(doc.ID, "failed", fmt.Sprintf("Tracker write error: %v", err))
|
|
return
|
|
}
|
|
|
|
// Mark as completed
|
|
p.db.MarkProcessed(doc.ID)
|
|
p.db.LogAction(doc.ID, "processed", "Successfully processed and added to tracker")
|
|
log.Printf("[pipeline] Successfully processed: %s", relPath)
|
|
}
|
|
|
|
func (p *Pipeline) writeToTracker(docPath string, extracted map[string]interface{}) error {
|
|
// Check if row exists for this document
|
|
existing, err := p.tools.FindRow(
|
|
p.cfg.Tracker.Path,
|
|
p.cfg.Tracker.Sheet,
|
|
p.cfg.Tracker.HeaderRow,
|
|
"Document Path",
|
|
docPath,
|
|
)
|
|
if err != nil {
|
|
// Row not found is OK - we'll add a new one
|
|
existing = &clients.ExcelFindResponse{Found: false}
|
|
}
|
|
|
|
now := time.Now().Format("02-01-2006 15:04:05")
|
|
|
|
// Build row data
|
|
row := map[string]interface{}{
|
|
"Document Path": docPath,
|
|
"Document Type": getStringField(extracted, "document_type"),
|
|
"Client Name": getStringField(extracted, "client_name"),
|
|
"Matter Reference": getStringField(extracted, "matter_reference"),
|
|
"TM Number": getStringField(extracted, "tm_number"),
|
|
"Trademark Name": getStringField(extracted, "trademark_name"),
|
|
"Trademark Class": getStringField(extracted, "trademark_class"),
|
|
"Filing Date": getStringField(extracted, "filing_date"),
|
|
"Response Deadline": getStringField(extracted, "response_deadline"),
|
|
"Hearing Date": getStringField(extracted, "hearing_date"),
|
|
"Amount": getStringField(extracted, "amount"),
|
|
"Currency": getStringField(extracted, "currency"),
|
|
"Status": getStringField(extracted, "status"),
|
|
"Last Updated": now,
|
|
"Update Source": "Pipeline",
|
|
"Processing Notes": "",
|
|
}
|
|
|
|
if existing.Found {
|
|
// Check if manually edited
|
|
if updateSource, ok := existing.Row["Update Source"].(string); ok && updateSource == "Manual" {
|
|
log.Printf("[pipeline] Skipping update (manually edited): %s", docPath)
|
|
return nil
|
|
}
|
|
|
|
// Update existing row
|
|
// Preserve First Processed
|
|
if fp, ok := existing.Row["First Processed"]; ok {
|
|
row["First Processed"] = fp
|
|
}
|
|
|
|
return p.tools.UpdateRow(
|
|
p.cfg.Tracker.Path,
|
|
p.cfg.Tracker.Sheet,
|
|
p.cfg.Tracker.HeaderRow,
|
|
existing.RowNum,
|
|
row,
|
|
)
|
|
}
|
|
|
|
// Add new row
|
|
row["First Processed"] = now
|
|
return p.tools.AddRow(
|
|
p.cfg.Tracker.Path,
|
|
p.cfg.Tracker.Sheet,
|
|
p.cfg.Tracker.HeaderRow,
|
|
row,
|
|
)
|
|
}
|
|
|
|
func (p *Pipeline) logError(path, msg string) {
|
|
errMsg := fmt.Sprintf("[%s] %s: %s", time.Now().Format("2006-01-02 15:04:05"), path, msg)
|
|
log.Printf("[pipeline] ERROR: %s", errMsg)
|
|
|
|
if p.errorLog != nil {
|
|
p.errorLog.WriteString(errMsg + "\n")
|
|
}
|
|
}
|
|
|
|
func detectDocType(ext string) string {
|
|
switch ext {
|
|
case ".pdf":
|
|
return "PDF document"
|
|
case ".docx", ".doc":
|
|
return "Word document"
|
|
case ".png", ".jpg", ".jpeg":
|
|
return "Image/screenshot"
|
|
default:
|
|
return "Document"
|
|
}
|
|
}
|
|
|
|
func getStringField(m map[string]interface{}, key string) string {
|
|
v, ok := m[key]
|
|
if !ok || v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|