@laitszkin/apollo-toolkit 2.14.23 → 3.0.0
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.
- package/AGENTS.md +3 -0
- package/CHANGELOG.md +11 -0
- package/README.md +9 -0
- package/analyse-app-logs/README.md +5 -5
- package/analyse-app-logs/SKILL.md +7 -5
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/codex/codex-memory-manager/README.md +2 -2
- package/codex/codex-memory-manager/SKILL.md +5 -5
- package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +3 -2
- package/codex/learn-skill-from-conversations/README.md +1 -1
- package/codex/learn-skill-from-conversations/SKILL.md +2 -2
- package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +3 -2
- package/docs-to-voice/README.md +3 -3
- package/docs-to-voice/SKILL.md +4 -4
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +51 -0
- package/feature-propose/SKILL.md +1 -0
- package/generate-spec/README.md +3 -6
- package/generate-spec/SKILL.md +2 -3
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/generate-spec/tests/test_create_specs.py +166 -0
- package/katex/SKILL.md +3 -3
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/katex/tests/test_render_katex.py +174 -0
- package/learning-error-book/SKILL.md +2 -2
- package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +134 -0
- package/lib/cli.js +66 -0
- package/lib/tool-runner.js +214 -0
- package/maintain-project-constraints/SKILL.md +3 -3
- package/maintain-skill-catalog/SKILL.md +2 -2
- package/novel-to-short-video/SKILL.md +2 -2
- package/open-github-issue/README.md +31 -22
- package/open-github-issue/SKILL.md +54 -40
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/open_github_issue.py +130 -3
- package/open-github-issue/tests/test_open_github_issue.py +95 -0
- package/openai-text-to-image-storyboard/README.md +1 -1
- package/openai-text-to-image-storyboard/SKILL.md +1 -1
- package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +177 -0
- package/package.json +1 -1
- package/read-github-issue/SKILL.md +9 -9
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/resolve-review-comments/SKILL.md +8 -8
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/review-codebases/README.md +2 -0
- package/review-codebases/SKILL.md +1 -0
- package/text-to-short-video/README.md +1 -1
- package/text-to-short-video/SKILL.md +1 -1
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
- package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +194 -0
- package/weekly-financial-event-report/SKILL.md +2 -2
- package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +64 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import string
|
|
10
|
+
import tempfile
|
|
11
|
+
import types
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
PIL_AVAILABLE = importlib.util.find_spec("PIL") is not None
|
|
17
|
+
|
|
18
|
+
if PIL_AVAILABLE:
|
|
19
|
+
from PIL import Image
|
|
20
|
+
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate_storyboard_images.py"
|
|
21
|
+
SPEC = importlib.util.spec_from_file_location("generate_storyboard_images", SCRIPT_PATH)
|
|
22
|
+
MODULE = importlib.util.module_from_spec(SPEC)
|
|
23
|
+
SPEC.loader.exec_module(MODULE)
|
|
24
|
+
else:
|
|
25
|
+
Image = None
|
|
26
|
+
MODULE = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def png_bytes(width: int, height: int, color: tuple[int, int, int] = (64, 128, 255)) -> bytes:
|
|
30
|
+
buffer = io.BytesIO()
|
|
31
|
+
Image.new("RGB", (width, height), color).save(buffer, format="PNG")
|
|
32
|
+
return buffer.getvalue()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@unittest.skipUnless(PIL_AVAILABLE, "Pillow is required for storyboard image tests")
|
|
36
|
+
class GenerateStoryboardImagesTests(unittest.TestCase):
|
|
37
|
+
def test_sanitize_component_property_removes_invalid_path_characters(self) -> None:
|
|
38
|
+
generator = random.Random(20260418)
|
|
39
|
+
alphabet = string.ascii_letters + string.digits + string.punctuation + " \t中文"
|
|
40
|
+
|
|
41
|
+
for _ in range(200):
|
|
42
|
+
raw = "".join(generator.choice(alphabet) for _ in range(generator.randint(0, 30)))
|
|
43
|
+
sanitized = MODULE.sanitize_component(raw, "fallback")
|
|
44
|
+
with self.subTest(raw=raw, sanitized=sanitized):
|
|
45
|
+
self.assertTrue(sanitized)
|
|
46
|
+
self.assertNotRegex(sanitized, MODULE.INVALID_PATH_CHARS)
|
|
47
|
+
self.assertNotIn(" ", sanitized)
|
|
48
|
+
|
|
49
|
+
def test_parse_image_dimensions_supports_png_and_jpeg(self) -> None:
|
|
50
|
+
png = png_bytes(320, 180)
|
|
51
|
+
self.assertEqual(MODULE.parse_image_dimensions(png), (320, 180))
|
|
52
|
+
|
|
53
|
+
buffer = io.BytesIO()
|
|
54
|
+
Image.new("RGB", (160, 90), (0, 0, 0)).save(buffer, format="JPEG")
|
|
55
|
+
self.assertEqual(MODULE.parse_image_dimensions(buffer.getvalue()), (160, 90))
|
|
56
|
+
|
|
57
|
+
def test_parse_prompts_file_supports_structured_scene_mode(self) -> None:
|
|
58
|
+
payload = {
|
|
59
|
+
"characters": [
|
|
60
|
+
{
|
|
61
|
+
"id": "hero",
|
|
62
|
+
"name": "Kai",
|
|
63
|
+
"appearance": "short black hair",
|
|
64
|
+
"outfit": "blue jacket",
|
|
65
|
+
"description": "determined teenage inventor",
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"scenes": [
|
|
69
|
+
{
|
|
70
|
+
"title": "Workshop",
|
|
71
|
+
"description": "Kai repairs a flickering lantern inside a cramped workshop.",
|
|
72
|
+
"character_ids": ["hero"],
|
|
73
|
+
"character_descriptions": {"hero": "focused and covered with grease"},
|
|
74
|
+
"camera": "medium shot",
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
80
|
+
prompts_file = Path(temp_dir) / "prompts.json"
|
|
81
|
+
prompts_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
82
|
+
items = MODULE.parse_prompts_file(prompts_file)
|
|
83
|
+
|
|
84
|
+
self.assertEqual(len(items), 1)
|
|
85
|
+
self.assertEqual(items[0]["title"], "Workshop")
|
|
86
|
+
prompt_payload = json.loads(items[0]["prompt"])
|
|
87
|
+
self.assertEqual(prompt_payload["characters"][0]["description"], "focused and covered with grease")
|
|
88
|
+
self.assertEqual(prompt_payload["camera"], "medium shot")
|
|
89
|
+
|
|
90
|
+
def test_build_scene_json_prompt_rejects_unknown_override_character(self) -> None:
|
|
91
|
+
scene = {
|
|
92
|
+
"title": "Workshop",
|
|
93
|
+
"description": "Lantern repair",
|
|
94
|
+
"character_ids": ["hero"],
|
|
95
|
+
"character_descriptions": {"ghost": "not listed"},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
with self.assertRaises(SystemExit) as context:
|
|
99
|
+
MODULE.build_scene_json_prompt(
|
|
100
|
+
scene=scene,
|
|
101
|
+
scene_index=1,
|
|
102
|
+
prompts_file=Path("prompts.json"),
|
|
103
|
+
character_profiles={
|
|
104
|
+
"hero": {
|
|
105
|
+
"id": "hero",
|
|
106
|
+
"name": "Kai",
|
|
107
|
+
"appearance": "short black hair",
|
|
108
|
+
"outfit": "blue jacket",
|
|
109
|
+
"description": "determined teenage inventor",
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self.assertIn("ids not listed in character_ids", str(context.exception))
|
|
115
|
+
|
|
116
|
+
def test_generate_image_supports_b64_payload(self) -> None:
|
|
117
|
+
raw = png_bytes(100, 60)
|
|
118
|
+
|
|
119
|
+
with patch.object(
|
|
120
|
+
MODULE,
|
|
121
|
+
"post_json",
|
|
122
|
+
return_value={"data": [{"b64_json": MODULE.base64.b64encode(raw).decode("ascii"), "revised_prompt": "ok"}]},
|
|
123
|
+
):
|
|
124
|
+
image_bytes, revised_prompt = MODULE.generate_image(
|
|
125
|
+
base_url="https://example.com",
|
|
126
|
+
api_key="token",
|
|
127
|
+
image_model="gpt-image-1",
|
|
128
|
+
prompt="scene",
|
|
129
|
+
aspect_ratio=None,
|
|
130
|
+
image_size=None,
|
|
131
|
+
quality=None,
|
|
132
|
+
style=None,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.assertEqual(image_bytes, raw)
|
|
136
|
+
self.assertEqual(revised_prompt, "ok")
|
|
137
|
+
|
|
138
|
+
def test_main_generates_cropped_images_and_summary(self) -> None:
|
|
139
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
140
|
+
project_dir = Path(temp_dir)
|
|
141
|
+
args = types.SimpleNamespace(
|
|
142
|
+
content_name="Lantern / Story",
|
|
143
|
+
project_dir=str(project_dir),
|
|
144
|
+
env_file=str(project_dir / ".env"),
|
|
145
|
+
api_url="https://example.com",
|
|
146
|
+
api_key="token",
|
|
147
|
+
prompts_file=None,
|
|
148
|
+
prompt=["Lantern workshop at dusk"],
|
|
149
|
+
image_model="gpt-image-1",
|
|
150
|
+
aspect_ratio="1:1",
|
|
151
|
+
image_size=None,
|
|
152
|
+
quality=None,
|
|
153
|
+
style=None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch.object(
|
|
157
|
+
MODULE, "load_dotenv_file", return_value=False
|
|
158
|
+
), patch.object(
|
|
159
|
+
MODULE,
|
|
160
|
+
"generate_image",
|
|
161
|
+
return_value=(png_bytes(200, 100), "Lantern workshop at dusk, improved"),
|
|
162
|
+
):
|
|
163
|
+
exit_code = MODULE.main()
|
|
164
|
+
|
|
165
|
+
self.assertEqual(exit_code, 0)
|
|
166
|
+
output_dir = project_dir / "pictures" / "Lantern_Story"
|
|
167
|
+
summary = json.loads((output_dir / "storyboard.json").read_text(encoding="utf-8"))
|
|
168
|
+
self.assertEqual(summary["aspect_ratio"], "1:1")
|
|
169
|
+
self.assertEqual(summary["images"][0]["width"], 100)
|
|
170
|
+
self.assertEqual(summary["images"][0]["height"], 100)
|
|
171
|
+
self.assertEqual(summary["images"][0]["source_width"], 200)
|
|
172
|
+
self.assertEqual(summary["images"][0]["source_height"], 100)
|
|
173
|
+
self.assertEqual(summary["images"][0]["revised_prompt"], "Lantern workshop at dusk, improved")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
unittest.main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: read-github-issue
|
|
3
|
-
description: Read and search remote GitHub issues via GitHub CLI (`gh`). Use when users ask to list issues, filter issue candidates, inspect a specific issue with comments, or gather issue context before planning follow-up work. Prefer the bundled
|
|
3
|
+
description: Read and search remote GitHub issues via GitHub CLI (`gh`). Use when users ask to list issues, filter issue candidates, inspect a specific issue with comments, or gather issue context before planning follow-up work. Prefer the bundled CLI tools when they are present and working, but fall back to direct `gh issue list` / `gh issue view` commands when the tools are missing or fail for repository-specific reasons.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Read GitHub Issue
|
|
@@ -10,12 +10,12 @@ description: Read and search remote GitHub issues via GitHub CLI (`gh`). Use whe
|
|
|
10
10
|
- Required: none.
|
|
11
11
|
- Conditional: none.
|
|
12
12
|
- Optional: none.
|
|
13
|
-
- Fallback: If the bundled
|
|
13
|
+
- Fallback: If the bundled CLI tools are missing or fail but `gh` is available, continue with raw `gh issue list` / `gh issue view`; only stop when `gh` itself is unavailable or unauthenticated.
|
|
14
14
|
|
|
15
15
|
## Standards
|
|
16
16
|
|
|
17
17
|
- Evidence: Verify repository context first, then read remote issue data directly from `gh issue list` / `gh issue view` instead of paraphrasing from memory.
|
|
18
|
-
- Execution: Confirm the target repo, prefer the bundled
|
|
18
|
+
- Execution: Confirm the target repo, prefer the bundled CLI tools for deterministic output, then fall back to raw `gh` commands whenever the tools are unavailable or broken in the target repository.
|
|
19
19
|
- Quality: Keep the skill focused on issue discovery and retrieval only; do not embed any hardcoded fixing, branching, PR, or push workflow.
|
|
20
20
|
- Output: Return candidate issues, selected issue details, comments summary, and any missing information needed before follow-up work.
|
|
21
21
|
|
|
@@ -35,12 +35,12 @@ Use this skill to gather trustworthy GitHub issue context with `gh`: discover op
|
|
|
35
35
|
- Run `gh repo view --json nameWithOwner,isPrivate,defaultBranchRef` to confirm target repo.
|
|
36
36
|
- If the user specifies another repository, always use `--repo <owner>/<repo>` in issue commands.
|
|
37
37
|
|
|
38
|
-
### 2) Find candidate issues with the bundled
|
|
38
|
+
### 2) Find candidate issues with the bundled CLI
|
|
39
39
|
|
|
40
40
|
- Preferred command:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
|
|
43
|
+
apltk find-github-issues --limit 50 --state open
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
- Optional filters:
|
|
@@ -61,7 +61,7 @@ gh issue list --limit 50 --state open
|
|
|
61
61
|
- Preferred command:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
|
|
64
|
+
apltk read-github-issue 123 --comments
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
- Optional flags:
|
|
@@ -82,16 +82,16 @@ gh issue view 123 --comments
|
|
|
82
82
|
- Identify missing acceptance criteria, repro details, affected components, or environment context.
|
|
83
83
|
- If issue text and comments are insufficient, state exactly what is missing instead of inventing a fix plan.
|
|
84
84
|
|
|
85
|
-
## Included
|
|
85
|
+
## Included CLI
|
|
86
86
|
|
|
87
|
-
### `
|
|
87
|
+
### `apltk find-github-issues`
|
|
88
88
|
|
|
89
89
|
- Purpose: consistent remote issue listing via `gh issue list`.
|
|
90
90
|
- Outputs a readable table by default, or JSON with `--output json`.
|
|
91
91
|
- Uses only GitHub CLI so it reflects remote GitHub state.
|
|
92
92
|
- Treat it as a convenience wrapper, not a hard dependency.
|
|
93
93
|
|
|
94
|
-
### `
|
|
94
|
+
### `apltk read-github-issue`
|
|
95
95
|
|
|
96
96
|
- Purpose: deterministic issue detail retrieval via `gh issue view`.
|
|
97
97
|
- Outputs either a human-readable summary or full JSON for downstream automation.
|
|
Binary file
|
|
@@ -46,8 +46,8 @@ Use this skill to run an end-to-end GitHub PR review loop: collect review thread
|
|
|
46
46
|
- If user does not provide PR number, infer from current branch context.
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
apltk review-threads list --repo <owner>/<repo> --pr <number>
|
|
50
|
+
apltk review-threads list
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
## 2) Read unresolved review threads
|
|
@@ -55,8 +55,8 @@ python3 scripts/review_threads.py list
|
|
|
55
55
|
Use table view for quick scan, then JSON when you need full details.
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
apltk review-threads list --pr <number> --state unresolved --output table
|
|
59
|
+
apltk review-threads list --pr <number> --state unresolved --output json > /tmp/pr_threads.json
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
The JSON output contains `thread_id`, `path`, `line`, and comment bodies for decision and resolution.
|
|
@@ -98,13 +98,13 @@ Track adopted thread IDs in a JSON file:
|
|
|
98
98
|
Resolve only threads you actually addressed in code.
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
|
|
101
|
+
apltk review-threads resolve --pr <number> --thread-id-file adopted_threads.json
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
Optional preview without mutating GitHub state:
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
|
-
|
|
107
|
+
apltk review-threads resolve --pr <number> --thread-id-file adopted_threads.json --dry-run
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
## 8) Handle non-adopted comments
|
|
@@ -113,9 +113,9 @@ python3 scripts/review_threads.py resolve --pr <number> --thread-id-file adopted
|
|
|
113
113
|
- Reply with a concise technical reason and, if needed, a proposed follow-up.
|
|
114
114
|
- Never resolve rejected or unhandled feedback threads.
|
|
115
115
|
|
|
116
|
-
##
|
|
116
|
+
## CLI
|
|
117
117
|
|
|
118
|
-
### `
|
|
118
|
+
### `apltk review-threads`
|
|
119
119
|
|
|
120
120
|
- `list`: fetch PR review threads via GitHub GraphQL (`gh api graphql`), supports repo/PR inference.
|
|
121
121
|
- `resolve`: resolve selected review threads by thread IDs.
|
|
Binary file
|
|
@@ -50,6 +50,8 @@ For each confirmed finding, delegate publication to `$open-github-issue` with:
|
|
|
50
50
|
- file references and causal reasoning in `suspected-cause`
|
|
51
51
|
- reproduction conditions when known
|
|
52
52
|
|
|
53
|
+
When invoking the publisher CLI directly, use `apltk open-github-issue --payload-file <json>` or `@file` inputs for Markdown-rich fields so shell parsing cannot consume backticks or code snippets.
|
|
54
|
+
|
|
53
55
|
If issue publication is unavailable, return draft issue content instead of switching to an ad-hoc publishing path.
|
|
54
56
|
|
|
55
57
|
## Output expectations
|
|
@@ -74,6 +74,7 @@ Only continue to the next level when the current level has no confirmed findings
|
|
|
74
74
|
- `suspected-cause`: file references, causal chain, and confidence
|
|
75
75
|
- `reproduction`: concrete trigger or conditions when known; otherwise leave empty
|
|
76
76
|
- `repo`: target repository in `owner/repo` format when known
|
|
77
|
+
- If invoking the publisher CLI directly, pass finding details through `apltk open-github-issue --payload-file <json>` or `@file` inputs rather than inline shell arguments so code snippets and backticks survive unchanged.
|
|
77
78
|
|
|
78
79
|
## Evidence standard
|
|
79
80
|
|
|
@@ -56,7 +56,7 @@ cp ~/.codex/skills/text-to-short-video/.env.example \
|
|
|
56
56
|
If API-generated video ratio/size does not match the target, run:
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
-
|
|
59
|
+
apltk enforce-video-aspect-ratio \
|
|
60
60
|
--input-video "<downloaded_video_path>" \
|
|
61
61
|
--output-video "<final_output_video_path>" \
|
|
62
62
|
--env-file ~/.codex/skills/text-to-short-video/.env \
|
|
@@ -173,7 +173,7 @@ If provider returns multiple outputs, keep the best one that matches requested s
|
|
|
173
173
|
When output ratio or resolution differs from target, run:
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
|
|
176
|
+
apltk enforce-video-aspect-ratio \
|
|
177
177
|
--input-video "<downloaded_video_path>" \
|
|
178
178
|
--output-video "<final_output_video_path>" \
|
|
179
179
|
--env-file ~/.codex/skills/text-to-short-video/.env \
|
|
Binary file
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
import types
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "enforce_video_aspect_ratio.py"
|
|
19
|
+
SPEC = importlib.util.spec_from_file_location("enforce_video_aspect_ratio", SCRIPT_PATH)
|
|
20
|
+
MODULE = importlib.util.module_from_spec(SPEC)
|
|
21
|
+
SPEC.loader.exec_module(MODULE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_crop(expression: str) -> tuple[int, int, int, int] | None:
|
|
25
|
+
match = re.search(r"crop=(\d+):(\d+):(\d+):(\d+)", expression)
|
|
26
|
+
if not match:
|
|
27
|
+
return None
|
|
28
|
+
return tuple(int(value) for value in match.groups())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EnforceVideoAspectRatioTests(unittest.TestCase):
|
|
32
|
+
def test_parse_size_property_round_trips_positive_dimensions(self) -> None:
|
|
33
|
+
generator = random.Random(20260418)
|
|
34
|
+
for _ in range(150):
|
|
35
|
+
width = generator.randint(1, 9999)
|
|
36
|
+
height = generator.randint(1, 9999)
|
|
37
|
+
raw = f"{width}x{height}"
|
|
38
|
+
with self.subTest(raw=raw):
|
|
39
|
+
self.assertEqual(MODULE.parse_size(raw), (width, height))
|
|
40
|
+
|
|
41
|
+
def test_load_dotenv_file_parses_quotes_and_respects_override(self) -> None:
|
|
42
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
43
|
+
env_file = Path(temp_dir) / ".env"
|
|
44
|
+
env_file.write_text(
|
|
45
|
+
'TEXT_TO_SHORT_VIDEO_WIDTH="720"\n'
|
|
46
|
+
"TEXT_TO_SHORT_VIDEO_HEIGHT=1280 # comment\n",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
original = os.environ.get("TEXT_TO_SHORT_VIDEO_WIDTH")
|
|
50
|
+
os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"] = "999"
|
|
51
|
+
try:
|
|
52
|
+
loaded = MODULE.load_dotenv_file(env_file, override=False)
|
|
53
|
+
self.assertTrue(loaded)
|
|
54
|
+
self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"], "999")
|
|
55
|
+
self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_HEIGHT"], "1280")
|
|
56
|
+
MODULE.load_dotenv_file(env_file, override=True)
|
|
57
|
+
self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"], "720")
|
|
58
|
+
finally:
|
|
59
|
+
if original is None:
|
|
60
|
+
os.environ.pop("TEXT_TO_SHORT_VIDEO_WIDTH", None)
|
|
61
|
+
else:
|
|
62
|
+
os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"] = original
|
|
63
|
+
os.environ.pop("TEXT_TO_SHORT_VIDEO_HEIGHT", None)
|
|
64
|
+
|
|
65
|
+
def test_build_video_filter_property_keeps_crop_within_bounds(self) -> None:
|
|
66
|
+
generator = random.Random(20260418)
|
|
67
|
+
for _ in range(200):
|
|
68
|
+
input_width = generator.randint(32, 4096)
|
|
69
|
+
input_height = generator.randint(32, 4096)
|
|
70
|
+
target_width = generator.randint(32, 2160)
|
|
71
|
+
target_height = generator.randint(32, 3840)
|
|
72
|
+
|
|
73
|
+
expression, crop_applied = MODULE.build_video_filter(
|
|
74
|
+
input_width=input_width,
|
|
75
|
+
input_height=input_height,
|
|
76
|
+
target_width=target_width,
|
|
77
|
+
target_height=target_height,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with self.subTest(
|
|
81
|
+
input_width=input_width,
|
|
82
|
+
input_height=input_height,
|
|
83
|
+
target_width=target_width,
|
|
84
|
+
target_height=target_height,
|
|
85
|
+
expression=expression,
|
|
86
|
+
):
|
|
87
|
+
if expression is None:
|
|
88
|
+
self.assertFalse(crop_applied)
|
|
89
|
+
self.assertEqual((input_width, input_height), (target_width, target_height))
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
self.assertIn(f"scale={target_width}:{target_height}", expression)
|
|
93
|
+
crop = parse_crop(expression)
|
|
94
|
+
if crop is None:
|
|
95
|
+
self.assertFalse(crop_applied)
|
|
96
|
+
self.assertEqual(input_width * target_height, input_height * target_width)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
crop_width, crop_height, offset_x, offset_y = crop
|
|
100
|
+
self.assertTrue(crop_applied)
|
|
101
|
+
self.assertEqual(crop_width % 2, 0)
|
|
102
|
+
self.assertEqual(crop_height % 2, 0)
|
|
103
|
+
self.assertGreaterEqual(crop_width, 2)
|
|
104
|
+
self.assertGreaterEqual(crop_height, 2)
|
|
105
|
+
self.assertLessEqual(crop_width, input_width)
|
|
106
|
+
self.assertLessEqual(crop_height, input_height)
|
|
107
|
+
self.assertGreaterEqual(offset_x, 0)
|
|
108
|
+
self.assertGreaterEqual(offset_y, 0)
|
|
109
|
+
self.assertLessEqual(offset_x + crop_width, input_width)
|
|
110
|
+
self.assertLessEqual(offset_y + crop_height, input_height)
|
|
111
|
+
|
|
112
|
+
def test_resolve_output_path_enforces_in_place_rules(self) -> None:
|
|
113
|
+
input_video = Path("/tmp/input.mp4")
|
|
114
|
+
args = types.SimpleNamespace(in_place=False, output_video=None)
|
|
115
|
+
self.assertEqual(MODULE.resolve_output_path(args, input_video), Path("/tmp/input_aspect_fixed.mp4"))
|
|
116
|
+
|
|
117
|
+
args = types.SimpleNamespace(in_place=True, output_video=None)
|
|
118
|
+
self.assertEqual(MODULE.resolve_output_path(args, input_video), input_video)
|
|
119
|
+
|
|
120
|
+
args = types.SimpleNamespace(in_place=True, output_video="/tmp/out.mp4")
|
|
121
|
+
with self.assertRaises(SystemExit):
|
|
122
|
+
MODULE.resolve_output_path(args, input_video)
|
|
123
|
+
|
|
124
|
+
def test_main_copies_file_when_video_already_matches_target(self) -> None:
|
|
125
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
126
|
+
input_video = Path(temp_dir) / "input.mp4"
|
|
127
|
+
input_video.write_bytes(b"video")
|
|
128
|
+
output_video = Path(temp_dir) / "copy.mp4"
|
|
129
|
+
args = types.SimpleNamespace(
|
|
130
|
+
input_video=str(input_video),
|
|
131
|
+
output_video=str(output_video),
|
|
132
|
+
in_place=False,
|
|
133
|
+
target_size="640x360",
|
|
134
|
+
target_width=None,
|
|
135
|
+
target_height=None,
|
|
136
|
+
env_file=str(Path(temp_dir) / ".env"),
|
|
137
|
+
force=False,
|
|
138
|
+
ffmpeg_bin="ffmpeg",
|
|
139
|
+
ffprobe_bin="ffprobe",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch.object(
|
|
143
|
+
MODULE, "load_dotenv_file", return_value=False
|
|
144
|
+
), patch.object(MODULE, "required_command"), patch.object(
|
|
145
|
+
MODULE, "probe_video_size", return_value=(640, 360)
|
|
146
|
+
), patch("sys.stdout", new_callable=io.StringIO) as stdout:
|
|
147
|
+
exit_code = MODULE.main()
|
|
148
|
+
|
|
149
|
+
self.assertEqual(exit_code, 0)
|
|
150
|
+
self.assertEqual(output_video.read_bytes(), b"video")
|
|
151
|
+
self.assertIn("already matches target size", stdout.getvalue())
|
|
152
|
+
|
|
153
|
+
def test_main_runs_ffmpeg_and_reports_output_dimensions(self) -> None:
|
|
154
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
155
|
+
input_video = Path(temp_dir) / "input.mp4"
|
|
156
|
+
output_video = Path(temp_dir) / "output.mp4"
|
|
157
|
+
input_video.write_bytes(b"video")
|
|
158
|
+
args = types.SimpleNamespace(
|
|
159
|
+
input_video=str(input_video),
|
|
160
|
+
output_video=str(output_video),
|
|
161
|
+
in_place=False,
|
|
162
|
+
target_size="1080x1920",
|
|
163
|
+
target_width=None,
|
|
164
|
+
target_height=None,
|
|
165
|
+
env_file=str(Path(temp_dir) / ".env"),
|
|
166
|
+
force=True,
|
|
167
|
+
ffmpeg_bin="ffmpeg",
|
|
168
|
+
ffprobe_bin="ffprobe",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def fake_run(command, check):
|
|
172
|
+
self.assertIn("-vf", command)
|
|
173
|
+
Path(command[-1]).write_bytes(b"processed")
|
|
174
|
+
return types.SimpleNamespace(returncode=0)
|
|
175
|
+
|
|
176
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch.object(
|
|
177
|
+
MODULE, "load_dotenv_file", return_value=False
|
|
178
|
+
), patch.object(MODULE, "required_command"), patch.object(
|
|
179
|
+
MODULE,
|
|
180
|
+
"probe_video_size",
|
|
181
|
+
side_effect=[(1920, 1080), (1080, 1920)],
|
|
182
|
+
), patch.object(MODULE.subprocess, "run", side_effect=fake_run), patch(
|
|
183
|
+
"sys.stdout", new_callable=io.StringIO
|
|
184
|
+
) as stdout:
|
|
185
|
+
exit_code = MODULE.main()
|
|
186
|
+
|
|
187
|
+
self.assertEqual(exit_code, 0)
|
|
188
|
+
self.assertTrue(output_video.is_file())
|
|
189
|
+
self.assertIn("Center crop applied: yes", stdout.getvalue())
|
|
190
|
+
self.assertIn("Output size: 1080x1920", stdout.getvalue())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
unittest.main()
|
|
@@ -10,7 +10,7 @@ description: Read a user-specified PDF that marks the week's key financial event
|
|
|
10
10
|
- Required: `pdf` to render the final report.
|
|
11
11
|
- Conditional: `document-vision-reader` when the source PDF's highlighted markers are visible in layout but not recoverable from machine-readable text alone.
|
|
12
12
|
- Optional: none.
|
|
13
|
-
- Fallback: If source-PDF extraction through `pdf` is unavailable or fails on macOS, use the bundled `
|
|
13
|
+
- Fallback: If source-PDF extraction through `pdf` is unavailable or fails on macOS, use the bundled `apltk extract-pdf-text-pdfkit` helper before giving up; only stop when neither `pdf` nor the local PDFKit fallback can recover the marked events, or when final PDF rendering itself cannot be completed.
|
|
14
14
|
|
|
15
15
|
## Standards
|
|
16
16
|
|
|
@@ -72,7 +72,7 @@ Do not guess any input that materially changes the research window or report sco
|
|
|
72
72
|
- If `pdf` extraction is unavailable or fails because the current machine lacks local PDF tooling, and the host is macOS, run:
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
|
|
75
|
+
apltk extract-pdf-text-pdfkit /absolute/path/to/source.pdf
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
- The bundled extractor prints page-delimited text directly from PDFKit so the agent can still build the source-event table without adding Python PDF packages ad hoc.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
REPORTLAB_AVAILABLE = importlib.util.find_spec("reportlab") is not None
|
|
14
|
+
|
|
15
|
+
if REPORTLAB_AVAILABLE:
|
|
16
|
+
from reportlab.pdfgen import canvas
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "extract_pdf_text_pdfkit.swift"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@unittest.skipUnless(shutil.which("swift"), "swift is required for PDFKit script tests")
|
|
23
|
+
@unittest.skipUnless(sys.platform == "darwin", "PDFKit script tests only work on macOS")
|
|
24
|
+
class ExtractPdfTextPdfkitTests(unittest.TestCase):
|
|
25
|
+
def run_script(self, *args: str) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
return subprocess.run(
|
|
27
|
+
["swift", str(SCRIPT_PATH), *args],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
check=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def test_missing_path_prints_usage(self) -> None:
|
|
34
|
+
result = self.run_script()
|
|
35
|
+
self.assertEqual(result.returncode, 1)
|
|
36
|
+
self.assertIn("Usage: swift scripts/extract_pdf_text_pdfkit.swift", result.stderr)
|
|
37
|
+
|
|
38
|
+
def test_unreadable_pdf_reports_missing_file(self) -> None:
|
|
39
|
+
result = self.run_script("/tmp/does-not-exist.pdf")
|
|
40
|
+
self.assertEqual(result.returncode, 1)
|
|
41
|
+
self.assertIn("Unable to open PDF", result.stderr)
|
|
42
|
+
|
|
43
|
+
@unittest.skipUnless(REPORTLAB_AVAILABLE, "reportlab is required to generate fixture PDFs")
|
|
44
|
+
def test_extracts_text_from_generated_pdf(self) -> None:
|
|
45
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
46
|
+
pdf_path = Path(temp_dir) / "sample.pdf"
|
|
47
|
+
pdf = canvas.Canvas(str(pdf_path))
|
|
48
|
+
pdf.drawString(72, 720, "Weekly event headline")
|
|
49
|
+
pdf.showPage()
|
|
50
|
+
pdf.drawString(72, 720, "Second page details")
|
|
51
|
+
pdf.save()
|
|
52
|
+
|
|
53
|
+
result = self.run_script(str(pdf_path))
|
|
54
|
+
|
|
55
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
56
|
+
self.assertIn(f"PDF_PATH={pdf_path}", result.stdout)
|
|
57
|
+
self.assertIn("PAGE_COUNT=2", result.stdout)
|
|
58
|
+
self.assertIn("=== PAGE 1 ===", result.stdout)
|
|
59
|
+
self.assertIn("Weekly event headline", result.stdout)
|
|
60
|
+
self.assertIn("Second page details", result.stdout)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
unittest.main()
|