mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
Compare commits
53 Commits
docs/lark-
...
feat-svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95d1ef0fa | ||
|
|
d1cdf6e75f | ||
|
|
c7aaaa6507 | ||
|
|
7dcc2fde34 | ||
|
|
f54e1fea26 | ||
|
|
313f6316e1 | ||
|
|
78770368e1 | ||
|
|
092f2d3d9a | ||
|
|
e0a8f28ffc | ||
|
|
a7d5533c9a | ||
|
|
27e7ef8d5b | ||
|
|
4364609e37 | ||
|
|
a6b35d1e77 | ||
|
|
8d0197630f | ||
|
|
7ef44f7c27 | ||
|
|
33596111c7 | ||
|
|
fe8620425d | ||
|
|
76214a6176 | ||
|
|
a843ef0ac2 | ||
|
|
2a3e6ef2ef | ||
|
|
16f075b04a | ||
|
|
e5e17c17cf | ||
|
|
46014e9b77 | ||
|
|
57cc929ad1 | ||
|
|
bd63a20342 | ||
|
|
d82d4e3333 | ||
|
|
66ea925c3a | ||
|
|
0672f6de28 | ||
|
|
4dc182b8dd | ||
|
|
306307b3b3 | ||
|
|
589200c8c2 | ||
|
|
a215a33c8b | ||
|
|
1666c4db43 | ||
|
|
f3a40e4cda | ||
|
|
00222052ef | ||
|
|
f8950cdc8a | ||
|
|
74e7c5abee | ||
|
|
50754e53b1 | ||
|
|
ca8efe5d92 | ||
|
|
5ae2594a5f | ||
|
|
fd96f6e895 | ||
|
|
81c36bcf85 | ||
|
|
283462a36f | ||
|
|
d4e074a494 | ||
|
|
15e7ab8b66 | ||
|
|
f043ee61d8 | ||
|
|
5b264cf7b2 | ||
|
|
ead6362ab6 | ||
|
|
9c0c5ae26a | ||
|
|
8a450b6437 | ||
|
|
e196f68ef6 | ||
|
|
dff21a86ec | ||
|
|
38bf5402d9 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,19 +263,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -283,28 +277,7 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -319,22 +292,15 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -344,24 +310,16 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -27,9 +27,6 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
@@ -55,5 +52,3 @@ cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
/.agent-work/
|
||||
/.agents/
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -2,71 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
@@ -1398,9 +1333,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
18
README.md
18
README.md
@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # Comma-separated values
|
||||
```
|
||||
|
||||
### JSON Output Contract
|
||||
|
||||
With `--format json` (the default), success and error envelopes are distinct.
|
||||
|
||||
Success goes to **stdout**, exit code `0`:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
Errors go to **stderr**, non-zero exit code:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||
|
||||
### Pagination
|
||||
|
||||
```bash
|
||||
|
||||
18
README.zh.md
18
README.zh.md
@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # 逗号分隔值
|
||||
```
|
||||
|
||||
### JSON 输出契约
|
||||
|
||||
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||
|
||||
成功信封写入 **stdout**,退出码 0:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
错误信封写入 **stderr**,退出码非 0:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||
|
||||
### 分页
|
||||
|
||||
```bash
|
||||
|
||||
@@ -20,28 +20,13 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
cmd := NewCmdApi(f, runF)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestRootCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -69,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -92,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
@@ -113,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -140,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -153,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -166,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -232,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -251,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -270,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -287,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -312,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -343,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -383,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
// Should return an error
|
||||
@@ -424,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -463,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -498,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -564,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -615,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -671,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -736,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -756,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -775,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -806,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -827,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -845,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -874,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -895,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -914,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -932,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -949,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -966,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -989,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -1030,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1056,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -22,6 +22,11 @@ import (
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdAuthWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdAuthWithContext creates the auth command with subcommands.
|
||||
func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
@@ -38,7 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLoginWithContext(ctx, f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
|
||||
@@ -42,6 +42,11 @@ var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
return NewCmdAuthLoginWithContext(context.Background(), f, runF)
|
||||
}
|
||||
|
||||
// NewCmdAuthLoginWithContext creates the auth login subcommand.
|
||||
func NewCmdAuthLoginWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -73,7 +78,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
|
||||
18
cmd/build.go
18
cmd/build.go
@@ -90,8 +90,9 @@ func WithoutPlugins() BuildOption {
|
||||
}
|
||||
|
||||
// WithoutStrictMode builds the complete repository-owned command tree without
|
||||
// applying user/profile strict-mode pruning. It is intended for offline
|
||||
// inspection tools, not production execution.
|
||||
// applying user/profile strict-mode pruning or credential-backed bootstrap
|
||||
// probes. It is intended for offline inspection tools and pure local commands
|
||||
// that must not require account configuration.
|
||||
func WithoutStrictMode() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipStrictMode = true
|
||||
@@ -146,6 +147,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.skipStrictMode {
|
||||
ctx = cmdutil.ContextWithCredentialBootstrapDisabled(ctx)
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
@@ -192,10 +196,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithContext(ctx, f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithContext(ctx, f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -218,8 +222,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
if !cfg.skipStrictMode {
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
|
||||
34
cmd/root.go
34
cmd/root.go
@@ -103,10 +103,16 @@ func Execute() int {
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts := []BuildOption{
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
}
|
||||
if isLocalSVGlideInvocation(rawInvocationArgs) {
|
||||
buildOpts = append(buildOpts, WithoutStrictMode())
|
||||
}
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts...,
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -130,6 +136,30 @@ func Execute() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isLocalSVGlideInvocation(args []string) bool {
|
||||
positionals := make([]string, 0, 2)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--profile":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
continue
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
continue
|
||||
default:
|
||||
positionals = append(positionals, arg)
|
||||
if len(positionals) == 2 {
|
||||
return positionals[0] == "slides" && positionals[1] == "+create-svglide"
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
|
||||
@@ -14,13 +14,11 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -105,11 +103,6 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -5,9 +5,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -26,6 +29,27 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
type countingKeychain struct {
|
||||
gets int
|
||||
sets int
|
||||
removes int
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Get(service, account string) (string, error) {
|
||||
k.gets++
|
||||
return "", fmt.Errorf("unexpected keychain Get for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Set(service, account, value string) error {
|
||||
k.sets++
|
||||
return fmt.Errorf("unexpected keychain Set for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Remove(service, account string) error {
|
||||
k.removes++
|
||||
return fmt.Errorf("unexpected keychain Remove for %s/%s", service, account)
|
||||
}
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
// auth, config, and schema commands have auth check disabled,
|
||||
// while api does not.
|
||||
@@ -75,6 +99,63 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalSVGlideInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "local svglide", args: []string{"slides", "+create-svglide", "--action", "init"}, want: true},
|
||||
{name: "with profile", args: []string{"--profile", "demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "with profile equals", args: []string{"--profile=demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "other slides shortcut", args: []string{"slides", "+create"}, want: false},
|
||||
{name: "root help", args: []string{"--help"}, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLocalSVGlideInvocation(tt.args); got != tt.want {
|
||||
t.Fatalf("isLocalSVGlideInvocation(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSVGlideRootCommandDoesNotTouchKeychain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
kc := &countingKeychain{}
|
||||
_, rootCmd, _ := buildInternal(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(&in, &out, &errOut),
|
||||
WithKeychain(kc),
|
||||
WithoutStrictMode(),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
rootCmd.SetArgs([]string{
|
||||
"slides",
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout=%s\nstderr=%s", err, out.String(), errOut.String())
|
||||
}
|
||||
if kc.gets != 0 || kc.sets != 0 || kc.removes != 0 {
|
||||
t.Fatalf("keychain touched: gets=%d sets=%d removes=%d", kc.gets, kc.sets, kc.removes)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
|
||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||
// It uses the same source as schema execution so completion candidates match
|
||||
// what `schema` can resolve.
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the schema catalog and renders the
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.SchemaCatalog()
|
||||
if len(catalog.Services()) == 0 {
|
||||
// No embedded metadata and the runtime fallback is empty too: offline
|
||||
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||
}
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
|
||||
@@ -102,8 +102,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -165,7 +164,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -227,23 +226,12 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
pm := "npm"
|
||||
install := updater.RunNpmInstall
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
pm = "pnpm"
|
||||
install = updater.RunPnpmInstall
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -251,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
|
||||
npmResult := install(latest)
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined, pm),
|
||||
"hint": permissionHint(combined),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -275,7 +263,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined, pm); hint != "" {
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -286,7 +274,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest, pm)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
@@ -316,33 +304,23 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if skillsResult != nil {
|
||||
skillsPM := "npx"
|
||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||
skillsPM = "pnpm dlx"
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
return ""
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||
}
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
return ""
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,27 +57,6 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||
// and fails the test if the npm install path is invoked.
|
||||
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.PnpmInstallOverride = pnpmFn
|
||||
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||
t.Errorf("npm install must not be called for a pnpm install")
|
||||
return &selfupdate.NpmResult{}
|
||||
}
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -102,110 +81,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "via pnpm") {
|
||||
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||
)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error exit")
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||
t.Errorf("expected failure envelope, got: %s", out)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -391,9 +266,6 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
@@ -867,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
@@ -877,25 +749,16 @@ func TestPermissionHint(t *testing.T) {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||
}
|
||||
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied", "npm")
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error", "npm"); got != "" {
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ type Options struct {
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdWhoamiWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdWhoamiWithContext creates the whoami command using the build context
|
||||
// for registration-time strict-mode presentation.
|
||||
func NewCmdWhoamiWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
@@ -63,7 +69,7 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &opts.As)
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# `slides +create-svglide` Codex Runtime Design
|
||||
|
||||
Date: 2026-07-02
|
||||
Branch: `feat-svglide-07`
|
||||
Scope: first local-only version of `lark-cli slides +create-svglide`
|
||||
|
||||
## Result
|
||||
|
||||
Build `slides +create-svglide` as a staged local runtime for AnyGen SVG Slides. The command creates and manages a run directory that Codex can fill with generated content, assets, and SVG slides. The CLI owns state, prompts, schemas, validation, preview, receipts, and recovery. Codex owns LLM reasoning, web research, image/search execution, chart design, and SVG authoring.
|
||||
|
||||
The first version does not publish to Feishu Slides. It must produce a local, inspectable SVG deck workbench.
|
||||
|
||||
## Context
|
||||
|
||||
`feat-svglide-07` currently starts from the latest `origin/main` and has only the existing Slides XML shortcut surface. There is no current `+create-svglide` implementation on this branch.
|
||||
|
||||
The AnyGen SVG Slides prompt should be reused as contracts and workflow rules, not pasted as one large prompt. Its value is split across request interpretation, research, design brief, outline, `slide_content.md`, asset planning, SVG authoring, protocol validation, preview, and repair.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a staged `slides +create-svglide` command group.
|
||||
- Create a local run directory under a user-specified `--out` path, usually `.lark-slides/svglide-runs/<run-id>`.
|
||||
- Generate prompt task files that tell Codex exactly what to produce for each stage.
|
||||
- Generate JSON schemas for stage outputs.
|
||||
- Track stage state in `run.json`.
|
||||
- Validate JSON outputs, SVG protocol basics, asset href existence, slide count, placeholder slides, and preview generation.
|
||||
- Generate `preview.html` for local inspection.
|
||||
- Write receipts and `repair_queue.md` so failed runs can resume from the current stage.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No online Feishu Slides creation.
|
||||
- No `slide_engine` or `slide` server changes.
|
||||
- No SVG-to-SXSD conversion.
|
||||
- No built-in model API provider.
|
||||
- No built-in web search, image generation, or image search client.
|
||||
- No complete 12-agent process runner.
|
||||
- No PPTX import/edit workflow.
|
||||
|
||||
## Command Surface
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide init --title "Demo" --input ./source.md --audience "..." --delivery-mode self_read --pages 8 --out ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide next <run-dir>
|
||||
lark-cli slides +create-svglide status <run-dir>
|
||||
lark-cli slides +create-svglide validate <run-dir>
|
||||
lark-cli slides +create-svglide preview <run-dir>
|
||||
```
|
||||
|
||||
`init` creates the run directory, writes the initial request files, schemas, stage prompts, and `run.json`.
|
||||
|
||||
`next` reads `run.json`, finds the next stage, verifies required inputs, renders or refreshes that stage's Codex task prompt, and reports the exact files Codex must create. It must not pretend LLM work is complete.
|
||||
|
||||
`status` checks declared outputs and receipts for each stage, then prints the current stage, missing files, and next useful command.
|
||||
|
||||
`validate` runs deterministic checks and writes validation receipts.
|
||||
|
||||
`preview` writes `preview.html` from `outline/deck.json` and `slides/*.svg`.
|
||||
|
||||
## Run Directory Contract
|
||||
|
||||
```text
|
||||
<run-dir>/
|
||||
run.json
|
||||
README.md
|
||||
request/request.json
|
||||
request/source_manifest.json
|
||||
research/research_notes.md
|
||||
research/sources.json
|
||||
brief/design_brief.json
|
||||
brief/visual_system.json
|
||||
outline/deck.json
|
||||
content/slide_content.md
|
||||
content/slide_content.json
|
||||
assets/assets_plan.json
|
||||
assets/images/
|
||||
assets/charts/
|
||||
slides/*.svg
|
||||
prompts/*.task.md
|
||||
schemas/*.schema.json
|
||||
receipts/*.json
|
||||
receipts/generation_summary.md
|
||||
repair_queue.md
|
||||
preview.html
|
||||
```
|
||||
|
||||
The run directory is local agent state. It should not be committed by default.
|
||||
|
||||
## State Model
|
||||
|
||||
`run.json` stores:
|
||||
|
||||
- version
|
||||
- runtime, always `codex` in v1
|
||||
- command name
|
||||
- title
|
||||
- created and updated timestamps
|
||||
- current stage
|
||||
- stage list with status, inputs, outputs, and receipt path
|
||||
- important artifact paths
|
||||
- policy flags: `publish_enabled=false`, `network_by_codex=true`, `image_generation_by_codex=true`, `overwrite=false`
|
||||
|
||||
Stage statuses:
|
||||
|
||||
```text
|
||||
pending
|
||||
ready
|
||||
in_progress
|
||||
done
|
||||
failed
|
||||
blocked
|
||||
needs_repair
|
||||
```
|
||||
|
||||
## Stage Design
|
||||
|
||||
### 1. request
|
||||
|
||||
Role: Request Interpreter
|
||||
|
||||
Input: CLI flags and local source path.
|
||||
|
||||
Output: `request/request.json`, `request/source_manifest.json`.
|
||||
|
||||
Validation: title, audience, delivery mode, page count, and source references must be explicit or marked missing.
|
||||
|
||||
### 2. research
|
||||
|
||||
Role: Researcher
|
||||
|
||||
Input: request files and source files.
|
||||
|
||||
Output: `research/research_notes.md`, `research/sources.json`.
|
||||
|
||||
Validation: key facts need source references. Codex may perform web research, but the CLI only validates resulting files.
|
||||
|
||||
### 3. design_brief
|
||||
|
||||
Role: Design Brief Resolver and Visual System Planner
|
||||
|
||||
Input: request and research outputs.
|
||||
|
||||
Output: `brief/design_brief.json`, `brief/visual_system.json`.
|
||||
|
||||
Validation: narrative spine, depth, tone, and visual system dimensions must be present.
|
||||
|
||||
### 4. outline
|
||||
|
||||
Role: Outline Planner
|
||||
|
||||
Input: design brief.
|
||||
|
||||
Output: `outline/deck.json`.
|
||||
|
||||
Validation: page count matches request; each slide has id, title, summary, role, and key message.
|
||||
|
||||
### 5. slide_content
|
||||
|
||||
Role: Content Builder
|
||||
|
||||
Input: deck outline and research notes.
|
||||
|
||||
Output: `content/slide_content.md`, `content/slide_content.json`.
|
||||
|
||||
Validation: every slide has key material, content blocks, and source notes. This is content planning, not final layout.
|
||||
|
||||
### 6. assets
|
||||
|
||||
Role: Asset Planner and Chart Generator
|
||||
|
||||
Input: slide content and visual system.
|
||||
|
||||
Output: `assets/assets_plan.json`, optional `assets/images/*`, optional `assets/charts/*.svg`.
|
||||
|
||||
Validation: every planned asset has purpose plus either a local path or a fallback. Chart takeaway must be written before chart type.
|
||||
|
||||
### 7. svg_author
|
||||
|
||||
Role: SVG Author
|
||||
|
||||
Input: deck, slide content, visual system, and assets.
|
||||
|
||||
Output: `slides/*.svg`.
|
||||
|
||||
Validation: each slide must contain more than a background. Each slide needs a background, title, visible content or visual element, semantic id, and valid SVG root.
|
||||
|
||||
### 8. validate_preview_repair
|
||||
|
||||
Role: Protocol Validator, Preview Agent, and Repair Agent
|
||||
|
||||
Input: generated slides.
|
||||
|
||||
Output: `receipts/lint.json`, `receipts/preview.json`, `repair_queue.md`, `preview.html`.
|
||||
|
||||
Validation: SVG protocol lint, local href checks, slide count match, preview write success, and unresolved issues recorded in the repair queue.
|
||||
|
||||
## Code Layout
|
||||
|
||||
```text
|
||||
shortcuts/slides/
|
||||
slides_create_svglide.go
|
||||
slides_create_svglide_test.go
|
||||
|
||||
internal/svglide/
|
||||
run.go
|
||||
init.go
|
||||
stage.go
|
||||
prompt.go
|
||||
schema.go
|
||||
validate.go
|
||||
preview.go
|
||||
receipt.go
|
||||
```
|
||||
|
||||
The shortcut package should stay thin. State, prompt rendering, validation, and preview logic belong in `internal/svglide` so they can be tested without a Cobra/runtime-heavy command harness.
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
Update `skills/lark-slides/SKILL.md` and add a focused reference file for the local SVG runtime. The skill should explain that `+create-svglide` is local-only in v1, requires Codex to fill stage outputs, and must not be described as an online publish path.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing required inputs block the stage and write a receipt.
|
||||
- Invalid JSON or schema mismatch marks the stage failed.
|
||||
- Invalid SVG marks `needs_repair` and writes `repair_queue.md`.
|
||||
- Existing output paths are not overwritten unless an explicit overwrite policy is enabled.
|
||||
- Partially completed stages remain inspectable; reruns resume from the current stage.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `init` creates the expected directory tree and `run.json`.
|
||||
- `init` refuses to overwrite an existing run directory by default.
|
||||
- `status` identifies missing outputs.
|
||||
- `next` renders the correct stage prompt and does not mark Codex-only stages done.
|
||||
- `validate` catches invalid SVG, missing hrefs, placeholder slides, and slide count mismatch.
|
||||
- `preview` writes HTML that references generated SVG files.
|
||||
|
||||
Fixtures:
|
||||
|
||||
- `testdata/svglide_run_valid/`
|
||||
- `testdata/svglide_run_invalid/`
|
||||
|
||||
No live end-to-end test is required for v1 because this version does not call Feishu APIs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A user can initialize a run directory from local input.
|
||||
- Codex can follow generated task prompts stage by stage.
|
||||
- The CLI can report status and missing artifacts.
|
||||
- The CLI can validate a completed local SVG deck.
|
||||
- The CLI can generate local preview HTML.
|
||||
- Failed validation produces actionable repair output.
|
||||
- No online presentation is created.
|
||||
|
||||
## Further Judgment
|
||||
|
||||
This design deliberately optimizes for artifact contracts rather than agent-count symmetry. Once the local runtime is stable, individual stages can be split into fuller agents without changing the run directory contract.
|
||||
2426
docs/vendor/anygen-svg/source.full.md
vendored
Normal file
2426
docs/vendor/anygen-svg/source.full.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
docs/vendor/anygen-svg/source.meta.json
vendored
Normal file
8
docs/vendor/anygen-svg/source.meta.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"doc_url": "https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd",
|
||||
"local_full_snapshot": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.md",
|
||||
"local_handoff": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md",
|
||||
"fetched_by": "local export",
|
||||
"fetched_for": "slides +create-svglide AnyGen SVG prompt runtime experiment",
|
||||
"experiment_mode": "experiment_unrestricted_assets"
|
||||
}
|
||||
20
docs/vendor/anygen-svg/source.outline.md
vendored
Normal file
20
docs/vendor/anygen-svg/source.outline.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# AnyGen SVG Slides Local Outline
|
||||
|
||||
Source full snapshot: `docs/vendor/anygen-svg/source.full.md`
|
||||
Source handoff: `/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md`
|
||||
Remote doc: `https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd`
|
||||
|
||||
Required sections to split:
|
||||
|
||||
- System prompt(编排 / mode_system_prompt_svg)
|
||||
- SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
- resolve_design_brief
|
||||
- slide_outline
|
||||
- activate_slides_edit
|
||||
- slides_edit
|
||||
- finish_slides_edit
|
||||
- slide_organize
|
||||
- compute_custom_shape_bbox
|
||||
- generate_svg_chart
|
||||
- slides_convert
|
||||
- slides_parse_template
|
||||
@@ -72,28 +72,6 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||
`CategoryPolicy`.
|
||||
|
||||
### Success envelope (stdout)
|
||||
|
||||
For contrast: success responses render to **stdout** as an
|
||||
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": { "guid": "e297d3d0-..." },
|
||||
"meta": { "count": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
Consumers must branch on `ok` (or the process exit code). The success
|
||||
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||
will misclassify every successful call as a failure, which is
|
||||
especially dangerous around write commands (e.g. retrying a create that
|
||||
already succeeded).
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | When | Exit | Typed struct |
|
||||
|
||||
@@ -77,10 +77,14 @@ func loadService(service string) map[string]json.RawMessage {
|
||||
// space→dot fallback covers domains where the two already coincide.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
|
||||
@@ -48,6 +48,22 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
type skipCredentialBootstrapKey struct{}
|
||||
|
||||
// ContextWithCredentialBootstrapDisabled marks a command-tree build as
|
||||
// credential-free. Use it only for purely local command surfaces that must be
|
||||
// constructed without probing strict-mode, profile, or keychain state.
|
||||
func ContextWithCredentialBootstrapDisabled(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, skipCredentialBootstrapKey{}, true)
|
||||
}
|
||||
|
||||
// IsCredentialBootstrapDisabled reports whether credential-backed bootstrap
|
||||
// probes must be skipped for this context.
|
||||
func IsCredentialBootstrapDisabled(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(skipCredentialBootstrapKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
@@ -109,6 +125,9 @@ func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
}
|
||||
|
||||
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -148,6 +167,9 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
// Account.SupportedIdentities from the credential provider chain.
|
||||
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -38,6 +40,8 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -45,6 +49,25 @@ func UserAgentValue() string {
|
||||
return SourceValue + "/" + build.Version
|
||||
}
|
||||
|
||||
// AgentTraceValue returns a header-safe value from the
|
||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
||||
// surrounding whitespace, rejects values containing any Unicode
|
||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
||||
// for any invalid or empty value. Callers can use the result
|
||||
// directly in HTTP headers without further sanitisation.
|
||||
func AgentTraceValue() string {
|
||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
||||
if v == "" || len(v) > agentTraceMaxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BaseSecurityHeaders returns headers that every request must carry.
|
||||
func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTraceValue(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " ")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(envvars.CliAgentTrace, val)
|
||||
if got := AgentTraceValue(); got != val {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -19,7 +19,6 @@ const (
|
||||
// Content safety scanning mode
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
agentNameMaxLen = 128
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
func AgentName() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||
}
|
||||
|
||||
func AgentTrace() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||
}
|
||||
|
||||
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" || len(v) > maxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\x01injected")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||
t.Setenv(CliAgentName, longVal)
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTrace(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTrace(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " ")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(CliAgentTrace, longVal)
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(CliAgentTrace, val)
|
||||
if got := AgentTrace(); got != val {
|
||||
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,20 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,35 +114,8 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -6,7 +6,8 @@ package registry
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
@@ -17,14 +18,3 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
||||
func RuntimeCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||
}
|
||||
|
||||
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||
// nothing to resolve; the merged view is the only data such binaries have.
|
||||
func SchemaCatalog() apicatalog.Catalog {
|
||||
if len(EmbeddedServicesTyped()) > 0 {
|
||||
return EmbeddedCatalog()
|
||||
}
|
||||
return RuntimeCatalog()
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||
// restores them (with a full state reset) on cleanup.
|
||||
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
resetInit()
|
||||
orig := embeddedMetaJSON
|
||||
embeddedMetaJSON = data
|
||||
t.Cleanup(func() {
|
||||
waitBackgroundRefresh()
|
||||
embeddedMetaJSON = orig
|
||||
resetInit()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||
}
|
||||
if _, ok := c.Service("embedded_svc"); !ok {
|
||||
t.Fatal("expected embedded_svc from embedded metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||
// Init seeds via sync fetch.
|
||||
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write(testEnvelopeJSON("remote_svc"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
testMetaURL = ts.URL
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceRuntime {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||
}
|
||||
if _, ok := c.Service("remote_svc"); !ok {
|
||||
t.Fatal("expected remote_svc from runtime fallback")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
@@ -86,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
// After a CLI upgrade the embedded data can be fresher than an old
|
||||
// cache; an equal/older cache must not shadow it.
|
||||
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||
// marker, tagged with the given top-level data version and brand.
|
||||
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||
t.Helper()
|
||||
cDir := filepath.Join(dir, "cache")
|
||||
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := MergedRegistry{
|
||||
Version: version,
|
||||
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||
}
|
||||
data, _ := json.Marshal(reg)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||
mData, _ := json.Marshal(cm)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||
t.Helper()
|
||||
embedded, _ := json.Marshal(MergedRegistry{
|
||||
Version: embeddedVer,
|
||||
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||
})
|
||||
swapEmbeddedMeta(t, embedded)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||
InitWithBrand(core.BrandFeishu)
|
||||
}
|
||||
|
||||
func titleOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
svc, ok := ServiceTyped(name)
|
||||
if !ok {
|
||||
t.Fatalf("service %q not loaded", name)
|
||||
}
|
||||
return svc.Title
|
||||
}
|
||||
|
||||
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "2.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "2.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "not-a-semver")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||
// plugin builds without compiled meta_data.json still get remote data.
|
||||
initWithCache(t, "0.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,9 @@ func hasEmbeddedServices() bool {
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||
// so cache overlay passes the version gate in InitWithBrand.
|
||||
func testRegistry(name string) MergedRegistry {
|
||||
return MergedRegistry{
|
||||
Version: "1.0.0",
|
||||
Version: "test-1.0",
|
||||
Services: []meta.Service{
|
||||
{
|
||||
Name: name,
|
||||
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
resetInit()
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
resetInit()
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
if reg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||
if reg.Version != "test-1.0" {
|
||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -54,32 +53,22 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
switch d.Method {
|
||||
case InstallNpm:
|
||||
return d.NpmAvailable
|
||||
case InstallPnpm:
|
||||
return d.PnpmAvailable
|
||||
}
|
||||
return false
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
switch {
|
||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||
return "installed via pnpm, but pnpm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm or pnpm"
|
||||
return "not installed via npm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
@@ -103,7 +92,6 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
@@ -113,38 +101,17 @@ type Updater struct {
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
|
||||
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||
// binary was installed cannot change during a single process, so caching is
|
||||
// the correct semantics — and it is required for correctness: the update
|
||||
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||
// skills, so a re-detection at skills time could resolve a now-stale
|
||||
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||
// sequential.
|
||||
detectCache *DetectResult
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
if u.detectCache != nil {
|
||||
return *u.detectCache
|
||||
}
|
||||
result := u.detectInstallMethod()
|
||||
u.detectCache = &result
|
||||
return result
|
||||
}
|
||||
|
||||
// detectInstallMethod performs the real (uncached) detection.
|
||||
func (u *Updater) detectInstallMethod() DetectResult {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
@@ -153,54 +120,24 @@ func (u *Updater) detectInstallMethod() DetectResult {
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
_, npmErr := exec.LookPath("npm")
|
||||
_, pnpmErr := exec.LookPath("pnpm")
|
||||
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||
}
|
||||
|
||||
// detectFromResolved classifies the resolved binary path into an install
|
||||
// method and records package-manager availability. Split out from
|
||||
// DetectInstallMethod so the classification is unit-testable without touching
|
||||
// the filesystem or PATH.
|
||||
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
method = InstallNpm
|
||||
}
|
||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||
switch method {
|
||||
case InstallNpm:
|
||||
d.NpmAvailable = npmOnPath
|
||||
case InstallPnpm:
|
||||
d.PnpmAvailable = pnpmOnPath
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||
// whose resolved path runs through pnpm's home directory (e.g.
|
||||
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||
// followed by "store". Matching only these two shapes (rather than any bare
|
||||
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||
// classification is OS-independent and unit-testable anywhere.
|
||||
func containsPnpmMarker(p string) bool {
|
||||
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == ".pnpm" {
|
||||
return true
|
||||
}
|
||||
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||
return true
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
@@ -226,29 +163,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||
if u.PnpmInstallOverride != nil {
|
||||
return u.PnpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
pnpmPath, err := exec.LookPath("pnpm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
@@ -347,40 +261,19 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
||||
return u.runSkillsCommand(args...)
|
||||
}
|
||||
|
||||
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||
// launcher selection is unit-testable on any platform.
|
||||
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||
if method == InstallPnpm && pnpmAvailable {
|
||||
r := args
|
||||
if len(r) > 0 && r[0] == "-y" {
|
||||
r = r[1:]
|
||||
}
|
||||
return "pnpm", append([]string{"dlx"}, r...)
|
||||
}
|
||||
return "npx", args
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
det := u.DetectInstallMethod()
|
||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||
binPath, err := exec.LookPath(launcher)
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,147 +371,3 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPnpmMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Classic virtual-store layout (.pnpm segment).
|
||||
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/usr/local/bin/lark-cli", false},
|
||||
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||
// "pnpm" immediately followed by "store".
|
||||
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsPnpmMarker(c.path); got != c.want {
|
||||
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||
u := &Updater{DetectOverride: nil}
|
||||
u.DetectOverride = func() DetectResult {
|
||||
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||
}
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm {
|
||||
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||
}
|
||||
if !got.PnpmAvailable {
|
||||
t.Errorf("PnpmAvailable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||
}
|
||||
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||
t.Error("pnpm available should CanAutoUpdate")
|
||||
}
|
||||
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualReason_Pnpm(t *testing.T) {
|
||||
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||
t.Errorf("pnpm reason = %q", got)
|
||||
}
|
||||
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||
t.Errorf("manual reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||
return r
|
||||
}}
|
||||
got := u.RunPnpmInstall("2.0.0")
|
||||
if got.Err != nil {
|
||||
t.Fatalf("unexpected err: %v", got.Err)
|
||||
}
|
||||
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsInvocation(t *testing.T) {
|
||||
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
method InstallMethod
|
||||
pnpmAvailable bool
|
||||
args []string
|
||||
wantLauncher string
|
||||
wantRest []string
|
||||
}{
|
||||
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||
if gotLauncher != c.wantLauncher {
|
||||
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||
}
|
||||
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||
// stays consistent with what was detected — and reported — before the update.
|
||||
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
u := New()
|
||||
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||
u.detectCache = &cached
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
411
internal/svglide/agent_runtime_e2e_test.go
Normal file
411
internal/svglide/agent_runtime_e2e_test.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFakeAgentHappyPathProducesSVGDeck(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
opts := InitOptions{Title: "电影介绍", Pages: 1}
|
||||
setStringInitOptionField(t, &opts, "Topic", "介绍一部电影")
|
||||
setStringInitOptionField(t, &opts, "Language", "zh")
|
||||
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
|
||||
setStringInitOptionField(t, &opts, "AgentID", "fake-agent-e2e")
|
||||
|
||||
if err := InitRun("demo", opts); err != nil {
|
||||
t.Fatalf("topic-only fake-agent init should succeed: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("demo", "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, stage := range []string{
|
||||
StageRequest,
|
||||
StageRequestResolution,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
} {
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != stage {
|
||||
t.Fatalf("current stage = %q, want %q", run.CurrentStage, stage)
|
||||
}
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", stage, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, stage)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
writeFakeAgentStageArtifacts(t, stage)
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("complete %s: %v", stage, err)
|
||||
}
|
||||
}
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", StageValidatePreviewRepair, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, StageValidatePreviewRepair)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
|
||||
repair, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("repair: %v", err)
|
||||
}
|
||||
if repair.Status != "passed" {
|
||||
t.Fatalf("repair status = %q, want passed: %+v", repair.Status, repair)
|
||||
}
|
||||
mustWritePassedScreenshotEvidenceForTest(t)
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("complete %s: %v", StageValidatePreviewRepair, err)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/image_usage.json",
|
||||
"receipts/media_pressure.json",
|
||||
"receipts/content_payload.json",
|
||||
"receipts/editorial_quality.json",
|
||||
"receipts/screenshot_evidence.json",
|
||||
"receipts/chart_quality.json",
|
||||
"quality_report.json",
|
||||
"anygen_semantic_report.json",
|
||||
"receipts/delivery.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing final artifact %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.SemanticMetrics.VisibleLeakCount != 0 || delivery.SemanticMetrics.MissingFontTokenCount != 0 {
|
||||
t.Fatalf("delivery semantic metrics = %+v, want no visible leaks and all font tokens", delivery.SemanticMetrics)
|
||||
}
|
||||
if delivery.Status != StatusReady {
|
||||
t.Fatalf("delivery status = %q, want ready with full-chain screenshot evidence: %+v", delivery.Status, delivery.FullChainEvidence)
|
||||
}
|
||||
if len(delivery.FullChainEvidence.ScreenshotEvidence) == 0 {
|
||||
t.Fatalf("delivery screenshot evidence is empty: %+v", delivery.FullChainEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeAgentChartChainRendersAndValidatesVegaLite(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
opts := InitOptions{Title: "Chart Deck", Pages: 1}
|
||||
setStringInitOptionField(t, &opts, "Topic", "chart-only revenue comparison")
|
||||
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
|
||||
setStringInitOptionField(t, &opts, "AgentID", "fake-agent-chart-e2e")
|
||||
|
||||
if err := InitRun("demo", opts); err != nil {
|
||||
t.Fatalf("chart fake-agent init should succeed: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("demo", "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"input_text":"chart-only revenue comparison","resolved_entity":{"name":"chart-only revenue comparison","type":"topic","confidence_bp":9000,"confidence_band":"high","reason":"chart-only E2E fixture"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"visual_quality_contract":{"profile":"data_report","requires_real_images":false,"required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1,"reason":"chart-only E2E"},"clarification_question":""}`)
|
||||
mustWriteTestFile(t, "demo/request/theme_contract.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"theme_contract":{"content_type":{"primary":"data_report","secondary":["chart_only"]},"subject_type":{"primary":"topic","named_entity":false,"entity_name":"chart-only revenue comparison"},"delivery_format":{"primary":"self_read","density":"medium"},"evidence_type":{"primary":"quantitative_comparison","requires_sources":true},"asset_needs":{"requires_real_images":false,"required_roles":[],"min_real_image_pages":0,"min_dominant_real_image_pages":0,"min_unique_real_images":0,"cover_requires_dominant_real_image":false},"layout_rhythm":{"min_slide_count":1,"min_distinct_layout_archetypes":1,"max_adjacent_same_archetype":0,"required_page_roles":["cover","chart","closing"]},"typography_identity":{"profile":"data_report","display_category":"sans","body_category":"sans","number_category":"mono"},"quality_floor":{"profile":"chart_only","reason":"chart-only E2E fixture; no raster images required."},"rationale":"This fixture verifies Vega-Lite chart rendering without real image requirements."}}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"web1","path":"https://example.com/filing","title":"Company filing","excerpt":"Segment revenue data","usage":"chart data","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#76B900"},"typography":{"title":32,"body":16},"layout_language":"financial chart page"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","requires_real_images":false,"required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1,"topic_archetype":"","media_pressure":{},"editorial_quality_target":{},"reason":"chart-only E2E fixture"}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"prompt_contract":`+promptContractJSON(StageOutline)+`,"title":"Chart Deck","slides":[{"id":"s1","title":"Data center leads","summary":"Revenue mix comparison","role":"content","key_message":"Data center revenue leads the mix","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Data center leads","body":"Data center revenue leads the mix","labels":["Revenue $B","Source: web1"]},"production_instruction":{"layout":"Embed the rendered chart asset with rect role","asset_ids":["revenue_mix"]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Data center revenue leads the mix","central_claim":"Data center revenue is the dominant segment in this comparison.","audience_takeaway":"The audience should read the chart as a segment mix proof point, not decoration.","supporting_points":[{"text":"The chart compares reported segment revenue values from the filing source.","source_refs":["web1"]},{"text":"The data center bar is intentionally the visual anchor because it leads the mix.","source_refs":["web1"]}],"source_bound_facts":[{"fact":"Segment revenue data comes from the company filing source.","source_ref":"web1","usage":"visual_data"}],"visual_data_items":[{"label":"Data Center","value":"22.1","role":"metric","explanation":"Largest segment in the comparison.","source_ref":"web1"},{"label":"Gaming","value":"2.9","role":"metric","explanation":"Secondary segment for contrast.","source_ref":"web1"},{"label":"Professional Visualization","value":"0.5","role":"metric","explanation":"Smaller segment for scale context.","source_ref":"web1"}],"source_refs":["web1"],"visuals":[{"id":"revenue_mix","type":"chart","instruction":"Compare segment revenue"}],"so_what":"This proves the slide's message with a real quantitative relationship."}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"chart-only E2E fixture; no raster image required.","candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[],"no_image_reason":"chart-only E2E fixture; no raster image required."}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[],"no_image_reason":"chart-only E2E fixture; no raster image required."}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[{"id":"revenue_mix","slide_id":"s1","purpose":"comparison","takeaway":"Data center revenue leads the mix","renderer":"vega-lite","data_source_ids":["web1"],"unit":"$B","min_width":600,"min_height":320}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/specs/revenue_mix.vl.json", `{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","width":640,"height":360,"title":{"text":"Segment revenue comparison ($B)","subtitle":"Source: web1 company filing"},"data":{"values":[{"segment":"Data Center","revenue":22.1},{"segment":"Gaming","revenue":2.9},{"segment":"Professional Visualization","revenue":0.5}]},"mark":{"type":"bar","tooltip":true},"encoding":{"x":{"field":"segment","type":"nominal","sort":"-y","axis":{"title":"Segment"}},"y":{"field":"revenue","type":"quantitative","axis":{"title":"Revenue $B"}},"color":{"field":"segment","type":"nominal","legend":null}}}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"revenue_mix","slide_id":"s1","renderer":"vega-lite","brief_id":"revenue_mix","spec_path":"assets/charts/specs/revenue_mix.vl.json","svg_path":"assets/charts/revenue_mix.svg","source_id":"web1","unit":"$B","takeaway":"Data center revenue leads the mix","render_receipt":"receipts/chart_render.json"}]}`)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", StageAssets, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, StageAssets)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("complete %s: %v", StageAssets, err)
|
||||
}
|
||||
if status.CurrentStage != StageSVGAuthor {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageSVGAuthor)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "assets", "charts", "revenue_mix.svg")); err != nil {
|
||||
t.Fatalf("missing rendered chart SVG: %v", err)
|
||||
}
|
||||
var renderReport ChartRenderReport
|
||||
raw, err := readRunRegularArtifact("demo", chartRenderReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &renderReport); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if renderReport.Status != "passed" || len(renderReport.Charts) != 1 {
|
||||
t.Fatalf("chart render report = %+v, want one passed chart", renderReport)
|
||||
}
|
||||
|
||||
next, err = NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", StageSVGAuthor, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, StageSVGAuthor)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" width="960" height="540" slide:role="slide" viewBox="0 0 960 540"><slide:note>Source: web1</slide:note><rect width="960" height="540" fill="#fff"/>`+parserSafeTextBody()+`<rect slide:role="chart" href="../assets/charts/revenue_mix.svg" x="80" y="120" width="720" height="360"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"evidence","layout_family":"data_report","layout_archetype":"chart_forward","layout_signature":"hero_chart_with_title","thumbnail_job":"chart","visual_center":"Vega-Lite rendered revenue chart","topic_fit_claim":"uses chart evidence for revenue comparison","information_density_plan":"one chart plus one title","page_difference_from_previous":"single-slide chart fixture","primary_asset":"assets/charts/revenue_mix.svg","asset_role":"chart evidence","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"chart-forward financial evidence","data_visual_rationale":"Revenue comparison needs a standard chart","source_evidence":["web1 supports revenue data"],"container_fit_plan":"chart has open canvas and title outside chart bounds","container_decision":"no text card needed","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"chart_forward","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"revenue_mix","renderer":"vega-lite","unit":"$B","source":"web1","why_chart_is_needed":"compare segment revenue"},"fusion_spec":{"enabled":false},"qa_expectations":["chart is rendered asset, not hand drawn"]}]}`)
|
||||
|
||||
status, err = CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("complete %s: %v", StageSVGAuthor, err)
|
||||
}
|
||||
if status.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
quality, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality.Status != "passed" {
|
||||
t.Fatalf("quality = %+v, want passed", quality)
|
||||
}
|
||||
var usage ChartUsageReport
|
||||
raw, err = readRunRegularArtifact("demo", chartUsageReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &usage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usage.Status != "passed" || len(usage.Charts) != 1 {
|
||||
t.Fatalf("chart usage = %+v, want one passed chart", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EChineseTeaThemeContractQualityGate(t *testing.T) {
|
||||
root := copySVGlideE2EFixtureToTempRun(t, "testdata/svglide/e2e/chinese_tea_minimal")
|
||||
if err := ValidateStageOutputs(root); err != nil {
|
||||
t.Fatalf("ValidateStageOutputs returned %v", err)
|
||||
}
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckQuality returned %v", err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("quality status = %q, issues = %#v", report.Status, report.Issues)
|
||||
}
|
||||
if !report.Metrics.ThemeContractPresent || !report.Metrics.ThemeAssetNeedsApplied || report.Metrics.DominantRealImagePages < 3 {
|
||||
t.Fatalf("metrics = %+v, want theme contract applied with at least 3 dominant real-image pages", report.Metrics)
|
||||
}
|
||||
creative, err := CheckCreativeQuality(root)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckCreativeQuality returned %v", err)
|
||||
}
|
||||
if creative.Status != "passed" {
|
||||
t.Fatalf("creative status = %q, issues = %#v", creative.Status, creative.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EChineseTeaRejectsGenericVectorOnlyDeck(t *testing.T) {
|
||||
root := copySVGlideE2EFixtureToTempRun(t, "testdata/svglide/e2e/chinese_tea_minimal")
|
||||
mustWriteTestFile(t, filepath.Join(root, "assets", "assets_manifest.json"), `{"assets":[]}`)
|
||||
mustWriteTestFile(t, filepath.Join(root, "assets", "asset_inventory.json"), `{"items":[]}`)
|
||||
mustWriteTestFile(t, filepath.Join(root, "assets", "image_candidates.json"), `{"requires_real_images":true,"candidates":[]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckQuality returned %v", err)
|
||||
}
|
||||
if report.Status != "failed" || !qualityIssueCodesContain(report.Issues, "svglide.media_pressure.real_image_pages") {
|
||||
t.Fatalf("report = %#v, want real-image media pressure failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func copySVGlideE2EFixtureToTempRun(t *testing.T, fixtureRel string) string {
|
||||
t.Helper()
|
||||
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
runRoot := filepath.Join(cwd, "fixture")
|
||||
copyTestDir(t, filepath.Join(repoRoot, fixtureRel), runRoot)
|
||||
for name, schema := range DefaultSchemas() {
|
||||
mustWriteTestFile(t, filepath.Join("fixture", "schemas", name), schema)
|
||||
}
|
||||
return "fixture"
|
||||
}
|
||||
|
||||
func assertNextTaskHasRuntimeProtocolFields(t *testing.T, next NextTaskReport, stage string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["protocol"] != "anygen-svg-slides" {
|
||||
t.Fatalf("%s next.protocol = %v, want anygen-svg-slides", stage, payload["protocol"])
|
||||
}
|
||||
agentTask, ok := payload["agent_task"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s next.agent_task missing: %+v", stage, payload)
|
||||
}
|
||||
if agentTask["stage"] != stage {
|
||||
t.Fatalf("%s agent_task.stage = %v, want %s", stage, agentTask["stage"], stage)
|
||||
}
|
||||
if payload["prompt_context"] == nil || payload["tool_invocation_contract"] == nil {
|
||||
t.Fatalf("%s next missing prompt_context/tool_invocation_contract: %+v", stage, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeAgentReceiptsFromNext(t *testing.T, next NextTaskReport) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolContract, _ := payload["tool_invocation_contract"].(map[string]any)
|
||||
for _, call := range callsFromContract(toolContract, "required_calls", "conditional_calls") {
|
||||
id, _ := call["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
writeToolCallReceiptFromContractForE2E(t, next, call)
|
||||
}
|
||||
}
|
||||
|
||||
func callsFromContract(contract map[string]any, keys ...string) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, key := range keys {
|
||||
values, _ := contract[key].([]any)
|
||||
for _, value := range values {
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeToolCallReceiptFromContractForE2E(t *testing.T, next NextTaskReport, call map[string]any) {
|
||||
t.Helper()
|
||||
id, _ := call["id"].(string)
|
||||
promptID, _ := call["prompt_id"].(string)
|
||||
if promptID == "" {
|
||||
promptID = id
|
||||
}
|
||||
consumed := stringsFromJSONValue(call["consumes"])
|
||||
if len(consumed) == 0 {
|
||||
consumed = next.Inputs
|
||||
}
|
||||
produced := stringsFromJSONValue(call["produces"])
|
||||
if len(produced) == 0 {
|
||||
produced = next.Outputs
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"protocol": "anygen-svg-slides",
|
||||
"stage": next.Stage,
|
||||
"call_id": id,
|
||||
"prompt_id": promptID,
|
||||
"invocation": stringFromJSONValue(call["invocation"], "required"),
|
||||
"condition": stringFromJSONValue(call["condition"], "always"),
|
||||
"condition_matched": true,
|
||||
"order": intFromJSONValue(call["order"]),
|
||||
"cardinality": stringFromJSONValue(call["cardinality"], "once"),
|
||||
"consumed": consumed,
|
||||
"produced": produced,
|
||||
"status": "done",
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", next.Stage, id+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func stringFromJSONValue(value any, fallback string) string {
|
||||
if text, ok := value.(string); ok && text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intFromJSONValue(value any) int {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int(typed)
|
||||
case int:
|
||||
return typed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stringsFromJSONValue(value any) []string {
|
||||
values, _ := value.([]any)
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
if text, ok := item.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFakeAgentStageArtifacts(t *testing.T, stage string) {
|
||||
t.Helper()
|
||||
switch stage {
|
||||
case StageRequest:
|
||||
return
|
||||
case StageRequestResolution:
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"input_text":"介绍一部电影","resolved_entity":{"name":"介绍一部电影","type":"topic","confidence_bp":5000,"confidence_band":"medium","reason":"用户请求是开放主题,需要先研究确定内容方向"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"clarification_question":""}`)
|
||||
mustWriteTestFile(t, "demo/request/theme_contract.json", validThemeContractJSON())
|
||||
case StageResearch:
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# 电影资料\n\n用户提供主题。")
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"user1","path":"topic://介绍一部电影","title":"用户主题","excerpt":"介绍一部电影","usage":"primary brief","retrieval":"user_provided"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"介绍一部电影","type":"topic"},"queries":[{"query":"介绍一部电影","purpose":"context"}],"sources":[{"id":"user1","title":"用户主题","url":"topic://介绍一部电影","retrieved_at":"2026-07-04T00:00:00Z","usage":"context","status":"retrieved"}],"coverage":{"identity_confirmed":false,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":"开放主题需要用研究材料确定内容边界。"}}`)
|
||||
case StageDesignBrief:
|
||||
writeValidDesignBriefOutputs(t)
|
||||
case StageOutline:
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"prompt_contract":`+promptContractJSON(StageOutline)+`,"main_title":"电影介绍","style_instruction":{"aesthetic_direction":"Editorial cinematic deck","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"一部电影","summary":"用一个清晰观点介绍电影","role":"cover","key_message":"电影的核心吸引力","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"image_claim","story_function":"hook","primary_asset_role":"cinematic topic anchor","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
case StageSlideContent:
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# 一部电影\n\n电影的核心吸引力。")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"一部电影","body":"电影的核心吸引力","labels":["电影"]},"production_instruction":{"layout":"Use local hero image, no visible source note","asset_ids":["hero"]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"电影的核心吸引力","central_claim":"这页用一个清晰观点说明电影为什么值得被介绍。","audience_takeaway":"观众应先获得电影主题的核心吸引力,再进入后续分析。","supporting_points":[{"text":"用户请求是介绍一部电影,因此封面需要先建立主题识别。","source_refs":["user1"]},{"text":"视觉和标题共同承担开场钩子,而不是只放一个片名。","source_refs":["user1"]}],"source_bound_facts":[{"fact":"用户主题要求生成电影介绍。","source_ref":"user1","usage":"context"}],"source_refs":["user1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a cinematic hero image"}],"so_what":"这页应作为有观点的开场,而不是空泛标题页。"}]}`)
|
||||
case StageAssets:
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"cand-hero","query":"movie hero image","source_url":"https://example.com/movie-hero.png","source_class":"user_provided","format":"png","width":1200,"height":800,"has_alpha":false,"asset_role":"hero_photo","fit_role":"split_panel","local_path":"assets/images/movie-hero.png","score_bp":9000,"selected":true,"selection_reason":"user-provided cinematic hero image","format_exception_reason":"","rejection_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/movie-hero.png","usage":"Cinematic hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","kind":"image","source_url":"https://example.com/movie-hero.png","local_path":"assets/images/movie-hero.png","usage":"Cinematic hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/movie-hero.png","source_url":"https://example.com/movie-hero.png","width":1200,"height":800,"semantic_type":"hero","large_ok":true,"full_bleed_ok":false,"recommended_use":"cover split image","avoid_reason":"","format":"png","has_alpha":false,"asset_role":"hero_photo","fit_role":"split_panel","candidate_id":"cand-hero","selection_reason":"user-provided cinematic hero image","format_exception_reason":""}]}`)
|
||||
mustWriteTestPNGFile(t, "demo/assets/images/movie-hero.png")
|
||||
case StageSVGAuthor:
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" width="960" height="540" slide:role="slide" viewBox="0 0 960 540"><slide:note>Source: user1</slide:note><rect width="960" height="540" fill="#fff"/><image slide:role="image" href="../assets/images/movie-hero.png" x="520" y="80" width="320" height="240"/><foreignObject x="48" y="72" width="360" height="120" slide:role="shape" slide:shape-type="text"><p xmlns="http://www.w3.org/1999/xhtml" style="margin:0;font-family:Inter,Arial,sans-serif;font-size:28px;line-height:1.25;color:#111;">电影介绍</p><p xmlns="http://www.w3.org/1999/xhtml" style="margin:12px 0 0 0;font-family:Inter,Arial,sans-serif;font-size:20px;line-height:1.35;color:#333;">电影的核心吸引力</p></foreignObject></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"image_claim","thumbnail_job":"电影介绍","visual_center":"movie hero image and title","topic_fit_claim":"introduces the requested movie topic","information_density_plan":"one claim plus one visual anchor","page_difference_from_previous":"opening page","primary_asset":"assets/images/movie-hero.png","asset_role":"cinematic topic anchor","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"image-led cinematic introduction","data_visual_rationale":"","source_evidence":["user1 supports the topic"],"container_fit_plan":"text sits in image-safe open area with no default card","container_decision":"image-led open composition","text_carrier":"image_dark_zone","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"image_annotation","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["no visible process text"]}]}`)
|
||||
default:
|
||||
t.Fatalf("unexpected fake-agent stage %q", stage)
|
||||
}
|
||||
}
|
||||
1016
internal/svglide/anygen_semantics.go
Normal file
1016
internal/svglide/anygen_semantics.go
Normal file
File diff suppressed because it is too large
Load Diff
34
internal/svglide/asset_path.go
Normal file
34
internal/svglide/asset_path.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validatePreparedImageAssetPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("image asset path must not be empty")
|
||||
}
|
||||
if strings.Contains(path, `\`) {
|
||||
return "", fmt.Errorf("image asset path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(path, "%") {
|
||||
return "", fmt.Errorf("image asset path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(path, ":") || strings.Contains(path, "//") || isAbsoluteRunPath(path) {
|
||||
return "", fmt.Errorf("image asset path %q must be a local assets/images/<file> path", raw)
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 || parts[0] != "assets" || parts[1] != "images" {
|
||||
return "", fmt.Errorf("image asset path %q must match assets/images/<file>", raw)
|
||||
}
|
||||
fileName := parts[2]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("image asset path %q must include a file name", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("image asset file name %q must not contain dot segments", fileName)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
46
internal/svglide/asset_path_test.go
Normal file
46
internal/svglide/asset_path_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePreparedImageAssetPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", path: "assets/images/hero.png", want: "assets/images/hero.png"},
|
||||
{name: "trim", path: " assets/images/hero.png ", want: "assets/images/hero.png"},
|
||||
{name: "empty", path: "", wantErr: true},
|
||||
{name: "remote", path: "https://example.com/hero.png", wantErr: true},
|
||||
{name: "parent directory", path: "../hero.png", wantErr: true},
|
||||
{name: "absolute", path: "/Users/example/hero.png", wantErr: true},
|
||||
{name: "file url", path: "file:///tmp/hero.png", wantErr: true},
|
||||
{name: "protocol relative", path: "//example.com/hero.png", wantErr: true},
|
||||
{name: "data url", path: "data:image/png;base64,AAAA", wantErr: true},
|
||||
{name: "percent", path: "assets/images/hero%2epng", wantErr: true},
|
||||
{name: "nested", path: "assets/images/nested/hero.png", wantErr: true},
|
||||
{name: "wrong directory", path: "assets/other/hero.png", wantErr: true},
|
||||
{name: "leading dot", path: "assets/images/.hero.png", wantErr: true},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png", wantErr: true},
|
||||
{name: "backslash", path: `assets\images\hero.png`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validatePreparedImageAssetPath(tt.path)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("path = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
236
internal/svglide/assets.go
Normal file
236
internal/svglide/assets.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
assetsPlanPath = "assets/assets_plan.json"
|
||||
assetsManifestPath = "assets/assets_manifest.json"
|
||||
assetInventoryPath = "assets/asset_inventory.json"
|
||||
)
|
||||
|
||||
type deckAssetsFile struct {
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Assets []deckAsset `json:"assets"`
|
||||
NoImageReason string `json:"no_image_reason"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type deckAsset struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
VisualID string `json:"visual_id"`
|
||||
Type string `json:"type"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
LocalPath string `json:"local_path"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Status string `json:"status"`
|
||||
Usage string `json:"usage"`
|
||||
MissingReason string `json:"missing_reason"`
|
||||
}
|
||||
|
||||
type assetInventoryFile struct {
|
||||
Items []assetInventoryItem `json:"items"`
|
||||
}
|
||||
|
||||
type assetInventoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
SemanticType string `json:"semantic_type"`
|
||||
LargeOK bool `json:"large_ok"`
|
||||
FullBleedOK bool `json:"full_bleed_ok"`
|
||||
RecommendedUse string `json:"recommended_use"`
|
||||
AvoidReason string `json:"avoid_reason"`
|
||||
Format string `json:"format"`
|
||||
HasAlpha bool `json:"has_alpha"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FitRole string `json:"fit_role"`
|
||||
CandidateID string `json:"candidate_id"`
|
||||
SelectionReason string `json:"selection_reason"`
|
||||
FormatExceptionReason string `json:"format_exception_reason"`
|
||||
}
|
||||
|
||||
func readDeckAssetsArtifact(safeRoot string, path string) (deckAssetsFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return deckAssetsFile{}, err
|
||||
}
|
||||
var file deckAssetsFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return deckAssetsFile{}, fmt.Errorf("read assets artifact %q: %w", path, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func readAssetInventory(safeRoot string) (assetInventoryFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, assetInventoryPath)
|
||||
if err != nil {
|
||||
return assetInventoryFile{}, fmt.Errorf("read asset inventory %q: %w", assetInventoryPath, err)
|
||||
}
|
||||
var inventory assetInventoryFile
|
||||
if err := json.Unmarshal(raw, &inventory); err != nil {
|
||||
return assetInventoryFile{}, fmt.Errorf("%s: invalid JSON: %w", assetInventoryPath, err)
|
||||
}
|
||||
return inventory, nil
|
||||
}
|
||||
|
||||
func readAssetsManifest(safeRoot string) (deckAssetsFile, error) {
|
||||
file, err := readDeckAssetsArtifact(safeRoot, assetsManifestPath)
|
||||
if err != nil {
|
||||
return deckAssetsFile{}, fmt.Errorf("read assets manifest %q: %w", assetsManifestPath, err)
|
||||
}
|
||||
file, changed, err := normalizeAssetsManifestFromPlan(safeRoot, file)
|
||||
if err != nil {
|
||||
return deckAssetsFile{}, err
|
||||
}
|
||||
if changed {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, assetsManifestPath)
|
||||
if err != nil {
|
||||
return deckAssetsFile{}, err
|
||||
}
|
||||
if err := writeJSON(target, file); err != nil {
|
||||
return deckAssetsFile{}, err
|
||||
}
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func normalizeAssetsManifestFromPlan(safeRoot string, manifest deckAssetsFile) (deckAssetsFile, bool, error) {
|
||||
plan, err := readDeckAssetsArtifact(safeRoot, assetsPlanPath)
|
||||
if err != nil {
|
||||
return manifest, false, nil
|
||||
}
|
||||
changed := false
|
||||
if len(manifest.PromptContract) == 0 && len(plan.PromptContract) > 0 {
|
||||
manifest.PromptContract = plan.PromptContract
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(manifest.Mode) == "" && strings.TrimSpace(plan.Mode) != "" {
|
||||
manifest.Mode = plan.Mode
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(manifest.NoImageReason) == "" && strings.TrimSpace(plan.NoImageReason) != "" {
|
||||
manifest.NoImageReason = plan.NoImageReason
|
||||
changed = true
|
||||
}
|
||||
existing := make(map[string]bool, len(manifest.Assets))
|
||||
for _, asset := range manifest.Assets {
|
||||
key := assetManifestConsistencyKey(asset)
|
||||
if key != "" {
|
||||
existing[key] = true
|
||||
}
|
||||
}
|
||||
for _, asset := range plan.Assets {
|
||||
key := assetManifestConsistencyKey(asset)
|
||||
if key == "" || existing[key] {
|
||||
continue
|
||||
}
|
||||
status := assetStatus(asset)
|
||||
if status != "ready" && status != "deferred" && status != "needs_generation" {
|
||||
continue
|
||||
}
|
||||
manifest.Assets = append(manifest.Assets, asset)
|
||||
existing[key] = true
|
||||
changed = true
|
||||
}
|
||||
return manifest, changed, nil
|
||||
}
|
||||
|
||||
func assetManifestConsistencyKey(asset deckAsset) string {
|
||||
slideID := assetSlideID(asset)
|
||||
id := assetID(asset)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(asset.VisualID)
|
||||
}
|
||||
if slideID == "" || id == "" {
|
||||
return ""
|
||||
}
|
||||
return slideID + "/" + id
|
||||
}
|
||||
|
||||
func assetType(asset deckAsset) string {
|
||||
if value := strings.TrimSpace(asset.Kind); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(asset.Type)
|
||||
}
|
||||
|
||||
func assetPath(asset deckAsset) string {
|
||||
if value := strings.TrimSpace(asset.LocalPath); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(asset.Path)
|
||||
}
|
||||
|
||||
func assetStatus(asset deckAsset) string {
|
||||
return strings.TrimSpace(asset.Status)
|
||||
}
|
||||
|
||||
func assetSlideID(asset deckAsset) string {
|
||||
return strings.TrimSpace(asset.SlideID)
|
||||
}
|
||||
|
||||
func assetID(asset deckAsset) string {
|
||||
return strings.TrimSpace(asset.ID)
|
||||
}
|
||||
|
||||
func assetExt(asset deckAsset) string {
|
||||
raw := assetPath(asset)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(asset.SourceURL)
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
|
||||
raw = parsed.Path
|
||||
}
|
||||
if i := strings.IndexAny(raw, "?#"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(raw))
|
||||
}
|
||||
|
||||
func isRasterImageAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" || assetType(asset) != "image" {
|
||||
return false
|
||||
}
|
||||
switch assetExt(asset) {
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".avif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isGeneratedSVGAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" {
|
||||
return false
|
||||
}
|
||||
if assetType(asset) == "generated_svg" {
|
||||
return true
|
||||
}
|
||||
return assetExt(asset) == ".svg" && assetType(asset) != "chart"
|
||||
}
|
||||
|
||||
func isChartSVGAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" {
|
||||
return false
|
||||
}
|
||||
return assetType(asset) == "chart" && assetExt(asset) == ".svg"
|
||||
}
|
||||
|
||||
func isPreviewWrapperImageAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" || assetType(asset) != "image" {
|
||||
return false
|
||||
}
|
||||
normalized := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(assetPath(asset))), "./")
|
||||
return strings.HasPrefix(normalized, "slides/") && assetExt(asset) == ".svg"
|
||||
}
|
||||
1035
internal/svglide/author.go
Normal file
1035
internal/svglide/author.go
Normal file
File diff suppressed because it is too large
Load Diff
324
internal/svglide/author_diagram_renderer.go
Normal file
324
internal/svglide/author_diagram_renderer.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
authorVisualFormGeneric = "generic"
|
||||
authorVisualFormFourQuadrant = "four_quadrant"
|
||||
authorVisualFormSpectrum = "spectrum"
|
||||
authorVisualFormMapRoute = "map_route"
|
||||
authorVisualFormProcessFlow = "process_flow"
|
||||
authorVisualFormParameterMatrix = "parameter_matrix"
|
||||
authorVisualFormSensoryWheel = "sensory_wheel"
|
||||
authorVisualFormObjectCallout = "object_callout"
|
||||
)
|
||||
|
||||
func renderAuthorInlineDiagram(b *strings.Builder, visual authorSlideVisual, content authorSlideContent, x, y, width, height int, theme authorTheme) {
|
||||
form := authorVisualForm(visual, content)
|
||||
labels := authorDiagramLabels(content, visual, 8)
|
||||
if len(labels) == 0 {
|
||||
labels = []string{firstNonEmpty(visual.Instruction, visual.ID, "visual")}
|
||||
}
|
||||
switch form {
|
||||
case authorVisualFormFourQuadrant:
|
||||
renderAuthorFourQuadrantDiagram(b, labels, x, y, width, height, theme)
|
||||
case authorVisualFormSpectrum:
|
||||
renderAuthorSpectrumDiagram(b, labels, x, y, width, height, theme)
|
||||
case authorVisualFormMapRoute:
|
||||
renderAuthorMapRouteDiagram(b, labels, x, y, width, height, theme)
|
||||
case authorVisualFormProcessFlow:
|
||||
renderAuthorProcessFlowDiagram(b, labels, x, y, width, height, theme)
|
||||
case authorVisualFormParameterMatrix:
|
||||
renderAuthorParameterMatrixDiagram(b, labels, x, y, width, height, theme)
|
||||
case authorVisualFormSensoryWheel:
|
||||
renderAuthorSensoryWheelDiagram(b, labels, x, y, width, height, theme)
|
||||
case authorVisualFormObjectCallout:
|
||||
renderAuthorObjectCalloutDiagram(b, labels, x, y, width, height, theme)
|
||||
default:
|
||||
renderAuthorGenericDiagram(b, labels, x, y, width, height, theme)
|
||||
}
|
||||
}
|
||||
|
||||
func authorVisualForm(visual authorSlideVisual, content authorSlideContent) string {
|
||||
if form := normalizeAuthorVisualForm(visual.VisualForm); form != "" {
|
||||
return form
|
||||
}
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
visual.Type,
|
||||
visual.ID,
|
||||
visual.Instruction,
|
||||
content.Content,
|
||||
content.Notes,
|
||||
}, " "))
|
||||
switch {
|
||||
case strings.TrimSpace(visual.Type) == "map":
|
||||
return authorVisualFormMapRoute
|
||||
case containsAny(haystack, []string{"four_quadrant", "four quadrant", "quadrant", "2x2", "四象限", "象限"}):
|
||||
return authorVisualFormFourQuadrant
|
||||
case containsAny(haystack, []string{"spectrum", "gradient", "liquor color", "color spectrum", "six tea", "六大茶类", "光谱", "茶汤", "色谱"}):
|
||||
return authorVisualFormSpectrum
|
||||
case containsAny(haystack, []string{"map", "route", "region", "origin", "geography", "province", "产区", "地图", "地域", "路线"}):
|
||||
return authorVisualFormMapRoute
|
||||
case containsAny(haystack, []string{"process", "flow", "timeline", "craft", "fermentation", "firing", "工艺", "流程", "制作", "发酵", "杀青"}):
|
||||
return authorVisualFormProcessFlow
|
||||
case containsAny(haystack, []string{"parameter", "matrix", "temperature", "steep", "water", "ratio", "参数", "矩阵", "水温", "冲泡", "投茶"}):
|
||||
return authorVisualFormParameterMatrix
|
||||
case containsAny(haystack, []string{"sensory", "wheel", "taste", "flavor", "aroma", "五感", "风味", "香气", "口感", "品鉴"}):
|
||||
return authorVisualFormSensoryWheel
|
||||
case containsAny(haystack, []string{"callout", "object", "teaware", "vessel", "utensil", "器物", "茶具", "盖碗", "紫砂", "标注"}):
|
||||
return authorVisualFormObjectCallout
|
||||
default:
|
||||
return authorVisualFormGeneric
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAuthorVisualForm(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, "-", "_")
|
||||
value = strings.ReplaceAll(value, " ", "_")
|
||||
switch value {
|
||||
case authorVisualFormFourQuadrant, "quadrant", "two_by_two", "2x2":
|
||||
return authorVisualFormFourQuadrant
|
||||
case authorVisualFormSpectrum, "color_spectrum", "liquor_spectrum":
|
||||
return authorVisualFormSpectrum
|
||||
case authorVisualFormMapRoute, "region_map", "map", "route_map":
|
||||
return authorVisualFormMapRoute
|
||||
case authorVisualFormProcessFlow, "flow", "timeline", "craft_process":
|
||||
return authorVisualFormProcessFlow
|
||||
case authorVisualFormParameterMatrix, "matrix", "parameter_bridge", "parameters":
|
||||
return authorVisualFormParameterMatrix
|
||||
case authorVisualFormSensoryWheel, "wheel", "flavor_wheel", "taste_wheel":
|
||||
return authorVisualFormSensoryWheel
|
||||
case authorVisualFormObjectCallout, "callout", "object_annotation", "teaware_callout":
|
||||
return authorVisualFormObjectCallout
|
||||
case authorVisualFormGeneric:
|
||||
return authorVisualFormGeneric
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderAuthorFourQuadrantDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormFourQuadrant)
|
||||
cx, cy := x+width/2, y+height/2
|
||||
fmt.Fprintf(b, ` <rect x="%d" y="%d" width="%d" height="%d" fill="none" stroke="%s" stroke-width="1.2" opacity="0.56"/>`+"\n", x, y, width, height, escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, ` <path d="M%d %d V%d M%d %d H%d" stroke="%s" stroke-width="1.1" opacity="0.50"/>`+"\n", cx, y, y+height, x, cy, x+width, escapeAttr(theme.Muted))
|
||||
points := [][2]int{{x + width/4, y + height/3}, {x + width*3/4, y + height/3}, {x + width/4, y + height*3/4}, {x + width*3/4, y + height*3/4}}
|
||||
for i, pt := range points {
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.88"/>`+"\n", pt[0], pt[1]-9, compactDiagramRadius(height, 8), escapeAttr(authorDiagramColor(theme, i)))
|
||||
writeAuthorDiagramLabel(b, pt[0], pt[1]+18, 120, compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorSpectrumDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormSpectrum)
|
||||
count := clampInt(len(labels), 4, 6)
|
||||
gap := 6
|
||||
barW := (width - gap*(count-1)) / count
|
||||
if barW < 18 {
|
||||
barW = 18
|
||||
}
|
||||
barY := y + height/2 - compactDiagramRadius(height, 11)
|
||||
for i := 0; i < count; i++ {
|
||||
bh := compactDiagramRadius(height, 20) + i%3*8
|
||||
xx := x + i*(barW+gap)
|
||||
yy := barY - bh/2 + (i%2)*8
|
||||
fmt.Fprintf(b, ` <rect x="%d" y="%d" width="%d" height="%d" fill="%s" opacity="0.86"/>`+"\n", xx, yy, barW, bh, escapeAttr(authorSpectrumColor(theme, i)))
|
||||
writeAuthorDiagramLabel(b, xx+barW/2, y+height-8, maxInt(72, barW+20), compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="1" opacity="0.42"/>`+"\n", x, y+height/2, x+width, escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorMapRouteDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormMapRoute)
|
||||
fmt.Fprintf(b, ` <path d="M%d %d C%d %d %d %d %d %d C%d %d %d %d %d %d" fill="none" stroke="%s" stroke-width="1.2" opacity="0.38"/>`+"\n",
|
||||
x+width/5, y+height/6, x+width/2, y+4, x+width*4/5, y+height/5, x+width*3/4, y+height/2, x+width*2/3, y+height*4/5, x+width/3, y+height-4, x+width/5, y+height*2/3, escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, ` <path d="M%d %d C%d %d %d %d %d %d" fill="none" stroke="%s" stroke-width="2.6" opacity="0.78"/>`+"\n",
|
||||
x+20, y+height-26, x+width/3, y+height/2, x+width*2/3, y+height/3, x+width-22, y+24, escapeAttr(theme.Accent))
|
||||
points := [][2]int{{x + 24, y + height - 28}, {x + width/2, y + height/2 - 3}, {x + width - 22, y + 24}}
|
||||
for i, pt := range points {
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" stroke="%s" stroke-width="2"/>`+"\n", pt[0], pt[1], compactDiagramRadius(height, 8), escapeAttr(authorDiagramColor(theme, i)), escapeAttr(theme.Background))
|
||||
writeAuthorDiagramLabel(b, pt[0], pt[1]+24, 120, compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorProcessFlowDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormProcessFlow)
|
||||
count := clampInt(len(labels), 3, 5)
|
||||
step := width / count
|
||||
midY := y + height/2
|
||||
for i := 0; i < count; i++ {
|
||||
xx := x + i*step
|
||||
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="2" opacity="0.55"/>`+"\n", xx+compactDiagramRadius(height, 9), midY, xx+step-compactDiagramRadius(height, 9), escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.90"/>`+"\n", xx+step/2, midY, compactDiagramRadius(height, 12), escapeAttr(authorDiagramColor(theme, i)))
|
||||
writeAuthorDiagramNumber(b, xx+step/2, midY+4, 38, compactDiagramFontSize(height), theme.Background, i+1)
|
||||
writeAuthorDiagramLabel(b, xx+step/2, midY+30, maxInt(72, step-10), compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorParameterMatrixDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormParameterMatrix)
|
||||
rows, cols := 3, 3
|
||||
cellW, cellH := width/cols, height/rows
|
||||
for r := 0; r <= rows; r++ {
|
||||
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="1" opacity="0.45"/>`+"\n", x, y+r*cellH, x+width, escapeAttr(theme.Muted))
|
||||
}
|
||||
for c := 0; c <= cols; c++ {
|
||||
fmt.Fprintf(b, ` <path d="M%d %d V%d" stroke="%s" stroke-width="1" opacity="0.45"/>`+"\n", x+c*cellW, y, y+height, escapeAttr(theme.Muted))
|
||||
}
|
||||
for i := 0; i < rows*cols && i < len(labels); i++ {
|
||||
c, r := i%cols, i/cols
|
||||
fmt.Fprintf(b, ` <rect x="%d" y="%d" width="%d" height="%d" fill="%s" opacity="0.14"/>`+"\n", x+c*cellW+4, y+r*cellH+4, cellW-8, cellH-8, escapeAttr(authorDiagramColor(theme, i)))
|
||||
writeAuthorDiagramLabel(b, x+c*cellW+cellW/2, y+r*cellH+cellH/2+4, maxInt(72, cellW-14), compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorSensoryWheelDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormSensoryWheel)
|
||||
cx, cy := x+width/2, y+height/2
|
||||
radius := minInt(width, height) / 3
|
||||
if radius < 28 {
|
||||
radius = minInt(width, height) / 2
|
||||
}
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="none" stroke="%s" stroke-width="1.2" opacity="0.54"/>`+"\n", cx, cy, radius, escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.15"/>`+"\n", cx, cy, radius/2, escapeAttr(theme.Accent))
|
||||
points := [][2]int{{cx, cy - radius}, {cx + radius, cy}, {cx, cy + radius}, {cx - radius, cy}, {cx + radius*7/10, cy - radius*7/10}, {cx - radius*7/10, cy + radius*7/10}}
|
||||
for i, pt := range points {
|
||||
fmt.Fprintf(b, ` <path d="M%d %d L%d %d" stroke="%s" stroke-width="1" opacity="0.45"/>`+"\n", cx, cy, pt[0], pt[1], escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.88"/>`+"\n", pt[0], pt[1], compactDiagramRadius(height, 7), escapeAttr(authorDiagramColor(theme, i)))
|
||||
writeAuthorDiagramLabel(b, pt[0], pt[1]+18, 112, compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorObjectCalloutDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormObjectCallout)
|
||||
cx, cy := x+width/2, y+height/2
|
||||
fmt.Fprintf(b, ` <ellipse cx="%d" cy="%d" rx="%d" ry="%d" fill="%s" opacity="0.10" stroke="%s" stroke-width="1.2"/>`+"\n", cx, cy, width/5, height/4, escapeAttr(theme.Accent), escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(b, ` <path d="M%d %d C%d %d %d %d %d %d" fill="none" stroke="%s" stroke-width="2.4" opacity="0.72"/>`+"\n", cx-width/4, cy, cx-width/8, cy-height/4, cx+width/8, cy-height/4, cx+width/4, cy, escapeAttr(theme.Accent))
|
||||
callouts := [][4]int{
|
||||
{cx - width/5, cy - height/7, x + 8, y + 16},
|
||||
{cx + width/5, cy, x + width - 8, y + height/2},
|
||||
{cx, cy + height/4, x + width/2, y + height - 10},
|
||||
}
|
||||
for i, c := range callouts {
|
||||
fmt.Fprintf(b, ` <path d="M%d %d L%d %d" stroke="%s" stroke-width="1" opacity="0.58"/>`+"\n", c[0], c[1], c[2], c[3], escapeAttr(theme.Muted))
|
||||
anchor := "middle"
|
||||
if i == 0 {
|
||||
anchor = "start"
|
||||
} else if i == 1 {
|
||||
anchor = "end"
|
||||
}
|
||||
writeAuthorDiagramLabel(b, c[2], c[3], 118, compactDiagramFontSize(height), theme.Ink, anchor, authorLabelAt(labels, i))
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func renderAuthorGenericDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
|
||||
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormGeneric)
|
||||
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="1.2" opacity="0.62"/>`+"\n", x, y+height/2, x+width, escapeAttr(theme.Muted))
|
||||
step := width
|
||||
if len(labels) > 1 {
|
||||
step = width / (len(labels) - 1)
|
||||
}
|
||||
for i, label := range labels {
|
||||
cx := x
|
||||
if len(labels) > 1 {
|
||||
cx = x + i*step
|
||||
}
|
||||
cy := y + height/2
|
||||
if i%2 == 1 {
|
||||
cy -= compactDiagramRadius(height, 22)
|
||||
} else {
|
||||
cy += compactDiagramRadius(height, 22)
|
||||
}
|
||||
r := compactDiagramRadius(height, 14+(i%3)*3)
|
||||
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.88"/>`+"\n", cx, cy, r, escapeAttr(authorDiagramColor(theme, i)))
|
||||
fmt.Fprintf(b, ` <path d="M%d %d L%d %d" stroke="%s" stroke-width="1" opacity="0.55"/>`+"\n", cx, cy, cx, y+height/2, escapeAttr(theme.Muted))
|
||||
writeAuthorDiagramLabel(b, cx, cy+compactDiagramRadius(height, 24), 128, compactDiagramFontSize(height), theme.Ink, "middle", label)
|
||||
}
|
||||
fmt.Fprintf(b, " </g>\n")
|
||||
}
|
||||
|
||||
func writeAuthorDiagramNumber(b *strings.Builder, x, baselineY, maxWidth int, fontSize int, color string, value int) {
|
||||
writeAuthorDiagramLabelWithFamily(b, x, baselineY, maxWidth, fontSize, color, "middle", fmt.Sprintf("%02d", value), authorFontNumber, 700)
|
||||
}
|
||||
|
||||
func writeAuthorDiagramLabel(b *strings.Builder, x, baselineY, maxWidth int, fontSize int, color string, anchor string, text string) {
|
||||
writeAuthorDiagramLabelWithFamily(b, x, baselineY, maxWidth, fontSize, color, anchor, text, authorFontLabel, 600)
|
||||
}
|
||||
|
||||
func writeAuthorDiagramLabelWithFamily(b *strings.Builder, x, baselineY, maxWidth int, fontSize int, color string, anchor string, text string, family string, weight int) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
width := clampInt(len([]rune(text))*fontSize/2+24, 44, maxInt(44, maxWidth))
|
||||
height := maxInt(fontSize*2, 24)
|
||||
left := x - width/2
|
||||
textAlign := "center"
|
||||
switch anchor {
|
||||
case "start":
|
||||
left = x
|
||||
textAlign = "left"
|
||||
case "end":
|
||||
left = x - width
|
||||
textAlign = "right"
|
||||
}
|
||||
top := baselineY - fontSize
|
||||
fmt.Fprintf(b, ` <foreignObject x="%d" y="%d" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", left, top, width, height)
|
||||
fmt.Fprintf(b, ` <p xmlns="http://www.w3.org/1999/xhtml" style="margin:0;font-family:%s;color:%s;font-size:%dpx;line-height:1.12;font-weight:%d;text-align:%s;">%s</p>`+"\n", family, escapeAttr(color), fontSize, weight, textAlign, escapeText(text))
|
||||
fmt.Fprintf(b, " </foreignObject>\n")
|
||||
}
|
||||
|
||||
func authorSpectrumColor(theme authorTheme, index int) string {
|
||||
switch index % 6 {
|
||||
case 0:
|
||||
return "#D9D7A3"
|
||||
case 1:
|
||||
return "#A7B56A"
|
||||
case 2:
|
||||
return "#C89A4B"
|
||||
case 3:
|
||||
return theme.Accent
|
||||
case 4:
|
||||
return "#A63E2B"
|
||||
default:
|
||||
return "#6C4A2E"
|
||||
}
|
||||
}
|
||||
|
||||
func authorLabelAt(labels []string, index int) string {
|
||||
if len(labels) == 0 {
|
||||
return "item"
|
||||
}
|
||||
if index >= 0 && index < len(labels) {
|
||||
return labels[index]
|
||||
}
|
||||
return labels[index%len(labels)]
|
||||
}
|
||||
|
||||
func compactDiagramFontSize(height int) int {
|
||||
if height < 120 {
|
||||
return 10
|
||||
}
|
||||
if height < 180 {
|
||||
return 11
|
||||
}
|
||||
return 13
|
||||
}
|
||||
|
||||
func compactDiagramRadius(height int, value int) int {
|
||||
if height < 120 {
|
||||
return maxInt(5, value*2/3)
|
||||
}
|
||||
return value
|
||||
}
|
||||
1025
internal/svglide/author_test.go
Normal file
1025
internal/svglide/author_test.go
Normal file
File diff suppressed because it is too large
Load Diff
141
internal/svglide/chart_brief.go
Normal file
141
internal/svglide/chart_brief.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartBriefsPath = "assets/charts/chart_briefs.json"
|
||||
|
||||
type chartBriefFile struct {
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Charts []chartBriefEntry `json:"charts"`
|
||||
}
|
||||
|
||||
type chartBriefEntry struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
Takeaway string `json:"takeaway"`
|
||||
Renderer string `json:"renderer"`
|
||||
SourceIDs []string `json:"data_source_ids"`
|
||||
Unit string `json:"unit"`
|
||||
MinWidth int `json:"min_width,omitempty"`
|
||||
MinHeight int `json:"min_height,omitempty"`
|
||||
FallbackPolicy string `json:"fallback_policy,omitempty"`
|
||||
}
|
||||
|
||||
func readChartBriefs(safeRoot string) (chartBriefFile, bool, error) {
|
||||
exists, err := runRegularFileExists(safeRoot, chartBriefsPath)
|
||||
if err != nil {
|
||||
return chartBriefFile{}, false, err
|
||||
}
|
||||
if !exists {
|
||||
return chartBriefFile{}, false, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, chartBriefsPath)
|
||||
if err != nil {
|
||||
return chartBriefFile{}, true, err
|
||||
}
|
||||
var file chartBriefFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return chartBriefFile{}, true, fmt.Errorf("%s: invalid JSON: %w", chartBriefsPath, err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func ensureEmptyChartBriefsForNoChartDeck(safeRoot string) error {
|
||||
if exists, err := runRegularFileExists(safeRoot, chartBriefsPath); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return nil
|
||||
}
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slideContentHasChartVisual(content) {
|
||||
return nil
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contract, err := RequiredPromptContractForStage(StageAssets, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawContract, err := json.Marshal(contract)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartBriefsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, chartBriefFile{
|
||||
PromptContract: rawContract,
|
||||
Charts: []chartBriefEntry{},
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateChartBriefsGate(safeRoot string) error {
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
briefs, present, err := readChartBriefs(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasChartVisual := slideContentHasChartVisual(content)
|
||||
if !present {
|
||||
if hasChartVisual {
|
||||
return fmt.Errorf("chart_briefs_gate: chart visual exists but %s is missing", chartBriefsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasChartVisual && len(briefs.Charts) == 0 {
|
||||
return fmt.Errorf("chart_briefs_gate: chart visual exists but %s has no chart briefs", chartBriefsPath)
|
||||
}
|
||||
for _, entry := range briefs.Charts {
|
||||
id := strings.TrimSpace(entry.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief id must not be empty")
|
||||
}
|
||||
if renderer := strings.TrimSpace(entry.Renderer); renderer != requiredChartRendererVegaLite {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q renderer = %q, want %q", id, renderer, requiredChartRendererVegaLite)
|
||||
}
|
||||
if strings.TrimSpace(entry.SlideID) == "" {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q slide_id must not be empty", id)
|
||||
}
|
||||
if strings.TrimSpace(entry.Takeaway) == "" {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q takeaway must not be empty", id)
|
||||
}
|
||||
if len(entry.SourceIDs) == 0 {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q data_source_ids must not be empty", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slideContentHasChartVisual(content qualityContentFile) bool {
|
||||
for _, slide := range content.Slides {
|
||||
for _, visual := range slide.Visuals {
|
||||
if strings.TrimSpace(visual.Type) == "chart" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartSpecPathForBrief(id string) string {
|
||||
return filepath.ToSlash(filepath.Join("assets", "charts", "specs", strings.TrimSpace(id)+".vl.json"))
|
||||
}
|
||||
|
||||
func chartSVGPathForBrief(id string) string {
|
||||
return filepath.ToSlash(filepath.Join("assets", "charts", strings.TrimSpace(id)+".svg"))
|
||||
}
|
||||
50
internal/svglide/chart_brief_test.go
Normal file
50
internal/svglide/chart_brief_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureEmptyChartBriefsForNoChartDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
|
||||
|
||||
if err := ensureEmptyChartBriefsForNoChartDeck("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", chartBriefsPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"charts": []`) {
|
||||
t.Fatalf("chart_briefs = %s, want empty charts array", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureEmptyChartBriefsDoesNotHideMissingChartBriefForChartDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Revenue","source_refs":["web1"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
|
||||
if err := ensureEmptyChartBriefsForNoChartDeck("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", chartBriefsPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("chart_briefs should not be auto-created for chart deck, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBriefRejectsNativeSVGRenderer(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Revenue","source_refs":["web1"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[{"id":"revenue","slide_id":"s1","purpose":"comparison","takeaway":"Revenue increased","renderer":"native-svg","data_source_ids":["web1"],"unit":"$"}]}`)
|
||||
|
||||
err := ValidateChartBriefsGate("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected native-svg chart brief renderer to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "renderer") || !strings.Contains(err.Error(), "vega-lite") {
|
||||
t.Fatalf("error = %v, want renderer vega-lite rejection", err)
|
||||
}
|
||||
}
|
||||
74
internal/svglide/chart_manifest.go
Normal file
74
internal/svglide/chart_manifest.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartManifestPath = "assets/charts/chart_manifest.json"
|
||||
|
||||
type chartManifestFile struct {
|
||||
Renderer string `json:"renderer"`
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Charts []chartManifestEntry `json:"charts"`
|
||||
}
|
||||
|
||||
type chartManifestEntry struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Renderer string `json:"renderer"`
|
||||
BriefID string `json:"brief_id,omitempty"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SourceID string `json:"source_id"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Takeaway string `json:"takeaway,omitempty"`
|
||||
RenderReceipt string `json:"render_receipt,omitempty"`
|
||||
}
|
||||
|
||||
func readChartManifest(safeRoot string) (chartManifestFile, bool, error) {
|
||||
exists, err := runRegularFileExists(safeRoot, chartManifestPath)
|
||||
if err != nil {
|
||||
return chartManifestFile{}, false, err
|
||||
}
|
||||
if !exists {
|
||||
return chartManifestFile{}, false, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, chartManifestPath)
|
||||
if err != nil {
|
||||
return chartManifestFile{}, false, err
|
||||
}
|
||||
var file chartManifestFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return chartManifestFile{}, true, fmt.Errorf("%s: invalid JSON: %w", chartManifestPath, err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func chartEntryRenderer(file chartManifestFile, entry chartManifestEntry) string {
|
||||
if value := strings.TrimSpace(entry.Renderer); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(file.Renderer)
|
||||
}
|
||||
|
||||
func countVegaLiteSpecEntries(file chartManifestFile) int {
|
||||
count := 0
|
||||
for _, entry := range file.Charts {
|
||||
if chartEntryRenderer(file, entry) == requiredChartRendererVegaLite && strings.TrimSpace(entry.SpecPath) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countChartSVGEntries(file chartManifestFile) int {
|
||||
count := 0
|
||||
for _, entry := range file.Charts {
|
||||
if strings.TrimSpace(entry.SVGPath) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
343
internal/svglide/chart_quality.go
Normal file
343
internal/svglide/chart_quality.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartQualityReportPath = "receipts/chart_quality.json"
|
||||
|
||||
type ChartQualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Metrics ChartQualityMetrics `json:"metrics"`
|
||||
Issues []ChartQualityIssue `json:"issues"`
|
||||
Charts []ChartQualityChart `json:"charts"`
|
||||
}
|
||||
|
||||
type ChartQualityMetrics struct {
|
||||
Charts int `json:"charts"`
|
||||
VegaLiteCharts int `json:"vega_lite_charts"`
|
||||
MissingAxisCount int `json:"missing_axis_count"`
|
||||
MissingUnitCount int `json:"missing_unit_count"`
|
||||
MissingSourceCount int `json:"missing_source_count"`
|
||||
MissingDirectLabelCount int `json:"missing_direct_label_count"`
|
||||
DecorativeChartCount int `json:"decorative_chart_count"`
|
||||
}
|
||||
|
||||
type ChartQualityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type ChartQualityChart struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Renderer string `json:"renderer"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SpecPath string `json:"spec_path,omitempty"`
|
||||
}
|
||||
|
||||
func CheckChartQuality(root string) (ChartQualityReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return ChartQualityReport{}, err
|
||||
}
|
||||
manifest, present, err := readChartManifest(safeRoot)
|
||||
if err != nil {
|
||||
return ChartQualityReport{}, err
|
||||
}
|
||||
report := ChartQualityReport{
|
||||
Status: "passed",
|
||||
Issues: []ChartQualityIssue{},
|
||||
Charts: []ChartQualityChart{},
|
||||
}
|
||||
if !present {
|
||||
if err := writeChartQualityReport(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
sourceIDs, sourceErr := readKnownSourceIDs(safeRoot)
|
||||
if sourceErr != nil {
|
||||
addChartQualityIssue(&report, "research/sources.json", "svglide.chart_quality.sources_unreadable", sourceErr.Error())
|
||||
sourceIDs = map[string]bool{}
|
||||
}
|
||||
renderReport, renderErr := readChartRenderReport(safeRoot)
|
||||
if renderErr != nil {
|
||||
addChartQualityIssue(&report, chartRenderReceiptPath, "svglide.chart_quality.missing_render_receipt", renderErr.Error())
|
||||
}
|
||||
renderByID := chartRenderEntriesByID(renderReport)
|
||||
for _, chart := range manifest.Charts {
|
||||
renderer := normalizedRequiredChartRenderer(chartEntryRenderer(manifest, chart))
|
||||
svgPath := strings.TrimSpace(chart.SVGPath)
|
||||
item := ChartQualityChart{
|
||||
ID: strings.TrimSpace(chart.ID),
|
||||
SlideID: strings.TrimSpace(chart.SlideID),
|
||||
Renderer: renderer,
|
||||
SVGPath: svgPath,
|
||||
SpecPath: strings.TrimSpace(chart.SpecPath),
|
||||
}
|
||||
report.Charts = append(report.Charts, item)
|
||||
report.Metrics.Charts++
|
||||
if renderer == requiredChartRendererVegaLite {
|
||||
report.Metrics.VegaLiteCharts++
|
||||
validateVegaLiteChartQuality(&report, safeRoot, chart, sourceIDs, renderByID, renderErr == nil)
|
||||
}
|
||||
if svgPath == "" {
|
||||
addChartQualityIssue(&report, chartManifestPath, "svglide.chart_quality.missing_svg", fmt.Sprintf("chart %q has no svg_path", chart.ID))
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, svgPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(&report, svgPath, "svglide.chart_quality.missing_svg", fmt.Sprintf("chart %q SVG cannot be read: %v", chart.ID, err))
|
||||
continue
|
||||
}
|
||||
checkChartSVGQuality(&report, svgPath, string(raw))
|
||||
}
|
||||
if len(report.Issues) > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
if err := writeChartQualityReport(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func validateVegaLiteChartQuality(report *ChartQualityReport, safeRoot string, chart chartManifestEntry, sourceIDs map[string]bool, renderByID map[string]ChartRenderEntry, hasRenderReceipt bool) {
|
||||
id := strings.TrimSpace(chart.ID)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(chart.SVGPath)
|
||||
}
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
code string
|
||||
name string
|
||||
}{
|
||||
{strings.TrimSpace(chart.BriefID), "svglide.chart_quality.missing_brief_id", "brief_id"},
|
||||
{strings.TrimSpace(chart.SpecPath), "svglide.chart_quality.missing_spec_path", "spec_path"},
|
||||
{strings.TrimSpace(chart.SVGPath), "svglide.chart_quality.missing_svg", "svg_path"},
|
||||
{strings.TrimSpace(chart.SourceID), "svglide.chart_quality.missing_source", "source_id"},
|
||||
{strings.TrimSpace(chart.Unit), "svglide.chart_quality.missing_unit", "unit"},
|
||||
{strings.TrimSpace(chart.Takeaway), "svglide.chart_quality.missing_takeaway", "takeaway"},
|
||||
{strings.TrimSpace(chart.RenderReceipt), "svglide.chart_quality.missing_render_receipt", "render_receipt"},
|
||||
} {
|
||||
if required.value == "" {
|
||||
addChartQualityIssue(report, chartManifestPath, required.code, fmt.Sprintf("chart %q is missing %s", id, required.name))
|
||||
}
|
||||
}
|
||||
if chart.RenderReceipt != "" && chart.RenderReceipt != chartRenderReceiptPath {
|
||||
addChartQualityIssue(report, chartManifestPath, "svglide.chart_quality.missing_render_receipt", fmt.Sprintf("chart %q render_receipt = %q, want %q", id, chart.RenderReceipt, chartRenderReceiptPath))
|
||||
}
|
||||
if sourceID := strings.TrimSpace(chart.SourceID); sourceID != "" && !sourceIDs[sourceID] {
|
||||
addChartQualityIssue(report, chartManifestPath, "svglide.chart_quality.unknown_source_id", fmt.Sprintf("chart %q references unknown source_id %q", id, sourceID))
|
||||
}
|
||||
validateVegaLiteSpec(report, safeRoot, chart)
|
||||
if !hasRenderReceipt {
|
||||
return
|
||||
}
|
||||
renderEntry, ok := renderByID[id]
|
||||
if !ok {
|
||||
addChartQualityIssue(report, chartRenderReceiptPath, "svglide.chart_quality.render_receipt_missing_chart", fmt.Sprintf("render receipt has no entry for chart %q", id))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(chart.SpecPath) != "" {
|
||||
raw, err := readRunRegularArtifact(safeRoot, chart.SpecPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(report, chart.SpecPath, "svglide.chart_quality.missing_spec_path", err.Error())
|
||||
} else if got := sha256Hex(raw); got != renderEntry.SpecSHA256 {
|
||||
addChartQualityIssue(report, chart.SpecPath, "svglide.chart_quality.spec_hash_mismatch", fmt.Sprintf("chart %q spec hash %s, want %s", id, got, renderEntry.SpecSHA256))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(chart.SVGPath) != "" {
|
||||
raw, err := readRunRegularArtifact(safeRoot, chart.SVGPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(report, chart.SVGPath, "svglide.chart_quality.missing_svg", err.Error())
|
||||
} else if got := sha256Hex(raw); got != renderEntry.SVGSHA256 {
|
||||
addChartQualityIssue(report, chart.SVGPath, "svglide.chart_quality.svg_hash_mismatch", fmt.Sprintf("chart %q SVG hash %s, want %s", id, got, renderEntry.SVGSHA256))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateVegaLiteSpec(report *ChartQualityReport, safeRoot string, chart chartManifestEntry) {
|
||||
specPath := strings.TrimSpace(chart.SpecPath)
|
||||
if specPath == "" {
|
||||
return
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, specPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.missing_spec_path", err.Error())
|
||||
return
|
||||
}
|
||||
var spec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.invalid_spec_json", err.Error())
|
||||
return
|
||||
}
|
||||
if len(spec["$schema"]) == 0 {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_schema", "Vega-Lite spec must include $schema")
|
||||
}
|
||||
if len(spec["mark"]) == 0 {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_mark", "Vega-Lite spec must include mark")
|
||||
}
|
||||
if len(spec["encoding"]) == 0 {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_encoding", "Vega-Lite spec must include encoding")
|
||||
}
|
||||
if !vegaLiteSpecHasData(spec) {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_data", "Vega-Lite spec must include data.values or a local data reference")
|
||||
}
|
||||
}
|
||||
|
||||
func vegaLiteSpecHasData(spec map[string]json.RawMessage) bool {
|
||||
raw := spec["data"]
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var data map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return false
|
||||
}
|
||||
if len(data["values"]) > 0 {
|
||||
return true
|
||||
}
|
||||
var urlValue string
|
||||
if err := json.Unmarshal(data["url"], &urlValue); err == nil && strings.HasPrefix(urlValue, "assets/charts/data/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readChartRenderReport(safeRoot string) (ChartRenderReport, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, chartRenderReceiptPath)
|
||||
if err != nil {
|
||||
return ChartRenderReport{}, err
|
||||
}
|
||||
var report ChartRenderReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return ChartRenderReport{}, fmt.Errorf("%s: invalid JSON: %w", chartRenderReceiptPath, err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func chartRenderEntriesByID(report ChartRenderReport) map[string]ChartRenderEntry {
|
||||
out := map[string]ChartRenderEntry{}
|
||||
for _, entry := range report.Charts {
|
||||
if id := strings.TrimSpace(entry.ID); id != "" {
|
||||
out[id] = entry
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func checkChartSVGQuality(report *ChartQualityReport, path, svg string) {
|
||||
visible := strings.ToLower(visibleSemanticText(svg))
|
||||
raw := strings.ToLower(svg)
|
||||
if !chartHasUnit(visible) {
|
||||
report.Metrics.MissingUnitCount++
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.missing_unit", "chart must include a visible unit such as $, %, bps, billion, million, points, goals, or score")
|
||||
}
|
||||
if !chartHasSource(visible) {
|
||||
report.Metrics.MissingSourceCount++
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.missing_source", "chart must include a visible source note or source label")
|
||||
}
|
||||
hasAxis := chartHasAxis(raw, visible)
|
||||
hasDirectLabel := chartHasDirectLabel(raw, visible)
|
||||
if !hasAxis {
|
||||
report.Metrics.MissingAxisCount++
|
||||
}
|
||||
if !hasDirectLabel {
|
||||
report.Metrics.MissingDirectLabelCount++
|
||||
}
|
||||
if !hasAxis || !hasDirectLabel {
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.missing_labeling", "chart must include readable axes or direct labels")
|
||||
}
|
||||
if chartLooksDecorative(raw, visible) {
|
||||
report.Metrics.DecorativeChartCount++
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.decorative_chart", "chart looks decorative: it lacks enough labels, axes, units, or source context")
|
||||
}
|
||||
}
|
||||
|
||||
func writeChartQualityReport(safeRoot string, report ChartQualityReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartQualityReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func addChartQualityIssue(report *ChartQualityReport, path, code, message string) {
|
||||
report.Issues = append(report.Issues, ChartQualityIssue{
|
||||
Path: filepath.ToSlash(path),
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
})
|
||||
}
|
||||
|
||||
func chartHasUnit(visible string) bool {
|
||||
for _, token := range []string{"$", "%", "bps", "bp", "points", "point", "score", "goals", "goal", "usd", "rmb", "billion", "million", "bn", "分", "美元", "亿元", "亿", "倍"} {
|
||||
if strings.Contains(visible, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartHasSource(visible string) bool {
|
||||
normalized := strings.NewReplacer(":", ":", "﹕", ":", ":", ":", "\n", " ").Replace(visible)
|
||||
for _, token := range []string{
|
||||
"source:",
|
||||
"sources:",
|
||||
"data source:",
|
||||
"source note:",
|
||||
"来源:",
|
||||
"数据源:",
|
||||
"资料来源:",
|
||||
"数据来源:",
|
||||
"sec 10-k",
|
||||
"sec 10-q",
|
||||
"company filings",
|
||||
"company filing",
|
||||
"annual report",
|
||||
"quarterly report",
|
||||
"official statistics",
|
||||
"official data",
|
||||
"fifa official",
|
||||
"olympics official",
|
||||
"年报",
|
||||
"财报",
|
||||
} {
|
||||
if strings.Contains(normalized, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartHasAxis(raw, visible string) bool {
|
||||
if strings.Contains(raw, "role=\"axis\"") || strings.Contains(raw, "aria-label=\"axis") || strings.Contains(raw, "class=\"axis") {
|
||||
return true
|
||||
}
|
||||
for _, token := range []string{"x-axis", "y-axis", "axis", "year", "quarter", "fy", "q1", "q2", "q3", "q4", "年度", "季度"} {
|
||||
if strings.Contains(raw, token) || strings.Contains(visible, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartHasDirectLabel(raw, visible string) bool {
|
||||
if strings.Contains(raw, "direct-label") || strings.Contains(raw, "data-label") || strings.Contains(raw, "mark-text") {
|
||||
return true
|
||||
}
|
||||
return strings.Count(raw, "<text") >= 2 && containsDigit(visible)
|
||||
}
|
||||
|
||||
func chartLooksDecorative(raw, visible string) bool {
|
||||
barCount := strings.Count(raw, "<rect") + strings.Count(raw, "<path")
|
||||
textCount := strings.Count(raw, "<text")
|
||||
return barCount >= 2 && (textCount == 0 || !containsDigit(visible) || !chartHasUnit(visible) || !chartHasSource(visible))
|
||||
}
|
||||
113
internal/svglide/chart_quality_test.go
Normal file
113
internal/svglide/chart_quality_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChartQualityRequiresUnitsSourcesAndLabels(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
copyChartQualityTestData(t, "weak_financial_chart", "demo")
|
||||
|
||||
report, err := CheckChartQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Metrics.Charts != 1 || report.Metrics.VegaLiteCharts != 1 {
|
||||
t.Fatalf("metrics = %+v, want one Vega-Lite chart", report.Metrics)
|
||||
}
|
||||
if report.Metrics.MissingUnitCount != 1 {
|
||||
t.Fatalf("missing unit count = %d, want 1", report.Metrics.MissingUnitCount)
|
||||
}
|
||||
if report.Metrics.MissingSourceCount != 1 {
|
||||
t.Fatalf("missing source count = %d, want 1", report.Metrics.MissingSourceCount)
|
||||
}
|
||||
if report.Metrics.MissingAxisCount != 1 || report.Metrics.MissingDirectLabelCount != 1 {
|
||||
t.Fatalf("label metrics = %+v, want missing axis and direct label", report.Metrics)
|
||||
}
|
||||
if report.Metrics.DecorativeChartCount != 1 {
|
||||
t.Fatalf("decorative chart count = %d, want 1", report.Metrics.DecorativeChartCount)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"svglide.chart_quality.missing_unit",
|
||||
"svglide.chart_quality.missing_source",
|
||||
"svglide.chart_quality.missing_labeling",
|
||||
"svglide.chart_quality.decorative_chart",
|
||||
} {
|
||||
if !chartQualityIssueCodesContain(report.Issues, code) {
|
||||
t.Fatalf("issues = %+v, want %s", report.Issues, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartQualityDoesNotTreatCompanyComparisonAsSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"peer","slide_id":"s1","renderer":"vega-lite","spec_path":"assets/charts/specs/peer.vl.json","svg_path":"assets/charts/peer.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/peer.svg", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 240"><g role="axis"><text>FY2024</text></g><text>Company comparison</text><text>$22.1B</text><rect width="120" height="160"/></svg>`)
|
||||
|
||||
report, err := CheckChartQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for missing source", report.Status)
|
||||
}
|
||||
if report.Metrics.MissingSourceCount != 1 {
|
||||
t.Fatalf("missing source count = %d, want 1", report.Metrics.MissingSourceCount)
|
||||
}
|
||||
if !chartQualityIssueCodesContain(report.Issues, "svglide.chart_quality.missing_source") {
|
||||
t.Fatalf("issues = %+v, want missing source", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func copyChartQualityTestData(t *testing.T, name string, root string) {
|
||||
t.Helper()
|
||||
srcRoot := chartQualityTestDataRoot(t, name)
|
||||
err := filepath.WalkDir(srcRoot, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(srcRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, raw, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chartQualityTestDataRoot(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "testdata", "chart_quality", name)
|
||||
}
|
||||
|
||||
func chartQualityIssueCodesContain(issues []ChartQualityIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
211
internal/svglide/chart_render.go
Normal file
211
internal/svglide/chart_render.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartRenderReceiptPath = "receipts/chart_render.json"
|
||||
|
||||
type ChartRenderReport struct {
|
||||
Status string `json:"status"`
|
||||
Renderer string `json:"renderer"`
|
||||
Charts []ChartRenderEntry `json:"charts"`
|
||||
Issues []ChartRenderIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ChartRenderEntry struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SpecSHA256 string `json:"spec_sha256"`
|
||||
SVGSHA256 string `json:"svg_sha256"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type ChartRenderIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func RenderVegaLiteCharts(root string) (ChartRenderReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return ChartRenderReport{}, err
|
||||
}
|
||||
report := ChartRenderReport{
|
||||
Status: "passed",
|
||||
Renderer: "node-vega-lite",
|
||||
Charts: []ChartRenderEntry{},
|
||||
Issues: []ChartRenderIssue{},
|
||||
}
|
||||
manifest, present, err := readChartManifest(safeRoot)
|
||||
if err != nil {
|
||||
return ChartRenderReport{}, err
|
||||
}
|
||||
if !present || len(manifest.Charts) == 0 {
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
nodePath, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_node",
|
||||
Message: "node executable is not available in PATH",
|
||||
})
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
rendererScript, err := findNodeChartRendererScript()
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_node_dependencies",
|
||||
Path: "internal/svglide/chart_renderer",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
if err := validateNodeChartRendererDependencies(rendererScript); err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_node_dependencies",
|
||||
Path: "internal/svglide/chart_renderer",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
for _, chart := range manifest.Charts {
|
||||
if chartEntryRenderer(manifest, chart) != requiredChartRendererVegaLite {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.unsupported_renderer",
|
||||
Path: chartManifestPath,
|
||||
Message: fmt.Sprintf("chart %q renderer must be vega-lite for local SVG deck", chart.ID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
specPath := strings.TrimSpace(chart.SpecPath)
|
||||
svgPath := strings.TrimSpace(chart.SVGPath)
|
||||
if specPath == "" || svgPath == "" {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_path",
|
||||
Path: chartManifestPath,
|
||||
Message: fmt.Sprintf("chart %q must include spec_path and svg_path", chart.ID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
specAbs, err := safeRunPath(safeRoot, specPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.invalid_spec_path", Path: specPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
svgAbs, err := safeRunPath(safeRoot, svgPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.invalid_svg_path", Path: svgPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(svgAbs), 0o755); err != nil {
|
||||
return report, err
|
||||
}
|
||||
cmd := exec.Command(nodePath, rendererScript, "--input", specAbs, "--output", svgAbs)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.node_renderer_failed",
|
||||
Path: specPath,
|
||||
Message: strings.TrimSpace(string(output)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
specRaw, err := readRunRegularArtifact(safeRoot, specPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.read_spec", Path: specPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
svgRaw, err := readRunRegularArtifact(safeRoot, svgPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.read_svg", Path: svgPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Charts = append(report.Charts, ChartRenderEntry{
|
||||
ID: strings.TrimSpace(chart.ID),
|
||||
SlideID: strings.TrimSpace(chart.SlideID),
|
||||
SpecPath: specPath,
|
||||
SVGPath: svgPath,
|
||||
SpecSHA256: sha256Hex(specRaw),
|
||||
SVGSHA256: sha256Hex(svgRaw),
|
||||
Command: "node internal/svglide/chart_renderer/render-vegalite.mjs --input " + specPath + " --output " + svgPath,
|
||||
})
|
||||
}
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
|
||||
func writeChartRenderReport(safeRoot string, report ChartRenderReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartRenderReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func sha256Hex(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func findNodeChartRendererScript() (string, error) {
|
||||
if _, file, _, ok := runtime.Caller(0); ok {
|
||||
candidate := filepath.Join(filepath.Dir(file), "chart_renderer", "render-vegalite.mjs")
|
||||
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for dir := cwd; ; dir = filepath.Dir(dir) {
|
||||
for _, rel := range []string{
|
||||
filepath.Join("internal", "svglide", "chart_renderer", "render-vegalite.mjs"),
|
||||
filepath.Join("chart_renderer", "render-vegalite.mjs"),
|
||||
} {
|
||||
candidate := filepath.Join(dir, rel)
|
||||
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot locate internal/svglide/chart_renderer/render-vegalite.mjs from %s", cwd)
|
||||
}
|
||||
|
||||
func validateNodeChartRendererDependencies(scriptPath string) error {
|
||||
root := filepath.Dir(scriptPath)
|
||||
for _, rel := range []string{
|
||||
filepath.Join("node_modules", "vega", "package.json"),
|
||||
filepath.Join("node_modules", "vega-lite", "package.json"),
|
||||
} {
|
||||
path := filepath.Join(root, rel)
|
||||
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("missing %s; run npm --prefix internal/svglide/chart_renderer install", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
66
internal/svglide/chart_render_test.go
Normal file
66
internal/svglide/chart_render_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChartRenderRendersVegaLiteSpecWithNodeRenderer(t *testing.T) {
|
||||
if script, err := findNodeChartRendererScript(); err != nil {
|
||||
t.Skip(err)
|
||||
} else if err := validateNodeChartRendererDependencies(script); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"revenue","slide_id":"s1","renderer":"vega-lite","brief_id":"revenue","spec_path":"assets/charts/specs/revenue.vl.json","svg_path":"assets/charts/revenue.svg","source_id":"web1","unit":"$","takeaway":"Revenue increased","render_receipt":"receipts/chart_render.json"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/specs/revenue.vl.json", minimalVegaLiteSpecForTest())
|
||||
|
||||
report, err := RenderVegaLiteCharts("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
if report.Renderer != "node-vega-lite" {
|
||||
t.Fatalf("renderer = %q, want node-vega-lite", report.Renderer)
|
||||
}
|
||||
if len(report.Charts) != 1 || report.Charts[0].SpecSHA256 == "" || report.Charts[0].SVGSHA256 == "" {
|
||||
t.Fatalf("render report = %+v, want one hashed chart", report)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join("demo", "assets", "charts", "revenue.svg")); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("rendered SVG missing or empty, info=%+v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartRenderWritesEmptyReceiptForNoChartManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
report, err := RenderVegaLiteCharts("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || len(report.Charts) != 0 {
|
||||
t.Fatalf("report = %+v, want passed empty report", report)
|
||||
}
|
||||
}
|
||||
|
||||
func minimalVegaLiteSpecForTest() string {
|
||||
return `{
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"width": 640,
|
||||
"height": 320,
|
||||
"data": {
|
||||
"values": [
|
||||
{"quarter": "Q1", "revenue": 2},
|
||||
{"quarter": "Q2", "revenue": 5}
|
||||
]
|
||||
},
|
||||
"mark": "bar",
|
||||
"encoding": {
|
||||
"x": {"field": "quarter", "type": "nominal", "title": "Quarter"},
|
||||
"y": {"field": "revenue", "type": "quantitative", "title": "Revenue ($B)"}
|
||||
}
|
||||
}`
|
||||
}
|
||||
1
internal/svglide/chart_renderer/.gitignore
vendored
Normal file
1
internal/svglide/chart_renderer/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
917
internal/svglide/chart_renderer/package-lock.json
generated
Normal file
917
internal/svglide/chart_renderer/package-lock.json
generated
Normal file
@@ -0,0 +1,917 @@
|
||||
{
|
||||
"name": "@svglide/chart-renderer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@svglide/chart-renderer",
|
||||
"dependencies": {
|
||||
"vega": "^6.2.0",
|
||||
"vega-lite": "^6.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://bnpm.byted.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://bnpm.byted.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://bnpm.byted.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://bnpm.byted.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://bnpm.byted.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://bnpm.byted.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://bnpm.byted.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://bnpm.byted.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://bnpm.byted.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo-projection": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
|
||||
"integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"d3-array": "1 - 3",
|
||||
"d3-geo": "1.12.0 - 3"
|
||||
},
|
||||
"bin": {
|
||||
"geo2svg": "bin/geo2svg.js",
|
||||
"geograticule": "bin/geograticule.js",
|
||||
"geoproject": "bin/geoproject.js",
|
||||
"geoquantize": "bin/geoquantize.js",
|
||||
"geostitch": "bin/geostitch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://bnpm.byted.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://bnpm.byted.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://bnpm.byted.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://bnpm.byted.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://bnpm.byted.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://bnpm.byted.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://bnpm.byted.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://bnpm.byted.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://bnpm.byted.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://bnpm.byted.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://bnpm.byted.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://bnpm.byted.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://bnpm.byted.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://bnpm.byted.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://bnpm.byted.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://bnpm.byted.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/vega": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://bnpm.byted.org/vega/-/vega-6.2.0.tgz",
|
||||
"integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-crossfilter": "~5.1.0",
|
||||
"vega-dataflow": "~6.1.0",
|
||||
"vega-encode": "~5.1.0",
|
||||
"vega-event-selector": "~4.0.0",
|
||||
"vega-expression": "~6.1.0",
|
||||
"vega-force": "~5.1.0",
|
||||
"vega-format": "~2.1.0",
|
||||
"vega-functions": "~6.1.0",
|
||||
"vega-geo": "~5.1.0",
|
||||
"vega-hierarchy": "~5.1.0",
|
||||
"vega-label": "~2.1.0",
|
||||
"vega-loader": "~5.1.0",
|
||||
"vega-parser": "~7.1.0",
|
||||
"vega-projection": "~2.1.0",
|
||||
"vega-regression": "~2.1.0",
|
||||
"vega-runtime": "~7.1.0",
|
||||
"vega-scale": "~8.1.0",
|
||||
"vega-scenegraph": "~5.1.0",
|
||||
"vega-statistics": "~2.0.0",
|
||||
"vega-time": "~3.1.0",
|
||||
"vega-transforms": "~5.1.0",
|
||||
"vega-typings": "~2.1.0",
|
||||
"vega-util": "~2.1.0",
|
||||
"vega-view": "~6.1.0",
|
||||
"vega-view-transforms": "~5.1.0",
|
||||
"vega-voronoi": "~5.1.0",
|
||||
"vega-wordcloud": "~5.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-canvas": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-canvas/-/vega-canvas-2.0.0.tgz",
|
||||
"integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-crossfilter": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz",
|
||||
"integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-dataflow": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz",
|
||||
"integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-loader": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-encode": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-encode/-/vega-encode-5.1.0.tgz",
|
||||
"integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-event-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-expression": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-expression/-/vega-expression-6.1.0.tgz",
|
||||
"integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-force": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-force/-/vega-force-5.1.0.tgz",
|
||||
"integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-format/-/vega-format-2.1.0.tgz",
|
||||
"integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-format": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-functions": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://bnpm.byted.org/vega-functions/-/vega-functions-6.1.1.tgz",
|
||||
"integrity": "sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-selections": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-geo": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-geo/-/vega-geo-5.1.0.tgz",
|
||||
"integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-projection": "^2.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-hierarchy": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz",
|
||||
"integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-label": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-label/-/vega-label-2.1.0.tgz",
|
||||
"integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-lite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://bnpm.byted.org/vega-lite/-/vega-lite-6.4.3.tgz",
|
||||
"integrity": "sha512-d/7hPjfz560UERaQuTmGgIVfXAe3g2hJWeC+igDeaGohUdEoNrHLXgR/yTOBT8vV/lIuuKnw+0/xWWblkDwkMQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"json-stringify-pretty-compact": "~4.0.0",
|
||||
"tslib": "~2.8.1",
|
||||
"vega-event-selector": "~4.0.0",
|
||||
"vega-expression": "~6.1.0",
|
||||
"vega-util": "~2.1.0",
|
||||
"yargs": "~18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vl2pdf": "bin/vl2pdf",
|
||||
"vl2png": "bin/vl2png",
|
||||
"vl2svg": "bin/vl2svg",
|
||||
"vl2vg": "bin/vl2vg"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vega": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-loader": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-loader/-/vega-loader-5.1.0.tgz",
|
||||
"integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dsv": "^3.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-parser": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-parser/-/vega-parser-7.1.0.tgz",
|
||||
"integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-event-selector": "^4.0.0",
|
||||
"vega-functions": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-projection": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-projection/-/vega-projection-2.1.0.tgz",
|
||||
"integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-geo-projection": "^4.0.0",
|
||||
"vega-scale": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-regression": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-regression/-/vega-regression-2.1.0.tgz",
|
||||
"integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-runtime": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-runtime/-/vega-runtime-7.1.0.tgz",
|
||||
"integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-scale": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-scale/-/vega-scale-8.1.0.tgz",
|
||||
"integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-scenegraph": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz",
|
||||
"integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0",
|
||||
"d3-shape": "^3.2.0",
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-loader": "^5.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-selections": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://bnpm.byted.org/vega-selections/-/vega-selections-6.1.2.tgz",
|
||||
"integrity": "sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "3.2.4",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-statistics": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-statistics/-/vega-statistics-2.0.0.tgz",
|
||||
"integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-time/-/vega-time-3.1.0.tgz",
|
||||
"integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-transforms": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-transforms/-/vega-transforms-5.1.0.tgz",
|
||||
"integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-typings": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-typings/-/vega-typings-2.1.0.tgz",
|
||||
"integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/geojson": "7946.0.16",
|
||||
"vega-event-selector": "^4.0.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-util": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://bnpm.byted.org/vega-util/-/vega-util-2.1.1.tgz",
|
||||
"integrity": "sha512-tpNmm8bGtUa8gKfFDSjXPffxqSyPr91vaWIEBnJS/rijhoLZMwM+mgYQG6XfwdcBSN1+jkZ57P0sYSEW/jophw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-view": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-view/-/vega-view-6.1.0.tgz",
|
||||
"integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-timer": "^3.0.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-functions": "^6.1.0",
|
||||
"vega-runtime": "^7.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-view-transforms": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz",
|
||||
"integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-voronoi": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz",
|
||||
"integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-delaunay": "^6.0.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-wordcloud": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz",
|
||||
"integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://bnpm.byted.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://bnpm.byted.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://bnpm.byted.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://bnpm.byted.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
internal/svglide/chart_renderer/package.json
Normal file
15
internal/svglide/chart_renderer/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@svglide/chart-renderer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"render": "node ./render-vegalite.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"vega": "^6.2.0",
|
||||
"vega-lite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
28
internal/svglide/chart_renderer/render-vegalite.mjs
Normal file
28
internal/svglide/chart_renderer/render-vegalite.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as vega from "vega";
|
||||
import * as vegaLite from "vega-lite";
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index < 0 || index + 1 >= process.argv.length) {
|
||||
throw new Error(`missing required argument ${name}`);
|
||||
}
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
const input = readArg("--input");
|
||||
const output = readArg("--output");
|
||||
const raw = fs.readFileSync(input, "utf8");
|
||||
const vlSpec = JSON.parse(raw);
|
||||
const vgSpec = vegaLite.compile(vlSpec).spec;
|
||||
const view = new vega.View(vega.parse(vgSpec), {
|
||||
renderer: "svg",
|
||||
logLevel: vega.Warn
|
||||
});
|
||||
|
||||
await view.runAsync();
|
||||
const svg = await view.toSVG();
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, svg);
|
||||
view.finalize();
|
||||
195
internal/svglide/chart_usage.go
Normal file
195
internal/svglide/chart_usage.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartUsageReceiptPath = "receipts/chart_usage.json"
|
||||
|
||||
type ChartUsageReport struct {
|
||||
Status string `json:"status"`
|
||||
Charts []ChartUsageChart `json:"charts"`
|
||||
Issues []ChartUsageIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ChartUsageChart struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
ReferenceCount int `json:"reference_count"`
|
||||
}
|
||||
|
||||
type ChartUsageIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type chartUsageReference struct {
|
||||
SlideID string
|
||||
Path string
|
||||
Href string
|
||||
Width float64
|
||||
Height float64
|
||||
}
|
||||
|
||||
func EvaluateChartUsageRun(safeRoot string, deck authorDeck, manifest chartManifestFile, briefs chartBriefFile) ChartUsageReport {
|
||||
report := ChartUsageReport{Status: "passed", Charts: []ChartUsageChart{}, Issues: []ChartUsageIssue{}}
|
||||
refsByPath := map[string][]chartUsageReference{}
|
||||
rawBySlide := map[string]string{}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.read_slide", Path: slidePath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
rawText := string(raw)
|
||||
rawBySlide[strings.TrimSpace(slide.ID)] = rawText
|
||||
refs, issues := extractChartUsageReferences(strings.TrimSpace(slide.ID), slidePath, rawText)
|
||||
if len(issues) > 0 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, issues...)
|
||||
}
|
||||
for _, ref := range refs {
|
||||
refsByPath[ref.Path] = append(refsByPath[ref.Path], ref)
|
||||
}
|
||||
}
|
||||
briefByID := chartBriefByID(briefs)
|
||||
expectedSlideIDs := map[string]bool{}
|
||||
for _, chart := range manifest.Charts {
|
||||
id := strings.TrimSpace(chart.ID)
|
||||
slideID := strings.TrimSpace(chart.SlideID)
|
||||
svgPath := strings.TrimSpace(chart.SVGPath)
|
||||
expectedSlideIDs[slideID] = true
|
||||
refs := refsByPath[svgPath]
|
||||
report.Charts = append(report.Charts, ChartUsageChart{ID: id, SlideID: slideID, SVGPath: svgPath, ReferenceCount: len(refs)})
|
||||
if len(refs) == 0 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.not_referenced", Path: svgPath, Message: fmt.Sprintf("chart %q is not referenced by a <rect slide:role=\"chart\">", id)})
|
||||
continue
|
||||
}
|
||||
if len(refs) > 1 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.duplicate_reference", Path: svgPath, Message: fmt.Sprintf("chart %q has %d references; expected exactly one", id, len(refs))})
|
||||
}
|
||||
minWidth, minHeight := chartUsageMinSize(briefByID[strings.TrimSpace(chart.BriefID)])
|
||||
for _, ref := range refs {
|
||||
if ref.SlideID != slideID {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.wrong_slide", Path: svgPath, Message: fmt.Sprintf("chart %q referenced on slide %q, want %q", id, ref.SlideID, slideID)})
|
||||
}
|
||||
if ref.Width < float64(minWidth) || ref.Height < float64(minHeight) {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.too_small", Path: svgPath, Message: fmt.Sprintf("chart %q rendered at %.0fx%.0f, minimum is %dx%d", id, ref.Width, ref.Height, minWidth, minHeight)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for slideID := range expectedSlideIDs {
|
||||
hasValidRef := false
|
||||
for _, refs := range refsByPath {
|
||||
for _, ref := range refs {
|
||||
if ref.SlideID == slideID {
|
||||
hasValidRef = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasValidRef && chartSlideLooksHandDrawn(rawBySlide[slideID]) {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.hand_drawn_chart", Path: slideID, Message: "slide appears to hand-draw chart primitives instead of embedding a rendered chart asset"})
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func writeChartUsageReport(safeRoot string, report ChartUsageReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartUsageReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func extractChartUsageReferences(slideID, slidePath, svg string) ([]chartUsageReference, []ChartUsageIssue) {
|
||||
refs := []chartUsageReference{}
|
||||
issues := []ChartUsageIssue{}
|
||||
decoder := xml.NewDecoder(strings.NewReader(svg))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs := parseSVGAttrs(start.Attr)
|
||||
if strings.TrimSpace(attrs["role"]) != "chart" {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != "rect" {
|
||||
issues = append(issues, ChartUsageIssue{Code: "svglide.chart_usage.invalid_chart_element", Path: slidePath, Message: fmt.Sprintf("<%s slide:role=\"chart\"> is invalid; use <rect slide:role=\"chart\">", start.Name.Local)})
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
refs = append(refs, chartUsageReference{
|
||||
SlideID: slideID,
|
||||
Path: normalizeChartHref(slidePath, href),
|
||||
Href: href,
|
||||
Width: parseChartUsageFloatAttr(attrs["width"]),
|
||||
Height: parseChartUsageFloatAttr(attrs["height"]),
|
||||
})
|
||||
}
|
||||
return refs, issues
|
||||
}
|
||||
|
||||
func normalizeChartHref(slidePath, href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if strings.HasPrefix(href, "assets/charts/") {
|
||||
return href
|
||||
}
|
||||
return normalizeSlideAssetHref(slidePath, href)
|
||||
}
|
||||
|
||||
func parseChartUsageFloatAttr(raw string) float64 {
|
||||
raw = strings.TrimSpace(strings.TrimSuffix(raw, "px"))
|
||||
value, _ := strconv.ParseFloat(raw, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func chartBriefByID(briefs chartBriefFile) map[string]chartBriefEntry {
|
||||
out := map[string]chartBriefEntry{}
|
||||
for _, brief := range briefs.Charts {
|
||||
if id := strings.TrimSpace(brief.ID); id != "" {
|
||||
out[id] = brief
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func chartUsageMinSize(brief chartBriefEntry) (int, int) {
|
||||
minWidth := 480
|
||||
minHeight := 260
|
||||
if brief.MinWidth > minWidth {
|
||||
minWidth = brief.MinWidth
|
||||
}
|
||||
if brief.MinHeight > minHeight {
|
||||
minHeight = brief.MinHeight
|
||||
}
|
||||
return minWidth, minHeight
|
||||
}
|
||||
|
||||
func chartSlideLooksHandDrawn(svg string) bool {
|
||||
raw := strings.ToLower(svg)
|
||||
rects := strings.Count(raw, "<rect") - strings.Count(raw, `slide:role="chart"`)
|
||||
circles := strings.Count(raw, "<circle")
|
||||
lines := strings.Count(raw, "<line")
|
||||
paths := strings.Count(raw, "<path")
|
||||
texts := strings.Count(raw, "<text")
|
||||
return rects >= 4 || circles >= 6 || (lines >= 2 && (rects+paths+circles) >= 4) || (texts >= 5 && (rects+paths+circles+lines) >= 4)
|
||||
}
|
||||
121
internal/svglide/chart_usage_test.go
Normal file
121
internal/svglide/chart_usage_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChartUsageAcceptsRectChartReference(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsImageRoleChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<image slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.invalid_chart_element") {
|
||||
t.Fatalf("issues = %+v, want invalid_chart_element", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsGroupRoleChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<g slide:role="chart" href="../assets/charts/revenue.svg"></g>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.invalid_chart_element") {
|
||||
t.Fatalf("issues = %+v, want invalid_chart_element", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsWrongSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("other-slide"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.wrong_slide") {
|
||||
t.Fatalf("issues = %+v, want wrong_slide", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsTinyChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="240" height="120"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.too_small") {
|
||||
t.Fatalf("issues = %+v, want too_small", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsHandDrawnChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<line x1="100" y1="420" x2="700" y2="420"/><line x1="100" y1="100" x2="100" y2="420"/><rect x="150" y="320" width="60" height="100"/><rect x="250" y="260" width="60" height="160"/><rect x="350" y="210" width="60" height="210"/><rect x="450" y="180" width="60" height="240"/><text x="150" y="450">$2B</text><text x="250" y="450">$5B</text><text x="350" y="450">$8B</text><text x="450" y="450">$9B</text>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.hand_drawn_chart") {
|
||||
t.Fatalf("issues = %+v, want hand_drawn_chart", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityWritesChartUsageReceipt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[],"no_image_reason":"Chart usage receipt smoke does not exercise raster image selection."}`)
|
||||
|
||||
if _, err := CheckQuality("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readRunRegularArtifact("demo", chartUsageReceiptPath); err != nil {
|
||||
t.Fatalf("missing chart usage receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeChartUsageDeckForTest(t *testing.T, body string) authorDeck {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">`+body+`</svg>`)
|
||||
return authorDeck{Slides: []authorDeckSlide{{ID: "s1", Path: "slides/01.svg"}}}
|
||||
}
|
||||
|
||||
func chartUsageManifestForTest(slideID string) chartManifestFile {
|
||||
return chartManifestFile{
|
||||
Renderer: "vega-lite",
|
||||
Charts: []chartManifestEntry{{
|
||||
ID: "revenue",
|
||||
SlideID: slideID,
|
||||
Renderer: "vega-lite",
|
||||
BriefID: "revenue",
|
||||
SpecPath: "assets/charts/specs/revenue.vl.json",
|
||||
SVGPath: "assets/charts/revenue.svg",
|
||||
SourceID: "web1",
|
||||
Unit: "$",
|
||||
Takeaway: "Revenue increased",
|
||||
RenderReceipt: chartRenderReceiptPath,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func chartUsageBriefsForTest() chartBriefFile {
|
||||
return chartBriefFile{Charts: []chartBriefEntry{{
|
||||
ID: "revenue",
|
||||
SlideID: "s1",
|
||||
Purpose: "trend",
|
||||
Takeaway: "Revenue increased",
|
||||
Renderer: "vega-lite",
|
||||
SourceIDs: []string{"web1"},
|
||||
Unit: "$",
|
||||
}}}
|
||||
}
|
||||
|
||||
func chartUsageIssuesContain(issues []ChartUsageIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
445
internal/svglide/content_payload.go
Normal file
445
internal/svglide/content_payload.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const contentPayloadReportPath = "receipts/content_payload.json"
|
||||
|
||||
type ContentPayloadReport struct {
|
||||
Status string `json:"status"`
|
||||
Metrics ContentPayloadMetrics `json:"metrics"`
|
||||
Issues []ContentPayloadIssue `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
type ContentPayloadMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
SubstantiveSlides int `json:"substantive_slides"`
|
||||
SparseLabelListCount int `json:"sparse_label_list_count"`
|
||||
MissingCentralClaimCount int `json:"missing_central_claim_count"`
|
||||
MissingSupportingPointsCount int `json:"missing_supporting_points_count"`
|
||||
MissingSourceBoundFactCount int `json:"missing_source_bound_fact_count"`
|
||||
MissingVisualDataItemsCount int `json:"missing_visual_data_items_count"`
|
||||
SourceBindingIssueCount int `json:"source_binding_issue_count"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
}
|
||||
|
||||
type ContentPayloadIssue struct {
|
||||
Code string `json:"code"`
|
||||
SlideID string `json:"slide_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type contentPayloadFile struct {
|
||||
PromptContract json.RawMessage `json:"prompt_contract"`
|
||||
Slides []contentPayloadSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type contentPayloadSlide struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
CentralClaim string `json:"central_claim"`
|
||||
AudienceTakeaway string `json:"audience_takeaway"`
|
||||
SupportingPoints []contentPayloadSupportingPoint `json:"supporting_points"`
|
||||
SourceBoundFacts []contentPayloadSourceBoundFact `json:"source_bound_facts"`
|
||||
ExamplesOrParameters []contentPayloadExampleOrParameter `json:"examples_or_parameters"`
|
||||
VisualDataItems []contentPayloadVisualDataItem `json:"visual_data_items"`
|
||||
SoWhat string `json:"so_what"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []contentPayloadVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type contentPayloadSupportingPoint struct {
|
||||
Text string `json:"text"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
}
|
||||
|
||||
type contentPayloadSourceBoundFact struct {
|
||||
Fact string `json:"fact"`
|
||||
SourceRef string `json:"source_ref"`
|
||||
Usage string `json:"usage"`
|
||||
}
|
||||
|
||||
type contentPayloadExampleOrParameter struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Explanation string `json:"explanation"`
|
||||
SourceRef string `json:"source_ref"`
|
||||
}
|
||||
|
||||
type contentPayloadVisualDataItem struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Role string `json:"role"`
|
||||
Explanation string `json:"explanation"`
|
||||
SourceRef string `json:"source_ref"`
|
||||
}
|
||||
|
||||
type contentPayloadVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
VisualForm string `json:"visual_form"`
|
||||
}
|
||||
|
||||
type contentPayloadDeckSlideMeta struct {
|
||||
ID string
|
||||
Title string
|
||||
Role string
|
||||
}
|
||||
|
||||
func EvaluateContentPayloadRun(root string) (ContentPayloadReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return ContentPayloadReport{}, err
|
||||
}
|
||||
return evaluateContentPayloadAtRoot(safeRoot)
|
||||
}
|
||||
|
||||
func evaluateContentPayloadAtRoot(safeRoot string) (ContentPayloadReport, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return ContentPayloadReport{}, fmt.Errorf("content/slide_content.json: read artifact: %w", err)
|
||||
}
|
||||
var file contentPayloadFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return ContentPayloadReport{}, fmt.Errorf("content/slide_content.json: invalid JSON: %w", err)
|
||||
}
|
||||
report := ContentPayloadReport{
|
||||
Status: "passed",
|
||||
Metrics: ContentPayloadMetrics{Slides: len(file.Slides)},
|
||||
Issues: []ContentPayloadIssue{},
|
||||
}
|
||||
if !contentPayloadStrict(file) {
|
||||
return report, nil
|
||||
}
|
||||
sourceIDs, err := readKnownSourceIDs(safeRoot)
|
||||
if err != nil {
|
||||
return ContentPayloadReport{}, err
|
||||
}
|
||||
metaByID := readContentPayloadDeckMeta(safeRoot)
|
||||
for i, slide := range file.Slides {
|
||||
if meta, ok := metaByID[strings.TrimSpace(slide.ID)]; ok {
|
||||
if strings.TrimSpace(slide.Role) == "" {
|
||||
slide.Role = meta.Role
|
||||
}
|
||||
if strings.TrimSpace(slide.Title) == "" {
|
||||
slide.Title = meta.Title
|
||||
}
|
||||
}
|
||||
evaluateContentPayloadSlide(&report, slide, sourceIDs, i, len(file.Slides))
|
||||
}
|
||||
report.Metrics.IssueCount = len(report.Issues)
|
||||
if report.Metrics.IssueCount > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeContentPayloadReport(safeRoot string, report ContentPayloadReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, contentPayloadReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func contentPayloadStrict(file contentPayloadFile) bool {
|
||||
if raw := strings.TrimSpace(string(file.PromptContract)); raw != "" && raw != "null" && raw != "{}" {
|
||||
return true
|
||||
}
|
||||
for _, slide := range file.Slides {
|
||||
if strings.TrimSpace(slide.CentralClaim) != "" ||
|
||||
strings.TrimSpace(slide.AudienceTakeaway) != "" ||
|
||||
len(slide.SupportingPoints) > 0 ||
|
||||
len(slide.SourceBoundFacts) > 0 ||
|
||||
len(slide.VisualDataItems) > 0 ||
|
||||
strings.TrimSpace(slide.SoWhat) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readContentPayloadDeckMeta(safeRoot string) map[string]contentPayloadDeckSlideMeta {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "outline/deck.json")
|
||||
if err != nil {
|
||||
return map[string]contentPayloadDeckSlideMeta{}
|
||||
}
|
||||
var deck struct {
|
||||
Slides []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Role string `json:"role"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return map[string]contentPayloadDeckSlideMeta{}
|
||||
}
|
||||
byID := make(map[string]contentPayloadDeckSlideMeta, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
byID[id] = contentPayloadDeckSlideMeta{ID: id, Title: strings.TrimSpace(slide.Title), Role: strings.TrimSpace(slide.Role)}
|
||||
}
|
||||
return byID
|
||||
}
|
||||
|
||||
func evaluateContentPayloadSlide(report *ContentPayloadReport, slide contentPayloadSlide, sourceIDs map[string]bool, index int, total int) {
|
||||
substantive := isSubstantiveContentPayloadSlide(slide, index, total)
|
||||
if substantive {
|
||||
report.Metrics.SubstantiveSlides++
|
||||
}
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
hasFloor := contentPayloadHasFloor(slide)
|
||||
if substantive && isSparseLabelList(slide.Content) && !hasFloor {
|
||||
addContentPayloadIssue(report, "svglide.content_payload.sparse_label_list", id, "slide content is a label list without enough structured audience payload")
|
||||
}
|
||||
if substantive && len([]rune(strings.TrimSpace(slide.CentralClaim))) < 12 {
|
||||
addContentPayloadIssue(report, "svglide.content_payload.missing_central_claim", id, "substantive slide needs a central_claim of at least 12 characters")
|
||||
}
|
||||
if substantive && validSupportingPointCount(slide.SupportingPoints) < 2 {
|
||||
addContentPayloadIssue(report, "svglide.content_payload.missing_supporting_points", id, "substantive slide needs at least two source-backed supporting_points")
|
||||
}
|
||||
if substantive && validSourceBoundFactCount(slide.SourceBoundFacts) < 1 {
|
||||
addContentPayloadIssue(report, "svglide.content_payload.missing_source_bound_fact", id, "substantive slide needs at least one source_bound_fact")
|
||||
}
|
||||
checkContentPayloadSourceBindings(report, slide, sourceIDs)
|
||||
checkContentPayloadVisualData(report, slide)
|
||||
}
|
||||
|
||||
func contentPayloadHasFloor(slide contentPayloadSlide) bool {
|
||||
return len([]rune(strings.TrimSpace(slide.CentralClaim))) >= 12 &&
|
||||
validSupportingPointCount(slide.SupportingPoints) >= 2 &&
|
||||
validSourceBoundFactCount(slide.SourceBoundFacts) >= 1 &&
|
||||
contentPayloadVisualDataRequirementMet(slide)
|
||||
}
|
||||
|
||||
func validSupportingPointCount(points []contentPayloadSupportingPoint) int {
|
||||
count := 0
|
||||
for _, point := range points {
|
||||
if len([]rune(strings.TrimSpace(point.Text))) < 12 || len(point.SourceRefs) == 0 {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func validSourceBoundFactCount(facts []contentPayloadSourceBoundFact) int {
|
||||
count := 0
|
||||
for _, fact := range facts {
|
||||
if len([]rune(strings.TrimSpace(fact.Fact))) < 8 || strings.TrimSpace(fact.SourceRef) == "" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func isSubstantiveContentPayloadSlide(slide contentPayloadSlide, index int, total int) bool {
|
||||
role := strings.ToLower(strings.TrimSpace(slide.Role))
|
||||
switch role {
|
||||
case "cover", "opening", "agenda", "section", "section_divider", "divider":
|
||||
return false
|
||||
case "closing", "end", "appendix":
|
||||
return false
|
||||
}
|
||||
idTitle := strings.ToLower(strings.Join([]string{slide.ID, slide.Title, slide.Content}, " "))
|
||||
if index == 0 && (strings.Contains(idTitle, "cover") || strings.Contains(idTitle, "opening") || strings.Contains(idTitle, "封面") || strings.Contains(idTitle, "开场")) {
|
||||
return false
|
||||
}
|
||||
if total > 1 && index == total-1 && (strings.Contains(idTitle, "closing") || strings.Contains(idTitle, "结语") || strings.Contains(idTitle, "总结") || strings.Contains(idTitle, "end")) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func checkContentPayloadSourceBindings(report *ContentPayloadReport, slide contentPayloadSlide, sourceIDs map[string]bool) {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
for _, point := range slide.SupportingPoints {
|
||||
for _, ref := range point.SourceRefs {
|
||||
checkContentPayloadSourceRef(report, id, ref, sourceIDs)
|
||||
}
|
||||
}
|
||||
for _, fact := range slide.SourceBoundFacts {
|
||||
checkContentPayloadSourceRef(report, id, fact.SourceRef, sourceIDs)
|
||||
}
|
||||
for _, item := range slide.ExamplesOrParameters {
|
||||
if strings.TrimSpace(item.SourceRef) != "" {
|
||||
checkContentPayloadSourceRef(report, id, item.SourceRef, sourceIDs)
|
||||
}
|
||||
}
|
||||
for _, item := range slide.VisualDataItems {
|
||||
if strings.TrimSpace(item.SourceRef) != "" {
|
||||
checkContentPayloadSourceRef(report, id, item.SourceRef, sourceIDs)
|
||||
}
|
||||
if strings.TrimSpace(item.Explanation) == "" {
|
||||
addContentPayloadIssue(report, "svglide.content_payload.visual_data_without_explanation", id, fmt.Sprintf("visual_data_item %q needs an explanation", strings.TrimSpace(item.Label)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkContentPayloadSourceRef(report *ContentPayloadReport, slideID string, ref string, sourceIDs map[string]bool) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" || sourceIDs[ref] {
|
||||
return
|
||||
}
|
||||
addContentPayloadIssue(report, "svglide.content_payload.unknown_source_ref", slideID, fmt.Sprintf("structured payload references unknown source id %q", ref))
|
||||
}
|
||||
|
||||
func checkContentPayloadVisualData(report *ContentPayloadReport, slide contentPayloadSlide) {
|
||||
if contentPayloadVisualDataRequirementMet(slide) {
|
||||
return
|
||||
}
|
||||
addContentPayloadIssue(report, "svglide.content_payload.visual_form_missing_data", strings.TrimSpace(slide.ID), "declared visual_form needs matching visual_data_items")
|
||||
}
|
||||
|
||||
func contentPayloadVisualDataRequirementMet(slide contentPayloadSlide) bool {
|
||||
requiredRole, minItems := contentPayloadVisualDataRequirement(slide.Visuals)
|
||||
if minItems == 0 {
|
||||
return true
|
||||
}
|
||||
if requiredRole == "" {
|
||||
return len(slide.VisualDataItems) >= minItems
|
||||
}
|
||||
count := 0
|
||||
for _, item := range slide.VisualDataItems {
|
||||
if strings.TrimSpace(item.Explanation) == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.Role) == requiredRole {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count >= minItems
|
||||
}
|
||||
|
||||
func contentPayloadVisualDataRequirement(visuals []contentPayloadVisual) (string, int) {
|
||||
requiredRole := ""
|
||||
minItems := 0
|
||||
for _, visual := range visuals {
|
||||
typ := strings.TrimSpace(visual.Type)
|
||||
form := normalizeAuthorVisualForm(visual.VisualForm)
|
||||
if typ == "chart" {
|
||||
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "metric", 2)
|
||||
continue
|
||||
}
|
||||
if typ == "table" {
|
||||
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "", 3)
|
||||
continue
|
||||
}
|
||||
switch form {
|
||||
case authorVisualFormProcessFlow:
|
||||
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "step", 3)
|
||||
case authorVisualFormMapRoute:
|
||||
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "map_anchor", 3)
|
||||
case authorVisualFormParameterMatrix, authorVisualFormFourQuadrant, authorVisualFormSpectrum, authorVisualFormSensoryWheel:
|
||||
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "", 3)
|
||||
case authorVisualFormObjectCallout:
|
||||
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "callout", 3)
|
||||
}
|
||||
}
|
||||
return requiredRole, minItems
|
||||
}
|
||||
|
||||
func strongerVisualDataRequirement(currentRole string, currentMin int, nextRole string, nextMin int) (string, int) {
|
||||
if nextMin > currentMin {
|
||||
return nextRole, nextMin
|
||||
}
|
||||
return currentRole, currentMin
|
||||
}
|
||||
|
||||
func addContentPayloadIssue(report *ContentPayloadReport, code string, slideID string, message string) {
|
||||
report.Issues = append(report.Issues, ContentPayloadIssue{Code: code, SlideID: slideID, Message: message})
|
||||
switch code {
|
||||
case "svglide.content_payload.sparse_label_list":
|
||||
report.Metrics.SparseLabelListCount++
|
||||
case "svglide.content_payload.missing_central_claim":
|
||||
report.Metrics.MissingCentralClaimCount++
|
||||
case "svglide.content_payload.missing_supporting_points":
|
||||
report.Metrics.MissingSupportingPointsCount++
|
||||
case "svglide.content_payload.missing_source_bound_fact":
|
||||
report.Metrics.MissingSourceBoundFactCount++
|
||||
case "svglide.content_payload.visual_form_missing_data", "svglide.content_payload.visual_data_without_explanation":
|
||||
report.Metrics.MissingVisualDataItemsCount++
|
||||
case "svglide.content_payload.unknown_source_ref":
|
||||
report.Metrics.SourceBindingIssueCount++
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeContentPayloadIssues(issues []ContentPayloadIssue) string {
|
||||
if len(issues) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, minPositive(len(issues), 3))
|
||||
for i, issue := range issues {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(issue.SlideID) != "" {
|
||||
parts = append(parts, fmt.Sprintf("%s:%s", issue.SlideID, issue.Code))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, issue.Code)
|
||||
}
|
||||
if len(issues) > len(parts) {
|
||||
parts = append(parts, fmt.Sprintf("+%d more", len(issues)-len(parts)))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
var sparseLabelSplitRE = regexp.MustCompile(`[,\n\r;/|,、;::]+`)
|
||||
|
||||
func isSparseLabelList(content string) bool {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
rawTokens := sparseLabelSplitRE.Split(content, -1)
|
||||
tokens := make([]string, 0, len(rawTokens))
|
||||
totalRunes := 0
|
||||
richTokens := 0
|
||||
for _, raw := range rawTokens {
|
||||
token := strings.TrimSpace(strings.Trim(raw, "-•· \t"))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
totalRunes += len([]rune(token))
|
||||
if contentPayloadTokenHasExplanation(token) {
|
||||
richTokens++
|
||||
}
|
||||
}
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
avg := totalRunes / len(tokens)
|
||||
return avg <= 8 && richTokens < 2
|
||||
}
|
||||
|
||||
func contentPayloadTokenHasExplanation(token string) bool {
|
||||
for _, r := range token {
|
||||
if unicode.IsDigit(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"是", "为", "有", "由", "因", "使", "让", "会", "能", "把", "从", "到",
|
||||
"决定", "来自", "意味着", "用于", "形成", "影响", "体现", "because", "drives", "means",
|
||||
"%", "℃", "°", "ml", "g", "kg", "年", "月", "倍", "x",
|
||||
} {
|
||||
if strings.Contains(strings.ToLower(token), strings.ToLower(marker)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
175
internal/svglide/content_payload_test.go
Normal file
175
internal/svglide/content_payload_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestContentPayloadRejectsSparseLabelList(t *testing.T) {
|
||||
initContentPayloadTestRun(t, `{
|
||||
"prompt_contract": `+promptContractJSON(StageSlideContent)+`,
|
||||
"slides": [{
|
||||
"id": "02-tea-types",
|
||||
"content": "白茶\n绿茶\n黄茶\n乌龙\n红茶\n黑茶",
|
||||
"central_claim": "",
|
||||
"audience_takeaway": "",
|
||||
"supporting_points": [],
|
||||
"source_bound_facts": [],
|
||||
"source_refs": ["tea-source"],
|
||||
"visuals": [{"id": "tea_taxonomy", "type": "diagram", "instruction": "Six tea classes", "visual_form": "parameter_matrix"}],
|
||||
"so_what": ""
|
||||
}]
|
||||
}`)
|
||||
|
||||
report, err := EvaluateContentPayloadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"svglide.content_payload.sparse_label_list",
|
||||
"svglide.content_payload.missing_supporting_points",
|
||||
"svglide.content_payload.missing_source_bound_fact",
|
||||
"svglide.content_payload.visual_form_missing_data",
|
||||
} {
|
||||
if !contentPayloadIssueCodesContain(report.Issues, want) {
|
||||
t.Fatalf("issues = %+v, want %s", report.Issues, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPayloadPassesStructuredCultureSlide(t *testing.T) {
|
||||
initContentPayloadTestRun(t, `{
|
||||
"prompt_contract": `+promptContractJSON(StageSlideContent)+`,
|
||||
"slides": [{
|
||||
"id": "02-tea-types",
|
||||
"content": "白茶\n绿茶\n黄茶\n乌龙\n红茶\n黑茶",
|
||||
"central_claim": "六大茶类的核心差异来自氧化程度和工艺路径。",
|
||||
"audience_takeaway": "理解分类逻辑后,观众能把茶名、工艺和风味联系起来。",
|
||||
"supporting_points": [
|
||||
{"text": "绿茶通过杀青固定鲜爽风味,因此呈现低氧化特征。", "source_refs": ["tea-source"]},
|
||||
{"text": "乌龙茶处在半氧化区间,香气和焙火层次更复杂。", "source_refs": ["tea-source"]}
|
||||
],
|
||||
"source_bound_facts": [
|
||||
{"fact": "茶类划分和加工方式直接相关。", "source_ref": "tea-source", "usage": "evidence"}
|
||||
],
|
||||
"examples_or_parameters": [
|
||||
{"label": "氧化程度", "value": "低到高", "explanation": "用同一条尺度解释绿茶、乌龙、红茶的差异。", "source_ref": "tea-source"}
|
||||
],
|
||||
"visual_data_items": [
|
||||
{"label": "白茶", "role": "comparison", "explanation": "轻加工,适合放在低干预端。", "source_ref": "tea-source"},
|
||||
{"label": "绿茶", "role": "comparison", "explanation": "杀青保持鲜爽,氧化程度低。", "source_ref": "tea-source"},
|
||||
{"label": "黄茶", "role": "comparison", "explanation": "闷黄形成不同汤色和口感。", "source_ref": "tea-source"},
|
||||
{"label": "乌龙", "role": "comparison", "explanation": "半氧化形成花果香和焙火感。", "source_ref": "tea-source"},
|
||||
{"label": "红茶", "role": "comparison", "explanation": "较高氧化带来甜香和红汤。", "source_ref": "tea-source"},
|
||||
{"label": "黑茶", "role": "comparison", "explanation": "后发酵带来陈化和醇厚感。", "source_ref": "tea-source"}
|
||||
],
|
||||
"source_refs": ["tea-source"],
|
||||
"visuals": [{"id": "tea_taxonomy", "type": "diagram", "instruction": "Six tea classes", "visual_form": "parameter_matrix"}],
|
||||
"so_what": "这页应把分类从名词列表变成可理解的风味地图。"
|
||||
}]
|
||||
}`)
|
||||
|
||||
report, err := EvaluateContentPayloadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
if report.Metrics.SparseLabelListCount != 0 {
|
||||
t.Fatalf("sparse count = %d, want 0", report.Metrics.SparseLabelListCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPayloadRejectsUnknownSourceRefs(t *testing.T) {
|
||||
initContentPayloadTestRun(t, structuredContentPayloadSlideJSON("missing-source"))
|
||||
|
||||
report, err := EvaluateContentPayloadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !contentPayloadIssueCodesContain(report.Issues, "svglide.content_payload.unknown_source_ref") {
|
||||
t.Fatalf("issues = %+v, want unknown_source_ref", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPayloadRequiresVisualDataForDiagram(t *testing.T) {
|
||||
initContentPayloadTestRun(t, `{
|
||||
"prompt_contract": `+promptContractJSON(StageSlideContent)+`,
|
||||
"slides": [{
|
||||
"id": "03-process",
|
||||
"content": "采摘\n萎凋\n揉捻\n氧化\n干燥",
|
||||
"central_claim": "茶的风味是在连续加工步骤里逐渐形成的。",
|
||||
"audience_takeaway": "观众需要看到每一步如何改变茶叶状态,而不是只看到步骤名称。",
|
||||
"supporting_points": [
|
||||
{"text": "萎凋会改变叶片含水状态,为后续揉捻做准备。", "source_refs": ["tea-source"]},
|
||||
{"text": "氧化程度会影响汤色、香气和滋味表达。", "source_refs": ["tea-source"]}
|
||||
],
|
||||
"source_bound_facts": [
|
||||
{"fact": "加工步骤会影响茶叶最终品质。", "source_ref": "tea-source", "usage": "evidence"}
|
||||
],
|
||||
"visual_data_items": [],
|
||||
"source_refs": ["tea-source"],
|
||||
"visuals": [{"id": "craft_flow", "type": "diagram", "instruction": "Tea craft process", "visual_form": "process_flow"}],
|
||||
"so_what": "这页应解释工艺如何塑造风味,而不是画空流程线。"
|
||||
}]
|
||||
}`)
|
||||
|
||||
report, err := EvaluateContentPayloadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !contentPayloadIssueCodesContain(report.Issues, "svglide.content_payload.visual_form_missing_data") {
|
||||
t.Fatalf("issues = %+v, want visual_form_missing_data", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func initContentPayloadTestRun(t *testing.T, slideContent string) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"tea-source","path":"https://example.com/tea","title":"Tea source","excerpt":"Tea classification and process facts","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"品中国茶","slides":[{"id":"02-tea-types","title":"六大茶类","role":"content","summary":"分类","key_message":"分类来自工艺","path":"slides/02.svg"},{"id":"03-process","title":"工艺路径","role":"content","summary":"工艺","key_message":"工艺塑造风味","path":"slides/03.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", slideContent)
|
||||
}
|
||||
|
||||
func structuredContentPayloadSlideJSON(sourceRef string) string {
|
||||
return `{
|
||||
"prompt_contract": ` + promptContractJSON(StageSlideContent) + `,
|
||||
"slides": [{
|
||||
"id": "02-tea-types",
|
||||
"content": "白茶\n绿茶\n黄茶",
|
||||
"central_claim": "六大茶类的核心差异来自氧化程度和工艺路径。",
|
||||
"audience_takeaway": "理解分类逻辑后,观众能把茶名、工艺和风味联系起来。",
|
||||
"supporting_points": [
|
||||
{"text": "绿茶通过杀青固定鲜爽风味,因此呈现低氧化特征。", "source_refs": ["` + sourceRef + `"]},
|
||||
{"text": "乌龙茶处在半氧化区间,香气和焙火层次更复杂。", "source_refs": ["tea-source"]}
|
||||
],
|
||||
"source_bound_facts": [
|
||||
{"fact": "茶类划分和加工方式直接相关。", "source_ref": "tea-source", "usage": "evidence"}
|
||||
],
|
||||
"visual_data_items": [
|
||||
{"label": "白茶", "role": "comparison", "explanation": "轻加工,适合放在低干预端。", "source_ref": "tea-source"},
|
||||
{"label": "绿茶", "role": "comparison", "explanation": "杀青保持鲜爽,氧化程度低。", "source_ref": "tea-source"},
|
||||
{"label": "黄茶", "role": "comparison", "explanation": "闷黄形成不同汤色和口感。", "source_ref": "tea-source"}
|
||||
],
|
||||
"source_refs": ["tea-source"],
|
||||
"visuals": [{"id": "tea_taxonomy", "type": "diagram", "instruction": "Six tea classes", "visual_form": "parameter_matrix"}],
|
||||
"so_what": "这页应把分类从名词列表变成可理解的风味地图。"
|
||||
}]
|
||||
}`
|
||||
}
|
||||
|
||||
func contentPayloadIssueCodesContain(issues []ContentPayloadIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
1120
internal/svglide/creative_quality.go
Normal file
1120
internal/svglide/creative_quality.go
Normal file
File diff suppressed because it is too large
Load Diff
393
internal/svglide/creative_quality_test.go
Normal file
393
internal/svglide/creative_quality_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckCreativeQualityRejectsMissingVisualReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
|
||||
}
|
||||
if !creativeIssueCodesContain(report.Issues, "svglide.creative.missing_visual_receipts") {
|
||||
t.Fatalf("Issues = %+v, want missing_visual_receipts", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityWarnModeDowngradesHardFailures(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setRunVisualQualityModeForTest(t, VisualQualityModeWarn)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed in warn mode; report=%+v", report.Status, report)
|
||||
}
|
||||
if len(report.Issues) == 0 || report.Issues[0].Severity != "warning" {
|
||||
t.Fatalf("Issues = %+v, want warning issues", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsProcessLeakAndWeakTextBoxStack(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "card_stack", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540"/><rect rx="12" x="40" y="40" width="220" height="100"/><rect rx="12" x="300" y="40" width="220" height="100"/><rect rx="12" x="560" y="40" width="220" height="100"/><text x="48" y="90">接缝取色说明</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"card_stack","thumbnail_job":"cards","visual_center":"","topic_fit_claim":"","information_density_plan":"same","page_difference_from_previous":"same","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"stacked cards","data_visual_rationale":"","source_evidence":["web1"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
|
||||
}
|
||||
for _, code := range []string{"svglide.creative.process_leak", "svglide.creative.weak_slide"} {
|
||||
if !creativeIssueCodesContain(report.Issues, code) {
|
||||
t.Fatalf("Issues = %+v, want %s", report.Issues, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsDataVisualWithoutNumericEvidence(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "data_scoreboard", "scoreboard", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540"/><g class="chart"><rect x="48" y="200" width="100" height="200"/></g><text x="48" y="80">Scoreboard</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"proof","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"scoreboard","thumbnail_job":"score","visual_center":"score panel","topic_fit_claim":"shows data claim","information_density_plan":"one metric and one explanation","page_difference_from_previous":"first data page","primary_asset":"","asset_role":"data proof","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"data scoreboard","data_visual_rationale":"compare result shape","source_evidence":["match report"],"fusion_spec":{"enabled":false},"qa_expectations":["numeric evidence required"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || !creativeIssueCodesContain(report.Issues, "svglide.creative.chart_without_evidence") {
|
||||
t.Fatalf("report = %+v, want chart_without_evidence failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsPseudoTacticalDiagram(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "evidence_board", "pitch_lane_diagram", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f6f8fa"/><rect x="90" y="160" width="560" height="300" fill="#0B3D2E"/><path d="M120 230 C260 180,420 280,600 220" fill="none" stroke="#E8C15A" stroke-width="5"/><path d="M120 320 C260 270,420 370,600 310" fill="none" stroke="#E8C15A" stroke-width="5"/><text x="690" y="230">边路推进</text><text x="690" y="320">禁区终点</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"tactical_explanation","layout_family":"evidence_board","layout_archetype":"annotated_image","layout_signature":"pitch_diagram_with_forward_lanes","thumbnail_job":"terminal chain","visual_center":"pitch diagram showing lanes ending at the striker zone","topic_fit_claim":"explains a tactical system","information_density_plan":"one tactical map and lane labels","page_difference_from_previous":"different abstract tactical page","primary_asset":"tactical pitch diagram","asset_role":"system explanation graphic","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"pitch-map annotation using lines and coordinates","data_visual_rationale":"uses scoring facts to justify the diagram","source_evidence":["src_group_i includes match sequence","src_bio gives 62 goals in 54 appearances"],"container_fit_plan":"labels outside lanes","container_decision":"no cards","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"pitch_lane_diagram","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["tactical claim must remain tied to source-backed scoring evidence"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.PseudoAnalysisDiagramCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.pseudo_analysis_diagram") {
|
||||
t.Fatalf("report = %+v, want pseudo_analysis_diagram failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityAllowsSourceBoundTacticalDiagram(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "evidence_board", "pitch_lane_diagram", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f6f8fa"/><rect x="90" y="160" width="560" height="300" fill="#0B3D2E"/><path d="M120 230 C260 180,420 280,600 220" fill="none" stroke="#E8C15A" stroke-width="5"/><text x="690" y="230">79' right-channel pass</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"tactical_explanation","layout_family":"evidence_board","layout_archetype":"annotated_image","layout_signature":"pitch_diagram_with_forward_lanes","thumbnail_job":"terminal chain","visual_center":"pitch diagram showing source-bound right-channel sequence","topic_fit_claim":"explains a tactical system","information_density_plan":"one tactical map and lane labels","page_difference_from_previous":"different abstract tactical page","primary_asset":"tactical pitch diagram","asset_role":"system explanation graphic","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"pitch-map annotation using lines and coordinates","data_visual_rationale":"79' sequence explains the right-channel lane","source_evidence":["src_match: 79 minute right channel pass sequence into the penalty area"],"container_fit_plan":"labels outside lanes","container_decision":"no cards","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"pitch_lane_diagram","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["tactical geometry must bind to match source"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creativeIssueCodesContain(report.Issues, "svglide.creative.pseudo_analysis_diagram") {
|
||||
t.Fatalf("report = %+v, did not want pseudo_analysis_diagram failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityIgnoresNegativeDiagramGuardrail(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "open_warning_ledger_no_diagram", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#07111F"/><text x="72" y="120">Risk statement</text><text x="72" y="180">No fake tactical diagram is used here.</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"risk","layout_family":"quiet_synthesis","layout_archetype":"statement_ledger","layout_signature":"open_warning_ledger_no_diagram","thumbnail_job":"risk statement","visual_center":"open editorial statement without diagram","topic_fit_claim":"does not draw fake tactical geometry","information_density_plan":"one sourced risk claim and no fake map","page_difference_from_previous":"different open statement page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"not a diagram; text-led synthesis","data_visual_rationale":"","source_evidence":["src_group_i: France 4-1 Norway scoreline"],"container_fit_plan":"open text","container_decision":"no cards","text_carrier":"open_grid","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"open_warning_ledger_no_diagram","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["no fake tactical diagram"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creativeIssueCodesContain(report.Issues, "svglide.creative.pseudo_analysis_diagram") {
|
||||
t.Fatalf("report = %+v, did not want pseudo_analysis_diagram failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeQualityDetectsDefaultCardTextContainer(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "editorial_text", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f7f6f1"/><rect x="64" y="70" width="360" height="320" rx="24" fill="#101319"/><text x="96" y="140" fill="#fff">Athlete story</text><text x="96" y="202" fill="#fff">Every major claim is simply placed inside a rounded card.</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"single_claim_poster","layout_signature":"editorial_text","thumbnail_job":"text card","visual_center":"main text block","topic_fit_claim":"introduces the sports topic","information_density_plan":"one main claim and supporting explanation","page_difference_from_previous":"opening page with a text-led composition","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"plain text card for a simple claim","data_visual_rationale":"","source_evidence":["official athlete bio"],"fusion_spec":{"enabled":false},"qa_expectations":["use open editorial text when no panel is needed"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.DefaultCardTextContainerCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.default_card_text_container") {
|
||||
t.Fatalf("report = %+v, want default_card_text_container failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeQualityDetectsTopicTypographyMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "character_product_focus", "sports_profile", creativeQualityGoodSVG())
|
||||
if err := os.WriteFile(filepath.Join("demo", "brief", "typography_contract.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"profile":"sports_editorial","font_source":"slide_font_theme_presets","selected_moods":["culture_heritage"],"roles":{"display":{"family":"ChillJinshuSongMedium","weight":"700","size":"42","usage":"cover title"},"body":{"family":"Noto Serif SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Noto Sans SC","weight":"700","size":"34","usage":"scores"},"label":{"family":"ChillDuanHeiSong_CompactRegular","weight":"600","size":"13","usage":"labels"}},"rules":["sports deck typography should carry athletic score identity"]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"sports_profile","thumbnail_job":"sports profile","visual_center":"athlete profile and opening claim","topic_fit_claim":"matches the sports profile topic","information_density_plan":"one claim plus athlete context","page_difference_from_previous":"opening page","primary_asset":"assets/images/athlete.png","asset_role":"sports topic anchor","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"sports editorial profile","data_visual_rationale":"","source_evidence":["league profile"],"fusion_spec":{"enabled":false},"qa_expectations":["typography carries sports identity"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.TopicTypographyMismatchCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.typography.identity.profile_mismatch") {
|
||||
t.Fatalf("report = %+v, want typography profile mismatch failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityAppliesThemeLayoutRhythm(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/theme_contract.json", chineseTeaThemeContractJSONForTest())
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"single_claim_poster","layout_signature":"single_claim_poster","thumbnail_job":"opening","visual_center":"opening claim","topic_fit_claim":"introduces the tea topic","information_density_plan":"one opening claim","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"opening editorial poster","data_visual_rationale":"","source_evidence":["source"],"text_carrier":"open_grid","shape_language":"open editorial text","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"renderer":"none"},"fusion_spec":{"enabled":false},"qa_expectations":["theme rhythm applies"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for theme rhythm", report.Status)
|
||||
}
|
||||
for _, want := range []string{"svglide.creative.theme_min_slide_count", "svglide.creative.theme_required_page_role"} {
|
||||
if !creativeIssueCodesContain(report.Issues, want) {
|
||||
t.Fatalf("issues = %+v, want %s", report.Issues, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsRepeatedLayoutArchetype(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteRepeatedArchetypeDeck(t)
|
||||
mustWriteRepeatedArchetypeReceipts(t)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"svglide.creative.layout_archetype_overuse",
|
||||
"svglide.creative.adjacent_layout_archetype",
|
||||
"svglide.creative.left_right_chart_overuse",
|
||||
} {
|
||||
if !creativeIssueCodesContain(report.Issues, code) {
|
||||
t.Fatalf("Issues = %+v, want %s", report.Issues, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsRepeatedRenderedVisualSkeleton(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := authorDeck{Title: "Repeated Skeleton Deck"}
|
||||
receipts := visualReceiptsFile{}
|
||||
for i := 1; i <= 6; i++ {
|
||||
id := fmt.Sprintf("s%d", i)
|
||||
path := fmt.Sprintf("slides/%02d.svg", i)
|
||||
deck.Slides = append(deck.Slides, authorDeckSlide{
|
||||
ID: id,
|
||||
Title: fmt.Sprintf("Page %d", i),
|
||||
Summary: "Summary",
|
||||
Role: "content",
|
||||
KeyMessage: "Key message",
|
||||
LayoutFamily: "quiet_synthesis",
|
||||
LayoutArchetype: "poster_stat_lockup",
|
||||
LayoutSignature: fmt.Sprintf("unique_layout_%02d", i),
|
||||
StoryFunction: "proof",
|
||||
Path: path,
|
||||
})
|
||||
mustWriteTestFile(t, filepath.Join("demo", path), repeatedGenericNodeLineSVGForTest())
|
||||
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt(id, "quiet_synthesis", "poster_stat_lockup", fmt.Sprintf("unique_layout_%02d", i), "unique metadata but same rendered skeleton", "source evidence 2026"))
|
||||
}
|
||||
rawDeck, err := json.Marshal(deck)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawReceipts, err := json.Marshal(receipts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", string(rawDeck))
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", string(rawReceipts))
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.VisualSkeletonMaxRatioBP != 10000 || !creativeIssueCodesContain(report.Issues, "svglide.creative.visual_skeleton_repetition") {
|
||||
t.Fatalf("report = %+v, want visual_skeleton_repetition failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsVisualIntentMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "sensory_wheel_page", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f6f8fa"/><g data-svglide-visual-form="spectrum"><rect x="80" y="220" width="80" height="80"/><rect x="180" y="220" width="80" height="80"/><rect x="280" y="220" width="80" height="80"/><rect x="380" y="220" width="80" height="80"/></g><text x="80" y="120">Taste</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Taste wheel","source_refs":["source"],"visuals":[{"id":"taste","type":"diagram","visual_form":"sensory_wheel","instruction":"Render a sensory wheel"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"proof","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"sensory_wheel_page","thumbnail_job":"taste wheel","visual_center":"taste wheel","topic_fit_claim":"explains sensory dimensions","information_density_plan":"one sensory diagram","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"sensory wheel diagram","data_visual_rationale":"","source_evidence":["source evidence 2026"],"container_fit_plan":"open diagram labels","container_decision":"no cards","text_carrier":"line_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"diagram_sensory_wheel","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"renderer":"none"},"fusion_spec":{"enabled":false},"qa_expectations":["visual form must match"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.VisualIntentMismatchCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.visual_intent_mismatch") {
|
||||
t.Fatalf("report = %+v, want visual_intent_mismatch failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeQualityVisualFixtures(t *testing.T) {
|
||||
t.Chdir(filepath.Join("..", ".."))
|
||||
base := filepath.Join("testdata", "svglide", "visual_quality")
|
||||
weak, err := CheckCreativeQuality(filepath.Join(base, "germany_2026_weak_visual_run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if weak.Status != "failed" || !creativeIssueCodesContain(weak.Issues, "svglide.creative.weak_slide") || !creativeIssueCodesContain(weak.Issues, "svglide.creative.process_leak") {
|
||||
t.Fatalf("weak fixture report = %+v, want weak/process failure", weak)
|
||||
}
|
||||
good, err := CheckCreativeQuality(filepath.Join(base, "fusion_split_good_run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if good.Status != "passed" {
|
||||
t.Fatalf("fusion fixture report = %+v, want passed", good)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteCreativeQualityBaseDeck(t *testing.T, family, signature, svg string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Creative Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Opening key","layout_family":"`+family+`","layout_archetype":"`+inferAuthorLayoutArchetype(family, signature)+`","layout_signature":"`+signature+`","story_function":"hook","primary_asset_role":"topic anchor","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", svg)
|
||||
}
|
||||
|
||||
func mustWriteRepeatedArchetypeDeck(t *testing.T) {
|
||||
t.Helper()
|
||||
deck := authorDeck{Title: "Financial Deck"}
|
||||
deck.Slides = append(deck.Slides,
|
||||
authorDeckSlide{ID: "s1", Title: "Cover", Summary: "Cover", Role: "cover", KeyMessage: "Cover", LayoutFamily: "full_bleed_hero", LayoutArchetype: "full_bleed_photo_title", LayoutSignature: "chip_cover", StoryFunction: "hook", PrimaryAssetRole: "hero image", Path: "slides/01.svg"},
|
||||
)
|
||||
for i, title := range []string{"Executive summary", "Income", "Segment", "Margin", "Cash flow"} {
|
||||
page := i + 2
|
||||
deck.Slides = append(deck.Slides, authorDeckSlide{
|
||||
ID: fmt.Sprintf("s%d", page),
|
||||
Title: title,
|
||||
Summary: title,
|
||||
Role: "content",
|
||||
KeyMessage: title,
|
||||
LayoutFamily: "data_scoreboard",
|
||||
LayoutArchetype: "image_argument_split",
|
||||
LayoutSignature: fmt.Sprintf("left_text_right_chart_%d", page),
|
||||
StoryFunction: "proof",
|
||||
PrimaryAssetRole: "chart",
|
||||
Path: fmt.Sprintf("slides/%02d.svg", page),
|
||||
})
|
||||
}
|
||||
deck.Slides = append(deck.Slides,
|
||||
authorDeckSlide{ID: "s7", Title: "Close", Summary: "Close", Role: "close", KeyMessage: "Close", LayoutFamily: "quiet_synthesis", LayoutArchetype: "closing_poster", LayoutSignature: "closing_poster", StoryFunction: "synthesis", PrimaryAssetRole: "closing", Path: "slides/07.svg"},
|
||||
)
|
||||
raw, err := json.Marshal(deck)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", string(raw))
|
||||
for i := 1; i <= 7; i++ {
|
||||
body := `<rect width="960" height="540"/><text x="48" y="80">NVIDIA financial report</text>`
|
||||
if i >= 2 && i <= 6 {
|
||||
body += `<g class="chart"><rect x="600" y="160" width="220" height="160"/></g>`
|
||||
}
|
||||
mustWriteTestFile(t, fmt.Sprintf("demo/slides/%02d.svg", i), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+body+`</svg>`)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteRepeatedArchetypeReceipts(t *testing.T) {
|
||||
t.Helper()
|
||||
receipts := visualReceiptsFile{}
|
||||
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt("s1", "full_bleed_hero", "full_bleed_photo_title", "chip_cover", "cover", "NVIDIA image"))
|
||||
for i, label := range []string{"revenue $22.1B", "net income $12.3B", "data center $18.4B", "gross margin 76.0%", "free cash flow $11.2B"} {
|
||||
page := i + 2
|
||||
receipt := repeatedArchetypeReceipt(
|
||||
fmt.Sprintf("s%d", page),
|
||||
"data_scoreboard",
|
||||
"image_argument_split",
|
||||
fmt.Sprintf("left_text_right_chart_%d", page),
|
||||
"left text right chart",
|
||||
label,
|
||||
)
|
||||
receipt.DataVisualRationale = label
|
||||
receipts.Slides = append(receipts.Slides, receipt)
|
||||
}
|
||||
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt("s7", "quiet_synthesis", "closing_poster", "closing_poster", "closing", "NVIDIA report"))
|
||||
raw, err := json.Marshal(receipts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", string(raw))
|
||||
}
|
||||
|
||||
func repeatedArchetypeReceipt(slideID string, family string, archetype string, signature string, intent string, evidence string) visualReceipt {
|
||||
return visualReceipt{
|
||||
SlideID: slideID,
|
||||
StoryJob: "proof",
|
||||
LayoutFamily: family,
|
||||
LayoutArchetype: archetype,
|
||||
LayoutSignature: signature,
|
||||
ThumbnailJob: "thumbnail",
|
||||
VisualCenter: "visual center",
|
||||
TopicFitClaim: "topic fit",
|
||||
InformationDensityPlan: "one claim plus supporting visual",
|
||||
PageDifferenceFromPrevious: "different named page in sequence",
|
||||
PrimaryAsset: "chart.svg",
|
||||
AssetRole: "chart",
|
||||
FontRoleUsage: map[string]string{"display": "Inter", "body": "Aptos", "number": "Roboto Mono", "label": "Inter"},
|
||||
CompositionIntent: intent,
|
||||
SourceEvidence: []string{evidence},
|
||||
FusionSpec: visualFusionReceipt{Enabled: false},
|
||||
QAExpectations: []string{"vary layout"},
|
||||
}
|
||||
}
|
||||
|
||||
func creativeQualityGoodSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540"/><text x="48" y="80">Opening</text><text x="48" y="132">A focused claim</text></svg>`
|
||||
}
|
||||
|
||||
func repeatedGenericNodeLineSVGForTest() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540" fill="#f6f8fa"/><g data-svglide-visual-form="generic"><path d="M120 270 H840" stroke="#999"/><circle cx="180" cy="230" r="20"/><circle cx="340" cy="310" r="20"/><circle cx="500" cy="230" r="20"/><circle cx="660" cy="310" r="20"/><text x="80" y="120">Repeated generic diagram</text></g></svg>`
|
||||
}
|
||||
|
||||
func setRunVisualQualityModeForTest(t *testing.T, mode string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run.VisualQualityMode = mode
|
||||
if err := writeJSON(filepath.Join("demo", "run.json"), run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func creativeIssueCodesContain(issues []CreativeQualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want || strings.Contains(issue.Code, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
153
internal/svglide/creative_visual_skeleton.go
Normal file
153
internal/svglide/creative_visual_skeleton.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
svgVisualFormAttrPattern = regexp.MustCompile(`(?i)\bdata-svglide-visual-form\s*=\s*"([^"]+)"`)
|
||||
svgPathTagPattern = regexp.MustCompile(`(?is)<path\b`)
|
||||
svgLineTagPattern = regexp.MustCompile(`(?is)<line\b`)
|
||||
svgCircleTagPattern = regexp.MustCompile(`(?is)<circle\b`)
|
||||
svgEllipseTagPattern = regexp.MustCompile(`(?is)<ellipse\b`)
|
||||
)
|
||||
|
||||
type visualSkeletonSummary struct {
|
||||
FormHint string
|
||||
RectCount int
|
||||
PathCount int
|
||||
LineCount int
|
||||
CircleCount int
|
||||
EllipseCount int
|
||||
ImageCount int
|
||||
TextCount int
|
||||
}
|
||||
|
||||
func analyzeVisualSkeleton(svg string) visualSkeletonSummary {
|
||||
summary := visualSkeletonSummary{
|
||||
RectCount: len(svgRectTagForCreativePattern.FindAllStringIndex(svg, -1)),
|
||||
PathCount: len(svgPathTagPattern.FindAllStringIndex(svg, -1)),
|
||||
LineCount: len(svgLineTagPattern.FindAllStringIndex(svg, -1)),
|
||||
CircleCount: len(svgCircleTagPattern.FindAllStringIndex(svg, -1)),
|
||||
EllipseCount: len(svgEllipseTagPattern.FindAllStringIndex(svg, -1)),
|
||||
ImageCount: countSVGImageElements(svg),
|
||||
TextCount: len(svgTextBlockForCreativePattern.FindAllStringIndex(svg, -1)),
|
||||
}
|
||||
if match := svgVisualFormAttrPattern.FindStringSubmatch(svg); len(match) == 2 {
|
||||
summary.FormHint = normalizeAuthorVisualForm(match[1])
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func visualSkeletonSignature(summary visualSkeletonSummary) string {
|
||||
if summary.FormHint != "" {
|
||||
return "diagram:" + summary.FormHint + "|" + visualSkeletonGeometryToken(summary)
|
||||
}
|
||||
switch {
|
||||
case summary.ImageCount > 0 && summary.PathCount+summary.LineCount+summary.CircleCount+summary.EllipseCount >= 5:
|
||||
return "image_annotation|" + visualSkeletonGeometryToken(summary)
|
||||
case summary.ImageCount > 0:
|
||||
return "image_forward|" + visualSkeletonGeometryToken(summary)
|
||||
case summary.CircleCount >= 4 && summary.PathCount+summary.LineCount >= 4:
|
||||
return "node_line|" + visualSkeletonGeometryToken(summary)
|
||||
case summary.RectCount >= 8 && summary.PathCount+summary.LineCount >= 4:
|
||||
return "matrix_grid|" + visualSkeletonGeometryToken(summary)
|
||||
case summary.RectCount >= 4:
|
||||
return "rect_series|" + visualSkeletonGeometryToken(summary)
|
||||
case summary.PathCount+summary.LineCount >= 3:
|
||||
return "rule_path|" + visualSkeletonGeometryToken(summary)
|
||||
case summary.TextCount > 0:
|
||||
return "open_text|" + visualSkeletonGeometryToken(summary)
|
||||
default:
|
||||
return "minimal|" + visualSkeletonGeometryToken(summary)
|
||||
}
|
||||
}
|
||||
|
||||
func visualSkeletonGeometryToken(summary visualSkeletonSummary) string {
|
||||
return fmt.Sprintf("r%d-p%d-l%d-c%d-e%d-i%d-t%d",
|
||||
bucketVisualSkeletonCount(summary.RectCount),
|
||||
bucketVisualSkeletonCount(summary.PathCount),
|
||||
bucketVisualSkeletonCount(summary.LineCount),
|
||||
bucketVisualSkeletonCount(summary.CircleCount),
|
||||
bucketVisualSkeletonCount(summary.EllipseCount),
|
||||
bucketVisualSkeletonCount(summary.ImageCount),
|
||||
bucketVisualSkeletonCount(summary.TextCount),
|
||||
)
|
||||
}
|
||||
|
||||
func bucketVisualSkeletonCount(value int) int {
|
||||
switch {
|
||||
case value <= 0:
|
||||
return 0
|
||||
case value == 1:
|
||||
return 1
|
||||
case value <= 3:
|
||||
return 3
|
||||
case value <= 6:
|
||||
return 6
|
||||
default:
|
||||
return 9
|
||||
}
|
||||
}
|
||||
|
||||
func expectedSlideVisualForm(content authorSlideContent) string {
|
||||
for _, visual := range content.Visuals {
|
||||
if !requiresConcreteVisualForm(visual.Type) {
|
||||
continue
|
||||
}
|
||||
if form := normalizeAuthorVisualForm(visual.VisualForm); form != "" {
|
||||
return form
|
||||
}
|
||||
return authorVisualForm(visual, content)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func visualSkeletonMatchesForm(summary visualSkeletonSummary, expected string) bool {
|
||||
expected = normalizeAuthorVisualForm(expected)
|
||||
if expected == "" {
|
||||
return true
|
||||
}
|
||||
if summary.FormHint == expected {
|
||||
return true
|
||||
}
|
||||
switch expected {
|
||||
case authorVisualFormFourQuadrant:
|
||||
return summary.RectCount >= 1 && summary.PathCount+summary.LineCount >= 2 && summary.CircleCount >= 4
|
||||
case authorVisualFormSpectrum:
|
||||
return summary.RectCount >= 4 && summary.CircleCount <= 2
|
||||
case authorVisualFormMapRoute:
|
||||
return summary.PathCount >= 2 && summary.CircleCount >= 2
|
||||
case authorVisualFormProcessFlow:
|
||||
return summary.CircleCount >= 3 && summary.PathCount+summary.LineCount >= 3
|
||||
case authorVisualFormParameterMatrix:
|
||||
return summary.PathCount+summary.LineCount >= 4 && summary.RectCount >= 1
|
||||
case authorVisualFormSensoryWheel:
|
||||
return summary.CircleCount >= 4 && summary.PathCount+summary.LineCount >= 4
|
||||
case authorVisualFormObjectCallout:
|
||||
return summary.EllipseCount >= 1 && summary.PathCount+summary.LineCount >= 3
|
||||
case authorVisualFormGeneric:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRepeatedVisualSkeletonRisk(signature string) bool {
|
||||
return !containsAny(strings.ToLower(signature), []string{
|
||||
"open_text|",
|
||||
"minimal|",
|
||||
"image_forward|",
|
||||
})
|
||||
}
|
||||
|
||||
func repeatedVisualSkeletonRisk(counts map[string]int) bool {
|
||||
for signature, count := range counts {
|
||||
if count >= 3 && isRepeatedVisualSkeletonRisk(signature) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
184
internal/svglide/delivery_contract.go
Normal file
184
internal/svglide/delivery_contract.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DeliveryTargetLocalPreview = "local_preview"
|
||||
DeliveryTargetOnlineSlide = "online_slide"
|
||||
DeliveryTargetBoth = "both"
|
||||
|
||||
deliveryContractPath = "request/delivery_contract.json"
|
||||
)
|
||||
|
||||
type DeliveryContractFile struct {
|
||||
PromptContract StagePromptContract `json:"prompt_contract,omitempty"`
|
||||
DeliveryContract DeliveryContract `json:"delivery_contract"`
|
||||
}
|
||||
|
||||
type DeliveryContract struct {
|
||||
DeliveryTarget string `json:"delivery_target"`
|
||||
RequiresOnlineSlide bool `json:"requires_online_slide"`
|
||||
RequiresLocalPreview bool `json:"requires_local_preview"`
|
||||
RequiresRealImages bool `json:"requires_real_images"`
|
||||
Reason string `json:"reason"`
|
||||
DetectedSignals []string `json:"detected_signals"`
|
||||
}
|
||||
|
||||
func ResolveDeliveryContract(title string, topic string, explicitTarget string) DeliveryContract {
|
||||
raw := normalizeDeliveryContractText(title + " " + topic)
|
||||
target := normalizeDeliveryTarget(explicitTarget)
|
||||
if target == "" {
|
||||
target = DeliveryTargetLocalPreview
|
||||
}
|
||||
signals := deliverySignals(raw)
|
||||
onlineSignal := requestHasOnlineDeliverySignalText(raw)
|
||||
if explicitTarget == "" && onlineSignal {
|
||||
target = DeliveryTargetOnlineSlide
|
||||
}
|
||||
requiresOnline := target == DeliveryTargetOnlineSlide || target == DeliveryTargetBoth
|
||||
return DeliveryContract{
|
||||
DeliveryTarget: target,
|
||||
RequiresOnlineSlide: requiresOnline,
|
||||
RequiresLocalPreview: target == DeliveryTargetLocalPreview || target == DeliveryTargetBoth,
|
||||
RequiresRealImages: requestHasRealImageSignalText(raw),
|
||||
Reason: "resolved from explicit delivery target and request text",
|
||||
DetectedSignals: signals,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeDeliveryTarget(target string) string {
|
||||
return normalizeDeliveryTarget(target)
|
||||
}
|
||||
|
||||
func RequestHasOnlineDeliverySignal(title string, topic string) bool {
|
||||
return requestHasOnlineDeliverySignalText(normalizeDeliveryContractText(title + " " + topic))
|
||||
}
|
||||
|
||||
func DeliveryTargetConflictsWithOnlineSignal(title string, topic string, target string) bool {
|
||||
return normalizeDeliveryTarget(target) == DeliveryTargetLocalPreview && RequestHasOnlineDeliverySignal(title, topic)
|
||||
}
|
||||
|
||||
func normalizeDeliveryTarget(target string) string {
|
||||
switch strings.TrimSpace(target) {
|
||||
case "":
|
||||
return ""
|
||||
case DeliveryTargetLocalPreview:
|
||||
return DeliveryTargetLocalPreview
|
||||
case DeliveryTargetOnlineSlide:
|
||||
return DeliveryTargetOnlineSlide
|
||||
case DeliveryTargetBoth:
|
||||
return DeliveryTargetBoth
|
||||
default:
|
||||
return strings.TrimSpace(target)
|
||||
}
|
||||
}
|
||||
|
||||
func deliverySignals(raw string) []string {
|
||||
out := []string{}
|
||||
for _, token := range []string{
|
||||
"线上", "飞书", "lark", "feishu", "online", "share", "共享", "创建 slide", "创建slides",
|
||||
"真实", "实际", "图片", "照片", "论文", "paper", "report", "pdf",
|
||||
} {
|
||||
if strings.Contains(raw, strings.ToLower(token)) {
|
||||
out = appendUnique(out, token)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requestHasOnlineDeliverySignalText(raw string) bool {
|
||||
if containsAny(raw, []string{"不需要线上", "无需线上", "不要线上", "本地预览即可", "local preview only"}) {
|
||||
return false
|
||||
}
|
||||
return containsAny(raw, []string{
|
||||
"线上", "飞书", "lark", "feishu", "online", "share", "共享",
|
||||
"创建 slide", "创建slides", "真正创建", "线上 slide", "线上slides",
|
||||
"download the report as pdf", "下载为 pdf", "导出 pdf",
|
||||
})
|
||||
}
|
||||
|
||||
func requestHasRealImageSignalText(raw string) bool {
|
||||
if containsAny(raw, []string{"纯向量", "不要图片", "不使用图片", "no photos", "no images", "vector-only", "chart-only"}) {
|
||||
return false
|
||||
}
|
||||
return containsAny(raw, []string{
|
||||
"真实", "实际", "美观", "视觉冲击", "图片", "照片", "官网", "论文", "paper", "report",
|
||||
"公司", "品牌", "人物", "地点", "赛事", "产品", "financial report", "deep dive",
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeDeliveryContractText(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
return value
|
||||
}
|
||||
|
||||
func readDeliveryContract(safeRoot string, run Run) (DeliveryContract, bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, deliveryContractPath)
|
||||
if err != nil {
|
||||
return ResolveDeliveryContract(run.Title, run.Intent.Topic, run.DeliveryTarget), false, nil
|
||||
}
|
||||
var file DeliveryContractFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return DeliveryContract{}, true, fmt.Errorf("%s: invalid JSON: %w", deliveryContractPath, err)
|
||||
}
|
||||
contract := file.DeliveryContract
|
||||
if strings.TrimSpace(contract.DeliveryTarget) == "" {
|
||||
contract = ResolveDeliveryContract(run.Title, run.Intent.Topic, run.DeliveryTarget)
|
||||
}
|
||||
return contract, true, nil
|
||||
}
|
||||
|
||||
func ValidateDeliveryContractGate(safeRoot string) error {
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contract, _, err := readDeliveryContract(safeRoot, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch contract.DeliveryTarget {
|
||||
case DeliveryTargetLocalPreview, DeliveryTargetOnlineSlide, DeliveryTargetBoth:
|
||||
default:
|
||||
return fmt.Errorf("delivery_contract_gate: unsupported delivery_target %q", contract.DeliveryTarget)
|
||||
}
|
||||
if contract.DeliveryTarget == DeliveryTargetLocalPreview && RequestHasOnlineDeliverySignal(run.Title, run.Intent.Topic) {
|
||||
return fmt.Errorf("delivery_contract_gate: request asks for online delivery but delivery_target is local_preview")
|
||||
}
|
||||
if contract.RequiresOnlineSlide && contract.DeliveryTarget == DeliveryTargetLocalPreview {
|
||||
return fmt.Errorf("delivery_contract_gate: requires_online_slide=true conflicts with local_preview target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDeliveryContractFile(writeRoot string, opts InitOptions) error {
|
||||
contract := ResolveDeliveryContract(opts.Title, opts.Topic, opts.DeliveryTarget)
|
||||
promptContract, err := promptContractForInitArtifact(StageRequestResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(filepath.Join(writeRoot, deliveryContractPath), DeliveryContractFile{
|
||||
PromptContract: promptContract,
|
||||
DeliveryContract: contract,
|
||||
})
|
||||
}
|
||||
|
||||
func promptContractForInitArtifact(stage string) (StagePromptContract, error) {
|
||||
requiredPromptIDs, err := CorePromptIDsForProfile(RouteProfileLocalSVGDeck)
|
||||
if err != nil {
|
||||
return StagePromptContract{}, err
|
||||
}
|
||||
return StagePromptContract{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Stage: stage,
|
||||
Orchestrator: "mode_system_prompt_svg",
|
||||
ProtocolReference: "svg_reference",
|
||||
RequiredPromptIDs: requiredPromptIDs,
|
||||
}, nil
|
||||
}
|
||||
57
internal/svglide/delivery_contract_test.go
Normal file
57
internal/svglide/delivery_contract_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDeliveryContractDetectsOnlineSlide(t *testing.T) {
|
||||
got := ResolveDeliveryContract("DeepSeek V4", "生成真实美观的线上 svg ppt", "")
|
||||
if got.DeliveryTarget != DeliveryTargetOnlineSlide {
|
||||
t.Fatalf("DeliveryTarget = %q, want %q", got.DeliveryTarget, DeliveryTargetOnlineSlide)
|
||||
}
|
||||
if !got.RequiresOnlineSlide {
|
||||
t.Fatal("RequiresOnlineSlide = false, want true")
|
||||
}
|
||||
if !got.RequiresRealImages {
|
||||
t.Fatal("RequiresRealImages = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryContractAllowsExplicitLocalPreview(t *testing.T) {
|
||||
got := ResolveDeliveryContract("Demo", "生成本地 SVG preview,不需要线上创建", DeliveryTargetLocalPreview)
|
||||
if got.DeliveryTarget != DeliveryTargetLocalPreview {
|
||||
t.Fatalf("DeliveryTarget = %q, want %q", got.DeliveryTarget, DeliveryTargetLocalPreview)
|
||||
}
|
||||
if got.RequiresOnlineSlide {
|
||||
t.Fatal("RequiresOnlineSlide = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryTargetConflictsWithOnlineSignal(t *testing.T) {
|
||||
if !DeliveryTargetConflictsWithOnlineSignal("Demo", "请创建线上飞书 slide", DeliveryTargetLocalPreview) {
|
||||
t.Fatal("conflict = false, want true")
|
||||
}
|
||||
if DeliveryTargetConflictsWithOnlineSignal("Demo", "本地预览即可,不需要线上创建", DeliveryTargetLocalPreview) {
|
||||
t.Fatal("conflict = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDeliveryContractUsesManifestPromptIDs(t *testing.T) {
|
||||
contract, err := promptContractForInitArtifact(StageRequestResolution)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := CorePromptIDsForProfile(RouteProfileLocalSVGDeck)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(contract.RequiredPromptIDs, want) {
|
||||
t.Fatalf("RequiredPromptIDs = %v, want manifest-derived %v", contract.RequiredPromptIDs, want)
|
||||
}
|
||||
for _, id := range []string{"mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding", "svglide_visual_quality_overlay", "slide_font_catalog"} {
|
||||
if !slices.Contains(contract.RequiredPromptIDs, id) {
|
||||
t.Fatalf("RequiredPromptIDs missing %q: %v", id, contract.RequiredPromptIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
229
internal/svglide/editorial_quality.go
Normal file
229
internal/svglide/editorial_quality.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const editorialQualityReportPath = "receipts/editorial_quality.json"
|
||||
|
||||
type EditorialQualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Score int `json:"score"`
|
||||
Metrics EditorialQualityMetrics `json:"metrics"`
|
||||
Issues []EditorialQualityIssue `json:"issues"`
|
||||
Target editorialQualityTarget `json:"target"`
|
||||
}
|
||||
|
||||
type EditorialQualityMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
MediaPressureIssueCount int `json:"media_pressure_issue_count"`
|
||||
DominantRealImagePages int `json:"dominant_real_image_pages"`
|
||||
MaxConsecutiveInfographicPages int `json:"max_consecutive_infographic_pages"`
|
||||
CardDominantRatioBP int `json:"card_dominant_ratio_bp"`
|
||||
ShapeLanguageMaxRatioBP int `json:"shape_language_max_ratio_bp"`
|
||||
CreativeErrorCount int `json:"creative_error_count"`
|
||||
ContentPayloadIssueCount int `json:"content_payload_issue_count"`
|
||||
SparseLabelListCount int `json:"sparse_label_list_count"`
|
||||
MissingEvidencePayloadCount int `json:"missing_evidence_payload_count"`
|
||||
MissingVisualDataItemsCount int `json:"missing_visual_data_items_count"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
}
|
||||
|
||||
type EditorialQualityIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type editorialQualityTarget struct {
|
||||
MinimumScore int `json:"minimum_score"`
|
||||
RequireMediaPressurePassed bool `json:"require_media_pressure_passed"`
|
||||
RequireCoverDominantRealImage bool `json:"require_cover_dominant_real_image"`
|
||||
MaxConsecutiveInfographicOnlyPages int `json:"max_consecutive_infographic_only_pages"`
|
||||
MaxCardDominantRatioBP int `json:"max_card_dominant_ratio_bp"`
|
||||
MaxShapeLanguageMaxRatioBP int `json:"max_shape_language_max_ratio_bp"`
|
||||
}
|
||||
|
||||
func EvaluateEditorialQualityRun(contract qualityVisualContract, media MediaPressureReport, creative CreativeQualityReport, contentPayloadReports ...ContentPayloadReport) EditorialQualityReport {
|
||||
target := resolveEditorialQualityTarget(contract)
|
||||
contentPayload := ContentPayloadReport{}
|
||||
if len(contentPayloadReports) > 0 {
|
||||
contentPayload = contentPayloadReports[0]
|
||||
}
|
||||
report := EditorialQualityReport{
|
||||
Status: "passed",
|
||||
Score: 100,
|
||||
Metrics: editorialMetrics(media, creative, contentPayload),
|
||||
Issues: []EditorialQualityIssue{},
|
||||
Target: target,
|
||||
}
|
||||
if target.RequireMediaPressurePassed && media.Status != "passed" {
|
||||
report.Score -= 35
|
||||
addEditorialIssue(&report, mediaPressureReportPath, "svglide.editorial_quality.media_pressure", "media pressure must pass before the deck can be considered visually ready")
|
||||
}
|
||||
if target.RequireCoverDominantRealImage && media.Metrics.CoverDominantRealImagePages == 0 {
|
||||
report.Score -= 25
|
||||
addEditorialIssue(&report, mediaPressureReportPath, "svglide.editorial_quality.cover_hero", "topic archetype requires a cover-level dominant real image")
|
||||
}
|
||||
if target.MaxConsecutiveInfographicOnlyPages > 0 && media.Metrics.MaxConsecutiveInfographicPages > target.MaxConsecutiveInfographicOnlyPages {
|
||||
report.Score -= 20
|
||||
addEditorialIssue(&report, mediaPressureReportPath, "svglide.editorial_quality.infographic_run", fmt.Sprintf("consecutive infographic-only run is %d, want <= %d", media.Metrics.MaxConsecutiveInfographicPages, target.MaxConsecutiveInfographicOnlyPages))
|
||||
}
|
||||
if target.MaxCardDominantRatioBP > 0 && report.Metrics.CardDominantRatioBP > target.MaxCardDominantRatioBP {
|
||||
report.Score -= 15
|
||||
addEditorialIssue(&report, creativeQualityReportPath, "svglide.editorial_quality.card_overuse", fmt.Sprintf("card-dominant slide ratio is %d bp, want <= %d bp", report.Metrics.CardDominantRatioBP, target.MaxCardDominantRatioBP))
|
||||
}
|
||||
if target.MaxShapeLanguageMaxRatioBP > 0 && report.Metrics.ShapeLanguageMaxRatioBP > target.MaxShapeLanguageMaxRatioBP {
|
||||
report.Score -= 15
|
||||
addEditorialIssue(&report, creativeQualityReportPath, "svglide.editorial_quality.shape_language_overuse", fmt.Sprintf("shape language max ratio is %d bp, want <= %d bp", report.Metrics.ShapeLanguageMaxRatioBP, target.MaxShapeLanguageMaxRatioBP))
|
||||
}
|
||||
if creative.Status != "passed" {
|
||||
report.Score -= minPositive(creativeErrorCount(creative)*5, 25)
|
||||
}
|
||||
if contentPayload.Metrics.IssueCount > 0 {
|
||||
report.Score -= minPositive(contentPayload.Metrics.IssueCount*6, 30)
|
||||
if contentPayload.Metrics.SparseLabelListCount > 0 {
|
||||
addEditorialIssue(&report, contentPayloadReportPath, "svglide.editorial.content_sparse_label_list", fmt.Sprintf("sparse label-list slides: %d", contentPayload.Metrics.SparseLabelListCount))
|
||||
}
|
||||
if contentPayload.Metrics.MissingCentralClaimCount+contentPayload.Metrics.MissingSupportingPointsCount+contentPayload.Metrics.MissingSourceBoundFactCount+contentPayload.Metrics.SourceBindingIssueCount > 0 {
|
||||
addEditorialIssue(&report, contentPayloadReportPath, "svglide.editorial.content_missing_evidence_payload", "substantive slides need central claims, supporting points, and source-bound facts")
|
||||
}
|
||||
if contentPayload.Metrics.MissingVisualDataItemsCount > 0 {
|
||||
addEditorialIssue(&report, contentPayloadReportPath, "svglide.editorial.content_visual_data_mismatch", fmt.Sprintf("visual forms missing data items: %d", contentPayload.Metrics.MissingVisualDataItemsCount))
|
||||
}
|
||||
}
|
||||
if target.MinimumScore > 0 && report.Score < target.MinimumScore {
|
||||
addEditorialIssue(&report, editorialQualityReportPath, "svglide.editorial_quality.score", fmt.Sprintf("editorial quality score is %d, want >= %d", report.Score, target.MinimumScore))
|
||||
}
|
||||
if report.Score < 0 {
|
||||
report.Score = 0
|
||||
}
|
||||
report.Metrics.IssueCount = len(report.Issues)
|
||||
if report.Metrics.IssueCount > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func EvaluateEditorialQualityExecutionFailure(contract qualityVisualContract, media MediaPressureReport, err error) EditorialQualityReport {
|
||||
target := resolveEditorialQualityTarget(contract)
|
||||
report := EditorialQualityReport{
|
||||
Status: "failed",
|
||||
Score: 0,
|
||||
Metrics: EditorialQualityMetrics{
|
||||
Slides: media.Metrics.Slides,
|
||||
MediaPressureIssueCount: media.Metrics.IssueCount,
|
||||
DominantRealImagePages: media.Metrics.DominantRealImagePages,
|
||||
MaxConsecutiveInfographicPages: media.Metrics.MaxConsecutiveInfographicPages,
|
||||
CreativeErrorCount: 1,
|
||||
IssueCount: 1,
|
||||
},
|
||||
Issues: []EditorialQualityIssue{{
|
||||
Code: "svglide.editorial_quality.creative_unavailable",
|
||||
Path: creativeQualityReportPath,
|
||||
Message: err.Error(),
|
||||
Severity: "error",
|
||||
}},
|
||||
Target: target,
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func writeEditorialQualityReport(safeRoot string, report EditorialQualityReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, editorialQualityReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func resolveEditorialQualityTarget(contract qualityVisualContract) editorialQualityTarget {
|
||||
target := defaultEditorialQualityTarget(strings.TrimSpace(contract.TopicArchetype))
|
||||
explicit := contract.EditorialQualityTarget
|
||||
if explicit.MinimumScore > 0 {
|
||||
target.MinimumScore = explicit.MinimumScore
|
||||
}
|
||||
if explicit.RequireMediaPressurePassed {
|
||||
target.RequireMediaPressurePassed = true
|
||||
}
|
||||
if explicit.RequireCoverDominantRealImage {
|
||||
target.RequireCoverDominantRealImage = true
|
||||
}
|
||||
if explicit.MaxConsecutiveInfographicOnlyPages > 0 {
|
||||
target.MaxConsecutiveInfographicOnlyPages = explicit.MaxConsecutiveInfographicOnlyPages
|
||||
}
|
||||
if explicit.MaxCardDominantRatioBP > 0 {
|
||||
target.MaxCardDominantRatioBP = explicit.MaxCardDominantRatioBP
|
||||
}
|
||||
if explicit.MaxShapeLanguageMaxRatioBP > 0 {
|
||||
target.MaxShapeLanguageMaxRatioBP = explicit.MaxShapeLanguageMaxRatioBP
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func defaultEditorialQualityTarget(archetype string) editorialQualityTarget {
|
||||
switch archetype {
|
||||
case "financial_company_report", "named_company_report":
|
||||
return editorialQualityTarget{MinimumScore: 75, RequireMediaPressurePassed: true, RequireCoverDominantRealImage: true, MaxConsecutiveInfographicOnlyPages: 3, MaxCardDominantRatioBP: 5000, MaxShapeLanguageMaxRatioBP: 8000}
|
||||
case "premium_product_brand", "brand_official_site":
|
||||
return editorialQualityTarget{MinimumScore: 80, RequireMediaPressurePassed: true, RequireCoverDominantRealImage: true, MaxConsecutiveInfographicOnlyPages: 2, MaxCardDominantRatioBP: 3500, MaxShapeLanguageMaxRatioBP: 7500}
|
||||
case "sports_editorial", "event_editorial":
|
||||
return editorialQualityTarget{MinimumScore: 78, RequireMediaPressurePassed: true, RequireCoverDominantRealImage: true, MaxConsecutiveInfographicOnlyPages: 2, MaxCardDominantRatioBP: 4000, MaxShapeLanguageMaxRatioBP: 7000}
|
||||
default:
|
||||
return editorialQualityTarget{}
|
||||
}
|
||||
}
|
||||
|
||||
func editorialMetrics(media MediaPressureReport, creative CreativeQualityReport, contentPayload ContentPayloadReport) EditorialQualityMetrics {
|
||||
slides := media.Metrics.Slides
|
||||
if slides == 0 {
|
||||
slides = creative.Metrics.Slides
|
||||
}
|
||||
cardRatio := 0
|
||||
if creative.Metrics.Slides > 0 {
|
||||
cardRatio = creative.Metrics.CardDominantSlideCount * 10000 / creative.Metrics.Slides
|
||||
}
|
||||
return EditorialQualityMetrics{
|
||||
Slides: slides,
|
||||
MediaPressureIssueCount: media.Metrics.IssueCount,
|
||||
DominantRealImagePages: media.Metrics.DominantRealImagePages,
|
||||
MaxConsecutiveInfographicPages: media.Metrics.MaxConsecutiveInfographicPages,
|
||||
CardDominantRatioBP: cardRatio,
|
||||
ShapeLanguageMaxRatioBP: creative.Metrics.ShapeLanguageMaxRatioBP,
|
||||
CreativeErrorCount: creativeErrorCount(creative),
|
||||
ContentPayloadIssueCount: contentPayload.Metrics.IssueCount,
|
||||
SparseLabelListCount: contentPayload.Metrics.SparseLabelListCount,
|
||||
MissingEvidencePayloadCount: contentPayload.Metrics.MissingCentralClaimCount + contentPayload.Metrics.MissingSupportingPointsCount + contentPayload.Metrics.MissingSourceBoundFactCount + contentPayload.Metrics.SourceBindingIssueCount,
|
||||
MissingVisualDataItemsCount: contentPayload.Metrics.MissingVisualDataItemsCount,
|
||||
}
|
||||
}
|
||||
|
||||
func creativeErrorCount(report CreativeQualityReport) int {
|
||||
count := 0
|
||||
for _, issue := range report.Issues {
|
||||
if issue.Severity == "error" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func addEditorialIssue(report *EditorialQualityReport, path, code, message string) {
|
||||
report.Issues = append(report.Issues, EditorialQualityIssue{
|
||||
Code: code,
|
||||
Path: path,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
})
|
||||
}
|
||||
|
||||
func isZeroEditorialQualityTarget(target editorialQualityTarget) bool {
|
||||
return target.MinimumScore == 0 &&
|
||||
!target.RequireMediaPressurePassed &&
|
||||
!target.RequireCoverDominantRealImage &&
|
||||
target.MaxConsecutiveInfographicOnlyPages == 0 &&
|
||||
target.MaxCardDominantRatioBP == 0 &&
|
||||
target.MaxShapeLanguageMaxRatioBP == 0
|
||||
}
|
||||
118
internal/svglide/editorial_quality_test.go
Normal file
118
internal/svglide/editorial_quality_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditorialQualityRejectsPassedMechanicalGateWithoutMediaPressure(t *testing.T) {
|
||||
contract := qualityVisualContract{TopicArchetype: "financial_company_report"}
|
||||
media := MediaPressureReport{
|
||||
Status: "failed",
|
||||
Metrics: MediaPressureMetrics{Slides: 5, IssueCount: 2, DominantRealImagePages: 0, CoverDominantRealImagePages: 0, MaxConsecutiveInfographicPages: 5},
|
||||
Issues: []MediaPressureIssue{{Code: "svglide.media_pressure.cover_dominant_real_image", Severity: "error"}},
|
||||
Policy: defaultMediaPressurePolicy("financial_company_report", 5),
|
||||
}
|
||||
creative := CreativeQualityReport{
|
||||
Status: "passed",
|
||||
Metrics: CreativeQualityMetrics{Slides: 5, ShapeLanguageMaxRatioBP: 10000},
|
||||
}
|
||||
|
||||
report := EvaluateEditorialQualityRun(contract, media, creative)
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !editorialIssueCodesContain(report.Issues, "svglide.editorial_quality.media_pressure") {
|
||||
t.Fatalf("issues = %+v, want media pressure issue", report.Issues)
|
||||
}
|
||||
if !editorialIssueCodesContain(report.Issues, "svglide.editorial_quality.cover_hero") {
|
||||
t.Fatalf("issues = %+v, want cover hero issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorialQualityPassesWhenArchetypeFloorIsMet(t *testing.T) {
|
||||
contract := qualityVisualContract{TopicArchetype: "sports_editorial"}
|
||||
media := MediaPressureReport{
|
||||
Status: "passed",
|
||||
Metrics: MediaPressureMetrics{Slides: 8, IssueCount: 0, DominantRealImagePages: 3, CoverDominantRealImagePages: 1, MaxConsecutiveInfographicPages: 2},
|
||||
Policy: defaultMediaPressurePolicy("sports_editorial", 8),
|
||||
}
|
||||
creative := CreativeQualityReport{
|
||||
Status: "passed",
|
||||
Metrics: CreativeQualityMetrics{Slides: 8, CardDominantSlideCount: 2, ShapeLanguageMaxRatioBP: 6250},
|
||||
}
|
||||
|
||||
report := EvaluateEditorialQualityRun(contract, media, creative)
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
if report.Score < report.Target.MinimumScore {
|
||||
t.Fatalf("score = %d, want >= %d", report.Score, report.Target.MinimumScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorialQualityExecutionFailureWritesActionableIssue(t *testing.T) {
|
||||
contract := qualityVisualContract{TopicArchetype: "financial_company_report"}
|
||||
media := MediaPressureReport{
|
||||
Status: "passed",
|
||||
Metrics: MediaPressureMetrics{Slides: 3, IssueCount: 0, DominantRealImagePages: 2, CoverDominantRealImagePages: 1},
|
||||
}
|
||||
|
||||
report := EvaluateEditorialQualityExecutionFailure(contract, media, errors.New("creative report missing"))
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Metrics.CreativeErrorCount != 1 {
|
||||
t.Fatalf("creative errors = %d, want 1", report.Metrics.CreativeErrorCount)
|
||||
}
|
||||
if !editorialIssueCodesContain(report.Issues, "svglide.editorial_quality.creative_unavailable") {
|
||||
t.Fatalf("issues = %+v, want creative unavailable issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorialQualityFailsContentPayloadIssues(t *testing.T) {
|
||||
contract := qualityVisualContract{}
|
||||
media := MediaPressureReport{
|
||||
Status: "passed",
|
||||
Metrics: MediaPressureMetrics{Slides: 3, IssueCount: 0},
|
||||
}
|
||||
creative := CreativeQualityReport{
|
||||
Status: "passed",
|
||||
Metrics: CreativeQualityMetrics{Slides: 3},
|
||||
}
|
||||
payload := ContentPayloadReport{
|
||||
Status: "failed",
|
||||
Metrics: ContentPayloadMetrics{
|
||||
Slides: 3,
|
||||
SubstantiveSlides: 2,
|
||||
SparseLabelListCount: 1,
|
||||
MissingSupportingPointsCount: 1,
|
||||
MissingSourceBoundFactCount: 1,
|
||||
MissingVisualDataItemsCount: 1,
|
||||
IssueCount: 4,
|
||||
},
|
||||
}
|
||||
|
||||
report := EvaluateEditorialQualityRun(contract, media, creative, payload)
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Metrics.SparseLabelListCount != 1 {
|
||||
t.Fatalf("sparse count = %d, want 1", report.Metrics.SparseLabelListCount)
|
||||
}
|
||||
if !editorialIssueCodesContain(report.Issues, "svglide.editorial.content_sparse_label_list") {
|
||||
t.Fatalf("issues = %+v, want content_sparse_label_list", report.Issues)
|
||||
}
|
||||
if !editorialIssueCodesContain(report.Issues, "svglide.editorial.content_visual_data_mismatch") {
|
||||
t.Fatalf("issues = %+v, want content_visual_data_mismatch", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func editorialIssueCodesContain(issues []EditorialQualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
172
internal/svglide/font_catalog.go
Normal file
172
internal/svglide/font_catalog.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
slideSupportedFontsPath = "font_catalog/slide_supported_fonts.json"
|
||||
slideFontThemePresetsPath = "font_catalog/slide_font_theme_presets.json"
|
||||
typographyFontSourcePreset = "slide_font_theme_presets"
|
||||
)
|
||||
|
||||
//go:embed font_catalog/slide_supported_fonts.json font_catalog/slide_font_theme_presets.json font_catalog/slide_font_tags.json
|
||||
var slideFontCatalogFS embed.FS
|
||||
|
||||
type slideSupportedFontsFile struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Counts struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"counts"`
|
||||
Fonts []slideSupportedFont `json:"fonts"`
|
||||
}
|
||||
|
||||
type slideSupportedFont struct {
|
||||
FontFamily string `json:"font_family"`
|
||||
Source string `json:"source"`
|
||||
Lang string `json:"lang"`
|
||||
Display map[string]string `json:"display_name"`
|
||||
}
|
||||
|
||||
type slideFontThemePresetsFile struct {
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
VisualMoodPresets map[string]slideFontMoodPreset `json:"visual_mood_presets"`
|
||||
}
|
||||
|
||||
type slideFontMoodPreset struct {
|
||||
Themes []string `json:"themes"`
|
||||
Intent string `json:"intent"`
|
||||
Roles map[string][]string `json:"roles"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type typographyCatalogValidation struct {
|
||||
MissingSource bool
|
||||
MissingSelectedMood bool
|
||||
UnknownMoods []string
|
||||
UnsupportedRoles map[string]string
|
||||
StackRoles map[string]string
|
||||
PresetMismatchRoles map[string]string
|
||||
}
|
||||
|
||||
func loadSlideSupportedFonts() (slideSupportedFontsFile, error) {
|
||||
raw, err := slideFontCatalogFS.ReadFile(slideSupportedFontsPath)
|
||||
if err != nil {
|
||||
return slideSupportedFontsFile{}, err
|
||||
}
|
||||
var catalog slideSupportedFontsFile
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
return slideSupportedFontsFile{}, fmt.Errorf("%s: invalid JSON: %w", slideSupportedFontsPath, err)
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func loadSlideFontThemePresets() (slideFontThemePresetsFile, error) {
|
||||
raw, err := slideFontCatalogFS.ReadFile(slideFontThemePresetsPath)
|
||||
if err != nil {
|
||||
return slideFontThemePresetsFile{}, err
|
||||
}
|
||||
var presets slideFontThemePresetsFile
|
||||
if err := json.Unmarshal(raw, &presets); err != nil {
|
||||
return slideFontThemePresetsFile{}, fmt.Errorf("%s: invalid JSON: %w", slideFontThemePresetsPath, err)
|
||||
}
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
func validateTypographyAgainstFontCatalog(contract typographyContractFile) (typographyCatalogValidation, error) {
|
||||
catalog, err := loadSlideSupportedFonts()
|
||||
if err != nil {
|
||||
return typographyCatalogValidation{}, err
|
||||
}
|
||||
presets, err := loadSlideFontThemePresets()
|
||||
if err != nil {
|
||||
return typographyCatalogValidation{}, err
|
||||
}
|
||||
knownFonts := make(map[string]bool, len(catalog.Fonts))
|
||||
for _, font := range catalog.Fonts {
|
||||
if strings.TrimSpace(font.FontFamily) != "" {
|
||||
knownFonts[font.FontFamily] = true
|
||||
}
|
||||
}
|
||||
|
||||
result := typographyCatalogValidation{
|
||||
UnsupportedRoles: map[string]string{},
|
||||
StackRoles: map[string]string{},
|
||||
PresetMismatchRoles: map[string]string{},
|
||||
}
|
||||
if strings.TrimSpace(contract.FontSource) != typographyFontSourcePreset {
|
||||
result.MissingSource = true
|
||||
}
|
||||
selectedMoods := nonEmptyStrings(contract.SelectedMoods)
|
||||
if len(selectedMoods) == 0 {
|
||||
result.MissingSelectedMood = true
|
||||
}
|
||||
allowedByRole := map[string]map[string]bool{}
|
||||
for _, mood := range selectedMoods {
|
||||
preset, ok := presets.VisualMoodPresets[mood]
|
||||
if !ok {
|
||||
result.UnknownMoods = append(result.UnknownMoods, mood)
|
||||
continue
|
||||
}
|
||||
for role, candidates := range preset.Roles {
|
||||
if allowedByRole[role] == nil {
|
||||
allowedByRole[role] = map[string]bool{}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
allowedByRole[role][candidate] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(result.UnknownMoods)
|
||||
|
||||
for _, role := range []string{"display", "body", "number", "label"} {
|
||||
font := contract.Roles[role]
|
||||
family := strings.TrimSpace(font.Family)
|
||||
if strings.Contains(family, ",") {
|
||||
result.StackRoles[role] = family
|
||||
continue
|
||||
}
|
||||
if !knownFonts[family] {
|
||||
result.UnsupportedRoles[role] = family
|
||||
continue
|
||||
}
|
||||
if len(selectedMoods) > 0 && len(result.UnknownMoods) == 0 {
|
||||
allowed := allowedByRole[role]
|
||||
if len(allowed) > 0 && !allowed[family] {
|
||||
result.PresetMismatchRoles[role] = family
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (v typographyCatalogValidation) OK() bool {
|
||||
return !v.MissingSource &&
|
||||
!v.MissingSelectedMood &&
|
||||
len(v.UnknownMoods) == 0 &&
|
||||
len(v.UnsupportedRoles) == 0 &&
|
||||
len(v.StackRoles) == 0 &&
|
||||
len(v.PresetMismatchRoles) == 0
|
||||
}
|
||||
|
||||
func sortedRoleFontPairs(values map[string]string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%q", key, values[key]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
1837
internal/svglide/font_catalog/slide_font_specimen.html
Normal file
1837
internal/svglide/font_catalog/slide_font_specimen.html
Normal file
File diff suppressed because it is too large
Load Diff
3566
internal/svglide/font_catalog/slide_font_tags.json
Normal file
3566
internal/svglide/font_catalog/slide_font_tags.json
Normal file
File diff suppressed because it is too large
Load Diff
208
internal/svglide/font_catalog/slide_font_theme_presets.json
Normal file
208
internal/svglide/font_catalog/slide_font_theme_presets.json
Normal file
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"version": "2026-07-07",
|
||||
"status": "initial",
|
||||
"source_font_catalog": "internal/svglide/font_catalog/slide_supported_fonts.json",
|
||||
"principles": [
|
||||
"Only use canonical Slide font_family values from slide_supported_fonts.json.",
|
||||
"Do not emit arbitrary CSS font stacks for online Slide creation.",
|
||||
"Choose by deck topic, visual mood, language, and text role instead of by topic alone.",
|
||||
"A preset is a ranked candidate pool, not a hard one-font mapping.",
|
||||
"Chinese decks should prefer zh-capable display/body candidates unless the page is intentionally Latin-led."
|
||||
],
|
||||
"role_definitions": {
|
||||
"display": "Cover title, section title, editorial headline, hero phrase.",
|
||||
"body": "Readable paragraph and bullets.",
|
||||
"number": "Financial numbers, scores, tables, axes, labels with numeric density.",
|
||||
"label": "Tags, captions, source notes, chart labels, small annotations."
|
||||
},
|
||||
"visual_mood_presets": {
|
||||
"corporate_neutral": {
|
||||
"themes": ["business report", "strategy", "organization", "operations", "meeting summary"],
|
||||
"intent": "稳健、中性、清晰,避免强烈装饰。",
|
||||
"roles": {
|
||||
"display": ["Montserrat", "Inter", "Lato", "Noto Sans SC", "SimHei"],
|
||||
"body": ["Inter", "Lato", "Open Sans", "Noto Sans SC", "Arial"],
|
||||
"number": ["Roboto Mono", "Inconsolata", "Source Code Pro", "Roboto"],
|
||||
"label": ["Inter", "Roboto", "Noto Sans SC", "Arial"]
|
||||
}
|
||||
},
|
||||
"finance_institutional": {
|
||||
"themes": ["financial report", "earnings", "investor update", "audit", "valuation"],
|
||||
"intent": "机构化、可信、数字清晰,弱装饰、强层级。",
|
||||
"roles": {
|
||||
"display": ["IBM Plex Sans", "Montserrat", "Libre Franklin", "Noto Sans SC", "SimHei"],
|
||||
"body": ["IBM Plex Sans", "Inter", "Roboto", "Noto Sans SC"],
|
||||
"number": ["Roboto Mono", "Source Code Pro", "Inconsolata"],
|
||||
"label": ["IBM Plex Sans", "Roboto Condensed", "Noto Sans SC"]
|
||||
}
|
||||
},
|
||||
"tech_ai_precision": {
|
||||
"themes": ["AI", "chip", "cloud", "engineering", "developer platform", "cybersecurity"],
|
||||
"intent": "精密、现代、工程感,适合深色和高对比图表。",
|
||||
"roles": {
|
||||
"display": ["Exo 2", "Rajdhani SemiBold", "Titillium Web SemiBold", "LogoSC Unbounded Sans", "ChillDINGothic SemiBold"],
|
||||
"body": ["IBM Plex Sans", "Inter", "Roboto", "Noto Sans SC"],
|
||||
"number": ["Roboto Mono", "Source Code Pro", "Inconsolata"],
|
||||
"label": ["Rajdhani SemiBold", "Roboto Condensed", "Noto Sans SC"]
|
||||
}
|
||||
},
|
||||
"data_scientific": {
|
||||
"themes": ["research", "science", "lab", "data analysis", "methodology", "benchmark"],
|
||||
"intent": "理性、克制、可读,强调图表和注释。",
|
||||
"roles": {
|
||||
"display": ["IBM Plex Sans", "Source Code Pro", "Roboto Slab", "Noto Serif SC"],
|
||||
"body": ["IBM Plex Sans", "Noto Sans", "Noto Sans SC", "Open Sans"],
|
||||
"number": ["Source Code Pro", "Roboto Mono", "Inconsolata"],
|
||||
"label": ["IBM Plex Sans", "Noto Sans", "Noto Sans SC"]
|
||||
}
|
||||
},
|
||||
"luxury_editorial": {
|
||||
"themes": ["luxury", "premium brand", "jewelry", "eyewear", "watch", "gallery", "boutique"],
|
||||
"intent": "高级、克制、留白、杂志感,标题与正文需明显分化。",
|
||||
"roles": {
|
||||
"display": ["Playfair Display", "EB Garamond", "Libre Baskerville", "ChillJinshuSongMedium", "Songti SC"],
|
||||
"body": ["Lora", "Libre Baskerville", "Noto Serif SC", "Songti SC", "Alegreya"],
|
||||
"number": ["Montserrat", "Lato", "Roboto"],
|
||||
"label": ["Josefin Sans", "Montserrat", "ChillDINGothic SemiBold", "Noto Sans SC"]
|
||||
}
|
||||
},
|
||||
"fashion_high_contrast": {
|
||||
"themes": ["fashion", "lookbook", "art direction", "campaign", "magazine"],
|
||||
"intent": "高反差、大片感、强标题,适合图文融合。",
|
||||
"roles": {
|
||||
"display": ["Abril Fatface", "Playfair Display", "Bebas Neue", "Poiret One", "ChillJinshuSongMedium"],
|
||||
"body": ["Raleway", "Lato", "Lora", "Noto Serif SC"],
|
||||
"number": ["Bebas Neue", "Montserrat", "Roboto Condensed"],
|
||||
"label": ["Raleway", "Josefin Sans", "Montserrat"]
|
||||
}
|
||||
},
|
||||
"culture_heritage": {
|
||||
"themes": ["tea", "museum", "heritage", "history", "traditional craft", "architecture"],
|
||||
"intent": "文化感、书卷气、东方审美,但正文仍要可读。",
|
||||
"roles": {
|
||||
"display": ["ChillJinshuSongMedium", "Noto Serif SC", "Songti SC", "ZCOOL XiaoWei", "Kaiti SC"],
|
||||
"body": ["Noto Serif SC", "Songti SC", "Noto Sans SC"],
|
||||
"number": ["Noto Sans SC", "SimHei", "Roboto"],
|
||||
"label": ["ChillDuanHeiSong_CompactRegular", "Noto Sans SC", "Songti SC"]
|
||||
}
|
||||
},
|
||||
"calligraphy_poetic": {
|
||||
"themes": ["poetry", "calligraphy", "literature", "festival", "ceremony", "classical culture"],
|
||||
"intent": "诗性、手写、仪式感;只适合少量标题或视觉锚点,不适合长正文。",
|
||||
"roles": {
|
||||
"display": ["Ma Shan Zheng", "Liu Jian Mao Cao", "Long Cang", "Zhi Mang Xing", "Kaiti SC"],
|
||||
"body": ["Noto Serif SC", "Songti SC", "Kaiti SC"],
|
||||
"number": ["Noto Sans SC", "SimHei"],
|
||||
"label": ["Kaiti SC", "Songti SC", "Noto Sans SC"]
|
||||
}
|
||||
},
|
||||
"sports_broadcast": {
|
||||
"themes": ["sports", "football", "basketball", "athlete profile", "match report", "tournament"],
|
||||
"intent": "转播感、速度、冲击、比分清晰。",
|
||||
"roles": {
|
||||
"display": ["Anton", "Bebas Neue", "Oswald", "Teko", "Fjalla One"],
|
||||
"body": ["Barlow Condensed", "Roboto Condensed", "Noto Sans SC", "ChillDuanSans WideSemiBold"],
|
||||
"number": ["Anton", "Bebas Neue", "Teko", "Roboto Condensed"],
|
||||
"label": ["Oswald", "Barlow Condensed", "ChillDuanSans WideSemiBold"]
|
||||
}
|
||||
},
|
||||
"youth_pop": {
|
||||
"themes": ["youth", "social media", "entertainment", "creator economy", "consumer brand", "music"],
|
||||
"intent": "年轻、活泼、强识别,允许更高饱和度和圆润字体。",
|
||||
"roles": {
|
||||
"display": ["DouyinSans", "ZCOOL KuaiLe", "Righteous", "Comfortaa", "ChillRoundF"],
|
||||
"body": ["Nunito Sans", "Quicksand", "Noto Sans SC", "Resource Han Rounded CN"],
|
||||
"number": ["DouyinSans", "Montserrat", "Roboto"],
|
||||
"label": ["DouyinSans", "Comfortaa", "Resource Han Rounded CN"]
|
||||
}
|
||||
},
|
||||
"warm_lifestyle": {
|
||||
"themes": ["travel", "food", "home", "wellness", "coffee", "daily life", "local guide"],
|
||||
"intent": "温暖、轻松、有亲和力,正文要舒适。",
|
||||
"roles": {
|
||||
"display": ["Quicksand", "Nunito", "Lora", "975Maru SC", "Resource Han Rounded CN"],
|
||||
"body": ["Nunito Sans", "Lora", "Noto Sans SC", "Songti SC"],
|
||||
"number": ["Nunito Sans", "Roboto", "Montserrat"],
|
||||
"label": ["Quicksand", "Nunito Sans", "Resource Han Rounded CN"]
|
||||
}
|
||||
},
|
||||
"education_readable": {
|
||||
"themes": ["course", "training", "explainer", "knowledge base", "tutorial", "school"],
|
||||
"intent": "高可读、低噪声、层级明确。",
|
||||
"roles": {
|
||||
"display": ["Nunito Sans", "Source Code Pro", "Noto Sans SC", "SimHei"],
|
||||
"body": ["Noto Sans SC", "Open Sans", "Roboto", "Arial"],
|
||||
"number": ["Roboto Mono", "Roboto", "Source Code Pro"],
|
||||
"label": ["Noto Sans SC", "Roboto", "Arial"]
|
||||
}
|
||||
},
|
||||
"government_formal": {
|
||||
"themes": ["policy", "government", "public affairs", "official report", "regulation"],
|
||||
"intent": "正式、稳重、低风险,避免娱乐化。",
|
||||
"roles": {
|
||||
"display": ["Songti SC Black", "Noto Serif SC", "SimHei"],
|
||||
"body": ["Songti SC", "Noto Serif SC", "Noto Sans SC"],
|
||||
"number": ["SimHei", "Noto Sans SC", "Roboto"],
|
||||
"label": ["SimHei", "Noto Sans SC", "Songti SC"]
|
||||
}
|
||||
},
|
||||
"medical_clean": {
|
||||
"themes": ["medical", "healthcare", "biotech", "clinical", "pharma", "public health"],
|
||||
"intent": "清洁、可信、轻科技,避免过强装饰。",
|
||||
"roles": {
|
||||
"display": ["IBM Plex Sans", "Lato", "Noto Sans SC", "Source Code Pro"],
|
||||
"body": ["IBM Plex Sans", "Open Sans", "Noto Sans SC", "Roboto"],
|
||||
"number": ["Roboto Mono", "Source Code Pro", "Roboto"],
|
||||
"label": ["IBM Plex Sans", "Noto Sans SC", "Roboto"]
|
||||
}
|
||||
},
|
||||
"industrial_engineering": {
|
||||
"themes": ["manufacturing", "automotive", "energy", "infrastructure", "supply chain", "construction"],
|
||||
"intent": "硬朗、工程、结构感,适合流程图和技术剖面。",
|
||||
"roles": {
|
||||
"display": ["Barlow Condensed", "Archivo Narrow", "Roboto Condensed", "ChillDuanSans WideSemiBold"],
|
||||
"body": ["Barlow", "Roboto", "Noto Sans SC", "ChillReunion_Sans"],
|
||||
"number": ["Roboto Mono", "Teko", "Source Code Pro"],
|
||||
"label": ["Archivo Narrow", "Roboto Condensed", "ChillDINGothic SemiBold"]
|
||||
}
|
||||
},
|
||||
"startup_product": {
|
||||
"themes": ["product launch", "SaaS", "growth", "startup pitch", "mobile app", "platform"],
|
||||
"intent": "现代、清爽、产品化,适合界面截图和指标叙事。",
|
||||
"roles": {
|
||||
"display": ["Poppins", "Montserrat", "DM Sans 9pt", "DouyinSans", "Noto Sans SC"],
|
||||
"body": ["DM Sans 9pt", "Inter", "Noto Sans SC", "Open Sans"],
|
||||
"number": ["Roboto Mono", "DM Sans 9pt", "Montserrat"],
|
||||
"label": ["DM Sans 9pt", "Inter", "Noto Sans SC"]
|
||||
}
|
||||
},
|
||||
"gaming_sci_fi": {
|
||||
"themes": ["gaming", "esports", "sci-fi", "future city", "metaverse", "virtual event"],
|
||||
"intent": "未来、竞技、强风格;需要避免全页难读。",
|
||||
"roles": {
|
||||
"display": ["Exo", "Exo 2", "Play", "Rajdhani SemiBold", "LogoSC Unbounded Sans"],
|
||||
"body": ["Exo 2", "Rajdhani SemiBold", "Noto Sans SC", "Roboto"],
|
||||
"number": ["Rajdhani SemiBold", "Roboto Mono", "Teko"],
|
||||
"label": ["Exo 2", "Rajdhani SemiBold", "LogoSC Unbounded Sans"]
|
||||
},
|
||||
"notes": ["Orbitron would be a strong sci-fi candidate, but it is not currently present in slide_supported_fonts.json; gate should reject it until the font catalog contains it."]
|
||||
}
|
||||
},
|
||||
"topic_to_mood_examples": {
|
||||
"NVIDIA Q4 financial report": ["finance_institutional", "tech_ai_precision"],
|
||||
"Kaneko Optical brand introduction": ["luxury_editorial", "culture_heritage"],
|
||||
"2026 World Cup Germany or Norway Haaland": ["sports_broadcast", "youth_pop"],
|
||||
"Chinese tea introduction": ["culture_heritage", "calligraphy_poetic", "warm_lifestyle"],
|
||||
"Apple product strategy": ["startup_product", "tech_ai_precision", "corporate_neutral"],
|
||||
"public policy explainer": ["government_formal", "education_readable"],
|
||||
"biotech market report": ["medical_clean", "finance_institutional", "data_scientific"]
|
||||
},
|
||||
"draft_quality_rules": [
|
||||
"At least two different font families should be used across display/body/number/label unless the deck is a plain operational report.",
|
||||
"Decorative script fonts are display-only by default and should not be used for body text.",
|
||||
"Numeric-heavy decks should select number fonts from monospace, condensed, or tabular-looking candidates.",
|
||||
"Chinese body text should prefer Noto Sans SC, Noto Serif SC, Songti SC, SimHei, or Resource Han Rounded CN depending on mood.",
|
||||
"A generated deck should include the chosen mood preset names and role-level font choices in typography_contract.json.",
|
||||
"If any selected font_family is not present in slide_supported_fonts.json, the run should fail before online Slide creation."
|
||||
]
|
||||
}
|
||||
3704
internal/svglide/font_catalog/slide_supported_fonts.json
Normal file
3704
internal/svglide/font_catalog/slide_supported_fonts.json
Normal file
File diff suppressed because it is too large
Load Diff
220
internal/svglide/image_candidates.go
Normal file
220
internal/svglide/image_candidates.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const imageCandidatesPath = "assets/image_candidates.json"
|
||||
|
||||
type imageCandidatesFile struct {
|
||||
RequiresRealImages bool `json:"requires_real_images"`
|
||||
NoImageReason string `json:"no_image_reason"`
|
||||
AttemptedSources []string `json:"attempted_sources"`
|
||||
FailureReasonCode string `json:"failure_reason_code"`
|
||||
Candidates []imageCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
type imageCandidate struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SourceClass string `json:"source_class"`
|
||||
Format string `json:"format"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
HasAlpha bool `json:"has_alpha"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FitRole string `json:"fit_role"`
|
||||
LocalPath string `json:"local_path"`
|
||||
ScoreBP int `json:"score_bp"`
|
||||
Selected bool `json:"selected"`
|
||||
SelectionReason string `json:"selection_reason"`
|
||||
FormatExceptionReason string `json:"format_exception_reason"`
|
||||
RejectionReason string `json:"rejection_reason"`
|
||||
EvidenceRole string `json:"evidence_role"`
|
||||
}
|
||||
|
||||
func readImageCandidates(safeRoot string) (imageCandidatesFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, imageCandidatesPath)
|
||||
if err != nil {
|
||||
return imageCandidatesFile{}, fmt.Errorf("read image candidates %q: %w", imageCandidatesPath, err)
|
||||
}
|
||||
var file imageCandidatesFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return imageCandidatesFile{}, fmt.Errorf("%s: invalid JSON: %w", imageCandidatesPath, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func ValidateImageCandidatesGate(safeRoot string) error {
|
||||
manifest, err := readAssetsManifest(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventory, err := readAssetInventory(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates, err := readImageCandidates(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run, _ := readRunFile(safeRoot)
|
||||
deliveryContract, _, _ := readDeliveryContract(safeRoot, run)
|
||||
requiresRealImages := candidates.RequiresRealImages || deliveryContract.RequiresRealImages
|
||||
hasRasterAsset := false
|
||||
for _, asset := range manifest.Assets {
|
||||
if isRasterImageAsset(asset) {
|
||||
hasRasterAsset = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !requiresRealImages && len(candidates.Candidates) == 0 {
|
||||
if strings.TrimSpace(candidates.NoImageReason) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: no real image candidates; set no_image_reason when requires_real_images=false")
|
||||
}
|
||||
if hasRasterAsset {
|
||||
return fmt.Errorf("image_candidates_gate: ready raster image assets require selected candidates even when requires_real_images=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if requiresRealImages {
|
||||
if err := validateImageCandidateSearchBreadth(candidates); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
selectedByID := selectedImageCandidatesByID(candidates)
|
||||
selectedByPath := selectedImageCandidatesByLocalPath(candidates)
|
||||
inventoryByPath := inventoryItemByPath(inventory)
|
||||
for _, asset := range manifest.Assets {
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
item, ok := inventoryByPath[path]
|
||||
if !ok {
|
||||
return fmt.Errorf("image_candidates_gate: ready image asset %q path %q has no asset_inventory entry", assetID(asset), path)
|
||||
}
|
||||
candidate, ok := selectedCandidateForInventoryItem(item, selectedByID, selectedByPath)
|
||||
if !ok {
|
||||
return fmt.Errorf("image_candidates_gate: ready image asset %q path %q has no selected candidate", assetID(asset), path)
|
||||
}
|
||||
if strings.TrimSpace(item.CandidateID) != "" && strings.TrimSpace(candidate.LocalPath) != "" && strings.TrimSpace(candidate.LocalPath) != path {
|
||||
return fmt.Errorf("image_candidates_gate: asset %q candidate_id %q points to local_path %q, want %q", assetID(asset), item.CandidateID, candidate.LocalPath, path)
|
||||
}
|
||||
if strings.TrimSpace(candidate.SourceURL) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: selected candidate for asset %q has empty source_url", assetID(asset))
|
||||
}
|
||||
if strings.TrimSpace(candidate.SelectionReason) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: selected candidate for asset %q has empty selection_reason", assetID(asset))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectedImageCandidatesByID(file imageCandidatesFile) map[string]imageCandidate {
|
||||
out := make(map[string]imageCandidate)
|
||||
for _, candidate := range file.Candidates {
|
||||
if !candidate.Selected {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(candidate.ID)
|
||||
if id != "" {
|
||||
out[id] = candidate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedImageCandidatesByLocalPath(file imageCandidatesFile) map[string]imageCandidate {
|
||||
out := make(map[string]imageCandidate)
|
||||
for _, candidate := range file.Candidates {
|
||||
if !candidate.Selected {
|
||||
continue
|
||||
}
|
||||
localPath := strings.TrimSpace(candidate.LocalPath)
|
||||
if localPath != "" {
|
||||
out[localPath] = candidate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedCandidateForInventoryItem(item assetInventoryItem, byID, byPath map[string]imageCandidate) (imageCandidate, bool) {
|
||||
candidateID := strings.TrimSpace(item.CandidateID)
|
||||
if candidateID != "" {
|
||||
candidate, ok := byID[candidateID]
|
||||
return candidate, ok
|
||||
}
|
||||
candidate, ok := byPath[strings.TrimSpace(item.Path)]
|
||||
return candidate, ok
|
||||
}
|
||||
|
||||
func validateImageCandidateSearchBreadth(file imageCandidatesFile) error {
|
||||
selectedCount := 0
|
||||
coverHeroCandidates := 0
|
||||
selectedCoverHeroFromUser := false
|
||||
roleCandidateCount := map[string]int{}
|
||||
selectedImportantRoles := map[string]bool{}
|
||||
userProvidedImportantRoles := map[string]bool{}
|
||||
for _, candidate := range file.Candidates {
|
||||
role := strings.TrimSpace(candidate.AssetRole)
|
||||
if role != "" {
|
||||
roleCandidateCount[role]++
|
||||
}
|
||||
if role == "hero_photo" && strings.TrimSpace(candidate.FitRole) == "full_bleed" {
|
||||
coverHeroCandidates++
|
||||
if candidate.Selected && strings.TrimSpace(candidate.SourceClass) == "user_provided" {
|
||||
selectedCoverHeroFromUser = true
|
||||
}
|
||||
}
|
||||
if candidate.Selected {
|
||||
selectedCount++
|
||||
if isImportantImageRole(role) {
|
||||
selectedImportantRoles[role] = true
|
||||
if strings.TrimSpace(candidate.SourceClass) == "user_provided" {
|
||||
userProvidedImportantRoles[role] = true
|
||||
}
|
||||
}
|
||||
} else if strings.TrimSpace(candidate.RejectionReason) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: rejected candidate %q has empty rejection_reason", candidate.ID)
|
||||
}
|
||||
}
|
||||
if selectedCount == 0 {
|
||||
return fmt.Errorf("image_candidates_gate: requires_real_images=true but no selected image candidate exists")
|
||||
}
|
||||
if coverHeroCandidates > 0 && coverHeroCandidates < 3 && !selectedCoverHeroFromUser {
|
||||
return fmt.Errorf("image_candidates_gate: cover hero search needs at least 3 candidates, got %d", coverHeroCandidates)
|
||||
}
|
||||
for role := range selectedImportantRoles {
|
||||
if userProvidedImportantRoles[role] {
|
||||
continue
|
||||
}
|
||||
if roleCandidateCount[role] < 2 {
|
||||
return fmt.Errorf("image_candidates_gate: role %q needs at least 2 candidates, got %d", role, roleCandidateCount[role])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isImportantImageRole(role string) bool {
|
||||
switch role {
|
||||
case "hero_photo", "scene_photo", "factory_photo", "store_photo", "people_photo", "transparent_subject", "floating_product", "logo", "chip_device", "ui_screenshot", "product_screen", "paper_screenshot", "paper_figure", "repo_screenshot", "official_logo", "source_page_screenshot":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inventoryItemByPath(inventory assetInventoryFile) map[string]assetInventoryItem {
|
||||
out := make(map[string]assetInventoryItem)
|
||||
for _, item := range inventory.Items {
|
||||
if path := strings.TrimSpace(item.Path); path != "" {
|
||||
out[path] = item
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
53
internal/svglide/image_candidates_test.go
Normal file
53
internal/svglide/image_candidates_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateImageCandidatesGateRejectsReadyImageWithoutSelectedCandidate(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected ready image without selected candidate to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "image_candidates_gate") {
|
||||
t.Fatalf("error = %v, want image_candidates_gate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageCandidatesGateRejectsCandidateIDPathMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"c1","query":"brand hero photo","source_url":"https://example.com/other.jpg","source_class":"user_provided","format":"jpg","width":1600,"height":900,"has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","local_path":"assets/images/other.jpg","score_bp":9200,"selected":true,"selection_reason":"user-provided high-resolution hero photo","format_exception_reason":"","rejection_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","candidate_id":"c1","selection_reason":"user-provided high-resolution hero photo"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil || !strings.Contains(err.Error(), "candidate_id") {
|
||||
t.Fatalf("expected candidate_id/path mismatch failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageCandidatesGateAllowsExplicitNoImageDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"chart-only deck; no real raster image required","candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
mustWriteNoChartAssetsForTest(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("explicit no-image deck rejected: %v", err)
|
||||
}
|
||||
}
|
||||
338
internal/svglide/image_usage.go
Normal file
338
internal/svglide/image_usage.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const imageUsageReportPath = "receipts/image_usage.json"
|
||||
|
||||
type ImageUsageReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []ImageUsageSlide `json:"slides"`
|
||||
Issues []ImageUsageIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ImageUsageSlide struct {
|
||||
SlideID string `json:"slide_id"`
|
||||
CanvasWidth float64 `json:"canvas_width"`
|
||||
CanvasHeight float64 `json:"canvas_height"`
|
||||
Assets []ImageUsageAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type ImageUsageAsset struct {
|
||||
AssetID string `json:"asset_id"`
|
||||
Path string `json:"path"`
|
||||
Href string `json:"href"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FitRole string `json:"fit_role"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
AreaBP int `json:"area_bp"`
|
||||
UsageStatus string `json:"usage_status"`
|
||||
}
|
||||
|
||||
type ImageUsageIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func EvaluateImageUsageRun(safeRoot string, deck authorDeck, manifest deckAssetsFile, inventory assetInventoryFile) ImageUsageReport {
|
||||
report := ImageUsageReport{Status: "passed", Slides: []ImageUsageSlide{}, Issues: []ImageUsageIssue{}}
|
||||
inventoryByPath := inventoryItemByPath(inventory)
|
||||
readyAssetsByPath := readyAssetsByPath(manifest)
|
||||
usageByPath := map[string]ImageUsageAsset{}
|
||||
for _, slide := range deck.Slides {
|
||||
slideUsage := ImageUsageSlide{SlideID: strings.TrimSpace(slide.ID), Assets: []ImageUsageAsset{}}
|
||||
raw, err := readRunRegularArtifact(safeRoot, strings.TrimSpace(slide.Path))
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.image_usage.read_slide", Path: slide.Path, Message: err.Error()})
|
||||
report.Slides = append(report.Slides, slideUsage)
|
||||
continue
|
||||
}
|
||||
canvasWidth, canvasHeight := imageUsageCanvasSize(raw)
|
||||
slideUsage.CanvasWidth = canvasWidth
|
||||
slideUsage.CanvasHeight = canvasHeight
|
||||
usages, issues := extractSlideImageUsages(slide.Path, raw, canvasWidth, canvasHeight, readyAssetsByPath, inventoryByPath)
|
||||
if len(issues) > 0 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, issues...)
|
||||
}
|
||||
for _, usage := range usages {
|
||||
usageByPath[usage.Path] = usage
|
||||
slideUsage.Assets = append(slideUsage.Assets, usage)
|
||||
}
|
||||
report.Slides = append(report.Slides, slideUsage)
|
||||
}
|
||||
for _, asset := range manifest.Assets {
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
if issue, ok := validateReadyRasterImageAsset(safeRoot, asset); ok {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, issue)
|
||||
}
|
||||
item, ok := inventoryByPath[path]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
usage, used := usageByPath[path]
|
||||
if !used {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.quality.image_usage_missing", Path: path, Message: fmt.Sprintf("ready image asset %q is not referenced by any slide SVG", assetID(asset))})
|
||||
continue
|
||||
}
|
||||
if item.FitRole == "full_bleed" && usage.AreaBP < 4500 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.quality.image_usage_area", Path: path, Message: fmt.Sprintf("asset %q fit_role=full_bleed but SVG area is only %d bp", assetID(asset), usage.AreaBP)})
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func validateReadyRasterImageAsset(safeRoot string, asset deckAsset) (ImageUsageIssue, bool) {
|
||||
path := assetPath(asset)
|
||||
if !shouldDecodeReadyRasterImagePath(path) {
|
||||
return ImageUsageIssue{}, false
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ImageUsageIssue{
|
||||
Code: "svglide.quality.image_asset_not_decodable",
|
||||
Path: path,
|
||||
Message: fmt.Sprintf("ready image asset %q has an empty local path", assetID(asset)),
|
||||
}, true
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
if isRunSymlink(safeRoot, path) {
|
||||
return ImageUsageIssue{}, false
|
||||
}
|
||||
return ImageUsageIssue{
|
||||
Code: "svglide.quality.image_asset_not_decodable",
|
||||
Path: path,
|
||||
Message: fmt.Sprintf("ready image asset %q cannot be read or decoded: %v", assetID(asset), err),
|
||||
}, true
|
||||
}
|
||||
if err := validateRasterImageBytes(path, raw); err != nil {
|
||||
return ImageUsageIssue{
|
||||
Code: "svglide.quality.image_asset_not_decodable",
|
||||
Path: path,
|
||||
Message: fmt.Sprintf("ready image asset %q is not a decodable image: %v", assetID(asset), err),
|
||||
}, true
|
||||
}
|
||||
return ImageUsageIssue{}, false
|
||||
}
|
||||
|
||||
func shouldDecodeReadyRasterImagePath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(path)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:") {
|
||||
return false
|
||||
}
|
||||
if isAbsoluteRunPath(path) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isRunSymlink(safeRoot string, rel string) bool {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
func validateRasterImageBytes(path string, raw []byte) error {
|
||||
if len(raw) < 16 {
|
||||
return fmt.Errorf("file is only %d bytes", len(raw))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path)))
|
||||
switch ext {
|
||||
case ".webp":
|
||||
if isWebPImage(raw) {
|
||||
return nil
|
||||
}
|
||||
case ".avif":
|
||||
if isAVIFImage(raw) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return fmt.Errorf("decoded dimensions are %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWebPImage(raw []byte) bool {
|
||||
return len(raw) >= 12 && string(raw[:4]) == "RIFF" && string(raw[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
func isAVIFImage(raw []byte) bool {
|
||||
if len(raw) < 12 {
|
||||
return false
|
||||
}
|
||||
brand := string(raw[4:12])
|
||||
return strings.HasPrefix(brand, "ftypavif") || strings.HasPrefix(brand, "ftypavis")
|
||||
}
|
||||
|
||||
func writeImageUsageReport(safeRoot string, report ImageUsageReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, imageUsageReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func extractSlideImageUsages(slidePath string, raw []byte, canvasWidth float64, canvasHeight float64, readyAssetsByPath map[string]deckAsset, inventoryByPath map[string]assetInventoryItem) ([]ImageUsageAsset, []ImageUsageIssue) {
|
||||
out := []ImageUsageAsset{}
|
||||
issues := []ImageUsageIssue{}
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(raw)))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "image" {
|
||||
continue
|
||||
}
|
||||
attrs := parseSVGAttrs(start.Attr)
|
||||
href := attrs["href"]
|
||||
normalized := normalizeSlideAssetHref(slidePath, href)
|
||||
asset, assetOK := readyAssetsByPath[normalized]
|
||||
if !assetOK {
|
||||
issues = append(issues, ImageUsageIssue{
|
||||
Code: "svglide.quality.image_usage_unregistered",
|
||||
Path: strings.TrimSpace(slidePath),
|
||||
Message: fmt.Sprintf("slide SVG image href %q resolves to %q, which is not registered as a ready asset", href, normalized),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
item, ok := inventoryByPath[normalized]
|
||||
if !ok {
|
||||
issues = append(issues, ImageUsageIssue{
|
||||
Code: "svglide.quality.image_usage_missing_inventory",
|
||||
Path: normalized,
|
||||
Message: fmt.Sprintf("ready image asset %q is referenced by SVG but missing from asset_inventory", assetID(asset)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
width := parseImageUsageFloatAttr(attrs["width"])
|
||||
height := parseImageUsageFloatAttr(attrs["height"])
|
||||
out = append(out, ImageUsageAsset{
|
||||
AssetID: item.ID,
|
||||
Path: normalized,
|
||||
Href: href,
|
||||
AssetRole: item.AssetRole,
|
||||
FitRole: item.FitRole,
|
||||
X: parseImageUsageFloatAttr(attrs["x"]),
|
||||
Y: parseImageUsageFloatAttr(attrs["y"]),
|
||||
Width: width,
|
||||
Height: height,
|
||||
AreaBP: areaBPForCanvas(width, height, canvasWidth, canvasHeight),
|
||||
UsageStatus: "matched",
|
||||
})
|
||||
}
|
||||
return out, issues
|
||||
}
|
||||
|
||||
func imageUsageCanvasSize(raw []byte) (float64, float64) {
|
||||
width, height := svgViewBoxSize(string(raw))
|
||||
if width <= 0 {
|
||||
width = defaultSlideWidth
|
||||
}
|
||||
if height <= 0 {
|
||||
height = defaultSlideHeight
|
||||
}
|
||||
return width, height
|
||||
}
|
||||
|
||||
func parseSVGAttrs(attrs []xml.Attr) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, attr := range attrs {
|
||||
out[attr.Name.Local] = attr.Value
|
||||
if attr.Name.Space != "" {
|
||||
out[attr.Name.Space+":"+attr.Name.Local] = attr.Value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSlideAssetHref(slidePath, href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" || strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || filepath.IsAbs(href) {
|
||||
return strings.TrimPrefix(filepath.ToSlash(href), "./")
|
||||
}
|
||||
base := filepath.Dir(filepath.ToSlash(slidePath))
|
||||
normalized := filepath.Clean(filepath.Join(base, href))
|
||||
return strings.TrimPrefix(filepath.ToSlash(normalized), "./")
|
||||
}
|
||||
|
||||
func parseImageUsageFloatAttr(raw string) float64 {
|
||||
raw = strings.TrimSpace(strings.TrimSuffix(raw, "px"))
|
||||
value, _ := strconv.ParseFloat(raw, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func areaBP(width, height float64) int {
|
||||
return areaBPForCanvas(width, height, 1280, 720)
|
||||
}
|
||||
|
||||
func areaBPForCanvas(width, height float64, canvasWidth float64, canvasHeight float64) int {
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0
|
||||
}
|
||||
if canvasWidth <= 0 {
|
||||
canvasWidth = defaultSlideWidth
|
||||
}
|
||||
if canvasHeight <= 0 {
|
||||
canvasHeight = defaultSlideHeight
|
||||
}
|
||||
canvasArea := canvasWidth * canvasHeight
|
||||
if canvasArea <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(width * height * 10000 / canvasArea)
|
||||
}
|
||||
|
||||
func readyAssetsByPath(manifest deckAssetsFile) map[string]deckAsset {
|
||||
out := map[string]deckAsset{}
|
||||
for _, asset := range manifest.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
if path := strings.TrimSpace(assetPath(asset)); path != "" {
|
||||
out[path] = asset
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
135
internal/svglide/image_usage_test.go
Normal file
135
internal/svglide/image_usage_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestImageUsageRejectsSelectedImageNotReferencedBySVG(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="80" y="120">No image</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing") {
|
||||
t.Fatalf("expected selected image missing from SVG to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsFullBleedHeroUsedAsThumbnail(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="900" y="500" width="180" height="100"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_area") {
|
||||
t.Fatalf("expected full-bleed hero thumbnail usage to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageAreaUsesActualSVGCanvas(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Metrics.CoverHeroAreaBP != 10000 {
|
||||
t.Fatalf("cover hero area = %d, want 10000 for full 960x540 canvas", report.Metrics.CoverHeroAreaBP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageParsesSingleQuotedAndXLinkHrefImage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" xlink:href='../assets/images/hero.jpg' x='0' y='0' width='1280' height='720'/><text x="80" y="120">Cover</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing") {
|
||||
t.Fatalf("xlink:href image should be detected as used: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsUnregisteredSVGImageHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/temporary.jpg" x="0" y="0" width="1280" height="720"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_unregistered") {
|
||||
t.Fatalf("expected unregistered SVG image href to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsReferencedReadyImageMissingInventory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="0" y="0" width="1280" height="720"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing_inventory") {
|
||||
t.Fatalf("expected referenced ready image missing inventory to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsReadyImageAssetNotDecodable(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.jpg", "fixture image bytes")
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_asset_not_decodable") {
|
||||
t.Fatalf("expected undecodable ready image asset to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func writeMinimalImageQualityDeckForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.jpg" x="0" y="0" width="1280" height="720"/><text x="80" y="120">Cover</text></svg>`)
|
||||
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_photo_title")
|
||||
}
|
||||
237
internal/svglide/init.go
Normal file
237
internal/svglide/init.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type InitOptions struct {
|
||||
Title string
|
||||
Input string
|
||||
Topic string
|
||||
Language string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
DeliveryTarget string
|
||||
Pages int
|
||||
Now time.Time
|
||||
Overwrite bool
|
||||
AgentRuntime string
|
||||
AgentID string
|
||||
RouteProfile string
|
||||
ExecutionProfile string
|
||||
}
|
||||
|
||||
func InitRun(root string, opts InitOptions) error {
|
||||
root = strings.TrimSpace(root)
|
||||
opts.Title = strings.TrimSpace(opts.Title)
|
||||
opts.Input = strings.TrimSpace(opts.Input)
|
||||
opts.Topic = strings.TrimSpace(opts.Topic)
|
||||
opts.Language = strings.TrimSpace(opts.Language)
|
||||
opts.DeliveryTarget = strings.TrimSpace(opts.DeliveryTarget)
|
||||
opts.AgentRuntime = strings.TrimSpace(opts.AgentRuntime)
|
||||
opts.AgentID = strings.TrimSpace(opts.AgentID)
|
||||
opts.RouteProfile = strings.TrimSpace(opts.RouteProfile)
|
||||
opts.ExecutionProfile = strings.TrimSpace(opts.ExecutionProfile)
|
||||
if root == "" {
|
||||
return fmt.Errorf("out path is required")
|
||||
}
|
||||
if opts.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if (opts.Input == "") == (opts.Topic == "") {
|
||||
return fmt.Errorf("exactly one of input or topic is required")
|
||||
}
|
||||
safeRoot, err := validate.SafeOutputPath(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRunRoot(root, safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Input != "" {
|
||||
safeInput, err := validate.SafeInputPath(opts.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInputOutsideRunRoot(safeRoot, safeInput); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Input = safeInput
|
||||
}
|
||||
|
||||
if opts.Overwrite {
|
||||
return initOverwrite(safeRoot, opts)
|
||||
}
|
||||
|
||||
return initNoReplace(safeRoot, opts)
|
||||
}
|
||||
|
||||
func validateRunRoot(root string, safeRoot string) error {
|
||||
if filepath.Clean(root) == "." {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
canonicalCwd, err := vfs.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve working directory: %w", err)
|
||||
}
|
||||
if filepath.Clean(safeRoot) == filepath.Clean(canonicalCwd) {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputOutsideRunRoot(safeRoot string, safeInput string) error {
|
||||
root := filepath.Clean(safeRoot)
|
||||
input := filepath.Clean(safeInput)
|
||||
rel, err := filepath.Rel(root, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare input and output paths: %w", err)
|
||||
}
|
||||
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
|
||||
return fmt.Errorf("input path %q must be outside output run directory %q", safeInput, safeRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initNoReplace(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("%s already exists or cannot be created; refusing to overwrite: %w", safeRoot, err)
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func initOverwrite(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.RemoveAll(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func writeClaimedRunDirectory(safeRoot string, opts InitOptions) error {
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = vfs.RemoveAll(safeRoot)
|
||||
}
|
||||
}()
|
||||
if err := writeRunDirectory(safeRoot, safeRoot, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRunDirectory(writeRoot string, runRoot string, opts InitOptions) error {
|
||||
for _, dir := range []string{
|
||||
"request",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"assets/images",
|
||||
"publish",
|
||||
"slides",
|
||||
"schemas",
|
||||
"receipts",
|
||||
} {
|
||||
if err := vfs.MkdirAll(filepath.Join(writeRoot, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: opts.Title,
|
||||
Input: opts.Input,
|
||||
Topic: opts.Topic,
|
||||
Language: opts.Language,
|
||||
Audience: opts.Audience,
|
||||
DeliveryMode: opts.DeliveryMode,
|
||||
DeliveryTarget: opts.DeliveryTarget,
|
||||
Pages: opts.Pages,
|
||||
Out: runRoot,
|
||||
Now: opts.Now,
|
||||
AgentRuntime: opts.AgentRuntime,
|
||||
AgentID: opts.AgentID,
|
||||
RouteProfile: opts.RouteProfile,
|
||||
ExecutionProfile: opts.ExecutionProfile,
|
||||
})
|
||||
run.Policy.Overwrite = opts.Overwrite
|
||||
if err := writeJSON(filepath.Join(writeRoot, "run.json"), run); err != nil {
|
||||
return err
|
||||
}
|
||||
request := map[string]any{
|
||||
"title": opts.Title,
|
||||
"audience": opts.Audience,
|
||||
"delivery_mode": opts.DeliveryMode,
|
||||
"delivery_target": run.DeliveryTarget,
|
||||
"pages": opts.Pages,
|
||||
"intent": run.Intent,
|
||||
"agent": run.Agent,
|
||||
}
|
||||
if opts.Input != "" {
|
||||
request["input"] = opts.Input
|
||||
}
|
||||
if opts.Topic != "" {
|
||||
request["topic"] = opts.Topic
|
||||
}
|
||||
if opts.Language != "" {
|
||||
request["language"] = opts.Language
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "request.json"), request); err != nil {
|
||||
return err
|
||||
}
|
||||
source := map[string]string{"type": "topic", "topic": opts.Topic}
|
||||
if opts.Input != "" {
|
||||
source = map[string]string{"path": opts.Input, "type": "local"}
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "source_manifest.json"), map[string]any{
|
||||
"sources": []map[string]string{source},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeDeliveryContractFile(writeRoot, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeStaticFiles(writeRoot)
|
||||
}
|
||||
|
||||
func writeStaticFiles(root string) error {
|
||||
if err := writeText(filepath.Join(root, "README.md"), renderRunREADME()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePromptManifest(root); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, schema := range DefaultSchemas() {
|
||||
if err := writeText(filepath.Join(root, "schemas", name), schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderRunREADME() string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Local Run\n\n")
|
||||
b.WriteString("This directory is a local agent-neutral SVG slides runtime. It does not publish to Feishu Slides.\n")
|
||||
return b.String()
|
||||
}
|
||||
564
internal/svglide/init_test.go
Normal file
564
internal/svglide/init_test.go
Normal file
@@ -0,0 +1,564 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitRunWritesDirectoryContract(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
canonicalCwd, err := filepath.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "demo"
|
||||
wantInput := filepath.Join(canonicalCwd, "source.md")
|
||||
err = InitRun(root, InitOptions{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Now: time.Date(2026, 7, 2, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"run.json",
|
||||
"README.md",
|
||||
"prompt_manifest.json",
|
||||
"request/request.json",
|
||||
"request/source_manifest.json",
|
||||
"request/delivery_contract.json",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"schemas/request.schema.json",
|
||||
"schemas/deck.schema.json",
|
||||
"receipts",
|
||||
"slides",
|
||||
"assets/images",
|
||||
"publish",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
|
||||
t.Fatalf("missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
if run.Input != wantInput {
|
||||
t.Fatalf("run.Input = %q, want %q", run.Input, wantInput)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join(root, "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request["title"] != "Demo" || request["input"] != wantInput || request["audience"] != "产品负责人" || request["delivery_mode"] != "self_read" || request["delivery_target"] != DeliveryTargetLocalPreview || request["pages"] != float64(8) {
|
||||
t.Fatalf("unexpected request.json: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(root, "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0].Path != wantInput || manifest.Sources[0].Type != "local" {
|
||||
t.Fatalf("unexpected source_manifest.json: %+v", manifest)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts directory should not be generated per run, stat err = %v", err)
|
||||
}
|
||||
|
||||
promptRaw, err := os.ReadFile(filepath.Join(root, "prompt_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := string(promptRaw)
|
||||
for _, want := range []string{"mode_system_prompt_svg", "svg_reference", "tools/slides_edit.md", "tools/resolve_delivery_contract.md", "tools/resolve_image_assets.md", "tools/generate_svg_chart.md", "tools/generate_vega_lite_chart.md", "tools/publish_online.md"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt manifest missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
schemaRaw, err := os.ReadFile(filepath.Join(root, "schemas", "deck.schema.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deckSchema map[string]any
|
||||
if err := json.Unmarshal(schemaRaw, &deckSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := deckSchema["properties"]; !ok || !strings.Contains(string(schemaRaw), "key_message") {
|
||||
t.Fatalf("deck schema missing properties/key_message: %s", string(schemaRaw))
|
||||
}
|
||||
if !strings.Contains(string(schemaRaw), `"minItems": 1`) || !strings.Contains(string(schemaRaw), `^slides/[^/]+\\.svg$`) {
|
||||
t.Fatalf("deck schema missing minItems/path pattern: %s", string(schemaRaw))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"source_manifest.schema.json",
|
||||
"delivery_contract.schema.json",
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"slide_copy_plan.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"assets_manifest.schema.json",
|
||||
"image_candidates.schema.json",
|
||||
"asset_inventory.schema.json",
|
||||
"chart_manifest.schema.json",
|
||||
"image_usage.schema.json",
|
||||
"chart_quality.schema.json",
|
||||
"typography_contract.schema.json",
|
||||
"quality.schema.json",
|
||||
"receipt.schema.json",
|
||||
"lint.schema.json",
|
||||
"preview.schema.json",
|
||||
"svg_publish_request_evidence.schema.json",
|
||||
"online_slide.schema.json",
|
||||
"publish_online.schema.json",
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", name, err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("schema %s is not valid JSON: %v", name, err)
|
||||
}
|
||||
if schema["type"] == nil {
|
||||
t.Fatalf("schema %s missing type: %s", name, string(raw))
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{name: "request.schema.json", want: []string{`"purpose"`, `"language"`, `"visual_style_query"`}},
|
||||
{name: "delivery_contract.schema.json", want: []string{`"delivery_target"`, `"requires_online_slide"`, `"requires_real_images"`}},
|
||||
{name: "design_brief.schema.json", want: []string{`"visual_system"`, `"narrative_spine"`, `"depth"`, `"tone"`}},
|
||||
{name: "deck.schema.json", want: []string{`"main_title"`, `"style_instruction"`, `"aesthetic_direction"`}},
|
||||
{name: "sources.schema.json", want: []string{`"retrieval"`}},
|
||||
{name: "slide_content.schema.json", want: []string{`"source_refs"`, `"central_claim"`, `"supporting_points"`, `"source_bound_facts"`, `"visuals"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "slide_copy_plan.schema.json", want: []string{`"audience_copy"`, `"production_instruction"`}},
|
||||
{name: "assets_plan.schema.json", want: []string{`"experiment_unrestricted_assets"`, `"slide_id"`, `"status"`, `"deferred"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "image_candidates.schema.json", want: []string{`"requires_real_images"`, `"format_exception_reason"`, `"selection_reason"`, `"paper_screenshot"`}},
|
||||
{name: "asset_inventory.schema.json", want: []string{`"large_ok"`, `"candidate_id"`, `"format_exception_reason"`}},
|
||||
{name: "image_usage.schema.json", want: []string{`"area_bp"`, `"usage_status"`}},
|
||||
{name: "chart_manifest.schema.json", want: []string{`"vega-lite"`, `"spec_path"`, `"svg_path"`}},
|
||||
{name: "chart_quality.schema.json", want: []string{`"missing_unit_count"`, `"missing_source_count"`, `"decorative_chart_count"`}},
|
||||
{name: "typography_contract.schema.json", want: []string{`"display"`, `"number"`, `"label"`}},
|
||||
{name: "quality.schema.json", want: []string{`"metrics"`, `"real_image_assets"`, `"vega_lite_spec_assets"`, `"content_payload_issue_count"`}},
|
||||
{name: "svg_publish_request_evidence.schema.json", want: []string{`"content_type"`, `"content_root"`, `"forbidden_format_detected"`}},
|
||||
{name: "online_slide.schema.json", want: []string{`"presentation_id"`, `"blocked_reason_code"`}},
|
||||
{name: "content_payload.schema.json", want: []string{`"sparse_label_list_count"`, `"source_binding_issue_count"`}},
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", tc.name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", tc.name, err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("schema %s missing %s: %s", tc.name, want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRuntimeBindingMentionsCopyPlanAndAssetInventory(t *testing.T) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var binding PromptAssetContract
|
||||
found := false
|
||||
for _, asset := range assets {
|
||||
if asset.ID == "svglide_local_runtime_binding" {
|
||||
binding = asset
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing svglide_local_runtime_binding")
|
||||
}
|
||||
raw, err := readPromptAssetFile(binding.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range []string{"image_candidates", "asset_inventory", "image_usage", "chart_manifest", "typography_contract", "slide_copy_plan", "audience_copy", "production_instruction"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("runtime binding missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "run.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRootWithoutRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantREADME := "keep this readme\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(wantREADME), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
gotREADME, readErr := os.ReadFile(filepath.Join(root, "README.md"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(gotREADME) != wantREADME {
|
||||
t.Fatalf("README overwritten: got %q, want %q", string(gotREADME), wantREADME)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected existing root refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunOverwriteReplacesOldRunDirectory(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(filepath.Join(root, "slides"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "slides", "old.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "slides", "old.svg")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old slide should be removed, stat err = %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !run.Policy.Overwrite {
|
||||
t.Fatalf("Policy.Overwrite = false, want true: %+v", run.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsOverlappingInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
input string
|
||||
overwrite bool
|
||||
}{
|
||||
{name: "same path overwrite", root: "source.md", input: "source.md", overwrite: true},
|
||||
{name: "input under output overwrite", root: "demo", input: "demo/source.md", overwrite: true},
|
||||
{name: "input under output no overwrite", root: "demo", input: "demo/source.md", overwrite: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.MkdirAll(filepath.Dir(tt.input), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tt.input, []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(tt.root, InitOptions{Title: "Demo", Input: tt.input, Overwrite: tt.overwrite})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping input/output refusal")
|
||||
}
|
||||
got, readErr := os.ReadFile(tt.input)
|
||||
if readErr != nil {
|
||||
t.Fatalf("source should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "source" {
|
||||
t.Fatalf("source content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsUnsafePaths(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "absolute root", root: filepath.Join(cwd, "demo"), opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping root", root: "../escape", opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping input", root: "demo", opts: InitOptions{Title: "Demo", Input: "../source.md"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected unsafe path refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsRootResolvingToCWDWhenOverwrite(t *testing.T) {
|
||||
for _, root := range []string{".", "./", "subdir/.."} {
|
||||
t.Run(root, func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
markerPath := filepath.Join(cwd, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected root resolving to CWD to be rejected")
|
||||
}
|
||||
got, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("marker should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "keep" {
|
||||
t.Fatalf("marker content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPromptManifestContracts(t *testing.T) {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manifest.Source != anyGenPromptRoot {
|
||||
t.Fatalf("Source = %q, want %q", manifest.Source, anyGenPromptRoot)
|
||||
}
|
||||
if manifest.Runtime != "agent" {
|
||||
t.Fatalf("Runtime = %q, want agent", manifest.Runtime)
|
||||
}
|
||||
entries := map[string]PromptManifestEntry{}
|
||||
for _, entry := range manifest.Entries {
|
||||
entries[entry.Name] = entry
|
||||
}
|
||||
for _, want := range []string{"anygen_source_full", "anygen_svg_readme", "mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding", "svglide_visual_quality_overlay", "slide_font_catalog", "resolve_theme_contract", "plan_research", "resolve_design_brief", "slide_outline", "activate_slides_edit", "slides_edit", "finish_slides_edit", "resolve_image_assets", "generate_vega_lite_chart", "generate_svg_chart", "slides_convert", "slides_parse_template"} {
|
||||
if entries[want].Path == "" {
|
||||
t.Fatalf("manifest missing %q: %+v", want, manifest.Entries)
|
||||
}
|
||||
}
|
||||
if entries["anygen_source_full"].Path != "docs/vendor/anygen-svg/source.full.md" || entries["anygen_source_full"].Always || entries["anygen_source_full"].SHA256 == "" {
|
||||
t.Fatalf("anygen_source_full entry = %+v, want hashed provenance-only source.full.md path", entries["anygen_source_full"])
|
||||
}
|
||||
if entries["anygen_svg_readme"].Path != "skills/lark-slides/references/anygen-svg/README.md" || !entries["anygen_svg_readme"].Always {
|
||||
t.Fatalf("anygen_svg_readme entry = %+v, want always README path", entries["anygen_svg_readme"])
|
||||
}
|
||||
if !entries["mode_system_prompt_svg"].Always || !entries["svg_reference"].Always {
|
||||
t.Fatalf("core prompt entries must be always available: %+v", manifest.Entries)
|
||||
}
|
||||
if entries["svglide_local_runtime_binding"].Role != "runtime_binding" || !entries["svglide_local_runtime_binding"].Always {
|
||||
t.Fatalf("runtime binding entry = %+v, want always runtime_binding", entries["svglide_local_runtime_binding"])
|
||||
}
|
||||
if entries["svglide_visual_quality_overlay"].Role != "runtime_binding" || !entries["svglide_visual_quality_overlay"].Always {
|
||||
t.Fatalf("visual quality overlay entry = %+v, want always runtime_binding", entries["svglide_visual_quality_overlay"])
|
||||
}
|
||||
if entries["slide_font_catalog"].Role != "runtime_binding" || !entries["slide_font_catalog"].Always {
|
||||
t.Fatalf("slide font catalog entry = %+v, want always runtime_binding", entries["slide_font_catalog"])
|
||||
}
|
||||
if entries["resolve_theme_contract"].Path != "skills/lark-slides/references/anygen-svg/tools/resolve_theme_contract.md" {
|
||||
t.Fatalf("resolve_theme_contract path = %q", entries["resolve_theme_contract"].Path)
|
||||
}
|
||||
if entries["resolve_theme_contract"].Stage != StageRequestResolution {
|
||||
t.Fatalf("resolve_theme_contract stage = %q, want %q", entries["resolve_theme_contract"].Stage, StageRequestResolution)
|
||||
}
|
||||
if !stringSliceContains(entries["resolve_theme_contract"].Produces, "request/theme_contract.json") {
|
||||
t.Fatalf("resolve_theme_contract produces = %v, want request/theme_contract.json", entries["resolve_theme_contract"].Produces)
|
||||
}
|
||||
if entries["plan_research"].Stage != StageResearch {
|
||||
t.Fatalf("plan_research stage = %q, want %q", entries["plan_research"].Stage, StageResearch)
|
||||
}
|
||||
if !stringSliceContains(entries["plan_research"].Produces, "research/research_plan.json") || !stringSliceContains(entries["plan_research"].Produces, "research/queries.json") {
|
||||
t.Fatalf("plan_research produces = %v, want research plan and queries", entries["plan_research"].Produces)
|
||||
}
|
||||
if entries["activate_slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("activate_slides_edit stage = %q, want %q", entries["activate_slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("slides_edit stage = %q, want %q", entries["slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["generate_svg_chart"].Stage != StageAssets {
|
||||
t.Fatalf("generate_svg_chart stage = %q, want %q", entries["generate_svg_chart"].Stage, StageAssets)
|
||||
}
|
||||
if entries["generate_vega_lite_chart"].Stage != StageAssets {
|
||||
t.Fatalf("generate_vega_lite_chart stage = %q, want %q", entries["generate_vega_lite_chart"].Stage, StageAssets)
|
||||
}
|
||||
if entries["resolve_image_assets"].Stage != StageAssets {
|
||||
t.Fatalf("resolve_image_assets stage = %q, want %q", entries["resolve_image_assets"].Stage, StageAssets)
|
||||
}
|
||||
promptPaths, err := PromptPathsForStage(StageSVGAuthor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := strings.Join(promptPaths, "\n")
|
||||
if strings.Contains(paths, "source.full.md") {
|
||||
t.Fatalf("SVG author prompt paths should not require source snapshot:\n%s", paths)
|
||||
}
|
||||
for _, want := range []string{"README.md", "mode_system_prompt_svg.md", "svg_reference.md", "svglide_local_runtime_binding.md", "svglide_visual_quality_overlay.md", "slide_font_catalog.md", "tools/activate_slides_edit.md", "tools/slides_edit.md", "tools/compute_custom_shape_bbox.md"} {
|
||||
if !strings.Contains(paths, want) {
|
||||
t.Fatalf("SVG author prompt paths missing %q:\n%s", want, paths)
|
||||
}
|
||||
}
|
||||
if strings.Contains(paths, "tools/slides_convert.md") || strings.Contains(paths, "tools/slides_parse_template.md") {
|
||||
t.Fatalf("local SVG author prompt paths must not expose legacy tool prompts:\n%s", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsBlankRequiredFields(t *testing.T) {
|
||||
blankRoot := " "
|
||||
t.Chdir(t.TempDir())
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "root", root: blankRoot, opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "title", root: "title", opts: InitOptions{Title: " \t", Input: "source.md"}},
|
||||
{name: "input", root: "input", opts: InitOptions{Title: "Demo", Input: " \t"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected blank field refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunAcceptsTopicOnlyIntent(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
opts := InitOptions{
|
||||
Title: "电影介绍",
|
||||
Now: time.Date(2026, 7, 3, 10, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
}
|
||||
setStringInitOptionField(t, &opts, "Topic", "介绍一部电影")
|
||||
setStringInitOptionField(t, &opts, "Language", "zh")
|
||||
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
|
||||
setStringInitOptionField(t, &opts, "AgentID", "test-agent-1")
|
||||
|
||||
if err := InitRun("demo", opts); err != nil {
|
||||
t.Fatalf("topic-only init should succeed without --input: %v", err)
|
||||
}
|
||||
|
||||
runRaw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run map[string]any
|
||||
if err := json.Unmarshal(runRaw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run["runtime"] == "codex" || run["runtime"] == "fake-agent" {
|
||||
t.Fatalf("run.runtime = %v, want agent-neutral protocol runtime separate from agent runtime", run["runtime"])
|
||||
}
|
||||
agent, ok := run["agent"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("run.agent missing or invalid: %+v", run)
|
||||
}
|
||||
if agent["runtime"] != "fake-agent" || agent["id"] != "test-agent-1" {
|
||||
t.Fatalf("run.agent = %+v, want fake-agent/test-agent-1", agent)
|
||||
}
|
||||
intent, ok := run["intent"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("run.intent missing or invalid: %+v", run)
|
||||
}
|
||||
if intent["source_mode"] != "topic" || intent["topic"] != "介绍一部电影" || intent["language"] != "zh" {
|
||||
t.Fatalf("run.intent = %+v, want topic-only zh intent", intent)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join("demo", "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input, ok := request["input"]; ok && input != "" {
|
||||
t.Fatalf("topic-only request.json input = %v, want absent or empty", input)
|
||||
}
|
||||
if request["intent"] == nil || request["agent"] == nil {
|
||||
t.Fatalf("request.json missing intent/agent: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join("demo", "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []map[string]string `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0]["type"] != "topic" || manifest.Sources[0]["topic"] != "介绍一部电影" {
|
||||
t.Fatalf("source_manifest.json = %+v, want one topic source", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func setStringInitOptionField(t *testing.T, opts *InitOptions, name string, value string) {
|
||||
t.Helper()
|
||||
field := reflect.ValueOf(opts).Elem().FieldByName(name)
|
||||
if !field.IsValid() {
|
||||
t.Fatalf("InitOptions missing %s field required by agent runtime protocol", name)
|
||||
}
|
||||
if field.Kind() != reflect.String || !field.CanSet() {
|
||||
t.Fatalf("InitOptions.%s = %s canSet=%v, want settable string", name, field.Kind(), field.CanSet())
|
||||
}
|
||||
field.SetString(value)
|
||||
}
|
||||
20
internal/svglide/io.go
Normal file
20
internal/svglide/io.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(path, raw, 0o644)
|
||||
}
|
||||
|
||||
func writeText(path string, content string) error {
|
||||
return validate.AtomicWrite(path, []byte(content), 0o644)
|
||||
}
|
||||
201
internal/svglide/local_screenshot.go
Normal file
201
internal/svglide/local_screenshot.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CaptureScreenshots(root string) (ScreenshotEvidenceReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ScreenshotEvidenceReport{}, err
|
||||
}
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
deckPath = "outline/deck.json"
|
||||
}
|
||||
deck, err := readAuthorDeck(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return ScreenshotEvidenceReport{}, err
|
||||
}
|
||||
chrome, err := findChromeExecutable()
|
||||
if err != nil {
|
||||
report := screenshotErrorReport(len(deck.Slides), screenshotEvidenceReportPath, "svglide.screenshot_evidence.missing_chrome", err.Error())
|
||||
_ = writeScreenshotEvidenceReport(safeRoot, report)
|
||||
return report, err
|
||||
}
|
||||
report := ScreenshotEvidenceReport{
|
||||
Status: "passed",
|
||||
Metrics: ScreenshotEvidenceMetrics{Slides: len(deck.Slides)},
|
||||
Issues: []ScreenshotEvidenceIssue{},
|
||||
Slides: []ScreenshotEvidenceSlide{},
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
evidence, err := captureSlideScreenshot(safeRoot, chrome, slide)
|
||||
if err != nil {
|
||||
addScreenshotEvidenceIssue(&report, strings.TrimSpace(slide.Path), "svglide.screenshot_evidence.capture_failed", err.Error())
|
||||
continue
|
||||
}
|
||||
report.Slides = append(report.Slides, evidence)
|
||||
}
|
||||
report = validateScreenshotEvidenceReport(safeRoot, deck, report)
|
||||
if err := writeScreenshotEvidenceReport(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
return report, fmt.Errorf("%s status is %q, want passed", screenshotEvidenceReportPath, report.Status)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func captureSlideScreenshot(safeRoot string, chrome string, slide authorDeckSlide) (ScreenshotEvidenceSlide, error) {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return ScreenshotEvidenceSlide{}, err
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
return ScreenshotEvidenceSlide{}, err
|
||||
}
|
||||
canvasWidth, canvasHeight := imageUsageCanvasSize(raw)
|
||||
viewportWidth := int(canvasWidth)
|
||||
viewportHeight := int(canvasHeight)
|
||||
if viewportWidth <= 0 || viewportHeight <= 0 {
|
||||
return ScreenshotEvidenceSlide{}, fmt.Errorf("invalid canvas %.0fx%.0f for %s", canvasWidth, canvasHeight, slidePath)
|
||||
}
|
||||
screenshotPath := filepath.ToSlash(filepath.Join("screenshots", strings.TrimSuffix(filepath.Base(slidePath), filepath.Ext(slidePath))+".png"))
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, screenshotPath)
|
||||
if err != nil {
|
||||
return ScreenshotEvidenceSlide{}, err
|
||||
}
|
||||
htmlPath, err := writeSlideScreenshotHTML(safeRoot, slidePath, raw, viewportWidth, viewportHeight)
|
||||
if err != nil {
|
||||
return ScreenshotEvidenceSlide{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := runChromeScreenshot(ctx, chrome, htmlPath, target, viewportWidth, viewportHeight); err != nil {
|
||||
return ScreenshotEvidenceSlide{}, err
|
||||
}
|
||||
pixelWidth, pixelHeight := pngDimensions(target)
|
||||
return ScreenshotEvidenceSlide{
|
||||
SlideID: strings.TrimSpace(slide.ID),
|
||||
SlidePath: slidePath,
|
||||
ScreenshotPath: screenshotPath,
|
||||
CanvasWidth: canvasWidth,
|
||||
CanvasHeight: canvasHeight,
|
||||
ViewportWidth: viewportWidth,
|
||||
ViewportHeight: viewportHeight,
|
||||
PixelWidth: pixelWidth,
|
||||
PixelHeight: pixelHeight,
|
||||
Scale: 1,
|
||||
Status: "passed",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeSlideScreenshotHTML(safeRoot string, slidePath string, raw []byte, width int, height int) (string, error) {
|
||||
htmlPath := filepath.Join(safeRoot, "screenshots", "."+strings.TrimSuffix(filepath.Base(slidePath), filepath.Ext(slidePath))+".html")
|
||||
if err := os.MkdirAll(filepath.Dir(htmlPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:` + fmt.Sprint(width) + `px;height:` + fmt.Sprint(height) + `px;overflow:hidden;background:#fff}svg{display:block;width:` + fmt.Sprint(width) + `px;height:` + fmt.Sprint(height) + `px}</style></head><body>` + string(raw) + `</body></html>`
|
||||
if strings.Contains(strings.ToLower(string(raw)), "<script") {
|
||||
body = `<!doctype html><html><body><pre>` + html.EscapeString(string(raw)) + `</pre></body></html>`
|
||||
}
|
||||
if err := os.WriteFile(htmlPath, []byte(body), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return htmlPath, nil
|
||||
}
|
||||
|
||||
func runChromeScreenshot(ctx context.Context, chrome string, htmlPath string, target string, width int, height int) error {
|
||||
args := []string{
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
fmt.Sprintf("--window-size=%d,%d", width, height),
|
||||
"--screenshot=" + target,
|
||||
localFileURL(htmlPath),
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, chrome, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("chrome screenshot failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findChromeExecutable() (string, error) {
|
||||
candidates := []string{}
|
||||
if env := strings.TrimSpace(os.Getenv("SVGLIDE_CHROME")); env != "" {
|
||||
candidates = append(candidates, env)
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
candidates = append(candidates,
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
filepath.Join(os.Getenv("HOME"), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
)
|
||||
default:
|
||||
candidates = append(candidates, "google-chrome", "chromium", "chromium-browser")
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if strings.Contains(candidate, string(os.PathSeparator)) {
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if path, err := exec.LookPath(candidate); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("Chrome/Chromium executable not found; set SVGLIDE_CHROME")
|
||||
}
|
||||
|
||||
func pngDimensions(path string) (int, int) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
cfg, _, err := image.DecodeConfig(file)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func localFileURL(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}).String()
|
||||
}
|
||||
|
||||
func screenshotErrorReport(slides int, path, code, message string) ScreenshotEvidenceReport {
|
||||
return ScreenshotEvidenceReport{
|
||||
Status: "failed",
|
||||
Metrics: ScreenshotEvidenceMetrics{Slides: slides, MissingScreenshots: slides, IssueCount: 1},
|
||||
Issues: []ScreenshotEvidenceIssue{{
|
||||
Code: code,
|
||||
Path: path,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
}},
|
||||
Slides: []ScreenshotEvidenceSlide{},
|
||||
}
|
||||
}
|
||||
234
internal/svglide/media_pressure.go
Normal file
234
internal/svglide/media_pressure.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const mediaPressureReportPath = "receipts/media_pressure.json"
|
||||
|
||||
type MediaPressureReport struct {
|
||||
Status string `json:"status"`
|
||||
Metrics MediaPressureMetrics `json:"metrics"`
|
||||
Issues []MediaPressureIssue `json:"issues"`
|
||||
Slides []MediaPressureSlide `json:"slides"`
|
||||
Policy MediaPressurePolicy `json:"policy"`
|
||||
}
|
||||
|
||||
type MediaPressureMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
RealImagePages int `json:"real_image_pages"`
|
||||
DominantRealImagePages int `json:"dominant_real_image_pages"`
|
||||
CoverDominantRealImagePages int `json:"cover_dominant_real_image_pages"`
|
||||
MaxConsecutiveInfographicPages int `json:"max_consecutive_infographic_pages"`
|
||||
UniqueRealImages int `json:"unique_real_images"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
}
|
||||
|
||||
type MediaPressureIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type MediaPressureSlide struct {
|
||||
SlideID string `json:"slide_id"`
|
||||
Path string `json:"path"`
|
||||
VisualRole string `json:"visual_role"`
|
||||
LargestImageAreaBP int `json:"largest_image_area_bp"`
|
||||
RealImageCount int `json:"real_image_count"`
|
||||
DominantRealImage bool `json:"dominant_real_image"`
|
||||
InfographicOnly bool `json:"infographic_only"`
|
||||
}
|
||||
|
||||
type MediaPressurePolicy struct {
|
||||
TopicArchetype string `json:"topic_archetype"`
|
||||
MinRealImagePages int `json:"min_real_image_pages"`
|
||||
MinDominantRealImagePages int `json:"min_dominant_real_image_pages"`
|
||||
DominantImageMinAreaBP int `json:"dominant_image_min_area_bp"`
|
||||
RequireCoverDominantRealImage bool `json:"require_cover_dominant_real_image"`
|
||||
MaxConsecutiveInfographicOnlyPages int `json:"max_consecutive_infographic_only_pages"`
|
||||
MinUniqueRealImages int `json:"min_unique_real_images"`
|
||||
}
|
||||
|
||||
type mediaPressureContract struct {
|
||||
MinRealImagePages int `json:"min_real_image_pages"`
|
||||
MinDominantRealImagePages int `json:"min_dominant_real_image_pages"`
|
||||
DominantImageMinAreaBP int `json:"dominant_image_min_area_bp"`
|
||||
RequireCoverDominantRealImage bool `json:"require_cover_dominant_real_image"`
|
||||
MaxConsecutiveInfographicOnlyPages int `json:"max_consecutive_infographic_only_pages"`
|
||||
MinUniqueRealImages int `json:"min_unique_real_images"`
|
||||
}
|
||||
|
||||
func EvaluateMediaPressureRun(deck authorDeck, contract qualityVisualContract, imageUsage ImageUsageReport) MediaPressureReport {
|
||||
policy := resolveMediaPressurePolicy(deck, contract)
|
||||
report := MediaPressureReport{
|
||||
Status: "passed",
|
||||
Metrics: MediaPressureMetrics{Slides: len(deck.Slides)},
|
||||
Issues: []MediaPressureIssue{},
|
||||
Slides: []MediaPressureSlide{},
|
||||
Policy: policy,
|
||||
}
|
||||
usageBySlide := map[string]ImageUsageSlide{}
|
||||
uniqueImages := map[string]bool{}
|
||||
for _, slide := range imageUsage.Slides {
|
||||
usageBySlide[strings.TrimSpace(slide.SlideID)] = slide
|
||||
for _, asset := range slide.Assets {
|
||||
if strings.TrimSpace(asset.Path) != "" {
|
||||
uniqueImages[asset.Path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Metrics.UniqueRealImages = len(uniqueImages)
|
||||
infographicRun := 0
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
usage := usageBySlide[id]
|
||||
stat := MediaPressureSlide{
|
||||
SlideID: id,
|
||||
Path: strings.TrimSpace(slide.Path),
|
||||
VisualRole: normalizedSlideVisualRole(slide),
|
||||
}
|
||||
for _, asset := range usage.Assets {
|
||||
stat.RealImageCount++
|
||||
if asset.AreaBP > stat.LargestImageAreaBP {
|
||||
stat.LargestImageAreaBP = asset.AreaBP
|
||||
}
|
||||
}
|
||||
if stat.RealImageCount > 0 {
|
||||
report.Metrics.RealImagePages++
|
||||
}
|
||||
stat.DominantRealImage = stat.LargestImageAreaBP >= policy.DominantImageMinAreaBP
|
||||
if stat.DominantRealImage {
|
||||
report.Metrics.DominantRealImagePages++
|
||||
if isCoverSlide(slide) {
|
||||
report.Metrics.CoverDominantRealImagePages++
|
||||
}
|
||||
}
|
||||
stat.InfographicOnly = !stat.DominantRealImage
|
||||
if stat.InfographicOnly {
|
||||
infographicRun++
|
||||
if infographicRun > report.Metrics.MaxConsecutiveInfographicPages {
|
||||
report.Metrics.MaxConsecutiveInfographicPages = infographicRun
|
||||
}
|
||||
} else {
|
||||
infographicRun = 0
|
||||
}
|
||||
report.Slides = append(report.Slides, stat)
|
||||
}
|
||||
addMediaPressureFailures(&report)
|
||||
if len(report.Issues) > 0 {
|
||||
report.Status = "failed"
|
||||
report.Metrics.IssueCount = len(report.Issues)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func writeMediaPressureReport(safeRoot string, report MediaPressureReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, mediaPressureReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func resolveMediaPressurePolicy(deck authorDeck, contract qualityVisualContract) MediaPressurePolicy {
|
||||
policy := defaultMediaPressurePolicy(strings.TrimSpace(contract.TopicArchetype), len(deck.Slides))
|
||||
contractPolicy := contract.MediaPressure
|
||||
if contractPolicy.MinRealImagePages > 0 {
|
||||
policy.MinRealImagePages = contractPolicy.MinRealImagePages
|
||||
}
|
||||
if contractPolicy.MinDominantRealImagePages > 0 {
|
||||
policy.MinDominantRealImagePages = contractPolicy.MinDominantRealImagePages
|
||||
}
|
||||
if contractPolicy.DominantImageMinAreaBP > 0 {
|
||||
policy.DominantImageMinAreaBP = contractPolicy.DominantImageMinAreaBP
|
||||
}
|
||||
if contractPolicy.RequireCoverDominantRealImage {
|
||||
policy.RequireCoverDominantRealImage = true
|
||||
}
|
||||
if contractPolicy.MaxConsecutiveInfographicOnlyPages > 0 {
|
||||
policy.MaxConsecutiveInfographicOnlyPages = contractPolicy.MaxConsecutiveInfographicOnlyPages
|
||||
}
|
||||
if contractPolicy.MinUniqueRealImages > 0 {
|
||||
policy.MinUniqueRealImages = contractPolicy.MinUniqueRealImages
|
||||
}
|
||||
if policy.DominantImageMinAreaBP <= 0 {
|
||||
policy.DominantImageMinAreaBP = 3000
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func defaultMediaPressurePolicy(archetype string, slides int) MediaPressurePolicy {
|
||||
policy := MediaPressurePolicy{
|
||||
TopicArchetype: archetype,
|
||||
DominantImageMinAreaBP: 3000,
|
||||
}
|
||||
switch strings.TrimSpace(archetype) {
|
||||
case "financial_company_report", "named_company_report":
|
||||
policy.MinRealImagePages = minPositive(slides, 2)
|
||||
policy.MinDominantRealImagePages = minPositive(slides, 2)
|
||||
policy.RequireCoverDominantRealImage = true
|
||||
policy.MaxConsecutiveInfographicOnlyPages = 3
|
||||
policy.MinUniqueRealImages = minPositive(slides, 2)
|
||||
case "premium_product_brand", "brand_official_site":
|
||||
policy.MinRealImagePages = minPositive(slides, 4)
|
||||
policy.MinDominantRealImagePages = minPositive(slides, 4)
|
||||
policy.RequireCoverDominantRealImage = true
|
||||
policy.MaxConsecutiveInfographicOnlyPages = 2
|
||||
policy.MinUniqueRealImages = minPositive(slides, 3)
|
||||
case "sports_editorial", "event_editorial":
|
||||
policy.MinRealImagePages = minPositive(slides, 3)
|
||||
policy.MinDominantRealImagePages = minPositive(slides, 3)
|
||||
policy.RequireCoverDominantRealImage = true
|
||||
policy.MaxConsecutiveInfographicOnlyPages = 2
|
||||
policy.MinUniqueRealImages = minPositive(slides, 3)
|
||||
case "cultural_lifestyle_editorial", "food_beverage_culture":
|
||||
policy.MinRealImagePages = minPositive(slides, 4)
|
||||
policy.MinDominantRealImagePages = minPositive(slides, 3)
|
||||
policy.RequireCoverDominantRealImage = true
|
||||
policy.MaxConsecutiveInfographicOnlyPages = 2
|
||||
policy.MinUniqueRealImages = minPositive(slides, 4)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func addMediaPressureFailures(report *MediaPressureReport) {
|
||||
policy := report.Policy
|
||||
if policy.MinRealImagePages > 0 && report.Metrics.RealImagePages < policy.MinRealImagePages {
|
||||
addMediaPressureIssue(report, mediaPressureReportPath, "svglide.media_pressure.real_image_pages", fmt.Sprintf("media pressure requires at least %d real-image page(s), got %d", policy.MinRealImagePages, report.Metrics.RealImagePages))
|
||||
}
|
||||
if policy.MinDominantRealImagePages > 0 && report.Metrics.DominantRealImagePages < policy.MinDominantRealImagePages {
|
||||
addMediaPressureIssue(report, mediaPressureReportPath, "svglide.media_pressure.dominant_real_image_pages", fmt.Sprintf("media pressure requires at least %d dominant real-image page(s), got %d", policy.MinDominantRealImagePages, report.Metrics.DominantRealImagePages))
|
||||
}
|
||||
if policy.RequireCoverDominantRealImage && report.Metrics.CoverDominantRealImagePages == 0 {
|
||||
addMediaPressureIssue(report, "slides", "svglide.media_pressure.cover_dominant_real_image", "media pressure requires the cover to use a dominant real image")
|
||||
}
|
||||
if policy.MaxConsecutiveInfographicOnlyPages > 0 && report.Metrics.MaxConsecutiveInfographicPages > policy.MaxConsecutiveInfographicOnlyPages {
|
||||
addMediaPressureIssue(report, mediaPressureReportPath, "svglide.media_pressure.infographic_run", fmt.Sprintf("media pressure allows at most %d consecutive infographic-only page(s), got %d", policy.MaxConsecutiveInfographicOnlyPages, report.Metrics.MaxConsecutiveInfographicPages))
|
||||
}
|
||||
if policy.MinUniqueRealImages > 0 && report.Metrics.UniqueRealImages < policy.MinUniqueRealImages {
|
||||
addMediaPressureIssue(report, filepath.ToSlash(assetInventoryPath), "svglide.media_pressure.unique_real_images", fmt.Sprintf("media pressure requires at least %d unique real image(s), got %d", policy.MinUniqueRealImages, report.Metrics.UniqueRealImages))
|
||||
}
|
||||
}
|
||||
|
||||
func addMediaPressureIssue(report *MediaPressureReport, path, code, message string) {
|
||||
report.Issues = append(report.Issues, MediaPressureIssue{
|
||||
Code: code,
|
||||
Path: path,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
})
|
||||
}
|
||||
|
||||
func minPositive(a, b int) int {
|
||||
if a <= 0 {
|
||||
return 0
|
||||
}
|
||||
if b <= 0 || a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
106
internal/svglide/media_pressure_test.go
Normal file
106
internal/svglide/media_pressure_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMediaPressureRejectsCompanyReportWithoutDominantRealHero(t *testing.T) {
|
||||
deck := authorDeck{Slides: []authorDeckSlide{
|
||||
{ID: "s1", Role: "cover", VisualRole: "hero_cover", Path: "slides/01.svg"},
|
||||
{ID: "s2", Role: "income", Path: "slides/02.svg"},
|
||||
{ID: "s3", Role: "peers", Path: "slides/03.svg"},
|
||||
}}
|
||||
contract := qualityVisualContract{
|
||||
TopicArchetype: "financial_company_report",
|
||||
MediaPressure: mediaPressureContract{
|
||||
MinDominantRealImagePages: 2,
|
||||
RequireCoverDominantRealImage: true,
|
||||
MaxConsecutiveInfographicOnlyPages: 2,
|
||||
},
|
||||
}
|
||||
usage := ImageUsageReport{Status: "passed", Slides: []ImageUsageSlide{
|
||||
{SlideID: "s1", CanvasWidth: 960, CanvasHeight: 540, Assets: []ImageUsageAsset{}},
|
||||
{SlideID: "s2", CanvasWidth: 960, CanvasHeight: 540, Assets: []ImageUsageAsset{}},
|
||||
{SlideID: "s3", CanvasWidth: 960, CanvasHeight: 540, Assets: []ImageUsageAsset{}},
|
||||
}}
|
||||
|
||||
report := EvaluateMediaPressureRun(deck, contract, usage)
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !mediaPressureIssueCodesContain(report.Issues, "svglide.media_pressure.cover_dominant_real_image") {
|
||||
t.Fatalf("issues = %+v, want cover dominant real image issue", report.Issues)
|
||||
}
|
||||
if !mediaPressureIssueCodesContain(report.Issues, "svglide.media_pressure.dominant_real_image_pages") {
|
||||
t.Fatalf("issues = %+v, want dominant image page issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaPressureAllowsConfiguredDominantImageRhythm(t *testing.T) {
|
||||
deck := authorDeck{Slides: []authorDeckSlide{
|
||||
{ID: "s1", Role: "cover", VisualRole: "hero_cover", Path: "slides/01.svg"},
|
||||
{ID: "s2", Role: "analysis", Path: "slides/02.svg"},
|
||||
{ID: "s3", Role: "moment", Path: "slides/03.svg"},
|
||||
{ID: "s4", Role: "closing", Path: "slides/04.svg"},
|
||||
}}
|
||||
contract := qualityVisualContract{
|
||||
TopicArchetype: "sports_editorial",
|
||||
MediaPressure: mediaPressureContract{
|
||||
MinRealImagePages: 2,
|
||||
MinDominantRealImagePages: 2,
|
||||
RequireCoverDominantRealImage: true,
|
||||
MaxConsecutiveInfographicOnlyPages: 2,
|
||||
MinUniqueRealImages: 2,
|
||||
},
|
||||
}
|
||||
usage := ImageUsageReport{Status: "passed", Slides: []ImageUsageSlide{
|
||||
{SlideID: "s1", Assets: []ImageUsageAsset{{Path: "assets/images/cover.jpg", AreaBP: 8000}}},
|
||||
{SlideID: "s2", Assets: []ImageUsageAsset{}},
|
||||
{SlideID: "s3", Assets: []ImageUsageAsset{{Path: "assets/images/moment.jpg", AreaBP: 4500}}},
|
||||
{SlideID: "s4", Assets: []ImageUsageAsset{}},
|
||||
}}
|
||||
|
||||
report := EvaluateMediaPressureRun(deck, contract, usage)
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
if report.Metrics.DominantRealImagePages != 2 || report.Metrics.MaxConsecutiveInfographicPages != 1 {
|
||||
t.Fatalf("metrics = %+v, want two dominant pages and max infographic run 1", report.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaPressureDefaultPolicyRequiresCultureRealImages(t *testing.T) {
|
||||
deck := authorDeck{Slides: []authorDeckSlide{
|
||||
{ID: "s1", Role: "cover", VisualRole: "hero_cover", Path: "slides/01.svg"},
|
||||
{ID: "s2", Role: "taxonomy", Path: "slides/02.svg"},
|
||||
{ID: "s3", Role: "process", Path: "slides/03.svg"},
|
||||
{ID: "s4", Role: "closing", Path: "slides/04.svg"},
|
||||
}}
|
||||
contract := qualityVisualContract{
|
||||
TopicArchetype: "cultural_lifestyle_editorial",
|
||||
}
|
||||
usage := ImageUsageReport{Status: "passed", Slides: []ImageUsageSlide{
|
||||
{SlideID: "s1", Assets: []ImageUsageAsset{}},
|
||||
{SlideID: "s2", Assets: []ImageUsageAsset{}},
|
||||
{SlideID: "s3", Assets: []ImageUsageAsset{}},
|
||||
{SlideID: "s4", Assets: []ImageUsageAsset{}},
|
||||
}}
|
||||
|
||||
report := EvaluateMediaPressureRun(deck, contract, usage)
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Policy.MinRealImagePages == 0 || report.Policy.MinUniqueRealImages == 0 {
|
||||
t.Fatalf("policy = %+v, want culture default real-image pressure", report.Policy)
|
||||
}
|
||||
if !mediaPressureIssueCodesContain(report.Issues, "svglide.media_pressure.real_image_pages") {
|
||||
t.Fatalf("issues = %+v, want real_image_pages issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPressureIssueCodesContain(issues []MediaPressureIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
202
internal/svglide/metrics.go
Normal file
202
internal/svglide/metrics.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SemanticMetrics struct {
|
||||
SlideCount int `json:"slide_count"`
|
||||
SlidesWithSlideRole int `json:"slides_with_slide_role"`
|
||||
ImageCount int `json:"image_count"`
|
||||
TextCount int `json:"text_count"`
|
||||
NoteCount int `json:"note_count"`
|
||||
SourceRefCount int `json:"source_ref_count"`
|
||||
MissingAssetCount int `json:"missing_asset_count"`
|
||||
SlidesWithoutSourceRefs int `json:"slides_without_source_refs"`
|
||||
VisibleLeakCount int `json:"visible_leak_count"`
|
||||
FontTokenCount int `json:"font_token_count"`
|
||||
MissingFontTokenCount int `json:"missing_font_token_count"`
|
||||
ParserUnsafeCount int `json:"parser_unsafe_count"`
|
||||
}
|
||||
|
||||
func MissingAssetCountForRun(safeRoot string, run Run) int {
|
||||
metrics, err := ComputeSemanticMetrics(safeRoot, run)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return metrics.MissingAssetCount
|
||||
}
|
||||
|
||||
func ComputeSemanticMetrics(safeRoot string, run Run) (SemanticMetrics, error) {
|
||||
var metrics SemanticMetrics
|
||||
deck, err := readAuthorDeck(safeRoot, semanticDeckPath(run))
|
||||
if err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
metrics.SlideCount = len(deck.Slides)
|
||||
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err == nil {
|
||||
sourceRefBySlideID := make(map[string]int, len(content.Slides))
|
||||
for _, slide := range content.Slides {
|
||||
count := 0
|
||||
for _, ref := range slide.SourceRefs {
|
||||
if strings.TrimSpace(ref) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
sourceRefBySlideID[strings.TrimSpace(slide.ID)] = count
|
||||
metrics.SourceRefCount += count
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
if sourceRefBySlideID[strings.TrimSpace(slide.ID)] == 0 {
|
||||
metrics.SlidesWithoutSourceRefs++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readyAssets := map[string]deckAsset{}
|
||||
if assets, err := readAssetsManifest(safeRoot); err == nil {
|
||||
for _, asset := range assets.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
if path != "" {
|
||||
readyAssets[path] = asset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
svg := string(raw)
|
||||
metrics.VisibleLeakCount += countVisibleLeakMarkers(svg)
|
||||
fontTokens := countFontTokens(svg)
|
||||
metrics.FontTokenCount += fontTokens
|
||||
metrics.ParserUnsafeCount += len(lintParserSafeSVG(slidePath, raw))
|
||||
if strings.Contains(svg, `slide:role="slide"`) || strings.Contains(svg, `slide:role='slide'`) {
|
||||
metrics.SlidesWithSlideRole++
|
||||
}
|
||||
metrics.TextCount += strings.Count(svg, "<text")
|
||||
metrics.TextCount += strings.Count(svg, `slide:shape-type="text"`)
|
||||
metrics.NoteCount += strings.Count(svg, "<slide:note")
|
||||
for _, ref := range activeSVGAssetRefs(svg) {
|
||||
switch ref.Kind {
|
||||
case "image":
|
||||
metrics.ImageCount++
|
||||
}
|
||||
resolvedHref, hrefErr := svgHrefRunPath(slidePath, ref.Href)
|
||||
if hrefErr != nil {
|
||||
metrics.MissingAssetCount++
|
||||
continue
|
||||
}
|
||||
asset, ok := readyAssets[resolvedHref]
|
||||
if !ok {
|
||||
metrics.MissingAssetCount++
|
||||
continue
|
||||
}
|
||||
if err := readyAssetLocalAvailability(safeRoot, run, asset); err != nil {
|
||||
metrics.MissingAssetCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func countVisibleLeakMarkers(svg string) int {
|
||||
count := 0
|
||||
lower := strings.ToLower(visibleSemanticText(svg))
|
||||
for _, marker := range []string{
|
||||
"sources:",
|
||||
"source note",
|
||||
"production_instruction",
|
||||
"图片要完整",
|
||||
"必须让眼镜完整出现",
|
||||
"不要裁切",
|
||||
"来源来自官网",
|
||||
} {
|
||||
count += strings.Count(lower, strings.ToLower(marker))
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func visibleSemanticText(svg string) string {
|
||||
decoder := xml.NewDecoder(strings.NewReader(svg))
|
||||
var builder strings.Builder
|
||||
excludedDepth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return builder.String()
|
||||
}
|
||||
return svg
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
if excludedDepth > 0 || semanticTextExcludedElement(typed) {
|
||||
excludedDepth++
|
||||
}
|
||||
case xml.CharData:
|
||||
if excludedDepth == 0 {
|
||||
builder.WriteByte(' ')
|
||||
builder.Write(typed)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if excludedDepth > 0 {
|
||||
excludedDepth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func semanticTextExcludedElement(start xml.StartElement) bool {
|
||||
if start.Name.Space == slideNamespace && start.Name.Local == "note" {
|
||||
return true
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "style", "script", "metadata", "title", "desc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func countFontTokens(svg string) int {
|
||||
count := 0
|
||||
for _, token := range []string{"--font-display", "--font-body", "--font-number", "--font-label"} {
|
||||
if strings.Contains(svg, token) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func readyAssetLocalAvailability(safeRoot string, run Run, asset deckAsset) error {
|
||||
path := assetPath(asset)
|
||||
if strings.HasPrefix(path, "https://") {
|
||||
if normalizedRouteProfile(run.RouteProfile) == RouteProfileLocalSVGDeck {
|
||||
return fmt.Errorf("local_svg_deck ready image asset path %q must be a local assets/images/<file>", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, _, exists, err := lstatRunPath(safeRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("asset path %q is missing or not a regular file", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
295
internal/svglide/online_publish.go
Normal file
295
internal/svglide/online_publish.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
onlineSlideReportPath = "publish/online_slide.json"
|
||||
onlinePublishReceiptRel = "receipts/publish_online.json"
|
||||
)
|
||||
|
||||
type OnlineSlidePublishReport struct {
|
||||
Status string `json:"status"`
|
||||
PresentationID string `json:"presentation_id,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SlideCount int `json:"slide_count"`
|
||||
Publisher string `json:"publisher"`
|
||||
BlockedReasonCode string `json:"blocked_reason_code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type OnlinePublishReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Report OnlineSlidePublishReport `json:"report"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type OnlinePublisher interface {
|
||||
Publish(root string, evidence SVGPublishRequestEvidence) (OnlineSlidePublishReport, error)
|
||||
}
|
||||
|
||||
type PublishOnlineOptions struct {
|
||||
AllowSmokePublish bool
|
||||
}
|
||||
|
||||
type MissingOnlinePublisher struct{}
|
||||
|
||||
func (MissingOnlinePublisher) Publish(root string, evidence SVGPublishRequestEvidence) (OnlineSlidePublishReport, error) {
|
||||
return OnlineSlidePublishReport{
|
||||
Status: StatusBlocked,
|
||||
Publisher: "missing",
|
||||
BlockedReasonCode: "svglide.publish_online.missing_publisher",
|
||||
Message: "online slide delivery was requested, but no Lark Slides publisher is configured",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PublishOnlineRun(root string, publisher OnlinePublisher) (OnlineSlidePublishReport, error) {
|
||||
return PublishOnlineRunWithOptions(root, publisher, PublishOnlineOptions{})
|
||||
}
|
||||
|
||||
func PublishOnlineRunWithOptions(root string, publisher OnlinePublisher, opts PublishOnlineOptions) (OnlineSlidePublishReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return OnlineSlidePublishReport{}, err
|
||||
}
|
||||
if run.SmokeTest && !opts.AllowSmokePublish {
|
||||
report := OnlineSlidePublishReport{
|
||||
Status: StatusBlocked,
|
||||
Publisher: "smoke_guard",
|
||||
BlockedReasonCode: "svglide.publish_online.smoke_publish_not_allowed",
|
||||
Message: "smoke SVGlide runs cannot be published unless --allow-smoke-publish is explicitly set",
|
||||
}
|
||||
if writeErr := writeOnlinePublishArtifacts(safeRoot, report); writeErr != nil {
|
||||
return report, writeErr
|
||||
}
|
||||
return report, fmt.Errorf("publish_online blocked: %s", report.BlockedReasonCode)
|
||||
}
|
||||
if run.FullChainRequired {
|
||||
if err := validateFullChainReadyForPublish(safeRoot, run); err != nil {
|
||||
report := OnlineSlidePublishReport{
|
||||
Status: StatusBlocked,
|
||||
Publisher: "full_chain_gate",
|
||||
BlockedReasonCode: "svglide.publish_online.full_chain_incomplete",
|
||||
Message: err.Error(),
|
||||
}
|
||||
if writeErr := writeOnlinePublishArtifacts(safeRoot, report); writeErr != nil {
|
||||
return report, writeErr
|
||||
}
|
||||
return report, fmt.Errorf("publish_online blocked by full-chain gate: %w", err)
|
||||
}
|
||||
}
|
||||
contract, _, err := readDeliveryContract(safeRoot, run)
|
||||
if err != nil {
|
||||
return OnlineSlidePublishReport{}, err
|
||||
}
|
||||
if !contract.RequiresOnlineSlide {
|
||||
report := OnlineSlidePublishReport{
|
||||
Status: StatusBlocked,
|
||||
Publisher: "none",
|
||||
BlockedReasonCode: "svglide.publish_online.not_requested",
|
||||
Message: "online slide delivery was not requested by delivery_contract",
|
||||
}
|
||||
if writeErr := writeOnlinePublishArtifacts(safeRoot, report); writeErr != nil {
|
||||
return report, writeErr
|
||||
}
|
||||
return report, fmt.Errorf("publish_online status is %q", report.Status)
|
||||
}
|
||||
evidence, err := buildSVGPublishRequestEvidence(safeRoot, run)
|
||||
if writeErr := writeSVGPublishRequestEvidence(safeRoot, evidence); writeErr != nil {
|
||||
if err != nil {
|
||||
return OnlineSlidePublishReport{}, fmt.Errorf("%w; write SVG publish request evidence: %v", err, writeErr)
|
||||
}
|
||||
return OnlineSlidePublishReport{}, writeErr
|
||||
}
|
||||
if err != nil {
|
||||
report := OnlineSlidePublishReport{
|
||||
Status: StatusBlocked,
|
||||
Publisher: "svg_payload_gate",
|
||||
BlockedReasonCode: "svglide.publish_online.svg_payload_gate_failed",
|
||||
Message: err.Error(),
|
||||
}
|
||||
if writeErr := writeOnlinePublishArtifacts(safeRoot, report); writeErr != nil {
|
||||
return report, fmt.Errorf("%w; write publish artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, fmt.Errorf("publish_online blocked by SVG payload gate: %w", err)
|
||||
}
|
||||
if publisher == nil {
|
||||
publisher = MissingOnlinePublisher{}
|
||||
}
|
||||
report, err := publisher.Publish(root, evidence)
|
||||
if err != nil {
|
||||
if writeErr := writeOnlinePublishArtifacts(safeRoot, report); writeErr != nil {
|
||||
return report, fmt.Errorf("%w; write publish artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, err
|
||||
}
|
||||
if report.Status == "" {
|
||||
report.Status = StatusBlocked
|
||||
}
|
||||
if report.Publisher == "" {
|
||||
report.Publisher = "unknown"
|
||||
}
|
||||
if writeErr := writeOnlinePublishArtifacts(safeRoot, report); writeErr != nil {
|
||||
return report, writeErr
|
||||
}
|
||||
if writeErr := writeRequiredToolCallReceipts(safeRoot, StagePublishOnline, run); writeErr != nil {
|
||||
return report, writeErr
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
return report, fmt.Errorf("publish_online status is %q", report.Status)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func validateFullChainReadyForPublish(safeRoot string, run Run) error {
|
||||
if normalizeExecutionProfile(run.ExecutionProfile) != ExecutionProfileFullChain {
|
||||
return nil
|
||||
}
|
||||
if !run.FullChainRequired {
|
||||
return nil
|
||||
}
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == StagePublishOnline {
|
||||
break
|
||||
}
|
||||
if stage.Status != StatusDone {
|
||||
return fmt.Errorf("stage %q status is %q, want done before publish", stage.Name, stage.Status)
|
||||
}
|
||||
path, err := existingDeliveryEvidencePath(safeRoot, stage.Receipt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("stage %q receipt %s is missing", stage.Name, stage.Receipt)
|
||||
}
|
||||
valid, err := validDeliveryStageReceipt(safeRoot, stage, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("stage %q receipt %s is not passed/done", stage.Name, stage.Receipt)
|
||||
}
|
||||
}
|
||||
localEvidence := []string{
|
||||
"run.json",
|
||||
"prompt_manifest.json",
|
||||
"request/request.json",
|
||||
"request/source_manifest.json",
|
||||
"request/entity_resolution.json",
|
||||
"request/theme_contract.json",
|
||||
deliveryContractPath,
|
||||
"research/research_plan.json",
|
||||
"research/queries.json",
|
||||
"research/research_notes.md",
|
||||
"research/sources.json",
|
||||
"research/research_coverage.json",
|
||||
"brief/design_brief.json",
|
||||
"brief/visual_system.json",
|
||||
"brief/typography_contract.json",
|
||||
"brief/visual_quality_contract.json",
|
||||
"outline/deck.json",
|
||||
"content/slide_content.md",
|
||||
"content/slide_content.json",
|
||||
"content/slide_copy_plan.json",
|
||||
"assets/image_candidates.json",
|
||||
"assets/assets_plan.json",
|
||||
"assets/assets_manifest.json",
|
||||
"assets/asset_inventory.json",
|
||||
"assets/charts/chart_briefs.json",
|
||||
"assets/charts/chart_manifest.json",
|
||||
chartRenderReceiptPath,
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
renderedVisualReceiptPath,
|
||||
imageUsageReportPath,
|
||||
mediaPressureReportPath,
|
||||
chartUsageReceiptPath,
|
||||
contentPayloadReportPath,
|
||||
"quality_report.json",
|
||||
anyGenSemanticReportPath,
|
||||
visualReceiptsPath,
|
||||
creativeQualityReportPath,
|
||||
editorialQualityReportPath,
|
||||
screenshotEvidenceReportPath,
|
||||
chartQualityReportPath,
|
||||
deliveryReceiptPath,
|
||||
}
|
||||
for _, rel := range localEvidence {
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("full-chain evidence %s is missing", rel)
|
||||
}
|
||||
}
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == StageRequest || stage.Name == StagePublishOnline {
|
||||
continue
|
||||
}
|
||||
rel := promptContextReceiptPath(stage.Name)
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("prompt context receipt %s is missing", rel)
|
||||
}
|
||||
}
|
||||
slides, err := publishSlidePaths(safeRoot, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(slides) == 0 {
|
||||
return fmt.Errorf("full-chain evidence has no slides")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeOnlinePublishArtifacts(safeRoot string, report OnlineSlidePublishReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, onlineSlideReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(target, report); err != nil {
|
||||
return err
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, onlinePublishReceiptRel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(receiptTarget, OnlinePublishReceipt{
|
||||
Stage: StagePublishOnline,
|
||||
Status: report.Status,
|
||||
Report: report,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func readOnlineSlidePublishReport(safeRoot string) (OnlineSlidePublishReport, bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, onlineSlideReportPath)
|
||||
if err != nil {
|
||||
return OnlineSlidePublishReport{}, false, nil
|
||||
}
|
||||
var report OnlineSlidePublishReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return OnlineSlidePublishReport{}, true, fmt.Errorf("%s: invalid JSON: %w", onlineSlideReportPath, err)
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
func onlineSlideReportEvidencePath(safeRoot string) (string, error) {
|
||||
exists, err := runRegularFileExists(safeRoot, onlineSlideReportPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return "", nil
|
||||
}
|
||||
return filepath.ToSlash(onlineSlideReportPath), nil
|
||||
}
|
||||
181
internal/svglide/online_publish_test.go
Normal file
181
internal/svglide/online_publish_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPublishOnlineMissingPublisherBlocks(t *testing.T) {
|
||||
root := setupPublishOnlineSmokeTestRun(t)
|
||||
writePublishOnlineSVGPayloadForTest(t)
|
||||
report, err := PublishOnlineRunWithOptions(root, nil, PublishOnlineOptions{AllowSmokePublish: true})
|
||||
if err == nil {
|
||||
t.Fatal("PublishOnlineRun err = nil, want blocked error")
|
||||
}
|
||||
if report.Status != StatusBlocked {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, StatusBlocked)
|
||||
}
|
||||
if report.BlockedReasonCode != "svglide.publish_online.missing_publisher" {
|
||||
t.Fatalf("BlockedReasonCode = %q", report.BlockedReasonCode)
|
||||
}
|
||||
raw, readErr := os.ReadFile(filepath.Join(root, onlineSlideReportPath))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var written OnlineSlidePublishReport
|
||||
if err := json.Unmarshal(raw, &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written.Status != StatusBlocked {
|
||||
t.Fatalf("written status = %q, want blocked", written.Status)
|
||||
}
|
||||
assertSVGPublishRequestEvidencePassed(t, root)
|
||||
}
|
||||
|
||||
func TestPublishOnlinePassesSVGRequestEvidenceToPublisher(t *testing.T) {
|
||||
root := setupPublishOnlineSmokeTestRun(t)
|
||||
writePublishOnlineSVGPayloadForTest(t)
|
||||
publisher := &recordingOnlinePublisher{}
|
||||
|
||||
report, err := PublishOnlineRunWithOptions(root, publisher, PublishOnlineOptions{AllowSmokePublish: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed", report.Status)
|
||||
}
|
||||
if !publisher.Called {
|
||||
t.Fatal("publisher was not called")
|
||||
}
|
||||
if publisher.Evidence.ContentType != "svg" || publisher.Evidence.SlideCount != 1 {
|
||||
t.Fatalf("publisher evidence = %+v, want one SVG slide", publisher.Evidence)
|
||||
}
|
||||
if got := publisher.Evidence.Slides[0].ContentRoot; got != "svg" {
|
||||
t.Fatalf("content root = %q, want svg", got)
|
||||
}
|
||||
assertSVGPublishRequestEvidencePassed(t, root)
|
||||
}
|
||||
|
||||
func TestPublishOnlineFullChainBlocksIncompleteRunBeforePublisher(t *testing.T) {
|
||||
root := setupPublishOnlineTestRun(t)
|
||||
writePublishOnlineSVGPayloadForTest(t)
|
||||
publisher := &recordingOnlinePublisher{}
|
||||
|
||||
report, err := PublishOnlineRun(root, publisher)
|
||||
if err == nil {
|
||||
t.Fatal("PublishOnlineRun err = nil, want full-chain incomplete error")
|
||||
}
|
||||
if publisher.Called {
|
||||
t.Fatal("publisher should not be called before full-chain evidence is complete")
|
||||
}
|
||||
if report.Status != StatusBlocked || report.BlockedReasonCode != "svglide.publish_online.full_chain_incomplete" {
|
||||
t.Fatalf("report = %+v, want full_chain_incomplete blocked", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishOnlineBlocksBeforePublisherWhenPayloadIsNotSVG(t *testing.T) {
|
||||
root := setupPublishOnlineSmokeTestRun(t)
|
||||
mustWriteTestFile(t, filepath.Join(root, "outline", "deck.json"), `{"slides":[{"id":"s1","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, filepath.Join(root, "slides", "01.svg"), `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data/></slide>`)
|
||||
publisher := &recordingOnlinePublisher{}
|
||||
|
||||
report, err := PublishOnlineRunWithOptions(root, publisher, PublishOnlineOptions{AllowSmokePublish: true})
|
||||
if err == nil {
|
||||
t.Fatal("PublishOnlineRun err = nil, want SVG payload gate error")
|
||||
}
|
||||
if publisher.Called {
|
||||
t.Fatal("publisher should not be called when SVG payload gate fails")
|
||||
}
|
||||
if report.Status != StatusBlocked || report.BlockedReasonCode != "svglide.publish_online.svg_payload_gate_failed" {
|
||||
t.Fatalf("report = %+v, want SVG payload gate blocked", report)
|
||||
}
|
||||
|
||||
raw, readErr := os.ReadFile(filepath.Join(root, svgPublishRequestEvidencePath))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var evidence SVGPublishRequestEvidence
|
||||
if err := json.Unmarshal(raw, &evidence); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evidence.Status != "failed" || !evidence.ForbiddenFormatDetected {
|
||||
t.Fatalf("evidence = %+v, want failed forbidden format", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPublishOnlineSmokeTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
root := "demo"
|
||||
if err := InitRun(root, InitOptions{
|
||||
Title: "Online Smoke Demo",
|
||||
Topic: "生成线上 SVG PPT smoke",
|
||||
DeliveryTarget: DeliveryTargetOnlineSlide,
|
||||
ExecutionProfile: ExecutionProfileSmoke,
|
||||
Now: time.Date(2026, 7, 7, 20, 0, 0, 0, time.UTC),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func setupPublishOnlineTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
root := "demo"
|
||||
if err := InitRun(root, InitOptions{
|
||||
Title: "Online Demo",
|
||||
Topic: "生成真实美观的线上 SVG PPT",
|
||||
DeliveryTarget: DeliveryTargetOnlineSlide,
|
||||
Now: time.Date(2026, 7, 7, 20, 0, 0, 0, time.UTC),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func writePublishOnlineSVGPayloadForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"slides":[{"id":"s1","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
}
|
||||
|
||||
func assertSVGPublishRequestEvidencePassed(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(root, svgPublishRequestEvidencePath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var evidence SVGPublishRequestEvidence
|
||||
if err := json.Unmarshal(raw, &evidence); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evidence.Status != "passed" || evidence.ContentType != "svg" || evidence.SlideCount != 1 {
|
||||
t.Fatalf("evidence = %+v, want passed SVG evidence for one slide", evidence)
|
||||
}
|
||||
if evidence.Slides[0].ContentRoot != "svg" || evidence.Slides[0].SHA256 == "" {
|
||||
t.Fatalf("slide evidence = %+v, want svg root and sha", evidence.Slides[0])
|
||||
}
|
||||
}
|
||||
|
||||
type recordingOnlinePublisher struct {
|
||||
Called bool
|
||||
Evidence SVGPublishRequestEvidence
|
||||
}
|
||||
|
||||
func (p *recordingOnlinePublisher) Publish(root string, evidence SVGPublishRequestEvidence) (OnlineSlidePublishReport, error) {
|
||||
p.Called = true
|
||||
p.Evidence = evidence
|
||||
return OnlineSlidePublishReport{
|
||||
Status: "passed",
|
||||
Publisher: "recording-test",
|
||||
PresentationID: "pres_svg",
|
||||
URL: "https://example.larkoffice.com/slides/pres_svg",
|
||||
SlideCount: evidence.SlideCount,
|
||||
}, nil
|
||||
}
|
||||
272
internal/svglide/parser_safe.go
Normal file
272
internal/svglide/parser_safe.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
parserSafeSlideWidth = 960
|
||||
parserSafeSlideHeight = 540
|
||||
)
|
||||
|
||||
type parserSafeFrame struct {
|
||||
Name xml.Name
|
||||
Excluded bool
|
||||
InFreeformShape bool
|
||||
}
|
||||
|
||||
type parserSafeTextForeignObject struct {
|
||||
ContentDepth int
|
||||
HasAllowed bool
|
||||
Invalid bool
|
||||
}
|
||||
|
||||
func lintParserSafeSVG(path string, raw []byte) []ValidationIssue {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var issues []ValidationIssue
|
||||
var stack []parserSafeFrame
|
||||
var textFOStack []parserSafeTextForeignObject
|
||||
var rootSeen bool
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
if !rootSeen {
|
||||
rootSeen = true
|
||||
if issue, ok := lintParserSafeCanvas(path, typed); ok {
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
|
||||
parentFreeformShape := len(stack) > 0 && stack[len(stack)-1].InFreeformShape
|
||||
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
|
||||
if typed.Name.Space == svgNamespace && typed.Name.Local == "style" {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.style",
|
||||
Message: "SVG slides for the online parser must not use <style>; inline parser-safe attributes instead",
|
||||
})
|
||||
}
|
||||
if !excluded {
|
||||
issues = append(issues, lintParserSafeElement(path, typed, parentFreeformShape)...)
|
||||
}
|
||||
|
||||
if len(textFOStack) > 0 {
|
||||
currentDepth := len(stack)
|
||||
last := &textFOStack[len(textFOStack)-1]
|
||||
if currentDepth == last.ContentDepth {
|
||||
if parserSafeDirectTextTag(typed) {
|
||||
last.HasAllowed = true
|
||||
} else {
|
||||
last.Invalid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parserSafeTextForeignObjectElement(typed, excluded) {
|
||||
textFOStack = append(textFOStack, parserSafeTextForeignObject{ContentDepth: len(stack) + 1})
|
||||
}
|
||||
stack = append(stack, parserSafeFrame{Name: typed.Name, Excluded: excluded, InFreeformShape: parentFreeformShape || parserSafeFreeformShapeGroup(typed)})
|
||||
case xml.CharData:
|
||||
if len(textFOStack) == 0 || strings.TrimSpace(string(typed)) == "" {
|
||||
continue
|
||||
}
|
||||
last := &textFOStack[len(textFOStack)-1]
|
||||
if len(stack) == last.ContentDepth {
|
||||
last.Invalid = true
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(textFOStack) > 0 && stack[len(stack)-1].Name.Space == svgNamespace && stack[len(stack)-1].Name.Local == "foreignObject" {
|
||||
last := textFOStack[len(textFOStack)-1]
|
||||
if last.Invalid || !last.HasAllowed {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.foreign_object",
|
||||
Message: "text foreignObject must contain direct XHTML p/h1/h2/h3/small/ul/ol children, not div wrappers or bare text",
|
||||
})
|
||||
}
|
||||
textFOStack = textFOStack[:len(textFOStack)-1]
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func lintParserSafeCanvas(path string, root xml.StartElement) (ValidationIssue, bool) {
|
||||
viewBox, ok := rootViewBox(root)
|
||||
if !ok || !viewBox.Valid || !floatEqual(viewBox.Width, parserSafeSlideWidth) || !floatEqual(viewBox.Height, parserSafeSlideHeight) {
|
||||
return ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.canvas",
|
||||
Message: fmt.Sprintf("online parser-safe SVG must use viewBox=\"0 0 %d %d\"", parserSafeSlideWidth, parserSafeSlideHeight),
|
||||
}, true
|
||||
}
|
||||
if width := attrValue(root, "width"); width != "" {
|
||||
parsed, ok := parseSVGDimension(width)
|
||||
if !ok || !floatEqual(parsed, parserSafeSlideWidth) {
|
||||
return ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.canvas",
|
||||
Message: fmt.Sprintf("online parser-safe SVG width must be %d when present", parserSafeSlideWidth),
|
||||
}, true
|
||||
}
|
||||
}
|
||||
if height := attrValue(root, "height"); height != "" {
|
||||
parsed, ok := parseSVGDimension(height)
|
||||
if !ok || !floatEqual(parsed, parserSafeSlideHeight) {
|
||||
return ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.canvas",
|
||||
Message: fmt.Sprintf("online parser-safe SVG height must be %d when present", parserSafeSlideHeight),
|
||||
}, true
|
||||
}
|
||||
}
|
||||
return ValidationIssue{}, false
|
||||
}
|
||||
|
||||
func lintParserSafeElement(path string, start xml.StartElement, inFreeformShape bool) []ValidationIssue {
|
||||
var issues []ValidationIssue
|
||||
if start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan") {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.text",
|
||||
Message: "online parser-safe SVG must use foreignObject text shapes instead of native SVG <text>/<tspan>",
|
||||
})
|
||||
}
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local == "class" && strings.TrimSpace(attr.Value) != "" {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.class",
|
||||
Message: "online parser-safe SVG must not rely on class selectors",
|
||||
})
|
||||
}
|
||||
if strings.Contains(attr.Value, "var(--") {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.css_var",
|
||||
Message: "online parser-safe SVG must not rely on CSS variables",
|
||||
})
|
||||
}
|
||||
}
|
||||
if !inFreeformShape {
|
||||
if issue, ok := lintParserSafeShapeType(path, start); ok {
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func lintParserSafeShapeType(path string, start xml.StartElement) (ValidationIssue, bool) {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return ValidationIssue{}, false
|
||||
}
|
||||
if hasSlideAttr(start, "role", "chart") {
|
||||
return ValidationIssue{}, false
|
||||
}
|
||||
if start.Name.Local == "rect" && parserSafeBackgroundRect(start) {
|
||||
return ValidationIssue{}, false
|
||||
}
|
||||
var expected []string
|
||||
switch start.Name.Local {
|
||||
case "rect":
|
||||
expected = []string{"rect", "round-rect"}
|
||||
case "circle", "ellipse":
|
||||
expected = []string{"ellipse"}
|
||||
case "path", "polygon", "polyline":
|
||||
expected = []string{"custom"}
|
||||
default:
|
||||
return ValidationIssue{}, false
|
||||
}
|
||||
if !hasSlideAttr(start, "role", "shape") || !slideAttrIn(start, "shape-type", expected...) {
|
||||
return ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.shape_type",
|
||||
Message: fmt.Sprintf("online parser-safe foreground <%s> must use slide:role=\"shape\" and parser-supported slide:shape-type", start.Name.Local),
|
||||
}, true
|
||||
}
|
||||
if (start.Name.Local == "path" || start.Name.Local == "polygon" || start.Name.Local == "polyline") &&
|
||||
(slideAttrValue(start, "width") == "" || slideAttrValue(start, "height") == "") {
|
||||
return ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.parser_safe.shape_type",
|
||||
Message: fmt.Sprintf("online parser-safe custom <%s> must declare slide:width and slide:height", start.Name.Local),
|
||||
}, true
|
||||
}
|
||||
return ValidationIssue{}, false
|
||||
}
|
||||
|
||||
func parserSafeBackgroundRect(start xml.StartElement) bool {
|
||||
if hasSlideAttr(start, "role", "background") {
|
||||
return true
|
||||
}
|
||||
if start.Name.Local != "rect" {
|
||||
return false
|
||||
}
|
||||
x := attrValue(start, "x")
|
||||
y := attrValue(start, "y")
|
||||
width := attrValue(start, "width")
|
||||
height := attrValue(start, "height")
|
||||
return (x == "" || x == "0") && (y == "" || y == "0") && width == "960" && height == "540"
|
||||
}
|
||||
|
||||
func parserSafeFreeformShapeGroup(start xml.StartElement) bool {
|
||||
return start.Name.Space == svgNamespace &&
|
||||
start.Name.Local == "g" &&
|
||||
hasSlideAttr(start, "role", "shape") &&
|
||||
hasSlideAttr(start, "shape-type", "freeform")
|
||||
}
|
||||
|
||||
func slideAttrIn(start xml.StartElement, local string, values ...string) bool {
|
||||
got := slideAttrValue(start, local)
|
||||
for _, value := range values {
|
||||
if got == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func slideAttrValue(start xml.StartElement, local string) string {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == slideNamespace && attr.Name.Local == local {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parserSafeTextForeignObjectElement(start xml.StartElement, excluded bool) bool {
|
||||
if excluded || start.Name.Space != svgNamespace || start.Name.Local != "foreignObject" {
|
||||
return false
|
||||
}
|
||||
return hasSlideAttr(start, "shape-type", "text")
|
||||
}
|
||||
|
||||
func parserSafeDirectTextTag(start xml.StartElement) bool {
|
||||
if start.Name.Space != "" && start.Name.Space != "http://www.w3.org/1999/xhtml" {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "p", "h1", "h2", "h3", "small", "ul", "ol":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
500
internal/svglide/preview.go
Normal file
500
internal/svglide/preview.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const defaultPreviewPath = "preview.html"
|
||||
const previewReceiptPath = "receipts/preview.json"
|
||||
|
||||
type PreviewReport struct {
|
||||
Status string `json:"status"`
|
||||
MissingAssetCount int `json:"missing_asset_count"`
|
||||
BrowserMissingAssetCount int `json:"browser_missing_asset_count"`
|
||||
RenderedVisual string `json:"rendered_visual,omitempty"`
|
||||
RenderedVisualIssueCount int `json:"rendered_visual_issue_count,omitempty"`
|
||||
Slides []PreviewSlideReport `json:"slides"`
|
||||
}
|
||||
|
||||
type PreviewSlideReport struct {
|
||||
Path string `json:"path"`
|
||||
Rendered bool `json:"rendered"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type previewDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []previewDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type previewDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type previewPageData struct {
|
||||
Title string
|
||||
Status string
|
||||
SlideCount int
|
||||
RenderedCount int
|
||||
Slides []previewPageSlide
|
||||
}
|
||||
|
||||
type previewPageSlide struct {
|
||||
Number int
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Role string
|
||||
KeyMessage string
|
||||
Path string
|
||||
Rendered bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func WritePreview(root string) (PreviewReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return PreviewReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return writeFailedPreview(safeRoot, run, "", "deck artifact path is empty")
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q: %v", deckPath, err))
|
||||
}
|
||||
var deck previewDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := PreviewReport{Slides: make([]PreviewSlideReport, 0, len(deck.Slides))}
|
||||
pageSlides := make([]previewPageSlide, 0, len(deck.Slides))
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
pageSlide := previewPageSlide{
|
||||
Number: i + 1,
|
||||
ID: strings.TrimSpace(slide.ID),
|
||||
Title: strings.TrimSpace(slide.Title),
|
||||
Summary: strings.TrimSpace(slide.Summary),
|
||||
Role: strings.TrimSpace(slide.Role),
|
||||
KeyMessage: strings.TrimSpace(slide.KeyMessage),
|
||||
Path: slidePath,
|
||||
}
|
||||
item := PreviewSlideReport{Path: slidePath}
|
||||
if pathErr != nil {
|
||||
item.Message = pathErr.Error()
|
||||
} else if slidePath == "" {
|
||||
item.Path = "(slide)"
|
||||
pageSlide.Path = item.Path
|
||||
item.Message = "slide path must not be empty"
|
||||
} else if raw, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
item.Message = err.Error()
|
||||
} else {
|
||||
item.Rendered = true
|
||||
pageSlide.Rendered = true
|
||||
for _, ref := range activeSVGAssetRefs(string(raw)) {
|
||||
resolvedHref, hrefErr := svgHrefRunPath(slidePath, ref.Href)
|
||||
if hrefErr != nil {
|
||||
report.BrowserMissingAssetCount++
|
||||
item.Rendered = false
|
||||
pageSlide.Rendered = false
|
||||
item.Message = appendPreviewMessage(item.Message, hrefErr.Error())
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(resolvedHref, "data:") || strings.HasPrefix(resolvedHref, "http://") || strings.HasPrefix(resolvedHref, "https://") {
|
||||
continue
|
||||
}
|
||||
if _, err := readRunRegularArtifact(safeRoot, resolvedHref); err != nil {
|
||||
report.BrowserMissingAssetCount++
|
||||
item.Rendered = false
|
||||
pageSlide.Rendered = false
|
||||
item.Message = appendPreviewMessage(item.Message, fmt.Sprintf("browser asset href %q resolves missing path %q", ref.Href, resolvedHref))
|
||||
}
|
||||
}
|
||||
}
|
||||
pageSlide.Message = item.Message
|
||||
report.Slides = append(report.Slides, item)
|
||||
pageSlides = append(pageSlides, pageSlide)
|
||||
}
|
||||
visualReport := EvaluateRenderedVisualRun(safeRoot, deck)
|
||||
if err := writeRenderedVisualReport(safeRoot, visualReport); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.RenderedVisual = renderedVisualReceiptPath
|
||||
report.RenderedVisualIssueCount = visualReport.Metrics.IssueCount
|
||||
if visualReport.Status != "passed" {
|
||||
for i := range report.Slides {
|
||||
if renderedVisualSlideFailed(visualReport, report.Slides[i].Path) {
|
||||
report.Slides[i].Rendered = false
|
||||
report.Slides[i].Message = appendPreviewMessage(report.Slides[i].Message, "rendered visual gate failed")
|
||||
pageSlides[i].Rendered = false
|
||||
pageSlides[i].Message = appendPreviewMessage(pageSlides[i].Message, "rendered visual gate failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
report = normalizePreviewReport(report)
|
||||
report.MissingAssetCount = MissingAssetCountForRun(safeRoot, run)
|
||||
|
||||
if err := writePreviewArtifacts(safeRoot, run, deck.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeFailedPreview(safeRoot string, run Run, path string, message string) (PreviewReport, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = "(deck)"
|
||||
}
|
||||
report := normalizePreviewReport(PreviewReport{
|
||||
MissingAssetCount: MissingAssetCountForRun(safeRoot, run),
|
||||
RenderedVisual: renderedVisualReceiptPath,
|
||||
Slides: []PreviewSlideReport{{
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}},
|
||||
})
|
||||
pageSlides := []previewPageSlide{{
|
||||
Number: 1,
|
||||
Title: "Preview failed",
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}}
|
||||
if err := writePreviewArtifacts(safeRoot, run, run.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
visualReport := RenderedVisualReport{
|
||||
Status: "failed",
|
||||
Metrics: RenderedVisualMetrics{Slides: 1, IssueCount: 1, OutOfCanvasCount: 1},
|
||||
Issues: []RenderedVisualIssue{{
|
||||
Path: path,
|
||||
Code: "svglide.rendered_visual.preview_failed",
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
}},
|
||||
Slides: []RenderedVisualSlideItem{{Path: path, Status: "failed", IssueCount: 1}},
|
||||
}
|
||||
if err := writeRenderedVisualReport(safeRoot, visualReport); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizePreviewReport(report PreviewReport) PreviewReport {
|
||||
if report.Slides == nil {
|
||||
report.Slides = []PreviewSlideReport{}
|
||||
}
|
||||
report.Status = "passed"
|
||||
for i := range report.Slides {
|
||||
report.Slides[i].Path = strings.TrimSpace(report.Slides[i].Path)
|
||||
if report.Slides[i].Path == "" {
|
||||
report.Slides[i].Path = "(slide)"
|
||||
}
|
||||
if !report.Slides[i].Rendered {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func appendPreviewMessage(existing string, next string) string {
|
||||
existing = strings.TrimSpace(existing)
|
||||
next = strings.TrimSpace(next)
|
||||
if existing == "" {
|
||||
return next
|
||||
}
|
||||
if next == "" {
|
||||
return existing
|
||||
}
|
||||
return existing + "; " + next
|
||||
}
|
||||
|
||||
func previewSlideObjectPath(path string) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("slide path must not be empty")
|
||||
}
|
||||
if strings.Contains(raw, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(raw, "%") {
|
||||
return "", fmt.Errorf("slide path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(raw, ":") || strings.Contains(raw, "//") {
|
||||
return "", fmt.Errorf("slide path %q must be a local slides/*.svg path", raw)
|
||||
}
|
||||
parts := strings.Split(raw, "/")
|
||||
if len(parts) != 2 || parts[0] != "slides" {
|
||||
return "", fmt.Errorf("slide path %q must match slides/<file>.svg", raw)
|
||||
}
|
||||
fileName := parts[1]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("slide path %q must include a slide file name", raw)
|
||||
}
|
||||
if strings.Contains(fileName, "/") || strings.Contains(fileName, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must not contain nested directories", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("slide path %q must not contain dot segments", raw)
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(fileName)) != ".svg" {
|
||||
return "", fmt.Errorf("slide path %q must end with .svg", raw)
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(raw))
|
||||
if cleaned != raw {
|
||||
return "", fmt.Errorf("slide path %q must already be normalized", raw)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writePreviewArtifacts(safeRoot string, run Run, title string, report PreviewReport, slides []previewPageSlide) error {
|
||||
report = normalizePreviewReport(report)
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
htmlRaw, err := renderPreviewHTML(title, report, slides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(target, htmlRaw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(receiptTarget, raw, 0o644)
|
||||
}
|
||||
|
||||
func renderPreviewHTML(title string, report PreviewReport, slides []previewPageSlide) ([]byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "SVGlide Preview"
|
||||
}
|
||||
var rendered int
|
||||
for _, slide := range slides {
|
||||
if slide.Rendered {
|
||||
rendered++
|
||||
}
|
||||
}
|
||||
data := previewPageData{
|
||||
Title: title,
|
||||
Status: report.Status,
|
||||
SlideCount: len(slides),
|
||||
RenderedCount: rendered,
|
||||
Slides: slides,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := previewTemplate.Execute(&b, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
var previewTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - SVGlide Preview</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f2933;
|
||||
--muted: #657286;
|
||||
--line: #d8dee8;
|
||||
--accent: #1d7a62;
|
||||
--warn: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.status.failed { background: var(--warn); }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.deck {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.slide {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 24px rgba(31,41,51,.06);
|
||||
}
|
||||
.frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
object {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.missing {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
color: var(--warn);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.details h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.label {
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
}
|
||||
.path {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.message { color: var(--warn); overflow-wrap: anywhere; }
|
||||
@media (max-width: 860px) {
|
||||
.bar { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.meta { flex-wrap: wrap; white-space: normal; }
|
||||
.slide { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="bar">
|
||||
<h1>{{.Title}}</h1>
|
||||
<div class="meta">
|
||||
<span class="status {{.Status}}">{{.Status}}</span>
|
||||
<span>{{.RenderedCount}} / {{.SlideCount}} rendered</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="deck">
|
||||
{{range .Slides}}
|
||||
<article class="slide">
|
||||
<div class="frame">
|
||||
{{if .Rendered}}
|
||||
<object data="{{.Path}}" type="image/svg+xml" aria-label="{{.Title}}"></object>
|
||||
{{else}}
|
||||
<div class="missing">{{.Message}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{printf "%02d" .Number}}. {{.Title}}</h2>
|
||||
{{if .Summary}}<div><span class="label">Summary</span><br>{{.Summary}}</div>{{end}}
|
||||
{{if .KeyMessage}}<div><span class="label">Key Message</span><br>{{.KeyMessage}}</div>{{end}}
|
||||
{{if .Role}}<div><span class="label">Role</span><br>{{.Role}}</div>{{end}}
|
||||
<div><span class="label">Path</span><br><span class="path">{{.Path}}</span></div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
347
internal/svglide/preview_test.go
Normal file
347
internal/svglide/preview_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePreviewWritesHTMLAndReceipt(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || !report.Slides[0].Rendered || report.Slides[0].Path != "slides/01.svg" {
|
||||
t.Fatalf("Slides = %+v, want rendered slides/01.svg", report.Slides)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
for _, want := range []string{"Demo - SVGlide Preview", `<link rel="icon" href="data:,">`, `data="slides/01.svg"`, "01. Slide", "Key Message"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("preview.html missing %q:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "passed" || len(receipt.Slides) != 1 || !receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want passed rendered slide", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsMissingAssetsFromSVGAndManifest(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="assets/images/missing.png"/></svg>`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"assets/images/missing.png","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.MissingAssetCount != 1 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 1", report.MissingAssetCount)
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.MissingAssetCount != 1 {
|
||||
t.Fatalf("receipt MissingAssetCount = %d, want 1", receipt.MissingAssetCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewFailsOnRenderedVisualOverflow(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="92" y="318" font-size="25">Revenue declined 4.3% year over year, but gross margin reached 46.6% and diluted EPS set a March-quarter record.</text></svg>`)
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.RenderedVisual != renderedVisualReceiptPath || report.RenderedVisualIssueCount == 0 {
|
||||
t.Fatalf("rendered visual fields = %q/%d, want receipt and issues", report.RenderedVisual, report.RenderedVisualIssueCount)
|
||||
}
|
||||
var visual RenderedVisualReport
|
||||
raw, err := os.ReadFile(filepath.Join("demo", renderedVisualReceiptPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visual.Status != "failed" || !renderedVisualHasCode(visual, "svglide.rendered_visual.text_overflow") {
|
||||
t.Fatalf("visual = %+v, want text overflow failure", visual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewEscapesDeckText(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDeckAt(t, filepath.Join("demo", "outline", "deck.json"), previewDeck{
|
||||
Title: `<Deck & Demo>`,
|
||||
Slides: []previewDeckSlide{{
|
||||
ID: "cover",
|
||||
Title: `<Cover & One>`,
|
||||
Summary: `Summary <script>bad()</script>`,
|
||||
Role: "cover",
|
||||
KeyMessage: `Message & context`,
|
||||
Path: "slides/01.svg",
|
||||
}},
|
||||
})
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
if _, err := WritePreview("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
if strings.Contains(html, "<script>bad()</script>") {
|
||||
t.Fatalf("preview.html contains unescaped script:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<script>bad()</script>") || !strings.Contains(html, "<Deck & Demo>") {
|
||||
t.Fatalf("preview.html missing escaped deck text:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewUsesRunArtifactDeckAndPreviewPath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
run.Artifacts.Preview = "public/deck.html"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/missing.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "public", "deck.html")); err != nil {
|
||||
t.Fatalf("missing custom preview path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default preview should not be written when artifact path is custom, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsUnsafeSlidePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slidePath string
|
||||
filePath string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "escape",
|
||||
slidePath: "../outside.svg",
|
||||
filePath: "outside.svg",
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "remote scheme",
|
||||
slidePath: "https:/evil.example/a.svg",
|
||||
filePath: filepath.Join("demo", "https:", "evil.example", "a.svg"),
|
||||
wantMessage: "local slides/*.svg",
|
||||
},
|
||||
{
|
||||
name: "encoded dot segment",
|
||||
slidePath: "slides/%2e%2e.svg",
|
||||
filePath: filepath.Join("demo", "slides", "%2e%2e.svg"),
|
||||
wantMessage: "percent encoding",
|
||||
},
|
||||
{
|
||||
name: "nested directory",
|
||||
slidePath: "slides/nested/01.svg",
|
||||
filePath: filepath.Join("demo", "slides", "nested", "01.svg"),
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "backslash",
|
||||
slidePath: `slides\01.svg`,
|
||||
filePath: filepath.Join("demo", `slides\01.svg`),
|
||||
wantMessage: "forward slashes",
|
||||
},
|
||||
{
|
||||
name: "wrong extension",
|
||||
slidePath: "slides/01.png",
|
||||
filePath: filepath.Join("demo", "slides", "01.png"),
|
||||
wantMessage: ".svg",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.slidePath)
|
||||
writeValidateTestFile(t, tt.filePath, visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || report.Slides[0].Rendered {
|
||||
t.Fatalf("Slides = %+v, want unrendered slide", report.Slides)
|
||||
}
|
||||
if !strings.Contains(report.Slides[0].Message, tt.wantMessage) {
|
||||
t.Fatalf("Message = %q, want %q", report.Slides[0].Message, tt.wantMessage)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want failed unrendered slide", receipt)
|
||||
}
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(htmlRaw), `data="`) {
|
||||
t.Fatalf("preview should not embed unsafe slide path:\n%s", string(htmlRaw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewWritesFailureArtifactsForDeckReadFailures(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html for failed deck read: %v", err)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Path != "outline/deck.json" {
|
||||
t.Fatalf("preview receipt = %+v, want failed deck report", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.html")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "preview.html")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "preview.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipt symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview receipt overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptsDirectorySymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipts directory symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "preview.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("preview receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readPreviewReceipt(t *testing.T) PreviewReport {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "preview.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PreviewReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func writeDeckAt(t *testing.T, path string, deck previewDeck) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
1725
internal/svglide/prompt.go
Normal file
1725
internal/svglide/prompt.go
Normal file
File diff suppressed because it is too large
Load Diff
902
internal/svglide/prompt_contract.go
Normal file
902
internal/svglide/prompt_contract.go
Normal file
@@ -0,0 +1,902 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolAnyGenSVGSlides = "anygen-svg-slides"
|
||||
)
|
||||
|
||||
type PromptAssetContract struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Role string `json:"role" yaml:"role"`
|
||||
OrchestratedBy string `json:"orchestrated_by,omitempty" yaml:"orchestrated_by,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty" yaml:"invocation,omitempty"`
|
||||
Stage string `json:"stage,omitempty" yaml:"stage,omitempty"`
|
||||
Order int `json:"order,omitempty" yaml:"order,omitempty"`
|
||||
Cardinality string `json:"cardinality,omitempty" yaml:"cardinality,omitempty"`
|
||||
Requires []string `json:"requires,omitempty" yaml:"requires,omitempty"`
|
||||
Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
|
||||
Trigger []string `json:"trigger,omitempty" yaml:"trigger,omitempty"`
|
||||
Consumes []string `json:"consumes,omitempty" yaml:"consumes,omitempty"`
|
||||
Produces []string `json:"produces,omitempty" yaml:"produces,omitempty"`
|
||||
CompletionGate []string `json:"completion_gate,omitempty" yaml:"completion_gate,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty" yaml:"phase_anchors,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty" yaml:"profiles,omitempty"`
|
||||
Exposure string `json:"exposure,omitempty" yaml:"exposure,omitempty"`
|
||||
Rules []any `json:"-" yaml:"rules,omitempty"`
|
||||
Path string `json:"path" yaml:"-"`
|
||||
SHA256 string `json:"sha256" yaml:"-"`
|
||||
}
|
||||
|
||||
type AnyGenOrchestrationGraph struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Orchestrator PromptAssetContract `json:"orchestrator"`
|
||||
ProtocolReference PromptAssetContract `json:"protocol_reference"`
|
||||
Assets []PromptAssetContract `json:"assets"`
|
||||
}
|
||||
|
||||
type ToolInvocationContract struct {
|
||||
Protocol string `json:"protocol"`
|
||||
RequiredCalls []ToolCallRequirement `json:"required_calls"`
|
||||
ConditionalCalls []ToolCallRequirement `json:"conditional_calls"`
|
||||
}
|
||||
|
||||
type ToolCallRequirement struct {
|
||||
ID string `json:"id"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
Cardinality string `json:"cardinality"`
|
||||
Condition string `json:"condition"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Produces []string `json:"produces"`
|
||||
}
|
||||
|
||||
type StagePromptContract struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Stage string `json:"stage"`
|
||||
ContextReceipt string `json:"context_receipt,omitempty"`
|
||||
Orchestrator string `json:"orchestrator"`
|
||||
ProtocolReference string `json:"protocol_reference"`
|
||||
RequiredPromptIDs []string `json:"required_prompt_ids"`
|
||||
ConditionalPromptIDs []string `json:"conditional_prompt_ids,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty"`
|
||||
}
|
||||
|
||||
type PromptContextAsset struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type PromptContext struct {
|
||||
ReadPolicy string `json:"read_policy"`
|
||||
Authority string `json:"authority"`
|
||||
Assets []PromptContextAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type AgentTask struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Stage string `json:"stage"`
|
||||
Objective string `json:"objective"`
|
||||
Orchestrator string `json:"orchestrator"`
|
||||
ProtocolReference string `json:"protocol_reference"`
|
||||
RequiredPrompts []string `json:"required_prompts"`
|
||||
RequiredCalls []ToolCallRequirement `json:"required_calls"`
|
||||
ConditionalCalls []ToolCallRequirement `json:"conditional_calls,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
CompletionGate []string `json:"completion_gate"`
|
||||
ToolCallReceiptDir string `json:"tool_call_receipt_dir"`
|
||||
PromptContext PromptContext `json:"prompt_context"`
|
||||
}
|
||||
|
||||
type PromptContextReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Protocol string `json:"protocol"`
|
||||
AgentTask AgentTask `json:"agent_task"`
|
||||
PromptContract StagePromptContract `json:"prompt_contract"`
|
||||
ToolInvocationContract ToolInvocationContract `json:"tool_invocation_contract"`
|
||||
AssetHashes map[string]string `json:"asset_hashes"`
|
||||
}
|
||||
|
||||
func LoadAnyGenPromptAssets() ([]PromptAssetContract, error) {
|
||||
manifest := DefaultPromptManifest()
|
||||
assets := make([]PromptAssetContract, 0, len(manifest.Entries))
|
||||
ids := map[string]bool{}
|
||||
for _, entry := range manifest.Entries {
|
||||
asset, err := loadPromptAssetContract(entry.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expectedID := entry.ID
|
||||
if expectedID == "" {
|
||||
expectedID = entry.Name
|
||||
}
|
||||
if asset.ID != expectedID {
|
||||
return nil, fmt.Errorf("%s: prompt asset id = %q, want %q", entry.Path, asset.ID, expectedID)
|
||||
}
|
||||
if len(asset.Profiles) == 0 {
|
||||
asset.Profiles = slices.Clone(entry.Profiles)
|
||||
}
|
||||
if asset.Exposure == "" {
|
||||
asset.Exposure = entry.Exposure
|
||||
}
|
||||
if ids[asset.ID] {
|
||||
return nil, fmt.Errorf("duplicate prompt asset id %q", asset.ID)
|
||||
}
|
||||
ids[asset.ID] = true
|
||||
assets = append(assets, asset)
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func loadPromptAssetContract(path string) (PromptAssetContract, error) {
|
||||
raw, err := readPromptAssetFile(path)
|
||||
if err != nil {
|
||||
return PromptAssetContract{}, err
|
||||
}
|
||||
frontmatter, err := semanticMarkdownFrontmatter(path, raw)
|
||||
if err != nil {
|
||||
return PromptAssetContract{}, err
|
||||
}
|
||||
var asset PromptAssetContract
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(frontmatter))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&asset); err != nil {
|
||||
return PromptAssetContract{}, fmt.Errorf("%s frontmatter: %w", path, err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return PromptAssetContract{}, fmt.Errorf("%s frontmatter must contain a single YAML document", path)
|
||||
}
|
||||
return PromptAssetContract{}, fmt.Errorf("%s frontmatter: %w", path, err)
|
||||
}
|
||||
asset.Path = filepath.ToSlash(filepath.Clean(path))
|
||||
asset.SHA256, err = promptAssetSHAStrict(path)
|
||||
if err != nil {
|
||||
return PromptAssetContract{}, err
|
||||
}
|
||||
if err := validatePromptAssetContract(asset); err != nil {
|
||||
return PromptAssetContract{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func validatePromptAssetContract(asset PromptAssetContract) error {
|
||||
if strings.TrimSpace(asset.ID) == "" {
|
||||
return fmt.Errorf("missing id")
|
||||
}
|
||||
if strings.TrimSpace(asset.Role) == "" {
|
||||
return fmt.Errorf("missing role")
|
||||
}
|
||||
if strings.TrimSpace(asset.Invocation) == "" {
|
||||
return fmt.Errorf("missing invocation")
|
||||
}
|
||||
switch asset.Role {
|
||||
case "source_snapshot", "reference_index", "semantic_contract", "runtime_binding":
|
||||
if asset.Invocation != "reference" {
|
||||
return fmt.Errorf("role %s must use invocation reference", asset.Role)
|
||||
}
|
||||
case "orchestrator":
|
||||
if asset.Invocation != "required" || asset.ID != "mode_system_prompt_svg" {
|
||||
return fmt.Errorf("orchestrator must be mode_system_prompt_svg with required invocation")
|
||||
}
|
||||
case "protocol_reference":
|
||||
if asset.Invocation != "required" || asset.ID != "svg_reference" {
|
||||
return fmt.Errorf("protocol_reference must be svg_reference with required invocation")
|
||||
}
|
||||
case "tool_prompt":
|
||||
if asset.OrchestratedBy != "mode_system_prompt_svg" {
|
||||
return fmt.Errorf("tool prompt %s must be orchestrated_by mode_system_prompt_svg", asset.ID)
|
||||
}
|
||||
if asset.Invocation != "required" && asset.Invocation != "conditional" {
|
||||
return fmt.Errorf("tool prompt %s uses unsupported invocation %q", asset.ID, asset.Invocation)
|
||||
}
|
||||
if strings.TrimSpace(asset.Stage) == "" {
|
||||
return fmt.Errorf("tool prompt %s missing stage", asset.ID)
|
||||
}
|
||||
if strings.TrimSpace(asset.Cardinality) == "" {
|
||||
return fmt.Errorf("tool prompt %s missing cardinality", asset.ID)
|
||||
}
|
||||
if strings.TrimSpace(asset.Condition) == "" {
|
||||
return fmt.Errorf("tool prompt %s missing condition", asset.ID)
|
||||
}
|
||||
if len(asset.Consumes) == 0 {
|
||||
return fmt.Errorf("tool prompt %s missing consumes", asset.ID)
|
||||
}
|
||||
if len(asset.Produces) == 0 {
|
||||
return fmt.Errorf("tool prompt %s missing produces", asset.ID)
|
||||
}
|
||||
if asset.Invocation == "conditional" && len(asset.Trigger) == 0 {
|
||||
return fmt.Errorf("conditional tool prompt %s missing trigger", asset.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported role %q", asset.Role)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildAnyGenOrchestrationGraph() (AnyGenOrchestrationGraph, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return AnyGenOrchestrationGraph{}, err
|
||||
}
|
||||
var orchestrators []PromptAssetContract
|
||||
var references []PromptAssetContract
|
||||
for _, asset := range assets {
|
||||
switch asset.Role {
|
||||
case "orchestrator":
|
||||
orchestrators = append(orchestrators, asset)
|
||||
case "protocol_reference":
|
||||
references = append(references, asset)
|
||||
}
|
||||
}
|
||||
if len(orchestrators) != 1 {
|
||||
return AnyGenOrchestrationGraph{}, fmt.Errorf("expected exactly one orchestrator, got %d", len(orchestrators))
|
||||
}
|
||||
if len(references) != 1 {
|
||||
return AnyGenOrchestrationGraph{}, fmt.Errorf("expected exactly one protocol reference, got %d", len(references))
|
||||
}
|
||||
return AnyGenOrchestrationGraph{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Orchestrator: orchestrators[0],
|
||||
ProtocolReference: references[0],
|
||||
Assets: assets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PromptAssetsForProfileStage(profile string, stage string) ([]PromptAssetContract, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PromptAssetContract, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
if !promptAssetAllowedForProfile(asset.Profiles, profile) {
|
||||
continue
|
||||
}
|
||||
if asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.AlwaysForPromptContext(stage) || asset.Stage == stage {
|
||||
out = append(out, asset)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func BuildToolInvocationContract(run Run, stage string) (ToolInvocationContract, error) {
|
||||
assets, err := PromptAssetsForProfileStage(run.RouteProfile, stage)
|
||||
if err != nil {
|
||||
return ToolInvocationContract{}, err
|
||||
}
|
||||
contract := ToolInvocationContract{Protocol: ProtocolAnyGenSVGSlides}
|
||||
for _, asset := range assets {
|
||||
if asset.Role != "tool_prompt" {
|
||||
continue
|
||||
}
|
||||
req := toolRequirementFromAsset(asset)
|
||||
switch asset.Invocation {
|
||||
case "required":
|
||||
contract.RequiredCalls = append(contract.RequiredCalls, req)
|
||||
case "conditional":
|
||||
contract.ConditionalCalls = append(contract.ConditionalCalls, req)
|
||||
}
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func RequiredPromptContractForStage(stage string, run Run) (StagePromptContract, error) {
|
||||
assets, err := PromptAssetsForProfileStage(run.RouteProfile, stage)
|
||||
if err != nil {
|
||||
return StagePromptContract{}, err
|
||||
}
|
||||
contract := StagePromptContract{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Stage: stage,
|
||||
ContextReceipt: promptContextReceiptPath(stage),
|
||||
Orchestrator: "mode_system_prompt_svg",
|
||||
ProtocolReference: "svg_reference",
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.Role == "runtime_binding" || asset.AlwaysForPromptContext(stage) {
|
||||
if asset.Invocation == "conditional" {
|
||||
contract.ConditionalPromptIDs = appendUnique(contract.ConditionalPromptIDs, asset.ID)
|
||||
} else {
|
||||
contract.RequiredPromptIDs = appendUnique(contract.RequiredPromptIDs, asset.ID)
|
||||
}
|
||||
}
|
||||
if asset.Stage == stage && len(asset.PhaseAnchors) > 0 {
|
||||
contract.PhaseAnchors = append(contract.PhaseAnchors, asset.PhaseAnchors...)
|
||||
}
|
||||
}
|
||||
if stage == StageResearch {
|
||||
contract.PhaseAnchors = []string{"Phase 3 - Build source material"}
|
||||
}
|
||||
if stage == StageSlideContent {
|
||||
contract.PhaseAnchors = []string{"Phase 6 - Write slide_content.md"}
|
||||
}
|
||||
if stage == StageAssets {
|
||||
contract.PhaseAnchors = appendUnique(contract.PhaseAnchors, "Phase 7 - Lock the visual direction & plan visuals")
|
||||
contract.PhaseAnchors = appendUnique(contract.PhaseAnchors, "<visuals>")
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func (asset PromptAssetContract) AlwaysForPromptContext(stage string) bool {
|
||||
return asset.Role == "reference_index" || asset.Role == "semantic_contract" || asset.Role == "runtime_binding" || asset.Stage == stage
|
||||
}
|
||||
|
||||
func RequiredToolCallsForStage(stage string, run Run) ([]ToolCallRequirement, error) {
|
||||
contract, err := BuildToolInvocationContract(run, stage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var calls []ToolCallRequirement
|
||||
for _, call := range contract.RequiredCalls {
|
||||
if call.Stage == stage {
|
||||
calls = append(calls, call)
|
||||
}
|
||||
}
|
||||
return calls, nil
|
||||
}
|
||||
|
||||
func TriggeredConditionalToolCalls(stage string, run Run, safeRoot string) ([]ToolCallRequirement, error) {
|
||||
contract, err := BuildToolInvocationContract(run, stage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var calls []ToolCallRequirement
|
||||
for _, call := range contract.ConditionalCalls {
|
||||
if call.Stage != stage {
|
||||
continue
|
||||
}
|
||||
matched, err := conditionMatched(call.Condition, run, safeRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if matched {
|
||||
calls = append(calls, call)
|
||||
}
|
||||
}
|
||||
return calls, nil
|
||||
}
|
||||
|
||||
func BuildAgentTask(stage Stage, run Run, safeRoot string, inputs, outputs []string) (AgentTask, StagePromptContract, ToolInvocationContract, error) {
|
||||
promptContract, err := RequiredPromptContractForStage(stage.Name, run)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
promptContext, err := promptContextForPromptContract(promptContract)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
requiredCalls, err := RequiredToolCallsForStage(stage.Name, run)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
conditionalCalls, err := TriggeredConditionalToolCalls(stage.Name, run, safeRoot)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
stageContract := ToolInvocationContract{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
RequiredCalls: requiredCalls,
|
||||
ConditionalCalls: conditionalCalls,
|
||||
}
|
||||
task := AgentTask{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Stage: stage.Name,
|
||||
Objective: stageObjective(stage.Name),
|
||||
Orchestrator: promptContract.Orchestrator,
|
||||
ProtocolReference: promptContract.ProtocolReference,
|
||||
RequiredPrompts: promptContract.RequiredPromptIDs,
|
||||
RequiredCalls: requiredCalls,
|
||||
ConditionalCalls: conditionalCalls,
|
||||
PhaseAnchors: promptContract.PhaseAnchors,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
CompletionGate: completionGateForStage(stage.Name, requiredCalls, conditionalCalls),
|
||||
ToolCallReceiptDir: filepath.ToSlash(filepath.Join("receipts", "tool_calls", stage.Name)),
|
||||
PromptContext: promptContext,
|
||||
}
|
||||
return task, promptContract, stageContract, nil
|
||||
}
|
||||
|
||||
func WritePromptContextReceipt(safeRoot string, stageName string, task AgentTask, promptContract StagePromptContract, toolContract ToolInvocationContract) error {
|
||||
assetHashes := map[string]string{}
|
||||
for _, asset := range task.PromptContext.Assets {
|
||||
assetHashes[asset.ID] = asset.SHA256
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, promptContextReceiptPath(stageName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, PromptContextReceipt{
|
||||
Stage: stageName,
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
AgentTask: task,
|
||||
PromptContract: promptContract,
|
||||
ToolInvocationContract: toolContract,
|
||||
AssetHashes: assetHashes,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidatePromptContextForStage(safeRoot string, stageName string, run Run) (PromptContextReceipt, error) {
|
||||
if stageName == StageRequest {
|
||||
return PromptContextReceipt{}, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, promptContextReceiptPath(stageName))
|
||||
if err != nil {
|
||||
return PromptContextReceipt{}, fmt.Errorf("missing_prompt_context: %w", err)
|
||||
}
|
||||
var receipt PromptContextReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return PromptContextReceipt{}, fmt.Errorf("invalid prompt context receipt: %w", err)
|
||||
}
|
||||
if receipt.Stage != stageName {
|
||||
return PromptContextReceipt{}, fmt.Errorf("wrong_stage_prompt_context: got %q want %q", receipt.Stage, stageName)
|
||||
}
|
||||
expectedContract, err := RequiredPromptContractForStage(stageName, run)
|
||||
if err != nil {
|
||||
return PromptContextReceipt{}, err
|
||||
}
|
||||
expectedContext, err := promptContextForPromptContract(expectedContract)
|
||||
if err != nil {
|
||||
return PromptContextReceipt{}, err
|
||||
}
|
||||
allowedIDs := make(map[string]string, len(expectedContext.Assets))
|
||||
requiredIDs := make(map[string]string, len(expectedContext.Assets))
|
||||
for _, asset := range expectedContext.Assets {
|
||||
allowedIDs[asset.ID] = asset.SHA256
|
||||
if !asset.Required {
|
||||
continue
|
||||
}
|
||||
requiredIDs[asset.ID] = asset.SHA256
|
||||
}
|
||||
for id, want := range requiredIDs {
|
||||
got, ok := receipt.AssetHashes[id]
|
||||
if !ok {
|
||||
return PromptContextReceipt{}, fmt.Errorf("missing_prompt_context_asset: %s", id)
|
||||
}
|
||||
if got != want {
|
||||
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", id, got, want)
|
||||
}
|
||||
}
|
||||
for _, asset := range receipt.AgentTask.PromptContext.Assets {
|
||||
want, ok := allowedIDs[asset.ID]
|
||||
if !ok {
|
||||
return PromptContextReceipt{}, fmt.Errorf("prompt context asset %q is not allowed for route profile %q stage %q", asset.ID, run.RouteProfile, stageName)
|
||||
}
|
||||
if strings.TrimSpace(asset.SHA256) != "" && asset.SHA256 != want {
|
||||
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", asset.ID, asset.SHA256, want)
|
||||
}
|
||||
}
|
||||
for id, want := range receipt.AssetHashes {
|
||||
expectedHash, ok := allowedIDs[id]
|
||||
if !ok {
|
||||
return PromptContextReceipt{}, fmt.Errorf("prompt context asset %q is not allowed for route profile %q stage %q", id, run.RouteProfile, stageName)
|
||||
}
|
||||
if expectedHash != want {
|
||||
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", id, want, expectedHash)
|
||||
}
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func ValidateToolCallReceiptsForStage(safeRoot string, stageName string, run Run, receipt PromptContextReceipt) error {
|
||||
if stageName == StageRequest {
|
||||
return nil
|
||||
}
|
||||
requiredCalls, err := RequiredToolCallsForStage(stageName, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conditionalCalls, err := TriggeredConditionalToolCalls(stageName, run, safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calls := append([]ToolCallRequirement{}, requiredCalls...)
|
||||
calls = append(calls, conditionalCalls...)
|
||||
promptIDs := promptIDsFromReceipt(receipt)
|
||||
for _, call := range calls {
|
||||
path := filepath.Join("receipts", "tool_calls", stageName, call.ID+".json")
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("missing_tool_call: %s: %w", call.ID, err)
|
||||
}
|
||||
var toolReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
CallID string `json:"call_id"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
Invocation string `json:"invocation"`
|
||||
Condition string `json:"condition"`
|
||||
ConditionMatched bool `json:"condition_matched"`
|
||||
Order int `json:"order"`
|
||||
Cardinality string `json:"cardinality"`
|
||||
Status string `json:"status"`
|
||||
Consumed []string `json:"consumed"`
|
||||
Produced []string `json:"produced"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &toolReceipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid tool call receipt: %w", path, err)
|
||||
}
|
||||
if toolReceipt.Stage != stageName || toolReceipt.CallID != call.ID || toolReceipt.PromptID != call.PromptID || toolReceipt.Status != StatusDone {
|
||||
return fmt.Errorf("%s: receipt does not satisfy tool call %s", path, call.ID)
|
||||
}
|
||||
if toolReceipt.Invocation != call.Invocation || toolReceipt.Condition != call.Condition || toolReceipt.Cardinality != call.Cardinality || toolReceipt.Order != call.Order {
|
||||
return fmt.Errorf("%s: receipt contract mismatch for tool call %s", path, call.ID)
|
||||
}
|
||||
if !toolReceipt.ConditionMatched {
|
||||
return fmt.Errorf("%s: condition_matched must be true for required tool call %s", path, call.ID)
|
||||
}
|
||||
if !stringSlicesEqual(toolReceipt.Consumed, call.Consumes) {
|
||||
return fmt.Errorf("%s: consumed artifacts = %v, want %v", path, toolReceipt.Consumed, call.Consumes)
|
||||
}
|
||||
if !stringSlicesEqual(toolReceipt.Produced, call.Produces) {
|
||||
return fmt.Errorf("%s: produced artifacts = %v, want %v", path, toolReceipt.Produced, call.Produces)
|
||||
}
|
||||
if !promptIDs[toolReceipt.PromptID] {
|
||||
return fmt.Errorf("%s: prompt_id %q is not in current prompt context", path, toolReceipt.PromptID)
|
||||
}
|
||||
if err := validateToolReceiptArtifactsExist(safeRoot, path, "consumed", toolReceipt.Consumed); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateToolReceiptArtifactsExist(safeRoot, path, "produced", toolReceipt.Produced); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateArtifactPromptContractForStage(safeRoot string, stageName string, outputs []string) error {
|
||||
if stageName == StageRequest || stageName == StageValidatePreviewRepair || stageName == StagePublishOnline {
|
||||
return nil
|
||||
}
|
||||
for _, output := range outputs {
|
||||
if hasGlobMeta(output) || !strings.HasSuffix(output, ".json") || strings.HasPrefix(output, "receipts/") || output == "quality_report.json" {
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var artifact struct {
|
||||
PromptContract StagePromptContract `json:"prompt_contract"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &artifact); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", output, err)
|
||||
}
|
||||
if artifact.PromptContract.Protocol == "" {
|
||||
return fmt.Errorf("%s: missing prompt_contract", output)
|
||||
}
|
||||
if artifact.PromptContract.Stage != stageName {
|
||||
return fmt.Errorf("%s: prompt_contract.stage = %q, want %q", output, artifact.PromptContract.Stage, stageName)
|
||||
}
|
||||
if artifact.PromptContract.Orchestrator != "mode_system_prompt_svg" {
|
||||
return fmt.Errorf("%s: prompt_contract.orchestrator = %q, want mode_system_prompt_svg", output, artifact.PromptContract.Orchestrator)
|
||||
}
|
||||
if artifact.PromptContract.ProtocolReference != "svg_reference" {
|
||||
return fmt.Errorf("%s: prompt_contract.protocol_reference = %q, want svg_reference", output, artifact.PromptContract.ProtocolReference)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptContextReceiptPath(stage string) string {
|
||||
return filepath.ToSlash(filepath.Join("receipts", "prompt_context", stage+".json"))
|
||||
}
|
||||
|
||||
func promptContextForPromptContract(contract StagePromptContract) (PromptContext, error) {
|
||||
ids := append([]string{}, contract.RequiredPromptIDs...)
|
||||
ids = append(ids, contract.ConditionalPromptIDs...)
|
||||
assets := make([]PromptContextAsset, 0, len(ids))
|
||||
assetByID, err := promptAssetsByID()
|
||||
if err != nil {
|
||||
return PromptContext{}, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
asset, ok := assetByID[id]
|
||||
if !ok {
|
||||
return PromptContext{}, fmt.Errorf("prompt context references unknown prompt id %q", id)
|
||||
}
|
||||
assets = append(assets, PromptContextAsset{
|
||||
ID: id,
|
||||
Role: asset.Role,
|
||||
Path: asset.Path,
|
||||
SHA256: asset.SHA256,
|
||||
Required: slices.Contains(contract.RequiredPromptIDs, id),
|
||||
})
|
||||
}
|
||||
return PromptContext{
|
||||
ReadPolicy: "read_required_assets_before_authoring",
|
||||
Authority: "cli_runtime_protocol",
|
||||
Assets: assets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolRequirementFromAsset(asset PromptAssetContract) ToolCallRequirement {
|
||||
return ToolCallRequirement{
|
||||
ID: asset.ID,
|
||||
Stage: asset.Stage,
|
||||
PromptID: asset.ID,
|
||||
Invocation: asset.Invocation,
|
||||
Order: asset.Order,
|
||||
Cardinality: asset.Cardinality,
|
||||
Condition: asset.Condition,
|
||||
Consumes: slices.Clone(asset.Consumes),
|
||||
Produces: slices.Clone(asset.Produces),
|
||||
}
|
||||
}
|
||||
|
||||
func promptPathByID(id string) string {
|
||||
for _, entry := range DefaultPromptManifest().Entries {
|
||||
entryID := entry.ID
|
||||
if entryID == "" {
|
||||
entryID = entry.Name
|
||||
}
|
||||
if entryID == id {
|
||||
return entry.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptRoleByID(id string) string {
|
||||
for _, entry := range DefaultPromptManifest().Entries {
|
||||
entryID := entry.ID
|
||||
if entryID == "" {
|
||||
entryID = entry.Name
|
||||
}
|
||||
if entryID == id {
|
||||
return entry.Role
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptAssetSHA(path string) string {
|
||||
hash, err := promptAssetSHAStrict(path)
|
||||
if err == nil {
|
||||
return hash
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
raw = []byte("missing:" + path)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func promptAssetSHAStrict(path string) (string, error) {
|
||||
raw, err := readPromptAssetFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func readPromptAssetFile(path string) ([]byte, error) {
|
||||
readPath := resolvePromptAssetReadPath(path)
|
||||
raw, err := os.ReadFile(readPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read prompt asset %q: %w", path, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func resolvePromptAssetReadPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return path
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
return filepath.Join(repoRoot, path)
|
||||
}
|
||||
|
||||
func promptAssetsByID() (map[string]PromptAssetContract, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]PromptAssetContract, len(assets))
|
||||
for _, asset := range assets {
|
||||
out[asset.ID] = asset
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func promptIDsFromReceipt(receipt PromptContextReceipt) map[string]bool {
|
||||
ids := make(map[string]bool, len(receipt.AgentTask.PromptContext.Assets)+len(receipt.AssetHashes))
|
||||
for _, asset := range receipt.AgentTask.PromptContext.Assets {
|
||||
ids[asset.ID] = true
|
||||
}
|
||||
for id := range receipt.AssetHashes {
|
||||
ids[id] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateToolReceiptArtifactsExist(safeRoot string, receiptPath string, field string, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return fmt.Errorf("%s: %s must not be empty", receiptPath, field)
|
||||
}
|
||||
for _, rel := range paths {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return fmt.Errorf("%s: %s contains empty path", receiptPath, field)
|
||||
}
|
||||
if hasGlobMeta(rel) {
|
||||
matches, err := filepath.Glob(filepath.Join(safeRoot, filepath.Clean(rel)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s glob %q invalid: %w", receiptPath, field, rel, err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("%s: %s glob %q matched no artifacts", receiptPath, field, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := readRunRegularArtifact(safeRoot, rel); err != nil {
|
||||
return fmt.Errorf("%s: %s artifact %q invalid: %w", receiptPath, field, rel, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stageObjective(stage string) string {
|
||||
switch stage {
|
||||
case StageRequestResolution:
|
||||
return "识别用户请求的真实实体、主题类型、置信度和歧义;低置信度时阻断后续研究。"
|
||||
case StageResearch:
|
||||
return "基于用户主题和本地/网页资料建立 source material。"
|
||||
case StageDesignBrief:
|
||||
return "调用/遵守 resolve_design_brief,生成 narrative spine、depth、tone、visual system。"
|
||||
case StageOutline:
|
||||
return "调用/遵守 slide_outline,生成 deck outline、页角色、key message 和 style instruction。"
|
||||
case StageSlideContent:
|
||||
return "按 mode_system_prompt_svg Phase 6 生成逐页内容稿、source refs 和 visual intents。"
|
||||
case StageAssets:
|
||||
return "按 <visuals> 规划/准备图片、图表、diagram、fallback;不得无理由全 diagram。"
|
||||
case StageSVGAuthor:
|
||||
return "调用/遵守 activate_slides_edit 和 slides_edit,按 svg_reference 写完整 SVG slides。"
|
||||
case StageValidatePreviewRepair:
|
||||
return "调用/遵守 finish_slides_edit,执行 validate、preview、quality、semantic repair。"
|
||||
case StagePublishOnline:
|
||||
return "按 delivery_contract 执行线上发布;没有真实 publisher 时必须写 blocked,不能用本地 preview 冒充线上 slide。"
|
||||
default:
|
||||
return "初始化或推进当前 SVGlide run stage。"
|
||||
}
|
||||
}
|
||||
|
||||
func completionGateForStage(stage string, required, conditional []ToolCallRequirement) []string {
|
||||
var gates []string
|
||||
for _, call := range append(append([]ToolCallRequirement{}, required...), conditional...) {
|
||||
gates = append(gates, call.Produces...)
|
||||
}
|
||||
if len(gates) == 0 {
|
||||
switch stage {
|
||||
case StageResearch:
|
||||
gates = []string{"sources_material_ready"}
|
||||
case StageSlideContent:
|
||||
gates = []string{"slide_content_ready"}
|
||||
case StageAssets:
|
||||
gates = []string{"assets_plan_ready"}
|
||||
default:
|
||||
gates = []string{"stage_outputs_ready"}
|
||||
}
|
||||
}
|
||||
return gates
|
||||
}
|
||||
|
||||
func appendUnique(values []string, value string) []string {
|
||||
if value == "" || slices.Contains(values, value) {
|
||||
return values
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func stringSlicesEqual(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if strings.TrimSpace(got[i]) != strings.TrimSpace(want[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionMatched(condition string, run Run, safeRoot string) (bool, error) {
|
||||
switch condition {
|
||||
case "", "always":
|
||||
return true, nil
|
||||
case "svg_has_custom_path":
|
||||
matches, _ := filepath.Glob(filepath.Join(safeRoot, "slides", "*.svg"))
|
||||
for _, path := range matches {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.Contains(string(raw), `slide:shape-type="custom"`) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
case "visual_type_chart":
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(string(raw), `"type":"chart"`) || strings.Contains(string(raw), `"type": "chart"`), nil
|
||||
case "required_chart_renderer_vega_lite":
|
||||
if contractRequiresVegaLiteChart(safeRoot) {
|
||||
return true, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(string(raw), `"type":"chart"`) || strings.Contains(string(raw), `"type": "chart"`), nil
|
||||
case "legacy_or_non_chart_svg_visual_reference_only":
|
||||
return run.RouteProfile == routeProfileImportedPPTX || run.RouteProfile == routeProfileLegacyEditor, nil
|
||||
case "input_is_pptx":
|
||||
if run.RouteProfile != routeProfileImportedPPTX {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(filepath.Ext(run.Intent.Input), ".pptx") || strings.EqualFold(filepath.Ext(run.Input), ".pptx"), nil
|
||||
case "template_requested":
|
||||
if run.RouteProfile != routeProfileTemplateReference {
|
||||
return false, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "request/request.json")
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(string(raw), `"template":true`) ||
|
||||
strings.Contains(string(raw), `"template": true`) ||
|
||||
strings.Contains(string(raw), `"template_requested":true`) ||
|
||||
strings.Contains(string(raw), `"template_requested": true`), nil
|
||||
case "outline_changed_after_initial_generation":
|
||||
return false, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func contractRequiresVegaLiteChart(safeRoot string) bool {
|
||||
for _, rel := range []string{"brief/visual_quality_contract.json", "request/entity_resolution.json"} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(raw), `"required_chart_renderer":"vega-lite"`) || strings.Contains(string(raw), `"required_chart_renderer": "vega-lite"`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
221
internal/svglide/prompt_manifest.go
Normal file
221
internal/svglide/prompt_manifest.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const anyGenPromptRoot = "skills/lark-slides/references/anygen-svg"
|
||||
const anyGenSourceFull = "docs/vendor/anygen-svg/source.full.md"
|
||||
|
||||
type PromptManifest struct {
|
||||
Source string `json:"source"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entries []PromptManifestEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type PromptManifestEntry struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Always bool `json:"always,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
OrchestratedBy string `json:"orchestrated_by,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
Cardinality string `json:"cardinality,omitempty"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
Condition string `json:"condition,omitempty"`
|
||||
Trigger []string `json:"trigger,omitempty"`
|
||||
Consumes []string `json:"consumes,omitempty"`
|
||||
Produces []string `json:"produces,omitempty"`
|
||||
CompletionGate []string `json:"completion_gate,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty"`
|
||||
Exposure string `json:"exposure,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultPromptManifest() PromptManifest {
|
||||
return PromptManifest{
|
||||
Source: anyGenPromptRoot,
|
||||
Runtime: "agent",
|
||||
Entries: []PromptManifestEntry{
|
||||
sourceSnapshotEntry("anygen_source_full", anyGenSourceFull),
|
||||
referenceEntry("anygen_svg_readme", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "README.md")), "reference_index"),
|
||||
referenceEntry("mode_system_prompt_svg", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "mode_system_prompt_svg.md")), "orchestrator"),
|
||||
referenceEntry("svg_reference", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svg_reference.md")), "protocol_reference"),
|
||||
referenceEntry("anygen_semantic_contract", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "semantic_contract.md")), "semantic_contract"),
|
||||
referenceEntry("svglide_local_runtime_binding", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svglide_local_runtime_binding.md")), "runtime_binding"),
|
||||
referenceEntry("svglide_visual_quality_overlay", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svglide_visual_quality_overlay.md")), "runtime_binding"),
|
||||
referenceEntry("slide_font_catalog", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "slide_font_catalog.md")), "runtime_binding"),
|
||||
toolEntry("resolve_delivery_contract", "resolve_delivery_contract", StageRequestResolution, 0, "once", "always", []string{"request/request.json", "request/source_manifest.json"}, []string{deliveryContractPath, "receipts/tool_calls/request_resolution/resolve_delivery_contract.json"}, []string{"delivery_contract_schema_valid", "delivery_target_not_downgraded"}),
|
||||
toolEntry("resolve_theme_contract", "resolve_theme_contract", StageRequestResolution, 0, "once", "always", []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}, []string{"request/theme_contract.json", "receipts/tool_calls/request_resolution/resolve_theme_contract.json"}, []string{"theme_contract_schema_valid", "theme_contract_gate_valid"}),
|
||||
toolEntry("plan_research", "plan_research", StageResearch, 1, "once", "always", []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json", "request/theme_contract.json", deliveryContractPath}, []string{"research/research_plan.json", "research/queries.json", "receipts/tool_calls/research/plan_research.json"}, []string{"research_plan_valid", "research_queries_valid"}),
|
||||
toolEntry("resolve_design_brief", "resolve_design_brief", StageDesignBrief, 1, "once", "always", []string{"request/request.json", "request/source_manifest.json", "request/theme_contract.json", "research/research_notes.md"}, []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json", "brief/visual_quality_contract.json"}, []string{"design_brief_resolved", "typography_contract_resolved", "visual_quality_contract_resolved"}),
|
||||
toolEntry("slide_outline", "slide_outline", StageOutline, 2, "once", "always", []string{"request/theme_contract.json", "brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json", "brief/visual_quality_contract.json"}, []string{"outline/deck.json"}, []string{"deck_outline_valid"}),
|
||||
toolEntry("activate_slides_edit", "activate_slides_edit", StageSVGAuthor, 3, "once", "always", []string{"outline/deck.json"}, []string{"receipts/tool_calls/svg_author/activate_slides_edit.json"}, []string{"slide_edit_activated"}),
|
||||
toolEntry("slides_edit", "slides_edit", StageSVGAuthor, 4, "once_or_more", "always", []string{"request/theme_contract.json", "outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "brief/typography_contract.json", "brief/visual_quality_contract.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "assets/image_candidates.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json"}, []string{"slides/*.svg"}, []string{"svg_protocol_valid", "slide_matches_outline_content_assets"}),
|
||||
toolEntry("finish_slides_edit", "finish_slides_edit", StageValidatePreviewRepair, 5, "once", "always", []string{"request/theme_contract.json", "slides/*.svg"}, []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "receipts/image_usage.json", "receipts/media_pressure.json", "receipts/chart_usage.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json", "receipts/editorial_quality.json", "receipts/screenshot_evidence.json", "receipts/chart_quality.json"}, []string{"validate_preview_rendered_visual_quality_semantic_creative_editorial_screenshot_chart_passed"}),
|
||||
conditionalToolEntry("slide_organize", "slide_organize", StageOutline, 6, "zero_or_more", "outline_changed_after_initial_generation", []string{"outline/deck.json"}, []string{"outline/deck.json"}, []string{"outline_structure_updated"}),
|
||||
conditionalToolEntry("compute_custom_shape_bbox", "compute_custom_shape_bbox", StageSVGAuthor, 7, "zero_or_more", "svg_has_custom_path", []string{"slides/*.svg"}, []string{"receipts/tool_calls/svg_author/compute_custom_shape_bbox.json"}, []string{"custom_shape_bbox_resolved"}),
|
||||
toolEntry("resolve_image_assets", "resolve_image_assets", StageAssets, 8, "once", "always", []string{"request/request.json", "request/entity_resolution.json", "request/theme_contract.json", "research/sources.json", "outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "brief/visual_quality_contract.json"}, []string{"assets/image_candidates.json", "assets/assets_plan.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "receipts/tool_calls/assets/resolve_image_assets.json"}, []string{"image_candidates_recorded", "selected_images_have_source_url", "selected_images_have_role_fit_reason"}),
|
||||
conditionalToolEntry("generate_vega_lite_chart", "generate_vega_lite_chart", StageAssets, 9, "zero_or_more", "required_chart_renderer_vega_lite", []string{"request/theme_contract.json", "content/slide_content.json", "research/sources.json", "assets/assets_manifest.json", "assets/charts/chart_briefs.json"}, []string{"assets/charts/chart_manifest.json", "assets/charts/specs/*.vl.json"}, []string{"vega_lite_specs_planned"}),
|
||||
conditionalToolEntry("generate_svg_chart", "generate_svg_chart", StageAssets, 10, "zero_or_more", "legacy_or_non_chart_svg_visual_reference_only", []string{"content/slide_content.json", "assets/assets_manifest.json"}, []string{"assets/assets_manifest.json"}, []string{"not_a_standard_chart_producer"}),
|
||||
toolEntry("publish_online", "publish_online", StagePublishOnline, 11, "once", "always", []string{"slides/*.svg", deliveryReceiptPath}, []string{svgPublishRequestEvidencePath, onlineSlideReportPath, onlinePublishReceiptRel}, []string{"svg_publish_request_evidence_passed", "online_slide_delivery_status_recorded"}),
|
||||
legacyConditionalToolEntry("slides_convert", "slides_convert", StageResearch, 12, "zero_or_more", "input_is_pptx", []string{"request/source_manifest.json"}, []string{"research/sources.json"}, []string{"slides_converted"}, []string{routeProfileImportedPPTX}),
|
||||
legacyConditionalToolEntry("slides_parse_template", "slides_parse_template", StageAssets, 13, "zero_or_more", "template_requested", []string{"request/request.json"}, []string{"assets/assets_manifest.json"}, []string{"template_parsed"}, []string{routeProfileTemplateReference}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func referenceEntry(id, path, role string) PromptManifestEntry {
|
||||
return PromptManifestEntry{Name: id, ID: id, Path: path, Always: true, Role: role, Invocation: "reference", Profiles: runtimeProfiles(), Exposure: "runtime"}
|
||||
}
|
||||
|
||||
func sourceSnapshotEntry(id, path string) PromptManifestEntry {
|
||||
entry := referenceEntry(id, path, "source_snapshot")
|
||||
entry.Always = false
|
||||
entry.Exposure = "audit"
|
||||
return entry
|
||||
}
|
||||
|
||||
func toolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string) PromptManifestEntry {
|
||||
return PromptManifestEntry{
|
||||
Name: name,
|
||||
ID: name,
|
||||
Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", file+".md")),
|
||||
Stage: stage,
|
||||
Role: "tool_prompt",
|
||||
OrchestratedBy: "mode_system_prompt_svg",
|
||||
Invocation: "required",
|
||||
Order: order,
|
||||
Cardinality: cardinality,
|
||||
Requires: []string{"mode_system_prompt_svg", "svg_reference"},
|
||||
Condition: condition,
|
||||
Trigger: []string{"initial_deck_generation"},
|
||||
Consumes: consumes,
|
||||
Produces: produces,
|
||||
CompletionGate: gate,
|
||||
Profiles: runtimeProfiles(),
|
||||
Exposure: "runtime",
|
||||
}
|
||||
}
|
||||
|
||||
func conditionalToolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string) PromptManifestEntry {
|
||||
entry := toolEntry(name, file, stage, order, cardinality, condition, consumes, produces, gate)
|
||||
entry.Invocation = "conditional"
|
||||
entry.Trigger = []string{condition}
|
||||
return entry
|
||||
}
|
||||
|
||||
func legacyConditionalToolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string, profiles []string) PromptManifestEntry {
|
||||
entry := conditionalToolEntry(name, file, stage, order, cardinality, condition, consumes, produces, gate)
|
||||
entry.Profiles = profiles
|
||||
entry.Exposure = "legacy"
|
||||
return entry
|
||||
}
|
||||
|
||||
func runtimeProfiles() []string {
|
||||
return []string{RouteProfileLocalSVGDeck, routeProfileImportedPPTX, routeProfileTemplateReference}
|
||||
}
|
||||
|
||||
func ResolvedPromptManifest() (PromptManifest, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return PromptManifest{}, err
|
||||
}
|
||||
entries := make([]PromptManifestEntry, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
entries = append(entries, PromptManifestEntry{
|
||||
Name: asset.ID,
|
||||
ID: asset.ID,
|
||||
Path: asset.Path,
|
||||
SHA256: asset.SHA256,
|
||||
Stage: asset.Stage,
|
||||
Always: asset.Role == "reference_index" || asset.Role == "semantic_contract" || asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.Role == "runtime_binding",
|
||||
Role: asset.Role,
|
||||
OrchestratedBy: asset.OrchestratedBy,
|
||||
Invocation: asset.Invocation,
|
||||
Order: asset.Order,
|
||||
Cardinality: asset.Cardinality,
|
||||
Requires: asset.Requires,
|
||||
Condition: asset.Condition,
|
||||
Trigger: asset.Trigger,
|
||||
Consumes: asset.Consumes,
|
||||
Produces: asset.Produces,
|
||||
CompletionGate: asset.CompletionGate,
|
||||
PhaseAnchors: asset.PhaseAnchors,
|
||||
Profiles: asset.Profiles,
|
||||
Exposure: asset.Exposure,
|
||||
})
|
||||
}
|
||||
return PromptManifest{Source: anyGenPromptRoot, Runtime: "agent", Entries: entries}, nil
|
||||
}
|
||||
|
||||
func PromptPathsForStage(stage string) ([]string, error) {
|
||||
return PromptPathsForStageForProfile(RouteProfileLocalSVGDeck, stage)
|
||||
}
|
||||
|
||||
func PromptPathsForStageForProfile(profile string, stage string) ([]string, error) {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := make([]string, 0, len(manifest.Entries))
|
||||
for _, entry := range manifest.Entries {
|
||||
if !promptAssetAllowedForProfile(entry.Profiles, profile) {
|
||||
continue
|
||||
}
|
||||
if entry.Always || entry.Stage == stage {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func CorePromptIDsForProfile(profile string) ([]string, error) {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(manifest.Entries))
|
||||
for _, entry := range manifest.Entries {
|
||||
if !promptAssetAllowedForProfile(entry.Profiles, profile) {
|
||||
continue
|
||||
}
|
||||
switch entry.Role {
|
||||
case "orchestrator", "protocol_reference", "runtime_binding":
|
||||
ids = appendUnique(ids, entry.ID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func CorePromptIDsForRun(run Run) ([]string, error) {
|
||||
return CorePromptIDsForProfile(normalizedRouteProfile(run.RouteProfile))
|
||||
}
|
||||
|
||||
func promptAssetAllowedForProfile(profiles []string, profile string) bool {
|
||||
if strings.TrimSpace(profile) == "" {
|
||||
profile = RouteProfileLocalSVGDeck
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range profiles {
|
||||
if candidate == profile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writePromptManifest(root string) error {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(filepath.Join(root, "prompt_manifest.json"), manifest)
|
||||
}
|
||||
246
internal/svglide/publish_svg_guard.go
Normal file
246
internal/svglide/publish_svg_guard.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const svgPublishRequestEvidencePath = "publish/request_evidence.json"
|
||||
|
||||
type SVGPublishRequestEvidence struct {
|
||||
Status string `json:"status"`
|
||||
ContentType string `json:"content_type"`
|
||||
Title string `json:"title,omitempty"`
|
||||
SlideCount int `json:"slide_count"`
|
||||
Slides []SVGPublishSlideEvidence `json:"slides"`
|
||||
ForbiddenFormatDetected bool `json:"forbidden_format_detected"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type SVGPublishSlideEvidence struct {
|
||||
Path string `json:"path"`
|
||||
ContentRoot string `json:"content_root"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ContentBytes int `json:"content_bytes"`
|
||||
ContentPrefix string `json:"content_prefix"`
|
||||
}
|
||||
|
||||
func BuildAndWriteSVGPublishRequestEvidence(root string) (SVGPublishRequestEvidence, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return SVGPublishRequestEvidence{}, err
|
||||
}
|
||||
evidence, err := buildSVGPublishRequestEvidence(safeRoot, run)
|
||||
if writeErr := writeSVGPublishRequestEvidence(safeRoot, evidence); writeErr != nil {
|
||||
if err != nil {
|
||||
return evidence, fmt.Errorf("%w; write SVG publish request evidence: %v", err, writeErr)
|
||||
}
|
||||
return evidence, writeErr
|
||||
}
|
||||
return evidence, err
|
||||
}
|
||||
|
||||
func buildSVGPublishRequestEvidence(safeRoot string, run Run) (SVGPublishRequestEvidence, error) {
|
||||
evidence := SVGPublishRequestEvidence{
|
||||
Status: "passed",
|
||||
ContentType: "svg",
|
||||
Title: strings.TrimSpace(run.Title),
|
||||
Slides: []SVGPublishSlideEvidence{},
|
||||
Issues: []ValidationIssue{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
slidePaths, err := publishSlidePaths(safeRoot, run)
|
||||
if err != nil {
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, ValidationIssue{
|
||||
Path: strings.TrimSpace(run.Artifacts.Deck),
|
||||
Code: "svglide.publish_svg.deck",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return evidence, fmt.Errorf("build SVG publish evidence: %w", err)
|
||||
}
|
||||
if len(slidePaths) == 0 {
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, ValidationIssue{
|
||||
Path: strings.TrimSpace(run.Artifacts.Deck),
|
||||
Code: "svglide.publish_svg.no_slides",
|
||||
Message: "publish payload must include at least one SVG slide",
|
||||
})
|
||||
return evidence, fmt.Errorf("build SVG publish evidence: no SVG slides")
|
||||
}
|
||||
for _, slidePath := range slidePaths {
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, ValidationIssue{Path: slidePath, Code: "svglide.publish_svg.read", Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
rootName, rootSpace, rootErr := xmlRootName(raw)
|
||||
slideEvidence := SVGPublishSlideEvidence{
|
||||
Path: slidePath,
|
||||
ContentRoot: rootName,
|
||||
SHA256: sha256Hex(raw),
|
||||
ContentBytes: len(raw),
|
||||
ContentPrefix: contentPrefix(raw),
|
||||
}
|
||||
evidence.Slides = append(evidence.Slides, slideEvidence)
|
||||
if rootErr != nil {
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, ValidationIssue{Path: slidePath, Code: "svglide.publish_svg.xml", Message: rootErr.Error()})
|
||||
continue
|
||||
}
|
||||
if detectForbiddenPublishFormat(raw, rootName) {
|
||||
evidence.ForbiddenFormatDetected = true
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, ValidationIssue{
|
||||
Path: slidePath,
|
||||
Code: "svglide.publish_svg.forbidden_format",
|
||||
Message: "publish payload must be raw SVG content, not Slides XML, SXSD, HTML, raster image, or data URL fallback",
|
||||
})
|
||||
}
|
||||
if rootName != "svg" || rootSpace != svgNamespace {
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, ValidationIssue{
|
||||
Path: slidePath,
|
||||
Code: "svglide.publish_svg.root",
|
||||
Message: "publish payload content root must be <svg>",
|
||||
})
|
||||
}
|
||||
for _, issue := range lintSVG(slidePath, raw) {
|
||||
evidence.Status = "failed"
|
||||
evidence.Issues = append(evidence.Issues, issue)
|
||||
}
|
||||
}
|
||||
evidence.SlideCount = len(evidence.Slides)
|
||||
if evidence.Status != "passed" {
|
||||
return evidence, fmt.Errorf("SVG publish request evidence failed with %d issue(s)", len(evidence.Issues))
|
||||
}
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func publishSlidePaths(safeRoot string, run Run) ([]string, error) {
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return nil, fmt.Errorf("deck artifact path is empty")
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var deck validationDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
return nil, fmt.Errorf("deck %q contains invalid JSON: %w", deckPath, err)
|
||||
}
|
||||
paths := make([]string, 0, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
path := strings.TrimSpace(slide.Path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("deck %q contains a slide with empty path", deckPath)
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(path))
|
||||
if clean != path || !strings.HasPrefix(clean, "slides/") || strings.Count(clean, "/") != 1 || strings.ToLower(filepath.Ext(clean)) != ".svg" {
|
||||
return nil, fmt.Errorf("slide path %q must be a single-level slides/*.svg path", path)
|
||||
}
|
||||
paths = append(paths, clean)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func ReadSVGPublishSlides(root string, evidence SVGPublishRequestEvidence) ([]string, error) {
|
||||
if evidence.Status != "passed" || evidence.ContentType != "svg" || evidence.ForbiddenFormatDetected {
|
||||
return nil, fmt.Errorf("SVG publish evidence status=%q content_type=%q forbidden=%v", evidence.Status, evidence.ContentType, evidence.ForbiddenFormatDetected)
|
||||
}
|
||||
if len(evidence.Slides) == 0 || evidence.SlideCount != len(evidence.Slides) {
|
||||
return nil, fmt.Errorf("SVG publish evidence slide_count=%d but has %d slide entries", evidence.SlideCount, len(evidence.Slides))
|
||||
}
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slides := make([]string, 0, len(evidence.Slides))
|
||||
for _, slide := range evidence.Slides {
|
||||
path := strings.TrimSpace(slide.Path)
|
||||
clean := filepath.ToSlash(filepath.Clean(path))
|
||||
if clean != path || !strings.HasPrefix(clean, "slides/") || strings.Count(clean, "/") != 1 || strings.ToLower(filepath.Ext(clean)) != ".svg" {
|
||||
return nil, fmt.Errorf("SVG publish evidence slide path %q is not a single-level slides/*.svg path", path)
|
||||
}
|
||||
if slide.ContentRoot != "svg" || strings.TrimSpace(slide.SHA256) == "" {
|
||||
return nil, fmt.Errorf("SVG publish evidence for %s is not a verified SVG slide", path)
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got := sha256Hex(raw); got != slide.SHA256 {
|
||||
return nil, fmt.Errorf("SVG publish evidence for %s is stale: sha256 %s want %s", path, got, slide.SHA256)
|
||||
}
|
||||
rootName, rootSpace, err := xmlRootName(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rootName != "svg" || rootSpace != svgNamespace || detectForbiddenPublishFormat(raw, rootName) {
|
||||
return nil, fmt.Errorf("SVG publish payload %s is no longer raw SVG content", path)
|
||||
}
|
||||
slides = append(slides, string(raw))
|
||||
}
|
||||
return slides, nil
|
||||
}
|
||||
|
||||
func xmlRootName(raw []byte) (string, string, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return "", "", fmt.Errorf("missing XML root element")
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid XML: %w", err)
|
||||
}
|
||||
if start, ok := token.(xml.StartElement); ok {
|
||||
return start.Name.Local, start.Name.Space, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func detectForbiddenPublishFormat(raw []byte, rootName string) bool {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.HasPrefix(lower, "data:image/") {
|
||||
return true
|
||||
}
|
||||
if len(raw) >= 8 && bytes.Equal(raw[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
||||
return true
|
||||
}
|
||||
if len(raw) >= 3 && raw[0] == 0xff && raw[1] == 0xd8 && raw[2] == 0xff {
|
||||
return true
|
||||
}
|
||||
switch rootName {
|
||||
case "slide", "presentation", "sxsd", "document", "html":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func contentPrefix(raw []byte) string {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if len(trimmed) > 160 {
|
||||
trimmed = trimmed[:160]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func writeSVGPublishRequestEvidence(safeRoot string, evidence SVGPublishRequestEvidence) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, svgPublishRequestEvidencePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, evidence)
|
||||
}
|
||||
1309
internal/svglide/quality.go
Normal file
1309
internal/svglide/quality.go
Normal file
File diff suppressed because it is too large
Load Diff
1011
internal/svglide/quality_test.go
Normal file
1011
internal/svglide/quality_test.go
Normal file
File diff suppressed because it is too large
Load Diff
153
internal/svglide/receipt.go
Normal file
153
internal/svglide/receipt.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type validationLintReceipt struct {
|
||||
Status string `json:"status"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
|
||||
report = normalizeValidationReport(report)
|
||||
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(validationLintReceipt{
|
||||
Status: validationReceiptStatus(report),
|
||||
Issues: report.Issues,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func normalizeValidationReport(report ValidationReport) ValidationReport {
|
||||
if report.Issues == nil {
|
||||
report.Issues = []ValidationIssue{}
|
||||
}
|
||||
report.OK = len(report.Issues) == 0
|
||||
for i := range report.Issues {
|
||||
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
|
||||
if report.Issues[i].Path == "" {
|
||||
report.Issues[i].Path = "(deck)"
|
||||
}
|
||||
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
|
||||
if report.Issues[i].Code == "" {
|
||||
report.Issues[i].Code = "svglide.validation"
|
||||
}
|
||||
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
|
||||
if report.Issues[i].Severity == "" {
|
||||
report.Issues[i].Severity = "error"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func validationReceiptStatus(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "passed"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
|
||||
func renderRepairQueue(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Repair Queue\n\n")
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return "", fmt.Errorf("run file path must not be root")
|
||||
}
|
||||
dirRel := filepath.Dir(cleanRel)
|
||||
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := safeRunPath(safeRoot, cleanRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("run file path %q must be a regular file", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return path, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err := vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.Mkdir(cur, 0o755); err != nil {
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
920
internal/svglide/rendered_visual.go
Normal file
920
internal/svglide/rendered_visual.go
Normal file
@@ -0,0 +1,920 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const renderedVisualReceiptPath = "receipts/rendered_visual.json"
|
||||
|
||||
type RenderedVisualReport struct {
|
||||
Status string `json:"status"`
|
||||
Metrics RenderedVisualMetrics `json:"metrics"`
|
||||
Issues []RenderedVisualIssue `json:"issues"`
|
||||
Slides []RenderedVisualSlideItem `json:"slides"`
|
||||
}
|
||||
|
||||
type RenderedVisualMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
OutOfCanvasCount int `json:"out_of_canvas_count"`
|
||||
TextOverflowCount int `json:"text_overflow_count"`
|
||||
TextCollisionCount int `json:"text_collision_count"`
|
||||
UnsafeEdgeCount int `json:"unsafe_edge_count"`
|
||||
ContainerTextOverflowCount int `json:"container_text_overflow_count"`
|
||||
ContainerPaddingRiskCount int `json:"container_padding_risk_count"`
|
||||
ForeignObjectOverlapCount int `json:"foreign_object_overlap_count"`
|
||||
TightLineHeightCount int `json:"tight_line_height_count"`
|
||||
BoldOveruseCount int `json:"bold_overuse_count"`
|
||||
SmallTextPaddingRiskCount int `json:"small_text_padding_risk_count"`
|
||||
}
|
||||
|
||||
type RenderedVisualIssue struct {
|
||||
Path string `json:"path"`
|
||||
SlideID string `json:"slide_id,omitempty"`
|
||||
ElementID string `json:"element_id,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
Width float64 `json:"width,omitempty"`
|
||||
Height float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
type RenderedVisualSlideItem struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
}
|
||||
|
||||
type renderedVisualBox struct {
|
||||
Path string
|
||||
ElementID string
|
||||
Kind string
|
||||
Text string
|
||||
X float64
|
||||
Y float64
|
||||
Width float64
|
||||
Height float64
|
||||
RequiredHeight float64
|
||||
FontSize float64
|
||||
LineHeight float64
|
||||
FontWeight float64
|
||||
}
|
||||
|
||||
type renderedVisualContainer struct {
|
||||
Path string
|
||||
ElementID string
|
||||
Kind string
|
||||
X float64
|
||||
Y float64
|
||||
Width float64
|
||||
Height float64
|
||||
RX float64
|
||||
Fill string
|
||||
Stroke string
|
||||
Opacity float64
|
||||
}
|
||||
|
||||
type renderedVisualParseState struct {
|
||||
TranslateX float64
|
||||
TranslateY float64
|
||||
FontSize float64
|
||||
LineHeight float64
|
||||
FontWeight float64
|
||||
TextAnchor string
|
||||
InText bool
|
||||
InForeign bool
|
||||
ElementID string
|
||||
TextX float64
|
||||
TextY float64
|
||||
Text string
|
||||
Foreign renderedVisualBox
|
||||
}
|
||||
|
||||
var renderedStyleNumberPattern = regexp.MustCompile(`(?i)(font-size|line-height)\s*:\s*([0-9.]+)`)
|
||||
var renderedFontWeightNumberPattern = regexp.MustCompile(`(?i)font-weight\s*:\s*([0-9.]+|bold|bolder)`)
|
||||
var renderedTransformTranslatePattern = regexp.MustCompile(`(?i)translate\(\s*([\-0-9.]+)(?:[\s,]+([\-0-9.]+))?`)
|
||||
var renderedHTMLTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
var renderedWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
var renderedStyleValuePattern = regexp.MustCompile(`(?i)(fill|stroke|opacity|background|background-color)\s*:\s*([^;]+)`)
|
||||
var renderedPathTokenPattern = regexp.MustCompile(`[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?`)
|
||||
|
||||
func EvaluateRenderedVisualRun(safeRoot string, deck previewDeck) RenderedVisualReport {
|
||||
report := RenderedVisualReport{
|
||||
Status: "passed",
|
||||
Issues: []RenderedVisualIssue{},
|
||||
Slides: make([]RenderedVisualSlideItem, 0, len(deck.Slides)),
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
item := RenderedVisualSlideItem{Path: strings.TrimSpace(slide.Path), Status: "passed"}
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(item.Path, "svglide.rendered_visual.slide_path", err.Error(), renderedVisualBox{}))
|
||||
report.Slides = append(report.Slides, item)
|
||||
continue
|
||||
}
|
||||
item.Path = slidePath
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(slidePath, "svglide.rendered_visual.read_slide", err.Error(), renderedVisualBox{}))
|
||||
report.Slides = append(report.Slides, item)
|
||||
continue
|
||||
}
|
||||
slideReport := evaluateRenderedVisualSVG(slidePath, raw)
|
||||
item.Status = slideReport.Status
|
||||
item.IssueCount = slideReport.Metrics.IssueCount
|
||||
report.Issues = append(report.Issues, slideReport.Issues...)
|
||||
report.Slides = append(report.Slides, item)
|
||||
}
|
||||
report.Metrics.Slides = len(report.Slides)
|
||||
renderedVisualFinalize(&report)
|
||||
return report
|
||||
}
|
||||
|
||||
func previewDeckFromAuthorDeck(deck authorDeck) previewDeck {
|
||||
out := previewDeck{
|
||||
Title: deck.Title,
|
||||
Slides: make([]previewDeckSlide, 0, len(deck.Slides)),
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
out.Slides = append(out.Slides, previewDeckSlide{
|
||||
ID: slide.ID,
|
||||
Title: slide.Title,
|
||||
Summary: slide.Summary,
|
||||
Role: slide.Role,
|
||||
KeyMessage: slide.KeyMessage,
|
||||
Path: slide.Path,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func evaluateRenderedVisualSVG(path string, raw []byte) RenderedVisualReport {
|
||||
width, height := svgViewBoxSize(string(raw))
|
||||
if width <= 0 {
|
||||
width = defaultSlideWidth
|
||||
}
|
||||
if height <= 0 {
|
||||
height = defaultSlideHeight
|
||||
}
|
||||
boxes := renderedVisualTextBoxes(raw, path)
|
||||
containers := renderedVisualContainers(raw, path, width, height)
|
||||
report := RenderedVisualReport{
|
||||
Status: "passed",
|
||||
Issues: []RenderedVisualIssue{},
|
||||
Slides: []RenderedVisualSlideItem{{Path: path, Status: "passed"}},
|
||||
}
|
||||
const edge = 6.0
|
||||
var boldChars, totalChars int
|
||||
var firstBold renderedVisualBox
|
||||
for _, box := range boxes {
|
||||
if box.Width <= 0 || box.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
totalChars += len([]rune(box.Text))
|
||||
if box.FontWeight >= 700 {
|
||||
boldChars += len([]rune(box.Text))
|
||||
if firstBold.Text == "" {
|
||||
firstBold = box
|
||||
}
|
||||
}
|
||||
if box.Kind == "foreignObject" && box.RequiredHeight > box.Height*1.15 {
|
||||
issueBox := box
|
||||
issueBox.Height = box.RequiredHeight
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.text_box_overflow", "estimated foreignObject text height exceeds container height", issueBox))
|
||||
continue
|
||||
}
|
||||
if box.X < -edge || box.Y < -edge || box.X+box.Width > width+edge || box.Y+box.Height > height+edge {
|
||||
code := "svglide.rendered_visual.text_overflow"
|
||||
if box.Kind == "foreignObject" {
|
||||
code = "svglide.rendered_visual.text_box_overflow"
|
||||
}
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, code, "estimated text box extends outside slide canvas", box))
|
||||
continue
|
||||
}
|
||||
if box.X < edge || box.Y < edge || box.X+box.Width > width-edge || box.Y+box.Height > height-edge {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.unsafe_edge", "estimated text box is too close to slide edge", box))
|
||||
}
|
||||
if box.Kind == "foreignObject" && box.FontSize > 0 && box.LineHeight > 0 && box.LineHeight < box.FontSize*1.12 {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.tight_line_height", "foreignObject line-height is too tight for readable wrapped text", box))
|
||||
}
|
||||
if container, ok := nearestRenderedContainer(box, containers); ok {
|
||||
content := renderedBoxVisibleContentBounds(box)
|
||||
padding := renderedContainerPadding(container)
|
||||
if content.Y+content.Height > container.Y+container.Height-padding {
|
||||
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.container_text_overflow", "estimated text content exceeds visible card/container bottom padding", box, container))
|
||||
} else if box.FontSize > 0 && box.FontSize <= 16 {
|
||||
minMargin := minRenderedBoxMargin(content, container)
|
||||
riskPadding := math.Max(10, box.FontSize*0.75)
|
||||
if minMargin < riskPadding {
|
||||
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.small_text_padding_risk", "small text is too close to visible card/container edge", box, container))
|
||||
}
|
||||
}
|
||||
if content.X < container.X+padding || content.X+content.Width > container.X+container.Width-padding {
|
||||
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.container_padding_risk", "estimated text content violates visible card/container horizontal padding", box, container))
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalChars >= 40 && boldChars*100 > totalChars*65 && firstBold.Text != "" {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.bold_overuse", "more than 65% of visible text is bold, flattening typographic hierarchy", firstBold))
|
||||
}
|
||||
for i := 0; i < len(boxes); i++ {
|
||||
for j := i + 1; j < len(boxes); j++ {
|
||||
a, b := boxes[i], boxes[j]
|
||||
if a.Kind == "foreignObject" && b.Kind == "foreignObject" {
|
||||
if renderedBoxesOverlap(a, b, 3) {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.foreign_object_collision", fmt.Sprintf("estimated foreignObject boxes collide: %q and %q", a.Text, b.Text), a))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a.Kind == "foreignObject" || b.Kind == "foreignObject" {
|
||||
continue
|
||||
}
|
||||
if renderedBoxesOverlap(a, b, 3) {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.text_collision", fmt.Sprintf("estimated text boxes collide: %q and %q", a.Text, b.Text), a))
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Metrics.Slides = 1
|
||||
report.Slides[0].IssueCount = len(report.Issues)
|
||||
if len(report.Issues) > 0 {
|
||||
report.Slides[0].Status = "failed"
|
||||
}
|
||||
renderedVisualFinalize(&report)
|
||||
return report
|
||||
}
|
||||
|
||||
func renderedVisualTextBoxes(raw []byte, path string) []renderedVisualBox {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
stack := []renderedVisualParseState{{FontSize: 16}}
|
||||
var boxes []renderedVisualBox
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
current := stack[len(stack)-1]
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
next := current
|
||||
next.InText = false
|
||||
next.InForeign = current.InForeign
|
||||
next.Text = ""
|
||||
next.ElementID = renderedAttrValue(t.Attr, "id")
|
||||
next.TextAnchor = firstRenderedNonEmpty(renderedAttrValue(t.Attr, "text-anchor"), current.TextAnchor)
|
||||
next.FontSize = firstPositive(parseFloatAttr(t.Attr, "font-size"), parseStyleNumber(renderedAttrValue(t.Attr, "style"), "font-size"), current.FontSize)
|
||||
next.LineHeight = firstPositive(normalizeRenderedLineHeight(parseStyleNumber(renderedAttrValue(t.Attr, "style"), "line-height"), next.FontSize), current.LineHeight)
|
||||
next.FontWeight = firstPositive(parseFontWeightValue(renderedAttrValue(t.Attr, "font-weight")), parseFontWeightStyle(renderedAttrValue(t.Attr, "style")), current.FontWeight)
|
||||
dx, dy := parseTranslate(renderedAttrValue(t.Attr, "transform"))
|
||||
next.TranslateX += dx
|
||||
next.TranslateY += dy
|
||||
switch t.Name.Local {
|
||||
case "text":
|
||||
next.InText = true
|
||||
next.TextX = parseFloatAttr(t.Attr, "x") + next.TranslateX
|
||||
next.TextY = parseFloatAttr(t.Attr, "y") + next.TranslateY
|
||||
case "foreignObject":
|
||||
next.InForeign = true
|
||||
next.Foreign = renderedVisualBox{
|
||||
Path: path,
|
||||
ElementID: next.ElementID,
|
||||
Kind: "foreignObject",
|
||||
X: parseFloatAttr(t.Attr, "x") + next.TranslateX,
|
||||
Y: parseFloatAttr(t.Attr, "y") + next.TranslateY,
|
||||
Width: parseFloatAttr(t.Attr, "width"),
|
||||
Height: parseFloatAttr(t.Attr, "height"),
|
||||
FontSize: next.FontSize,
|
||||
LineHeight: next.LineHeight,
|
||||
FontWeight: next.FontWeight,
|
||||
}
|
||||
}
|
||||
stack = append(stack, next)
|
||||
case xml.CharData:
|
||||
for i := range stack {
|
||||
if stack[i].InText || stack[i].InForeign {
|
||||
stack[i].Text += string(t)
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) <= 1 {
|
||||
continue
|
||||
}
|
||||
top := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
switch t.Name.Local {
|
||||
case "text":
|
||||
text := normalizeRenderedText(top.Text)
|
||||
if text != "" {
|
||||
width := estimateRenderedTextWidth(text, top.FontSize)
|
||||
x := top.TextX
|
||||
switch strings.TrimSpace(top.TextAnchor) {
|
||||
case "middle":
|
||||
x -= width / 2
|
||||
case "end":
|
||||
x -= width
|
||||
}
|
||||
boxes = append(boxes, renderedVisualBox{
|
||||
Path: path,
|
||||
ElementID: top.ElementID,
|
||||
Kind: "text",
|
||||
Text: text,
|
||||
X: x,
|
||||
Y: top.TextY - top.FontSize*0.86,
|
||||
Width: width,
|
||||
Height: top.FontSize * 1.15,
|
||||
FontSize: top.FontSize,
|
||||
LineHeight: top.LineHeight,
|
||||
FontWeight: top.FontWeight,
|
||||
})
|
||||
}
|
||||
case "foreignObject":
|
||||
text := normalizeRenderedText(top.Text)
|
||||
if text != "" {
|
||||
requiredHeight := estimateForeignObjectTextHeight(text, top.FontSize, top.Foreign.Width)
|
||||
top.Foreign.Text = text
|
||||
top.Foreign.RequiredHeight = requiredHeight
|
||||
top.Foreign.FontSize = top.FontSize
|
||||
top.Foreign.LineHeight = top.LineHeight
|
||||
top.Foreign.FontWeight = top.FontWeight
|
||||
boxes = append(boxes, top.Foreign)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
|
||||
func renderedVisualFinalize(report *RenderedVisualReport) {
|
||||
report.Status = "passed"
|
||||
for _, issue := range report.Issues {
|
||||
report.Metrics.IssueCount++
|
||||
switch issue.Code {
|
||||
case "svglide.rendered_visual.text_overflow", "svglide.rendered_visual.text_box_overflow":
|
||||
report.Metrics.TextOverflowCount++
|
||||
case "svglide.rendered_visual.text_collision":
|
||||
report.Metrics.TextCollisionCount++
|
||||
case "svglide.rendered_visual.unsafe_edge":
|
||||
report.Metrics.UnsafeEdgeCount++
|
||||
case "svglide.rendered_visual.container_text_overflow":
|
||||
report.Metrics.ContainerTextOverflowCount++
|
||||
case "svglide.rendered_visual.container_padding_risk":
|
||||
report.Metrics.ContainerPaddingRiskCount++
|
||||
case "svglide.rendered_visual.foreign_object_collision":
|
||||
report.Metrics.ForeignObjectOverlapCount++
|
||||
case "svglide.rendered_visual.tight_line_height":
|
||||
report.Metrics.TightLineHeightCount++
|
||||
case "svglide.rendered_visual.bold_overuse":
|
||||
report.Metrics.BoldOveruseCount++
|
||||
case "svglide.rendered_visual.small_text_padding_risk":
|
||||
report.Metrics.SmallTextPaddingRiskCount++
|
||||
default:
|
||||
report.Metrics.OutOfCanvasCount++
|
||||
}
|
||||
}
|
||||
if report.Metrics.IssueCount > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
for i := range report.Slides {
|
||||
if report.Slides[i].IssueCount > 0 {
|
||||
report.Slides[i].Status = "failed"
|
||||
} else if report.Slides[i].Status == "" {
|
||||
report.Slides[i].Status = "passed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderedVisualContainerIssue(path, code, message string, box renderedVisualBox, container renderedVisualContainer) RenderedVisualIssue {
|
||||
issue := renderedVisualIssue(path, code, message, box)
|
||||
issue.Message = fmt.Sprintf("%s; nearest container x=%.2f y=%.2f width=%.2f height=%.2f", message, container.X, container.Y, container.Width, container.Height)
|
||||
return issue
|
||||
}
|
||||
|
||||
func renderedVisualIssue(path, code, message string, box renderedVisualBox) RenderedVisualIssue {
|
||||
return RenderedVisualIssue{
|
||||
Path: path,
|
||||
ElementID: box.ElementID,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
X: roundRenderedNumber(box.X),
|
||||
Y: roundRenderedNumber(box.Y),
|
||||
Width: roundRenderedNumber(box.Width),
|
||||
Height: roundRenderedNumber(box.Height),
|
||||
}
|
||||
}
|
||||
|
||||
func writeRenderedVisualReport(safeRoot string, report RenderedVisualReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, renderedVisualReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func readRenderedVisualReport(safeRoot string) (RenderedVisualReport, bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, renderedVisualReceiptPath)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "missing or not a regular file") {
|
||||
return RenderedVisualReport{}, false, nil
|
||||
}
|
||||
return RenderedVisualReport{}, false, err
|
||||
}
|
||||
var report RenderedVisualReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return RenderedVisualReport{}, true, err
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
func renderedVisualSlideFailed(report RenderedVisualReport, path string) bool {
|
||||
for _, slide := range report.Slides {
|
||||
if slide.Path == path && slide.Status == "failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderedBoxesOverlap(a, b renderedVisualBox, pad float64) bool {
|
||||
return a.X < b.X+b.Width+pad &&
|
||||
a.X+a.Width+pad > b.X &&
|
||||
a.Y < b.Y+b.Height+pad &&
|
||||
a.Y+a.Height+pad > b.Y
|
||||
}
|
||||
|
||||
func renderedVisualContainers(raw []byte, path string, slideWidth, slideHeight float64) []renderedVisualContainer {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
type state struct {
|
||||
translateX float64
|
||||
translateY float64
|
||||
}
|
||||
stack := []state{{}}
|
||||
var containers []renderedVisualContainer
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
current := stack[len(stack)-1]
|
||||
next := current
|
||||
dx, dy := parseTranslate(renderedAttrValue(t.Attr, "transform"))
|
||||
next.translateX += dx
|
||||
next.translateY += dy
|
||||
switch t.Name.Local {
|
||||
case "rect":
|
||||
if renderedVisualRectLooksLikeChartMark(t.Attr) {
|
||||
stack = append(stack, next)
|
||||
continue
|
||||
}
|
||||
container := renderedVisualContainer{
|
||||
Path: path,
|
||||
ElementID: renderedAttrValue(t.Attr, "id"),
|
||||
Kind: "rect",
|
||||
X: parseFloatAttr(t.Attr, "x") + next.translateX,
|
||||
Y: parseFloatAttr(t.Attr, "y") + next.translateY,
|
||||
Width: parseFloatAttr(t.Attr, "width"),
|
||||
Height: parseFloatAttr(t.Attr, "height"),
|
||||
RX: parseFloatAttr(t.Attr, "rx"),
|
||||
Fill: firstRenderedNonEmpty(renderedAttrValue(t.Attr, "fill"), parseStyleValue(renderedAttrValue(t.Attr, "style"), "fill")),
|
||||
Stroke: firstRenderedNonEmpty(renderedAttrValue(t.Attr, "stroke"), parseStyleValue(renderedAttrValue(t.Attr, "style"), "stroke")),
|
||||
Opacity: firstPositive(parseFloatAttr(t.Attr, "opacity"), parseFloatLoose(parseStyleValue(renderedAttrValue(t.Attr, "style"), "opacity")), 1),
|
||||
}
|
||||
if isRenderedVisualContainer(container, slideWidth, slideHeight) {
|
||||
containers = append(containers, container)
|
||||
}
|
||||
case "path":
|
||||
if renderedVisualRectLooksLikeChartMark(t.Attr) {
|
||||
stack = append(stack, next)
|
||||
continue
|
||||
}
|
||||
x, y, width, height, ok := renderedPathBounds(renderedAttrValue(t.Attr, "d"))
|
||||
if ok {
|
||||
container := renderedVisualContainer{
|
||||
Path: path,
|
||||
ElementID: renderedAttrValue(t.Attr, "id"),
|
||||
Kind: "path",
|
||||
X: x + next.translateX,
|
||||
Y: y + next.translateY,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Fill: renderedContainerFill(t.Attr),
|
||||
Stroke: renderedContainerStroke(t.Attr),
|
||||
Opacity: firstPositive(parseFloatAttr(t.Attr, "opacity"), parseFloatLoose(parseStyleValue(renderedAttrValue(t.Attr, "style"), "opacity")), 1),
|
||||
}
|
||||
if isRenderedVisualContainer(container, slideWidth, slideHeight) {
|
||||
containers = append(containers, container)
|
||||
}
|
||||
}
|
||||
case "foreignObject":
|
||||
container := renderedVisualContainer{
|
||||
Path: path,
|
||||
ElementID: renderedAttrValue(t.Attr, "id"),
|
||||
Kind: "foreignObject",
|
||||
X: parseFloatAttr(t.Attr, "x") + next.translateX,
|
||||
Y: parseFloatAttr(t.Attr, "y") + next.translateY,
|
||||
Width: parseFloatAttr(t.Attr, "width"),
|
||||
Height: parseFloatAttr(t.Attr, "height"),
|
||||
Fill: renderedContainerFill(t.Attr),
|
||||
Stroke: renderedContainerStroke(t.Attr),
|
||||
Opacity: firstPositive(parseFloatAttr(t.Attr, "opacity"), parseFloatLoose(parseStyleValue(renderedAttrValue(t.Attr, "style"), "opacity")), 1),
|
||||
}
|
||||
if isRenderedVisualContainer(container, slideWidth, slideHeight) {
|
||||
containers = append(containers, container)
|
||||
}
|
||||
}
|
||||
stack = append(stack, next)
|
||||
case xml.EndElement:
|
||||
if len(stack) > 1 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
func isRenderedVisualContainer(container renderedVisualContainer, slideWidth, slideHeight float64) bool {
|
||||
if container.Width <= 0 || container.Height <= 0 || slideWidth <= 0 || slideHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
if container.Width/slideWidth < 0.04 || container.Height/slideHeight < 0.04 {
|
||||
return false
|
||||
}
|
||||
fill := strings.ToLower(strings.TrimSpace(container.Fill))
|
||||
stroke := strings.ToLower(strings.TrimSpace(container.Stroke))
|
||||
hasVisualStyle := container.RX > 0 || (fill != "" && fill != "none" && fill != "transparent") || (stroke != "" && stroke != "none" && stroke != "transparent")
|
||||
if !hasVisualStyle {
|
||||
return false
|
||||
}
|
||||
areaRatio := (container.Width * container.Height) / (slideWidth * slideHeight)
|
||||
if areaRatio > 0.55 {
|
||||
return false
|
||||
}
|
||||
touchesEdges := container.X <= 2 && container.Y <= 2 && container.X+container.Width >= slideWidth-2 && container.Y+container.Height >= slideHeight-2
|
||||
return !(areaRatio > 0.70 && touchesEdges)
|
||||
}
|
||||
|
||||
func renderedVisualRectLooksLikeChartMark(attrs []xml.Attr) bool {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
renderedAttrValue(attrs, "id"),
|
||||
renderedAttrValue(attrs, "class"),
|
||||
renderedAttrValue(attrs, "role"),
|
||||
renderedAttrValue(attrs, "aria-label"),
|
||||
renderedAttrValue(attrs, "data-mark"),
|
||||
renderedAttrValue(attrs, "data-role"),
|
||||
}, " "))
|
||||
for _, token := range []string{"mark-bar", "mark bar", "bar-mark", "axis", "tick", "plot", "vega", "data-point", "datapoint"} {
|
||||
if strings.Contains(haystack, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nearestRenderedContainer(box renderedVisualBox, containers []renderedVisualContainer) (renderedVisualContainer, bool) {
|
||||
content := renderedBoxVisibleContentBounds(box)
|
||||
centerX := content.X + content.Width/2
|
||||
var best renderedVisualContainer
|
||||
var bestArea float64
|
||||
for _, container := range containers {
|
||||
if centerX < container.X || centerX > container.X+container.Width {
|
||||
continue
|
||||
}
|
||||
overlapsVertically := content.Y < container.Y+container.Height && content.Y+content.Height > container.Y
|
||||
startsInside := box.Y >= container.Y && box.Y <= container.Y+container.Height
|
||||
if !overlapsVertically && !startsInside {
|
||||
continue
|
||||
}
|
||||
area := container.Width * container.Height
|
||||
if bestArea == 0 || area < bestArea {
|
||||
best = container
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return best, bestArea > 0
|
||||
}
|
||||
|
||||
func renderedBoxVisibleContentBounds(box renderedVisualBox) renderedVisualBox {
|
||||
out := box
|
||||
if box.Kind == "foreignObject" && box.RequiredHeight > 0 {
|
||||
out.Height = box.RequiredHeight
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderedContainerPadding(container renderedVisualContainer) float64 {
|
||||
shortSide := math.Min(container.Width, container.Height)
|
||||
return math.Max(12, math.Min(24, shortSide*0.10))
|
||||
}
|
||||
|
||||
func minRenderedBoxMargin(box renderedVisualBox, container renderedVisualContainer) float64 {
|
||||
return math.Min(
|
||||
math.Min(box.X-container.X, container.X+container.Width-(box.X+box.Width)),
|
||||
math.Min(box.Y-container.Y, container.Y+container.Height-(box.Y+box.Height)),
|
||||
)
|
||||
}
|
||||
|
||||
func estimateForeignObjectTextHeight(text string, fontSize float64, width float64) float64 {
|
||||
if fontSize <= 0 {
|
||||
fontSize = 16
|
||||
}
|
||||
if width <= 0 {
|
||||
return fontSize * 1.3
|
||||
}
|
||||
plain := normalizeRenderedText(renderedHTMLTagPattern.ReplaceAllString(text, " "))
|
||||
textWidth := estimateRenderedTextWidth(plain, fontSize)
|
||||
lines := math.Max(1, math.Ceil(textWidth/(width*0.92)))
|
||||
return lines * fontSize * 1.22
|
||||
}
|
||||
|
||||
func estimateRenderedTextWidth(text string, fontSize float64) float64 {
|
||||
if fontSize <= 0 {
|
||||
fontSize = 16
|
||||
}
|
||||
var units float64
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case r == ' ' || r == '\t' || r == '\n':
|
||||
units += 0.33
|
||||
case r < 128:
|
||||
if strings.ContainsRune(".,:;!|/\\'\"()-+%", r) {
|
||||
units += 0.36
|
||||
} else {
|
||||
units += 0.58
|
||||
}
|
||||
default:
|
||||
units += 1.0
|
||||
}
|
||||
}
|
||||
return units * fontSize
|
||||
}
|
||||
|
||||
func renderedContainerFill(attrs []xml.Attr) string {
|
||||
style := renderedAttrValue(attrs, "style")
|
||||
return firstRenderedNonEmpty(
|
||||
renderedAttrValue(attrs, "fill"),
|
||||
parseStyleValue(style, "fill"),
|
||||
parseStyleValue(style, "background-color"),
|
||||
parseStyleValue(style, "background"),
|
||||
)
|
||||
}
|
||||
|
||||
func renderedContainerStroke(attrs []xml.Attr) string {
|
||||
style := renderedAttrValue(attrs, "style")
|
||||
return firstRenderedNonEmpty(
|
||||
renderedAttrValue(attrs, "stroke"),
|
||||
parseStyleValue(style, "stroke"),
|
||||
)
|
||||
}
|
||||
|
||||
func renderedPathBounds(d string) (float64, float64, float64, float64, bool) {
|
||||
tokens := renderedPathTokenPattern.FindAllString(strings.TrimSpace(d), -1)
|
||||
if len(tokens) == 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
i := 0
|
||||
cmd := byte(0)
|
||||
var curX, curY float64
|
||||
minX, minY := math.Inf(1), math.Inf(1)
|
||||
maxX, maxY := math.Inf(-1), math.Inf(-1)
|
||||
hasPoint := false
|
||||
addPoint := func(x, y float64) {
|
||||
minX = math.Min(minX, x)
|
||||
minY = math.Min(minY, y)
|
||||
maxX = math.Max(maxX, x)
|
||||
maxY = math.Max(maxY, y)
|
||||
hasPoint = true
|
||||
}
|
||||
for i < len(tokens) {
|
||||
if renderedPathTokenIsCommand(tokens[i]) {
|
||||
cmd = tokens[i][0]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if cmd == 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
relative := cmd >= 'a' && cmd <= 'z'
|
||||
switch renderedPathCommandUpper(cmd) {
|
||||
case 'M', 'L', 'T':
|
||||
if i+1 >= len(tokens) {
|
||||
i = len(tokens)
|
||||
continue
|
||||
}
|
||||
x, y := parseFloatLoose(tokens[i]), parseFloatLoose(tokens[i+1])
|
||||
if relative {
|
||||
x += curX
|
||||
y += curY
|
||||
}
|
||||
curX, curY = x, y
|
||||
addPoint(curX, curY)
|
||||
i += 2
|
||||
case 'H':
|
||||
x := parseFloatLoose(tokens[i])
|
||||
if relative {
|
||||
x += curX
|
||||
}
|
||||
curX = x
|
||||
addPoint(curX, curY)
|
||||
i++
|
||||
case 'V':
|
||||
y := parseFloatLoose(tokens[i])
|
||||
if relative {
|
||||
y += curY
|
||||
}
|
||||
curY = y
|
||||
addPoint(curX, curY)
|
||||
i++
|
||||
case 'C':
|
||||
if i+5 >= len(tokens) {
|
||||
i = len(tokens)
|
||||
continue
|
||||
}
|
||||
for offset := 0; offset <= 4; offset += 2 {
|
||||
x, y := parseFloatLoose(tokens[i+offset]), parseFloatLoose(tokens[i+offset+1])
|
||||
if relative {
|
||||
x += curX
|
||||
y += curY
|
||||
}
|
||||
addPoint(x, y)
|
||||
if offset == 4 {
|
||||
curX, curY = x, y
|
||||
}
|
||||
}
|
||||
i += 6
|
||||
case 'S', 'Q':
|
||||
if i+3 >= len(tokens) {
|
||||
i = len(tokens)
|
||||
continue
|
||||
}
|
||||
for offset := 0; offset <= 2; offset += 2 {
|
||||
x, y := parseFloatLoose(tokens[i+offset]), parseFloatLoose(tokens[i+offset+1])
|
||||
if relative {
|
||||
x += curX
|
||||
y += curY
|
||||
}
|
||||
addPoint(x, y)
|
||||
if offset == 2 {
|
||||
curX, curY = x, y
|
||||
}
|
||||
}
|
||||
i += 4
|
||||
case 'A':
|
||||
if i+6 >= len(tokens) {
|
||||
i = len(tokens)
|
||||
continue
|
||||
}
|
||||
x, y := parseFloatLoose(tokens[i+5]), parseFloatLoose(tokens[i+6])
|
||||
if relative {
|
||||
x += curX
|
||||
y += curY
|
||||
}
|
||||
curX, curY = x, y
|
||||
addPoint(curX, curY)
|
||||
i += 7
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
if !hasPoint || maxX <= minX || maxY <= minY {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
return minX, minY, maxX - minX, maxY - minY, true
|
||||
}
|
||||
|
||||
func renderedPathTokenIsCommand(token string) bool {
|
||||
if len(token) != 1 {
|
||||
return false
|
||||
}
|
||||
ch := token[0]
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
|
||||
}
|
||||
|
||||
func renderedPathCommandUpper(cmd byte) byte {
|
||||
if cmd >= 'a' && cmd <= 'z' {
|
||||
return cmd - ('a' - 'A')
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func renderedAttrValue(attrs []xml.Attr, name string) string {
|
||||
for _, attr := range attrs {
|
||||
if attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFloatAttr(attrs []xml.Attr, name string) float64 {
|
||||
return parseFloatLoose(renderedAttrValue(attrs, name))
|
||||
}
|
||||
|
||||
func parseFloatLoose(value string) float64 {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, "px"))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseStyleNumber(style, name string) float64 {
|
||||
for _, match := range renderedStyleNumberPattern.FindAllStringSubmatch(style, -1) {
|
||||
if len(match) == 3 && strings.EqualFold(match[1], name) {
|
||||
return parseFloatLoose(match[2])
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseStyleValue(style, name string) string {
|
||||
for _, match := range renderedStyleValuePattern.FindAllStringSubmatch(style, -1) {
|
||||
if len(match) == 3 && strings.EqualFold(match[1], name) {
|
||||
return strings.TrimSpace(match[2])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFontWeightStyle(style string) float64 {
|
||||
match := renderedFontWeightNumberPattern.FindStringSubmatch(style)
|
||||
if len(match) != 2 {
|
||||
return 0
|
||||
}
|
||||
return parseFontWeightValue(match[1])
|
||||
}
|
||||
|
||||
func parseFontWeightValue(value string) float64 {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
switch value {
|
||||
case "bold", "bolder":
|
||||
return 700
|
||||
default:
|
||||
return parseFloatLoose(value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRenderedLineHeight(value, fontSize float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value < 4 && fontSize > 0 {
|
||||
return value * fontSize
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseTranslate(transform string) (float64, float64) {
|
||||
match := renderedTransformTranslatePattern.FindStringSubmatch(transform)
|
||||
if len(match) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
x := parseFloatLoose(match[1])
|
||||
y := 0.0
|
||||
if len(match) > 2 {
|
||||
y = parseFloatLoose(match[2])
|
||||
}
|
||||
return x, y
|
||||
}
|
||||
|
||||
func firstRenderedNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPositive(values ...float64) float64 {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeRenderedText(text string) string {
|
||||
text = renderedWhitespacePattern.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func roundRenderedNumber(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
168
internal/svglide/rendered_visual_test.go
Normal file
168
internal/svglide/rendered_visual_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderedVisualGateDetectsAppleSubtitleOverflow(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="92" y="318" font-size="25">Revenue declined 4.3% year over year, but gross margin reached 46.6% and diluted EPS set a March-quarter record.</text></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_overflow") {
|
||||
t.Fatalf("report = %+v, want text_overflow", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsForeignObjectClip(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><foreignObject x="370" y="58" width="820" height="58" style="font-size:48px;line-height:1.16"><h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0">这支美国队的优势在结构,不只在星味</h2></foreignObject></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/02.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_box_overflow") {
|
||||
t.Fatalf("report = %+v, want text_box_overflow", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsTimelineCollision(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><g font-size="46"><text x="984" y="338">2022</text><text x="1160" y="338" text-anchor="end">2024</text></g></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/03.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_collision") {
|
||||
t.Fatalf("report = %+v, want text_collision", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsMetricCardInternalOverflow(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<rect x="930" y="134" width="190" height="118" rx="10" fill="#202420" stroke="#3d443f"/>
|
||||
<foreignObject x="950" y="239" width="150" height="129" style="font-size:16px">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">current assets / liabilities</div>
|
||||
</foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/05-ratios-peers.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.container_text_overflow") {
|
||||
t.Fatalf("report = %+v, want container_text_overflow", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsPathPanelInternalOverflow(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<path id="panel" d="M 930 134 H 1120 V 252 H 930 Z" fill="#202420" stroke="#3d443f"/>
|
||||
<foreignObject x="950" y="239" width="150" height="129" style="font-size:16px">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">current assets / liabilities</div>
|
||||
</foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/05-ratios-peers.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.container_text_overflow") {
|
||||
t.Fatalf("report = %+v, want container_text_overflow for path panel", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsForeignObjectWrapperPaddingRisk(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<foreignObject x="100" y="100" width="260" height="120" style="background-color:#202420;font-size:15px">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Text starts at the visual wrapper edge instead of respecting inner padding.</div>
|
||||
</foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/06.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.container_padding_risk") {
|
||||
t.Fatalf("report = %+v, want container_padding_risk for foreignObject wrapper", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateAllowsTextInsideCardPadding(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<rect x="100" y="100" width="280" height="150" rx="10" fill="#202420" stroke="#3d443f"/>
|
||||
<foreignObject x="124" y="126" width="220" height="84" style="font-size:18px">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">A fitted note with room.</div>
|
||||
</foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDoesNotTreatChartBarsAsTextContainers(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<rect x="120" y="160" width="280" height="320" class="mark-bar" fill="#76b900"/>
|
||||
<text x="132" y="188" font-size="22">$22.1B</text>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/chart.svg", []byte(svg))
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed for chart mark label", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsForeignObjectOverlap(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<foreignObject x="120" y="120" width="260" height="80" style="font-size:22px"><p xmlns="http://www.w3.org/1999/xhtml">First label</p></foreignObject>
|
||||
<foreignObject x="180" y="150" width="260" height="80" style="font-size:22px"><p xmlns="http://www.w3.org/1999/xhtml">Second label</p></foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/02.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.foreign_object_collision") {
|
||||
t.Fatalf("report = %+v, want foreign_object_collision", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsTightLineHeight(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<foreignObject x="120" y="120" width="420" height="90" style="font-size:20px;line-height:20px"><p xmlns="http://www.w3.org/1999/xhtml">Dense label copy with enough words to wrap into two lines.</p></foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/03.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.tight_line_height") {
|
||||
t.Fatalf("report = %+v, want tight_line_height", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsBoldOveruse(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<text x="100" y="120" font-size="34" font-weight="800">Revenue acceleration is the story</text>
|
||||
<text x="100" y="180" font-size="28" font-weight="800">Margins expand while supply stays tight</text>
|
||||
<text x="100" y="236" font-size="24" font-weight="800">Every sentence should not be bold</text>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/04.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.bold_overuse") {
|
||||
t.Fatalf("report = %+v, want bold_overuse", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateAllowsFittedText(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="92" y="120" font-size="24">Short title</text><foreignObject x="92" y="180" width="520" height="90" style="font-size:24px"><p xmlns="http://www.w3.org/1999/xhtml">One fitted sentence.</p></foreignObject></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateRegressionFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
code string
|
||||
}{
|
||||
{"apple subtitle", "apple_subtitle_overflow.svg", "svglide.rendered_visual.text_overflow"},
|
||||
{"apple cashflow", "apple_cashflow_callout_overflow.svg", "svglide.rendered_visual.text_overflow"},
|
||||
{"leica timeline", "leica_timeline_collision.svg", "svglide.rendered_visual.text_collision"},
|
||||
{"sports title", "sports_foreign_object_clip.svg", "svglide.rendered_visual.text_box_overflow"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "svglide", "rendered_visual", tt.file))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
report := evaluateRenderedVisualSVG(tt.file, raw)
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, tt.code) {
|
||||
t.Fatalf("report = %+v, want %s", report, tt.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func renderedVisualHasCode(report RenderedVisualReport, code string) bool {
|
||||
for _, issue := range report.Issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
1103
internal/svglide/repair.go
Normal file
1103
internal/svglide/repair.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user