anyformat 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {anyformat-1.2.0 → anyformat-1.3.0}/PKG-INFO +1 -1
  2. {anyformat-1.2.0 → anyformat-1.3.0}/pyproject.toml +1 -1
  3. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/_viz.py +6 -2
  4. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/cli.py +1 -1
  5. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/fields.py +1 -1
  6. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/results.py +60 -12
  7. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/topology.py +31 -0
  8. {anyformat-1.2.0 → anyformat-1.3.0}/README.md +0 -0
  9. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/__init__.py +0 -0
  10. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/_banner.py +0 -0
  11. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/_http.py +0 -0
  12. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/_source.py +0 -0
  13. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/builder.py +0 -0
  14. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/client.py +0 -0
  15. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/errors.py +0 -0
  16. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/handles.py +0 -0
  17. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/result.py +0 -0
  18. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/sdk/views.py +0 -0
  19. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/__init__.py +0 -0
  20. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/builder.py +0 -0
  21. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/checks.py +0 -0
  22. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/definition.py +0 -0
  23. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/nodes.py +0 -0
  24. {anyformat-1.2.0 → anyformat-1.3.0}/src/anyformat/workflow/schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: anyformat
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Python SDK for the AnyFormat API
5
5
  Author-email: Anyformat <info@anyformat.ai>
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anyformat"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Python SDK for the AnyFormat API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -15,6 +15,8 @@ from anyformat.sdk._source import SourceDocument
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from PIL.Image import Image
18
+ from PIL.ImageDraw import ImageDraw
19
+ from PIL.ImageFont import FreeTypeFont, ImageFont
18
20
 
19
21
  _EMERALD = (74, 222, 128) # #4ADE80 high (>=80)
20
22
  _AMBER = (251, 191, 36) # #FBBF24 medium (50-79)
@@ -110,7 +112,9 @@ def _draw_on_page(page: "Image", boxes: list[Box]) -> "Image":
110
112
  return Image.alpha_composite(base, overlay).convert("RGB")
111
113
 
112
114
 
113
- def _draw_label(draw, x: float, y: float, text: str, color: tuple[int, int, int], font) -> None:
115
+ def _draw_label(
116
+ draw: "ImageDraw", x: float, y: float, text: str, color: tuple[int, int, int], font: "FreeTypeFont | ImageFont"
117
+ ) -> None:
114
118
  left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
115
119
  pad = 2
116
120
  tag_w = (right - left) + 2 * pad
@@ -119,7 +123,7 @@ def _draw_label(draw, x: float, y: float, text: str, color: tuple[int, int, int]
119
123
  draw.text((x + pad, y + pad), text, fill=(255, 255, 255, 255), font=font)
120
124
 
121
125
 
122
- def _load_font(size: int):
126
+ def _load_font(size: int) -> "FreeTypeFont | ImageFont":
123
127
  from PIL import ImageFont
124
128
 
125
129
  return ImageFont.load_default(size=size)
@@ -179,7 +179,7 @@ class OnConflictMode(str, Enum):
179
179
 
180
180
 
181
181
  @app.callback(invoke_without_command=True)
182
- def main(
182
+ def root(
183
183
  ctx: typer.Context,
184
184
  version_flag: Annotated[
185
185
  bool,
@@ -71,7 +71,7 @@ class _Base(BaseModel):
71
71
 
72
72
  @model_validator(mode="before")
73
73
  @classmethod
74
- def _inject_data_type(cls, data):
74
+ def _inject_data_type(cls, data: object) -> object:
75
75
  # Direct Python construction (``StringField(name=..., description=...)``)
76
76
  # should not require repeating the discriminator literal — the subclass
77
77
  # already pins it. Inject it from the ``Literal[…]`` annotation when
@@ -5,9 +5,16 @@ This is the canonical response shape — every cookbook recipe and the OpenAPI s
5
5
  mirror it.
6
6
  """
7
7
 
8
- from typing import Any, Generic, Optional, TypeVar, Union
8
+ from typing import Any, Generic, Literal, Optional, TypeVar, Union
9
9
 
10
- from pydantic import BaseModel, Field, field_validator, model_serializer, model_validator
10
+ from pydantic import (
11
+ BaseModel,
12
+ Field,
13
+ SerializerFunctionWrapHandler,
14
+ field_validator,
15
+ model_serializer,
16
+ model_validator,
17
+ )
11
18
 
12
19
  # Generic over the extracted ``value`` / ``value_override`` so SDK consumers
13
20
  # can parameterise ``ExtractedField[float]`` etc. and stop seeing ``Any``. The
@@ -30,6 +37,30 @@ class Evidence(BaseModel):
30
37
  page_number: int = Field(description="1-indexed page number where the snippet was found.")
31
38
 
32
39
 
40
+ class ConsolidationCandidate(BaseModel):
41
+ """One part of a long document's answer for a field whose parts disagreed."""
42
+
43
+ value: Optional[Any] = Field(default=None, description="The value this part of the document gave.")
44
+ value_unit: Optional[str] = Field(default=None, description="The unit of `value`, when it has one.")
45
+ evidence: list[Evidence] = Field(
46
+ default_factory=list, description="Source-text snippets this part cited. Empty when it cited none."
47
+ )
48
+
49
+ @field_validator("evidence", mode="before")
50
+ @classmethod
51
+ def _coerce_null_evidence(cls, value: Any) -> Any:
52
+ return [] if value is None else value
53
+
54
+
55
+ class Consolidation(BaseModel):
56
+ """Why a field of a long document has no value: its parts gave answers that could not be combined."""
57
+
58
+ status: Literal["unresolved"] = Field(description="Always `unresolved`: no value could be chosen.")
59
+ candidates: list[ConsolidationCandidate] = Field(
60
+ description="Each part's answer, to compare against the document before entering the value."
61
+ )
62
+
63
+
33
64
  class ExtractedField(BaseModel, Generic[ValueT]):
34
65
  """One extracted field's value, confidence, and supporting evidence."""
35
66
 
@@ -58,6 +89,20 @@ class ExtractedField(BaseModel, Generic[ValueT]):
58
89
  default_factory=list,
59
90
  description="Source-text snippets the model used to derive this value.",
60
91
  )
92
+ value_source: Optional[Literal["document", "lookup"]] = Field(
93
+ default=None,
94
+ description="Where a smart-lookup field's value came from. Omitted for other fields.",
95
+ )
96
+ lookup_check: Optional[Literal["confirmed", "conflict"]] = Field(
97
+ default=None,
98
+ description="Whether the lookup `confirmed` the document value or was in `conflict` with it (the document "
99
+ "value is kept). Omitted when no lookup compared the two.",
100
+ )
101
+ consolidation: Optional[Consolidation] = Field(
102
+ default=None,
103
+ description="Set when a long document's parts gave answers that could not be combined; `value` is then "
104
+ "`null`. Omitted otherwise.",
105
+ )
61
106
 
62
107
  @field_validator("evidence", mode="before")
63
108
  @classmethod
@@ -66,6 +111,15 @@ class ExtractedField(BaseModel, Generic[ValueT]):
66
111
  # when a field has no extracted value; collapse that to an empty list.
67
112
  return [] if value is None else value
68
113
 
114
+ @model_serializer(mode="wrap")
115
+ def _drop_unset_provenance(self, handler: SerializerFunctionWrapHandler): # noqa: ANN202 — pydantic reads a return annotation as the serialization schema
116
+ # Only lookup or unresolved fields carry these; every other field keeps its shape.
117
+ data = handler(self)
118
+ for key in ("value_source", "lookup_check", "consolidation"):
119
+ if data.get(key) is None:
120
+ data.pop(key, None)
121
+ return data
122
+
69
123
 
70
124
  # A scalar extracted field, or a list of object-field rows — each row is a dict
71
125
  # from cell name to ExtractedField (the shape produced by object-type extraction).
@@ -266,11 +320,9 @@ class ClassificationResult(BaseModel):
266
320
 
267
321
 
268
322
  class Validation(BaseModel):
269
- """A configured rule verdict or an automatic extraction review warning."""
323
+ """A configured rule verdict."""
270
324
 
271
- rule_id: str = Field(
272
- description="The configured rule ID, or an `extract.consolidation:` ID for an automatic review warning."
273
- )
325
+ rule_id: str = Field(description="The configured rule ID.")
274
326
  description: str = Field(
275
327
  default="",
276
328
  description="The rule's natural-language description; empty for a deterministic rule.",
@@ -313,11 +365,7 @@ class Extraction(BaseModel):
313
365
  )
314
366
  validations: list[Validation] = Field(
315
367
  default_factory=list,
316
- description=(
317
- "Configured validation verdicts and automatic extraction review warnings. "
318
- "Unresolved chunk fields have a null value and an inconclusive warning with candidate values and evidence, "
319
- "even without a validate node."
320
- ),
368
+ description="Verdicts of the workflow's validate nodes.",
321
369
  )
322
370
 
323
371
 
@@ -484,7 +532,7 @@ class ResultsResponseV3(ResultsResponse):
484
532
  model_config = {"json_schema_extra": _v3_schema_transform}
485
533
 
486
534
  @model_serializer(mode="wrap")
487
- def _drop_legacy_collection_id(self, handler):
535
+ def _drop_legacy_collection_id(self, handler: SerializerFunctionWrapHandler): # noqa: ANN202 — pydantic reads a return annotation as the serialization schema
488
536
  data = handler(self)
489
537
  data.pop("collection_id", None)
490
538
  return data
@@ -618,6 +618,35 @@ class NoParseAsBranchTarget:
618
618
  ]
619
619
 
620
620
 
621
+ class NoParseUnderSplitter:
622
+ """Reject a Parse reachable from a Splitter, however deep. A Parse inside a
623
+ partition re-parses every file of the packet once per partition, not the slice."""
624
+
625
+ name = "no_parse_under_splitter"
626
+
627
+ def check(self, graph: "WorkflowDefinition") -> list[Violation]:
628
+ successors: dict[str, list[str]] = {}
629
+ for edge in graph.edges:
630
+ successors.setdefault(edge.source, []).append(edge.target)
631
+ frontier = [n.id for n in graph.nodes if isinstance(n, SplitterNode)]
632
+ downstream_of_splitter: set[str] = set()
633
+ while frontier:
634
+ for target in successors.get(frontier.pop(), []):
635
+ if target not in downstream_of_splitter:
636
+ downstream_of_splitter.add(target)
637
+ frontier.append(target)
638
+ return [
639
+ Violation(
640
+ rule=self.name,
641
+ message=f"parse node {node.id!r} sits downstream of a splitter; a parse "
642
+ "cannot run inside a partition.",
643
+ node_ids=[node.id],
644
+ )
645
+ for node in graph.nodes
646
+ if isinstance(node, ParseNode) and node.id in downstream_of_splitter
647
+ ]
648
+
649
+
621
650
  class EveryExtractNodeHasFields:
622
651
  """Belt-and-braces redundancy with ``ExtractionSchema.fields min_length=1``:
623
652
  pydantic catches the empty-fields case first at the wire boundary, but
@@ -839,6 +868,7 @@ RULES: list[TopologyRule] = [
839
868
  EveryExtractNodeHasFields(),
840
869
  EveryIfElseHasTrueBranch(),
841
870
  NoParseAsBranchTarget(),
871
+ NoParseUnderSplitter(),
842
872
  NoCodeSandboxTemplate(),
843
873
  CodeSandboxTemplateFollowsExtract(),
844
874
  ValidationRuleIdReferencesExist(),
@@ -894,6 +924,7 @@ __all__ = [
894
924
  "ExactlyOneParseNode",
895
925
  "NoCycles",
896
926
  "NoParseAsBranchTarget",
927
+ "NoParseUnderSplitter",
897
928
  "NonBranchingNodeHasAtMostOneOutgoing",
898
929
  "RULES",
899
930
  "SingleUpstreamNodeHasOneIncoming",
File without changes