Go orchestrator

This commit is contained in:
MangoPig
2026-02-21 23:52:55 +00:00
parent 6dce685906
commit 6fc84b20b6
15 changed files with 1857 additions and 46 deletions
+58 -7
View File
@@ -76,16 +76,67 @@ Rationale: De-risk unknowns (Docling OCR quality, Ollama CPU inference speed) be
- Response: `{ "response": "<json>" }` (strip `<think>...</think>` tags in post-processing)
- Note: Qwen3 includes reasoning by default; `/no_think` leaves empty tags, so strip instead
### 1c. Python Tools Service
### 1c. Python Tools Service (DONE)
- [ ] FastAPI + openpyxl service
- [ ] Endpoints: read tracker, write/update rows, get column schema
- [ ] Test with sample Tracker.xlsx
- [x] FastAPI + openpyxl service
- [x] Generic Excel API (not tracker-specific)
- [x] Test with Invoice_Schedule_2026Q1.xlsx
### 1d. Manual Integration Test
**Tools API Spec:**
- [ ] Chain test: PDF → Docling → Ollama → Tools → Excel updated
- [ ] Document any issues / adjustments needed
- Image: Custom Dockerfile (python:3.12-slim + fastapi + openpyxl)
- Port: `8000`
- Volume: `/data``/data/jingtian/BenjaminTeam`
**Endpoints:**
| Endpoint | Method | Description |
| -------------------- | ------ | ---------------------------- |
| `/health` | GET | Health check |
| `/excel/sheets` | GET | List sheet names in workbook |
| `/excel/schema` | GET | Get column headers |
| `/excel/read` | GET | Read all rows |
| `/excel/row/{n}` | GET | Get specific row (1-indexed) |
**Query Params:**
- `file_path` (required) — path relative to `/data/`
- `sheet_name` (required) — name of the sheet to read
- `header_row` (optional, default=1) — which row contains headers
**Tested:**
- Invoice_Schedule_2026Q1.xlsx with `header_row=4` — extracted 6 invoice rows ✅
- Columns: Client, Matter Ref, TM Number, Description, Amount (HKD), Due Date, Status
**Future Endpoints (Phase 2+):**
- `POST /excel/row` — Add new row
- `PUT /excel/row/{n}` — Update specific row
- `DELETE /excel/row/{n}` — Delete row
- `POST /excel/format` — Apply formatting
### 1d. Manual Integration Test (DONE)
- [x] Chain test: PDF → Docling → Ollama → JSON extraction
- [x] Chain test: PNG → Docling (Chinese OCR) → Ollama → JSON extraction
- [x] Chain test: DOCX → Docling → Ollama → JSON extraction
- [x] Chain test: XLSX → Tools API → Ollama → JSON summary
**Tested Files:**
| File | Type | Docling | Ollama | Key Extractions |
| --------------------------------- | ---- | ------- | ------ | ------------------------------------------------ |
| Filing_Receipt_307800905.pdf | PDF | ✅ | ✅ | TM#307800905, NOVA, Class 9, deadline 30-05-2026 |
| Email_Monee_...20260221.png | PNG | ✅ | ✅ | TM#306527151, 官藥坊, evidence of use |
| Letter_Re_TM306735835.docx | DOCX | ✅ | ✅ | TM#306735835, CALIFORNIA BABY, hearing 17-04-2026|
| Invoice_Schedule_2026Q1.xlsx | XLSX | ✅ | ✅ | HKD 474,000 total, 6 invoices, 3 sent/1 paid/2 draft |
**Notes:**
- Qwen3 1.7b correctly extracted Chinese trademark 官藥坊 from PNG
- All deadlines, TM numbers, client names extracted accurately
- Minor: XLSX currency guessed as USD (HKD in source) — fix with better prompting
## Phase 2: Go Orchestrator
+221 -39
View File
@@ -1,7 +1,7 @@
"""
Generic Excel API Service
A FastAPI service for reading Excel files (.xlsx).
A FastAPI service for reading and writing Excel files (.xlsx).
All file paths are relative to /data/ mount.
Endpoints:
@@ -10,26 +10,52 @@ Endpoints:
- GET /excel/schema - Get column headers
- GET /excel/read - Read all rows
- GET /excel/row/{row_num} - Get specific row (1-indexed)
- GET /excel/find - Find row by column value
- POST /excel/row - Add new row
- PUT /excel/row/{row_num} - Update row
- POST /excel/create - Create new workbook
"""
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
from pathlib import Path
from typing import Optional
from openpyxl import load_workbook
from typing import Optional, List, Any
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, Alignment
import os
app = FastAPI(
title="JingTian Tools Service",
description="Generic Excel API for reading xlsx files",
version="0.2.0",
description="Generic Excel API for reading and writing xlsx files",
version="0.3.0",
)
DATA_ROOT = Path(os.getenv("DATA_ROOT", "/data"))
def get_workbook(file_path: str):
"""Load workbook from file path relative to DATA_ROOT."""
# Request models
class AddRowRequest(BaseModel):
file_path: str
sheet_name: str
header_row: int = 1
row: dict[str, Any]
class UpdateRowRequest(BaseModel):
file_path: str
sheet_name: str
header_row: int = 1
row: dict[str, Any]
class CreateWorkbookRequest(BaseModel):
file_path: str
sheet_name: str
columns: List[str]
def get_workbook_readonly(file_path: str):
"""Load workbook from file path relative to DATA_ROOT (read-only)."""
full_path = DATA_ROOT / file_path
if not full_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
@@ -43,6 +69,21 @@ def get_workbook(file_path: str):
)
def get_workbook_writable(file_path: str):
"""Load workbook from file path relative to DATA_ROOT (writable)."""
full_path = DATA_ROOT / file_path
if not full_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
if not full_path.suffix.lower() == ".xlsx":
raise HTTPException(status_code=400, detail="Only .xlsx files supported")
try:
return load_workbook(full_path), full_path
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to open workbook: {str(e)}"
)
def get_sheet(wb, sheet_name: str):
"""Get sheet by name."""
if sheet_name not in wb.sheetnames:
@@ -60,9 +101,28 @@ def cell_to_str(cell_value) -> str:
return str(cell_value)
def get_headers(ws, header_row: int) -> list[str]:
"""Get headers from a worksheet."""
headers = []
for cell in ws[header_row]:
headers.append(cell_to_str(cell.value))
return headers
def get_column_index(headers: list[str], column_name: str) -> int:
"""Get 1-indexed column index for a header name."""
try:
return headers.index(column_name) + 1
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Column '{column_name}' not found. Available: {headers}",
)
@app.get("/health")
async def health():
return {"status": "healthy", "service": "tools", "version": "0.2.0"}
return {"status": "healthy", "service": "tools", "version": "0.3.0"}
@app.get("/excel/sheets")
@@ -70,7 +130,7 @@ async def list_sheets(
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
):
"""List all sheet names in the workbook."""
wb = get_workbook(file_path)
wb = get_workbook_readonly(file_path)
sheets = wb.sheetnames
wb.close()
return {"file_path": file_path, "sheets": sheets, "count": len(sheets)}
@@ -83,13 +143,9 @@ async def get_schema(
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
):
"""Get column headers from the specified sheet."""
wb = get_workbook(file_path)
wb = get_workbook_readonly(file_path)
ws = get_sheet(wb, sheet_name)
headers = []
for cell in ws[header_row]:
headers.append(cell_to_str(cell.value))
headers = get_headers(ws, header_row)
wb.close()
return {
"file_path": file_path,
@@ -112,29 +168,21 @@ async def read_all(
limit: Optional[int] = Query(None, description="Max rows to return"),
):
"""Read all rows from the specified sheet."""
wb = get_workbook(file_path)
wb = get_workbook_readonly(file_path)
ws = get_sheet(wb, sheet_name)
# Get headers
headers = [cell_to_str(cell.value) for cell in ws[header_row]]
# Determine data start row
headers = get_headers(ws, header_row)
data_start = start_row if start_row else header_row + 1
# Read rows
rows = []
row_count = 0
for row_num, row in enumerate(ws.iter_rows(min_row=data_start), start=data_start):
# Stop if limit reached
if limit and row_count >= limit:
break
# Skip completely empty rows
values = [cell_to_str(cell.value) for cell in row]
if all(v == "" for v in values):
continue
# Build row dict with headers as keys
row_data = {"_row_num": row_num}
for i, header in enumerate(headers):
if i < len(values):
@@ -165,13 +213,10 @@ async def get_row(
if row_num < 1:
raise HTTPException(status_code=400, detail="Row number must be >= 1")
wb = get_workbook(file_path)
wb = get_workbook_readonly(file_path)
ws = get_sheet(wb, sheet_name)
headers = get_headers(ws, header_row)
# Get headers
headers = [cell_to_str(cell.value) for cell in ws[header_row]]
# Get the specific row
try:
row = ws[row_num]
except Exception:
@@ -179,8 +224,6 @@ async def get_row(
raise HTTPException(status_code=404, detail=f"Row {row_num} not found")
values = [cell_to_str(cell.value) for cell in row]
# Build row dict
row_data = {"_row_num": row_num}
for i, header in enumerate(headers):
if i < len(values):
@@ -191,10 +234,149 @@ async def get_row(
return {"file_path": file_path, "sheet_name": sheet_name, "row": row_data}
# Future endpoints (Phase 2 & 3)
# POST /excel/row - Add new row
# PUT /excel/row/{row_num} - Update row
# DELETE /excel/row/{row_num} - Delete row
# POST /excel/column - Add column
# POST /excel/format - Apply formatting
# POST /excel/create - Create new workbook
@app.get("/excel/find")
async def find_row(
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
sheet_name: str = Query(..., description="Sheet name to search"),
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
column: str = Query(..., description="Column name to search"),
value: str = Query(..., description="Value to find"),
):
"""Find a row by column value."""
wb = get_workbook_readonly(file_path)
ws = get_sheet(wb, sheet_name)
headers = get_headers(ws, header_row)
col_idx = get_column_index(headers, column)
data_start = header_row + 1
for row_num, row in enumerate(ws.iter_rows(min_row=data_start), start=data_start):
values = [cell_to_str(cell.value) for cell in row]
if col_idx - 1 < len(values) and values[col_idx - 1] == value:
row_data = {"_row_num": row_num}
for i, header in enumerate(headers):
if i < len(values):
key = header if header else f"_col_{i + 1}"
row_data[key] = values[i]
wb.close()
return {
"found": True,
"row_num": row_num,
"row": row_data,
"message": f"Found at row {row_num}",
}
wb.close()
raise HTTPException(
status_code=404,
detail=f"No row found with {column}='{value}'",
)
@app.post("/excel/row")
async def add_row(request: AddRowRequest):
"""Add a new row to the sheet."""
wb, full_path = get_workbook_writable(request.file_path)
ws = get_sheet(wb, request.sheet_name)
headers = get_headers(ws, request.header_row)
# Find next empty row
next_row = ws.max_row + 1
# Write values
for col_name, value in request.row.items():
if col_name.startswith("_"):
continue
try:
col_idx = headers.index(col_name) + 1
ws.cell(row=next_row, column=col_idx, value=value)
except ValueError:
pass # Skip unknown columns
wb.save(full_path)
wb.close()
return {
"success": True,
"row_num": next_row,
"message": f"Added row at {next_row}",
}
@app.put("/excel/row/{row_num}")
async def update_row(row_num: int, request: UpdateRowRequest):
"""Update an existing row."""
if row_num < 1:
raise HTTPException(status_code=400, detail="Row number must be >= 1")
wb, full_path = get_workbook_writable(request.file_path)
ws = get_sheet(wb, request.sheet_name)
headers = get_headers(ws, request.header_row)
# Check row exists
if row_num > ws.max_row:
wb.close()
raise HTTPException(status_code=404, detail=f"Row {row_num} not found")
# Update values
for col_name, value in request.row.items():
if col_name.startswith("_"):
continue
try:
col_idx = headers.index(col_name) + 1
ws.cell(row=row_num, column=col_idx, value=value)
except ValueError:
pass # Skip unknown columns
wb.save(full_path)
wb.close()
return {
"success": True,
"row_num": row_num,
"message": f"Updated row {row_num}",
}
@app.post("/excel/create")
async def create_workbook(request: CreateWorkbookRequest):
"""Create a new workbook with specified columns."""
full_path = DATA_ROOT / request.file_path
# Create parent directories if needed
full_path.parent.mkdir(parents=True, exist_ok=True)
# Check if file exists
if full_path.exists():
raise HTTPException(
status_code=409,
detail=f"File already exists: {request.file_path}",
)
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = request.sheet_name
# Write headers with formatting
header_font = Font(bold=True)
for col_idx, col_name in enumerate(request.columns, start=1):
cell = ws.cell(row=1, column=col_idx, value=col_name)
cell.font = header_font
cell.alignment = Alignment(horizontal="center")
# Auto-adjust column widths
for col_idx, col_name in enumerate(request.columns, start=1):
ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = max(
len(col_name) + 2, 12
)
wb.save(full_path)
wb.close()
return {
"success": True,
"file_path": request.file_path,
"sheet_name": request.sheet_name,
"columns": request.columns,
"message": f"Created workbook with {len(request.columns)} columns",
}
+31
View File
@@ -0,0 +1,31 @@
FROM golang:1.22-alpine AS builder
# Install build dependencies (for sqlite)
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Build with CGO enabled (required for sqlite)
RUN CGO_ENABLED=1 go build -o tracker .
# Runtime image
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata curl
WORKDIR /app
COPY --from=builder /app/tracker .
# Config is mounted at runtime via docker-compose volume
# Default config path: /app/config.yaml
ENTRYPOINT ["./tracker"]
CMD ["-config", "/app/config.yaml"]
+135
View File
@@ -0,0 +1,135 @@
// Path: Code/Tracker/clients/docling.go
package clients
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type DoclingClient struct {
baseURL string
httpClient *http.Client
}
type DoclingResponse struct {
Document struct {
MdContent string `json:"md_content"`
} `json:"document"`
Status string `json:"status"`
ProcessingTime float64 `json:"processing_time"`
}
func NewDoclingClient(baseURL string) *DoclingClient {
return &DoclingClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{
Timeout: 5 * time.Minute, // OCR can take a while
},
}
}
func (c *DoclingClient) Health() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("docling unhealthy: status %d", resp.StatusCode)
}
return nil
}
func (c *DoclingClient) Convert(filePath string) (string, error) {
// Open file
f, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
// Create multipart form
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Add file
part, err := writer.CreateFormFile("files", filepath.Base(filePath))
if err != nil {
return "", fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, f); err != nil {
return "", fmt.Errorf("failed to copy file: %w", err)
}
// Add options
writer.WriteField("to_formats", "md")
writer.WriteField("do_ocr", "true")
// For images, force OCR
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" {
writer.WriteField("force_ocr", "true")
writer.WriteField("ocr_lang", "en")
writer.WriteField("ocr_lang", "ch_tra")
}
writer.Close()
// Make request
req, err := http.NewRequest("POST", c.baseURL+"/v1/convert/file", &buf)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("docling error: status %d, body: %s", resp.StatusCode, string(body))
}
// Parse response
var result DoclingResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
return result.Document.MdContent, nil
}
func getMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".pdf":
return "application/pdf"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".doc":
return "application/msword"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".xls":
return "application/vnd.ms-excel"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
default:
return "application/octet-stream"
}
}
+165
View File
@@ -0,0 +1,165 @@
// Path: Code/Tracker/clients/ollama.go
package clients
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
type OllamaClient struct {
baseURL string
model string
temperature float64
httpClient *http.Client
}
type OllamaRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Options OllamaOptions `json:"options"`
}
type OllamaOptions struct {
Temperature float64 `json:"temperature"`
}
type OllamaResponse struct {
Response string `json:"response"`
Done bool `json:"done"`
}
func NewOllamaClient(baseURL, model string, temperature float64) *OllamaClient {
return &OllamaClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
model: model,
temperature: temperature,
httpClient: &http.Client{
Timeout: 5 * time.Minute, // LLM inference can take a while on CPU
},
}
}
func (c *OllamaClient) Health() error {
resp, err := c.httpClient.Get(c.baseURL + "/api/tags")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ollama unhealthy: status %d", resp.StatusCode)
}
return nil
}
func (c *OllamaClient) Extract(documentContent, documentType string) (map[string]interface{}, error) {
prompt := buildExtractionPrompt(documentContent, documentType)
reqBody := OllamaRequest{
Model: c.model,
Prompt: prompt,
Stream: false,
Options: OllamaOptions{
Temperature: c.temperature,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := c.httpClient.Post(c.baseURL+"/api/generate", "application/json", bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama error: status %d, body: %s", resp.StatusCode, string(body))
}
var result OllamaResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Clean up response - remove <think> tags
response := cleanResponse(result.Response)
// Parse JSON from response
extracted, err := parseJSON(response)
if err != nil {
return nil, fmt.Errorf("failed to parse JSON from response: %w", err)
}
return extracted, nil
}
func buildExtractionPrompt(content, docType string) string {
return fmt.Sprintf(`/no_think
Extract structured information from this document as JSON. Include ALL fields that are present.
Required fields (include if found):
- document_type: The type of document (e.g., "Filing Receipt", "Legal Letter", "Email", "Invoice", "Memo")
- client_name: Client or applicant name
- matter_reference: Matter/case reference number (e.g., "JT/2026/1234")
- tm_number: Trademark application number
- trademark_name: Name of the trademark
- trademark_class: Trademark class number(s)
- filing_date: Date of filing (format: DD-MM-YYYY)
- response_deadline: Deadline for response (format: DD-MM-YYYY)
- hearing_date: Hearing date if mentioned (format: DD-MM-YYYY)
- amount: Invoice/fee amount (number only)
- currency: Currency code (e.g., "HKD", "USD")
- status: Document or payment status
- sender_name: Name of sender/author
- recipient_name: Name of recipient
- subject: Email/letter subject
- summary: Brief 1-2 sentence summary of the document
Document type hint: %s
Document content:
%s
Respond ONLY with valid JSON. No explanation.`, docType, content)
}
func cleanResponse(response string) string {
// Remove <think>...</think> tags
re := regexp.MustCompile(`(?s)<think>.*?</think>`)
response = re.ReplaceAllString(response, "")
return strings.TrimSpace(response)
}
func parseJSON(response string) (map[string]interface{}, error) {
// Try to find JSON in the response
response = strings.TrimSpace(response)
// Find JSON object boundaries
start := strings.Index(response, "{")
end := strings.LastIndex(response, "}")
if start == -1 || end == -1 || end < start {
return nil, fmt.Errorf("no JSON object found in response")
}
jsonStr := response[start : end+1]
var result map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return result, nil
}
+216
View File
@@ -0,0 +1,216 @@
// Path: Code/Tracker/clients/tools.go
package clients
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type ToolsClient struct {
baseURL string
httpClient *http.Client
}
type ExcelReadResponse struct {
FilePath string `json:"file_path"`
SheetName string `json:"sheet_name"`
HeaderRow int `json:"header_row"`
Columns []string `json:"columns"`
RowCount int `json:"row_count"`
Rows []map[string]interface{} `json:"rows"`
}
type ExcelFindResponse struct {
Found bool `json:"found"`
RowNum int `json:"row_num"`
Row map[string]interface{} `json:"row"`
Message string `json:"message"`
}
type ExcelWriteRequest struct {
FilePath string `json:"file_path"`
SheetName string `json:"sheet_name"`
HeaderRow int `json:"header_row"`
Row map[string]interface{} `json:"row"`
}
type ExcelUpdateRequest struct {
FilePath string `json:"file_path"`
SheetName string `json:"sheet_name"`
HeaderRow int `json:"header_row"`
RowNum int `json:"row_num"`
Row map[string]interface{} `json:"row"`
}
func NewToolsClient(baseURL string) *ToolsClient {
return &ToolsClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *ToolsClient) Health() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tools unhealthy: status %d", resp.StatusCode)
}
return nil
}
func (c *ToolsClient) ReadExcel(filePath, sheetName string, headerRow int) (*ExcelReadResponse, error) {
params := url.Values{}
params.Set("file_path", filePath)
params.Set("sheet_name", sheetName)
params.Set("header_row", strconv.Itoa(headerRow))
resp, err := c.httpClient.Get(c.baseURL + "/excel/read?" + params.Encode())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("tools error: status %d, body: %s", resp.StatusCode, string(body))
}
var result ExcelReadResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (c *ToolsClient) FindRow(filePath, sheetName string, headerRow int, column, value string) (*ExcelFindResponse, error) {
params := url.Values{}
params.Set("file_path", filePath)
params.Set("sheet_name", sheetName)
params.Set("header_row", strconv.Itoa(headerRow))
params.Set("column", column)
params.Set("value", value)
resp, err := c.httpClient.Get(c.baseURL + "/excel/find?" + params.Encode())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return &ExcelFindResponse{Found: false}, nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("tools error: status %d, body: %s", resp.StatusCode, string(body))
}
var result ExcelFindResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (c *ToolsClient) AddRow(filePath, sheetName string, headerRow int, row map[string]interface{}) error {
reqBody := ExcelWriteRequest{
FilePath: filePath,
SheetName: sheetName,
HeaderRow: headerRow,
Row: row,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := c.httpClient.Post(c.baseURL+"/excel/row", "application/json", bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("tools error: status %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *ToolsClient) UpdateRow(filePath, sheetName string, headerRow, rowNum int, row map[string]interface{}) error {
reqBody := ExcelUpdateRequest{
FilePath: filePath,
SheetName: sheetName,
HeaderRow: headerRow,
RowNum: rowNum,
Row: row,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PUT", c.baseURL+"/excel/row/"+strconv.Itoa(rowNum), bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("tools error: status %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *ToolsClient) CreateTracker(filePath, sheetName string, columns []string) error {
reqBody := map[string]interface{}{
"file_path": filePath,
"sheet_name": sheetName,
"columns": columns,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := c.httpClient.Post(c.baseURL+"/excel/create", "application/json", bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("tools error: status %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
watch:
root: /data
extensions:
- .pdf
- .docx
- .doc
- .png
- .jpg
- .jpeg
exclude:
- _LLM
- .git
- "~$"
stability_seconds: 120
services:
# Use Docker container names when running in Docker Compose
# Use localhost URLs when running standalone
docling: http://docling:5001
ollama: http://ollama:11434
tools: http://tools:8000
ollama:
model: qwen3:1.7b
temperature: 0
tracker:
path: Admin/Tracker.xlsx
sheet: Tracker
header_row: 1
database:
path: /data/_LLM/state.db
logging:
level: info
error_file: /data/_LLM/errors.log
+73
View File
@@ -0,0 +1,73 @@
// Path: Code/Tracker/config/config.go
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Watch WatchConfig `yaml:"watch"`
Services ServicesConfig `yaml:"services"`
Ollama OllamaConfig `yaml:"ollama"`
Tracker TrackerConfig `yaml:"tracker"`
Database DatabaseConfig `yaml:"database"`
Logging LoggingConfig `yaml:"logging"`
}
type WatchConfig struct {
Root string `yaml:"root"`
Extensions []string `yaml:"extensions"`
Exclude []string `yaml:"exclude"`
StabilitySeconds int `yaml:"stability_seconds"`
}
type ServicesConfig struct {
Docling string `yaml:"docling"`
Ollama string `yaml:"ollama"`
Tools string `yaml:"tools"`
}
type OllamaConfig struct {
Model string `yaml:"model"`
Temperature float64 `yaml:"temperature"`
}
type TrackerConfig struct {
Path string `yaml:"path"`
Sheet string `yaml:"sheet"`
HeaderRow int `yaml:"header_row"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
ErrorFile string `yaml:"error_file"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Set defaults
if cfg.Watch.StabilitySeconds == 0 {
cfg.Watch.StabilitySeconds = 120
}
if cfg.Tracker.HeaderRow == 0 {
cfg.Tracker.HeaderRow = 1
}
return &cfg, nil
}
+228
View File
@@ -0,0 +1,228 @@
// Path: Code/Tracker/db/sqlite.go
package db
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
type Document struct {
ID int64
FilePath string
FileHash string
FileSize int64
FirstSeen time.Time
LastModified time.Time
LastProcessed sql.NullTime
Status string // pending, processing, completed, failed
}
type Extraction struct {
ID int64
DocumentID int64
ExtractionJSON string
ExtractedAt time.Time
}
type ProcessingLog struct {
ID int64
DocumentID int64
Action string // processed, updated, skipped, failed
Details string
CreatedAt time.Time
}
func Open(dbPath string) (*DB, error) {
// Ensure directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
if err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
conn.Close()
return nil, err
}
return db, nil
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT UNIQUE NOT NULL,
file_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_modified TIMESTAMP NOT NULL,
last_processed TIMESTAMP,
status TEXT DEFAULT 'pending'
);
CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(file_path);
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
CREATE TABLE IF NOT EXISTS extractions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id),
extraction_json TEXT NOT NULL,
extracted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_extractions_doc ON extractions(document_id);
CREATE TABLE IF NOT EXISTS processing_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id),
action TEXT NOT NULL,
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_log_doc ON processing_log(document_id);
`
_, err := db.conn.Exec(schema)
return err
}
// GetDocument retrieves a document by file path
func (db *DB) GetDocument(filePath string) (*Document, error) {
row := db.conn.QueryRow(`
SELECT id, file_path, file_hash, file_size, first_seen, last_modified, last_processed, status
FROM documents WHERE file_path = ?
`, filePath)
var doc Document
err := row.Scan(&doc.ID, &doc.FilePath, &doc.FileHash, &doc.FileSize,
&doc.FirstSeen, &doc.LastModified, &doc.LastProcessed, &doc.Status)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &doc, nil
}
// UpsertDocument creates or updates a document record
func (db *DB) UpsertDocument(filePath, fileHash string, fileSize int64, lastModified time.Time) (*Document, error) {
existing, err := db.GetDocument(filePath)
if err != nil {
return nil, err
}
if existing == nil {
// Insert new
result, err := db.conn.Exec(`
INSERT INTO documents (file_path, file_hash, file_size, last_modified, status)
VALUES (?, ?, ?, ?, 'pending')
`, filePath, fileHash, fileSize, lastModified)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return db.getDocumentByID(id)
}
// Update existing
_, err = db.conn.Exec(`
UPDATE documents SET file_hash = ?, file_size = ?, last_modified = ?, status = 'pending'
WHERE id = ?
`, fileHash, fileSize, lastModified, existing.ID)
if err != nil {
return nil, err
}
return db.getDocumentByID(existing.ID)
}
func (db *DB) getDocumentByID(id int64) (*Document, error) {
row := db.conn.QueryRow(`
SELECT id, file_path, file_hash, file_size, first_seen, last_modified, last_processed, status
FROM documents WHERE id = ?
`, id)
var doc Document
err := row.Scan(&doc.ID, &doc.FilePath, &doc.FileHash, &doc.FileSize,
&doc.FirstSeen, &doc.LastModified, &doc.LastProcessed, &doc.Status)
if err != nil {
return nil, err
}
return &doc, nil
}
// UpdateStatus updates the status of a document
func (db *DB) UpdateStatus(docID int64, status string) error {
_, err := db.conn.Exec(`UPDATE documents SET status = ? WHERE id = ?`, status, docID)
return err
}
// MarkProcessed marks a document as successfully processed
func (db *DB) MarkProcessed(docID int64) error {
_, err := db.conn.Exec(`
UPDATE documents SET status = 'completed', last_processed = CURRENT_TIMESTAMP
WHERE id = ?
`, docID)
return err
}
// SaveExtraction saves the extraction result
func (db *DB) SaveExtraction(docID int64, extractionJSON map[string]interface{}) error {
jsonBytes, err := json.Marshal(extractionJSON)
if err != nil {
return err
}
_, err = db.conn.Exec(`
INSERT INTO extractions (document_id, extraction_json) VALUES (?, ?)
`, docID, string(jsonBytes))
return err
}
// LogAction logs a processing action
func (db *DB) LogAction(docID int64, action, details string) error {
_, err := db.conn.Exec(`
INSERT INTO processing_log (document_id, action, details) VALUES (?, ?, ?)
`, docID, action, details)
return err
}
// NeedsProcessing checks if a document needs processing based on hash
func (db *DB) NeedsProcessing(filePath, currentHash string) (bool, error) {
doc, err := db.GetDocument(filePath)
if err != nil {
return false, err
}
if doc == nil {
return true, nil // New file
}
if doc.FileHash != currentHash {
return true, nil // File changed
}
if doc.Status == "failed" {
return true, nil // Retry failed
}
return false, nil // Already processed with same hash
}
+11
View File
@@ -0,0 +1,11 @@
module github.com/goko/jingtian-tracker
go 1.22
require (
github.com/fsnotify/fsnotify v1.7.0
github.com/mattn/go-sqlite3 v1.14.22
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.4.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"github.com/goko/jingtian-tracker/config"
"github.com/goko/jingtian-tracker/db"
"github.com/goko/jingtian-tracker/pipeline"
"github.com/goko/jingtian-tracker/watcher"
)
func main() {
// Parse flags
configPath := flag.String("config", "config.yaml", "Path to config file")
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("JingTian Document Tracker starting...")
// Load config
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
log.Printf("Config loaded: watching %s", cfg.Watch.Root)
// Initialize database
database, err := db.Open(cfg.Database.Path)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer database.Close()
log.Printf("Database initialized: %s", cfg.Database.Path)
// Initialize watcher
w, err := watcher.New(cfg)
if err != nil {
log.Fatalf("Failed to create watcher: %v", err)
}
// Initialize pipeline
p, err := pipeline.New(cfg, database, w)
if err != nil {
log.Fatalf("Failed to create pipeline: %v", err)
}
defer p.Close()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("Shutdown signal received, stopping...")
w.Stop()
}()
// Run pipeline
if err := p.Run(); err != nil {
log.Fatalf("Pipeline error: %v", err)
}
log.Println("JingTian Document Tracker stopped.")
}
+341
View File
@@ -0,0 +1,341 @@
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": getField(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 {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}
func getField(m map[string]interface{}, key string) interface{} {
if v, ok := m[key]; ok {
return v
}
return ""
}
+241
View File
@@ -0,0 +1,241 @@
package watcher
import (
"crypto/sha256"
"encoding/hex"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/goko/jingtian-tracker/config"
)
type FileEvent struct {
Path string
Hash string
Size int64
ModTime time.Time
}
type Watcher struct {
cfg *config.Config
watcher *fsnotify.Watcher
pending map[string]time.Time // path -> first seen time
pendingMu sync.Mutex
Events chan FileEvent
done chan struct{}
}
func New(cfg *config.Config) (*Watcher, error) {
fsWatcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
w := &Watcher{
cfg: cfg,
watcher: fsWatcher,
pending: make(map[string]time.Time),
Events: make(chan FileEvent, 100),
done: make(chan struct{}),
}
return w, nil
}
func (w *Watcher) Start() error {
// Add all directories recursively
err := filepath.Walk(w.cfg.Watch.Root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
}
if info.IsDir() {
// Check exclusions
for _, excl := range w.cfg.Watch.Exclude {
if strings.Contains(path, excl) {
return filepath.SkipDir
}
}
return w.watcher.Add(path)
}
return nil
})
if err != nil {
return err
}
// Start event loop
go w.eventLoop()
// Start stability checker
go w.stabilityChecker()
log.Printf("[watcher] Started watching: %s", w.cfg.Watch.Root)
return nil
}
func (w *Watcher) Stop() {
close(w.done)
w.watcher.Close()
close(w.Events)
}
func (w *Watcher) eventLoop() {
for {
select {
case <-w.done:
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Handle directory creation - add to watch
if event.Op&fsnotify.Create != 0 {
info, err := os.Stat(event.Name)
if err == nil && info.IsDir() {
// Check exclusions
excluded := false
for _, excl := range w.cfg.Watch.Exclude {
if strings.Contains(event.Name, excl) {
excluded = true
break
}
}
if !excluded {
w.watcher.Add(event.Name)
log.Printf("[watcher] Added directory: %s", event.Name)
}
continue
}
}
// Handle file create/write
if event.Op&(fsnotify.Create|fsnotify.Write) != 0 {
if w.isWatchedFile(event.Name) {
w.pendingMu.Lock()
if _, exists := w.pending[event.Name]; !exists {
w.pending[event.Name] = time.Now()
log.Printf("[watcher] File detected, starting stability check: %s", event.Name)
} else {
// File was modified again, reset timer
w.pending[event.Name] = time.Now()
}
w.pendingMu.Unlock()
}
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("[watcher] Error: %v", err)
}
}
}
func (w *Watcher) stabilityChecker() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-w.done:
return
case <-ticker.C:
w.checkStability()
}
}
}
func (w *Watcher) checkStability() {
w.pendingMu.Lock()
defer w.pendingMu.Unlock()
stabilityDuration := time.Duration(w.cfg.Watch.StabilitySeconds) * time.Second
now := time.Now()
for path, firstSeen := range w.pending {
// Check if file has been stable long enough
if now.Sub(firstSeen) < stabilityDuration {
continue
}
// Get current file info
info, err := os.Stat(path)
if err != nil {
// File was deleted
delete(w.pending, path)
continue
}
// Calculate hash
hash, err := hashFile(path)
if err != nil {
log.Printf("[watcher] Error hashing file %s: %v", path, err)
delete(w.pending, path)
continue
}
// Emit event
event := FileEvent{
Path: path,
Hash: hash,
Size: info.Size(),
ModTime: info.ModTime(),
}
select {
case w.Events <- event:
log.Printf("[watcher] File stable, emitting event: %s", path)
default:
log.Printf("[watcher] Event channel full, dropping: %s", path)
}
delete(w.pending, path)
}
}
func (w *Watcher) isWatchedFile(path string) bool {
// Check exclusions
for _, excl := range w.cfg.Watch.Exclude {
if strings.Contains(path, excl) {
return false
}
}
// Check extension
ext := strings.ToLower(filepath.Ext(path))
for _, watchExt := range w.cfg.Watch.Extensions {
if ext == strings.ToLower(watchExt) {
return true
}
}
return false
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// HashFile exports the hash function for use by other packages
func HashFile(path string) (string, error) {
return hashFile(path)
}
+22
View File
@@ -76,6 +76,28 @@ services:
retries: 3
start_period: 10s
# Tracker (Go Orchestrator - Document Processing Pipeline)
tracker:
build: ./Tracker
container_name: jt-tracker
restart: unless-stopped
volumes:
- /data/jingtian/BenjaminTeam:/data:rw
- ./Tracker/config.yaml.example:/app/config.yaml:ro
depends_on:
docling:
condition: service_healthy
ollama:
condition: service_healthy
tools:
condition: service_healthy
healthcheck:
test: ["CMD", "pgrep", "-f", "tracker"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
docling_models:
name: jt-docling-models