diff --git a/Code/TODO.md b/Code/TODO.md index 1505c44..a689643 100644 --- a/Code/TODO.md +++ b/Code/TODO.md @@ -49,7 +49,15 @@ Rationale: De-risk unknowns (Docling OCR quality, Ollama CPU inference speed) be **Tested Formats:** - PDF (Filing Receipt) — extracted text, tables, CJK content ✅ -- PNG (Email screenshot) — extracted subject, dates, recipient ✅ (CJK trademark garbled, may need `ocr_lang`) +- PNG (Email screenshot) — extracted subject, dates, recipient ✅ +- PNG with `ocr_lang=ch_tra` — Traditional Chinese extracted correctly (官藥坊) ✅ + +**Portable Chinese OCR:** + +- Auto-downloads `ch_sim` (Simplified) and `ch_tra` (Traditional) models on first run +- Models persist in `docling_models` volume +- First run takes ~1-2 min extra for model download; subsequent runs instant +- Use `ocr_lang=ch_tra` for HK/Taiwan, `ocr_lang=ch_sim` for Mainland China ### 1b. Ollama (DONE) diff --git a/Code/Tools/Service/Dockerfile b/Code/Tools/Service/Dockerfile new file mode 100644 index 0000000..18ed63f --- /dev/null +++ b/Code/Tools/Service/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Code/Tools/Service/main.py b/Code/Tools/Service/main.py new file mode 100644 index 0000000..512dbcd --- /dev/null +++ b/Code/Tools/Service/main.py @@ -0,0 +1,200 @@ +""" +Generic Excel API Service + +A FastAPI service for reading Excel files (.xlsx). +All file paths are relative to /data/ mount. + +Endpoints: +- GET /health - Health check +- GET /excel/sheets - List all sheet names +- GET /excel/schema - Get column headers +- GET /excel/read - Read all rows +- GET /excel/row/{row_num} - Get specific row (1-indexed) +""" + +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel +from pathlib import Path +from typing import Optional +from openpyxl import load_workbook +import os + +app = FastAPI( + title="JingTian Tools Service", + description="Generic Excel API for reading xlsx files", + version="0.2.0", +) + +DATA_ROOT = Path(os.getenv("DATA_ROOT", "/data")) + + +def get_workbook(file_path: str): + """Load workbook from file path relative to DATA_ROOT.""" + 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, read_only=True, data_only=True) + 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: + raise HTTPException( + status_code=404, + detail=f"Sheet '{sheet_name}' not found. Available: {wb.sheetnames}", + ) + return wb[sheet_name] + + +def cell_to_str(cell_value) -> str: + """Convert cell value to string, empty string for None.""" + if cell_value is None: + return "" + return str(cell_value) + + +@app.get("/health") +async def health(): + return {"status": "healthy", "service": "tools", "version": "0.2.0"} + + +@app.get("/excel/sheets") +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) + sheets = wb.sheetnames + wb.close() + return {"file_path": file_path, "sheets": sheets, "count": len(sheets)} + + +@app.get("/excel/schema") +async def get_schema( + file_path: str = Query(..., description="Path to xlsx file relative to /data/"), + sheet_name: str = Query(..., description="Sheet name to read"), + header_row: int = Query(1, description="Row number containing headers (1-indexed)"), +): + """Get column headers from the specified sheet.""" + wb = get_workbook(file_path) + ws = get_sheet(wb, sheet_name) + + headers = [] + for cell in ws[header_row]: + headers.append(cell_to_str(cell.value)) + + wb.close() + return { + "file_path": file_path, + "sheet_name": sheet_name, + "header_row": header_row, + "columns": headers, + "column_count": len(headers), + } + + +@app.get("/excel/read") +async def read_all( + file_path: str = Query(..., description="Path to xlsx file relative to /data/"), + sheet_name: str = Query(..., description="Sheet name to read"), + header_row: int = Query(1, description="Row number containing headers (1-indexed)"), + start_row: Optional[int] = Query( + None, + description="Start reading from this row (1-indexed, defaults to header_row + 1)", + ), + limit: Optional[int] = Query(None, description="Max rows to return"), +): + """Read all rows from the specified sheet.""" + wb = get_workbook(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 + 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): + key = header if header else f"_col_{i + 1}" + row_data[key] = values[i] + + rows.append(row_data) + row_count += 1 + + wb.close() + return { + "file_path": file_path, + "sheet_name": sheet_name, + "columns": headers, + "rows": rows, + "row_count": len(rows), + } + + +@app.get("/excel/row/{row_num}") +async def get_row( + row_num: int, + file_path: str = Query(..., description="Path to xlsx file relative to /data/"), + sheet_name: str = Query(..., description="Sheet name to read"), + header_row: int = Query(1, description="Row number containing headers (1-indexed)"), +): + """Get a specific row by row number (1-indexed).""" + if row_num < 1: + raise HTTPException(status_code=400, detail="Row number must be >= 1") + + wb = get_workbook(file_path) + ws = get_sheet(wb, sheet_name) + + # 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: + wb.close() + 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): + key = header if header else f"_col_{i + 1}" + row_data[key] = values[i] + + wb.close() + 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 diff --git a/Code/Tools/Service/requirements.txt b/Code/Tools/Service/requirements.txt new file mode 100644 index 0000000..eba4ac8 --- /dev/null +++ b/Code/Tools/Service/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +openpyxl==3.1.5 +pydantic==2.9.0 diff --git a/Code/docker-compose.yaml b/Code/docker-compose.yaml index aee4c46..d4f50b5 100644 --- a/Code/docker-compose.yaml +++ b/Code/docker-compose.yaml @@ -58,20 +58,23 @@ services: retries: 5 start_period: 120s - # Tools (Python FastAPI - Excel operations) - # tools: - # build: ./Tools/Service - # container_name: jt-tools - # restart: unless-stopped - # volumes: - # - /data/jingtian/BenjaminTeam:/data:rw - # ports: - # - "8080:8080" - # healthcheck: - # test: ["CMD", "curl", "-sf", "http://localhost:8080/health"] - # interval: 30s - # timeout: 10s - # retries: 3 + # Tools (Python FastAPI - Generic Excel API) + tools: + build: ./Tools/Service + container_name: jt-tools + restart: unless-stopped + environment: + DATA_ROOT: /data + volumes: + - /data/jingtian/BenjaminTeam:/data:rw + ports: + - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s volumes: docling_models: