asta-artifact 1.0.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.
@@ -0,0 +1,136 @@
1
+ # =========================
2
+ # OS / System files
3
+ # =========================
4
+ .DS_Store
5
+ .DS_Store?
6
+ ._*
7
+ .Spotlight-V100
8
+ .Trashes
9
+ Thumbs.db
10
+ ehthumbs.db
11
+ Desktop.ini
12
+
13
+ # =========================
14
+ # Editor / IDE files
15
+ # =========================
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+ *.swo
20
+ *.swn
21
+ *~
22
+ *.bak
23
+ *.tmp
24
+ *.orig
25
+
26
+ # =========================
27
+ # Logs
28
+ # =========================
29
+ logs/
30
+ *.log
31
+ npm-debug.log*
32
+ yarn-debug.log*
33
+ yarn-error.log*
34
+ pnpm-debug.log*
35
+
36
+ # =========================
37
+ # Environment variables / secrets
38
+ # =========================
39
+ .env
40
+ .env.*
41
+ !.env.example
42
+ *.pem
43
+ *.key
44
+ *.crt
45
+
46
+ # =========================
47
+ # Build / dist artifacts
48
+ # =========================
49
+ dist/
50
+ build/
51
+ out/
52
+ target/
53
+ bin/
54
+ obj/
55
+
56
+ # =========================
57
+ # Dependency directories
58
+ # =========================
59
+ node_modules/
60
+ vendor/
61
+ .venv/
62
+ venv/
63
+ env/
64
+ __pycache__/
65
+
66
+ # Yarn
67
+ **/.yarn/*
68
+ !.yarn/patches
69
+ !.yarn/plugins
70
+ !.yarn/releases
71
+ !.yarn/sdks
72
+ !.yarn/versions
73
+ .pnp.*
74
+
75
+ # =========================
76
+ # Test / coverage output
77
+ # =========================
78
+ coverage/
79
+ .coverage
80
+ .nyc_output/
81
+ test-results/
82
+ pytest_cache/
83
+
84
+ # =========================
85
+ # Temporary / cache files
86
+ # =========================
87
+ .cache/
88
+ .tmp/
89
+ .temp/
90
+ *.cache
91
+
92
+ # =========================
93
+ # Archive files
94
+ # =========================
95
+ *.zip
96
+ *.tar
97
+ *.tar.gz
98
+ *.rar
99
+ *.7z
100
+
101
+ # =========================
102
+ # Runtime files
103
+ # =========================
104
+ *.pid
105
+ *.seed
106
+ *.pid.lock
107
+
108
+ # =========================
109
+ # Misc
110
+ # =========================
111
+ *.lock
112
+ !.gitignore
113
+
114
+ # =========================
115
+ # Terraform
116
+ # =========================
117
+ *.tfstate
118
+ *.tfstate.*
119
+ .terraform/
120
+ .terraform.lock.hcl
121
+ infra/terraform.tfvars
122
+
123
+ # =========================
124
+ # Generated deploy files
125
+ # =========================
126
+ _asta_modal_*.py
127
+
128
+ # =========================
129
+ # Python packaging
130
+ # =========================
131
+ *.egg-info/
132
+
133
+ # =========================
134
+ # Claude Code worktrees
135
+ # =========================
136
+ .claude/worktrees/
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: asta-artifact
3
+ Version: 1.0.0
4
+ Summary: Asta artifact builder, models, content blocks, and HTML exporter
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: asta-types
7
+ Requires-Dist: markdown>=3.4
8
+ Requires-Dist: pydantic>=2.0
9
+ Provides-Extra: cli
10
+ Requires-Dist: click>=8.0; extra == 'cli'
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "asta-artifact"
7
+ version = "1.0.0"
8
+ description = "Asta artifact builder, models, content blocks, and HTML exporter"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "markdown>=3.4",
12
+ "pydantic>=2.0",
13
+ "asta-types",
14
+ ]
15
+
16
+ [tool.uv.sources]
17
+ asta-types = { path = "../types" }
18
+
19
+ [project.optional-dependencies]
20
+ cli = ["click>=8.0"]
21
+
22
+ [project.scripts]
23
+ artifacts = "asta_artifact.cli:main"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/asta_artifact"]
@@ -0,0 +1,60 @@
1
+ from .models import (
2
+ _AstaModel,
3
+ AnnotationType,
4
+ S2Author,
5
+ ContentBlockType,
6
+ DisplayText,
7
+ EntityType,
8
+ Facet,
9
+ Paper,
10
+ S2Paper,
11
+ Snippet,
12
+ paper_tag,
13
+ )
14
+
15
+ from .content import (
16
+ Code,
17
+ Figure,
18
+ Markdown,
19
+ Section,
20
+ SectionContext,
21
+ Sections,
22
+ SectionsContext,
23
+ )
24
+
25
+ from .artifact import Artifact
26
+ from .exporter import (
27
+ ArtifactExporter,
28
+ export_artifact_to_html,
29
+ export_artifacts_to_html_directory,
30
+ )
31
+
32
+ __all__ = [
33
+ # Enums
34
+ "EntityType",
35
+ "AnnotationType",
36
+ "ContentBlockType",
37
+ # Entity models
38
+ "S2Author",
39
+ "S2Paper",
40
+ "Paper",
41
+ # Annotation models
42
+ "Snippet",
43
+ "Facet",
44
+ "DisplayText",
45
+ "paper_tag",
46
+ # Content blocks
47
+ "Section",
48
+ "Sections",
49
+ "Markdown",
50
+ "Figure",
51
+ "Code",
52
+ "SectionContext",
53
+ "SectionsContext",
54
+ # Builder
55
+ "Artifact",
56
+ # Exporter
57
+ "ArtifactExporter",
58
+ "export_artifact_to_html",
59
+ "export_artifacts_to_html_directory",
60
+ ]
@@ -0,0 +1,281 @@
1
+ """Artifact builder producing canonical format::
2
+
3
+ {
4
+ "id": "...", "name": "...", "description": "...",
5
+ "entities": {"paper-1": {id, type, displayLabel, s2Metadata}},
6
+ "annotations": {"ann-1": {id, type, entityId, text, ...}},
7
+ "content": [{id, type, title, childIds}, {id, type, text}, ...]
8
+ }
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .models import (
19
+ Annotation,
20
+ AnnotationType,
21
+ DisplayText,
22
+ EntityType,
23
+ Facet,
24
+ Paper,
25
+ S2Paper,
26
+ Snippet,
27
+ )
28
+ from .content import (
29
+ ContentBlock,
30
+ Markdown,
31
+ Section,
32
+ SectionContext,
33
+ Sections,
34
+ SectionsContext,
35
+ content_block_from_dict,
36
+ )
37
+
38
+
39
+ class Artifact:
40
+ """Asta artifact with structured content, annotations, and entities."""
41
+
42
+ def __init__(self, artifact_id: str, name: str,
43
+ description: str | None = None):
44
+ if not artifact_id:
45
+ raise ValueError("artifact_id is required")
46
+ if not name:
47
+ raise ValueError("name is required")
48
+
49
+ self._id = artifact_id
50
+ self._name = name
51
+ self._description = description
52
+
53
+ self._content: list[ContentBlock] = []
54
+ self._entities: dict[str, Paper] = {}
55
+ self._annotations: dict[str, Annotation] = {}
56
+
57
+ self._content_counter = 0
58
+ self._entity_counter = 0
59
+ self._annotation_counter = 0
60
+
61
+ @property
62
+ def id(self) -> str:
63
+ return self._id
64
+
65
+ @property
66
+ def name(self) -> str:
67
+ return self._name
68
+
69
+ @property
70
+ def description(self) -> str | None:
71
+ return self._description
72
+
73
+ @property
74
+ def content(self) -> list[ContentBlock]:
75
+ return self._content
76
+
77
+ @property
78
+ def entities(self) -> dict[str, Paper]:
79
+ return self._entities
80
+
81
+ @property
82
+ def annotations(self) -> dict[str, Annotation]:
83
+ return self._annotations
84
+
85
+ def _next_content_id(self) -> str:
86
+ self._content_counter += 1
87
+ return f"c_{self._content_counter:03d}"
88
+
89
+ def _next_entity_id(self) -> str:
90
+ self._entity_counter += 1
91
+ return f"ent_{self._entity_counter:03d}"
92
+
93
+ def _next_annotation_id(self) -> str:
94
+ self._annotation_counter += 1
95
+ return f"ann_{self._annotation_counter:03d}"
96
+
97
+ def _update_counter(self, counter_attr: str, id_str: str) -> None:
98
+ """Update a counter to avoid ID collisions after loading."""
99
+ try:
100
+ num = int(id_str.split("_")[1])
101
+ setattr(self, counter_attr, max(getattr(self, counter_attr), num))
102
+ except (IndexError, ValueError):
103
+ pass
104
+
105
+ # --- Entity Methods ---
106
+
107
+ def add_paper_entity(self, display_label: str,
108
+ s2_metadata: S2Paper | dict | None = None,
109
+ entity_id: str | None = None) -> str:
110
+ """Add a Paper entity and return its ID."""
111
+ entity_id = entity_id or self._next_entity_id()
112
+ if isinstance(s2_metadata, dict):
113
+ s2_metadata = S2Paper.model_validate(s2_metadata)
114
+ entity = Paper(
115
+ id=entity_id,
116
+ display_label=display_label,
117
+ s2_metadata=s2_metadata,
118
+ )
119
+ self._entities[entity_id] = entity
120
+ return entity_id
121
+
122
+ # --- Annotation Methods ---
123
+
124
+ def add_snippet(self, entity_id: str, text: str = "",
125
+ heading: str | None = None,
126
+ sentence_id: str | None = None) -> str:
127
+ """Add a Snippet annotation and return its ID.
128
+
129
+ To link the snippet to a content block, append the returned ID
130
+ to the block's ``annotation_ids`` list.
131
+ """
132
+ ann_id = self._next_annotation_id()
133
+ ann = Snippet(
134
+ id=ann_id, entity_id=entity_id,
135
+ text=text, heading=heading, sentence_id=sentence_id,
136
+ )
137
+ self._annotations[ann_id] = ann
138
+ return ann_id
139
+
140
+ def add_facet(self, entity_id: str, name: str = "",
141
+ value: str | int | float | bool = "") -> str:
142
+ """Add a Facet annotation and return its ID."""
143
+ ann_id = self._next_annotation_id()
144
+ ann = Facet(
145
+ id=ann_id, entity_id=entity_id,
146
+ name=name, value=value,
147
+ )
148
+ self._annotations[ann_id] = ann
149
+ return ann_id
150
+
151
+ def add_display_text(self, entity_id: str, text: str = "") -> str:
152
+ """Add a DisplayText annotation and return its ID."""
153
+ ann_id = self._next_annotation_id()
154
+ ann = DisplayText(
155
+ id=ann_id, entity_id=entity_id,
156
+ text=text,
157
+ )
158
+ self._annotations[ann_id] = ann
159
+ return ann_id
160
+
161
+ # --- Content Methods ---
162
+
163
+ def markdown(self, text: str) -> Markdown:
164
+ """Add a top-level markdown block (not inside a section)."""
165
+ content_id = self._next_content_id()
166
+ block = Markdown(id=content_id, text=text)
167
+ self._content.append(block)
168
+ return block
169
+
170
+ def table(self, headers: list[str], rows: list[list[str]]) -> Markdown:
171
+ """Add a top-level markdown table."""
172
+ from .content import SectionContext
173
+ # Reuse SectionContext's table logic without a section
174
+ def _esc(v: str) -> str:
175
+ return str(v).replace("|", "\\|").replace("\n", " ")
176
+ lines = [
177
+ "| " + " | ".join(headers) + " |",
178
+ "| " + " | ".join("---" for _ in headers) + " |",
179
+ ]
180
+ for row in rows:
181
+ lines.append("| " + " | ".join(_esc(v) for v in row) + " |")
182
+ return self.markdown("\n".join(lines))
183
+
184
+ @contextmanager
185
+ def section(self, title: str, description: str | None = None,
186
+ section_id: str | None = None):
187
+ """Create a section and yield a SectionContext for adding content."""
188
+ content_id = section_id or self._next_content_id()
189
+ sec = Section(id=content_id, title=title, description=description)
190
+ self._content.append(sec)
191
+ ctx = SectionContext(self, sec)
192
+ yield ctx
193
+
194
+ @contextmanager
195
+ def sections(self, title: str):
196
+ """Create a SECTIONS container and yield a SectionsContext."""
197
+ content_id = self._next_content_id()
198
+ block = Sections(id=content_id, title=title)
199
+ self._content.append(block)
200
+ ctx = SectionsContext(self, block)
201
+ yield ctx
202
+
203
+ # --- Export ---
204
+
205
+ def to_dict(self) -> dict:
206
+ """Export as a dictionary matching the canonical schema."""
207
+ result: dict[str, Any] = {
208
+ "id": self._id,
209
+ "name": self._name,
210
+ }
211
+
212
+ if self._description:
213
+ result["description"] = self._description
214
+
215
+ result["entities"] = {
216
+ eid: e.to_dict() for eid, e in self._entities.items()
217
+ }
218
+ result["annotations"] = {
219
+ aid: a.to_dict() for aid, a in self._annotations.items()
220
+ }
221
+ result["content"] = [block.to_dict() for block in self._content]
222
+
223
+ return result
224
+
225
+ def to_json(self, indent: int = 2) -> str:
226
+ return json.dumps(self.to_dict(), indent=indent)
227
+
228
+ # --- Load from existing ---
229
+
230
+ @classmethod
231
+ def load(cls, path: str | Path) -> Artifact:
232
+ """Load an artifact from a JSON file."""
233
+ path = Path(path)
234
+ with open(path, "r", encoding="utf-8") as f:
235
+ data = json.load(f)
236
+ return cls.from_dict(data)
237
+
238
+ @classmethod
239
+ def from_dict(cls, data: dict) -> Artifact:
240
+ """Create an Artifact from a canonical dictionary or A2A envelope."""
241
+ # Unwrap A2A envelope if present
242
+ if "artifactId" in data and "parts" in data:
243
+ part = data["parts"][0]
244
+ data = part["data"] if isinstance(part, dict) else part.root.data
245
+ builder = cls(
246
+ artifact_id=data["id"],
247
+ name=data["name"],
248
+ description=data.get("description"),
249
+ )
250
+
251
+ # Load content
252
+ for block_data in data.get("content", []):
253
+ block = content_block_from_dict(block_data)
254
+ builder._content.append(block)
255
+ builder._update_counter("_content_counter", block.id)
256
+
257
+ # Load entities
258
+ for entity_id, entity_data in data.get("entities", {}).items():
259
+ entity_type = entity_data.get("type")
260
+ if entity_type == EntityType.PAPER.value:
261
+ entity = Paper.from_dict(entity_data)
262
+ else:
263
+ raise ValueError(f"Unknown entity type: {entity_type}")
264
+ builder._entities[entity_id] = entity
265
+ builder._update_counter("_entity_counter", entity_id)
266
+
267
+ # Load annotations
268
+ for ann_id, ann_data in data.get("annotations", {}).items():
269
+ ann_type = ann_data.get("type")
270
+ if ann_type == AnnotationType.SNIPPET.value:
271
+ ann = Snippet.from_dict(ann_data)
272
+ elif ann_type == AnnotationType.FACET.value:
273
+ ann = Facet.from_dict(ann_data)
274
+ elif ann_type == AnnotationType.DISPLAY_TEXT.value:
275
+ ann = DisplayText.from_dict(ann_data)
276
+ else:
277
+ raise ValueError(f"Unknown annotation type: {ann_type}")
278
+ builder._annotations[ann_id] = ann
279
+ builder._update_counter("_annotation_counter", ann_id)
280
+
281
+ return builder
@@ -0,0 +1,77 @@
1
+ """Artifacts CLI — export A2A task artifacts to various formats.
2
+
3
+ Usage:
4
+ artifacts --input test-output/ --output export/
5
+ artifacts --input test-output/ --output export/ --format html
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+
14
+ import click
15
+
16
+ from asta_artifact import Artifact
17
+ from asta_artifact.exporter import export_artifacts_to_html_directory
18
+
19
+
20
+ def _load_artifacts_from_dir(input_dir: str) -> list[Artifact]:
21
+ """Load artifacts from all JSON files in a directory."""
22
+ artifacts = []
23
+ for fname in sorted(os.listdir(input_dir)):
24
+ if not fname.endswith(".json"):
25
+ continue
26
+ path = os.path.join(input_dir, fname)
27
+ with open(path, "r", encoding="utf-8") as f:
28
+ data = json.load(f)
29
+ # Support both raw artifact dicts and A2A envelopes with parts
30
+ if "artifacts" in data:
31
+ for art_data in data["artifacts"]:
32
+ if "parts" in art_data:
33
+ for part in art_data["parts"]:
34
+ part_data = part.get("data") or part
35
+ if "entities" in part_data and "content" in part_data:
36
+ artifacts.append(Artifact.from_dict(part_data))
37
+ elif "entities" in art_data and "content" in art_data:
38
+ artifacts.append(Artifact.from_dict(art_data))
39
+ elif "parts" in data:
40
+ for part in data["parts"]:
41
+ part_data = part.get("data") or part
42
+ if "entities" in part_data and "content" in part_data:
43
+ artifacts.append(Artifact.from_dict(part_data))
44
+ elif "entities" in data and "content" in data:
45
+ artifacts.append(Artifact.from_dict(data))
46
+ return artifacts
47
+
48
+
49
+ @click.command()
50
+ @click.option("--input", "input_dir", required=True,
51
+ help="Directory containing result JSON files")
52
+ @click.option("--output", "output_dir", required=True,
53
+ help="Directory to write exported files")
54
+ @click.option("--format", "fmt", default="html",
55
+ type=click.Choice(["html"]),
56
+ help="Output format (default: html)")
57
+ def main(input_dir: str, output_dir: str, fmt: str) -> None:
58
+ """Export A2A task artifacts to HTML."""
59
+ if not os.path.isdir(input_dir):
60
+ click.echo(f"Error: {input_dir} is not a directory")
61
+ sys.exit(1)
62
+
63
+ artifacts = _load_artifacts_from_dir(input_dir)
64
+ if not artifacts:
65
+ click.echo(f"Error: no artifacts found in {input_dir}")
66
+ sys.exit(1)
67
+
68
+ click.echo(f"Loaded {len(artifacts)} artifacts from {input_dir}")
69
+
70
+ if fmt == "html":
71
+ export_artifacts_to_html_directory(artifacts, output_dir)
72
+
73
+ click.echo(f"Exported to {output_dir}")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()