This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new d39c62b9fd improve skills (#3461)
d39c62b9fd is described below

commit d39c62b9fdec7819073895a361cec24793d84d76
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 30 17:05:39 2026 -0400

    improve skills (#3461)
---
 .skills/users/file-to-markdown-docker/SKILL.md     |  48 ++++----
 CHANGES.txt                                        |   5 +
 .../server/standard/SkillConfigExamplesTest.java   | 122 +++++++++++++++++++++
 3 files changed, 152 insertions(+), 23 deletions(-)

diff --git a/.skills/users/file-to-markdown-docker/SKILL.md 
b/.skills/users/file-to-markdown-docker/SKILL.md
index b43974e1b9..c798b6c774 100644
--- a/.skills/users/file-to-markdown-docker/SKILL.md
+++ b/.skills/users/file-to-markdown-docker/SKILL.md
@@ -123,29 +123,26 @@ fail inside the container even though it works fine 
outside it.
 Don't assume the `-full` image means OCR fired on a given file — confirm it,
 especially the first time you stand one up.
 
-**For PDFs**, `pdf:ocr-page-count` is a verified, code-confirmed signal —
-non-zero means Tesseract processed that many pages:
+The signal is `tk:parsed-by-full-set`: it lists
+`org.apache.tika.parser.ocr.TesseractOCRParser` when Tesseract was run on the
+file (a PDF page or an image), and leaves it out when it wasn't:
 
 ```bash
-curl -T scanned.pdf http://localhost:9998/rmeta | jq 
'.[0]."pdf:ocr-page-count"'
+curl -T scanned.pdf http://localhost:9998/rmeta | jq 
'.[0]."tk:parsed-by-full-set"'
 ```
 
-Secondary evidence: the same entry's `tk:parsed-by-full-set` lists
-`org.apache.tika.parser.ocr.TesseractOCRParser` when Tesseract ran.
+Do **not** use `pdf:ocr-page-count` for this. It counts the pages that looked
+like they needed OCR, whether or not an engine ran, so it is non-zero with OCR
+turned off too. `tk:ocr-warnings` names input the engine was not given:
+`skipped:<reason>` from an image check, `skipped:max-pages` once a
+`pages.ocr.maxPages` cap is reached (4.3.0+).
 
-`0` or `null` means OCR didn't run on this PDF — check you're on `-full` (not
-minimal), that the PDF is actually image-only (a PDF with a real text layer
-correctly skips OCR — that's not a bug), and that no mounted config sets
-`skipOcr: true` (see below). If the key exists but under a **camelCase name**
-(`pdf:ocrPageCount`) — or you see `X-TIKA:*` keys — your image predates the
-4.0.0 metadata-key renames; upgrade the image rather than adapting to the old
-spellings.
-
-**For standalone images** (not embedded in a PDF), there's no single
-verified flag confirmed here — this skill doesn't have a code-checked answer
-for that case yet. The pragmatic fallback: compare extracted-text length
-against a file you know is a genuine scan; suspiciously empty output on a
-visibly text-bearing image is the same "wrong image" signal as above.
+No engine listed means OCR didn't run — check you're on `-full` (not
+minimal), that the file actually needs it (a PDF with a real text layer
+correctly skips OCR — that's not a bug), and that no mounted config turns it
+off (see below). If you see camelCase keys (`pdf:ocrPageCount`) or `X-TIKA:*`
+keys, your image predates the 4.0.0 metadata-key renames; upgrade the image
+rather than adapting to the old spellings.
 
 ## Configuration (turning OCR off, or anything else)
 
@@ -159,18 +156,23 @@ docker run -d -p 127.0.0.1:9998:9998 \
   apache/tika:<version>-full -c /tika-config.json
 ```
 
+Through 4.2.x the server refuses a config file without a `server` block
+(`Couldn't find 'server' element`), so include one, even if empty; it is
+harmless on later versions.
+
 To keep `-full`'s other parsers but disable OCR specifically (e.g. you only
-wanted GDAL):
+wanted GDAL), empty the `text-recognizers` list (4.1.0+):
 
 ```json
 {
-  "parsers": [
-    { "default-parser": {} },
-    { "tesseract-ocr-parser": { "skipOcr": true } }
-  ]
+  "server": {},
+  "text-recognizers": []
 }
 ```
 
+Since 4.2.0 an OCR engine under `parsers` fails the config load; engines go
+under `engines` and are named in `text-recognizers`.
+
 ## Memory
 
 Size the container's `--memory` limit; don't pass `-Xmx` — the JVM sizes its
diff --git a/CHANGES.txt b/CHANGES.txt
index fee32d6a5b..77e7c60b46 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -63,6 +63,11 @@ Release 4.3.0 - ???
 
   OTHER CHANGES
 
+   * The file-to-markdown-docker agent skill turns OCR off with an empty 
text-recognizers
+     list and a server block (its tesseract-ocr-parser config failed to load 
since 4.2.0),
+     and checks tk:parsed-by-full-set, not pdf:ocr-page-count, to confirm OCR 
ran. A test
+     loads every config in the user skills in tika-app and tika-server 
(TIKA-XXXX).
+
    * A runtime fetcher or emitter save or delete reaches running pipes workers 
at
      their next fetch, through a change token on file and ignite stores
      (ConfigStore.changeToken); before, a worker kept the instance it first 
built
diff --git 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/SkillConfigExamplesTest.java
 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/SkillConfigExamplesTest.java
new file mode 100644
index 0000000000..7d68e385fa
--- /dev/null
+++ 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/SkillConfigExamplesTest.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.server.standard;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Options;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.server.core.TikaServerConfig;
+
+/**
+ * The user skills ship in the source release and agents copy their configs 
verbatim: every one
+ * must load in tika-app and tika-server.
+ */
+public class SkillConfigExamplesTest {
+
+    private static final String SKILLS_RELATIVE_PATH = ".skills/users";
+
+    private static final Pattern JSON_BLOCK = 
Pattern.compile("```json\\n(.*?)```", Pattern.DOTALL);
+
+    // a block with none of these is an output sample, not a config
+    private static final Set<String> CONFIG_KEYS = Set.of("server", "parsers", 
"engines",
+            "text-recognizers", "parse-context", "pipes", "detectors", 
"inference");
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    public void testUserSkillConfigsLoad() throws Exception {
+        Map<String, String> configs = findConfigs();
+        assertFalse(configs.isEmpty(), "no configs found under " + 
SKILLS_RELATIVE_PATH);
+        List<String> failures = new ArrayList<>();
+        int i = 0;
+        for (Map.Entry<String, String> e : configs.entrySet()) {
+            Path file = tempDir.resolve("config-" + i++ + ".json");
+            Files.writeString(file, e.getValue(), StandardCharsets.UTF_8);
+            try {
+                TikaLoader.load(file).loadParsers();
+                CommandLine cl = new DefaultParser().parse(
+                        new Options().addOption("c", true, ""), new String[] 
{"-c", file.toString()});
+                TikaServerConfig.load(cl);
+            } catch (Exception ex) {
+                failures.add(e.getKey() + ": " + ex.getMessage());
+            }
+        }
+        assertTrue(failures.isEmpty(), "skill configs that fail to load:\n" + 
String.join("\n", failures));
+    }
+
+    private static Map<String, String> findConfigs() throws Exception {
+        ObjectMapper mapper = new ObjectMapper();
+        Path root = findSkillsDir();
+        Map<String, String> configs = new TreeMap<>();
+        try (Stream<Path> files = Files.walk(root)) {
+            for (Path p : (Iterable<Path>) 
files.filter(Files::isRegularFile)::iterator) {
+                String name = root.relativize(p).toString();
+                if (!name.endsWith(".json") && !name.endsWith(".md")) {
+                    continue;
+                }
+                String text = Files.readString(p, StandardCharsets.UTF_8);
+                if (name.endsWith(".json")) {
+                    configs.put(name, text);
+                } else if (name.endsWith(".md")) {
+                    Matcher m = JSON_BLOCK.matcher(text);
+                    while (m.find()) {
+                        JsonNode node = mapper.readTree(m.group(1));
+                        if (node.isObject() && 
CONFIG_KEYS.stream().anyMatch(node::has)) {
+                            int line = text.substring(0, 
m.start()).split("\n", -1).length;
+                            configs.put(name + ":" + line, m.group(1));
+                        }
+                    }
+                }
+            }
+        }
+        return configs;
+    }
+
+    private static Path findSkillsDir() {
+        Path dir = Path.of("").toAbsolutePath();
+        for (int i = 0; i < 6 && dir != null; i++, dir = dir.getParent()) {
+            Path candidate = dir.resolve(SKILLS_RELATIVE_PATH);
+            if (Files.isDirectory(candidate)) {
+                return candidate;
+            }
+        }
+        throw new IllegalStateException(
+                "could not locate " + SKILLS_RELATIVE_PATH + " above " + 
Path.of("").toAbsolutePath());
+    }
+}

Reply via email to