Go orchestrator
This commit is contained in:
+58
-7
@@ -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)
|
- Response: `{ "response": "<json>" }` (strip `<think>...</think>` tags in post-processing)
|
||||||
- Note: Qwen3 includes reasoning by default; `/no_think` leaves empty tags, so strip instead
|
- 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
|
- [x] FastAPI + openpyxl service
|
||||||
- [ ] Endpoints: read tracker, write/update rows, get column schema
|
- [x] Generic Excel API (not tracker-specific)
|
||||||
- [ ] Test with sample Tracker.xlsx
|
- [x] Test with Invoice_Schedule_2026Q1.xlsx
|
||||||
|
|
||||||
### 1d. Manual Integration Test
|
**Tools API Spec:**
|
||||||
|
|
||||||
- [ ] Chain test: PDF → Docling → Ollama → Tools → Excel updated
|
- Image: Custom Dockerfile (python:3.12-slim + fastapi + openpyxl)
|
||||||
- [ ] Document any issues / adjustments needed
|
- 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
|
## Phase 2: Go Orchestrator
|
||||||
|
|
||||||
|
|||||||
+221
-39
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Generic Excel API Service
|
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.
|
All file paths are relative to /data/ mount.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
@@ -10,26 +10,52 @@ Endpoints:
|
|||||||
- GET /excel/schema - Get column headers
|
- GET /excel/schema - Get column headers
|
||||||
- GET /excel/read - Read all rows
|
- GET /excel/read - Read all rows
|
||||||
- GET /excel/row/{row_num} - Get specific row (1-indexed)
|
- 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 fastapi import FastAPI, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, List, Any
|
||||||
from openpyxl import load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.styles import Font, Alignment
|
||||||
import os
|
import os
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="JingTian Tools Service",
|
title="JingTian Tools Service",
|
||||||
description="Generic Excel API for reading xlsx files",
|
description="Generic Excel API for reading and writing xlsx files",
|
||||||
version="0.2.0",
|
version="0.3.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
DATA_ROOT = Path(os.getenv("DATA_ROOT", "/data"))
|
DATA_ROOT = Path(os.getenv("DATA_ROOT", "/data"))
|
||||||
|
|
||||||
|
|
||||||
def get_workbook(file_path: str):
|
# Request models
|
||||||
"""Load workbook from file path relative to DATA_ROOT."""
|
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
|
full_path = DATA_ROOT / file_path
|
||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
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):
|
def get_sheet(wb, sheet_name: str):
|
||||||
"""Get sheet by name."""
|
"""Get sheet by name."""
|
||||||
if sheet_name not in wb.sheetnames:
|
if sheet_name not in wb.sheetnames:
|
||||||
@@ -60,9 +101,28 @@ def cell_to_str(cell_value) -> str:
|
|||||||
return str(cell_value)
|
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")
|
@app.get("/health")
|
||||||
async def 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")
|
@app.get("/excel/sheets")
|
||||||
@@ -70,7 +130,7 @@ async def list_sheets(
|
|||||||
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
||||||
):
|
):
|
||||||
"""List all sheet names in the workbook."""
|
"""List all sheet names in the workbook."""
|
||||||
wb = get_workbook(file_path)
|
wb = get_workbook_readonly(file_path)
|
||||||
sheets = wb.sheetnames
|
sheets = wb.sheetnames
|
||||||
wb.close()
|
wb.close()
|
||||||
return {"file_path": file_path, "sheets": sheets, "count": len(sheets)}
|
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)"),
|
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
|
||||||
):
|
):
|
||||||
"""Get column headers from the specified sheet."""
|
"""Get column headers from the specified sheet."""
|
||||||
wb = get_workbook(file_path)
|
wb = get_workbook_readonly(file_path)
|
||||||
ws = get_sheet(wb, sheet_name)
|
ws = get_sheet(wb, sheet_name)
|
||||||
|
headers = get_headers(ws, header_row)
|
||||||
headers = []
|
|
||||||
for cell in ws[header_row]:
|
|
||||||
headers.append(cell_to_str(cell.value))
|
|
||||||
|
|
||||||
wb.close()
|
wb.close()
|
||||||
return {
|
return {
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
@@ -112,29 +168,21 @@ async def read_all(
|
|||||||
limit: Optional[int] = Query(None, description="Max rows to return"),
|
limit: Optional[int] = Query(None, description="Max rows to return"),
|
||||||
):
|
):
|
||||||
"""Read all rows from the specified sheet."""
|
"""Read all rows from the specified sheet."""
|
||||||
wb = get_workbook(file_path)
|
wb = get_workbook_readonly(file_path)
|
||||||
ws = get_sheet(wb, sheet_name)
|
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]]
|
|
||||||
|
|
||||||
# Determine data start row
|
|
||||||
data_start = start_row if start_row else header_row + 1
|
data_start = start_row if start_row else header_row + 1
|
||||||
|
|
||||||
# Read rows
|
|
||||||
rows = []
|
rows = []
|
||||||
row_count = 0
|
row_count = 0
|
||||||
for row_num, row in enumerate(ws.iter_rows(min_row=data_start), start=data_start):
|
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:
|
if limit and row_count >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Skip completely empty rows
|
|
||||||
values = [cell_to_str(cell.value) for cell in row]
|
values = [cell_to_str(cell.value) for cell in row]
|
||||||
if all(v == "" for v in values):
|
if all(v == "" for v in values):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build row dict with headers as keys
|
|
||||||
row_data = {"_row_num": row_num}
|
row_data = {"_row_num": row_num}
|
||||||
for i, header in enumerate(headers):
|
for i, header in enumerate(headers):
|
||||||
if i < len(values):
|
if i < len(values):
|
||||||
@@ -165,13 +213,10 @@ async def get_row(
|
|||||||
if row_num < 1:
|
if row_num < 1:
|
||||||
raise HTTPException(status_code=400, detail="Row number must be >= 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)
|
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:
|
try:
|
||||||
row = ws[row_num]
|
row = ws[row_num]
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -179,8 +224,6 @@ async def get_row(
|
|||||||
raise HTTPException(status_code=404, detail=f"Row {row_num} not found")
|
raise HTTPException(status_code=404, detail=f"Row {row_num} not found")
|
||||||
|
|
||||||
values = [cell_to_str(cell.value) for cell in row]
|
values = [cell_to_str(cell.value) for cell in row]
|
||||||
|
|
||||||
# Build row dict
|
|
||||||
row_data = {"_row_num": row_num}
|
row_data = {"_row_num": row_num}
|
||||||
for i, header in enumerate(headers):
|
for i, header in enumerate(headers):
|
||||||
if i < len(values):
|
if i < len(values):
|
||||||
@@ -191,10 +234,149 @@ async def get_row(
|
|||||||
return {"file_path": file_path, "sheet_name": sheet_name, "row": row_data}
|
return {"file_path": file_path, "sheet_name": sheet_name, "row": row_data}
|
||||||
|
|
||||||
|
|
||||||
# Future endpoints (Phase 2 & 3)
|
@app.get("/excel/find")
|
||||||
# POST /excel/row - Add new row
|
async def find_row(
|
||||||
# PUT /excel/row/{row_num} - Update row
|
file_path: str = Query(..., description="Path to xlsx file relative to /data/"),
|
||||||
# DELETE /excel/row/{row_num} - Delete row
|
sheet_name: str = Query(..., description="Sheet name to search"),
|
||||||
# POST /excel/column - Add column
|
header_row: int = Query(1, description="Row number containing headers (1-indexed)"),
|
||||||
# POST /excel/format - Apply formatting
|
column: str = Query(..., description="Column name to search"),
|
||||||
# POST /excel/create - Create new workbook
|
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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -76,6 +76,28 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
docling_models:
|
docling_models:
|
||||||
name: jt-docling-models
|
name: jt-docling-models
|
||||||
|
|||||||
Reference in New Issue
Block a user