TIKA-4766: typed Document parse contract for tika-grpc#2921
Conversation
c1de042 to
1d2d5bd
Compare
…t contract One small, stable proto instead of a message per source format. Contract (tika-grpc-api, ~200 lines of proto): - Document: content as a structured markdown block tree (headings, paragraphs, lists, tables, code blocks, inline runs -- CommonMark + GFM, the same markdown ToMarkdownContentHandler already renders), plus `markdown` as the authoritative rendered form. - DocumentMetadata: a small bounded set of typed common fields grouped by concern, not by source format (title/authors/keywords/languages, created and modified as Timestamps, page/word/character counts, dimensions, rights). - Tagged tail: `extra` carries every remaining Tika key losslessly and multivalue-preserving, typed only where Tika's own Property declares a type (integer/real/boolean/date) and string otherwise -- never guessed. - `embedded` recurses: a parent PDF and an embedded image are each a fully typed Document. - `format_category` is a cheap routing hint; cross-cutting concerns such as Creative Commons rights coexist with it rather than fighting a oneof. Format specifics live in per-parser DocumentTransformer code (tika-grpc-mapper), not in the wire contract: adding or changing a parser never touches the proto, and metadata churn lands in the mapper and the tagged tail, so a new or renamed metadata key never forces a client rebuild. Server (tika-grpc): - FetchAndParseReply.document (field 5) replaces the fields map (field 2, now reserved). TikaGrpcServerImpl maps parse output via DocumentBuilder. Modules: tika-grpc-api (proto + generated messages + bundled FileDescriptorSet), tika-grpc-mapper (DocumentBuilder, per-format transformers, markdown block-tree builder), both listed in tika-bom. Tests: transformer tests against real parse fixtures per format, block tree round-trip tests, DocumentBuilder envelope/status/embedded tests, server tests updated to read FetchAndParseReply.document.
1d2d5bd to
da51be9
Compare
|
@nddipiazza @tballison what do you think of this design instead? far less fields - ability to roll your own protobuf model in the future. Best of both worlds. Document structure is very markdown-friendly. I'll make the output of this be able to be the input of the grpc OpenNLP grpc server. |
nddipiazza
left a comment
There was a problem hiding this comment.
Review: e2e test coverage for the new typed Document contract
Reviewed the design overall — the shift from mirroring Tika's metadata taxonomy in protobuf to a small, stable document.proto (208 lines) plus per-parser mapper code is a solid answer to the #2916 feedback. A few things worth addressing before merge:
Testing gap: e2e coverage does not exercise the new contract
The only change in tika-e2e-tests/tika-grpc is a 2-line fix in HandlerTypeTest.java:
- String htmlContent = htmlReply.getFieldsMap().get("X-TIKA:content");
+ String htmlContent = htmlReply.getDocument().getMarkdown();(same for the TEXT handler case). This is a compile-fix to keep the pre-existing assertion working against the new API — it only proves document.markdown is non-empty over a real gRPC round-trip for two handler types.
It does not exercise, end-to-end, through the live server:
DocumentMetadatatyped fields (title/authors/dates/counts) — no e2e assertion anywhereextratagged-tail typing (int/bool/date/string) — no e2e assertionblocksstructured content tree (headings/tables/lists/code) — untouched by any e2e testembeddedrecursion for container formats (e.g. an Office doc with an embedded image) — no e2e coverage at allformat_categoryrouting hint — untouched
The other e2e tests (FileSystemFetcherTest, IgniteConfigStoreTest, ExternalTestBase) only ever inspect getFetchKey()/getStatus() on FetchAndParseReply — none of them look at getDocument(), so the bulk-corpus/streaming/ignite e2e paths give zero signal on the new typed contract.
The real proof of correctness for the mapping logic lives entirely in tika-grpc-mapper's unit tests (DocumentBuilderTest, MarkdownBlockTreeBuilderTest, one test class per transformer), which feed fixture-derived Metadata/markdown into DocumentBuilder in-process. That's good for the mapping logic itself, but it bypasses the actual gRPC wire serialization, the live server (TikaGrpcServerImpl), the pipes client, and fetcher plumbing entirely.
Ask: add (or extend HandlerTypeTest) at least one e2e case per format that fetches a real file through the live gRPC server and asserts on document.metadata (a couple of typed fields), document.extra (at least one tagged key), and one document.embedded case (e.g. an Office/PDF file with an embedded image) — not just that markdown is non-empty. Right now this new, larger surface area has no live-server coverage at all.
Other findings from code inspection
- Likely-dead error path:
DocumentBuilder.build()'sprimary == nullbranch (returnsFAILEDwith "No metadata returned from parse") looks unreachable in production — the only caller,TikaGrpcServerImpl, always passes a non-nullMetadata(tikaMetadata = new Metadata()as fallback whenemitData()/metadata list is empty). Worth double-checking this is intentional, since a real fetch failure with no emit data currently produces an "empty-but-success-shaped"Documentrather than hitting this explicit error branch. - Duplication: 7 of 8 format transformers (
Generic,Pdf,Html,Image,Office,Rtf,Epub) repeat an identical block mapping TITLE/DESCRIPTION/CREATOR/SUBJECT/LANGUAGE/CREATED/MODIFIED. Consider factoring this into a shared helper inTransformSupportcalled by each transformer, keeping only the format-specific additions per-transformer. - Minor:
parseTimeMsinTikaGrpcServerImplmeasures fetch+parse combined (timer starts beforepipesClient.process()), though the field's doc/intent reads as parse-only time.
Nice work on the overall shape of the contract — the main blocker from my read is the e2e coverage gap above.
nddipiazza
left a comment
There was a problem hiding this comment.
Overall this is a solid design and a much better-scoped answer to the #2916 feedback: a small stable 208-line proto, format logic pushed into per-parser Java transformers, and a lossless typed+tagged tail. Requesting changes mainly on test coverage for the new contract, plus a couple of correctness/maintainability issues found while reading the mapper code (see inline comments).
| "Parse should succeed with HTML handler type"); | ||
|
|
||
| String htmlContent = htmlReply.getFieldsMap().get("X-TIKA:content"); | ||
| String htmlContent = htmlReply.getDocument().getMarkdown(); |
There was a problem hiding this comment.
This is the only change anywhere in tika-e2e-tests/tika-grpc for this PR (this line and the analogous one at line 334 for the TEXT handler). It's a compile-fix that keeps the pre-existing "content is non-empty" assertion alive against the new API, but it does not exercise the new typed contract through the live gRPC server at all.
None of the e2e tests (HandlerTypeTest, FileSystemFetcherTest, IgniteConfigStoreTest, ExternalTestBase) assert on:
document.metadata(typed title/authors/dates/counts)document.extra(tagged-tail typing: int/bool/date/string)document.blocks(structured content tree: headings/tables/lists/code)document.embedded(recursion for container formats, e.g. an Office doc with an embedded image)document.format_category
FileSystemFetcherTest/IgniteConfigStoreTest/ExternalTestBase only ever inspect getFetchKey()/getStatus() on FetchAndParseReply and never call getDocument() at all, so the bulk-corpus/streaming/ignite e2e paths give zero signal on the new contract this PR introduces.
The real proof of correctness lives entirely in tika-grpc-mapper's unit tests, which feed fixture-derived Metadata/markdown into DocumentBuilder in-process -- good for the mapping logic, but it bypasses the actual gRPC wire serialization, the live server (TikaGrpcServerImpl), the pipes client, and fetcher plumbing entirely.
Requesting: add (or extend this test) at least one e2e case per format that fetches a real file through the live server and asserts on a couple of document.metadata fields, at least one document.extra tagged key, and one document.embedded case -- not just that markdown is non-empty.
| /** Maps Tika parse output (primary metadata plus optional embedded metadata list) to {@link Document}. */ | ||
| public static Document build(Metadata primary, List<Metadata> allMetadata, String markdownBody, | ||
| String docId, String pipesStatus, long parseTimeMs) { | ||
| if (primary == null) { |
There was a problem hiding this comment.
This primary == null branch looks unreachable in production. The only production caller, TikaGrpcServerImpl, computes Metadata primary = metadataList.isEmpty() ? tikaMetadata : metadataList.get(0), and tikaMetadata is always new Metadata() (never null) -- so this guard is only exercised today via DocumentBuilderTest calling DocumentBuilder.build(null, ...) directly.
Practical effect: on a real fetch/parse failure where emitData()/the metadata list is empty, the server currently builds a Document from an empty Metadata object rather than hitting this explicit "No metadata returned from parse" error path. The ParseStatus.status still ends up FAILED via mapPipesStatus, so it's not silently treated as success, but the specific error message here never surfaces. Worth either wiring the server to pass null when there's truly no metadata, or removing this branch if it's intentionally test-only.
| DocumentMetadata.Builder meta = document.getMetadataBuilder(); | ||
|
|
||
| // Common, cross-format fields -> typed (a date is a Timestamp, a count is an int). | ||
| TransformSupport.setString(tika, TikaCoreProperties.TITLE, meta::setTitle, consumed); |
There was a problem hiding this comment.
This same 7-line block (TITLE/DESCRIPTION/CREATOR/SUBJECT/LANGUAGE/CREATED/MODIFIED) is duplicated verbatim across 7 of the 8 transformers (Generic, Pdf, Html, Image, Office, Rtf, Epub). Consider factoring it into a shared TransformSupport.mapCommonFields(tika, meta, consumed) helper called by each transformer, leaving only the format-specific additions (page/word counts, dimensions, etc.) per-transformer. This would also reduce the risk of the common mapping drifting between formats over time.
| if (pipesResult.status().equals(PipesResult.RESULT_STATUS.FETCH_EXCEPTION)) { | ||
| fetchReplyBuilder.setErrorMessage(pipesResult.message()); | ||
| } | ||
| long parseTimeMs = (System.nanoTime() - parseStart) / 1_000_000L; |
There was a problem hiding this comment.
parseTimeMs is measured from before pipesClient.process() is called, so it includes fetch time as well as parse time, not just parse time. Either rename the field/timer to reflect that it's fetch+parse latency, or move parseStart to wherever the actual parse begins if ParseStatus.parse_time_ms is meant to be parse-only.
Summary
Follow-up to #2916, reshaped per the review there. Instead of mirroring Tika's open metadata taxonomy in protobuf (~5k lines of proto, per-format messages), this PR types the thing that is actually stable: the parsed document. One small contract —
document.protois 208 lines — and format specifics live in per-parser mapping code, never in the wire.FetchAndParseReply.fields(map<string,string>, field 2, now reserved) is replaced byFetchAndParseReply.document.How this answers the #2916 review
extratail), not schema — add/rename/retype a TikaPropertyand no client regenerates anythingDocument.extracarries every Tika key, multivalue-preserving; a test asserts nothing is droppedDocumentMetadatatypes only the bounded cross-format fields (title, authors, dates asTimestamp, counts, dimensions, rights)The shape
markdown(the same renderToMarkdownContentHandleralready produces since TIKA-4730) plusblocks— that markdown parsed once, format-agnostically, into a structured tree of headings/paragraphs/lists/tables/code blocks/inline runs (CommonMark + GFM, a spec that does not churn). This is what a downstream NLP/RAG/embeddings consumer actually wants: typed tables and sections, not a string to re-parse.DocumentMetadata, grouped by concern, not by source format. Dates areTimestamps, counts are ints — not strings that 12 language clients each re-parse.extra— every remaining key, typed only where Tika's ownPropertydeclares a type (integer/real/boolean/date), string otherwise, never guessed.embeddedrecurses: a PDF with an embedded image is a parentDocumentwith a fully typed child — no forcing two formats into one bucket (this was the oneof problem from TIKA-4766: Typed parse response grpc #2916).DocumentTransformer(seetika-grpc-mapper/docs/EXTENSIONS.md);PdfDocumentTransformeris 65 lines and the wire contract does not move.Deliberately not in this PR (follow-ups, each its own PR)
Documentas agoogle.protobuf.Any— so wildly different result shapes (e.g. a document-layout model's tree) never require Tika to model them. Built and tested on a branch; kept out to keep this reviewable..mdinput files (separate JIRA).optionalfields, compatible both directions.Open decisions where reviewer preference wins
repeated MetadataFieldwith a typed value oneof (as implemented) vs themap<string, StringList>suggested in TIKA-4766: Typed parse response grpc #2916. The typed-where-declared tail preserves types without guessing; the map is maximally churn-proof. Swapping is a one-message change — happy to go either way.markdown+blocksboth: today both ship (string render + structured tree). If payload size matters, a per-request flag choosing one is easy.fieldsis hard-removed (4.0, nothing consumes it yet); can switch to deprecate-then-remove if preferred.Client migration
fields["X-TIKA:content"]document.markdown(or walkdocument.blocks)fields["Content-Type"]document.content_typedocument.metadata.title/.authors/.created(Timestamp)document.extra(typed by declaredPropertytype, string otherwise)Test plan
./mvnw -pl tika-grpc-api,tika-grpc-mapper,tika-grpc test— green (transformer tests against real parse fixtures per format, block-tree tests,DocumentBuilderenvelope/status/embedded tests, server tests readingFetchAndParseReply.document)tika-grpc-apijar bundlesMETA-INF/org.apache.tika.grpc.v1.descriptors(verified: containsdocument.proto)tika-grpc-e2e-testcompiles against the new APIDownstream context: this contract is what the OpenNLP gRPC work (OPENNLP-1833) will consume as input — Tika parse → typed document → NLP/embeddings without re-parsing strings.