diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java new file mode 100644 index 000000000..41947dc1b --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java @@ -0,0 +1,138 @@ +/* + * 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.geaflow.ai.provenance; + +import java.util.Objects; + +/** + * Source lineage of an extracted fact: the document and chunk the fact came + * from, the span within the chunk, and the extractor and schema versions that + * produced it. + * + *

Provenance records reference source coordinates only. They must never + * carry raw document text so they can be logged and persisted without leaking + * private content.

+ */ +public class ProvenanceRef { + + private final String documentId; + private final String chunkId; + private final SourceSpan sourceSpan; + private final String extractorVersion; + private final String schemaVersion; + + public ProvenanceRef(String documentId, String chunkId, SourceSpan sourceSpan, + String extractorVersion, String schemaVersion) { + this.documentId = documentId; + this.chunkId = chunkId; + this.sourceSpan = sourceSpan; + this.extractorVersion = extractorVersion; + this.schemaVersion = schemaVersion; + } + + public String getDocumentId() { + return documentId; + } + + public String getChunkId() { + return chunkId; + } + + /** + * The span within the chunk this fact was extracted from, or null when the + * fact is attributed to the chunk as a whole. + */ + public SourceSpan getSourceSpan() { + return sourceSpan; + } + + public String getExtractorVersion() { + return extractorVersion; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + /** + * Validates that provenance is present for an extracted fact. + * + * @throws IllegalArgumentException when provenance is missing + */ + public static void validate(ProvenanceRef provenance) { + if (provenance == null) { + throw new IllegalArgumentException("provenance is missing for extracted fact"); + } + provenance.validate(); + } + + /** + * Validates this reference, throwing {@link IllegalArgumentException} when + * the document, chunk, extractor version or schema version is missing, or + * when the optional span does not belong to the referenced chunk. + */ + public void validate() { + require(documentId, "document id"); + require(chunkId, "chunk id"); + require(extractorVersion, "extractor version"); + require(schemaVersion, "schema version"); + if (sourceSpan != null) { + sourceSpan.validate(); + if (!Objects.equals(sourceSpan.getChunkId(), chunkId)) { + throw new IllegalArgumentException("source span chunk id '" + sourceSpan.getChunkId() + + "' does not match provenance chunk id '" + chunkId + "'"); + } + } + } + + private static void require(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("provenance " + name + " is required"); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProvenanceRef that = (ProvenanceRef) o; + return Objects.equals(documentId, that.documentId) + && Objects.equals(chunkId, that.chunkId) + && Objects.equals(sourceSpan, that.sourceSpan) + && Objects.equals(extractorVersion, that.extractorVersion) + && Objects.equals(schemaVersion, that.schemaVersion); + } + + @Override + public int hashCode() { + return Objects.hash(documentId, chunkId, sourceSpan, extractorVersion, schemaVersion); + } + + @Override + public String toString() { + return "ProvenanceRef{documentId='" + documentId + "', chunkId='" + chunkId + + "', sourceSpan=" + sourceSpan + ", extractorVersion='" + extractorVersion + + "', schemaVersion='" + schemaVersion + "'}"; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java new file mode 100644 index 000000000..48c1e3654 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java @@ -0,0 +1,106 @@ +/* + * 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.geaflow.ai.provenance; + +import java.util.Objects; + +/** + * Identifies a contiguous span of text within a chunk. + * + *

A span references positions only. It must never carry raw document text so + * that provenance records can be logged and persisted without leaking private + * content.

+ */ +public class SourceSpan { + + private final String chunkId; + private final int startOffset; + private final int endOffset; + + public SourceSpan(String chunkId, int startOffset, int endOffset) { + this.chunkId = chunkId; + this.startOffset = startOffset; + this.endOffset = endOffset; + } + + /** + * Creates a span and eagerly validates it. + */ + public static SourceSpan of(String chunkId, int startOffset, int endOffset) { + SourceSpan span = new SourceSpan(chunkId, startOffset, endOffset); + span.validate(); + return span; + } + + public String getChunkId() { + return chunkId; + } + + public int getStartOffset() { + return startOffset; + } + + public int getEndOffset() { + return endOffset; + } + + /** + * Validates this span, throwing {@link IllegalArgumentException} when the + * chunk id is missing or the offsets do not form a valid range. + */ + public void validate() { + if (chunkId == null || chunkId.trim().isEmpty()) { + throw new IllegalArgumentException("source span chunk id is required"); + } + if (startOffset < 0) { + throw new IllegalArgumentException( + "source span start offset must be non-negative, got " + startOffset); + } + if (endOffset < startOffset) { + throw new IllegalArgumentException("source span end offset " + endOffset + + " must be greater than or equal to start offset " + startOffset); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SourceSpan that = (SourceSpan) o; + return startOffset == that.startOffset + && endOffset == that.endOffset + && Objects.equals(chunkId, that.chunkId); + } + + @Override + public int hashCode() { + return Objects.hash(chunkId, startOffset, endOffset); + } + + @Override + public String toString() { + return "SourceSpan{chunkId='" + chunkId + "', startOffset=" + startOffset + + ", endOffset=" + endOffset + '}'; + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java new file mode 100644 index 000000000..a8de8c379 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java @@ -0,0 +1,129 @@ +/* + * 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.geaflow.ai.provenance; + +import com.google.gson.Gson; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ProvenanceRefTest { + + private static final Gson GSON = new Gson(); + + @Test + public void testValidProvenancePassesValidation() { + SourceSpan span = new SourceSpan("chunk-000012", 48, 132); + ProvenanceRef provenance = new ProvenanceRef("doc-001", "chunk-000012", span, + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + Assertions.assertDoesNotThrow(() -> ProvenanceRef.validate(provenance)); + Assertions.assertEquals("doc-001", provenance.getDocumentId()); + Assertions.assertEquals("chunk-000012", provenance.getChunkId()); + Assertions.assertEquals(span, provenance.getSourceSpan()); + } + + @Test + public void testProvenanceWithoutSpanIsValid() { + ProvenanceRef provenance = new ProvenanceRef("doc-001", "chunk-000013", null, + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + Assertions.assertDoesNotThrow(() -> provenance.validate()); + Assertions.assertNull(provenance.getSourceSpan()); + } + + @Test + public void testMissingProvenanceFailsValidation() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> ProvenanceRef.validate(null)); + } + + @Test + public void testMissingRequiredFieldsFailValidation() { + SourceSpan span = new SourceSpan("chunk-1", 0, 10); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef(null, "chunk-1", span, "e-1", "s-1").validate()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", " ", span, "e-1", "s-1").validate()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", "chunk-1", span, null, "s-1").validate()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", "chunk-1", span, "e-1", null).validate()); + } + + @Test + public void testSpanFromOtherChunkFails() { + SourceSpan span = new SourceSpan("chunk-other", 0, 10); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", "chunk-1", span, "e-1", "s-1").validate()); + } + + @Test + public void testJsonExampleRoundTrip() throws IOException { + ProvenanceRef provenance = readJson("/provenance/fact-provenance.json", + ProvenanceRef.class); + Assertions.assertDoesNotThrow(() -> provenance.validate()); + ProvenanceRef expected = new ProvenanceRef("doc-20260822-001", "chunk-000012", + new SourceSpan("chunk-000012", 48, 132), + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + Assertions.assertEquals(expected, provenance); + Assertions.assertEquals(expected, GSON.fromJson(GSON.toJson(provenance), ProvenanceRef.class)); + } + + @Test + public void testJsonExampleWithoutSpanRoundTrip() throws IOException { + ProvenanceRef provenance = readJson("/provenance/fact-provenance-without-span.json", + ProvenanceRef.class); + Assertions.assertDoesNotThrow(() -> provenance.validate()); + Assertions.assertNull(provenance.getSourceSpan()); + Assertions.assertEquals(provenance, + GSON.fromJson(GSON.toJson(provenance), ProvenanceRef.class)); + } + + @Test + public void testSourceSpanJsonExampleRoundTrip() throws IOException { + SourceSpan span = readJson("/provenance/source-span.json", SourceSpan.class); + Assertions.assertDoesNotThrow(() -> span.validate()); + Assertions.assertEquals(new SourceSpan("chunk-000012", 48, 132), span); + } + + @Test + public void testProvenanceDoesNotCarryRawText() { + String rawDocumentText = "Alice works at Acme Corp in Shanghai."; + SourceSpan span = new SourceSpan("chunk-000012", 0, rawDocumentText.length()); + ProvenanceRef provenance = new ProvenanceRef("doc-001", "chunk-000012", span, + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + String json = GSON.toJson(provenance); + Assertions.assertFalse(json.contains(rawDocumentText), + "provenance must not embed raw document text"); + } + + private static T readJson(String resource, Class type) throws IOException { + try (InputStream stream = ProvenanceRefTest.class.getResourceAsStream(resource)) { + Assertions.assertNotNull(stream, "missing test resource " + resource); + BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8)); + return GSON.fromJson(reader.lines().collect(Collectors.joining("\n")), type); + } + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java new file mode 100644 index 000000000..2b0a62109 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java @@ -0,0 +1,71 @@ +/* + * 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.geaflow.ai.provenance; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SourceSpanTest { + + @Test + public void testValidSpanPassesValidation() { + SourceSpan span = new SourceSpan("chunk-000012", 48, 132); + Assertions.assertDoesNotThrow(() -> span.validate()); + Assertions.assertEquals("chunk-000012", span.getChunkId()); + Assertions.assertEquals(48, span.getStartOffset()); + Assertions.assertEquals(132, span.getEndOffset()); + } + + @Test + public void testEmptySpanIsValid() { + // A zero-length span (e.g. an insertion point) is still a valid range. + Assertions.assertDoesNotThrow(() -> SourceSpan.of("chunk-1", 10, 10)); + } + + @Test + public void testMissingChunkIdFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of(null, 0, 10)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of(" ", 0, 10)); + } + + @Test + public void testNegativeStartOffsetFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of("chunk-1", -1, 10)); + } + + @Test + public void testEndBeforeStartFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of("chunk-1", 20, 10)); + } + + @Test + public void testEqualsAndHashCode() { + SourceSpan first = new SourceSpan("chunk-1", 0, 10); + SourceSpan second = new SourceSpan("chunk-1", 0, 10); + SourceSpan other = new SourceSpan("chunk-2", 0, 10); + Assertions.assertEquals(first, second); + Assertions.assertEquals(first.hashCode(), second.hashCode()); + Assertions.assertNotEquals(first, other); + } +} diff --git a/geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json b/geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json new file mode 100644 index 000000000..deca22e50 --- /dev/null +++ b/geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json @@ -0,0 +1,7 @@ +{ + "documentId": "doc-20260822-001", + "chunkId": "chunk-000013", + "sourceSpan": null, + "extractorVersion": "fake-extractor-1.0.0", + "schemaVersion": "extraction-schema-1.0.0" +} diff --git a/geaflow-ai/src/test/resources/provenance/fact-provenance.json b/geaflow-ai/src/test/resources/provenance/fact-provenance.json new file mode 100644 index 000000000..a042f13e5 --- /dev/null +++ b/geaflow-ai/src/test/resources/provenance/fact-provenance.json @@ -0,0 +1,11 @@ +{ + "documentId": "doc-20260822-001", + "chunkId": "chunk-000012", + "sourceSpan": { + "chunkId": "chunk-000012", + "startOffset": 48, + "endOffset": 132 + }, + "extractorVersion": "fake-extractor-1.0.0", + "schemaVersion": "extraction-schema-1.0.0" +} diff --git a/geaflow-ai/src/test/resources/provenance/source-span.json b/geaflow-ai/src/test/resources/provenance/source-span.json new file mode 100644 index 000000000..22e08cc58 --- /dev/null +++ b/geaflow-ai/src/test/resources/provenance/source-span.json @@ -0,0 +1,5 @@ +{ + "chunkId": "chunk-000012", + "startOffset": 48, + "endOffset": 132 +}