Skip to content

Commit 315ceae

Browse files
authored
feat: OCR scans with tesseract, drop docling from the default path. (#33)
pdftotext still wins on born-digital PDFs. Empty text layers go through pdftoppm + tesseract eng+deu. Optional OCR_ENGINE=paddle. Gitea #6.
1 parent 95a4212 commit 315ceae

17 files changed

Lines changed: 555 additions & 1762 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,26 @@ jobs:
7272
uv run python bin/kb/index --rebuild --json
7373
KB_ROOT="$PWD" /tmp/brain-eval --json
7474
75+
ocr:
76+
name: OCR (tesseract fixture)
77+
runs-on: ubuntu-latest
78+
steps:
79+
- uses: actions/checkout@v4
80+
- uses: actions/setup-go@v5
81+
with:
82+
go-version-file: go.mod
83+
- name: Install tesseract + poppler
84+
run: |
85+
sudo apt-get update
86+
sudo apt-get install -y --no-install-recommends \
87+
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu poppler-utils
88+
- name: Go OCR tests (synthetic HELLO PNG)
89+
run: go test ./internal/ocr -count=1
90+
7591
release:
7692
name: Release (semver)
7793
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
78-
needs: test
94+
needs: [test, ocr]
7995
runs-on: ubuntu-latest
8096
permissions:
8197
contents: write

‎AGENTS.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ skills/ in-project agent skills (vendored, no external links)
4242
bin/ self-describing tools bin/{subject}/{method}.go (shebang)
4343
bin/brain/ search.go serve.go index.go add.go get.go stats.go eval.go watch.go
4444
bin/chats/ sync.go import.go facts.go apply.go; libs in internal/chats
45-
bin/mail/ sync.go import.go (index_mail → brain/index.go)
45+
bin/mail/ sync.go import.go ocr.go (index_mail → brain/index.go)
4646
bin/markdown/ import.go (H2 leaf split; Python bin/md/import fallback)
4747
bin/postgres/ query.go (read-only YAML)
4848
bin/git/ import.go (go-git history; Python shim execs it)
@@ -71,9 +71,9 @@ bin/brain/index.go --rebuild --with-facts --with-chats
7171
- `sync` (Go) downloads messages + attachments; Gmail uses paginated list +
7272
`body.attachmentId` (not partId) for attachments.
7373
- `import` converts body + attachments to markdown. PDFs use poppler
74-
`pdftotext -layout` fast path (~15ms); textless/scanned PDFs fall back to
75-
docling (isolated subprocess — its native onnx can segfault the parent).
76-
Conversion never touches the brain DB (crash safety).
74+
`pdftotext -layout` fast path (~15ms); textless/scanned PDFs use
75+
`pdftoppm` + tesseract `eng+deu` (`bin/mail/ocr.go`). Optional
76+
`OCR_ENGINE=paddle`. Conversion never touches the brain DB (crash safety).
7777
- `index_mail` is a deprecation shim for `bin/brain/index.go --rebuild`. Bulk
7878
rebuild still deletes `var/kb.lbug` and creates FTS/HNSW last. Single-leaf
7979
write is `bin/brain/add.go` (safe while indexes exist; do not DROP INDEX).
@@ -101,6 +101,7 @@ bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit le
101101
bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence
102102
bin/reasoner/bakeoff.go [--model ID] [--json] # D18 CPU tool-call bake-off
103103
bin/postgres/query.go --profile onlyoffice -c 'SELECT 1'
104+
bin/mail/ocr.go <image|pdf> # tesseract eng+deu (scans)
104105
bin/md/tables # what the graph holds → YAML
105106
bin/brain/deduce "question" # thinking wrapper
106107
```

‎Dockerfile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ ENV PYTHONUNBUFFERED=1 \
1616

1717
WORKDIR /app
1818
RUN id -u 2dph 2>/dev/null || useradd --create-home --uid 1001 2dph
19+
RUN apt-get update \
20+
&& apt-get install -y --no-install-recommends \
21+
poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu \
22+
&& rm -rf /var/lib/apt/lists/*
1923

2024
COPY requirements.lock.txt /tmp/requirements.lock.txt
2125
RUN python -m pip install --no-cache-dir -r /tmp/requirements.lock.txt \

‎PLAN.md‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ A brain that loves facts and deduction. Evidence-first knowledge graph + hybrid
44
RAG over the operational Brain/ops/eSlider stack. Built like Sherlock
55
Holmes: nothing is asserted unless it has proof.
66

7-
Status: **in progress** — read path + MCP work; v1 goal is [epic #16](https://git.produktor.io/eSlider/2dph/issues/16)
8-
(milestone [v1 detective brain](https://git.produktor.io/eSlider/2dph/milestone/12)).
7+
Status: **v1 in** (epic [#16](https://git.produktor.io/eSlider/2dph/issues/16) closed).
8+
v2 board: milestone [v2](https://git.produktor.io/eSlider/2dph/milestone/13) — OCR [#6](https://git.produktor.io/eSlider/2dph/issues/6),
9+
[#29](https://git.produktor.io/eSlider/2dph/issues/29) OQ1, [#30](https://git.produktor.io/eSlider/2dph/issues/30) OQ3.
910
Gap: [docs/roadmap.md](docs/roadmap.md).
1011

1112
## What
@@ -74,6 +75,7 @@ detective method: **a fact needs ≥2 independent sources or it is
7475
reasoner/bakeoff.go CPU tool-call bake-off (D18; OpenAI tools)
7576
chats/sync.go import.go facts.go apply.go
7677
(libs in internal/chats; no chats index)
78+
mail/ocr.go tesseract eng+deu (pdftoppm scans)
7779
md/import (deprecated; bin/markdown/import.go)
7880
brain/extract brain/audit brain/deduce (thinking wrapper)
7981
web/search (deprecated shim → web/search.go)
@@ -115,10 +117,13 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
115117
## Open questions (v2)
116118

117119
- OQ1: mutually-contradicting evidence — how to resolve (authority weighting,
118-
temporal freshness, audit adjudication). **v2**; does not block epic #16.
119-
- OQ2: OCR — poppler `pdftotext` fast-path exists; scans still docling.
120-
[#6](https://git.produktor.io/eSlider/2dph/issues/6) (v2, does not block #16).
120+
temporal freshness, audit adjudication). **v2**; [#29](https://git.produktor.io/eSlider/2dph/issues/29).
121+
- OQ2: OCR — **in**. `pdftotext -layout` first; scans `pdftoppm` + tesseract
122+
`eng+deu` (`bin/mail/ocr.go`, `internal/ocr`). No gocv, no gosseract CGO
123+
(D21 Zig owns Ladybug CGO). Optional `OCR_ENGINE=paddle` / compose profile
124+
`ocr-paddle`. Docling left the default path. [#6](https://git.produktor.io/eSlider/2dph/issues/6).
121125
- OQ3: optional duckdb-md layer for `SELECT … FORMAT MARKDOWN` export/write-back.
126+
[#30](https://git.produktor.io/eSlider/2dph/issues/30).
122127
- OQ4: YAML-first storage for leafs — deferred: JSON is ~10x faster to
123128
serialize and unambiguous; YAML only where humans edit files.
124129

@@ -127,7 +132,8 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
127132
1. `bin/mail/sync.go` (Go, 8 workers) — paginated Gmail/OnlyOffice download.
128133
Gmail attachments key off `body.attachmentId`, not MIME `partId`.
129134
2. `bin/mail/import.go --from-raw` — message.json → message.md; PDFs via
130-
`pdftotext -layout` (~15ms) with docling subprocess fallback; ICS sidecars
135+
`pdftotext -layout` (~15ms); textless/scanned PDFs `pdftoppm` + tesseract
136+
`eng+deu`. ICS sidecars
131137
Latin-1→UTF-8 normalized.
132138
3. `bin/brain/index.go --rebuild` — fresh rebuild (repo corpus + mail) because ladybug
133139
corrupts its WAL on bulk-insert into an already-indexed DB. Conversion and
@@ -177,4 +183,4 @@ Narrative: [docs/roadmap.md](docs/roadmap.md).
177183
| 4 | [#15](https://git.produktor.io/eSlider/2dph/issues/15) | **in** — lever/loop documented (`search` → `get` → `audit`). |
178184
| 5 | [#19](https://git.produktor.io/eSlider/2dph/issues/19) | **in** — CI recall SoT is `bin/brain/eval.go` via Zig. Python `bin/kb/eval` stays as an explicit fallback. |
179185

180-
Does **not** block epic close: [#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR, OQ1, OQ3, OQ4.
186+
Does **not** block epic close: OQ1 [#29](https://git.produktor.io/eSlider/2dph/issues/29), OQ3 [#30](https://git.produktor.io/eSlider/2dph/issues/30), OQ4. OCR [#6](https://git.produktor.io/eSlider/2dph/issues/6) is **in**.

‎bin/mail/import‎

Lines changed: 11 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
bin/mail/import --since 2026-01-01 only messages after a date
88
bin/mail/import --limit 50 cap messages per run
99
bin/mail/import --no-attachments body only, skip attachment conversion
10-
bin/mail/import --ocr OCR scanned PDFs/images via docling
10+
bin/mail/import --ocr OCR images (PDFs OCR when textless)
1111
bin/mail/import --dry-run list messages without writing anything
1212
1313
Writes one directory per message: var/mail/{folder}/{message_id}/
@@ -16,10 +16,11 @@ Writes one directory per message: var/mail/{folder}/{message_id}/
1616
attachments/*.md converted attachment content
1717
1818
Indexing is a separate step (`bin/brain/index.go --rebuild`): conversion can
19-
crash in native docling and must not leave the brain DB mid-transaction.
19+
crash and must not leave the brain DB mid-transaction.
2020
21-
Requires ONLYOFFICE_URL/USER/PASS in .env (or env). Idempotent: a message
22-
already present (message.md exists) is skipped unless --force.
21+
Requires ONLYOFFICE_URL/USER/PASS in .env (or env) except `--from-raw`.
22+
Idempotent: a message already present (message.md exists) is skipped unless
23+
--force.
2324
"""
2425
from __future__ import annotations
2526

@@ -41,10 +42,10 @@ from mailconv import ( # noqa: E402
4142
IMAGE_SUFFIXES,
4243
LEGACY_OFFICE_SUFFIXES,
4344
TEXT_SUFFIXES,
45+
convert_pdf,
4446
html_to_markdown,
45-
is_convertible,
4647
normalize_markdown,
47-
subject_to_filename,
48+
ocr_image,
4849
zip_extract_safe,
4950
)
5051

@@ -147,77 +148,16 @@ def convert_file_to_md(path: Path, ocr: bool) -> str | None:
147148
except Exception as e:
148149
return f"\n<!-- conversion failed: {e} -->\n"
149150
if suffix == ".pdf":
150-
return _convert_pdf(path, ocr)
151+
return convert_pdf(path, ocr)
151152
if suffix in IMAGE_SUFFIXES and ocr:
152-
return _convert_pdf(path, ocr)
153+
return ocr_image(path) or "\n<!-- ocr unavailable -->\n"
153154
if suffix in LEGACY_OFFICE_SUFFIXES:
154155
return _convert_legacy(path)
155156
if suffix in ARCHIVE_SUFFIXES:
156157
return None # handled by caller (unpack + recurse)
157158
return None
158159

159160

160-
def _convert_pdf(path: Path, ocr: bool) -> str:
161-
"""Convert one PDF to markdown.
162-
163-
Fast path: poppler's pdftotext (-layout) extracts exact text from
164-
born-digital PDFs in ~15ms vs docling's 1-3s. Only textless PDFs (scanned
165-
pages, layout-heavy) fall back to docling, which runs isolated in a
166-
subprocess because its native onnx/RT-DETR has segfaulted the main process.
167-
"""
168-
text = _pdf_fast_text(path)
169-
if ocr or text is None or not text.strip():
170-
return _convert_pdf_docling(path, ocr)
171-
return normalize_markdown(text)
172-
173-
174-
def _pdf_fast_text(path: Path) -> str | None:
175-
"""pdftotext -layout; None when poppler is unavailable (or the PDF has no text layer)."""
176-
try:
177-
proc = subprocess.run(
178-
["pdftotext", "-layout", str(path), "-"],
179-
capture_output=True, timeout=60)
180-
except (OSError, subprocess.TimeoutExpired):
181-
return None
182-
if proc.returncode != 0:
183-
return None
184-
return proc.stdout.decode("utf-8", errors="replace")
185-
186-
187-
def _convert_pdf_docling(path: Path, ocr: bool) -> str:
188-
try:
189-
proc = subprocess.run(
190-
[sys.executable, os.path.abspath(__file__), "--pdf-worker", str(path),
191-
"--ocr" if ocr else "--no-ocr"],
192-
capture_output=True, text=True, timeout=600)
193-
except subprocess.TimeoutExpired:
194-
return "\n<!-- pdf conversion timed out -->\n"
195-
if proc.returncode != 0:
196-
tail = proc.stderr.strip().splitlines()[-3:]
197-
return f"\n<!-- pdf conversion failed: {proc.returncode}: {' | '.join(tail)} -->\n"
198-
return proc.stdout
199-
200-
201-
def _pdf_worker(path: Path, ocr: bool) -> None:
202-
"""docling worker entry: prints converted markdown on stdout, exits non-zero on error."""
203-
try:
204-
from docling.document_converter import DocumentConverter, PdfFormatOption
205-
from docling.datamodel.pipeline_options import PdfPipelineOptions
206-
opts = PdfPipelineOptions()
207-
opts.do_ocr = bool(ocr)
208-
opts.do_table_structure = True
209-
conv = DocumentConverter(format_options={"pdf": PdfFormatOption(pipeline_options=opts)})
210-
res = conv.convert(str(path))
211-
sys.stdout.write(normalize_markdown(res.document.export_to_markdown()))
212-
sys.exit(0)
213-
except Exception as e:
214-
# errors/stacktraces to stderr; the caller only reports a one-liner
215-
print(f"pdf-worker: {e}", file=sys.stderr)
216-
import traceback
217-
traceback.print_exc(file=sys.stderr)
218-
sys.exit(1)
219-
220-
221161
def _convert_legacy(path: Path) -> str:
222162
"""Legacy .doc/.xls/.ppt -> md via pandoc (installed) or a stub."""
223163
try:
@@ -356,19 +296,12 @@ def main(argv: list[str]) -> int:
356296
p.add_argument("--from-raw", default="",
357297
help="convert Go-synced dirs (var/mail/<folder>/<id>/message.json) to markdown")
358298
p.add_argument("--no-attachments", action="store_true", help="skip attachment download+convert")
359-
p.add_argument("--ocr", action="store_true", help="OCR scanned PDFs/images via docling")
299+
p.add_argument("--ocr", action="store_true", help="OCR images (PDFs OCR when textless)")
360300
p.add_argument("--force", action="store_true", help="re-import even if message.md exists")
361301
p.add_argument("--dry-run", action="store_true", help="list messages, write nothing")
362302
p.add_argument("--json", action="store_true")
363-
p.add_argument("--pdf-worker", default="", help=argparse.SUPPRESS)
364-
p.add_argument("--no-ocr", action="store_true", help=argparse.SUPPRESS)
365303
a = p.parse_args(argv)
366304

367-
if a.pdf_worker:
368-
_pdf_worker(Path(a.pdf_worker), ocr=not a.no_ocr)
369-
return 0
370-
371-
conf = load_env()
372305
fid = folder_id(a.folder)
373306
out_root = ROOT / "var" / "mail"
374307
summary: list[dict] = []
@@ -394,6 +327,7 @@ def main(argv: list[str]) -> int:
394327
target_dir=msg_dir.parent))
395328
summary.append(entry)
396329
else:
330+
conf = load_env()
397331
OOCLIENT = OOClient(conf)
398332
if a.id:
399333
messages = [{"id": i} for i in a.id]

‎bin/mail/ocr.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//usr/bin/env go run -tags=mail_ocr "$0" "$@"; exit
2+
//go:build mail_ocr
3+
//
4+
// bin/mail/ocr.go - OCR an image or scanned PDF (tesseract eng+deu).
5+
//
6+
// ./bin/mail/ocr.go scan.png
7+
// ./bin/mail/ocr.go scan.pdf
8+
// OCR_ENGINE=paddle ./bin/mail/ocr.go scan.png
9+
//
10+
// PDFs try pdftotext -layout first; empty text layer uses pdftoppm + tesseract.
11+
// No gocv. Tesseract CGO bindings are not used (D21 Zig owns Ladybug CGO).
12+
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
13+
package main
14+
15+
import (
16+
"fmt"
17+
"os"
18+
"strings"
19+
20+
"github.com/eSlider/2dph/internal/ocr"
21+
)
22+
23+
func main() {
24+
os.Exit(run(os.Args[1:]))
25+
}
26+
27+
func run(args []string) int {
28+
if len(args) != 1 || strings.HasPrefix(args[0], "-") {
29+
fmt.Fprintln(os.Stderr, `usage: bin/mail/ocr.go <image|pdf>`)
30+
return 2
31+
}
32+
path := args[0]
33+
var (
34+
text string
35+
err error
36+
)
37+
if strings.HasSuffix(strings.ToLower(path), ".pdf") {
38+
text, err = ocr.PDFFile(path)
39+
} else {
40+
text, err = ocr.ImageFile(path)
41+
}
42+
if err != nil {
43+
fmt.Fprintf(os.Stderr, "mail/ocr: %v\n", err)
44+
return 1
45+
}
46+
fmt.Println(text)
47+
return 0
48+
}

0 commit comments

Comments
 (0)