Skip to content

feat(ocr): a local Tesseract OCR service (deploy/ocr) - #7184

Merged
rbuergi merged 4 commits into
mainfrom
feat/ocr-tesseract-service
Oct 25, 2026
Merged

rbuergi merged 4 commits into
mainfrom
feat/ocr-tesseract-service

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

What

A local OCR service, deploy/ocr, built the way deploy/whisper and deploy/s1-extract are. It becomes the fleet's default OCR engine, the server half of the HttpService engine in MeshWeaver.Ocr.

Pairs with Refs Systemorph/MeshWeaver.Plugins#4204 (the client). This PR implements that branch's contract exactly: POST /ocr?languages=de,id with the image bytes, answered by {text, languages, engine}.

  • deploy/ocr/ is Tesseract 5.5 (Debian trixie) behind a stdlib HTTP server. Its only Python dependency is Pillow.
    • Packs baked in: eng, deu, fra, ita, ind, plus osd.
    • Language detection when no hints are given. osd turns the page upright and names the script, then each paragraph is identified by distinctive function words. A word two languages share counts for neither.
    • Hints restrict the set, or extend a narrowed default set. A hint with no baked pack is a 422 that names the installed packs.
    • Health: /healthz and /readyz.
    • Bounds: body 20 MiB (413, never read), pixels 40 MP (413, before decoding), a 150 s deadline that kills the Tesseract process (504), and 2 concurrent reads.
    • Network: none needed. The NetworkPolicy denies all egress, and no ServiceAccount token is mounted.
  • .github/workflows/ocr-image.yml is modelled on s1-extract-image.yml. On a PR it builds, and the build runs the tests. It also runs a negative control, the image's own CMD over HTTP, and a chart render check. On main it pushes meshweaver.azurecr.io/ocr:tesseract-5.5-<sha> and :tesseract-5.5 by OIDC and reads both back. The line is declared in .github/release-channels.json, so retention never prunes it.
  • deploy/ocr/helm is disabled by default: enabled: false renders nothing. When enabled, an immutable tag is required.
  • Portal chart: Ocr__Engine and Ocr__HttpService__Endpoint are rendered only when set. An instance's record can then turn the client on. When they are absent, the client answers NotConfigured.
  • Docs:
    • new page Doc/Architecture/OcrService, listed in the topic map;
    • DocumentIngestionEndVision G10/G11 and its evidence paragraph updated;
    • ReleaseEditions and DockerHubInCi updated.

Evidence (local, colima docker)

The image builds, and the build is the test:

  • test_server.py: 17 tests OK, offline with a fake engine.
  • smoke.py: 12/12 PASS against the real Tesseract 5.5.0 on fixtures/de-id.png.
    • With no hints, the text is read and the languages detected are ['de','id'].
    • languages=de,id reads both.
    • The page rotated 90° is turned upright by osd and reads the same.

Negative controls:

  • The same German+Indonesian assertion fails on fixtures/en.png. All six words are missing, and languages ['en'] != ['de','id'].
  • A blank page returns {"text": "", "languages": []}.
  • languages=en reports neither de nor id.
  • The whole smoke run with OCR_DEFAULT_LANGUAGES=en,id, so German cannot be read, exits 2: missing 'Gebäude'; languages ['id'] != ['de','id'], ×2.

Other checks:

  • With --network none --read-only the smoke passes (exit 0).
  • The real CMD (uid 10001, read-only root) passes, called from a sibling container.
  • A page takes about 0.4 to 0.7 s.
  • Image size: 88 MB content size (compressed), 353 MB on disk.

helm was not run locally. The CI step renders the chart three ways.

Enabling it on an instance (owner-gated, not in this PR)

These steps run in the estate's deployment repository, following the whisper precedent. The detailed steps are in Doc/Architecture/OcrService → Enabling it on an instance:

  1. vendor the chart;
  2. write a values overlay with enabled: true, the immutable tag and the portal namespace;
  3. add a Hosting/ChartRelease record Deployments/<env>-ocr, and get the ApplyChart approved;
  4. set extraPortalConfig to Ocr__Engine=HttpService and Ocr__HttpService__Endpoint=http://ocr:8080/ocr, then roll;
  5. verify on the running instance.

Nothing needs a recycle: no per-node hub serves this.

🤖 Generated with Claude Code

rbuergi and others added 2 commits October 25, 2026 11:21
Tesseract 5.5 with eng/deu/fra/ita/ind + osd baked into the image, behind a
stdlib HTTP server implementing the MeshWeaver.Ocr HttpService contract
(POST /ocr?languages=… → {text, languages, engine}). Language detection by
osd orientation + per-paragraph distinctive function words; hints restrict or
extend the set. Bounded body, pixels, deadline and concurrency; no egress.

The image build runs offline tests and a real-engine smoke on committed
German+Indonesian fixtures with negative controls; ocr-image.yml adds a
red-without-German control, the real CMD over HTTP, a chart render check,
and on main pushes tesseract-5.5-<sha> + the declared tesseract-5.5 line.
The helm chart renders nothing unless enabled; the portal chart passes
Ocr__Engine / Ocr__HttpService__Endpoint only when set.

Docs: Architecture/OcrService (topic map), End Vision G10/G11 updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 25, 2026 10:22
@rbuergi
rbuergi enabled auto-merge (squash) October 25, 2026 10:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The negative control aborts CI on its expected result, and request buffering, transparency handling, and image-tag validation need correction.

4 open issues
What changed in this PR

Adds a local Tesseract OCR service as the server counterpart to the MeshWeaver.Ocr HTTP client. Deployment remains disabled until explicitly enabled for an instance.

Changes:

  • Adds image recognition, orientation correction, language detection, and HTTP request limits.
  • Adds container tests, image publishing, and a standalone Helm chart with restricted networking.
  • Exposes optional portal configuration and documents deployment and release integration.
File Description
src/​MeshWeaver.Documentation/​Data/​Architecture/​ReleaseEditions.md Lists the OCR release channel.
src/​MeshWeaver.Documentation/​Data/​Architecture/​OcrService.md Documents the service contract and enablement.
src/​MeshWeaver.Documentation/​Data/​Architecture/​DocumentIngestionEndVision.md Updates OCR delivery status.
src/​MeshWeaver.Documentation/​Data/​Architecture/​DockerHubInCi.md Includes the OCR base-image build.
src/​MeshWeaver.Documentation/​Data/​Architecture.md Links the OCR architecture page.
deploy/​ocr/​README.md Describes API, testing, and installation.
deploy/​ocr/​helm/​values.yaml Defines disabled-by-default deployment settings.
deploy/​ocr/​helm/​templates/​service.yaml Exposes a cluster-internal service.
deploy/​ocr/​helm/​templates/​networkpolicy.yaml Restricts ingress and denies egress.
deploy/​ocr/​helm/​templates/​deployment.yaml Configures the hardened pod and probes.
deploy/​ocr/​helm/​templates/​_helpers.tpl Defines chart names and labels.
deploy/​ocr/​helm/​Chart.yaml Declares OCR chart metadata.
deploy/​ocr/​fixtures/​make_fixtures.py Generates multilingual smoke fixtures.
deploy/​ocr/​Dockerfile Packages Tesseract and runs build-time tests.
deploy/​ocr/​app/​test_server.py Tests decisions and HTTP limits offline.
deploy/​ocr/​app/​smoke.py Exercises real OCR with negative controls.
deploy/​ocr/​app/​server.py Implements HTTP endpoints and concurrency control.
deploy/​ocr/​app/​ocr.py Implements recognition and language identification.
deploy/​helm/​templates/​memex-portal/​config.yaml Emits optional OCR client settings.
.github/​workflows/​ocr-image.yml Builds, checks, and publishes OCR images.
.github/​release-channels.json Registers the moving OCR service tag.

Review effort: Balanced

Comment thread .github/workflows/ocr-image.yml Resolved
Comment thread deploy/ocr/app/server.py Resolved
Comment thread deploy/ocr/app/ocr.py Resolved
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    27 files  ±0      27 suites  ±0   52m 29s ⏱️ +28s
11 710 tests +5  11 513 ✅ +5  197 💤 ±0  0 ❌ ±0 
11 736 runs  +5  11 539 ✅ +5  197 💤 ±0  0 ❌ ±0 

Results for commit 66719cc. ± Comparison against base commit ee791b6.

This pull request removes 69 and adds 37 tests. Note that renamed tests count towards both.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, kind: "Timeout", layer: null)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.HubDisposingException: Hub node/a is shutting down — cannot create "x". The address may reactivate (recycle / restart); retry to get the authoritative answer.
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
…
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's FIRST member", fault: System.AggregateException: One or more errors occurred. (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's SECOND member", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall nested in an aggregate inside a wrapper", fault: System.InvalidOperationException: outer
 ---> System.AggregateException: One or more errors occurred. (Value does not fall within the expected range.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.ArgumentException: Value does not fall within the expected range.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---

   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall wrapped as InnerException", fault: System.InvalidOperationException: outer
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "an aggregate of unrelated faults", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Value does not fall within the expected range.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
, expected: False)
MeshWeaver.Graph.Test.MailCopyPartitionTest ‑ ACopyNotYetRetired_IsStillListed_ForTheOwnerAndForTheKeeper
MeshWeaver.Graph.Test.MailCopyPartitionTest ‑ ADestinationHeldByAnotherLiveRetirement_RefusesThisOne
MeshWeaver.Graph.Test.MailCopyPartitionTest ‑ ALegacyCopyLeasedByALiveSync_IsNotRetired_AndNothingIsCreated
MeshWeaver.Graph.Test.MailCopyPartitionTest ‑ ALegacyCopy_IsRetired_WithItsFiles_AndAFreshCopyIsSwitchedOn
MeshWeaver.Graph.Test.MailCopyPartitionTest ‑ ALegacySourceReplacedDuringTheRetirement_IsNotDeleted_AndNothingIsSwitchedOn
…

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi marked this pull request as draft October 25, 2026 11:42
auto-merge was automatically disabled October 25, 2026 11:42

Pull request was converted to draft

@rbuergi
rbuergi marked this pull request as ready for review October 25, 2026 11:44
@rbuergi
rbuergi enabled auto-merge (squash) October 25, 2026 11:44
rbuergi and others added 2 commits October 25, 2026 12:52
…the negative control capturable

- ocr-image.yml: the negative-control step ran under Actions' `bash -e`, so the smoke's expected
  non-zero exit aborted the step before `code=$?` (the PR's only red). The status is now captured
  through `||`, and the step demands an ASSERTION red: the German check's FAIL line, the smoke's own
  "<n> failure(s)" equal to the exit code, and no docker 125/126/127.
- server.py: a request takes one of OCR_MAX_ADMITTED places (chart limits.maxAdmitted, default 8)
  BEFORE its body is read; none free -> 503, body unread. Buffered uploads are bounded by
  maxAdmitted x maxBytes instead of by the number of callers.
- ocr.py: images with alpha (RGBA/LA/PA, or a tRNS key) are composited onto white instead of
  dropping alpha, which turned black-on-transparent text into one black page.
- chart: image.tag must match tesseract-<line>-<hex sha>; the CI chart check now also refuses
  tesseract-5.5, latest and other moving tags.

Regression tests (each red on the previous code): test_transparency_is_composited_onto_white
(RGBA, LA, P+tRNS), test_admission_refuses_beyond_the_bound_without_reading_the_body,
test_admission_below_concurrency_refuses_to_start, and the real-engine smoke check
"transparent page (text only in alpha)".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Slow uploads can indefinitely exhaust admission, and repeated publication can overwrite image tags that deployments treat as immutable.

2 open issues
4 resolved since last review

Review effort: Balanced

run: |
set -euo pipefail
short="${GITHUB_SHA::7}"
docker push "$ACR/ocr:${LINE}-${short}"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. A rerun or workflow_dispatch at the same main commit would overwrite the tag the header calls immutable. Replied rather than pushed in this round, because nothing can pull a divergent build yet: the chart renders nothing by default (enabled: false), so no portal pulls the image until an estate enables it. I have not checked the private estate overlays. The write-once guard has three possible shapes: skip the build when the immutable tag already exists, telling an absent tag apart from a failed lookup; lock the tag after its first push; or bind deployments to the digest. It needs to land before any estate enables the chart. Tracked as follow-up: rbuergi/Feedback/ocr-image-write-once-and-upload-deadline-2026-10-25

Comment thread deploy/ocr/app/server.py
cfg.admission.release()

def _read_admitted(self, url, size: int):
data = self.rfile.read(size)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct: OCR_READ_TIMEOUT_SECONDS bounds inactivity, not total upload time, so a caller that trickles bytes holds its admission place. Replied rather than pushed in this round. The service is cluster-internal: the chart's NetworkPolicy admits ingress only from pods in the named allowedNamespaces, and an optional bearer key sits on top. So only an in-cluster client the policy already admits could exhaust the places, and an ordinary portal upload over the cluster network does not trickle. The fix is an absolute deadline started before buffering, enforced across incremental reads, that closes the connection and releases admission on expiry (504), with a test. Tracked as follow-up: rbuergi/Feedback/ocr-image-write-once-and-upload-deadline-2026-10-25

@rbuergi
rbuergi enabled auto-merge (squash) October 25, 2026 12:09
@rbuergi
rbuergi merged commit 656e68c into main Oct 25, 2026
94 of 112 checks passed
rbuergi added a commit that referenced this pull request Oct 25, 2026
Every conflict resolved so the deletion wins: the chart files main changed (#7143, #7159, #7184,
#7170, #7119; all ported to MeshWeaver.Deploy by Deploy#4), the chart-reading guards main moved
onto RepoPaths (#7111), and the Memex.Portal.Shared files main moved to MeshWeaver.Plugins (#7125).
Also deleted: the build-host chart files main added after this branch was cut (frozen, in Deploy
now) and memex-local.yml (#7119), whose Linux gate is Deploy's homebrew.yml linux job. Kept from
main: instances.json's re-measured MeshWeaver.Deploy row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi deleted the feat/ocr-tesseract-service branch October 31, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants