Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,30 @@ git fetch → pick target (latest release tag by default, or a branch)
i.e. `git clone` the repo and `pip install -e .` rather than installing a wheel. The
`repo_dir` defaults to the repo root above the package; override it if needed.

## Run logs

With `logging.enabled: true` (the default), every `plan`/`run` writes a fresh
timestamped log to `logging.log_dir` (`scanfiler-<YYYYMMDD-HHMMSS-mmm>.log`). It records,
per file, whether it was **sent** to the LLM, **skipped** (with the reason — already
judged, already named, image under text mode, …), **processed** successfully, or
**failed**, and ends with a summary of the counts:

```
SEND: SCAN00001.pdf (pdf, 0 image(s), 354 text chars)
OK: SCAN00001.pdf -> receipts/2025-06-riverside-auto-service-receipt.pdf (conf=0.95)
SKIP (criteria): PIC00001.png - nothing to send in text mode
---- run summary ----
sent to LLM: 2
processed successfully: 2 (of which 0 routed to _Unsorted)
failed: 0
skipped (already judged): 0
skipped (criteria): 1 (selection 0 + nothing-to-send 1)
```

Failures log full diagnostics — status code, URL, the request (with base64 image data
redacted to a size summary), and the response body — so you can see exactly what the
server returned. Set `logging.enabled: false` to turn run logs off.

## Testing

```bash
Expand Down
6 changes: 5 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ ai:
extraction:
pdf_max_pages: 2 # only the first N pages go to the model
raster_dpi: 150 # rasterization DPI for the vision model
send_mode: auto # vision | text | auto (text if rich layer, else images)
send_mode: auto # vision | text | auto. text = never send images
# (image-only files are skipped); auto uses text
# when the layer is rich, else page images.

selection:
process_pattern: '^(SCAN|PIC|IMG)[\W_]*\d+' # "looks unprocessed"
Expand Down Expand Up @@ -49,6 +51,8 @@ apply:
on_collision: suffix # suffix (-2,-3) | skip | overwrite

logging:
enabled: true # write a fresh timestamped log file each run
log_dir: ./logs # where per-run logs land (scanfiler-<timestamp>.log)
level: info # debug | info | warn | error
audit_file: ./logs/audit.jsonl # JSONL record of every move (reversible via undo)
ledger_db: ./state/ledger.sqlite # content-hash processed-file ledger
Expand Down
1 change: 1 addition & 0 deletions samples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ apply:
action: copy

logging:
log_dir: samples/logs
audit_file: samples/logs/audit.jsonl
ledger_db: samples/state/ledger.sqlite

Expand Down
76 changes: 72 additions & 4 deletions scanfiler/ai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

Works against llama-server, mlx-vlm, or any cloud OpenAI-compatible endpoint. The
client is deliberately small and injectable so tests can swap in a stub (the same
approach actual-ai-categorizer uses for its provider).
approach actual-ai-categorizer uses for its provider). On failure it raises AIError,
which carries the status code, the (image-redacted) request, and the response body so
the run log can record exactly what went wrong.
"""

from __future__ import annotations

import copy
import json
import time
from typing import Protocol
Expand All @@ -17,6 +20,54 @@
from .schema import Decision, build_response_format


class AIError(RuntimeError):
"""An AI request that failed after all retries, with diagnostic detail."""

def __init__(
self, message: str, *, status_code: int | None = None, url: str | None = None,
attempts: int | None = None, request: dict | None = None,
response_text: str | None = None,
):
super().__init__(message)
self.status_code = status_code
self.url = url
self.attempts = attempts
self.request = request
self.response_text = response_text

def detail(self) -> str:
"""A single-line-ish summary with everything useful for the log."""
parts = [str(self)]
if self.status_code is not None:
parts.append(f"status_code={self.status_code}")
if self.url:
parts.append(f"url={self.url}")
if self.attempts is not None:
parts.append(f"attempts={self.attempts}")
if self.response_text:
parts.append(f"response={self.response_text[:4000]}")
if self.request is not None:
parts.append("request=" + json.dumps(self.request)[:8000])
return " | ".join(parts)


def redact_request(body: dict) -> dict:
"""Deep-copy the request, replacing base64 image data URLs with a size summary.

Keeps full request structure/text in logs without dumping megabytes of base64.
"""
b = copy.deepcopy(body)
for msg in b.get("messages", []):
content = msg.get("content")
if isinstance(content, list):
for part in content:
if part.get("type") == "image_url":
url = part.get("image_url", {}).get("url", "")
if isinstance(url, str) and url.startswith("data:"):
part["image_url"]["url"] = f"<data-url image, {len(url)} chars>"
return b


class AIClient(Protocol):
def decide(
self, system_prompt: str, user_content: list[dict],
Expand Down Expand Up @@ -49,19 +100,36 @@ def decide(
url = self.cfg.base_url.rstrip("/") + "/chat/completions"

last_exc: Exception | None = None
status_code: int | None = None
response_text: str | None = None
for attempt in range(1, self.cfg.max_retries + 1):
resp = None
try:
with httpx.Client(timeout=self.cfg.request_timeout_s) as client:
resp = client.post(url, json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
return Decision.model_validate(json.loads(content))
except Exception as exc: # noqa: BLE001 — retry transient failures
last_exc = exc
if resp is not None:
status_code = resp.status_code
try:
response_text = resp.text
except Exception: # noqa: BLE001
response_text = None
if attempt < self.cfg.max_retries:
time.sleep(min(2 ** attempt, 10))
raise RuntimeError(f"AI request failed after {self.cfg.max_retries} attempts: {last_exc}")

raise AIError(
f"AI request failed after {self.cfg.max_retries} attempts: {last_exc}",
status_code=status_code,
url=url,
attempts=self.cfg.max_retries,
request=redact_request(body),
response_text=response_text,
)


def make_client(cfg: AIConfig) -> AIClient:
Expand Down
33 changes: 21 additions & 12 deletions scanfiler/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ def _make_client(cfg: Config):
return make_client(cfg.ai)


def _stats_line(stats) -> str:
return (
f"sent={stats.sent} proposed={stats.proposed} unsorted={stats.unsorted} "
f"skipped_seen={stats.skipped_seen} "
f"skipped_criteria={stats.skipped_selection + stats.skipped_no_content} "
f"errors={stats.errors}"
)


def cmd_init(args) -> int:
dest = Path(args.config)
if dest.exists():
Expand All @@ -39,20 +48,20 @@ def cmd_plan(args) -> int:
from .ledger import open_ledger
from .pipeline import plan
from .proposals import write_proposals
from .runlog import open_run_log

cfg = _load(args)
client = _make_client(cfg)
with open_ledger(cfg.logging.ledger_db) as ledger:
proposals, stats = plan(cfg, client, ledger)
with open_run_log(cfg) as (log, log_path), open_ledger(cfg.logging.ledger_db) as ledger:
proposals, stats = plan(cfg, client, ledger, log)
if not args.dry_run:
write_proposals(args.proposals, proposals)
print(f"Wrote {len(proposals)} proposals to {args.proposals}")
else:
print("[dry-run] not writing proposals file")
print(
f"proposed={stats.proposed} unsorted={stats.unsorted} "
f"skipped_seen={stats.skipped_seen} errors={stats.errors}"
)
print(_stats_line(stats))
if log_path:
print(f"log: {log_path}")
return 0


Expand All @@ -77,6 +86,7 @@ def _run_once(cfg: Config, args) -> int:
from .lock import LockHeld, file_lock
from .pipeline import plan
from .proposals import write_proposals
from .runlog import open_run_log

# Check for + apply a newer release before doing any work. Done before the lock so a
# re-exec into the updated version doesn't deadlock against our own lockfile. On a
Expand All @@ -90,12 +100,11 @@ def _run_once(cfg: Config, args) -> int:
lock_path = Path(cfg.logging.ledger_db).with_suffix(".lock")
try:
with file_lock(lock_path):
with open_ledger(cfg.logging.ledger_db) as ledger:
proposals, stats = plan(cfg, client, ledger)
print(
f"proposed={stats.proposed} unsorted={stats.unsorted} "
f"skipped_seen={stats.skipped_seen} errors={stats.errors}"
)
with open_run_log(cfg) as (log, log_path), open_ledger(cfg.logging.ledger_db) as ledger:
proposals, stats = plan(cfg, client, ledger, log)
print(_stats_line(stats))
if log_path:
print(f"log: {log_path}")
if cfg.apply.mode == "auto" and not args.dry_run:
result = apply_proposals(cfg, proposals, ledger)
print(f"[auto-apply] run={result.run_id} applied={result.applied} "
Expand Down
2 changes: 2 additions & 0 deletions scanfiler/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class ApplyConfig(BaseModel):


class LoggingConfig(BaseModel):
enabled: bool = True # write a timestamped per-run log file
log_dir: Path = Path("./logs") # where per-run logs (and audit) live
level: Literal["debug", "info", "warn", "error"] = "info"
audit_file: Path = Path("./logs/audit.jsonl")
ledger_db: Path = Path("./state/ledger.sqlite")
Expand Down
6 changes: 6 additions & 0 deletions scanfiler/extract/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@


def extract_image(path: Path, cfg: ExtractionConfig) -> ExtractResult:
# Text-only mode: an image has no text layer, so there is nothing to send.
# Return empty (no images) without decoding; the pipeline skips it rather than
# shipping an image to a text-only model.
if cfg.send_mode == "text":
return ExtractResult(kind="image")

from PIL import Image

with Image.open(path) as im:
Expand Down
Loading