@optima-chat/optima-agent 0.8.91 → 0.8.93
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/.claude/skills/browser/SKILL.md +8 -0
- package/.claude/skills/homepage/SKILL.md +4 -3
- package/.claude/skills/kol-outreach/SKILL.md +371 -0
- package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
- package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
- package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
- package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
- package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
- package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
- package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
- package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
- package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
- package/.claude/skills/video-clone/SKILL.md +125 -217
- package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
- package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
- package/.claude/skills/video-clone/references/kling-api.md +75 -75
- package/.claude/skills/video-clone/references/url-parsing.md +32 -13
- package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
- package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
- package/.claude/skills/video-clone/scripts/_gate.py +162 -0
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
- package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
- package/.claude/skills/video-clone/scripts/_project.py +56 -0
- package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
- package/.claude/skills/video-clone/scripts/assemble.py +106 -0
- package/.claude/skills/video-clone/scripts/confirm.py +12 -0
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
- package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
- package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
- package/.claude/skills/video-clone/scripts/init_project.py +103 -0
- package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
- package/.claude/skills/video-clone/scripts/kling_generate.py +262 -0
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
- package/.claude/skills/video-clone/scripts/preflight.py +102 -0
- package/.claude/skills/video-clone/scripts/preview.py +208 -0
- package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
- package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
- package/.claude/skills/video-clone/scripts/status.py +202 -0
- package/.claude/skills/video-clone/scripts/status_test.py +174 -0
- package/package.json +2 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Gate Enforcement
|
|
2
|
+
|
|
3
|
+
This skill enforces its HARD-GATE mechanically, not textually. Every
|
|
4
|
+
executor script that costs money or generates output calls `require_gate()`
|
|
5
|
+
at startup and exits with code 1 if the gate isn't set. You cannot
|
|
6
|
+
rationalize past a CLI that won't produce output.
|
|
7
|
+
|
|
8
|
+
## State location
|
|
9
|
+
|
|
10
|
+
Each project has its own state file:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
gen-output/video-clone/<project>/.state/phase.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Schema (single-gate model — PR #65)
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"schema_version": 1,
|
|
21
|
+
"project": "handheld-phone-swap",
|
|
22
|
+
"task_type": "video_clone",
|
|
23
|
+
"created_at": "2026-04-11T16:55:00Z",
|
|
24
|
+
"current_phase": 0,
|
|
25
|
+
"gates": {
|
|
26
|
+
"preview_confirmed": {"status": false, "confirmed_at": null, "user_quote": null}
|
|
27
|
+
},
|
|
28
|
+
"history": []
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
There is **one gate**: `preview_confirmed`. It is set by `confirm.py`
|
|
33
|
+
after the user reviews the complete prep preview (analysis + grid +
|
|
34
|
+
prompt + edited frame). The prep scripts (analyze_source, extract_frames,
|
|
35
|
+
edit_first_frame) run freely before confirmation — only the generation
|
|
36
|
+
scripts are gated.
|
|
37
|
+
|
|
38
|
+
Gates are never un-set. Each set operation appends an entry to `history`
|
|
39
|
+
with timestamp + user quote + caller script name, giving you an audit
|
|
40
|
+
trail to answer "did the user actually confirm this?"
|
|
41
|
+
|
|
42
|
+
## Which script needs which gate
|
|
43
|
+
|
|
44
|
+
| Script | Required gate |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `analyze_source.py` | (none — prep runs freely) |
|
|
47
|
+
| `extract_frames.py` | (none — prep runs freely) |
|
|
48
|
+
| `edit_first_frame.py` | (none — prep runs freely) |
|
|
49
|
+
| `preview.py` | (none — collects artifacts, no cost) |
|
|
50
|
+
| `kling_generate.py` | **preview_confirmed** |
|
|
51
|
+
| `gen_video.py` | **preview_confirmed** |
|
|
52
|
+
| `save_workflow.py` | **preview_confirmed** |
|
|
53
|
+
| `assemble.py` | (none — post-processes already-generated videos) |
|
|
54
|
+
| `init_project.py` | (none — must run before any gate exists) |
|
|
55
|
+
| `confirm.py` | (none — sets the gate) |
|
|
56
|
+
| `preflight.py` | (none — environment check) |
|
|
57
|
+
| `status.py` | (none — read-only) |
|
|
58
|
+
|
|
59
|
+
## How to set the gate
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python scripts/confirm.py --project <name> --quote "<user's actual words>"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **`--quote` is required** and must be non-empty.
|
|
66
|
+
- **Quote heuristic**: if the quote contains negation markers (`不`, `改`,
|
|
67
|
+
`no`, `not`, `change`, `modify`, `wrong`, …), the script refuses to set
|
|
68
|
+
the gate unless you also pass `--force`. This prevents Claude from
|
|
69
|
+
using a correction ("不需要音频") as a confirmation.
|
|
70
|
+
- **Use `--force` sparingly**: only when the user said something like
|
|
71
|
+
"no audio, otherwise good" — a genuine confirmation that contains a
|
|
72
|
+
negation word.
|
|
73
|
+
- The gate records `caller` (the script name) in history. Fabricated
|
|
74
|
+
quotes are detectable in post-mortem review.
|
|
75
|
+
|
|
76
|
+
## How a blocked script looks
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
[HARD-GATE BLOCKED] kling_generate.py needs preview_confirmed=True
|
|
80
|
+
Current state: preview_confirmed=False
|
|
81
|
+
Project: /abs/path/to/gen-output/video-clone/handheld-phone-swap
|
|
82
|
+
|
|
83
|
+
To proceed:
|
|
84
|
+
1. Show the preview bundle to the user and wait for their confirmation.
|
|
85
|
+
2. Run: python scripts/confirm.py --project <name> --quote "<user's actual words>"
|
|
86
|
+
3. Retry this command.
|
|
87
|
+
|
|
88
|
+
Claude: do NOT rationalize past this. The gate exists because text
|
|
89
|
+
instructions alone did not stop prior bypass attempts. Go get the real
|
|
90
|
+
user confirmation.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exit code is always 1. Downstream pipes will break; you can't accidentally
|
|
94
|
+
feed "locked" output into the next step.
|
|
95
|
+
|
|
96
|
+
## Old 3-gate schema (pre-PR #65)
|
|
97
|
+
|
|
98
|
+
If you have a project created before the single-gate refactor, the state
|
|
99
|
+
file will have `plan_confirmed`, `prompt_confirmed`, `frame_confirmed`
|
|
100
|
+
instead of `preview_confirmed`. Any gated script will exit 1 with:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
[HARD-GATE BLOCKED] expected gate 'preview_confirmed' but it is not
|
|
104
|
+
present. This is most likely an old 3-gate schema project.
|
|
105
|
+
Either: complete this project with PR #65 scripts, or start a new project.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Do NOT manually edit old phase.json files to add `preview_confirmed`.
|
|
109
|
+
Start a new project with `init_project.py` and re-run prep.
|
|
110
|
+
|
|
111
|
+
## Common bypass attempts (and why the gate still wins)
|
|
112
|
+
|
|
113
|
+
| Attempt | Outcome |
|
|
114
|
+
|---|---|
|
|
115
|
+
| "analyze_source is just analysis, no cost" | Runs freely — no gate on prep scripts. Correct behavior. |
|
|
116
|
+
| "I'll edit phase.json to set the gate" | Possible, but leaves a gap in `history`. Audit trail shows no `confirm.py` call. |
|
|
117
|
+
| `confirm.py --quote 'ok'` without asking user | Sets gate with `user_quote: "ok"`. No user types that in isolation — obvious in post-mortem. |
|
|
118
|
+
| "I'll skip preview.py and confirm directly" | `confirm.py` doesn't require preview_v*.md — but `preview.py` must have run first for artifacts to be present for the user to review. |
|
|
119
|
+
| "I'll symlink phase.json to /dev/null" | Exit 1 on read. |
|
|
120
|
+
| "I'll delete .state/" | Exit 1 — "phase.json not found". |
|
|
121
|
+
|
|
122
|
+
The cheapest path is always to actually get the user's confirmation.
|
|
123
|
+
|
|
124
|
+
## When to read history
|
|
125
|
+
|
|
126
|
+
The `history` array is append-only. Useful cases:
|
|
127
|
+
|
|
128
|
+
- **Resuming a multi-session project** — read history to know what gate
|
|
129
|
+
is set and what the user said. Use `status.py` for a human-readable view.
|
|
130
|
+
- **Debugging bad output** — if a video is wrong, history shows the exact
|
|
131
|
+
quote the user gave when confirming the preview.
|
|
132
|
+
- **Verifying gate authenticity** — `caller` field shows which script set
|
|
133
|
+
the gate. `confirm.py` is the only legitimate caller.
|
|
134
|
+
|
|
135
|
+
## Why this exists
|
|
136
|
+
|
|
137
|
+
Previous versions had HARD-GATE rules in SKILL.md text with a
|
|
138
|
+
Rationalization Counter table. They did not stop Claude from running
|
|
139
|
+
ffprobe / downloading videos / calling `gen image` before the user
|
|
140
|
+
confirmed. The failure mode was always the same: Claude decided "my action
|
|
141
|
+
doesn't count as execution" and rationalized past the text rule.
|
|
142
|
+
|
|
143
|
+
Mechanical gates end the argument. The script either runs or it doesn't,
|
|
144
|
+
and the decision doesn't depend on how Claude interprets the rules.
|
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Video generation with audio — what the script handles + what you need to know
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `kling_generate.py` script calls the **Optima generation backend**, which
|
|
4
|
+
routes to Kling 3.0 internally. The script itself knows nothing about the
|
|
5
|
+
upstream provider — only the backend does.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
```bash
|
|
8
|
+
python scripts/kling_generate.py --project <name> --frame <confirmed-frame.png>
|
|
9
|
+
# options: --duration 5|10 --aspect-ratio 9:16 --mode std|pro
|
|
10
|
+
# --cfg-scale 0.5 --no-audio
|
|
11
|
+
```
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
## When to use `kling_generate.py` vs `gen_video.py`
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
| Need audio / lip sync? | Use |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Yes | `kling_generate.py` ($0.15/s ≈ $1.50 per 10s equivalent) |
|
|
18
|
+
| No | `gen_video.py` (~$0.02 per 10s equivalent) |
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
resp = requests.post('https://freeimage.host/api/1/upload', data={
|
|
15
|
-
'key': '6d207e02198a847aa98d0a2a901485a5',
|
|
16
|
-
'action': 'upload',
|
|
17
|
-
'source': img_b64,
|
|
18
|
-
'format': 'json',
|
|
19
|
-
})
|
|
20
|
-
img_url = resp.json()['image']['url']
|
|
21
|
-
```
|
|
20
|
+
Both scripts go through the same generation backend and the same billing
|
|
21
|
+
middleware — the difference is server-side provider selection.
|
|
22
22
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
```python
|
|
26
|
-
resp = requests.post('https://api.piapi.ai/api/v1/task',
|
|
27
|
-
headers={'x-api-key': PIAPI_KEY, 'Content-Type': 'application/json'},
|
|
28
|
-
json={
|
|
29
|
-
'model': 'kling',
|
|
30
|
-
'task_type': 'video_generation',
|
|
31
|
-
'input': {
|
|
32
|
-
'prompt': open('gen-output/video-clone/{project}/prompt.md').read(),
|
|
33
|
-
'negative_prompt': 'slow motion, dreamy, ethereal, cinematic, blurry, '
|
|
34
|
-
'distorted, deformed hands, extra fingers',
|
|
35
|
-
'image_url': img_url,
|
|
36
|
-
'duration': 10, # 多片段时用 5
|
|
37
|
-
'aspect_ratio': '9:16',
|
|
38
|
-
'mode': 'std', # 720p
|
|
39
|
-
'version': '3.0',
|
|
40
|
-
'cfg_scale': 0.5, # 必须 float!string → 500
|
|
41
|
-
'enable_audio': True,
|
|
42
|
-
},
|
|
43
|
-
'config': {'service_mode': 'public'},
|
|
44
|
-
}, timeout=60)
|
|
45
|
-
task_id = resp.json()['data']['task_id']
|
|
46
|
-
```
|
|
23
|
+
## Auth + API URL discovery
|
|
47
24
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
import time
|
|
52
|
-
while True:
|
|
53
|
-
r = requests.get(f'https://api.piapi.ai/api/v1/task/{task_id}',
|
|
54
|
-
headers={'x-api-key': PIAPI_KEY})
|
|
55
|
-
d = r.json().get('data', {})
|
|
56
|
-
status = d.get('status', '')
|
|
57
|
-
if status == 'completed': # 小写!
|
|
58
|
-
video_url = d['output']['video'] # 3.0 用 video,2.6 用 video_url
|
|
59
|
-
break
|
|
60
|
-
if status == 'failed':
|
|
61
|
-
raise RuntimeError(d.get('error', {}))
|
|
62
|
-
time.sleep(15)
|
|
63
|
-
```
|
|
25
|
+
`kling_generate.py` mirrors the `@optima-chat/gen-cli` auth convention, so
|
|
26
|
+
any user who has run `optima login` is automatically ready — no extra env
|
|
27
|
+
setup needed.
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
29
|
+
Token resolution (first match wins):
|
|
30
|
+
|
|
31
|
+
1. `OPTIMA_TOKEN` env var
|
|
32
|
+
2. `~/.optima/token.json` (`access_token` field)
|
|
33
|
+
|
|
34
|
+
Generation API URL resolution (first match wins):
|
|
35
|
+
|
|
36
|
+
1. `GENERATION_API_URL` env var
|
|
37
|
+
2. `~/.optima/token.json` `env` field → `ci` / `stage` / `prod` mapping
|
|
38
|
+
3. default: `https://gen-api.optima.onl` (prod)
|
|
39
|
+
|
|
40
|
+
If neither token source resolves, the script exits 1 with a clear hint
|
|
41
|
+
(`Run `optima login` or set OPTIMA_TOKEN`). **No fallback to direct upstream
|
|
42
|
+
calls** — that would bypass billing.
|
|
43
|
+
|
|
44
|
+
Run `scripts/preflight.py` to verify the auth state and other deps.
|
|
45
|
+
|
|
46
|
+
The `requests` Python package is also required.
|
|
47
|
+
|
|
48
|
+
## Billing
|
|
49
|
+
|
|
50
|
+
Billing is entirely server-side. When `kling_generate.py` calls
|
|
51
|
+
`POST /api/video/generate`, the backend's billing middleware pre-deducts
|
|
52
|
+
credits from the user's Optima wallet based on `metadata.duration`. If the
|
|
53
|
+
upstream task later fails, the server refunds the credits automatically
|
|
54
|
+
(see `billingClient.refund()` in the generation service worker).
|
|
55
|
+
|
|
56
|
+
This means the skill never sees raw USD pricing — it just sends a request
|
|
57
|
+
and waits. The user's wallet is the source of truth for how much was spent.
|
|
58
|
+
|
|
59
|
+
## Error handling
|
|
60
|
+
|
|
61
|
+
`_submit()` translates the common billing errors into readable messages:
|
|
62
|
+
|
|
63
|
+
- HTTP 402 `INSUFFICIENT_CREDITS` — user does not have enough credits
|
|
64
|
+
- HTTP 403 `PLAN_RESTRICTED` — user's plan does not include video generation
|
|
65
|
+
|
|
66
|
+
Other HTTP errors propagate via `raise_for_status()`. Polling uses the
|
|
67
|
+
backend's unified `GET /api/task/{id}` endpoint.
|
|
68
|
+
|
|
69
|
+
## Non-obvious traps (now handled server-side)
|
|
70
|
+
|
|
71
|
+
The `cfg_scale`-must-be-float / lowercase-status / 3.0-vs-2.6 response
|
|
72
|
+
differences / freeimage-vs-catbox quirks are all handled by the server's
|
|
73
|
+
PiAPI adapter (`optima-gen: packages/generation/src/adapters/piapi-video.ts`).
|
|
74
|
+
The skill no longer has to know about them.
|
|
75
|
+
|
|
76
|
+
## What changed vs the old direct-PiAPI version
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Before: script uploaded to freeimage.host, submitted to PiAPI, polled PiAPI,
|
|
79
|
+
downloaded from Kling CDN. No billing. Shared PiAPI key hardcoded in the
|
|
80
|
+
skill.
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
- freeimage.host key: `6d207e02198a847aa98d0a2a901485a5`
|
|
85
|
-
- 国内网络通过服务器中转 API,本地大 payload 会断连
|
|
82
|
+
After: script sends one POST + polls one endpoint on the Optima backend.
|
|
83
|
+
Billing is automatic. No upstream API keys in the skill. Provider switches
|
|
84
|
+
(if the Kling API ever changes) happen server-side without touching any
|
|
85
|
+
client code.
|
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
# URL
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
|
8
|
-
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
1
|
+
# URL / Source Download — decision table
|
|
2
|
+
|
|
3
|
+
**Do not use WebFetch** for source videos (anti-scraping, auth walls).
|
|
4
|
+
Choose the right tool based on the URL shape and use it manually — there
|
|
5
|
+
is no script wrapper because the right approach varies by platform.
|
|
6
|
+
|
|
7
|
+
| URL shape | Command |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `tiktok.com/@user/video/<id>` | `scout tiktok video-detail <id>` → grab video URL → `wget` |
|
|
10
|
+
| `vm.tiktok.com/<short>` | `curl -sI <url>` → `Location:` header → extract id → see TikTok row |
|
|
11
|
+
| `douyin.com/video/<id>` | `scout douyin video-download <id>` → `wget` |
|
|
12
|
+
| `v.douyin.com/<short>` | `scout douyin video-by-url "<url>"` |
|
|
13
|
+
| Instagram Reels (`instagram.com/reel/...`) | `scout instagram download-reel "<url>"` |
|
|
14
|
+
| 小红书视频 (`xiaohongshu.com/explore/<id>`) | `scout xhs note-detail <id>` → grab video link → `wget` |
|
|
15
|
+
| Local file path | Pass directly to `--video` |
|
|
16
|
+
|
|
17
|
+
After download, save to `gen-output/video-clone/<project>/source/` and
|
|
18
|
+
then feed the local path to `analyze_source.py --video <path>`.
|
|
19
|
+
|
|
20
|
+
## Why the script pipeline starts after download
|
|
21
|
+
|
|
22
|
+
Downloading is the *only* step that remains manual because platform APIs
|
|
23
|
+
change faster than the script would. Everything downstream of
|
|
24
|
+
`--video <local-file>` is automated by the Python scripts.
|
|
25
|
+
|
|
26
|
+
## Sanity checks before running analyze_source.py
|
|
27
|
+
|
|
28
|
+
1. File size > 0
|
|
29
|
+
2. `ffprobe -v error <file>` returns no errors
|
|
30
|
+
3. Duration makes sense for the source (`ffprobe -show_entries format=duration`)
|
|
31
|
+
|
|
32
|
+
If any of these fail, re-download with a different tool before proceeding.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared implementation for confirm.py.
|
|
2
|
+
|
|
3
|
+
run_confirm() handles:
|
|
4
|
+
- argparse with --project / --quote / --force
|
|
5
|
+
- quote validation (non-empty)
|
|
6
|
+
- negation heuristic (refuses quotes that sound like corrections unless --force)
|
|
7
|
+
- resolving the project directory via GEN_OUTPUT_ROOT
|
|
8
|
+
- automatically passing caller=Path(sys.argv[0]).name to _gate.set_gate()
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import _gate
|
|
18
|
+
|
|
19
|
+
NEGATION_MARKERS = (
|
|
20
|
+
"不", "别", "改", "调整", "不行", "不对", "再", "换",
|
|
21
|
+
"no", "not", "don't", "dont", "modify", "change", "different", "wrong",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
GATE_NAME = "preview_confirmed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _gen_output_root() -> Path:
|
|
28
|
+
override = os.environ.get("GEN_OUTPUT_ROOT")
|
|
29
|
+
if override:
|
|
30
|
+
return Path(override)
|
|
31
|
+
return Path.cwd() / "gen-output"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _looks_like_negation(quote: str) -> bool:
|
|
35
|
+
low = quote.lower()
|
|
36
|
+
return any(m in low for m in NEGATION_MARKERS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_confirm() -> int:
|
|
40
|
+
ap = argparse.ArgumentParser(
|
|
41
|
+
description=f"Record user confirmation for {GATE_NAME}."
|
|
42
|
+
)
|
|
43
|
+
ap.add_argument("--project", required=True, help="Project name under video-clone/")
|
|
44
|
+
ap.add_argument(
|
|
45
|
+
"--quote", required=True,
|
|
46
|
+
help="The user's actual confirmation words (verbatim). Required.",
|
|
47
|
+
)
|
|
48
|
+
ap.add_argument(
|
|
49
|
+
"--force", action="store_true",
|
|
50
|
+
help="Required if the quote contains negation markers "
|
|
51
|
+
"(used when user said something like 'no audio, rest is fine').",
|
|
52
|
+
)
|
|
53
|
+
args = ap.parse_args()
|
|
54
|
+
|
|
55
|
+
quote = args.quote.strip()
|
|
56
|
+
if not quote:
|
|
57
|
+
print(
|
|
58
|
+
"ERROR: --quote is empty. You must pass the user's actual "
|
|
59
|
+
"confirmation words.",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
if _looks_like_negation(quote) and not args.force:
|
|
65
|
+
print(
|
|
66
|
+
f"ERROR: quote looks like a correction / negation: {quote!r}\n"
|
|
67
|
+
f"If the user really confirmed (e.g. 'no audio, rest is fine'), "
|
|
68
|
+
f"re-run with --force. Otherwise, go back and get a cleaner "
|
|
69
|
+
f"confirmation from the user.",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
project_dir = _gen_output_root() / "video-clone" / args.project
|
|
75
|
+
if not project_dir.is_dir():
|
|
76
|
+
print(
|
|
77
|
+
f"ERROR: project directory not found at {project_dir}\n"
|
|
78
|
+
f"Run init_project.py first.",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
# IMPORTANT: caller is sys.argv[0] (the entry script), not __file__.
|
|
84
|
+
# We want the audit to record "confirm.py", not "_confirm.py".
|
|
85
|
+
caller = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "<unknown>"
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
_gate.set_gate(project_dir, GATE_NAME, quote, caller=caller)
|
|
89
|
+
except (ValueError, FileNotFoundError, KeyError) as e:
|
|
90
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
print(f"OK: {GATE_NAME} recorded for project {args.project}")
|
|
94
|
+
print(f" quote: {quote}")
|
|
95
|
+
print(f" caller: {caller}")
|
|
96
|
+
return 0
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Unit tests for _confirm.run_confirm() — caller injection + quote validation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
SCRIPTS_DIR = Path(__file__).parent
|
|
12
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
import _confirm # noqa: E402
|
|
15
|
+
import _gate # noqa: E402
|
|
16
|
+
|
|
17
|
+
TEMPLATE = json.loads(
|
|
18
|
+
(SCRIPTS_DIR.parent / "assets" / "phase-state-template.json").read_text(encoding="utf-8")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_project(root: Path, name: str = "p1") -> Path:
|
|
23
|
+
proj = root / "video-clone" / name
|
|
24
|
+
(proj / ".state").mkdir(parents=True)
|
|
25
|
+
state = json.loads(json.dumps(TEMPLATE))
|
|
26
|
+
state["project"] = name
|
|
27
|
+
state["task_type"] = "video_clone"
|
|
28
|
+
(proj / ".state" / "phase.json").write_text(json.dumps(state), encoding="utf-8")
|
|
29
|
+
return proj
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_with_argv(argv, env=None):
|
|
33
|
+
old_argv = sys.argv
|
|
34
|
+
old_env = dict(os.environ)
|
|
35
|
+
try:
|
|
36
|
+
sys.argv = argv
|
|
37
|
+
if env:
|
|
38
|
+
os.environ.update(env)
|
|
39
|
+
return _confirm.run_confirm()
|
|
40
|
+
finally:
|
|
41
|
+
sys.argv = old_argv
|
|
42
|
+
os.environ.clear()
|
|
43
|
+
os.environ.update(old_env)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_run_confirm_injects_caller_from_argv0():
|
|
47
|
+
with tempfile.TemporaryDirectory() as td:
|
|
48
|
+
_make_project(Path(td))
|
|
49
|
+
rc = _run_with_argv(
|
|
50
|
+
["confirm.py", "--project", "p1", "--quote", "OK 开始"],
|
|
51
|
+
env={"GEN_OUTPUT_ROOT": td},
|
|
52
|
+
)
|
|
53
|
+
assert rc == 0
|
|
54
|
+
state = _gate.load_state(Path(td) / "video-clone" / "p1")
|
|
55
|
+
history = state["history"]
|
|
56
|
+
assert history[-1]["caller"] == "confirm.py"
|
|
57
|
+
assert history[-1]["user_quote"] == "OK 开始"
|
|
58
|
+
print("PASS test_run_confirm_injects_caller_from_argv0")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_run_confirm_empty_quote_returns_one():
|
|
62
|
+
with tempfile.TemporaryDirectory() as td:
|
|
63
|
+
_make_project(Path(td))
|
|
64
|
+
rc = _run_with_argv(
|
|
65
|
+
["confirm.py", "--project", "p1", "--quote", " "],
|
|
66
|
+
env={"GEN_OUTPUT_ROOT": td},
|
|
67
|
+
)
|
|
68
|
+
assert rc == 1
|
|
69
|
+
print("PASS test_run_confirm_empty_quote_returns_one")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_run_confirm_negation_without_force_returns_one():
|
|
73
|
+
with tempfile.TemporaryDirectory() as td:
|
|
74
|
+
_make_project(Path(td))
|
|
75
|
+
rc = _run_with_argv(
|
|
76
|
+
["confirm.py", "--project", "p1", "--quote", "不要这样改"],
|
|
77
|
+
env={"GEN_OUTPUT_ROOT": td},
|
|
78
|
+
)
|
|
79
|
+
assert rc == 1
|
|
80
|
+
print("PASS test_run_confirm_negation_without_force_returns_one")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_run_confirm_negation_with_force_returns_zero():
|
|
84
|
+
with tempfile.TemporaryDirectory() as td:
|
|
85
|
+
_make_project(Path(td))
|
|
86
|
+
rc = _run_with_argv(
|
|
87
|
+
["confirm.py", "--project", "p1", "--quote", "no audio, rest is fine", "--force"],
|
|
88
|
+
env={"GEN_OUTPUT_ROOT": td},
|
|
89
|
+
)
|
|
90
|
+
assert rc == 0
|
|
91
|
+
print("PASS test_run_confirm_negation_with_force_returns_zero")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_run_confirm_missing_project_returns_one():
|
|
95
|
+
with tempfile.TemporaryDirectory() as td:
|
|
96
|
+
rc = _run_with_argv(
|
|
97
|
+
["confirm.py", "--project", "does-not-exist", "--quote", "ok"],
|
|
98
|
+
env={"GEN_OUTPUT_ROOT": td},
|
|
99
|
+
)
|
|
100
|
+
assert rc == 1
|
|
101
|
+
print("PASS test_run_confirm_missing_project_returns_one")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_run_confirm_success_writes_history_with_caller():
|
|
105
|
+
with tempfile.TemporaryDirectory() as td:
|
|
106
|
+
_make_project(Path(td), "p2")
|
|
107
|
+
rc = _run_with_argv(
|
|
108
|
+
["confirm.py", "--project", "p2", "--quote", "好的,开始"],
|
|
109
|
+
env={"GEN_OUTPUT_ROOT": td},
|
|
110
|
+
)
|
|
111
|
+
assert rc == 0
|
|
112
|
+
state = _gate.load_state(Path(td) / "video-clone" / "p2")
|
|
113
|
+
assert state["gates"]["preview_confirmed"]["status"] is True
|
|
114
|
+
assert state["history"][-1]["caller"] == "confirm.py"
|
|
115
|
+
print("PASS test_run_confirm_success_writes_history_with_caller")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
test_run_confirm_injects_caller_from_argv0()
|
|
120
|
+
test_run_confirm_empty_quote_returns_one()
|
|
121
|
+
test_run_confirm_negation_without_force_returns_one()
|
|
122
|
+
test_run_confirm_negation_with_force_returns_zero()
|
|
123
|
+
test_run_confirm_missing_project_returns_one()
|
|
124
|
+
test_run_confirm_success_writes_history_with_caller()
|
|
125
|
+
print("All 6 _confirm tests passed.")
|