mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
Compare commits
43 Commits
docs-skill
...
feat-svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
3
.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/
|
||||
|
||||
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,44 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -1371,8 +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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
243
internal/svglide/agent_runtime_e2e_test.go
Normal file
243
internal/svglide/agent_runtime_e2e_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
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)
|
||||
}
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("complete %s: %v", StageValidatePreviewRepair, err)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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 requiredCallsFromContract(toolContract) {
|
||||
id, _ := call["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
writeToolCallReceiptFromContractForE2E(t, next, call)
|
||||
}
|
||||
}
|
||||
|
||||
func requiredCallsFromContract(contract map[string]any) []map[string]any {
|
||||
values, _ := contract["required_calls"].([]any)
|
||||
out := make([]map[string]any, 0, len(values))
|
||||
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":""}`)
|
||||
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":"电影的核心吸引力","source_refs":["user1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a cinematic hero image"}]}]}`)
|
||||
case 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":"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":true,"recommended_use":"cover image","avoid_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/movie-hero.png", "png")
|
||||
case StageSVGAuthor:
|
||||
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 960 540">`+fontTokenStyleForTest()+`<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"/><text x="48" y="88">电影介绍</text><text x="48" y="150">电影的核心吸引力</text></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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"image-led cinematic introduction","data_visual_rationale":"","source_evidence":["user1 supports the topic"],"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
162
internal/svglide/assets.go
Normal file
162
internal/svglide/assets.go
Normal file
@@ -0,0 +1,162 @@
|
||||
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 {
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
638
internal/svglide/author.go
Normal file
638
internal/svglide/author.go
Normal file
@@ -0,0 +1,638 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlideWidth = 960
|
||||
defaultSlideHeight = 540
|
||||
defaultAuthorBgColor = "#FFFFFF"
|
||||
defaultAuthorInkColor = "#111827"
|
||||
defaultAuthorMuteColor = "#6B7280"
|
||||
defaultAuthorAccent = "#2563EB"
|
||||
svgAuthorReceipt = "receipts/svg_author.json"
|
||||
)
|
||||
|
||||
type AuthorReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []string `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []authorDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
VisualRole string `json:"visual_role"`
|
||||
VisualIntent string `json:"visual_intent"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
LayoutFamily string `json:"layout_family"`
|
||||
LayoutArchetype string `json:"layout_archetype"`
|
||||
LayoutSignature string `json:"layout_signature"`
|
||||
StoryFunction string `json:"story_function"`
|
||||
PrimaryAssetRole string `json:"primary_asset_role"`
|
||||
FusionCandidate bool `json:"fusion_candidate"`
|
||||
}
|
||||
|
||||
type authorSlideContentFile struct {
|
||||
Slides []authorSlideContent `json:"slides"`
|
||||
}
|
||||
|
||||
type authorSlideContent struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Notes string `json:"notes"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []authorSlideVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type authorSlideVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type authorAsset = deckAsset
|
||||
|
||||
type authorVisualSystem struct {
|
||||
ColorSystem struct {
|
||||
Background string `json:"background"`
|
||||
Ink string `json:"ink"`
|
||||
Muted string `json:"muted"`
|
||||
Accent string `json:"accent"`
|
||||
} `json:"color_system"`
|
||||
Typography struct {
|
||||
Title int `json:"title"`
|
||||
Body int `json:"body"`
|
||||
} `json:"typography"`
|
||||
LayoutLanguage string `json:"layout_language"`
|
||||
}
|
||||
|
||||
type authorTheme struct {
|
||||
Background string
|
||||
Ink string
|
||||
Muted string
|
||||
Accent string
|
||||
TitleSize int
|
||||
BodySize int
|
||||
}
|
||||
|
||||
type authorSlideTarget struct {
|
||||
Slide authorDeckSlide
|
||||
Content authorSlideContent
|
||||
Assets []authorAsset
|
||||
Path string
|
||||
Target string
|
||||
Page int
|
||||
}
|
||||
|
||||
func AuthorSlides(root string) (AuthorReport, error) {
|
||||
return authorSlides(root, nil)
|
||||
}
|
||||
|
||||
func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, strings.TrimSpace(run.Artifacts.Deck))
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
contentByID, err := readAuthorContent(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
theme, err := readAuthorTheme(safeRoot, "brief/visual_system.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
assetsBySlideID, err := readAuthorAssets(safeRoot, assetsManifestPath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := validateAuthorDeckContent(deck, contentByID); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
targets := make([]authorSlideTarget, 0, len(deck.Slides))
|
||||
report := AuthorReport{
|
||||
Status: StatusDone,
|
||||
Slides: make([]string, 0, len(deck.Slides)),
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if selectedPaths != nil && !selectedPaths[slidePath] {
|
||||
continue
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
targets = append(targets, authorSlideTarget{
|
||||
Slide: slide,
|
||||
Content: contentByID[strings.TrimSpace(slide.ID)],
|
||||
Assets: selectAuthorRenderableImageAssets(safeRoot, contentByID[strings.TrimSpace(slide.ID)], assetsBySlideID[strings.TrimSpace(slide.ID)]),
|
||||
Path: slidePath,
|
||||
Target: target,
|
||||
Page: i + 1,
|
||||
})
|
||||
report.Slides = append(report.Slides, slidePath)
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, svgAuthorReceipt)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, target.Assets, theme, target.Page, len(deck.Slides))
|
||||
if err := writeText(target.Target, svg); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
}
|
||||
if err := writeAuthorVisualReceipts(safeRoot, deck, contentByID, assetsBySlideID, selectedPaths); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := writeJSON(receiptTarget, StageReceipt{
|
||||
Stage: StageSVGAuthor,
|
||||
Status: StatusDone,
|
||||
Artifacts: report.Slides,
|
||||
}); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeAuthorVisualReceipts(safeRoot string, deck authorDeck, contentByID map[string]authorSlideContent, assetsBySlideID map[string][]authorAsset, selectedPaths map[string]bool) error {
|
||||
existingByID := map[string]visualReceipt{}
|
||||
if existing, err := readVisualReceipts(safeRoot); err == nil {
|
||||
for _, receipt := range existing.Slides {
|
||||
id := strings.TrimSpace(receipt.SlideID)
|
||||
if id != "" {
|
||||
existingByID[id] = receipt
|
||||
}
|
||||
}
|
||||
}
|
||||
out := visualReceiptsFile{Slides: make([]visualReceipt, 0, len(deck.Slides))}
|
||||
for i, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
receipt, ok := existingByID[id]
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
shouldRefresh := !ok || selectedPaths == nil || (pathErr == nil && selectedPaths[slidePath])
|
||||
if shouldRefresh {
|
||||
receipt = authorVisualReceiptForSlide(slide, contentByID[id], assetsBySlideID[id], i)
|
||||
}
|
||||
out.Slides = append(out.Slides, receipt)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, visualReceiptsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, out)
|
||||
}
|
||||
|
||||
func authorVisualReceiptForSlide(slide authorDeckSlide, content authorSlideContent, assets []authorAsset, index int) visualReceipt {
|
||||
layoutFamily := firstNonEmpty(slide.LayoutFamily, inferAuthorLayoutFamily(slide, content, assets))
|
||||
layoutSignature := firstNonEmpty(slide.LayoutSignature, inferAuthorLayoutSignature(layoutFamily, assets, index))
|
||||
layoutArchetype := firstNonEmpty(slide.LayoutArchetype, inferAuthorLayoutArchetype(layoutFamily, layoutSignature))
|
||||
primaryAsset, assetRole := authorPrimaryAssetEvidence(slide, assets)
|
||||
pageDifference := "opening page"
|
||||
if index > 0 {
|
||||
pageDifference = "different content block and slide order from previous page"
|
||||
}
|
||||
dataRationale := ""
|
||||
for _, visual := range content.Visuals {
|
||||
if strings.TrimSpace(visual.Type) == "chart" {
|
||||
dataRationale = firstNonEmpty(visual.Instruction, "chart visual requested by slide content")
|
||||
break
|
||||
}
|
||||
}
|
||||
return visualReceipt{
|
||||
SlideID: strings.TrimSpace(slide.ID),
|
||||
StoryJob: firstNonEmpty(slide.StoryFunction, slide.Role, "proof"),
|
||||
LayoutFamily: layoutFamily,
|
||||
LayoutArchetype: layoutArchetype,
|
||||
LayoutSignature: layoutSignature,
|
||||
ThumbnailJob: firstNonEmpty(slide.Title, slide.KeyMessage),
|
||||
VisualCenter: firstNonEmpty(primaryAsset, slide.Title, slide.KeyMessage),
|
||||
TopicFitClaim: firstNonEmpty(slide.KeyMessage, slide.Summary, content.Content),
|
||||
InformationDensityPlan: "one clear claim with supporting visual or concise text",
|
||||
PageDifferenceFromPrevious: pageDifference,
|
||||
PrimaryAsset: primaryAsset,
|
||||
AssetRole: assetRole,
|
||||
FontRoleUsage: map[string]string{
|
||||
"display": "Noto Serif CJK SC",
|
||||
"body": "Noto Sans CJK SC",
|
||||
"number": "Roboto Mono",
|
||||
"label": "PingFang SC",
|
||||
},
|
||||
CompositionIntent: "local author fallback layout with topic-specific text and available assets",
|
||||
DataVisualRationale: dataRationale,
|
||||
SourceEvidence: append([]string{}, content.SourceRefs...),
|
||||
FusionSpec: visualFusionReceipt{Enabled: false},
|
||||
QAExpectations: []string{"no process text", "font roles present", "layout is readable"},
|
||||
}
|
||||
}
|
||||
|
||||
func inferAuthorLayoutFamily(slide authorDeckSlide, content authorSlideContent, assets []authorAsset) string {
|
||||
if len(assets) > 0 && isCoverSlide(slide) {
|
||||
return "character_product_focus"
|
||||
}
|
||||
for _, visual := range content.Visuals {
|
||||
switch strings.TrimSpace(visual.Type) {
|
||||
case "chart", "table":
|
||||
return "data_scoreboard"
|
||||
}
|
||||
}
|
||||
if len(assets) > 1 {
|
||||
return "evidence_board"
|
||||
}
|
||||
return "quiet_synthesis"
|
||||
}
|
||||
|
||||
func inferAuthorLayoutSignature(layoutFamily string, assets []authorAsset, index int) string {
|
||||
switch layoutFamily {
|
||||
case "character_product_focus":
|
||||
return "image_claim"
|
||||
case "data_scoreboard":
|
||||
return "data_panel"
|
||||
case "evidence_board":
|
||||
return "evidence_collage"
|
||||
default:
|
||||
if index%2 == 1 {
|
||||
return "text_evidence_panel"
|
||||
}
|
||||
return "single_claim_poster"
|
||||
}
|
||||
}
|
||||
|
||||
func inferAuthorLayoutArchetype(layoutFamily string, layoutSignature string) string {
|
||||
signature := strings.ToLower(strings.TrimSpace(layoutSignature))
|
||||
switch {
|
||||
case strings.Contains(signature, "waterfall") || strings.Contains(signature, "bridge"):
|
||||
return "waterfall_bridge"
|
||||
case strings.Contains(signature, "bubble") || strings.Contains(signature, "peer"):
|
||||
return "peer_bubble_field"
|
||||
case strings.Contains(signature, "ledger") || strings.Contains(signature, "statement"):
|
||||
return "statement_ledger"
|
||||
case strings.Contains(signature, "timeline") || strings.Contains(signature, "route"):
|
||||
return "timeline_path"
|
||||
case strings.Contains(signature, "risk") || strings.Contains(signature, "radar"):
|
||||
return "risk_radar"
|
||||
case strings.Contains(signature, "split") || strings.Contains(signature, "left_text_right_chart"):
|
||||
return "image_argument_split"
|
||||
case strings.Contains(signature, "evidence") || strings.Contains(signature, "collage"):
|
||||
return "evidence_collage"
|
||||
case strings.Contains(signature, "poster"):
|
||||
return "poster_stat_lockup"
|
||||
}
|
||||
switch strings.TrimSpace(layoutFamily) {
|
||||
case "full_bleed_hero":
|
||||
return "full_bleed_photo_title"
|
||||
case "data_scoreboard":
|
||||
return "data_scoreboard"
|
||||
case "evidence_board":
|
||||
return "evidence_collage"
|
||||
case "timeline_route":
|
||||
return "timeline_path"
|
||||
case "character_product_focus":
|
||||
return "annotated_image"
|
||||
case "image_text_fusion_split":
|
||||
return "image_argument_split"
|
||||
default:
|
||||
return "poster_stat_lockup"
|
||||
}
|
||||
}
|
||||
|
||||
func authorPrimaryAssetEvidence(slide authorDeckSlide, assets []authorAsset) (string, string) {
|
||||
for _, asset := range assets {
|
||||
if path := assetPath(asset); path != "" {
|
||||
return path, firstNonEmpty(slide.PrimaryAssetRole, asset.Usage, "topic anchor")
|
||||
}
|
||||
}
|
||||
return "", firstNonEmpty(slide.PrimaryAssetRole, "none")
|
||||
}
|
||||
|
||||
func readAuthorDeck(safeRoot string, deckPath string) (authorDeck, error) {
|
||||
if deckPath == "" {
|
||||
return authorDeck{}, fmt.Errorf("deck artifact path is empty")
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
var deck authorDeck
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return authorDeck{}, fmt.Errorf("deck %q contains no slides", deckPath)
|
||||
}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
func readAuthorContent(safeRoot string, path string) (map[string]authorSlideContent, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
var file authorSlideContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
byID := make(map[string]authorSlideContent, len(file.Slides))
|
||||
for _, slide := range file.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("slide content id must not be empty")
|
||||
}
|
||||
if _, exists := byID[id]; exists {
|
||||
return nil, fmt.Errorf("slide content id %q is duplicated", id)
|
||||
}
|
||||
byID[id] = slide
|
||||
}
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
func readAuthorAssets(safeRoot string, path string) (map[string][]authorAsset, error) {
|
||||
file, err := readDeckAssetsArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read assets manifest %q: %w", path, err)
|
||||
}
|
||||
bySlideID := make(map[string][]authorAsset, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
slideID := assetSlideID(asset)
|
||||
bySlideID[slideID] = append(bySlideID[slideID], asset)
|
||||
}
|
||||
return bySlideID, nil
|
||||
}
|
||||
|
||||
func selectAuthorRenderableImageAssets(safeRoot string, content authorSlideContent, assets []authorAsset) []authorAsset {
|
||||
if len(content.Visuals) == 0 || len(assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
assetByID := make(map[string]authorAsset, len(assets))
|
||||
for _, asset := range assets {
|
||||
if assetType(asset) != "image" {
|
||||
continue
|
||||
}
|
||||
id := assetID(asset)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
assetByID[id] = asset
|
||||
}
|
||||
for _, visual := range content.Visuals {
|
||||
if strings.TrimSpace(visual.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(visual.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
asset, ok := assetByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !authorImageAssetUsable(safeRoot, asset) {
|
||||
continue
|
||||
}
|
||||
return []authorAsset{asset}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorImageAssetUsable(_ string, asset authorAsset) bool {
|
||||
if assetType(asset) != "image" {
|
||||
return false
|
||||
}
|
||||
path := assetPath(asset)
|
||||
return path != ""
|
||||
}
|
||||
|
||||
func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error {
|
||||
deckIDs := make(map[string]bool, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("deck slide id must not be empty")
|
||||
}
|
||||
if deckIDs[id] {
|
||||
return fmt.Errorf("deck slide id %q is duplicated", id)
|
||||
}
|
||||
deckIDs[id] = true
|
||||
if _, ok := contentByID[id]; !ok {
|
||||
return fmt.Errorf("deck slide id %q is missing from slide content", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAuthorTheme(safeRoot string, path string) (authorTheme, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
var visual authorVisualSystem
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
theme := authorTheme{
|
||||
Background: normalizeAuthorColor(visual.ColorSystem.Background, defaultAuthorBgColor),
|
||||
Ink: normalizeAuthorColor(visual.ColorSystem.Ink, defaultAuthorInkColor),
|
||||
Muted: normalizeAuthorColor(visual.ColorSystem.Muted, defaultAuthorMuteColor),
|
||||
Accent: normalizeAuthorColor(visual.ColorSystem.Accent, defaultAuthorAccent),
|
||||
TitleSize: visual.Typography.Title,
|
||||
BodySize: visual.Typography.Body,
|
||||
}
|
||||
if theme.TitleSize <= 0 {
|
||||
theme.TitleSize = 32
|
||||
}
|
||||
if theme.BodySize <= 0 {
|
||||
theme.BodySize = 16
|
||||
}
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
func normalizeAuthorColor(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if isAllowedAuthorHexColor(value) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isAllowedAuthorHexColor(value string) bool {
|
||||
if len(value) != 4 && len(value) != 7 && len(value) != 9 {
|
||||
return false
|
||||
}
|
||||
if value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, assets []authorAsset, theme authorTheme, page int, total int) string {
|
||||
title := firstNonEmpty(slide.Title, "Untitled slide")
|
||||
keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary)
|
||||
bodyLines := authorBodyLines(content.Content)
|
||||
footer := strings.TrimSpace(deckTitle)
|
||||
if footer == "" {
|
||||
footer = "SVGlide"
|
||||
}
|
||||
footnote := authorSourceFootnote(content.SourceRefs)
|
||||
heroAsset := firstReadyAuthorImageAsset(assets)
|
||||
contentWidth := 848
|
||||
contentHeight := 404
|
||||
if heroAsset != nil {
|
||||
contentWidth = 500
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="%s" xmlns:slide="%s" width="%d" height="%d" viewBox="0 0 960 540" slide:role="slide">`+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight)
|
||||
fmt.Fprintf(&b, " <style>:root{--font-display:\"Noto Serif CJK SC\",\"Songti SC\",serif;--font-body:\"Noto Sans CJK SC\",\"PingFang SC\",sans-serif;--font-number:\"Roboto Mono\",\"SFMono-Regular\",monospace;--font-label:\"PingFang SC\",\"Noto Sans CJK SC\",sans-serif;}</style>\n")
|
||||
if notes := strings.TrimSpace(content.Notes); notes != "" {
|
||||
fmt.Fprintf(&b, " <slide:note>%s</slide:note>\n", escapeText(notes))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="540" fill="%s" data-role="background"/>`+"\n", escapeAttr(theme.Background))
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="8" fill="%s"/>`+"\n", escapeAttr(theme.Accent))
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="48" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", contentWidth, contentHeight)
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-body);color:%s;">`+"\n", escapeAttr(theme.Ink))
|
||||
fmt.Fprintf(&b, ` <div style="font-family:var(--font-display);font-size:%dpx;font-weight:700;line-height:1.16;margin-bottom:16px;">%s</div>`+"\n", theme.TitleSize, escapeText(title))
|
||||
if keyMessage != "" {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.35;color:%s;margin-bottom:22px;">%s</div>`+"\n", maxInt(theme.BodySize+4, 18), escapeAttr(theme.Accent), escapeText(keyMessage))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <div style="border:1px solid #E5E7EB;border-radius:6px;padding:20px 24px;min-height:190px;background:#F9FAFB;">`+"\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.55;margin-bottom:8px;">- %s</div>`+"\n", theme.BodySize, escapeText(line))
|
||||
}
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
if footnote != "" {
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="456" width="520" height="18" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-label);color:%s;font-size:12px;line-height:1.2;">%s</div>`+"\n", escapeAttr(theme.Muted), escapeText(footnote))
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
}
|
||||
if heroAsset != nil {
|
||||
fmt.Fprintf(&b, ` <image slide:role="image" slide:shape-type="image" href="%s" x="600" y="160" width="304" height="190"/>`+"\n", escapeAttr(svgHrefForRunAsset(slide.Path, assetPath(*heroAsset))))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="482" width="848" height="32" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-label);color:%s;font-size:12px;display:flex;justify-content:space-between;">`+"\n", escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(&b, ` <span>%s</span><span style="font-family:var(--font-number)">%d / %d</span>`+"\n", escapeText(footer), page, total)
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
fmt.Fprintf(&b, "</svg>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func svgHrefForRunAsset(slidePath string, runAssetPath string) string {
|
||||
runAssetPath = strings.TrimSpace(runAssetPath)
|
||||
if runAssetPath == "" ||
|
||||
strings.HasPrefix(runAssetPath, "data:") ||
|
||||
strings.HasPrefix(runAssetPath, "http://") ||
|
||||
strings.HasPrefix(runAssetPath, "https://") {
|
||||
return runAssetPath
|
||||
}
|
||||
cleanSlidePath, err := previewSlideObjectPath(slidePath)
|
||||
if err != nil {
|
||||
return runAssetPath
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.ToSlash(filepath.Dir(cleanSlidePath)), filepath.ToSlash(runAssetPath))
|
||||
if err != nil {
|
||||
return runAssetPath
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func authorBodyLines(content string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"No content provided."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func authorSourceFootnote(sourceRefs []string) string {
|
||||
if len(sourceRefs) == 0 {
|
||||
return ""
|
||||
}
|
||||
refs := make([]string, 0, len(sourceRefs))
|
||||
for _, ref := range sourceRefs {
|
||||
if trimmed := strings.TrimSpace(ref); trimmed != "" {
|
||||
refs = append(refs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "来源:" + strings.Join(refs, " / ")
|
||||
}
|
||||
|
||||
func firstReadyAuthorImageAsset(assets []authorAsset) *authorAsset {
|
||||
for i := range assets {
|
||||
asset := &assets[i]
|
||||
if assetType(*asset) != "image" {
|
||||
continue
|
||||
}
|
||||
if assetPath(*asset) == "" {
|
||||
continue
|
||||
}
|
||||
return asset
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeText(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func escapeAttr(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
552
internal/svglide/author_test.go
Normal file
552
internal/svglide/author_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
report, err := AuthorSlides("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != StatusDone {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, StatusDone)
|
||||
}
|
||||
if len(report.Slides) != 2 {
|
||||
t.Fatalf("Slides len = %d, want 2: %+v", len(report.Slides), report.Slides)
|
||||
}
|
||||
receipt := readAuthorReceiptForTest(t)
|
||||
if receipt["stage"] != StageSVGAuthor {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageSVGAuthor)
|
||||
}
|
||||
if receipt["status"] != StatusDone {
|
||||
t.Fatalf("receipt status = %v, want %q", receipt["status"], StatusDone)
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
|
||||
for _, rel := range []string{"slides/01.svg", "slides/02.svg"} {
|
||||
raw, err := os.ReadFile(filepath.Join("demo", rel))
|
||||
if err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`slide:role="slide"`,
|
||||
`viewBox="0 0 960 540"`,
|
||||
`foreignObject`,
|
||||
`slide:role="shape"`,
|
||||
`slide:shape-type="text"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("%s missing %q:\n%s", rel, want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesFallsBackForUnsafeColorTokens(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"url(https://example.com/bg.svg)","ink":"red;background:url(https://example.com/x)","muted":"not-a-color","accent":"#abc"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, banned := range []string{"url(", "https://example.com", "red;background", "not-a-color"} {
|
||||
if strings.Contains(svg, banned) {
|
||||
t.Fatalf("SVG contains unsafe color token %q:\n%s", banned, svg)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`fill="#FFFFFF"`, `color:#111827`, `color:#6B7280`, `fill="#abc"`, `color:#abc`} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing normalized/default color %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesPreflightsSlidePathsBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/../02.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected invalid second slide path to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "01.svg")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("first slide output exists after preflight failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsMissingContentBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected missing slide content to fail")
|
||||
}
|
||||
for _, rel := range []string{"slides/01.svg", "receipts/svg_author.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s exists after content preflight failure, stat err = %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s1","content":"Duplicate body line","source_refs":[],"visuals":[{"id":"none-s1b","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected duplicate slide content id to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("svg_author receipt exists after duplicate content id failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForNoneVisualDespiteReadyAsset(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("visual type none should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForMismatchedVisualID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"other","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("mismatched visual id should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExperimentRemoteImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the remote hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`href="https://example.com/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("experiment remote image missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesSkipsUnsupportedReadyImageAssets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
asset string
|
||||
}{
|
||||
{
|
||||
name: "diagram",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero diagram","status":"ready"}]}`,
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"missing"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", tt.asset)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("unsupported asset should not render image:\n%s", string(raw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExistingAbsoluteImageAssetInExperiment(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), outside) || !strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("absolute asset should render image in experiment mode:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initAuthorDemoRun(t *testing.T, visualSystem string, deck string) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", visualSystem)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", deck)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersSourceFootnotes(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`来源`,
|
||||
`web1`,
|
||||
`slide:role="shape"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("source footnote missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersPreparedImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`slide:shape-type="image"`,
|
||||
`href="../assets/images/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("prepared image asset missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesReadsAssetsManifestAndSerializesNotes(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the manifest hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<slide:note>Speaker note</slide:note>`,
|
||||
`<image slide:role="image"`,
|
||||
`href="../assets/images/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersImageFootnoteAndMultilineBodyWithValidation(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthorInputsWithAnyGenContracts(t *testing.T, assets string) {
|
||||
t.Helper()
|
||||
if strings.Contains(assets, `"assets":[]`) && !strings.Contains(assets, `"no_image_reason"`) {
|
||||
assets = strings.TrimSuffix(strings.TrimSpace(assets), "}") + `,"no_image_reason":"Text-only deck; no image assets required"}`
|
||||
}
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"First claim","body":"First body line\nSecond body line","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}},{"id":"s2","audience_copy":{"title":"Second claim","body":"Point A\nPoint B\nPoint C","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s2","content":"Point A\nPoint B\nPoint C","source_refs":["web1"],"visuals":[{"id":"none-s2","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", assets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", assets)
|
||||
}
|
||||
|
||||
func readAuthorReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "svg_author.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func mustWriteTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasSuffix(filepath.ToSlash(path), assetsPlanPath) {
|
||||
manifestPath := filepath.Join(filepath.Dir(path), filepath.Base(assetsManifestPath))
|
||||
if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventoryPath := filepath.Join(filepath.Dir(path), filepath.Base(assetInventoryPath))
|
||||
if err := os.WriteFile(inventoryPath, []byte(testAssetInventoryJSON(content)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chartManifestPath := filepath.Join(filepath.Dir(path), "charts", "chart_manifest.json")
|
||||
if err := os.MkdirAll(filepath.Dir(chartManifestPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(chartManifestPath, []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAssetInventoryJSON(assets string) string {
|
||||
var file deckAssetsFile
|
||||
if err := json.Unmarshal([]byte(assets), &file); err != nil {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"items":[]}`
|
||||
}
|
||||
items := make([]map[string]any, 0, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": assetID(asset),
|
||||
"path": assetPath(asset),
|
||||
"source_url": strings.TrimSpace(asset.SourceURL),
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"semantic_type": assetType(asset),
|
||||
"large_ok": true,
|
||||
"full_bleed_ok": true,
|
||||
"recommended_use": strings.TrimSpace(asset.Usage),
|
||||
"avoid_reason": "",
|
||||
})
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"prompt_contract": json.RawMessage(promptContractJSON(StageAssets)),
|
||||
"items": items,
|
||||
})
|
||||
if err != nil {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"items":[]}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
63
internal/svglide/chart_manifest.go
Normal file
63
internal/svglide/chart_manifest.go
Normal file
@@ -0,0 +1,63 @@
|
||||
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"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
|
||||
func readChartManifest(safeRoot string) (chartManifestFile, bool, error) {
|
||||
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
|
||||
}
|
||||
512
internal/svglide/creative_quality.go
Normal file
512
internal/svglide/creative_quality.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
visualReceiptsPath = "visual_receipts.json"
|
||||
creativeQualityReportPath = "creative_quality_report.json"
|
||||
)
|
||||
|
||||
type CreativeQualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Issues []CreativeQualityIssue `json:"issues"`
|
||||
Metrics CreativeQualityMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
type CreativeQualityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type CreativeQualityMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
VisualReceipts int `json:"visual_receipts"`
|
||||
MissingVisualReceipts int `json:"missing_visual_receipts"`
|
||||
ProcessLeakCount int `json:"process_leak_count"`
|
||||
GenericFontSlideCount int `json:"generic_font_slide_count"`
|
||||
DistinctLayoutFamilyCount int `json:"distinct_layout_family_count"`
|
||||
DistinctLayoutArchetypeCount int `json:"distinct_layout_archetype_count"`
|
||||
LayoutArchetypeMaxRatioBP int `json:"layout_archetype_max_ratio_bp"`
|
||||
AdjacentLayoutArchetypeCount int `json:"adjacent_layout_archetype_count"`
|
||||
LeftRightChartArchetypeCount int `json:"left_right_chart_archetype_count"`
|
||||
LayoutSignatureMaxRatioBP int `json:"layout_signature_max_ratio_bp"`
|
||||
AdjacentLayoutRepetitionCount int `json:"adjacent_layout_repetition_count"`
|
||||
FusionSlideCount int `json:"fusion_slide_count"`
|
||||
FusionAdjacentCount int `json:"fusion_adjacent_count"`
|
||||
WeakSlideCount int `json:"weak_slide_count"`
|
||||
ChartWithoutEvidenceCount int `json:"chart_without_evidence_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
}
|
||||
|
||||
type visualReceiptsFile struct {
|
||||
Slides []visualReceipt `json:"slides"`
|
||||
}
|
||||
|
||||
type visualReceipt struct {
|
||||
SlideID string `json:"slide_id"`
|
||||
StoryJob string `json:"story_job"`
|
||||
LayoutFamily string `json:"layout_family"`
|
||||
LayoutArchetype string `json:"layout_archetype"`
|
||||
LayoutSignature string `json:"layout_signature"`
|
||||
ThumbnailJob string `json:"thumbnail_job"`
|
||||
VisualCenter string `json:"visual_center"`
|
||||
TopicFitClaim string `json:"topic_fit_claim"`
|
||||
InformationDensityPlan string `json:"information_density_plan"`
|
||||
PageDifferenceFromPrevious string `json:"page_difference_from_previous"`
|
||||
PrimaryAsset string `json:"primary_asset"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FontRoleUsage map[string]string `json:"font_role_usage"`
|
||||
CompositionIntent string `json:"composition_intent"`
|
||||
DataVisualRationale string `json:"data_visual_rationale"`
|
||||
SourceEvidence []string `json:"source_evidence"`
|
||||
FusionSpec visualFusionReceipt `json:"fusion_spec"`
|
||||
QAExpectations []string `json:"qa_expectations"`
|
||||
}
|
||||
|
||||
type visualFusionReceipt struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SeamSide string `json:"seam_side"`
|
||||
SampledColor string `json:"sampled_color"`
|
||||
PanelColor string `json:"panel_color"`
|
||||
FadeWidth int `json:"fade_width"`
|
||||
SubjectSafety string `json:"subject_safety"`
|
||||
}
|
||||
|
||||
func CheckCreativeQuality(root string) (CreativeQualityReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return CreativeQualityReport{}, err
|
||||
}
|
||||
deckPath := "outline/deck.json"
|
||||
deck, err := readAuthorDeck(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return CreativeQualityReport{}, err
|
||||
}
|
||||
mode := normalizedVisualQualityMode(run.VisualQualityMode)
|
||||
report := CreativeQualityReport{
|
||||
Status: "passed",
|
||||
Issues: []CreativeQualityIssue{},
|
||||
Metrics: CreativeQualityMetrics{Slides: len(deck.Slides)},
|
||||
}
|
||||
|
||||
receipts, receiptErr := readVisualReceipts(safeRoot)
|
||||
if receiptErr != nil {
|
||||
addCreativeIssue(&report, mode, visualReceiptsPath, "svglide.creative.missing_visual_receipts", receiptErr.Error(), "error")
|
||||
}
|
||||
report.Metrics.VisualReceipts = len(receipts.Slides)
|
||||
receiptBySlide := make(map[string]visualReceipt, len(receipts.Slides))
|
||||
for _, receipt := range receipts.Slides {
|
||||
id := strings.TrimSpace(receipt.SlideID)
|
||||
if id != "" {
|
||||
receiptBySlide[id] = receipt
|
||||
}
|
||||
}
|
||||
|
||||
familyCounts := make(map[string]int)
|
||||
archetypeCounts := make(map[string]int)
|
||||
layoutCounts := make(map[string]int)
|
||||
var previousLayout string
|
||||
var previousArchetype string
|
||||
var previousFamily string
|
||||
for i, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
receipt, hasReceipt := receiptBySlide[id]
|
||||
if !hasReceipt {
|
||||
report.Metrics.MissingVisualReceipts++
|
||||
addCreativeIssue(&report, mode, visualReceiptsPath, "svglide.creative.missing_visual_receipt", fmt.Sprintf("slide %q has no visual receipt", id), "error")
|
||||
}
|
||||
layoutFamily := firstNonEmpty(slide.LayoutFamily, receipt.LayoutFamily)
|
||||
layoutArchetype := firstNonEmpty(slide.LayoutArchetype, receipt.LayoutArchetype, inferAuthorLayoutArchetype(layoutFamily, firstNonEmpty(slide.LayoutSignature, receipt.LayoutSignature)))
|
||||
layoutSignature := firstNonEmpty(slide.LayoutSignature, receipt.LayoutSignature)
|
||||
if strings.TrimSpace(layoutFamily) == "" || strings.TrimSpace(layoutSignature) == "" || strings.TrimSpace(layoutArchetype) == "" {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.missing_layout_fields", fmt.Sprintf("slide %q must declare layout_family, layout_archetype, and layout_signature", id), "error")
|
||||
}
|
||||
if layoutFamily != "" {
|
||||
familyCounts[layoutFamily]++
|
||||
}
|
||||
if layoutArchetype != "" {
|
||||
archetypeCounts[layoutArchetype]++
|
||||
if i > 0 && layoutArchetype == previousArchetype {
|
||||
report.Metrics.AdjacentLayoutArchetypeCount++
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_layout_archetype", fmt.Sprintf("slide %q repeats adjacent layout_archetype %q", id, layoutArchetype), "error")
|
||||
}
|
||||
if isLeftRightChartArchetype(layoutArchetype, layoutSignature, receipt) {
|
||||
report.Metrics.LeftRightChartArchetypeCount++
|
||||
}
|
||||
previousArchetype = layoutArchetype
|
||||
}
|
||||
if layoutSignature != "" {
|
||||
layoutCounts[layoutSignature]++
|
||||
if i > 0 && layoutSignature == previousLayout {
|
||||
report.Metrics.AdjacentLayoutRepetitionCount++
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_layout_repetition", fmt.Sprintf("slide %q repeats adjacent layout_signature %q", id, layoutSignature), "error")
|
||||
}
|
||||
previousLayout = layoutSignature
|
||||
}
|
||||
if layoutFamily == "image_text_fusion_split" {
|
||||
report.Metrics.FusionSlideCount++
|
||||
if i > 0 && previousFamily == "image_text_fusion_split" {
|
||||
report.Metrics.FusionAdjacentCount++
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_fusion", fmt.Sprintf("slide %q repeats image_text_fusion_split after another fusion slide", id), "error")
|
||||
}
|
||||
if hasReceipt {
|
||||
checkFusionReceipt(&report, mode, id, receipt)
|
||||
}
|
||||
}
|
||||
previousFamily = layoutFamily
|
||||
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.invalid_slide_path", err.Error(), "error")
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.missing_slide_svg", err.Error(), "error")
|
||||
continue
|
||||
}
|
||||
svg := string(raw)
|
||||
leaks := countCreativeProcessLeaks(svg)
|
||||
if leaks > 0 {
|
||||
report.Metrics.ProcessLeakCount += leaks
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.process_leak", fmt.Sprintf("slide %q exposes process/source/prompt language in visible text", id), "error")
|
||||
}
|
||||
if svgHasGenericFontProblem(svg) {
|
||||
report.Metrics.GenericFontSlideCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.generic_fonts", fmt.Sprintf("slide %q uses generic/browser font roles instead of concrete deck typography", id), "error")
|
||||
}
|
||||
if hasReceipt && isWeakCreativeSlide(svg, receipt, layoutFamily) {
|
||||
report.Metrics.WeakSlideCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.weak_slide", fmt.Sprintf("slide %q lacks enough visual center, topic fit, differentiation, or information density", id), "error")
|
||||
}
|
||||
if hasDataVisualIntent(svg, layoutFamily, receipt) && !hasNumericSourceEvidence(receipt) {
|
||||
report.Metrics.ChartWithoutEvidenceCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.chart_without_evidence", fmt.Sprintf("slide %q uses data/chart visual language without numeric source_evidence", id), "error")
|
||||
}
|
||||
}
|
||||
|
||||
report.Metrics.DistinctLayoutFamilyCount = len(familyCounts)
|
||||
report.Metrics.DistinctLayoutArchetypeCount = len(archetypeCounts)
|
||||
report.Metrics.LayoutArchetypeMaxRatioBP = maxLayoutSignatureRatioBP(archetypeCounts, len(deck.Slides))
|
||||
report.Metrics.LayoutSignatureMaxRatioBP = maxLayoutSignatureRatioBP(layoutCounts, len(deck.Slides))
|
||||
if len(deck.Slides) >= 8 && report.Metrics.DistinctLayoutArchetypeCount < minDistinctLayoutArchetypes(len(deck.Slides)) {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_archetype_diversity", fmt.Sprintf("deck has %d distinct layout_archetype values, want >= %d", report.Metrics.DistinctLayoutArchetypeCount, minDistinctLayoutArchetypes(len(deck.Slides))), "error")
|
||||
}
|
||||
if len(deck.Slides) >= 5 && report.Metrics.LayoutArchetypeMaxRatioBP > maxArchetypeRatioBP(len(deck.Slides)) {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_archetype_overuse", fmt.Sprintf("most common layout_archetype ratio is %d bp, want <= %d bp", report.Metrics.LayoutArchetypeMaxRatioBP, maxArchetypeRatioBP(len(deck.Slides))), "error")
|
||||
}
|
||||
if report.Metrics.LeftRightChartArchetypeCount > maxLeftRightChartArchetypes(len(deck.Slides)) {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.left_right_chart_overuse", fmt.Sprintf("left/right chart archetype count is %d, want <= %d", report.Metrics.LeftRightChartArchetypeCount, maxLeftRightChartArchetypes(len(deck.Slides))), "error")
|
||||
}
|
||||
if len(deck.Slides) >= 8 && report.Metrics.LayoutSignatureMaxRatioBP > 3000 {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_overuse", fmt.Sprintf("most common layout_signature ratio is %d bp, want <= 3000 bp", report.Metrics.LayoutSignatureMaxRatioBP), "error")
|
||||
}
|
||||
if limit := maxFusionSlides(len(deck.Slides)); report.Metrics.FusionSlideCount > limit {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.fusion_overuse", fmt.Sprintf("image_text_fusion_split count is %d, want <= %d", report.Metrics.FusionSlideCount, limit), "error")
|
||||
}
|
||||
|
||||
for _, issue := range report.Issues {
|
||||
if issue.Severity == "warning" {
|
||||
report.Metrics.WarningCount++
|
||||
}
|
||||
if issue.Severity == "error" {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readVisualReceipts(safeRoot string) (visualReceiptsFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, visualReceiptsPath)
|
||||
if err != nil {
|
||||
return visualReceiptsFile{}, fmt.Errorf("%s: read artifact: %w", visualReceiptsPath, err)
|
||||
}
|
||||
var receipts visualReceiptsFile
|
||||
if err := json.Unmarshal(raw, &receipts); err != nil {
|
||||
return visualReceiptsFile{}, fmt.Errorf("%s: invalid JSON: %w", visualReceiptsPath, err)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
func addCreativeIssue(report *CreativeQualityReport, mode, path, code, message, severity string) {
|
||||
severity = strings.TrimSpace(severity)
|
||||
if severity == "" {
|
||||
severity = "error"
|
||||
}
|
||||
if mode == VisualQualityModeWarn && severity == "error" {
|
||||
severity = "warning"
|
||||
}
|
||||
report.Issues = append(report.Issues, CreativeQualityIssue{
|
||||
Path: path,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: severity,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizedVisualQualityMode(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case VisualQualityModeWarn:
|
||||
return VisualQualityModeWarn
|
||||
default:
|
||||
return VisualQualityModeStrict
|
||||
}
|
||||
}
|
||||
|
||||
func checkFusionReceipt(report *CreativeQualityReport, mode, slideID string, receipt visualReceipt) {
|
||||
if !receipt.FusionSpec.Enabled {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_missing_spec", fmt.Sprintf("slide %q uses image_text_fusion_split but fusion_spec.enabled is false", slideID), "error")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(receipt.FusionSpec.SeamSide) == "" || strings.TrimSpace(receipt.FusionSpec.SampledColor) == "" || strings.TrimSpace(receipt.FusionSpec.PanelColor) == "" {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_missing_spec", fmt.Sprintf("slide %q fusion_spec must include seam_side, sampled_color, and panel_color", slideID), "error")
|
||||
}
|
||||
if receipt.FusionSpec.FadeWidth < 80 || receipt.FusionSpec.FadeWidth > 180 {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_bad_fade", fmt.Sprintf("slide %q fusion fade_width must be 80-180 px, got %d", slideID, receipt.FusionSpec.FadeWidth), "error")
|
||||
}
|
||||
if strings.TrimSpace(receipt.FusionSpec.SubjectSafety) == "" {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.subject_safety_warning", fmt.Sprintf("slide %q fusion_spec lacks subject safety judgment", slideID), "warning")
|
||||
}
|
||||
if ok, delta := CheckSeamDelta(receipt.FusionSpec.SampledColor, receipt.FusionSpec.PanelColor); !ok {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_seam_delta", fmt.Sprintf("slide %q seam color delta is %d, want <= 45", slideID, delta), "warning")
|
||||
}
|
||||
}
|
||||
|
||||
func maxFusionSlides(slides int) int {
|
||||
if slides <= 0 {
|
||||
return 0
|
||||
}
|
||||
limit := slides * 30 / 100
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func isLeftRightChartArchetype(archetype string, signature string, receipt visualReceipt) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(archetype + " " + signature + " " + receipt.CompositionIntent))
|
||||
if strings.Contains(value, "left_text_right_chart") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(value, "left text right chart") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "split") && strings.Contains(value, "chart")
|
||||
}
|
||||
|
||||
func minDistinctLayoutArchetypes(slides int) int {
|
||||
switch {
|
||||
case slides >= 8:
|
||||
return 5
|
||||
case slides >= 5:
|
||||
return 3
|
||||
case slides >= 3:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func maxArchetypeRatioBP(slides int) int {
|
||||
if slides >= 8 {
|
||||
return 2500
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
func maxLeftRightChartArchetypes(slides int) int {
|
||||
if slides >= 8 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func countCreativeProcessLeaks(svg string) int {
|
||||
visible := strings.ToLower(visibleSemanticText(svg))
|
||||
count := 0
|
||||
for _, marker := range []string{
|
||||
"sources:",
|
||||
"source note",
|
||||
"prompt",
|
||||
"slide:note",
|
||||
"production_instruction",
|
||||
"素材说明",
|
||||
"制作说明",
|
||||
"接缝",
|
||||
"取色",
|
||||
"渐变遮罩",
|
||||
"source ref",
|
||||
} {
|
||||
count += strings.Count(visible, strings.ToLower(marker))
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
var fontRolePattern = regexp.MustCompile(`--font-(display|body|number|label)\s*:\s*([^;"}]+)`)
|
||||
|
||||
func svgHasGenericFontProblem(svg string) bool {
|
||||
matches := fontRolePattern.FindAllStringSubmatch(svg, -1)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
roles := make(map[string]bool)
|
||||
genericRoles := 0
|
||||
for _, match := range matches {
|
||||
if len(match) != 3 {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(match[1])
|
||||
if roles[role] {
|
||||
continue
|
||||
}
|
||||
roles[role] = true
|
||||
if isGenericOrBrowserFontStack(match[2]) {
|
||||
genericRoles++
|
||||
}
|
||||
}
|
||||
if len(roles) >= 4 && genericRoles == len(roles) {
|
||||
return true
|
||||
}
|
||||
visible := visibleSemanticText(svg)
|
||||
if containsCJK(visible) && !hasConcreteCJKFont(svg) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGenericOrBrowserFontStack(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.Trim(value, `"'`)
|
||||
for _, token := range []string{",", " "} {
|
||||
value = strings.ReplaceAll(value, token, "")
|
||||
}
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, concrete := range []string{"inter", "roboto", "georgia", "avenir", "sfpro", "noto", "sourcehan", "pingfang", "hiragino", "microsoftyahei", "songtisc"} {
|
||||
if strings.Contains(value, concrete) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, generic := range []string{"arial", "helvetica", "sans-serif", "serif", "system-ui", "ui-sans-serif"} {
|
||||
value = strings.ReplaceAll(value, generic, "")
|
||||
}
|
||||
return value == ""
|
||||
}
|
||||
|
||||
func containsCJK(text string) bool {
|
||||
for _, r := range text {
|
||||
if (r >= 0x4e00 && r <= 0x9fff) || (r >= 0x3400 && r <= 0x4dbf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasConcreteCJKFont(svg string) bool {
|
||||
lower := strings.ToLower(svg)
|
||||
for _, token := range []string{
|
||||
"noto sans cjk", "noto serif cjk", "source han sans", "source han serif",
|
||||
"pingfang", "hiragino sans gb", "microsoft yahei", "songti sc",
|
||||
"思源黑体", "思源宋体", "微软雅黑", "黑体", "宋体",
|
||||
} {
|
||||
if strings.Contains(lower, strings.ToLower(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWeakCreativeSlide(svg string, receipt visualReceipt, layoutFamily string) bool {
|
||||
score := 0
|
||||
if strings.TrimSpace(receipt.VisualCenter) == "" {
|
||||
score++
|
||||
}
|
||||
if strings.TrimSpace(receipt.TopicFitClaim) == "" {
|
||||
score++
|
||||
}
|
||||
if weakReceiptText(receipt.InformationDensityPlan) {
|
||||
score++
|
||||
}
|
||||
if weakReceiptText(receipt.PageDifferenceFromPrevious) {
|
||||
score++
|
||||
}
|
||||
if countSVGImageElements(svg) == 0 && !hasDataVisualIntent(svg, layoutFamily, receipt) {
|
||||
score++
|
||||
}
|
||||
if countRoundedTextPanels(svg) >= 3 && countSVGImageElements(svg) == 0 {
|
||||
score++
|
||||
}
|
||||
return score >= 3
|
||||
}
|
||||
|
||||
func weakReceiptText(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, marker := range []string{"same", "similar", "tbd", "none", "无", "同上", "相同"} {
|
||||
if value == marker || strings.Contains(value, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var roundedRectPattern = regexp.MustCompile(`(?is)<rect\b[^>]*(\brx\s*=|\bry\s*=)`)
|
||||
|
||||
func countRoundedTextPanels(svg string) int {
|
||||
return len(roundedRectPattern.FindAllStringIndex(svg, -1))
|
||||
}
|
||||
|
||||
func hasDataVisualIntent(svg string, layoutFamily string, receipt visualReceipt) bool {
|
||||
if strings.TrimSpace(layoutFamily) == "data_scoreboard" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(receipt.DataVisualRationale) != "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(svg)
|
||||
for _, marker := range []string{"chart", "axis", "bar", "line-chart", "vega", "data-score", "scoreboard"} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNumericSourceEvidence(receipt visualReceipt) bool {
|
||||
if containsDigit(receipt.DataVisualRationale) {
|
||||
return true
|
||||
}
|
||||
for _, value := range receipt.SourceEvidence {
|
||||
if containsDigit(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsDigit(value string) bool {
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeCreativeQualityReport(safeRoot string, report CreativeQualityReport) error {
|
||||
return writeJSON(filepath.Join(safeRoot, creativeQualityReportPath), report)
|
||||
}
|
||||
239
internal/svglide/creative_quality_test.go
Normal file
239
internal/svglide/creative_quality_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 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 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 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
|
||||
}
|
||||
226
internal/svglide/init.go
Normal file
226
internal/svglide/init.go
Normal file
@@ -0,0 +1,226 @@
|
||||
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
|
||||
Pages int
|
||||
Now time.Time
|
||||
Overwrite bool
|
||||
AgentRuntime string
|
||||
AgentID string
|
||||
RouteProfile 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.AgentRuntime = strings.TrimSpace(opts.AgentRuntime)
|
||||
opts.AgentID = strings.TrimSpace(opts.AgentID)
|
||||
opts.RouteProfile = strings.TrimSpace(opts.RouteProfile)
|
||||
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",
|
||||
"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,
|
||||
Pages: opts.Pages,
|
||||
Out: runRoot,
|
||||
Now: opts.Now,
|
||||
AgentRuntime: opts.AgentRuntime,
|
||||
AgentID: opts.AgentID,
|
||||
RouteProfile: opts.RouteProfile,
|
||||
})
|
||||
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,
|
||||
"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
|
||||
}
|
||||
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()
|
||||
}
|
||||
527
internal/svglide/init_test.go
Normal file
527
internal/svglide/init_test.go
Normal file
@@ -0,0 +1,527 @@
|
||||
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",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"schemas/request.schema.json",
|
||||
"schemas/deck.schema.json",
|
||||
"receipts",
|
||||
"slides",
|
||||
"assets/images",
|
||||
} {
|
||||
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["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/generate_svg_chart.md", "tools/generate_vega_lite_chart.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",
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"slide_copy_plan.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"assets_manifest.schema.json",
|
||||
"asset_inventory.schema.json",
|
||||
"chart_manifest.schema.json",
|
||||
"typography_contract.schema.json",
|
||||
"quality.schema.json",
|
||||
"receipt.schema.json",
|
||||
"lint.schema.json",
|
||||
"preview.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: "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"`, `"minItems"`, `"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: "asset_inventory.schema.json", want: []string{`"large_ok"`, `"full_bleed_ok"`, `"recommended_use"`}},
|
||||
{name: "chart_manifest.schema.json", want: []string{`"vega-lite"`, `"spec_path"`, `"svg_path"`}},
|
||||
{name: "typography_contract.schema.json", want: []string{`"display"`, `"number"`, `"label"`}},
|
||||
{name: "quality.schema.json", want: []string{`"metrics"`, `"real_image_assets"`, `"vega_lite_spec_assets"`}},
|
||||
} {
|
||||
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{"asset_inventory", "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", "resolve_design_brief", "slide_outline", "activate_slides_edit", "slides_edit", "finish_slides_edit", "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["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)
|
||||
}
|
||||
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", "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)
|
||||
}
|
||||
203
internal/svglide/metrics.go
Normal file
203
internal/svglide/metrics.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
if fontTokens < 4 {
|
||||
metrics.MissingFontTokenCount += 4 - fontTokens
|
||||
}
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
907
internal/svglide/prompt.go
Normal file
907
internal/svglide/prompt.go
Normal file
@@ -0,0 +1,907 @@
|
||||
package svglide
|
||||
|
||||
func DefaultSchemas() map[string]string {
|
||||
return map[string]string{
|
||||
"request.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"input": {"type": "string"},
|
||||
"topic": {"type": "string"},
|
||||
"purpose": {"type": "string"},
|
||||
"audience": {"type": "string"},
|
||||
"delivery_mode": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"template": {"type": "boolean"},
|
||||
"template_requested": {"type": "boolean"},
|
||||
"intent": {"type": "object"},
|
||||
"agent": {"type": "object"},
|
||||
"pages": {"type": "integer"},
|
||||
"visual_style_query": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"source_manifest.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"topic": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["local", "topic"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"entity_resolution.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["prompt_contract", "input_text", "resolved_entity", "ambiguity", "research_required", "clarification_question"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"input_text": {"type": "string"},
|
||||
"resolved_entity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "type", "confidence_bp", "confidence_band", "reason"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"confidence_bp": {"type": "integer"},
|
||||
"confidence_band": {"type": "string", "enum": ["high", "medium", "low"]},
|
||||
"reason": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"ambiguity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "candidates"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["resolved", "needs_clarification", "unknown"]},
|
||||
"candidates": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"research_required": {"type": "boolean"},
|
||||
"visual_quality_contract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profile": {"type": "string", "enum": ["brand_official_site", "product_official_site", "place_official_site", "film_or_media_entity", "data_report", "text_only"]},
|
||||
"requires_real_images": {"type": "boolean"},
|
||||
"min_image_coverage_bp": {"type": "integer"},
|
||||
"min_unique_images": {"type": "integer"},
|
||||
"min_official_images": {"type": "integer"},
|
||||
"allow_repeated_hero_only": {"type": "boolean"},
|
||||
"cover_requires_real_hero_image": {"type": "boolean"},
|
||||
"required_chart_renderer": {"type": "string", "enum": ["none", "svg", "vega-lite"]},
|
||||
"min_chart_svg_assets": {"type": "integer"},
|
||||
"min_vega_lite_specs": {"type": "integer"},
|
||||
"typography_contract_required": {"type": "boolean"},
|
||||
"forbid_preview_wrapper_images_as_real_images": {"type": "boolean"},
|
||||
"reason": {"type": "string"},
|
||||
"mode": {"type": "string", "enum": ["benchmark", "default_floor"]},
|
||||
"benchmark_available": {"type": "boolean"},
|
||||
"benchmark_usage": {"type": "string", "enum": ["quality_floor_only"]},
|
||||
"deck_type": {"type": "string"},
|
||||
"must_have": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"strong_cover": {"type": "boolean"},
|
||||
"semantic_image_coverage_min_bp": {"type": "integer"},
|
||||
"evidence_page_min_visuals": {"type": "integer"},
|
||||
"max_repeated_layout_ratio_bp": {"type": "integer"},
|
||||
"visual_roles_required": {"type": "array", "items": {"type": "string"}},
|
||||
"total_image_refs_min": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"clarification_question": {"type": "string"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"sources.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "path", "title", "excerpt", "usage", "retrieval"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"excerpt": {"type": "string"},
|
||||
"usage": {"type": "string"},
|
||||
"retrieval": {"type": "string", "enum": ["full_page", "local_file", "user_provided"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"research_coverage.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["prompt_contract", "entity", "queries", "sources", "coverage"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"entity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "type"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"type": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["query", "purpose"],
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"purpose": {"type": "string", "enum": ["entity_disambiguation", "facts", "visuals", "context"]}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "url", "retrieved_at", "usage", "status"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"url": {"type": "string"},
|
||||
"retrieved_at": {"type": "string"},
|
||||
"usage": {"type": "string", "enum": ["identity", "plot", "context", "visual_reference", "data"]},
|
||||
"status": {"type": "string", "enum": ["retrieved", "failed", "unavailable"]}
|
||||
}
|
||||
}
|
||||
},
|
||||
"coverage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["identity_confirmed", "has_reliable_source", "minimum_source_count_met", "source_count", "topic_only_rationale"],
|
||||
"properties": {
|
||||
"identity_confirmed": {"type": "boolean"},
|
||||
"has_reliable_source": {"type": "boolean"},
|
||||
"minimum_source_count_met": {"type": "boolean"},
|
||||
"source_count": {"type": "integer"},
|
||||
"topic_only_rationale": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"design_brief.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["narrative_spine", "depth", "tone", "visual_system", "deck_visual_system", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"design_rationale": {"type": "string"},
|
||||
"narrative_spine": {"type": "object"},
|
||||
"depth": {"type": "object"},
|
||||
"tone": {"type": "string"},
|
||||
"visual_system": {
|
||||
"type": "object",
|
||||
"required": ["color_system", "typography", "layout_language"],
|
||||
"properties": {
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "object"},
|
||||
"imagery_treatment": {"type": "object"},
|
||||
"material_texture": {"type": "object"},
|
||||
"decoration_language": {"type": "object"},
|
||||
"mood_coordinates": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"deck_visual_system": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["visual_keywords", "palette", "fonts", "page_family_budget", "asset_strategy"],
|
||||
"properties": {
|
||||
"visual_keywords": {"type": "array", "minItems": 1, "items": {"type": "string"}},
|
||||
"palette": {"type": "object"},
|
||||
"fonts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["font_display", "font_body", "font_number", "font_label"],
|
||||
"properties": {
|
||||
"font_display": {"type": "string"},
|
||||
"font_body": {"type": "string"},
|
||||
"font_number": {"type": "string"},
|
||||
"font_label": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"page_family_budget": {"type": "object"},
|
||||
"asset_strategy": {"type": "object"},
|
||||
"recurring_hero_rationale": {"type": "string"},
|
||||
"single_source_rationale": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"visual_system.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["color_system", "typography", "layout_language", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "object"},
|
||||
"imagery_treatment": {"type": "object"},
|
||||
"material_texture": {"type": "object"},
|
||||
"decoration_language": {"type": "object"},
|
||||
"mood_coordinates": {"type": "object"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"deck.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["main_title", "style_instruction", "slides", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"main_title": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"style_instruction": {
|
||||
"type": "object",
|
||||
"required": ["aesthetic_direction", "color_palette", "typography"],
|
||||
"properties": {
|
||||
"aesthetic_direction": {"type": "string"},
|
||||
"color_palette": {"type": "object"},
|
||||
"typography": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "summary", "role", "key_message", "layout_family", "layout_archetype", "layout_signature", "story_function", "primary_asset_role", "fusion_candidate", "path"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"page_title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"visual_role": {"type": "string"},
|
||||
"visual_intent": {"type": "string"},
|
||||
"key_message": {"type": "string"},
|
||||
"layout_family": {"type": "string", "enum": ["full_bleed_hero", "image_text_fusion_split", "evidence_board", "timeline_route", "data_scoreboard", "character_product_focus", "quiet_synthesis"]},
|
||||
"layout_archetype": {"type": "string", "enum": ["full_bleed_photo_title", "poster_stat_lockup", "image_argument_split", "annotated_image", "evidence_collage", "timeline_path", "data_scoreboard", "statement_ledger", "waterfall_bridge", "peer_bubble_field", "risk_radar", "closing_poster"]},
|
||||
"layout_signature": {"type": "string"},
|
||||
"story_function": {"type": "string"},
|
||||
"primary_asset_role": {"type": "string"},
|
||||
"fusion_candidate": {"type": "boolean"},
|
||||
"path": {"type": "string", "pattern": "^slides/[^/]+\\.svg$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"typography_contract.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["prompt_contract", "profile", "roles", "rules"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"profile": {"type": "string"},
|
||||
"rules": {"type": "array", "items": {"type": "string"}},
|
||||
"roles": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["display", "body", "number", "label"],
|
||||
"properties": {
|
||||
"display": {"type": "object"},
|
||||
"body": {"type": "object"},
|
||||
"number": {"type": "object"},
|
||||
"label": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"slide_content.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "content", "source_refs", "visuals"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
"source_refs": {"type": "array", "minItems": 1, "items": {"type": "string"}},
|
||||
"visuals": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "instruction"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["image", "diagram", "icon", "chart", "table", "crop", "none"]},
|
||||
"instruction": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"slide_copy_plan.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["prompt_contract", "slides"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "audience_copy", "production_instruction"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"audience_copy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "body", "labels"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
"labels": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"production_instruction": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["layout", "asset_ids"],
|
||||
"properties": {
|
||||
"layout": {"type": "string"},
|
||||
"asset_ids": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"assets_plan.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode", "assets", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"no_image_reason": {"type": "string"},
|
||||
"mode": {"type": "string", "enum": ["experiment_unrestricted_assets"]},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "slide_id", "type", "path", "usage", "status"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"slide_id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["image", "diagram", "icon", "chart", "table", "crop"]},
|
||||
"path": {"type": "string"},
|
||||
"usage": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["ready", "missing", "deferred"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"assets_manifest.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["assets", "prompt_contract"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"no_image_reason": {"type": "string"},
|
||||
"mode": {"type": "string"},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "slide_id", "kind", "status", "usage"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"slide_id": {"type": "string"},
|
||||
"visual_id": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["image", "chart", "diagram", "icon", "generated_svg", "table", "crop"]},
|
||||
"local_path": {"type": "string"},
|
||||
"source_url": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["ready", "unavailable", "needs_generation", "deferred", "missing"]},
|
||||
"usage": {"type": "string"},
|
||||
"missing_reason": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"asset_inventory.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["prompt_contract", "items"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "path", "source_url", "width", "height", "semantic_type", "large_ok", "full_bleed_ok", "recommended_use", "avoid_reason"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"source_url": {"type": "string"},
|
||||
"width": {"type": "integer"},
|
||||
"height": {"type": "integer"},
|
||||
"semantic_type": {"type": "string"},
|
||||
"large_ok": {"type": "boolean"},
|
||||
"full_bleed_ok": {"type": "boolean"},
|
||||
"recommended_use": {"type": "string"},
|
||||
"avoid_reason": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"chart_manifest.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["prompt_contract", "renderer", "charts"],
|
||||
"properties": {
|
||||
"prompt_contract": {"type": "object"},
|
||||
"renderer": {"type": "string", "enum": ["none", "svg", "vega-lite"]},
|
||||
"charts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "slide_id", "renderer", "svg_path"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"slide_id": {"type": "string"},
|
||||
"renderer": {"type": "string", "enum": ["svg", "vega-lite"]},
|
||||
"spec_path": {"type": "string"},
|
||||
"svg_path": {"type": "string"},
|
||||
"source_id": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"quality.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues", "metrics"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message", "severity"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides", "sources", "web_sources", "assets", "slides_with_source_refs", "slides_with_visuals", "slides_with_image_assets", "image_coverage_bp", "unique_image_assets", "official_image_assets"],
|
||||
"properties": {
|
||||
"slides": {"type": "integer"},
|
||||
"sources": {"type": "integer"},
|
||||
"web_sources": {"type": "integer"},
|
||||
"assets": {"type": "integer"},
|
||||
"slides_with_source_refs": {"type": "integer"},
|
||||
"slides_with_visuals": {"type": "integer"},
|
||||
"slides_with_image_assets": {"type": "integer"},
|
||||
"image_coverage_bp": {"type": "integer"},
|
||||
"unique_image_assets": {"type": "integer"},
|
||||
"official_image_assets": {"type": "integer"},
|
||||
"real_image_assets": {"type": "integer"},
|
||||
"slides_with_real_image_assets": {"type": "integer"},
|
||||
"generated_svg_assets": {"type": "integer"},
|
||||
"chart_svg_assets": {"type": "integer"},
|
||||
"vega_lite_spec_assets": {"type": "integer"},
|
||||
"preview_wrapper_image_count": {"type": "integer"},
|
||||
"cover_real_hero_image": {"type": "boolean"},
|
||||
"typography_contract_present": {"type": "boolean"},
|
||||
"total_image_refs": {"type": "integer"},
|
||||
"strong_cover": {"type": "boolean"},
|
||||
"evidence_page_max_visuals": {"type": "integer"},
|
||||
"repeated_layout_ratio_bp": {"type": "integer"},
|
||||
"visual_role_coverage_bp": {"type": "integer"},
|
||||
"rendered_visual_issue_count": {"type": "integer"},
|
||||
"rendered_visual_text_overflow_count": {"type": "integer"},
|
||||
"rendered_visual_text_collision_count": {"type": "integer"},
|
||||
"rendered_visual_out_of_canvas_count": {"type": "integer"},
|
||||
"visual_asset_required": {"type": "boolean"},
|
||||
"visual_asset_issue_count": {"type": "integer"},
|
||||
"cover_real_hero_required": {"type": "boolean"},
|
||||
"cover_real_hero_present": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"receipt.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["stage", "status"],
|
||||
"properties": {
|
||||
"stage": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"artifacts": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"lint.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"preview.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "missing_asset_count", "slides"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"missing_asset_count": {"type": "integer"},
|
||||
"browser_missing_asset_count": {"type": "integer"},
|
||||
"rendered_visual": {"type": "string"},
|
||||
"rendered_visual_issue_count": {"type": "integer"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "rendered"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"rendered": {"type": "boolean"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"rendered_visual.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "metrics", "issues", "slides"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides", "issue_count", "out_of_canvas_count", "text_overflow_count", "text_collision_count", "unsafe_edge_count"],
|
||||
"properties": {
|
||||
"slides": {"type": "integer"},
|
||||
"issue_count": {"type": "integer"},
|
||||
"out_of_canvas_count": {"type": "integer"},
|
||||
"text_overflow_count": {"type": "integer"},
|
||||
"text_collision_count": {"type": "integer"},
|
||||
"unsafe_edge_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message", "severity"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"slide_id": {"type": "string"},
|
||||
"element_id": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"x": {"type": "number"},
|
||||
"y": {"type": "number"},
|
||||
"width": {"type": "number"},
|
||||
"height": {"type": "number"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "status", "issue_count"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"issue_count": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"anygen_semantic_report.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "contract", "metrics", "findings"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"contract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "role", "path", "sha256", "rules"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"sha256": {"type": "string"},
|
||||
"rules": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slide_count", "slides_with_slide_role", "image_count", "text_count", "note_count", "source_ref_count", "missing_asset_count", "slides_without_source_refs", "visible_leak_count", "font_token_count", "missing_font_token_count"],
|
||||
"properties": {
|
||||
"slide_count": {"type": "integer"},
|
||||
"slides_with_slide_role": {"type": "integer"},
|
||||
"image_count": {"type": "integer"},
|
||||
"text_count": {"type": "integer"},
|
||||
"note_count": {"type": "integer"},
|
||||
"source_ref_count": {"type": "integer"},
|
||||
"missing_asset_count": {"type": "integer"},
|
||||
"slides_without_source_refs": {"type": "integer"},
|
||||
"visible_leak_count": {"type": "integer"},
|
||||
"font_token_count": {"type": "integer"},
|
||||
"missing_font_token_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rule_id", "kind", "severity", "code", "message"],
|
||||
"properties": {
|
||||
"rule_id": {"type": "string"},
|
||||
"kind": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"artifact": {"type": "string"},
|
||||
"field": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"value": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"visual_receipts.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides"],
|
||||
"properties": {
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slide_id", "story_job", "layout_family", "layout_archetype", "layout_signature", "thumbnail_job", "visual_center", "topic_fit_claim", "information_density_plan", "page_difference_from_previous", "primary_asset", "asset_role", "font_role_usage", "composition_intent", "data_visual_rationale", "source_evidence", "fusion_spec", "qa_expectations"],
|
||||
"properties": {
|
||||
"slide_id": {"type": "string"},
|
||||
"story_job": {"type": "string"},
|
||||
"layout_family": {"type": "string"},
|
||||
"layout_archetype": {"type": "string"},
|
||||
"layout_signature": {"type": "string"},
|
||||
"thumbnail_job": {"type": "string"},
|
||||
"visual_center": {"type": "string"},
|
||||
"topic_fit_claim": {"type": "string"},
|
||||
"information_density_plan": {"type": "string"},
|
||||
"page_difference_from_previous": {"type": "string"},
|
||||
"primary_asset": {"type": "string"},
|
||||
"asset_role": {"type": "string"},
|
||||
"font_role_usage": {"type": "object"},
|
||||
"composition_intent": {"type": "string"},
|
||||
"data_visual_rationale": {"type": "string"},
|
||||
"source_evidence": {"type": "array", "items": {"type": "string"}},
|
||||
"fusion_spec": {"type": "object"},
|
||||
"qa_expectations": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"creative_quality.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues", "metrics"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message", "severity"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides", "visual_receipts", "missing_visual_receipts", "process_leak_count", "generic_font_slide_count", "distinct_layout_family_count", "distinct_layout_archetype_count", "layout_archetype_max_ratio_bp", "adjacent_layout_archetype_count", "left_right_chart_archetype_count", "layout_signature_max_ratio_bp", "adjacent_layout_repetition_count", "fusion_slide_count", "fusion_adjacent_count", "weak_slide_count", "chart_without_evidence_count", "warning_count"],
|
||||
"properties": {
|
||||
"slides": {"type": "integer"},
|
||||
"visual_receipts": {"type": "integer"},
|
||||
"missing_visual_receipts": {"type": "integer"},
|
||||
"process_leak_count": {"type": "integer"},
|
||||
"generic_font_slide_count": {"type": "integer"},
|
||||
"distinct_layout_family_count": {"type": "integer"},
|
||||
"distinct_layout_archetype_count": {"type": "integer"},
|
||||
"layout_archetype_max_ratio_bp": {"type": "integer"},
|
||||
"adjacent_layout_archetype_count": {"type": "integer"},
|
||||
"left_right_chart_archetype_count": {"type": "integer"},
|
||||
"layout_signature_max_ratio_bp": {"type": "integer"},
|
||||
"adjacent_layout_repetition_count": {"type": "integer"},
|
||||
"fusion_slide_count": {"type": "integer"},
|
||||
"fusion_adjacent_count": {"type": "integer"},
|
||||
"weak_slide_count": {"type": "integer"},
|
||||
"chart_without_evidence_count": {"type": "integer"},
|
||||
"warning_count": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"delivery.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "route_profile", "orchestrator", "runtime_binding", "deck", "slides_dir", "slides", "preview", "quality_report", "anygen_semantic_report", "visual_receipts", "creative_quality_report", "semantic_metrics", "stage_status", "legacy_runtime_executed", "legacy_tool_ids", "legacy_artifact_matches", "core_prompt_ids", "observed_prompt_ids", "blocked_prompt_ids"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["ready", "needs_repair"]},
|
||||
"route_profile": {"type": "string"},
|
||||
"orchestrator": {"type": "string"},
|
||||
"runtime_binding": {"type": "string"},
|
||||
"deck": {"type": "string"},
|
||||
"slides_dir": {"type": "string"},
|
||||
"slides": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
"preview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "status", "missing_asset_count"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"missing_asset_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"quality_report": {"type": "string"},
|
||||
"anygen_semantic_report": {"type": "string"},
|
||||
"visual_receipts": {"type": "string"},
|
||||
"creative_quality_report": {"type": "string"},
|
||||
"semantic_metrics": {"type": "object"},
|
||||
"stage_status": {"type": "object"},
|
||||
"legacy_runtime_executed": {"type": "boolean"},
|
||||
"legacy_tool_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"legacy_artifact_matches": {"type": "array", "items": {"type": "string"}},
|
||||
"core_prompt_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"observed_prompt_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"blocked_prompt_ids": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"prompt_context.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["stage", "protocol", "agent_task", "prompt_contract", "tool_invocation_contract", "asset_hashes"],
|
||||
"properties": {
|
||||
"stage": {"type": "string"},
|
||||
"protocol": {"type": "string"},
|
||||
"agent_task": {"type": "object"},
|
||||
"prompt_contract": {"type": "object"},
|
||||
"tool_invocation_contract": {"type": "object"},
|
||||
"asset_hashes": {"type": "object"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"tool_call_receipt.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["stage", "prompt_id", "status"],
|
||||
"properties": {
|
||||
"stage": {"type": "string"},
|
||||
"prompt_id": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"consumed": {"type": "array", "items": {"type": "string"}},
|
||||
"produced": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
876
internal/svglide/prompt_contract.go
Normal file
876
internal/svglide/prompt_contract.go
Normal file
@@ -0,0 +1,876 @@
|
||||
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 {
|
||||
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。"
|
||||
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 "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
|
||||
}
|
||||
}
|
||||
193
internal/svglide/prompt_manifest.go
Normal file
193
internal/svglide/prompt_manifest.go
Normal file
@@ -0,0 +1,193 @@
|
||||
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"),
|
||||
toolEntry("resolve_design_brief", "resolve_design_brief", StageDesignBrief, 1, "once", "always", []string{"request/request.json", "research/research_notes.md"}, []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, []string{"design_brief_resolved", "typography_contract_resolved"}),
|
||||
toolEntry("slide_outline", "slide_outline", StageOutline, 2, "once", "always", []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_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{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "brief/typography_contract.json", "assets/assets_manifest.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{"slides/*.svg"}, []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json"}, []string{"validate_preview_rendered_visual_quality_semantic_creative_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"}),
|
||||
conditionalToolEntry("generate_vega_lite_chart", "generate_vega_lite_chart", StageAssets, 8, "zero_or_more", "required_chart_renderer_vega_lite", []string{"content/slide_content.json", "research/sources.json", "assets/assets_manifest.json"}, []string{"assets/charts/chart_manifest.json", "assets/charts/*.svg", "assets/charts/specs/*.vl.json"}, []string{"vega_lite_chart_assets_planned"}),
|
||||
conditionalToolEntry("generate_svg_chart", "generate_svg_chart", StageAssets, 9, "zero_or_more", "visual_type_chart_without_vega_lite_requirement", []string{"content/slide_content.json", "assets/assets_manifest.json"}, []string{"assets/assets_manifest.json", "assets/charts/chart_manifest.json"}, []string{"chart_assets_planned"}),
|
||||
legacyConditionalToolEntry("slides_convert", "slides_convert", StageResearch, 10, "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, 11, "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 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)
|
||||
}
|
||||
1043
internal/svglide/quality.go
Normal file
1043
internal/svglide/quality.go
Normal file
File diff suppressed because it is too large
Load Diff
911
internal/svglide/quality_test.go
Normal file
911
internal/svglide/quality_test.go
Normal file
@@ -0,0 +1,911 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckQualityAllowsExplicitLocalSourceWithoutFullPageWebSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"source.md","title":"Local Source","excerpt":"Input","usage":"Support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["local1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only slide; no image assets required"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsTopicDeckWithoutFullPageWebOrExplicitLocalSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"source1","path":"source.md","title":"Weak Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["source1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.research") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.research", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsSlideContentWithoutSourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.source_refs") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.source_refs", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingVisualAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityFailsEntityDeckWithoutRealVisualAssets(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/request.json", `{"title":"NVIDIA Financial Report","topic":"Generate a comprehensive financial report for Q4 2023 for Nvidia."}`)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"resolved_entity":{"name":"NVIDIA","type":"company","confidence_bp":9500,"confidence_band":"high","reason":"Named public company"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"clarification_question":""}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"NVIDIA Financial Report","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Financial report","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"nvda","path":"https://investor.nvidia.com/","title":"NVIDIA IR","excerpt":"Financial report","usage":"financial data","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Financial report","source_refs":["nvda"],"visuals":[{"id":"chart","type":"none","instruction":"Chart-only cover"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"This data-report deck does not require raster images; charts are enough."}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[],"no_image_reason":"This data-report deck does not require raster images; charts are enough."}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
|
||||
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()+`<rect width="1280" height="720" fill="#fff"/><text x="80" y="120" font-size="48">NVIDIA Q4 Financial Report</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Metrics.VisualAssetIssueCount == 0 {
|
||||
t.Fatalf("visual asset issue count = 0, want > 0")
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visual_asset.cover_real_hero_missing") {
|
||||
t.Fatalf("issues = %+v, want cover hero missing", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityPassesAnyGenReadyRun(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
if report.Metrics.Slides != 1 || report.Metrics.Sources != 1 || report.Metrics.WebSources != 1 || report.Metrics.Assets != 1 || report.Metrics.SlidesWithSourceRef != 1 || report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics = %+v, want all ones", report.Metrics)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
var written QualityReport
|
||||
if err := json.Unmarshal(raw, &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written.Status != "passed" {
|
||||
t.Fatalf("written status = %q, want passed", written.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsBrandOfficialSiteWithLowImageCoverage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{
|
||||
"resolved_entity":{"name":"KANEKO OPTICAL","type":"brand"},
|
||||
"visual_quality_contract":{
|
||||
"profile":"brand_official_site",
|
||||
"requires_real_images":true,
|
||||
"min_image_coverage_bp":7000,
|
||||
"min_unique_images":6,
|
||||
"min_official_images":4,
|
||||
"allow_repeated_hero_only":false,
|
||||
"reason":"真实品牌官网主题需要官网图片资产支撑。"
|
||||
}
|
||||
}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Kaneko","slides":[
|
||||
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"},
|
||||
{"id":"s2","title":"Thesis","summary":"Thesis","role":"thesis","key_message":"Thesis","path":"slides/02.svg"},
|
||||
{"id":"s3","title":"History","summary":"History","role":"history","key_message":"History","path":"slides/03.svg"},
|
||||
{"id":"s4","title":"Factory","summary":"Factory","role":"factory","key_message":"Factory","path":"slides/04.svg"},
|
||||
{"id":"s5","title":"Product","summary":"Product","role":"product","key_message":"Product","path":"slides/05.svg"},
|
||||
{"id":"s6","title":"Retail","summary":"Retail","role":"retail","key_message":"Retail","path":"slides/06.svg"},
|
||||
{"id":"s7","title":"Process","summary":"Process","role":"process","key_message":"Process","path":"slides/07.svg"},
|
||||
{"id":"s8","title":"Closing","summary":"Closing","role":"closing","key_message":"Closing","path":"slides/08.svg"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[
|
||||
{"id":"kaneko-home","path":"https://www.kaneko-optical.co.jp/zh-CHS/","title":"Home","excerpt":"Official site","usage":"identity","retrieval":"full_page"},
|
||||
{"id":"user-hero-image","path":"/Users/bytedance/Downloads/image_gwnb.png","title":"User image","excerpt":"Hero","usage":"visual reference","retrieval":"user_provided"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[
|
||||
{"id":"s1","content":"Cover","source_refs":["kaneko-home","user-hero-image"],"visuals":[{"id":"asset-cover","type":"image","instruction":"Hero"}]},
|
||||
{"id":"s2","content":"Thesis","source_refs":["kaneko-home"],"visuals":[{"id":"none-s2","type":"none","instruction":"Native diagram"}]},
|
||||
{"id":"s3","content":"History","source_refs":["kaneko-home"],"visuals":[{"id":"none-s3","type":"none","instruction":"Native timeline"}]},
|
||||
{"id":"s4","content":"Factory","source_refs":["kaneko-home"],"visuals":[{"id":"none-s4","type":"none","instruction":"Native matrix"}]},
|
||||
{"id":"s5","content":"Product","source_refs":["kaneko-home"],"visuals":[{"id":"none-s5","type":"none","instruction":"Native cards"}]},
|
||||
{"id":"s6","content":"Retail","source_refs":["kaneko-home"],"visuals":[{"id":"none-s6","type":"none","instruction":"Native metrics"}]},
|
||||
{"id":"s7","content":"Process","source_refs":["kaneko-home"],"visuals":[{"id":"none-s7","type":"none","instruction":"Native rail"}]},
|
||||
{"id":"s8","content":"Closing","source_refs":["kaneko-home","user-hero-image"],"visuals":[{"id":"asset-closing","type":"image","instruction":"Hero again"}]}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
|
||||
{"id":"asset-cover","slide_id":"s1","visual_id":"asset-cover","kind":"image","local_path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","status":"ready","usage":"cover"},
|
||||
{"id":"asset-closing","slide_id":"s8","visual_id":"asset-closing","kind":"image","local_path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","status":"ready","usage":"closing"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[
|
||||
{"id":"asset-cover","path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","width":1704,"height":868,"semantic_type":"brand hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":""}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png"/><text>Cover</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/08.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png"/><text>Closing</text></svg>`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for low image coverage; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_coverage") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.image_coverage", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsWeakCoverWhenVisualContractRequiresStrongCover(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand","must_have":{"strong_cover":true}}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","visual_intent":"Use a strong first impression","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/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png" x="40" y="40" width="320" height="180"/><text x="48" y="260">Cover</text></svg>`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for weak cover; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.weak_cover") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.weak_cover", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsPosterOnlyCoverWhenRealHeroImageRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","cover_requires_real_hero_image":true}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"NVIDIA Report","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":"nvidia","path":"https://www.nvidia.com/en-us/about-nvidia/","title":"NVIDIA","excerpt":"Official source","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["nvidia"],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/generated-chip.svg","source_url":"","status":"ready","usage":"Generated chip hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/generated-chip.svg", `<svg/>`)
|
||||
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/generated-chip.svg" x="0" y="0" width="960" height="540"/><text x="48" y="120" font-size="72">NVIDIA</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_generated_svg")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for generated SVG cover; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.cover_real_hero_image") {
|
||||
t.Fatalf("issues = %+v, want cover_real_hero_image", report.Issues)
|
||||
}
|
||||
if report.Metrics.CoverRealHeroImage {
|
||||
t.Fatalf("cover_real_hero_image = true, want false for generated SVG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityDoesNotCountPreviewSlideSVGImagesAsRealImages(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"resolved_entity":{"name":"Demo","type":"brand"},"visual_quality_contract":{"profile":"brand_official_site","requires_real_images":true,"min_image_coverage_bp":10000,"min_unique_images":1,"forbid_preview_wrapper_images_as_real_images":true}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"slides/01.svg","source_url":"","status":"ready","usage":"Preview wrapper"}]}`)
|
||||
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 960 540">`+fontTokenStyleForTest()+`<text x="48" y="80">Cover</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Metrics.RealImageAssets != 0 || report.Metrics.SlidesWithRealImageAssets != 0 {
|
||||
t.Fatalf("metrics = %+v, preview slide SVG must not count as real image", report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.preview_wrapper_image") {
|
||||
t.Fatalf("issues = %+v, want preview_wrapper_image", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingVegaLiteManifestWhenRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1}}`)
|
||||
mustWriteVegaLiteQualityDeck(t)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for missing Vega-Lite manifest", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.missing_chart_manifest") {
|
||||
t.Fatalf("issues = %+v, want missing_chart_manifest", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsHandwrittenChartWhenVegaLiteRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1}}`)
|
||||
mustWriteVegaLiteQualityDeck(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"svg","charts":[{"id":"revenue","slide_id":"s1","renderer":"svg","svg_path":"assets/charts/revenue.svg"}]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for hand-written SVG chart", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.chart_renderer") {
|
||||
t.Fatalf("issues = %+v, want chart_renderer", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingTypographyContractWhenRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","typography_contract_required":true}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Financial Report","slides":[{"id":"s1","title":"Summary","summary":"Summary","role":"cover","key_message":"Summary","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"none","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only report summary"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for missing typography contract", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.missing_typography_contract") && !qualityIssueCodesContain(report.Issues, "svglide.quality.typography_contract") {
|
||||
t.Fatalf("issues = %+v, want typography contract failure", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVisualContractMissingRealImagesFixtureFailsClosed(t *testing.T) {
|
||||
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture := filepath.Join(repoRoot, "testdata", "svglide", "strict_visual_contract_missing_real_images")
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
runRoot := filepath.Join(cwd, "fixture")
|
||||
copyTestDir(t, fixture, runRoot)
|
||||
|
||||
report, err := CheckQuality("fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
for _, want := range []string{"svglide.quality.image_coverage", "svglide.quality.cover_real_hero_image"} {
|
||||
if !qualityIssueCodesContain(report.Issues, want) {
|
||||
t.Fatalf("issues = %+v, want %s", report.Issues, want)
|
||||
}
|
||||
}
|
||||
if report.Metrics.RealImageAssets != 0 || report.Metrics.GeneratedSVGAssets == 0 {
|
||||
t.Fatalf("metrics = %+v, want generated SVG but zero real images", report.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func copyTestDir(t *testing.T, src string, dst string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
if entry.IsDir() {
|
||||
copyTestDir(t, srcPath, dstPath)
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dstPath, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsLowEvidenceDensityWhenVisualContractRequiresEvidenceGrid(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand_factory","must_have":{"evidence_page_min_visuals":4,"visual_roles_required":["hero_cover","evidence_grid"]}}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[
|
||||
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","visual_intent":"Full bleed cover image","key_message":"Cover","path":"slides/01.svg"},
|
||||
{"id":"s2","title":"Process","summary":"Process","role":"process","visual_role":"evidence_grid","visual_intent":"Use process images as evidence","key_message":"Process","path":"slides/02.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":"cover","type":"image","instruction":"Cover image"}]},
|
||||
{"id":"s2","content":"Process","source_refs":["web1"],"visuals":[{"id":"p1","type":"image","instruction":"Process 1"},{"id":"p2","type":"image","instruction":"Process 2"}]}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
|
||||
{"id":"cover","slide_id":"s1","visual_id":"cover","kind":"image","local_path":"assets/images/cover.png","source_url":"https://example.com/cover.png","status":"ready","usage":"Cover"},
|
||||
{"id":"p1","slide_id":"s2","visual_id":"p1","kind":"image","local_path":"assets/images/p1.png","source_url":"https://example.com/p1.png","status":"ready","usage":"Process 1"},
|
||||
{"id":"p2","slide_id":"s2","visual_id":"p2","kind":"image","local_path":"assets/images/p2.png","source_url":"https://example.com/p2.png","status":"ready","usage":"Process 2"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/cover.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p1.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p2.png", "png")
|
||||
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/cover.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/02.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/p1.png" x="40" y="40" width="320" height="180"/><image slide:role="image" href="../assets/images/p2.png" x="400" y="40" width="320" height="180"/><text x="48" y="300">Process</text></svg>`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for low evidence density; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.low_evidence_density") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.low_evidence_density", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanekoCalibrationFixtureIsQualityFloorOnly(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "svglide", "visual_quality", "kaneko_baseline_calibration.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
BenchmarkUsage string `json:"benchmark_usage"`
|
||||
DeckType string `json:"deck_type"`
|
||||
Minimums struct {
|
||||
StrongCover bool `json:"strong_cover"`
|
||||
EvidencePageMinVisuals int `json:"evidence_page_min_visuals"`
|
||||
SemanticImageCoverageMinBP int `json:"semantic_image_coverage_min_bp"`
|
||||
} `json:"minimums"`
|
||||
QualityDimensions []string `json:"quality_dimensions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixture.BenchmarkUsage != "quality_floor_only" {
|
||||
t.Fatalf("benchmark_usage = %q, want quality_floor_only", fixture.BenchmarkUsage)
|
||||
}
|
||||
if fixture.DeckType != "brand_factory" || !fixture.Minimums.StrongCover || fixture.Minimums.EvidencePageMinVisuals < 12 || fixture.Minimums.SemanticImageCoverageMinBP < 9000 {
|
||||
t.Fatalf("fixture = %+v, want脱敏质量下限指标", fixture)
|
||||
}
|
||||
if strings.Contains(string(raw), "<svg") || strings.Contains(string(raw), "viewBox") || strings.Contains(string(raw), "foreignObject") {
|
||||
t.Fatalf("fixture leaks SVG source: %s", string(raw))
|
||||
}
|
||||
if len(fixture.QualityDimensions) == 0 {
|
||||
t.Fatalf("quality_dimensions empty: %+v", fixture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsExperimentAssetsAndDeferredUnsupportedVisuals(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Full page excerpt","usage":"evidence","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"Chart claim","summary":"Needs chart later","role":"content","key_message":"Chart is deferred","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Chart-backed point","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a remote hero image"},{"id":"chart1","type":"chart","instruction":"Use a real chart when chart generation is enabled"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"},{"id":"chart1","slide_id":"s1","type":"chart","path":"","usage":"Deferred chart generation","status":"deferred"}]}`)
|
||||
mustWriteQualitySlideWithImage(t, "https://example.com/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsAbsoluteReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteQualitySlideWithImage(t, outside)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsEmptyVisuals(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visuals") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.visuals", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsVisualAssetTypeMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsAllDiagramWithoutNoImageReason(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Movie Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Movie hook","path":"slides/01.svg"},{"id":"s2","title":"Context","summary":"Context summary","role":"content","key_message":"Movie context","path":"slides/02.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/movie","title":"Movie Source","excerpt":"Movie excerpt","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":["web1"],"visuals":[{"id":"diagram1","type":"diagram","instruction":"Diagram fallback"}]},{"id":"s2","content":"Context","source_refs":["web1"],"visuals":[{"id":"diagram2","type":"diagram","instruction":"Diagram fallback"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"diagram1","slide_id":"s1","type":"diagram","path":"assets/images/diagram1.svg","usage":"Diagram fallback","status":"ready"},{"id":"diagram2","slide_id":"s2","type":"diagram","path":"assets/images/diagram2.svg","usage":"Diagram fallback","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/diagram1.svg", `<svg/>`)
|
||||
mustWriteTestFile(t, "demo/assets/images/diagram2.svg", `<svg/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want semantic failure for all-diagram fallback without no_image_reason; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainRule(report.Findings, "no_silent_all_diagram_fallback") {
|
||||
t.Fatalf("findings = %+v, want no_silent_all_diagram_fallback", report.Findings)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "anygen_semantic_report.json")); err != nil {
|
||||
t.Fatalf("missing anygen_semantic_report.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFindingsContainRule(findings []SemanticFinding, ruleID string) bool {
|
||||
for _, finding := range findings {
|
||||
if finding.RuleID == ruleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsUnsafeReadyImageAssetPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
assetPath string
|
||||
setupAsset func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "file url",
|
||||
assetPath: "file:///tmp/secret.png",
|
||||
},
|
||||
{
|
||||
name: "absolute path",
|
||||
assetPath: "/Users/example/secret.png",
|
||||
},
|
||||
{
|
||||
name: "missing local asset",
|
||||
assetPath: "assets/images/missing.png",
|
||||
},
|
||||
{
|
||||
name: "symlink local asset",
|
||||
assetPath: "assets/images/hero.png",
|
||||
setupAsset: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile("outside-hero.png", []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if tt.setupAsset != nil {
|
||||
tt.setupAsset(t)
|
||||
}
|
||||
writeSemanticImageDeck(t, tt.assetPath)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for unsafe ready image asset path %q; findings=%+v", report.Status, tt.assetPath, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.asset_path", report.Findings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsBrowserRelativeMissingImageHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="assets/images/hero.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed because browser resolves href against slides/ directory; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportAllowsBrowserRelativeImageHrefAndNoteSources(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["kaneko-home"],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
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 960 540">`+fontTokenStyleForTest()+`<slide:note>Sources: kaneko-home</slide:note><image slide:role="image" href="../assets/images/hero.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed for browser-relative image href and note source marker; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if report.Metrics.MissingAssetCount != 0 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 0", report.Metrics.MissingAssetCount)
|
||||
}
|
||||
if report.Metrics.VisibleLeakCount != 0 {
|
||||
t.Fatalf("VisibleLeakCount = %d, want 0 for slide:note source marker", report.Metrics.VisibleLeakCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportAllowsRegisteredChartHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, "../assets/charts/revenue.svg")
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed for registered chart href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsUnregisteredChartHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[]}`, "../assets/charts/revenue.svg")
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for unregistered chart href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsUnsafeReadyChartHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"file:///tmp/secret.svg","usage":"Revenue chart","status":"ready"}]}`, "file:///tmp/secret.svg")
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for unsafe chart href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsChartAssetUsedAsImageHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
|
||||
writeSemanticDeckWithSlideBody(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, `<image slide:role="image" href="../assets/charts/revenue.svg" x="80" y="80" width="640" height="360"/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for chart asset used as image href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_type") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.asset_type", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsExternalUseHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
|
||||
writeSemanticDeckWithSlideBody(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, `<use href="../assets/charts/revenue.svg" x="80" y="80" width="640" height="360"/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for external use href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_type") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.asset_type", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFindingsContainCode(findings []SemanticFinding, code string) bool {
|
||||
for _, finding := range findings {
|
||||
if finding.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeSemanticImageDeck(t *testing.T, assetPath string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Image Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Image hook","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+assetPath+`","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteQualitySlideWithImage(t, assetPath)
|
||||
}
|
||||
|
||||
func writeSemanticChartDeck(t *testing.T, assetsPlan string, chartHref string) {
|
||||
t.Helper()
|
||||
writeSemanticDeckWithSlideBody(t, assetsPlan, `<rect slide:role="chart" href="`+chartHref+`" x="80" y="80" width="640" height="360"/>`)
|
||||
}
|
||||
|
||||
func writeSemanticDeckWithSlideBody(t *testing.T, assetsPlan string, slideBody string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Chart Deck","slides":[{"id":"s1","title":"Revenue","summary":"Revenue summary","role":"content","key_message":"Revenue changed","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Revenue changed","source_refs":[],"visuals":[{"id":"chart1","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", assetsPlan)
|
||||
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 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/>`+slideBody+`<text x="48" y="500">Revenue</text></svg>`)
|
||||
}
|
||||
|
||||
func TestCheckQualityCountsSlidesWithVisualsPerPage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"},{"id":"logo","type":"diagram","instruction":"Support diagram"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"},{"id":"logo","slide_id":"s1","type":"diagram","path":"assets/images/logo.svg","usage":"Support diagram","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "logo.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics.slides_with_visuals = %d, want 1", report.Metrics.SlidesWithVisuals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsSymlinkReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("outside-hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityUsesOutlineDeckNotRunArtifactDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeStatusTestRunFile(t, run)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "custom"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/custom/deck.json", `{"title":"Custom Deck","slides":[{"id":"c1","title":"Custom 1","summary":"Custom summary 1","role":"cover","key_message":"Custom key 1","path":"slides/01.svg"},{"id":"c2","title":"Custom 2","summary":"Custom summary 2","role":"content","key_message":"Custom key 2","path":"slides/02.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":"Claim","source_refs":["web1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]},{"id":"c1","content":"Custom claim 1","source_refs":["web1"],"visuals":[{"id":"v2","type":"none","instruction":"Text-only"}]},{"id":"c2","content":"Custom claim 2","source_refs":["web1"],"visuals":[{"id":"v3","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only slide; no image assets required"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.Slides != 1 {
|
||||
t.Fatalf("metrics.slides = %d, want 1 from outline/deck.json", report.Metrics.Slides)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteVegaLiteQualityDeck(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Financial Deck","slides":[{"id":"s1","title":"Revenue","summary":"Revenue trend","role":"content","key_message":"Revenue expanded","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"single_chart","story_function":"proof","primary_asset_role":"financial chart","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"10k","path":"https://example.com/annual-report","title":"Annual report","excerpt":"Revenue was 60.9 billion","usage":"financial data","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Revenue expanded","source_refs":["10k"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"revenue","slide_id":"s1","visual_id":"revenue","kind":"chart","local_path":"assets/charts/revenue.svg","source_url":"","status":"ready","usage":"Revenue chart"}],"no_image_reason":"Financial chart page; no photo required"}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`)
|
||||
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 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/><rect slide:role="chart" href="../assets/charts/revenue.svg" x="80" y="96" width="720" height="320"/><text x="48" y="480">Revenue: 60.9B</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":"single_chart","thumbnail_job":"chart proof","visual_center":"revenue chart","topic_fit_claim":"matches financial report","information_density_plan":"one main chart and one sourced note","page_difference_from_previous":"first chart page","primary_asset":"assets/charts/revenue.svg","asset_role":"financial proof","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"financial analysis chart","data_visual_rationale":"shows revenue change with sourced numbers","source_evidence":["FY revenue 60.9 billion from 10k"],"fusion_spec":{"enabled":false},"qa_expectations":["chart has numeric evidence"]}]}`)
|
||||
}
|
||||
|
||||
func mustWriteQualitySlideWithImage(t *testing.T, href string) {
|
||||
t.Helper()
|
||||
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 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/><image slide:role="image" href="`+svgHrefForTestAsset(href)+`" x="40" y="40" width="320" height="180"/><text x="48" y="260">Claim</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "character_product_focus", "image_claim")
|
||||
}
|
||||
|
||||
func mustWriteQualityVisualReceiptForTest(t *testing.T, slideID string, family string, signature string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"`+slideID+`","story_job":"hook","layout_family":"`+family+`","layout_archetype":"`+inferAuthorLayoutArchetype(family, signature)+`","layout_signature":"`+signature+`","thumbnail_job":"readable claim","visual_center":"topic claim and supporting visual","topic_fit_claim":"matches the requested topic","information_density_plan":"one clear claim with supporting proof","page_difference_from_previous":"distinct opening treatment","primary_asset":"hero","asset_role":"topic anchor","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"focused editorial slide","data_visual_rationale":"","source_evidence":["web1 supports this claim"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
}
|
||||
|
||||
func svgHrefForTestAsset(path string) string {
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
return "../" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func qualityIssueCodesContain(issues []QualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
467
internal/svglide/rendered_visual.go
Normal file
467
internal/svglide/rendered_visual.go
Normal file
@@ -0,0 +1,467 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type renderedVisualParseState struct {
|
||||
TranslateX float64
|
||||
TranslateY float64
|
||||
FontSize 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 renderedTransformTranslatePattern = regexp.MustCompile(`(?i)translate\(\s*([\-0-9.]+)(?:[\s,]+([\-0-9.]+))?`)
|
||||
var renderedHTMLTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
var renderedWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
|
||||
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)
|
||||
report := RenderedVisualReport{
|
||||
Status: "passed",
|
||||
Issues: []RenderedVisualIssue{},
|
||||
Slides: []RenderedVisualSlideItem{{Path: path, Status: "passed"}},
|
||||
}
|
||||
const edge = 6.0
|
||||
for _, box := range boxes {
|
||||
if box.Width <= 0 || box.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
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" {
|
||||
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)
|
||||
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"),
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
case "foreignObject":
|
||||
text := normalizeRenderedText(top.Text)
|
||||
if text != "" {
|
||||
requiredHeight := estimateForeignObjectTextHeight(text, top.FontSize, top.Foreign.Width)
|
||||
top.Foreign.Text = text
|
||||
top.Foreign.RequiredHeight = requiredHeight
|
||||
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++
|
||||
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 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 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 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 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
|
||||
}
|
||||
73
internal/svglide/rendered_visual_test.go
Normal file
73
internal/svglide/rendered_visual_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
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 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
|
||||
}
|
||||
601
internal/svglide/repair.go
Normal file
601
internal/svglide/repair.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type RepairReport struct {
|
||||
Status string `json:"status"`
|
||||
LintOK bool `json:"lint_ok"`
|
||||
Preview string `json:"preview"`
|
||||
Quality string `json:"quality"`
|
||||
Creative string `json:"creative"`
|
||||
Semantic string `json:"semantic"`
|
||||
Reauthored bool `json:"reauthored"`
|
||||
}
|
||||
|
||||
type DeliveryReceipt struct {
|
||||
Status string `json:"status"`
|
||||
RouteProfile string `json:"route_profile"`
|
||||
Orchestrator string `json:"orchestrator"`
|
||||
RuntimeBinding string `json:"runtime_binding"`
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Slides []string `json:"slides"`
|
||||
Preview DeliveryPreviewEvidence `json:"preview"`
|
||||
QualityReport string `json:"quality_report"`
|
||||
AnyGenSemanticReport string `json:"anygen_semantic_report"`
|
||||
VisualReceipts string `json:"visual_receipts"`
|
||||
CreativeQualityReport string `json:"creative_quality_report"`
|
||||
SemanticMetrics SemanticMetrics `json:"semantic_metrics"`
|
||||
StageStatus map[string]string `json:"stage_status"`
|
||||
LegacyRuntimeExecuted bool `json:"legacy_runtime_executed"`
|
||||
LegacyToolIDs []string `json:"legacy_tool_ids"`
|
||||
LegacyArtifactMatches []string `json:"legacy_artifact_matches"`
|
||||
CorePromptIDs []string `json:"core_prompt_ids"`
|
||||
ObservedPromptIDs []string `json:"observed_prompt_ids"`
|
||||
BlockedPromptIDs []string `json:"blocked_prompt_ids"`
|
||||
}
|
||||
|
||||
type DeliveryPreviewEvidence struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
MissingAssetCount int `json:"missing_asset_count"`
|
||||
}
|
||||
|
||||
func RepairRun(root string) (RepairReport, error) {
|
||||
return repairRun(root, EvaluateAnyGenSemantics)
|
||||
}
|
||||
|
||||
func RepairRunWithSemanticContractFile(root string, contractPath string) (RepairReport, error) {
|
||||
contract, err := LoadSemanticContractFile(contractPath)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
return RepairRunWithSemanticContract(root, contract)
|
||||
}
|
||||
|
||||
func RepairRunWithSemanticContract(root string, contract SemanticContract) (RepairReport, error) {
|
||||
return repairRun(root, func(root string) (AnyGenSemanticReport, error) {
|
||||
return EvaluateAnyGenSemanticsWithContract(root, contract)
|
||||
})
|
||||
}
|
||||
|
||||
func repairRun(root string, evaluateSemantic func(string) (AnyGenSemanticReport, error)) (RepairReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
lint, validateErr := ValidateRun(root)
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
|
||||
reauthored := false
|
||||
if !lint.OK {
|
||||
repairPaths, ok := authorRepairPaths(lint)
|
||||
if ok {
|
||||
if _, err := authorSlides(root, repairPaths); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
reauthored = true
|
||||
lint, validateErr = ValidateRun(root)
|
||||
}
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := WritePreview(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
quality, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
if quality.Status != "passed" {
|
||||
if err := writeQualityRepairQueue(safeRoot, quality); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
}
|
||||
semantic, err := evaluateSemantic(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
report := RepairReport{
|
||||
Status: "failed",
|
||||
LintOK: lint.OK,
|
||||
Preview: preview.Status,
|
||||
Quality: quality.Status,
|
||||
Creative: creativeStatusFromQuality(safeRoot),
|
||||
Semantic: semantic.Status,
|
||||
Reauthored: reauthored,
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative == "passed" && report.Semantic == "passed" {
|
||||
report.Status = "passed"
|
||||
}
|
||||
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
artifacts := []string{
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
anyGenSemanticReportPath,
|
||||
visualReceiptsPath,
|
||||
creativeQualityReportPath,
|
||||
"repair_queue.md",
|
||||
previewPath,
|
||||
}
|
||||
if report.Status == "passed" {
|
||||
if _, err := writeDeliveryReceiptWithStatus(safeRoot, run, StatusReady); err != nil {
|
||||
return report, err
|
||||
}
|
||||
artifacts = append(artifacts, deliveryReceiptPath)
|
||||
} else if report.LintOK && report.Preview == "passed" {
|
||||
if _, err := writeDeliveryReceiptWithStatus(safeRoot, run, StatusNeedsRepair); err != nil {
|
||||
return report, err
|
||||
}
|
||||
artifacts = append(artifacts, deliveryReceiptPath)
|
||||
}
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: StageValidatePreviewRepair,
|
||||
Status: report.Status,
|
||||
Message: repairReceiptMessage(report),
|
||||
Artifacts: artifacts,
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
const deliveryReceiptPath = "receipts/delivery.json"
|
||||
|
||||
func writeDeliveryReceipt(safeRoot string, run Run) (DeliveryReceipt, error) {
|
||||
return writeDeliveryReceiptWithStatus(safeRoot, run, StatusReady)
|
||||
}
|
||||
|
||||
func writeDeliveryReceiptWithStatus(safeRoot string, run Run, status string) (DeliveryReceipt, error) {
|
||||
receipt, err := generateDeliveryReceiptWithStatus(safeRoot, run, status)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if err := ValidateDeliveryReceiptAgainstRun(receipt, run); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, deliveryReceiptPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if err := writeJSON(target, receipt); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func GenerateDeliveryReceipt(safeRoot string, run Run) (DeliveryReceipt, error) {
|
||||
return generateDeliveryReceiptWithStatus(safeRoot, run, StatusReady)
|
||||
}
|
||||
|
||||
func generateDeliveryReceiptWithStatus(safeRoot string, run Run, status string) (DeliveryReceipt, error) {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
status = StatusReady
|
||||
}
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
deckPath = "outline/deck.json"
|
||||
}
|
||||
deck, err := readAuthorDeck(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
slides := make([]string, 0, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if _, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
slides = append(slides, slidePath)
|
||||
}
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
requiredReports := []string{previewPath, "quality_report.json", anyGenSemanticReportPath, creativeQualityReportPath}
|
||||
if status == StatusReady {
|
||||
requiredReports = append(requiredReports, visualReceiptsPath)
|
||||
}
|
||||
for _, rel := range requiredReports {
|
||||
if _, err := readRunRegularArtifact(safeRoot, rel); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
}
|
||||
slidesDir := strings.TrimSpace(run.Artifacts.SlidesDir)
|
||||
if slidesDir == "" {
|
||||
slidesDir = "slides"
|
||||
}
|
||||
preview, err := readDeliveryPreviewEvidence(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
semantic, err := readDeliverySemanticReport(safeRoot)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
legacy, err := ScanLegacyRuntimeEvidence(safeRoot, run)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
receipt := DeliveryReceipt{
|
||||
Status: status,
|
||||
RouteProfile: normalizedRouteProfile(run.RouteProfile),
|
||||
Orchestrator: "mode_system_prompt_svg",
|
||||
RuntimeBinding: "svglide_local_runtime_binding",
|
||||
Deck: deckPath,
|
||||
SlidesDir: slidesDir,
|
||||
Slides: slides,
|
||||
Preview: preview,
|
||||
QualityReport: "quality_report.json",
|
||||
AnyGenSemanticReport: anyGenSemanticReportPath,
|
||||
VisualReceipts: visualReceiptsPath,
|
||||
CreativeQualityReport: creativeQualityReportPath,
|
||||
SemanticMetrics: semantic.Metrics,
|
||||
StageStatus: deliveryStageStatus(run),
|
||||
LegacyRuntimeExecuted: legacy.LegacyRuntimeExecuted,
|
||||
LegacyToolIDs: legacy.LegacyToolIDs,
|
||||
LegacyArtifactMatches: legacy.LegacyArtifactMatches,
|
||||
CorePromptIDs: []string{"mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding"},
|
||||
ObservedPromptIDs: legacy.ObservedPromptIDs,
|
||||
BlockedPromptIDs: legacy.BlockedPromptIDs,
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func creativeStatusFromQuality(safeRoot string) string {
|
||||
raw, err := readRunRegularArtifact(safeRoot, creativeQualityReportPath)
|
||||
if err != nil {
|
||||
return "missing"
|
||||
}
|
||||
var report CreativeQualityReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return "invalid"
|
||||
}
|
||||
return strings.TrimSpace(report.Status)
|
||||
}
|
||||
|
||||
func ValidateDeliveryReceiptAgainstRun(receipt DeliveryReceipt, run Run) error {
|
||||
if normalizedRouteProfile(run.RouteProfile) == RouteProfileLocalSVGDeck && receipt.LegacyRuntimeExecuted {
|
||||
return fmt.Errorf("legacy runtime evidence found for local_svg_deck: tools=%s artifacts=%s", strings.Join(receipt.LegacyToolIDs, ","), strings.Join(receipt.LegacyArtifactMatches, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LegacyRuntimeEvidence struct {
|
||||
LegacyRuntimeExecuted bool `json:"legacy_runtime_executed"`
|
||||
LegacyToolIDs []string `json:"legacy_tool_ids"`
|
||||
LegacyArtifactMatches []string `json:"legacy_artifact_matches"`
|
||||
ObservedPromptIDs []string `json:"observed_prompt_ids"`
|
||||
BlockedPromptIDs []string `json:"blocked_prompt_ids"`
|
||||
}
|
||||
|
||||
func ScanLegacyRuntimeEvidence(safeRoot string, run Run) (LegacyRuntimeEvidence, error) {
|
||||
legacyIDs, blockedIDs, err := legacyPromptIDSets(run)
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
evidence := LegacyRuntimeEvidence{
|
||||
LegacyToolIDs: []string{},
|
||||
LegacyArtifactMatches: []string{},
|
||||
ObservedPromptIDs: []string{},
|
||||
BlockedPromptIDs: sortedKeys(blockedIDs),
|
||||
}
|
||||
|
||||
toolMatches, err := filepath.Glob(filepath.Join(safeRoot, "receipts", "tool_calls", "*", "*.json"))
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
for _, path := range toolMatches {
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".json")
|
||||
if !legacyIDs[id] {
|
||||
continue
|
||||
}
|
||||
evidence.LegacyToolIDs = appendUnique(evidence.LegacyToolIDs, id)
|
||||
if rel, err := filepath.Rel(safeRoot, path); err == nil {
|
||||
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
|
||||
contextMatches, err := filepath.Glob(filepath.Join(safeRoot, "receipts", "prompt_context", "*.json"))
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
for _, path := range contextMatches {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
var receipt PromptContextReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return LegacyRuntimeEvidence{}, fmt.Errorf("%s: invalid prompt context JSON: %w", filepath.ToSlash(path), err)
|
||||
}
|
||||
rel := filepath.ToSlash(path)
|
||||
if localRel, err := filepath.Rel(safeRoot, path); err == nil {
|
||||
rel = filepath.ToSlash(localRel)
|
||||
}
|
||||
for id := range promptIDsFromReceipt(receipt) {
|
||||
evidence.ObservedPromptIDs = appendUnique(evidence.ObservedPromptIDs, id)
|
||||
if blockedIDs[id] {
|
||||
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, rel+"#"+id)
|
||||
}
|
||||
}
|
||||
}
|
||||
artifactMatches, err := scanLegacyRunArtifacts(safeRoot)
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
for _, match := range artifactMatches {
|
||||
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, match)
|
||||
}
|
||||
|
||||
sort.Strings(evidence.LegacyToolIDs)
|
||||
sort.Strings(evidence.LegacyArtifactMatches)
|
||||
sort.Strings(evidence.ObservedPromptIDs)
|
||||
evidence.LegacyRuntimeExecuted = len(evidence.LegacyToolIDs) > 0 || len(artifactMatches) > 0 || legacyPromptObserved(evidence.LegacyArtifactMatches, blockedIDs)
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func scanLegacyRunArtifacts(safeRoot string) ([]string, error) {
|
||||
var matches []string
|
||||
err := filepath.WalkDir(safeRoot, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == safeRoot {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if legacyRunArtifactMatch(rel) {
|
||||
matches = appendUnique(matches, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func legacyRunArtifactMatch(rel string) bool {
|
||||
lower := strings.ToLower(filepath.ToSlash(rel))
|
||||
base := strings.ToLower(filepath.Base(lower))
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
switch ext {
|
||||
case ".slides", ".pptx", ".sxsd", ".xml":
|
||||
return true
|
||||
}
|
||||
switch base {
|
||||
case "converted_pptx_manifest.json",
|
||||
"template_manifest.json",
|
||||
"sxsd_manifest.json",
|
||||
"editor_session.json",
|
||||
"publish_receipt.json",
|
||||
"readback_receipt.json",
|
||||
"live_create_receipt.json":
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "sxsd") || strings.Contains(lower, "legacy_editor") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func legacyPromptObserved(matches []string, blockedIDs map[string]bool) bool {
|
||||
for _, match := range matches {
|
||||
for id := range blockedIDs {
|
||||
if strings.HasSuffix(match, "#"+id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func legacyPromptIDSets(run Run) (map[string]bool, map[string]bool, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
legacyIDs := map[string]bool{}
|
||||
blockedIDs := map[string]bool{}
|
||||
profile := normalizedRouteProfile(run.RouteProfile)
|
||||
for _, asset := range assets {
|
||||
if asset.Exposure != "legacy" {
|
||||
continue
|
||||
}
|
||||
legacyIDs[asset.ID] = true
|
||||
if !promptAssetAllowedForProfile(asset.Profiles, profile) {
|
||||
blockedIDs[asset.ID] = true
|
||||
}
|
||||
}
|
||||
return legacyIDs, blockedIDs, nil
|
||||
}
|
||||
|
||||
func readDeliveryPreviewEvidence(safeRoot string, previewPath string) (DeliveryPreviewEvidence, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return DeliveryPreviewEvidence{}, err
|
||||
}
|
||||
var report PreviewReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return DeliveryPreviewEvidence{}, fmt.Errorf("%s: invalid JSON: %w", previewReceiptPath, err)
|
||||
}
|
||||
return DeliveryPreviewEvidence{
|
||||
Path: previewPath,
|
||||
Status: report.Status,
|
||||
MissingAssetCount: report.MissingAssetCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readDeliverySemanticReport(safeRoot string) (AnyGenSemanticReport, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, anyGenSemanticReportPath)
|
||||
if err != nil {
|
||||
return AnyGenSemanticReport{}, err
|
||||
}
|
||||
var report AnyGenSemanticReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return AnyGenSemanticReport{}, fmt.Errorf("%s: invalid JSON: %w", anyGenSemanticReportPath, err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func deliveryStageStatus(run Run) map[string]string {
|
||||
status := make(map[string]string, len(run.Stages))
|
||||
for _, stage := range run.Stages {
|
||||
status[stage.Name] = stage.Status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func normalizedRouteProfile(profile string) string {
|
||||
profile = strings.TrimSpace(profile)
|
||||
if profile == "" {
|
||||
return RouteProfileLocalSVGDeck
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]bool) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func canRepairByAuthoring(report ValidationReport) bool {
|
||||
_, ok := authorRepairPaths(report)
|
||||
return ok
|
||||
}
|
||||
|
||||
func authorRepairPaths(report ValidationReport) (map[string]bool, bool) {
|
||||
if report.OK || len(report.Issues) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, issue := range report.Issues {
|
||||
path, ok := repairIssueAuthorPath(issue)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
paths[path] = true
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func canRepairIssueByAuthoring(issue ValidationIssue) bool {
|
||||
_, ok := repairIssueAuthorPath(issue)
|
||||
return ok
|
||||
}
|
||||
|
||||
func repairIssueAuthorPath(issue ValidationIssue) (string, bool) {
|
||||
path := strings.TrimSpace(issue.Path)
|
||||
slidePath, err := previewSlideObjectPath(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(issue.Code) {
|
||||
case "svglide.path":
|
||||
return slidePath, strings.Contains(issue.Message, "missing or not a regular file")
|
||||
case "svglide.xml", "svglide.root", "svglide.slide_role", "svglide.viewbox", "svglide.visible_content":
|
||||
return slidePath, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func repairReceiptMessage(report RepairReport) string {
|
||||
if report.Status == "passed" {
|
||||
if report.Reauthored {
|
||||
return "lint, preview, quality, creative, and semantic report passed after reauthoring"
|
||||
}
|
||||
return "lint, preview, quality, creative, and semantic report passed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality != "passed" {
|
||||
return "quality gate failed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative != "passed" {
|
||||
return "creative quality gate failed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative == "passed" && report.Semantic != "passed" {
|
||||
return "semantic gate failed"
|
||||
}
|
||||
if report.Reauthored {
|
||||
return "repair reauthored slides but lint or preview still failed"
|
||||
}
|
||||
return "lint or preview failed"
|
||||
}
|
||||
|
||||
func writeQualityRepairQueue(safeRoot string, report QualityReport) error {
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderQualityRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func renderQualityRepairQueue(report QualityReport) string {
|
||||
if report.Status == "passed" || len(report.Issues) == 0 {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
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)
|
||||
if suggestion := qualityRepairSuggestion(issue.Code); suggestion != "" {
|
||||
fmt.Fprintf(&b, " - Repair: %s\n", suggestion)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func qualityRepairSuggestion(code string) string {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "svglide.quality.weak_cover":
|
||||
return "Rebuild cover with a full-bleed hero image or poster-style composition, reducing copy to one title and one subtitle."
|
||||
case "svglide.quality.low_evidence_density":
|
||||
return "Add a dense evidence grid or process image matrix using semantically relevant assets."
|
||||
case "svglide.quality.repetitive_layout":
|
||||
return "Vary slide rhythm across hero, thesis, evidence, detail, comparison, and closing layouts."
|
||||
case "svglide.quality.low_semantic_image_coverage":
|
||||
return "Replace decorative or generic visuals with images that prove the slide message."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
383
internal/svglide/repair_test.go
Normal file
383
internal/svglide/repair_test.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepairRunAuthorsMissingSlidesAndWritesFinalReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if !report.LintOK {
|
||||
t.Fatalf("LintOK = false, want true: %+v", report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "passed" {
|
||||
t.Fatalf("Quality = %q, want passed: %+v", report.Quality, report)
|
||||
}
|
||||
if report.Creative != "passed" {
|
||||
t.Fatalf("Creative = %q, want passed: %+v", report.Creative, report)
|
||||
}
|
||||
if !report.Reauthored {
|
||||
t.Fatalf("Reauthored = false, want true: %+v", report)
|
||||
}
|
||||
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
"creative_quality_report.json",
|
||||
"visual_receipts.json",
|
||||
"receipts/validate_preview_repair.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["stage"] != StageValidatePreviewRepair {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageValidatePreviewRepair)
|
||||
}
|
||||
if receipt["status"] != "passed" {
|
||||
t.Fatalf("receipt status = %v, want passed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "lint, preview, quality, creative, and semantic report passed after reauthoring" {
|
||||
t.Fatalf("receipt message = %v, want semantic-aware pass message", receipt["message"])
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["updated_at"]; ok {
|
||||
t.Fatalf("receipt contains updated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairWritesDeliveryReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing delivery receipt after passed repair: %v", err)
|
||||
}
|
||||
var delivery map[string]any
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatalf("invalid delivery receipt: %v", err)
|
||||
}
|
||||
if delivery["status"] != "ready" || delivery["deck"] != "outline/deck.json" {
|
||||
t.Fatalf("delivery receipt = %+v, want ready deck path", delivery)
|
||||
}
|
||||
if delivery["route_profile"] != RouteProfileLocalSVGDeck {
|
||||
t.Fatalf("delivery route_profile = %v, want %s", delivery["route_profile"], RouteProfileLocalSVGDeck)
|
||||
}
|
||||
if delivery["orchestrator"] != "mode_system_prompt_svg" || delivery["runtime_binding"] != "svglide_local_runtime_binding" {
|
||||
t.Fatalf("delivery prompt core = %+v, want orchestrator and runtime binding", delivery)
|
||||
}
|
||||
preview, ok := delivery["preview"].(map[string]any)
|
||||
if !ok || preview["path"] != "preview.html" || preview["status"] != "passed" {
|
||||
t.Fatalf("delivery preview = %+v, want preview object with passed status", delivery["preview"])
|
||||
}
|
||||
if _, ok := delivery["semantic_metrics"].(map[string]any); !ok {
|
||||
t.Fatalf("delivery missing semantic_metrics: %+v", delivery)
|
||||
}
|
||||
if delivery["legacy_runtime_executed"] != false {
|
||||
t.Fatalf("delivery legacy_runtime_executed = %v, want false", delivery["legacy_runtime_executed"])
|
||||
}
|
||||
for _, key := range []string{"core_prompt_ids", "observed_prompt_ids", "blocked_prompt_ids", "stage_status"} {
|
||||
if delivery[key] == nil {
|
||||
t.Fatalf("delivery missing %s: %+v", key, delivery)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"quality_report", "anygen_semantic_report", "visual_receipts", "creative_quality_report"} {
|
||||
if delivery[key] == "" || delivery[key] == nil {
|
||||
t.Fatalf("delivery receipt missing %s: %+v", key, delivery)
|
||||
}
|
||||
}
|
||||
slides, ok := delivery["slides"].([]any)
|
||||
if !ok || len(slides) != 1 || slides[0] != "slides/01.svg" {
|
||||
t.Fatalf("delivery slides = %+v, want slides/01.svg", delivery["slides"])
|
||||
}
|
||||
for _, rel := range []string{"outline/deck.json", "slides/01.svg", "preview.html", "quality_report.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("delivery path %s missing: %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRejectsLegacyRuntimeEvidenceForLocalProfile(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/receipts/tool_calls/research/slides_convert.json", `{"call_id":"slides_convert","status":"done"}`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err == nil {
|
||||
t.Fatalf("expected legacy runtime evidence to reject delivery, got report %+v", report)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "legacy runtime") || !strings.Contains(err.Error(), "slides_convert") {
|
||||
t.Fatalf("error = %v, want legacy runtime evidence for slides_convert", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunFailsWhenQualityFails(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"research/local.md","title":"Local source","excerpt":"Local excerpt","usage":"support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.LintOK != true {
|
||||
t.Fatalf("LintOK = %v, want true: %+v", report.LintOK, report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "failed" {
|
||||
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
|
||||
}
|
||||
|
||||
qualityRaw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var quality map[string]any
|
||||
if err := json.Unmarshal(qualityRaw, &quality); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality["status"] != "failed" {
|
||||
t.Fatalf("quality status = %v, want failed: %+v", quality["status"], quality)
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["status"] != "failed" {
|
||||
t.Fatalf("receipt status = %v, want failed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "quality gate failed" {
|
||||
t.Fatalf("receipt message = %v, want quality gate failed", receipt["message"])
|
||||
}
|
||||
deliveryRaw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing delivery receipt for failed quality repair: %v", err)
|
||||
}
|
||||
var delivery map[string]any
|
||||
if err := json.Unmarshal(deliveryRaw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery["status"] != StatusNeedsRepair {
|
||||
t.Fatalf("delivery status = %v, want %s", delivery["status"], StatusNeedsRepair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunWritesVisualQualityRepairQueue(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[
|
||||
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"},
|
||||
{"id":"s2","title":"Process","summary":"Process","role":"process","visual_role":"evidence_grid","key_message":"Process","path":"slides/02.svg"}
|
||||
]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand_factory","must_have":{"evidence_page_min_visuals":4}}}`)
|
||||
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":"cover","type":"image","instruction":"Cover image"}]},
|
||||
{"id":"s2","content":"Process","source_refs":["web1"],"visuals":[{"id":"p1","type":"image","instruction":"Process 1"},{"id":"p2","type":"image","instruction":"Process 2"}]}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
|
||||
{"id":"cover","slide_id":"s1","visual_id":"cover","kind":"image","local_path":"assets/images/cover.png","source_url":"https://example.com/cover.png","status":"ready","usage":"Cover"},
|
||||
{"id":"p1","slide_id":"s2","visual_id":"p1","kind":"image","local_path":"assets/images/p1.png","source_url":"https://example.com/p1.png","status":"ready","usage":"Process 1"},
|
||||
{"id":"p2","slide_id":"s2","visual_id":"p2","kind":"image","local_path":"assets/images/p2.png","source_url":"https://example.com/p2.png","status":"ready","usage":"Process 2"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/cover.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p1.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p2.png", "png")
|
||||
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/cover.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/02.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/p1.png" x="40" y="40" width="320" height="180"/><image slide:role="image" href="../assets/images/p2.png" x="400" y="40" width="320" height="180"/><text x="48" y="300">Process</text></svg>`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Quality != "failed" {
|
||||
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "Add a dense evidence grid or process image matrix") {
|
||||
t.Fatalf("repair queue = %q, want visual quality repair suggestion", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairReceiptMessagePrioritizesLintPreviewFailuresOverQuality(t *testing.T) {
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed"}); got != "lint or preview failed" {
|
||||
t.Fatalf("message = %q, want lint or preview failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed", Reauthored: true}); got != "repair reauthored slides but lint or preview still failed" {
|
||||
t.Fatalf("reauthored message = %q, want reauthored lint/preview failure", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "failed", Semantic: "passed"}); got != "quality gate failed" {
|
||||
t.Fatalf("quality-only message = %q, want quality gate failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "passed", Creative: "failed", Semantic: "passed"}); got != "creative quality gate failed" {
|
||||
t.Fatalf("creative-only message = %q, want creative quality gate failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "passed", Creative: "passed", Semantic: "failed"}); got != "semantic gate failed" {
|
||||
t.Fatalf("semantic-only message = %q, want semantic gate failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunOnlyReauthorsFailedSlidePaths(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
custom := `<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="#fff"/><text x="48" y="80">KEEP-CUSTOM-01</text></svg>`
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", custom)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != custom {
|
||||
t.Fatalf("slides/01.svg was overwritten:\n%s", string(raw))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "02.svg")); err != nil {
|
||||
t.Fatalf("missing reauthored slides/02.svg: %v", err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunReauthorsBackgroundOnlySVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", backgroundOnlySVG())
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunDoesNotAuthorInvalidSlidePath(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/../01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.Reauthored {
|
||||
t.Fatalf("Reauthored = true, want false for invalid path: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("svg_author receipt exists or stat failed, want no authoring: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunTreatsValidationArtifactWriteErrorAsFatal(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
if err := os.Remove(filepath.Join("demo", "repair_queue.md")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join("demo", "repair_queue.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := RepairRun("demo"); err == nil {
|
||||
t.Fatal("expected repair to return validation artifact write error")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("final repair receipt exists or stat failed, want no misleading final receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRepairReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "validate_preview_repair.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
182
internal/svglide/run.go
Normal file
182
internal/svglide/run.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StageRequest = "request"
|
||||
StageRequestResolution = "request_resolution"
|
||||
StageResearch = "research"
|
||||
StageDesignBrief = "design_brief"
|
||||
StageOutline = "outline"
|
||||
StageSlideContent = "slide_content"
|
||||
StageAssets = "assets"
|
||||
StageSVGAuthor = "svg_author"
|
||||
StageValidatePreviewRepair = "validate_preview_repair"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusReady = "ready"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusBlocked = "blocked"
|
||||
StatusNeedsRepair = "needs_repair"
|
||||
)
|
||||
|
||||
const (
|
||||
RouteProfileLocalSVGDeck = "local_svg_deck"
|
||||
routeProfileImportedPPTX = "imported_pptx"
|
||||
routeProfileTemplateReference = "template_reference"
|
||||
routeProfileLegacyEditor = "legacy_editor"
|
||||
)
|
||||
|
||||
const (
|
||||
VisualQualityModeStrict = "strict"
|
||||
VisualQualityModeWarn = "warn"
|
||||
)
|
||||
|
||||
type Run struct {
|
||||
Version int `json:"version"`
|
||||
Runtime string `json:"runtime"`
|
||||
Command string `json:"command"`
|
||||
RouteProfile string `json:"route_profile"`
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
VisualQualityMode string `json:"visual_quality_mode,omitempty"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Out string `json:"out"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Stages []Stage `json:"stages"`
|
||||
Artifacts ArtifactPaths `json:"artifacts"`
|
||||
Policy Policy `json:"policy"`
|
||||
Agent AgentSession `json:"agent"`
|
||||
Intent Intent `json:"intent"`
|
||||
}
|
||||
|
||||
type AgentSession struct {
|
||||
Runtime string `json:"runtime"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type Intent struct {
|
||||
SourceMode string `json:"source_mode"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
Input string `json:"input,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
type Stage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Receipt string `json:"receipt"`
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Preview string `json:"preview"`
|
||||
RepairQueue string `json:"repair_queue"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
PublishEnabled bool `json:"publish_enabled"`
|
||||
NetworkByAgent bool `json:"network_by_agent"`
|
||||
ImageGenerationByAgent bool `json:"image_generation_by_agent"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type NewRunConfig struct {
|
||||
Title string
|
||||
Input string
|
||||
Topic string
|
||||
Language string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Out string
|
||||
Now time.Time
|
||||
AgentRuntime string
|
||||
AgentID string
|
||||
RouteProfile string
|
||||
}
|
||||
|
||||
func NewRun(cfg NewRunConfig) Run {
|
||||
now := cfg.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
ts := now.Format(time.RFC3339)
|
||||
agentRuntime := cfg.AgentRuntime
|
||||
if agentRuntime == "" {
|
||||
agentRuntime = "codex"
|
||||
}
|
||||
routeProfile := strings.TrimSpace(cfg.RouteProfile)
|
||||
if routeProfile == "" {
|
||||
routeProfile = RouteProfileLocalSVGDeck
|
||||
}
|
||||
sourceMode := "local_file"
|
||||
if cfg.Topic != "" {
|
||||
sourceMode = "topic"
|
||||
}
|
||||
return Run{
|
||||
Version: 1,
|
||||
Runtime: "agent",
|
||||
Command: "slides +create-svglide",
|
||||
RouteProfile: routeProfile,
|
||||
Title: cfg.Title,
|
||||
Input: cfg.Input,
|
||||
Audience: cfg.Audience,
|
||||
DeliveryMode: cfg.DeliveryMode,
|
||||
VisualQualityMode: VisualQualityModeStrict,
|
||||
Pages: cfg.Pages,
|
||||
Out: cfg.Out,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentStage: StageRequest,
|
||||
Stages: DefaultStages(),
|
||||
Artifacts: ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
},
|
||||
Policy: Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByAgent: true,
|
||||
ImageGenerationByAgent: true,
|
||||
Overwrite: false,
|
||||
},
|
||||
Agent: AgentSession{
|
||||
Runtime: agentRuntime,
|
||||
ID: cfg.AgentID,
|
||||
},
|
||||
Intent: Intent{
|
||||
SourceMode: sourceMode,
|
||||
Topic: cfg.Topic,
|
||||
Input: cfg.Input,
|
||||
Language: cfg.Language,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultStages() []Stage {
|
||||
return []Stage{
|
||||
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
|
||||
{Name: StageRequestResolution, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"request/entity_resolution.json"}, Receipt: "receipts/request_resolution.json"},
|
||||
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json", "research/research_coverage.json"}, Receipt: "receipts/research.json"},
|
||||
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, Receipt: "receipts/design_brief.json"},
|
||||
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
|
||||
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md", "research/sources.json"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json", "content/slide_copy_plan.json"}, Receipt: "receipts/slide_content.json"},
|
||||
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "assets/charts/chart_manifest.json"}, Receipt: "receipts/assets.json"},
|
||||
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_manifest.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
|
||||
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json", "repair_queue.md", "preview.html", "receipts/delivery.json"}, Receipt: "receipts/validate_preview_repair.json"},
|
||||
}
|
||||
}
|
||||
198
internal/svglide/run_test.go
Normal file
198
internal/svglide/run_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultStagesAreOrdered(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
want := []string{
|
||||
StageRequest,
|
||||
StageRequestResolution,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
StageValidatePreviewRepair,
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stage count = %d, want %d", len(stages), len(want))
|
||||
}
|
||||
for i, stage := range stages {
|
||||
if stage.Name != want[i] {
|
||||
t.Fatalf("stage[%d] = %q, want %q", i, stage.Name, want[i])
|
||||
}
|
||||
if stage.Status != StatusPending {
|
||||
t.Fatalf("stage[%d].Status = %q, want %q", i, stage.Status, StatusPending)
|
||||
}
|
||||
if stage.Inputs == nil {
|
||||
t.Fatalf("stage[%d].Inputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Outputs == nil {
|
||||
t.Fatalf("stage[%d].Outputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Receipt == "" {
|
||||
t.Fatalf("stage[%d] missing receipt path", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
svgAuthor := mustStage(t, stages, StageSVGAuthor)
|
||||
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
|
||||
}
|
||||
repair := mustStage(t, stages, StageValidatePreviewRepair)
|
||||
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesFinalStageRequiresQualityReport(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
final := stages[len(stages)-1]
|
||||
if final.Name != StageValidatePreviewRepair {
|
||||
t.Fatalf("final stage = %q, want %q", final.Name, StageValidatePreviewRepair)
|
||||
}
|
||||
if !stringSliceContains(final.Outputs, "quality_report.json") {
|
||||
t.Fatalf("final outputs = %+v, want quality_report.json", final.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
research := mustStage(t, stages, StageResearch)
|
||||
want := []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}
|
||||
if !reflect.DeepEqual(research.Inputs, want) {
|
||||
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequestResolutionIsGateBetweenRequestAndResearch(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
requestResolution := mustStage(t, stages, StageRequestResolution)
|
||||
if !reflect.DeepEqual(requestResolution.Inputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("request_resolution Inputs = %v", requestResolution.Inputs)
|
||||
}
|
||||
if !reflect.DeepEqual(requestResolution.Outputs, []string{"request/entity_resolution.json"}) {
|
||||
t.Fatalf("request_resolution Outputs = %v", requestResolution.Outputs)
|
||||
}
|
||||
if requestResolution.Receipt != "receipts/request_resolution.json" {
|
||||
t.Fatalf("request_resolution Receipt = %q", requestResolution.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
outline := mustStage(t, stages, StageOutline)
|
||||
want := []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}
|
||||
if !reflect.DeepEqual(outline.Inputs, want) {
|
||||
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunSeparatesProtocolRuntimeFromAgentRuntime(t *testing.T) {
|
||||
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品和工程负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Out: ".lark-slides/svglide-runs/demo",
|
||||
Now: now,
|
||||
})
|
||||
if run.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", run.Version)
|
||||
}
|
||||
if run.Runtime != "agent" {
|
||||
t.Fatalf("Runtime = %q, want agent", run.Runtime)
|
||||
}
|
||||
if run.RouteProfile != RouteProfileLocalSVGDeck {
|
||||
t.Fatalf("RouteProfile = %q, want %q", run.RouteProfile, RouteProfileLocalSVGDeck)
|
||||
}
|
||||
if run.Agent.Runtime != "codex" {
|
||||
t.Fatalf("Agent.Runtime = %q, want default codex", run.Agent.Runtime)
|
||||
}
|
||||
if run.Intent.SourceMode != "local_file" || run.Intent.Input != "source.md" {
|
||||
t.Fatalf("Intent = %+v, want local_file source.md", run.Intent)
|
||||
}
|
||||
if run.Command != "slides +create-svglide" {
|
||||
t.Fatalf("Command = %q, want slides +create-svglide", run.Command)
|
||||
}
|
||||
if run.Title != "Demo" {
|
||||
t.Fatalf("Title = %q, want Demo", run.Title)
|
||||
}
|
||||
if run.Input != "source.md" {
|
||||
t.Fatalf("Input = %q, want source.md", run.Input)
|
||||
}
|
||||
if run.Audience != "产品和工程负责人" {
|
||||
t.Fatalf("Audience = %q, want 产品和工程负责人", run.Audience)
|
||||
}
|
||||
if run.DeliveryMode != "self_read" {
|
||||
t.Fatalf("DeliveryMode = %q, want self_read", run.DeliveryMode)
|
||||
}
|
||||
if run.Pages != 8 {
|
||||
t.Fatalf("Pages = %d, want 8", run.Pages)
|
||||
}
|
||||
if run.Out != ".lark-slides/svglide-runs/demo" {
|
||||
t.Fatalf("Out = %q, want .lark-slides/svglide-runs/demo", run.Out)
|
||||
}
|
||||
wantTS := now.Format(time.RFC3339)
|
||||
if run.CreatedAt != wantTS {
|
||||
t.Fatalf("CreatedAt = %q, want %q", run.CreatedAt, wantTS)
|
||||
}
|
||||
if run.UpdatedAt != wantTS {
|
||||
t.Fatalf("UpdatedAt = %q, want %q", run.UpdatedAt, wantTS)
|
||||
}
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
wantArtifacts := ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
}
|
||||
if run.Artifacts != wantArtifacts {
|
||||
t.Fatalf("Artifacts = %+v, want %+v", run.Artifacts, wantArtifacts)
|
||||
}
|
||||
wantStages := DefaultStages()
|
||||
if !reflect.DeepEqual(run.Stages, wantStages) {
|
||||
t.Fatalf("Stages = %+v, want %+v", run.Stages, wantStages)
|
||||
}
|
||||
wantPolicy := Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByAgent: true,
|
||||
ImageGenerationByAgent: true,
|
||||
Overwrite: false,
|
||||
}
|
||||
if run.Policy != wantPolicy {
|
||||
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStage(t *testing.T, stages []Stage, name string) Stage {
|
||||
t.Helper()
|
||||
for _, stage := range stages {
|
||||
if stage.Name == name {
|
||||
return stage
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return Stage{}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
736
internal/svglide/schema.go
Normal file
736
internal/svglide/schema.go
Normal file
@@ -0,0 +1,736 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type liteJSONSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
AdditionalProperties *bool `json:"additionalProperties"`
|
||||
Properties map[string]liteJSONSchema `json:"properties"`
|
||||
Items *liteJSONSchema `json:"items"`
|
||||
MinItems *int `json:"minItems"`
|
||||
Enum []string `json:"enum"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
var stageOutputSchemaPaths = map[string]string{
|
||||
"request/request.json": "schemas/request.schema.json",
|
||||
"request/source_manifest.json": "schemas/source_manifest.schema.json",
|
||||
"request/entity_resolution.json": "schemas/entity_resolution.schema.json",
|
||||
"research/sources.json": "schemas/sources.schema.json",
|
||||
"research/research_coverage.json": "schemas/research_coverage.schema.json",
|
||||
"brief/design_brief.json": "schemas/design_brief.schema.json",
|
||||
"brief/visual_system.json": "schemas/visual_system.schema.json",
|
||||
"brief/typography_contract.json": "schemas/typography_contract.schema.json",
|
||||
"outline/deck.json": "schemas/deck.schema.json",
|
||||
"content/slide_content.json": "schemas/slide_content.schema.json",
|
||||
"content/slide_copy_plan.json": "schemas/slide_copy_plan.schema.json",
|
||||
"assets/assets_plan.json": "schemas/assets_plan.schema.json",
|
||||
"assets/assets_manifest.json": "schemas/assets_manifest.schema.json",
|
||||
"assets/asset_inventory.json": "schemas/asset_inventory.schema.json",
|
||||
"assets/charts/chart_manifest.json": "schemas/chart_manifest.schema.json",
|
||||
"quality_report.json": "schemas/quality.schema.json",
|
||||
"anygen_semantic_report.json": "schemas/anygen_semantic_report.schema.json",
|
||||
"visual_receipts.json": "schemas/visual_receipts.schema.json",
|
||||
"creative_quality_report.json": "schemas/creative_quality.schema.json",
|
||||
"receipts/lint.json": "schemas/lint.schema.json",
|
||||
"receipts/preview.json": "schemas/preview.schema.json",
|
||||
"receipts/rendered_visual.json": "schemas/rendered_visual.schema.json",
|
||||
"receipts/delivery.json": "schemas/delivery.schema.json",
|
||||
}
|
||||
|
||||
const AnyGenSemanticReportSchema = `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "contract", "metrics", "findings"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"contract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "role", "path", "sha256", "rules"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"sha256": {"type": "string"},
|
||||
"rules": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slide_count", "slides_with_slide_role", "image_count", "text_count", "note_count", "source_ref_count", "missing_asset_count", "slides_without_source_refs", "visible_leak_count", "font_token_count", "missing_font_token_count"],
|
||||
"properties": {
|
||||
"slide_count": {"type": "integer"},
|
||||
"slides_with_slide_role": {"type": "integer"},
|
||||
"image_count": {"type": "integer"},
|
||||
"text_count": {"type": "integer"},
|
||||
"note_count": {"type": "integer"},
|
||||
"source_ref_count": {"type": "integer"},
|
||||
"missing_asset_count": {"type": "integer"},
|
||||
"slides_without_source_refs": {"type": "integer"},
|
||||
"visible_leak_count": {"type": "integer"},
|
||||
"font_token_count": {"type": "integer"},
|
||||
"missing_font_token_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rule_id", "kind", "severity", "code", "message"],
|
||||
"properties": {
|
||||
"rule_id": {"type": "string"},
|
||||
"kind": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"artifact": {"type": "string"},
|
||||
"field": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DeliveryReceiptSchema = `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "route_profile", "orchestrator", "runtime_binding", "deck", "slides_dir", "slides", "preview", "quality_report", "anygen_semantic_report", "visual_receipts", "creative_quality_report", "semantic_metrics", "stage_status", "legacy_runtime_executed", "legacy_tool_ids", "legacy_artifact_matches", "core_prompt_ids", "observed_prompt_ids", "blocked_prompt_ids"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["ready", "needs_repair"]},
|
||||
"route_profile": {"type": "string"},
|
||||
"orchestrator": {"type": "string"},
|
||||
"runtime_binding": {"type": "string"},
|
||||
"deck": {"type": "string"},
|
||||
"slides_dir": {"type": "string"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"preview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "status", "missing_asset_count"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"missing_asset_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"quality_report": {"type": "string"},
|
||||
"anygen_semantic_report": {"type": "string"},
|
||||
"visual_receipts": {"type": "string"},
|
||||
"creative_quality_report": {"type": "string"},
|
||||
"semantic_metrics": {"type": "object"},
|
||||
"stage_status": {"type": "object"},
|
||||
"legacy_runtime_executed": {"type": "boolean"},
|
||||
"legacy_tool_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"legacy_artifact_matches": {"type": "array", "items": {"type": "string"}},
|
||||
"core_prompt_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"observed_prompt_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"blocked_prompt_ids": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func ValidateStageOutputs(root string) error {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, output := range stage.Outputs {
|
||||
if hasGlobMeta(output) || strings.ToLower(filepath.Ext(output)) != ".json" {
|
||||
continue
|
||||
}
|
||||
if stage.Name == StageValidatePreviewRepair && output == deliveryReceiptPath {
|
||||
continue
|
||||
}
|
||||
schemaPath, ok := stageOutputSchemaPaths[output]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, output, schemaPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if output == "outline/deck.json" {
|
||||
if err := validateDeckSlideOutputPaths(safeRoot, output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
switch stage.Name {
|
||||
case StageRequestResolution:
|
||||
if err := ValidateRequestResolutionGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
case StageResearch:
|
||||
if err := ValidateResearchCoverageGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
case StageSlideContent:
|
||||
if err := ValidateSlideContentSourceRefsGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateSlideCopyPlanGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
case StageAssets:
|
||||
if err := ValidateAssetInventoryGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type entityResolutionArtifact struct {
|
||||
ResolvedEntity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ConfidenceBP int `json:"confidence_bp"`
|
||||
ConfidenceBand string `json:"confidence_band"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"resolved_entity"`
|
||||
Ambiguity struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"ambiguity"`
|
||||
ResearchRequired bool `json:"research_required"`
|
||||
ClarificationQuestion string `json:"clarification_question"`
|
||||
}
|
||||
|
||||
func ValidateRequestResolutionGate(safeRoot string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "request/entity_resolution.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("request/entity_resolution.json: read artifact: %w", err)
|
||||
}
|
||||
var resolution entityResolutionArtifact
|
||||
if err := json.Unmarshal(raw, &resolution); err != nil {
|
||||
return fmt.Errorf("request/entity_resolution.json: invalid JSON: %w", err)
|
||||
}
|
||||
name := strings.TrimSpace(resolution.ResolvedEntity.Name)
|
||||
entityType := strings.TrimSpace(resolution.ResolvedEntity.Type)
|
||||
reason := strings.TrimSpace(resolution.ResolvedEntity.Reason)
|
||||
if name == "" {
|
||||
return fmt.Errorf("request_resolution_gate: resolved_entity.name is required")
|
||||
}
|
||||
if entityType == "" {
|
||||
return fmt.Errorf("request_resolution_gate: resolved_entity.type is required")
|
||||
}
|
||||
if reason == "" {
|
||||
return fmt.Errorf("request_resolution_gate: resolved_entity.reason is required")
|
||||
}
|
||||
if resolution.ResolvedEntity.ConfidenceBP < 0 || resolution.ResolvedEntity.ConfidenceBP > 10000 {
|
||||
return fmt.Errorf("request_resolution_gate: confidence_bp %d outside 0..10000", resolution.ResolvedEntity.ConfidenceBP)
|
||||
}
|
||||
ambiguityStatus := strings.TrimSpace(resolution.Ambiguity.Status)
|
||||
if ambiguityStatus != "resolved" {
|
||||
if ambiguityStatus == "needs_clarification" && strings.TrimSpace(resolution.ClarificationQuestion) == "" {
|
||||
return fmt.Errorf("request_resolution_gate: needs_clarification requires clarification_question")
|
||||
}
|
||||
return fmt.Errorf("request_resolution_gate: ambiguity status %q blocks research", ambiguityStatus)
|
||||
}
|
||||
if entityType == "topic" {
|
||||
if !resolution.ResearchRequired {
|
||||
return fmt.Errorf("request_resolution_gate: topic requests must set research_required=true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if resolution.ResolvedEntity.ConfidenceBP < 7000 {
|
||||
return fmt.Errorf("request_resolution_gate: confidence_bp %d below 7000 for real-world entity type %q", resolution.ResolvedEntity.ConfidenceBP, entityType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sourcesArtifactForGate struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
Usage string `json:"usage"`
|
||||
Retrieval string `json:"retrieval"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
|
||||
type researchCoverageArtifact struct {
|
||||
Entity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"entity"`
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
Usage string `json:"usage"`
|
||||
Status string `json:"status"`
|
||||
} `json:"sources"`
|
||||
Coverage struct {
|
||||
SourceCount int `json:"source_count"`
|
||||
TopicOnlyRationale string `json:"topic_only_rationale"`
|
||||
} `json:"coverage"`
|
||||
}
|
||||
|
||||
func ValidateResearchCoverageGate(safeRoot string) error {
|
||||
requestType, requestName, err := readResolvedRequestEntity(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceIDs, err := readKnownSourceIDs(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "research/research_coverage.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("research/research_coverage.json: read artifact: %w", err)
|
||||
}
|
||||
var coverage researchCoverageArtifact
|
||||
if err := json.Unmarshal(raw, &coverage); err != nil {
|
||||
return fmt.Errorf("research/research_coverage.json: invalid JSON: %w", err)
|
||||
}
|
||||
retrievedCount := 0
|
||||
identityRetrieved := false
|
||||
for _, source := range coverage.Sources {
|
||||
id := strings.TrimSpace(source.ID)
|
||||
if !sourceIDs[id] {
|
||||
return fmt.Errorf("research_coverage_gate: research_coverage source id %q not found in research/sources.json", id)
|
||||
}
|
||||
if strings.TrimSpace(source.Status) == "retrieved" {
|
||||
retrievedCount++
|
||||
if strings.TrimSpace(source.Usage) == "identity" {
|
||||
identityRetrieved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if coverage.Coverage.SourceCount != retrievedCount {
|
||||
return fmt.Errorf("research_coverage_gate: source_count = %d, want %d retrieved coverage sources", coverage.Coverage.SourceCount, retrievedCount)
|
||||
}
|
||||
coverageType := strings.TrimSpace(coverage.Entity.Type)
|
||||
if coverageType != requestType {
|
||||
return fmt.Errorf("research_coverage_gate: entity type %q does not match request/entity_resolution.json type %q", coverageType, requestType)
|
||||
}
|
||||
if requestName != "" && strings.TrimSpace(coverage.Entity.Name) != requestName {
|
||||
return fmt.Errorf("research_coverage_gate: entity name %q does not match request/entity_resolution.json name %q", strings.TrimSpace(coverage.Entity.Name), requestName)
|
||||
}
|
||||
if requestType == "topic" {
|
||||
if strings.TrimSpace(coverage.Coverage.TopicOnlyRationale) == "" {
|
||||
return fmt.Errorf("research_coverage_gate: topic_only_rationale is required for topic research")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !identityRetrieved {
|
||||
return fmt.Errorf("research_coverage_gate: real-world entity requires a retrieved identity source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readResolvedRequestEntity(safeRoot string) (string, string, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "request/entity_resolution.json")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("request/entity_resolution.json: read artifact: %w", err)
|
||||
}
|
||||
var resolution entityResolutionArtifact
|
||||
if err := json.Unmarshal(raw, &resolution); err != nil {
|
||||
return "", "", fmt.Errorf("request/entity_resolution.json: invalid JSON: %w", err)
|
||||
}
|
||||
entityType := strings.TrimSpace(resolution.ResolvedEntity.Type)
|
||||
if entityType == "" {
|
||||
return "", "", fmt.Errorf("request/entity_resolution.json: resolved_entity.type is required")
|
||||
}
|
||||
return entityType, strings.TrimSpace(resolution.ResolvedEntity.Name), nil
|
||||
}
|
||||
|
||||
func ValidateSlideContentSourceRefsGate(safeRoot string) error {
|
||||
sourceIDs, err := readKnownSourceIDs(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("content/slide_content.json: read artifact: %w", err)
|
||||
}
|
||||
var content struct {
|
||||
Slides []struct {
|
||||
ID string `json:"id"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &content); err != nil {
|
||||
return fmt.Errorf("content/slide_content.json: invalid JSON: %w", err)
|
||||
}
|
||||
for _, slide := range content.Slides {
|
||||
if len(slide.SourceRefs) == 0 {
|
||||
return fmt.Errorf("slide_content_source_refs_gate: slide %q source_refs is empty", strings.TrimSpace(slide.ID))
|
||||
}
|
||||
for _, ref := range slide.SourceRefs {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if !sourceIDs[ref] {
|
||||
return fmt.Errorf("slide_content_source_refs_gate: slide %q source_refs contains unknown source id %q", slide.ID, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type slideCopyPlanArtifact struct {
|
||||
Slides []struct {
|
||||
ID string `json:"id"`
|
||||
AudienceCopy struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []string `json:"labels"`
|
||||
} `json:"audience_copy"`
|
||||
ProductionInstruction struct {
|
||||
Layout string `json:"layout"`
|
||||
AssetIDs []string `json:"asset_ids"`
|
||||
} `json:"production_instruction"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
|
||||
func ValidateSlideCopyPlanGate(safeRoot string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_copy_plan.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("content/slide_copy_plan.json: read artifact: %w", err)
|
||||
}
|
||||
var plan slideCopyPlanArtifact
|
||||
if err := json.Unmarshal(raw, &plan); err != nil {
|
||||
return fmt.Errorf("content/slide_copy_plan.json: invalid JSON: %w", err)
|
||||
}
|
||||
for i, slide := range plan.Slides {
|
||||
visible := strings.Join([]string{slide.AudienceCopy.Title, slide.AudienceCopy.Body, strings.Join(slide.AudienceCopy.Labels, " ")}, " ")
|
||||
if productionInstructionLeakVisible(visible) {
|
||||
return fmt.Errorf("slide_copy_plan_gate: slides[%d] audience_copy contains production_instruction language", i)
|
||||
}
|
||||
if strings.TrimSpace(slide.ProductionInstruction.Layout) == "" {
|
||||
return fmt.Errorf("slide_copy_plan_gate: slides[%d] production_instruction.layout is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func productionInstructionLeakVisible(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range []string{
|
||||
"production_instruction",
|
||||
"图片要完整",
|
||||
"必须让眼镜完整出现",
|
||||
"不要裁切",
|
||||
"来源来自",
|
||||
"用这张图",
|
||||
"封面要全屏",
|
||||
"用于判断",
|
||||
"sources:",
|
||||
"source note",
|
||||
} {
|
||||
if strings.Contains(lower, strings.ToLower(marker)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidateAssetInventoryGate(safeRoot string) error {
|
||||
manifest, err := readAssetsManifest(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventory, err := readAssetInventory(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventoryByID := make(map[string]assetInventoryItem, len(inventory.Items))
|
||||
inventoryByPath := make(map[string]assetInventoryItem, len(inventory.Items))
|
||||
for _, item := range inventory.Items {
|
||||
if id := strings.TrimSpace(item.ID); id != "" {
|
||||
inventoryByID[id] = item
|
||||
}
|
||||
if path := strings.TrimSpace(item.Path); path != "" {
|
||||
inventoryByPath[path] = item
|
||||
}
|
||||
}
|
||||
for _, asset := range manifest.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
id := assetID(asset)
|
||||
path := assetPath(asset)
|
||||
if _, ok := inventoryByID[id]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := inventoryByPath[path]; ok {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("asset_inventory_gate: ready asset %q path %q is missing from asset_inventory", id, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKnownSourceIDs(safeRoot string) (map[string]bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "research/sources.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("research/sources.json: read artifact: %w", err)
|
||||
}
|
||||
var sources sourcesArtifactForGate
|
||||
if err := json.Unmarshal(raw, &sources); err != nil {
|
||||
return nil, fmt.Errorf("research/sources.json: invalid JSON: %w", err)
|
||||
}
|
||||
ids := make(map[string]bool, len(sources.Sources))
|
||||
for _, source := range sources.Sources {
|
||||
id := strings.TrimSpace(source.ID)
|
||||
if id != "" {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateDeckSlideOutputPaths(safeRoot string, artifactPath string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
var deck struct {
|
||||
Slides []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
if _, err := previewSlideObjectPath(slide.Path); err != nil {
|
||||
return fmt.Errorf("%s: field slides[%d].path: %w", artifactPath, i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStageOutputSchema(safeRoot, artifactPath, schemaPath string) error {
|
||||
artifactRaw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
schemaRaw, err := readRunRegularArtifact(safeRoot, schemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
schema, err := decodeLiteJSONSchema(schemaRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
value, err := decodeJSONValue(artifactRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
if err := validateJSONValue(schema, value, ""); err != nil {
|
||||
return fmt.Errorf("%s: %w", artifactPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLiteJSONSchema(raw []byte) (liteJSONSchema, error) {
|
||||
var schema liteJSONSchema
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&schema); err != nil {
|
||||
return liteJSONSchema{}, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return liteJSONSchema{}, err
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rejectTrailingJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("contains trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONValue(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
switch schema.Type {
|
||||
case "":
|
||||
return nil
|
||||
case "object":
|
||||
return validateJSONObject(schema, value, fieldPath)
|
||||
case "array":
|
||||
return validateJSONArray(schema, value, fieldPath)
|
||||
case "string":
|
||||
return validateJSONString(schema, value, fieldPath)
|
||||
case "integer":
|
||||
if !isJSONInteger(value) {
|
||||
return fmt.Errorf("field %s expected integer, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("field %s expected boolean, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("field %s uses unsupported schema type %q", displayFieldPath(fieldPath), schema.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONObject(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected object, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
for _, required := range schema.Required {
|
||||
if _, ok := object[required]; !ok {
|
||||
return fmt.Errorf("field %s is required", joinFieldPath(fieldPath, required))
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil && !*schema.AdditionalProperties {
|
||||
for name := range object {
|
||||
if _, ok := schema.Properties[name]; !ok {
|
||||
return fmt.Errorf("field %s is not allowed by additionalProperties:false", joinFieldPath(fieldPath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, propertySchema := range schema.Properties {
|
||||
child, ok := object[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateJSONValue(propertySchema, child, joinFieldPath(fieldPath, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONArray(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected array, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if schema.MinItems != nil && len(array) < *schema.MinItems {
|
||||
return fmt.Errorf("field %s has %d items, want minItems %d", displayFieldPath(fieldPath), len(array), *schema.MinItems)
|
||||
}
|
||||
if schema.Items == nil {
|
||||
return nil
|
||||
}
|
||||
for i, item := range array {
|
||||
if err := validateJSONValue(*schema.Items, item, joinArrayFieldPath(fieldPath, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONString(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected string, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if len(schema.Enum) > 0 {
|
||||
for _, allowed := range schema.Enum {
|
||||
if text == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("field %s value %q is not in enum %v", displayFieldPath(fieldPath), text, schema.Enum)
|
||||
}
|
||||
if schema.Pattern != "" {
|
||||
matched, err := regexp.MatchString(schema.Pattern, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %s has invalid pattern %q: %w", displayFieldPath(fieldPath), schema.Pattern, err)
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %s value %q does not match pattern %q", displayFieldPath(fieldPath), text, schema.Pattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isJSONInteger(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return isCanonicalJSONInteger(typed.String())
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCanonicalJSONInteger(value string) bool {
|
||||
if value == "" || strings.ContainsAny(value, ".eE") {
|
||||
return false
|
||||
}
|
||||
var parsed big.Int
|
||||
_, ok := parsed.SetString(value, 10)
|
||||
return ok
|
||||
}
|
||||
|
||||
func jsonValueType(value any) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func joinFieldPath(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "." + name
|
||||
}
|
||||
|
||||
func joinArrayFieldPath(parent string, index int) string {
|
||||
if parent == "" {
|
||||
return fmt.Sprintf("[%d]", index)
|
||||
}
|
||||
return fmt.Sprintf("%s[%d]", parent, index)
|
||||
}
|
||||
|
||||
func displayFieldPath(path string) string {
|
||||
if path == "" {
|
||||
return "$"
|
||||
}
|
||||
return path
|
||||
}
|
||||
672
internal/svglide/schema_test.go
Normal file
672
internal/svglide/schema_test.go
Normal file
@@ -0,0 +1,672 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingRequiredField(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "title") {
|
||||
t.Fatalf("error = %v, want path and missing field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsCurrentRequestArtifacts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaAcceptsVisualQualityContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "介绍日本金子眼镜 https://www.kaneko-optical.co.jp/zh-CHS/",
|
||||
"resolved_entity": {
|
||||
"name": "金子眼鏡株式会社 / KANEKO OPTICAL",
|
||||
"type": "brand",
|
||||
"confidence_bp": 9600,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户给出官网 URL 和品牌视觉图,目标是真实品牌官网介绍。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"profile": "brand_official_site",
|
||||
"requires_real_images": true,
|
||||
"min_image_coverage_bp": 7000,
|
||||
"min_unique_images": 6,
|
||||
"min_official_images": 4,
|
||||
"allow_repeated_hero_only": false,
|
||||
"reason": "真实品牌官网主题需要官网图片资产支撑。"
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["KANEKO OPTICAL"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("entity_resolution schema rejected visual_quality_contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaAcceptsBenchmarkVisualQualityContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "生成品牌介绍 slides",
|
||||
"resolved_entity": {
|
||||
"name": "Demo Brand",
|
||||
"type": "brand",
|
||||
"confidence_bp": 9200,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户请求是真实品牌介绍。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"mode": "benchmark",
|
||||
"benchmark_available": true,
|
||||
"benchmark_usage": "quality_floor_only",
|
||||
"deck_type": "brand_factory",
|
||||
"must_have": {
|
||||
"strong_cover": true,
|
||||
"semantic_image_coverage_min_bp": 9000,
|
||||
"evidence_page_min_visuals": 8,
|
||||
"max_repeated_layout_ratio_bp": 5000,
|
||||
"visual_roles_required": ["hero_cover", "evidence_grid"],
|
||||
"total_image_refs_min": 12
|
||||
}
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["Demo Brand"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("entity_resolution schema rejected benchmark visual_quality_contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaAcceptsStrictVisualContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "Generate a financial report for Nvidia Q4 2023",
|
||||
"resolved_entity": {
|
||||
"name": "NVIDIA Corporation",
|
||||
"type": "public_company_financial_report",
|
||||
"confidence_bp": 9600,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户请求是真实上市公司财报。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"profile": "data_report",
|
||||
"requires_real_images": true,
|
||||
"min_image_coverage_bp": 3000,
|
||||
"min_unique_images": 2,
|
||||
"min_official_images": 1,
|
||||
"allow_repeated_hero_only": false,
|
||||
"cover_requires_real_hero_image": true,
|
||||
"required_chart_renderer": "vega-lite",
|
||||
"min_chart_svg_assets": 6,
|
||||
"min_vega_lite_specs": 6,
|
||||
"typography_contract_required": true,
|
||||
"forbid_preview_wrapper_images_as_real_images": true,
|
||||
"reason": "真实公司财报需要真实企业/产品/数据中心图片、Vega-Lite 图表和字体契约。"
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["NVIDIA"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("entity_resolution schema rejected strict visual contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaRejectsCopySourceBenchmarkUsage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "生成品牌介绍 slides",
|
||||
"resolved_entity": {
|
||||
"name": "Demo Brand",
|
||||
"type": "brand",
|
||||
"confidence_bp": 9200,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户请求是真实品牌介绍。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"mode": "benchmark",
|
||||
"benchmark_available": true,
|
||||
"benchmark_usage": "copy_source",
|
||||
"deck_type": "brand_factory",
|
||||
"must_have": {"strong_cover": true}
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["Demo Brand"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "benchmark_usage") {
|
||||
t.Fatalf("error = %v, want benchmark_usage context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsDeckSlidePathsThatPreviewRejects(t *testing.T) {
|
||||
for _, path := range []string{"slides/a%20.svg", "slides/.hidden.svg", "slides/a..b.svg", "slides/a:b.svg"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON(path))
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outline/deck.json") || !strings.Contains(err.Error(), "slides[0].path") {
|
||||
t.Fatalf("error = %v, want deck path context", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidDeckSlidePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON("slides/a%20.svg"))
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageOutline {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageOutline)
|
||||
}
|
||||
if got := stageStatus(t, run, StageOutline); got == StatusDone {
|
||||
t.Fatalf("outline stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "outline.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("outline receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidValidatePreviewRepairReceipts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "lint",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"failed"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/lint.json",
|
||||
},
|
||||
{
|
||||
name: "preview",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":"yes"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/preview.json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
tt.setup(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %v, want path %s", err, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidQualityReportSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(`{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected quality report schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") {
|
||||
t.Fatalf("error = %v, want path quality_report.json", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSourcesMissingRetrieval(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
if err := os.WriteFile(filepath.Join("demo", "research", "sources.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"supporting evidence"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected retrieval schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "research/sources.json") || !strings.Contains(err.Error(), "retrieval") {
|
||||
t.Fatalf("error = %v, want research/sources.json and retrieval", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSlideContentMissingSourceRefsOrVisualIds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing source_refs",
|
||||
raw: `{"prompt_contract":` + promptContractJSON(StageSlideContent) + `,"slides":[{"id":"s1","content":"Plan","visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "source_refs",
|
||||
},
|
||||
{
|
||||
name: "missing visual id",
|
||||
raw: `{"prompt_contract":` + promptContractJSON(StageSlideContent) + `,"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[{"type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "visuals[0].id",
|
||||
},
|
||||
{
|
||||
name: "empty visuals",
|
||||
raw: `{"prompt_contract":` + promptContractJSON(StageSlideContent) + `,"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[]}]}`,
|
||||
want: "visuals",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
if err := os.WriteFile(filepath.Join("demo", "content", "slide_content.json"), []byte(tt.raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected slide content schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content/slide_content.json") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want content/slide_content.json and %s", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsAssetsMissingStatus(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"https://example.com/a.png","usage":"hero image"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected asset schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "status") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json and status", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsExperimentAssetPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "outside images", path: "../a.png"},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"`+tt.path+`","usage":"hero image","status":"ready"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_manifest.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","kind":"image","local_path":"`+tt.path+`","usage":"hero image","status":"ready"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "asset_inventory.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"a1","path":"`+tt.path+`","source_url":"","width":960,"height":540,"semantic_type":"image","large_ok":true,"full_bleed_ok":true,"recommended_use":"hero image","avoid_reason":""}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected experiment asset path to pass schema validation, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingArtifactPromptContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected assets artifact without prompt_contract to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "prompt_contract") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json prompt_contract rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsWrongPromptContractOrchestrator(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{
|
||||
"prompt_contract": {
|
||||
"protocol": "anygen-svg-slides",
|
||||
"stage": "assets",
|
||||
"context_receipt": "receipts/prompt_context/assets.json",
|
||||
"orchestrator": "wrong_orchestrator",
|
||||
"protocol_reference": "svg_reference",
|
||||
"required_prompt_ids": ["mode_system_prompt_svg", "svg_reference"]
|
||||
},
|
||||
"mode": "experiment_unrestricted_assets",
|
||||
"assets": []
|
||||
}`)
|
||||
|
||||
err := ValidateArtifactPromptContractForStage("demo", StageAssets, []string{"assets/assets_plan.json"})
|
||||
if err == nil {
|
||||
t.Fatal("expected wrong prompt_contract.orchestrator to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "orchestrator") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json orchestrator rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSchemasIncludeAnyGenQualityContracts(t *testing.T) {
|
||||
schemas := DefaultSchemas()
|
||||
for _, name := range []string{
|
||||
"entity_resolution.schema.json",
|
||||
"sources.schema.json",
|
||||
"research_coverage.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"slide_copy_plan.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"assets_manifest.schema.json",
|
||||
"asset_inventory.schema.json",
|
||||
"chart_manifest.schema.json",
|
||||
"typography_contract.schema.json",
|
||||
"quality.schema.json",
|
||||
} {
|
||||
if strings.TrimSpace(schemas[name]) == "" {
|
||||
t.Fatalf("schema %s is missing", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["sources.schema.json"], `"retrieval"`) {
|
||||
t.Fatalf("sources schema missing retrieval contract: %s", schemas["sources.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["research_coverage.schema.json"], `"source_count"`) || !strings.Contains(schemas["research_coverage.schema.json"], `"topic_only_rationale"`) {
|
||||
t.Fatalf("research coverage schema missing coverage fields: %s", schemas["research_coverage.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"source_refs"`) {
|
||||
t.Fatalf("slide content schema missing source_refs: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"minItems"`) {
|
||||
t.Fatalf("slide content schema missing non-empty source_refs contract: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"visuals"`) {
|
||||
t.Fatalf("slide content schema missing visuals: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["deck.schema.json"], `"visual_role"`) || !strings.Contains(schemas["deck.schema.json"], `"visual_intent"`) {
|
||||
t.Fatalf("deck schema missing visual role fields: %s", schemas["deck.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["deck.schema.json"], `"layout_family"`) || !strings.Contains(schemas["deck.schema.json"], `"fusion_candidate"`) {
|
||||
t.Fatalf("deck schema missing visual family fields: %s", schemas["deck.schema.json"])
|
||||
}
|
||||
if strings.TrimSpace(schemas["visual_receipts.schema.json"]) == "" || strings.TrimSpace(schemas["creative_quality.schema.json"]) == "" {
|
||||
t.Fatalf("visual quality schemas are missing: receipts=%q creative=%q", schemas["visual_receipts.schema.json"], schemas["creative_quality.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_copy_plan.schema.json"], `"audience_copy"`) || !strings.Contains(schemas["slide_copy_plan.schema.json"], `"production_instruction"`) {
|
||||
t.Fatalf("slide copy plan schema missing audience_copy/production_instruction: %s", schemas["slide_copy_plan.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], `"slide_id"`) {
|
||||
t.Fatalf("assets schema missing slide_id: %s", schemas["assets_plan.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["assets_manifest.schema.json"], `"local_path"`) || !strings.Contains(schemas["assets_manifest.schema.json"], `"source_url"`) {
|
||||
t.Fatalf("assets manifest schema missing local_path/source_url: %s", schemas["assets_manifest.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["asset_inventory.schema.json"], `"large_ok"`) || !strings.Contains(schemas["asset_inventory.schema.json"], `"full_bleed_ok"`) {
|
||||
t.Fatalf("asset inventory schema missing image suitability fields: %s", schemas["asset_inventory.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["chart_manifest.schema.json"], `"vega-lite"`) || !strings.Contains(schemas["chart_manifest.schema.json"], `"spec_path"`) {
|
||||
t.Fatalf("chart manifest schema missing Vega-Lite fields: %s", schemas["chart_manifest.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["typography_contract.schema.json"], `"display"`) || !strings.Contains(schemas["typography_contract.schema.json"], `"number"`) {
|
||||
t.Fatalf("typography contract schema missing font roles: %s", schemas["typography_contract.schema.json"])
|
||||
}
|
||||
for _, want := range []string{`"experiment_unrestricted_assets"`, `"chart"`, `"table"`, `"crop"`, `"deferred"`} {
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], want) {
|
||||
t.Fatalf("assets schema missing %s: %s", want, schemas["assets_plan.schema.json"])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["quality.schema.json"], `"metrics"`) {
|
||||
t.Fatalf("quality schema missing metrics: %s", schemas["quality.schema.json"])
|
||||
}
|
||||
for _, want := range []string{`"strong_cover"`, `"evidence_page_max_visuals"`, `"repeated_layout_ratio_bp"`, `"visual_role_coverage_bp"`, `"real_image_assets"`, `"vega_lite_spec_assets"`, `"typography_contract_present"`} {
|
||||
if !strings.Contains(schemas["quality.schema.json"], want) {
|
||||
t.Fatalf("quality schema missing %s: %s", want, schemas["quality.schema.json"])
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"cover_requires_real_hero_image"`, `"required_chart_renderer"`, `"typography_contract_required"`} {
|
||||
if !strings.Contains(schemas["entity_resolution.schema.json"], want) {
|
||||
t.Fatalf("entity resolution schema missing %s: %s", want, schemas["entity_resolution.schema.json"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteResearchRejectsCoverageSourceIDsNotInSources(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"给阿嬷的情书","type":"film"},"queries":[{"query":"给阿嬷的情书 电影","purpose":"entity_disambiguation"}],"sources":[{"id":"missing","title":"Missing","url":"https://example.com/missing","retrieved_at":"2026-07-04T00:00:00Z","usage":"identity","status":"retrieved"}],"coverage":{"identity_confirmed":true,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":""}}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageResearch)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown coverage source id to block research completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "research_coverage") || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("error = %v, want research_coverage missing source id", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteResearchRejectsCoverageEntityTypeDowngrade(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"context","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"给阿嬷的情书","type":"topic"},"queries":[{"query":"给阿嬷的情书","purpose":"context"}],"sources":[{"id":"s1","title":"Example","url":"https://example.com","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":"伪装成开放主题以跳过 identity source。"}}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageResearch)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected research coverage entity type downgrade to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "entity type") || !strings.Contains(err.Error(), "request/entity_resolution.json") {
|
||||
t.Fatalf("error = %v, want entity type mismatch against request/entity_resolution.json", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSlideContentRejectsUnknownSourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# slides\n")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Claim","body":"Body","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Plan","source_refs":["missing"],"visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageSlideContent)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown slide source_refs to block slide_content completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "source_refs") || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("error = %v, want source_refs missing source id", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlideCopyPlanRejectsProductionInstructionInAudienceCopy(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# slides\n")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"产品页必须让眼镜完整出现","body":"Claim","labels":[]},"production_instruction":{"layout":"图片要完整,不要裁切","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageSlideContent)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected production instruction leakage in audience_copy to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "slide_copy_plan") || !strings.Contains(err.Error(), "production_instruction") {
|
||||
t.Fatalf("error = %v, want slide_copy_plan production_instruction rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSlideContentRejectsEmptySourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# slides\n")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Claim","body":"Body","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Plan","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageSlideContent)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected empty source_refs to block slide_content completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "source_refs") || (!strings.Contains(err.Error(), "empty") && !strings.Contains(err.Error(), "0 items")) {
|
||||
t.Fatalf("error = %v, want empty source_refs rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAssetsRejectsManifestAssetMissingInventoryEntry(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.png","usage":"Hero","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.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageAssets)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected manifest asset without inventory entry to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "asset_inventory") || !strings.Contains(err.Error(), "hero") {
|
||||
t.Fatalf("error = %v, want asset_inventory missing hero rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validSchemaDeckJSON(path string) string {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageOutline) + `,"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","layout_family":"full_bleed_hero","layout_archetype":"full_bleed_photo_title","layout_signature":"full_bleed_poster","story_function":"hook","primary_asset_role":"topic anchor","fusion_candidate":false,"path":"` + path + `"}]}`
|
||||
}
|
||||
|
||||
func validQualityReportJSON() string {
|
||||
return `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":1,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsNonCanonicalIntegers(t *testing.T) {
|
||||
for _, pages := range []string{"8.0", "8e0", "0.99999999999999999"} {
|
||||
t.Run(pages, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
raw := `{"title":"Demo","input":"source.md","pages":` + pages + `}`
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "pages") {
|
||||
t.Fatalf("error = %v, want path and pages field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidCurrentStageOutputSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "source_manifest.json"), []byte(`{"sources":[{"path":"source.md","type":"remote"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "request.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
228
internal/svglide/semantic_contract_test.go
Normal file
228
internal/svglide/semantic_contract_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPromptManifestIncludesSemanticContract(t *testing.T) {
|
||||
manifest := DefaultPromptManifest()
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Name == "anygen_semantic_contract" {
|
||||
if entry.Path != "skills/lark-slides/references/anygen-svg/semantic_contract.md" {
|
||||
t.Fatalf("semantic contract path = %q, want semantic_contract.md", entry.Path)
|
||||
}
|
||||
if !entry.Always {
|
||||
t.Fatalf("semantic contract entry = %+v, want always in prompt context", entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("prompt manifest missing anygen_semantic_contract: %+v", manifest.Entries)
|
||||
}
|
||||
|
||||
func TestDefaultPromptManifestIncludesVisualQualityOverlay(t *testing.T) {
|
||||
manifest := DefaultPromptManifest()
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Name != "svglide_visual_quality_overlay" {
|
||||
continue
|
||||
}
|
||||
if entry.Path != "skills/lark-slides/references/anygen-svg/svglide_visual_quality_overlay.md" {
|
||||
t.Fatalf("visual overlay path = %q, want svglide_visual_quality_overlay.md", entry.Path)
|
||||
}
|
||||
if !entry.Always || entry.Role != "runtime_binding" {
|
||||
t.Fatalf("visual overlay entry = %+v, want always runtime_binding", entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("prompt manifest missing svglide_visual_quality_overlay: %+v", manifest.Entries)
|
||||
}
|
||||
|
||||
func TestSemanticContractRejectsUnknownRuleField(t *testing.T) {
|
||||
path := writeSemanticContractFixture(t, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
rules:
|
||||
- id: bad_rule
|
||||
kind: artifact_exists
|
||||
artifact: outline/deck.json
|
||||
severity: error
|
||||
unknown_field: should_fail
|
||||
---
|
||||
# bad
|
||||
`)
|
||||
_, err := LoadSemanticContractFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown rule field to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown_field") {
|
||||
t.Fatalf("error = %v, want unknown_field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticContractRejectsRuleMissingIDKindOrSeverity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rule string
|
||||
want string
|
||||
}{
|
||||
{name: "id", rule: "kind: artifact_exists\n artifact: outline/deck.json\n severity: error", want: "missing id"},
|
||||
{name: "kind", rule: "id: missing_kind\n artifact: outline/deck.json\n severity: error", want: "missing kind"},
|
||||
{name: "severity", rule: "id: missing_severity\n kind: artifact_exists\n artifact: outline/deck.json", want: "missing severity"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := writeSemanticContractFixture(t, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
rules:
|
||||
- `+tc.rule+`
|
||||
---
|
||||
# bad
|
||||
`)
|
||||
_, err := LoadSemanticContractFile(path)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s to be rejected", tc.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticContractRejectsUnsupportedSeverity(t *testing.T) {
|
||||
path := writeSemanticContractFixture(t, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
rules:
|
||||
- id: bad_severity
|
||||
kind: artifact_exists
|
||||
artifact: outline/deck.json
|
||||
severity: errror
|
||||
---
|
||||
# bad
|
||||
`)
|
||||
_, err := LoadSemanticContractFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported severity to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported severity") {
|
||||
t.Fatalf("error = %v, want unsupported severity", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportIncludesMetrics(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","notes":"Speaker note","source_refs":["s1"],"visuals":[{"id":"hero","type":"image","instruction":"Use hero"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "images", "hero.png"), "png")
|
||||
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">`+fontTokenStyleForTest()+`<slide:note>Speaker note</slide:note><image slide:role="image" href="../assets/images/hero.png"/><text x="1" y="1">Claim</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Metrics.SlideCount != 1 || report.Metrics.ImageCount != 1 || report.Metrics.NoteCount != 1 || report.Metrics.SourceRefCount != 1 {
|
||||
t.Fatalf("metrics = %+v, want slide/image/note/source counts", report.Metrics)
|
||||
}
|
||||
if report.Metrics.MissingAssetCount != 0 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 0", report.Metrics.MissingAssetCount)
|
||||
}
|
||||
if report.Metrics.VisibleLeakCount != 0 || report.Metrics.FontTokenCount != 4 || report.Metrics.MissingFontTokenCount != 0 {
|
||||
t.Fatalf("metrics = %+v, want no visible leaks and all font tokens", report.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsRemoteReadyImageForLocalProfile(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"hero","type":"image","instruction":"Use hero"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
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">`+fontTokenStyleForTest()+`<image slide:role="image" href="https://example.com/hero.png"/><text x="1" y="1">Claim</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if !semanticFindingsContain(report.Findings, "local_svg_deck ready image asset path") {
|
||||
t.Fatalf("findings = %+v, want local_svg_deck remote ready asset rejection", report.Findings)
|
||||
}
|
||||
if report.Metrics.MissingAssetCount != 1 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 1 for remote ready asset in local profile", report.Metrics.MissingAssetCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsVisibleInstructionLeak(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[],"no_image_reason":"Text-only deck"}`)
|
||||
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">`+fontTokenStyleForTest()+`<text x="1" y="1">Sources: https://example.com</text><text x="1" y="40">产品页必须让眼镜完整出现</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if !semanticFindingsContain(report.Findings, "visible leak") {
|
||||
t.Fatalf("findings = %+v, want visible leak finding", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsMissingFontTokens(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[],"no_image_reason":"Text-only deck"}`)
|
||||
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"><text x="1" y="1">Claim</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if !semanticFindingsContain(report.Findings, "font token") {
|
||||
t.Fatalf("findings = %+v, want font token finding", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFindingsContain(findings []SemanticFinding, needle string) bool {
|
||||
for _, finding := range findings {
|
||||
if strings.Contains(finding.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func promptManifestHasSemanticContract() bool {
|
||||
for _, entry := range DefaultPromptManifest().Entries {
|
||||
if entry.Name == "anygen_semantic_contract" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeSemanticContractFixture(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "semantic_contract.md")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
161
internal/svglide/stage.go
Normal file
161
internal/svglide/stage.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StageReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
func CompleteCurrentStage(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
index, stage, err := currentStageWithIndex(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
outputsForMissing := stage.Outputs
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
outputsForMissing = outputsWithoutDeliveryReceipt(stage.Outputs)
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, outputsForMissing)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if len(missingOutputs) > 0 {
|
||||
return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", "))
|
||||
}
|
||||
|
||||
promptReceipt, err := ValidatePromptContextForStage(safeRoot, stage.Name, run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := ValidateToolCallReceiptsForStage(safeRoot, stage.Name, run, promptReceipt); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := ValidateArtifactPromptContractForStage(safeRoot, stage.Name, stage.Outputs); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := ValidateStageOutputs(root); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
semantic, err := EvaluateAnyGenSemantics(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if semantic.Status != "passed" {
|
||||
return StatusReport{}, fmt.Errorf("semantic_gate_failed: %s status is %q, want passed", anyGenSemanticReportPath, semantic.Status)
|
||||
}
|
||||
if err := validateFinalStageReceiptsPassed(safeRoot); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
deliveryRun := run
|
||||
deliveryRun.Stages[index].Status = StatusDone
|
||||
deliveryRun.CurrentStage = stage.Name
|
||||
if _, err := writeDeliveryReceipt(safeRoot, deliveryRun); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, deliveryReceiptPath, stageOutputSchemaPaths[deliveryReceiptPath]); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: stage.Name,
|
||||
Status: StatusDone,
|
||||
Artifacts: stage.Outputs,
|
||||
}); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
|
||||
run.Stages[index].Status = StatusDone
|
||||
if index < len(run.Stages)-1 {
|
||||
nextStage := &run.Stages[index+1]
|
||||
run.CurrentStage = nextStage.Name
|
||||
if nextStage.Status == "" {
|
||||
nextStage.Status = StatusPending
|
||||
}
|
||||
} else {
|
||||
run.CurrentStage = stage.Name
|
||||
}
|
||||
run.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
if err := writeRunFile(safeRoot, run); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return InspectStatus(root)
|
||||
}
|
||||
|
||||
func outputsWithoutDeliveryReceipt(outputs []string) []string {
|
||||
filtered := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
if output == deliveryReceiptPath {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, output)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
type stageStatusReceipt struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func validateFinalStageReceiptsPassed(safeRoot string) error {
|
||||
for _, path := range []string{"receipts/lint.json", "receipts/preview.json", renderedVisualReceiptPath, "quality_report.json", "anygen_semantic_report.json", creativeQualityReportPath} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read receipt: %w", path, err)
|
||||
}
|
||||
var receipt stageStatusReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", path, err)
|
||||
}
|
||||
if receipt.Status != "passed" {
|
||||
return fmt.Errorf("%s: status is %q, want passed", path, receipt.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentStageWithIndex(run Run) (int, Stage, error) {
|
||||
for i, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return i, stage, nil
|
||||
}
|
||||
}
|
||||
return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func writeRunFile(safeRoot string, run Run) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, "run.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, run)
|
||||
}
|
||||
|
||||
func writeStageReceipt(safeRoot string, receipt StageReceipt) error {
|
||||
if strings.TrimSpace(receipt.Stage) == "" {
|
||||
return fmt.Errorf("stage receipt stage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." {
|
||||
return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, receipt)
|
||||
}
|
||||
749
internal/svglide/stage_test.go
Normal file
749
internal/svglide/stage_test.go
Normal file
@@ -0,0 +1,749 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got != StatusDone {
|
||||
t.Fatalf("request stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequestResolution); got != StatusPending {
|
||||
t.Fatalf("request_resolution stage status = %q, want %q", got, StatusPending)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt StageReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt.Stage != StageRequest || receipt.Status != StatusDone {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionAdvancesToResearch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if got := stageStatus(t, run, StageRequestResolution); got != StatusDone {
|
||||
t.Fatalf("request_resolution stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionRejectsLowConfidenceRealEntity(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 6900, "medium", "resolved", ""))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected low confidence real entity to block before research")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "confidence_bp") {
|
||||
t.Fatalf("error = %v, want confidence_bp", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionRejectsAmbiguityEvenWithQuestion(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "needs_clarification", "你指的是哪一部电影?"))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguity to block before research")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "needs_clarification") {
|
||||
t.Fatalf("error = %v, want needs_clarification", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionAcceptsTopicOnlyWithResearchRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("topic", 5000, "medium", "resolved", ""))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing output error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected receipt write error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedValidatePreviewRepairReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"failed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"failed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":false}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":1,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWriteDeliveryReceiptForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed lint/preview receipts to block completion")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedQualityReport(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"failed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWriteDeliveryReceiptForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed quality report to block completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") && !strings.Contains(err.Error(), "status is \"failed\"") {
|
||||
t.Fatalf("error = %v, want quality report failure", err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteFinalStageRecomputesSemanticReport(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Image Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Image hook","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"file:///tmp/secret.png","usage":"Hero image","status":"ready"}]}`)
|
||||
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 960 540"><rect width="960" height="540" fill="#fff"/><image slide:role="image" href="file:///tmp/secret.png" x="40" y="40" width="320" height="180"/><text x="48" y="260">Claim</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":1,"slides_with_source_refs":1,"slides_with_visuals":1,"slides_with_image_assets":1,"image_coverage_bp":10000,"unique_image_assets":1,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWriteDeliveryReceiptForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected final complete to recompute semantic report and reject forged passed report")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "semantic_gate_failed") {
|
||||
t.Fatalf("error = %v, want semantic_gate_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteFinalStageRegeneratesDeliveryReceipt(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/receipts/delivery.json", `{"status":"ready","route_profile":"stale","orchestrator":"stale","runtime_binding":"stale","deck":"outline/deck.json","slides_dir":"slides","slides":["slides/01.svg"],"preview":{"path":"stale.html","status":"passed","missing_asset_count":0},"quality_report":"quality_report.json","anygen_semantic_report":"anygen_semantic_report.json","visual_receipts":"visual_receipts.json","creative_quality_report":"creative_quality_report.json","semantic_metrics":{"slide_count":1,"slides_with_slide_role":1,"image_count":0,"text_count":1,"note_count":0,"source_ref_count":0,"missing_asset_count":0,"slides_without_source_refs":0,"visible_leak_count":0,"font_token_count":4,"missing_font_token_count":0},"stage_status":{},"legacy_runtime_executed":false,"legacy_tool_ids":[],"legacy_artifact_matches":[],"core_prompt_ids":[],"observed_prompt_ids":[],"blocked_prompt_ids":[]}`)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("final completion should regenerate delivery receipt: %v", err)
|
||||
}
|
||||
if status.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("CurrentStage = %q, want final stage", status.CurrentStage)
|
||||
}
|
||||
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.RouteProfile != RouteProfileLocalSVGDeck || delivery.RuntimeBinding != "svglide_local_runtime_binding" || delivery.Preview.Path != "preview.html" {
|
||||
t.Fatalf("delivery was not regenerated from current run: %+v", delivery)
|
||||
}
|
||||
if delivery.StageStatus[StageValidatePreviewRepair] != StatusDone {
|
||||
t.Fatalf("delivery stage_status = %+v, want final stage done", delivery.StageStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteFinalStageRejectsLegacyRunArtifactEvidence(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/legacy/project.slides", "legacy project marker")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected legacy run artifact evidence to block final completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "legacy runtime") || !strings.Contains(err.Error(), "project.slides") {
|
||||
t.Fatalf("error = %v, want legacy runtime evidence for project.slides", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsMissingPromptContext(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_prompt_context to reject completing design_brief before next")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_prompt_context") && !strings.Contains(err.Error(), "prompt context") {
|
||||
t.Fatalf("error = %v, want missing_prompt_context", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageDesignBrief {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageDesignBrief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsStalePromptHash(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
writePromptContextReceiptForTest(t, StageDesignBrief, map[string]string{
|
||||
"mode_system_prompt_svg": "sha256:stale",
|
||||
"svg_reference": "sha256:stale",
|
||||
"resolve_design_brief": "sha256:stale",
|
||||
})
|
||||
writeToolCallReceiptForTest(t, StageDesignBrief, "resolve_design_brief")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected stale_prompt_context to reject changed prompt hashes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stale_prompt_context") && !strings.Contains(err.Error(), "prompt hash") {
|
||||
t.Fatalf("error = %v, want stale_prompt_context", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageDesignBrief {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageDesignBrief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsMissingRequiredToolCallReceipt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
writePromptContextReceiptForTest(t, StageDesignBrief, map[string]string{
|
||||
"mode_system_prompt_svg": "",
|
||||
"svg_reference": "",
|
||||
"resolve_design_brief": "",
|
||||
})
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_tool_call to reject design_brief without resolve_design_brief receipt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_tool_call") && !strings.Contains(err.Error(), "resolve_design_brief") {
|
||||
t.Fatalf("error = %v, want missing_tool_call for resolve_design_brief", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageDesignBrief {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageDesignBrief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsWrongToolCallContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
writePromptContextReceiptForTest(t, StageDesignBrief, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageDesignBrief, "resolve_design_brief")
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "tool_calls", StageDesignBrief, "resolve_design_brief.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt["condition"] = "wrong_condition"
|
||||
receipt["cardinality"] = "zero_or_more"
|
||||
receipt["consumed"] = []string{"request/request.json"}
|
||||
updated, err := json.MarshalIndent(receipt, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", StageDesignBrief, "resolve_design_brief.json"), string(append(updated, '\n')))
|
||||
|
||||
_, err = CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected wrong tool call receipt contract to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "receipt contract mismatch") && !strings.Contains(err.Error(), "consumed artifacts") {
|
||||
t.Fatalf("error = %v, want tool receipt contract rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsForgedEmptyPromptContext(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"stage": StageDesignBrief,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"agent_task": map[string]any{"stage": StageDesignBrief},
|
||||
"prompt_contract": map[string]any{"stage": StageDesignBrief},
|
||||
"tool_invocation_contract": map[string]any{"required_calls": []any{}},
|
||||
"asset_hashes": map[string]string{},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", StageDesignBrief+".json"), string(append(raw, '\n')))
|
||||
|
||||
_, err = CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected forged empty prompt context to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_prompt_context_asset") {
|
||||
t.Fatalf("error = %v, want missing_prompt_context_asset", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRecomputesConditionalCustomShapeBBox(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"Custom path","summary":"Custom summary","role":"cover","key_message":"Custom key","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
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 960 540"><path slide:role="shape" slide:shape-type="custom" d="M 10 10 L 120 10 L 120 80 Z"/><text x="48" y="160">Custom</text></svg>`)
|
||||
writePromptContextReceiptForTest(t, StageSVGAuthor, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "activate_slides_edit")
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected custom path SVG to require compute_custom_shape_bbox receipt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_tool_call") || !strings.Contains(err.Error(), "compute_custom_shape_bbox") {
|
||||
t.Fatalf("error = %v, want missing compute_custom_shape_bbox tool call", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteDoesNotRequireCustomShapeBBoxForPlainSVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"Plain","summary":"Plain summary","role":"cover","key_message":"Plain key","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
writePromptContextReceiptForTest(t, StageSVGAuthor, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "activate_slides_edit")
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "slides_edit")
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("plain SVG should not require compute_custom_shape_bbox: %v", err)
|
||||
}
|
||||
if status.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSVGDeckDoesNotTriggerLegacyPPTXTools(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Input = "source.pptx"
|
||||
run.Intent.Input = "source.pptx"
|
||||
run.RouteProfile = RouteProfileLocalSVGDeck
|
||||
|
||||
calls, err := TriggeredConditionalToolCalls(StageResearch, run, "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, call := range calls {
|
||||
if call.ID == "slides_convert" || call.ID == "slides_parse_template" {
|
||||
t.Fatalf("local SVG deck triggered legacy call %+v", call)
|
||||
}
|
||||
}
|
||||
|
||||
assets, err := PromptAssetsForProfileStage(RouteProfileLocalSVGDeck, StageResearch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if asset.ID == "slides_convert" || asset.ID == "slides_parse_template" {
|
||||
t.Fatalf("local SVG deck exposed legacy prompt %+v", asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePromptContextRejectsProfileDisallowedPrompt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.RouteProfile = RouteProfileLocalSVGDeck
|
||||
writeStatusTestRunFile(t, run)
|
||||
writePromptContextReceiptForTest(t, StageResearch, map[string]string{})
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "prompt_context", StageResearch+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PromptContextReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyID := "slides_convert"
|
||||
receipt.AgentTask.PromptContext.Assets = append(receipt.AgentTask.PromptContext.Assets, PromptContextAsset{
|
||||
ID: legacyID,
|
||||
Role: "tool_prompt",
|
||||
Path: promptPathByID(legacyID),
|
||||
SHA256: promptAssetSHA(promptPathByID(legacyID)),
|
||||
Required: false,
|
||||
})
|
||||
updated, err := json.MarshalIndent(receipt, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", StageResearch+".json"), string(append(updated, '\n')))
|
||||
|
||||
safeRoot, run, err := readRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ValidatePromptContextForStage(safeRoot, StageResearch, run)
|
||||
if err == nil {
|
||||
t.Fatal("expected local prompt context to reject profile-disallowed legacy prompt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), legacyID) || !strings.Contains(err.Error(), "not allowed") {
|
||||
t.Fatalf("error = %v, want disallowed legacy prompt %q", err, legacyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedPPTXProfileTriggersSlidesConvert(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Input = "source.pptx"
|
||||
run.Intent.Input = "source.pptx"
|
||||
run.RouteProfile = routeProfileImportedPPTX
|
||||
|
||||
calls, err := TriggeredConditionalToolCalls(StageResearch, run, "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !toolCallsContain(calls, "slides_convert") {
|
||||
t.Fatalf("imported_pptx calls = %+v, want slides_convert", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func stageStatus(t *testing.T, run Run, name string) string {
|
||||
t.Helper()
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == name {
|
||||
return stage.Status
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeValidDesignBriefOutputs(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"narrative_spine":{},"depth":{},"tone":"clear","visual_system":{"color_system":{},"typography":{},"layout_language":{}},"deck_visual_system":{"visual_keywords":["editorial"],"palette":{},"fonts":{"font_display":"Noto Serif CJK SC","font_body":"Noto Sans CJK SC","font_number":"Roboto Mono","font_label":"PingFang SC"},"page_family_budget":{},"asset_strategy":{}}}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"color_system":{},"typography":{},"layout_language":{}}`)
|
||||
mustWriteTestFile(t, "demo/brief/typography_contract.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"profile":"editorial_report","roles":{"display":{"family":"Noto Serif CJK SC","weight":"700","size":"42","usage":"cover and section titles"},"body":{"family":"Noto Sans CJK SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Roboto Mono","weight":"700","size":"34","usage":"financial figures"},"label":{"family":"PingFang SC","weight":"600","size":"13","usage":"labels and captions"}},"rules":["Use concrete font roles; do not fall back to generic browser stacks."]}`)
|
||||
}
|
||||
|
||||
func validEntityResolutionJSON(entityType string, confidenceBP int, confidenceBand string, ambiguityStatus string, clarificationQuestion string) string {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageRequestResolution) + `,"input_text":"给阿嬷的情书","resolved_entity":{"name":"给阿嬷的情书","type":"` + entityType + `","confidence_bp":` + strconv.Itoa(confidenceBP) + `,"confidence_band":"` + confidenceBand + `","reason":"从用户请求识别出的生成对象"},"ambiguity":{"status":"` + ambiguityStatus + `","candidates":[]},"research_required":true,"clarification_question":"` + clarificationQuestion + `"}`
|
||||
}
|
||||
|
||||
func promptContractJSON(stage string) string {
|
||||
return `{"protocol":"anygen-svg-slides","stage":"` + stage + `","context_receipt":"receipts/prompt_context/` + stage + `.json","orchestrator":"mode_system_prompt_svg","protocol_reference":"svg_reference","required_prompt_ids":["mode_system_prompt_svg","svg_reference"]}`
|
||||
}
|
||||
|
||||
func writePromptContextReceiptForTest(t *testing.T, stage string, hashes map[string]string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
contract, err := RequiredPromptContractForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, err := promptContextForPromptContract(contract)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assetHashes := map[string]string{}
|
||||
for _, asset := range context.Assets {
|
||||
if asset.Required {
|
||||
assetHashes[asset.ID] = asset.SHA256
|
||||
}
|
||||
}
|
||||
for id, hash := range hashes {
|
||||
if hash == "" {
|
||||
if asset, ok := promptContextAssetForTest(context, id); ok {
|
||||
hash = asset.SHA256
|
||||
} else {
|
||||
hash = promptAssetSHA(promptPathByID(id))
|
||||
}
|
||||
}
|
||||
assetHashes[id] = hash
|
||||
}
|
||||
requiredCalls, err := RequiredToolCallsForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"stage": stage,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"agent_task": map[string]any{"stage": stage, "prompt_context": context},
|
||||
"prompt_contract": contract,
|
||||
"asset_hashes": assetHashes,
|
||||
"tool_invocation_contract": map[string]any{
|
||||
"protocol": "anygen-svg-slides",
|
||||
"required_calls": requiredCalls,
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", stage+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func writeToolCallReceiptForTest(t *testing.T, stage string, callID string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
calls, err := RequiredToolCallsForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var call ToolCallRequirement
|
||||
found := false
|
||||
for _, candidate := range calls {
|
||||
if candidate.ID == callID {
|
||||
call = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing required call %q for stage %q", callID, stage)
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"protocol": "anygen-svg-slides",
|
||||
"stage": stage,
|
||||
"call_id": callID,
|
||||
"prompt_id": call.PromptID,
|
||||
"invocation": call.Invocation,
|
||||
"condition": call.Condition,
|
||||
"condition_matched": true,
|
||||
"order": call.Order,
|
||||
"cardinality": call.Cardinality,
|
||||
"consumed": call.Consumes,
|
||||
"produced": call.Produces,
|
||||
"status": "done",
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", stage, callID+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func writePromptContextReceiptWithoutToolCallsForTest(t *testing.T, stage string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
contract, err := RequiredPromptContractForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, err := promptContextForPromptContract(contract)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assetHashes := map[string]string{}
|
||||
for _, asset := range context.Assets {
|
||||
if asset.Required {
|
||||
assetHashes[asset.ID] = asset.SHA256
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"stage": stage,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"agent_task": map[string]any{"stage": stage, "prompt_context": context},
|
||||
"prompt_contract": contract,
|
||||
"tool_invocation_contract": map[string]any{"required_calls": []any{}, "conditional_calls": []any{}},
|
||||
"asset_hashes": assetHashes,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", stage+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func promptContextAssetForTest(context PromptContext, id string) (PromptContextAsset, bool) {
|
||||
for _, asset := range context.Assets {
|
||||
if asset.ID == id {
|
||||
return asset, true
|
||||
}
|
||||
}
|
||||
return PromptContextAsset{}, false
|
||||
}
|
||||
|
||||
func toolCallsContain(calls []ToolCallRequirement, id string) bool {
|
||||
for _, call := range calls {
|
||||
if call.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustWritePassedSemanticReportForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/anygen_semantic_report.json", `{"status":"passed","contract":{"id":"anygen_semantic_contract","role":"semantic_contract","path":"skills/lark-slides/references/anygen-svg/semantic_contract.md","sha256":"test","rules":1},"metrics":{"slide_count":1,"slides_with_slide_role":1,"image_count":0,"text_count":1,"note_count":0,"source_ref_count":0,"missing_asset_count":0,"slides_without_source_refs":0,"visible_leak_count":0,"font_token_count":4,"missing_font_token_count":0},"findings":[]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedRenderedVisualForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/rendered_visual.json", `{"status":"passed","metrics":{"slides":1,"issue_count":0,"out_of_canvas_count":0,"text_overflow_count":0,"text_collision_count":0,"unsafe_edge_count":0},"issues":[],"slides":[{"path":"slides/01.svg","status":"passed","issue_count":0}]}`)
|
||||
}
|
||||
|
||||
func mustWriteBasicVisualReceiptsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"single_claim_poster","thumbnail_job":"readable title","visual_center":"title block","topic_fit_claim":"matches demo topic","information_density_plan":"one claim with support","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"quiet synthesis","data_visual_rationale":"","source_evidence":["web1 supports claim"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedCreativeReportForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/creative_quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"visual_receipts":1,"missing_visual_receipts":0,"process_leak_count":0,"generic_font_slide_count":0,"distinct_layout_family_count":1,"distinct_layout_archetype_count":1,"layout_archetype_max_ratio_bp":10000,"adjacent_layout_archetype_count":0,"left_right_chart_archetype_count":0,"layout_signature_max_ratio_bp":10000,"adjacent_layout_repetition_count":0,"fusion_slide_count":0,"fusion_adjacent_count":0,"weak_slide_count":0,"chart_without_evidence_count":0,"warning_count":0}}`)
|
||||
}
|
||||
|
||||
func mustWriteDeliveryReceiptForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/delivery.json", `{"status":"ready","route_profile":"local_svg_deck","orchestrator":"mode_system_prompt_svg","runtime_binding":"svglide_local_runtime_binding","deck":"outline/deck.json","slides_dir":"slides","slides":["slides/01.svg"],"preview":{"path":"preview.html","status":"passed","missing_asset_count":0},"quality_report":"quality_report.json","anygen_semantic_report":"anygen_semantic_report.json","visual_receipts":"visual_receipts.json","creative_quality_report":"creative_quality_report.json","semantic_metrics":{"slide_count":1,"slides_with_slide_role":1,"image_count":0,"text_count":1,"note_count":0,"source_ref_count":0,"missing_asset_count":0,"slides_without_source_refs":0,"visible_leak_count":0,"font_token_count":4,"missing_font_token_count":0},"stage_status":{"validate_preview_repair":"pending"},"legacy_runtime_executed":false,"legacy_tool_ids":[],"legacy_artifact_matches":[],"core_prompt_ids":["mode_system_prompt_svg","svg_reference","svglide_local_runtime_binding"],"observed_prompt_ids":[],"blocked_prompt_ids":["slides_convert","slides_parse_template"]}`)
|
||||
}
|
||||
|
||||
func writePassingFinalStageArtifactsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Final Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Opening key","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"single_claim_poster","story_function":"hook","primary_asset_role":"none","fusion_candidate":false,"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":"Opening point","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only deck; no image assets required"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":1,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
}
|
||||
402
internal/svglide/status.go
Normal file
402
internal/svglide/status.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
CurrentStage string `json:"current_stage"`
|
||||
MissingInputs []string `json:"missing_inputs"`
|
||||
MissingOutputs []string `json:"missing_outputs"`
|
||||
NextCommand string `json:"next_command"`
|
||||
}
|
||||
|
||||
type NextTaskReport struct {
|
||||
Stage string `json:"stage"`
|
||||
Mode string `json:"mode"`
|
||||
Protocol string `json:"protocol"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
BlockingOwner string `json:"blocking_owner"`
|
||||
BlockingReason string `json:"blocking_reason,omitempty"`
|
||||
PromptPath string `json:"prompt_path,omitempty"`
|
||||
PromptPaths []string `json:"prompt_paths,omitempty"`
|
||||
AdapterPaths []string `json:"adapter_paths"`
|
||||
PromptManifest string `json:"prompt_manifest"`
|
||||
PromptContext string `json:"prompt_context"`
|
||||
PromptContract StagePromptContract `json:"prompt_contract"`
|
||||
ToolInvocationContract ToolInvocationContract `json:"tool_invocation_contract"`
|
||||
AgentTask AgentTask `json:"agent_task"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
}
|
||||
|
||||
const (
|
||||
createSVGlideAdapterPath = "skills/lark-slides/references/lark-slides-create-svglide.md"
|
||||
svglideExecutionMode = "execution"
|
||||
svglideBlockingOwner = "svglide-runtime"
|
||||
)
|
||||
|
||||
func ReadRun(root string) (Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return readRunFile(safeRoot)
|
||||
}
|
||||
|
||||
func InspectStatus(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
nextAction := "next"
|
||||
if len(missingOutputs) == 0 {
|
||||
nextAction = "complete"
|
||||
}
|
||||
return StatusReport{
|
||||
CurrentStage: stage.Name,
|
||||
MissingInputs: missingInputs,
|
||||
MissingOutputs: missingOutputs,
|
||||
NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action %s --run %s", nextAction, shellQuote(root)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NextTask(root string) (NextTaskReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if len(missingInputs) > 0 {
|
||||
return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
|
||||
}
|
||||
inputs, err := validateRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
outputs, err := validateRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
agentTask, promptContract, toolContract, err := BuildAgentTask(stage, run, safeRoot, inputs, outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if err := WritePromptContextReceipt(safeRoot, stage.Name, agentTask, promptContract, toolContract); err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
return NextTaskReport{
|
||||
Stage: stage.Name,
|
||||
Mode: svglideExecutionMode,
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
ApprovalRequired: false,
|
||||
BlockingOwner: svglideBlockingOwner,
|
||||
AdapterPaths: []string{createSVGlideAdapterPath},
|
||||
PromptManifest: "prompt_manifest.json",
|
||||
PromptContext: promptContextReceiptPath(stage.Name),
|
||||
PromptContract: promptContract,
|
||||
ToolInvocationContract: toolContract,
|
||||
AgentTask: agentTask,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readRun(root string) (string, Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
return safeRoot, run, nil
|
||||
}
|
||||
|
||||
func readRunFile(safeRoot string) (Run, error) {
|
||||
raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
return Run{}, fmt.Errorf("read run.json: %w", err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func currentStage(run Run) (Stage, error) {
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return stage, nil
|
||||
}
|
||||
}
|
||||
return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
var missing []string
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
exists, err := runGlobExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
paths := make([]string, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := safeRunPath(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func runGlobExists(safeRoot, rel string) (bool, error) {
|
||||
dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
entries, err := vfs.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := filepath.Match(pattern, entry.Name())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchRel := filepath.Join(dirRel, entry.Name())
|
||||
exists, err := runRegularFileExists(safeRoot, matchRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", "", "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
dirRel, pattern := filepath.Split(cleanRel)
|
||||
dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
|
||||
if pattern == "" {
|
||||
return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
|
||||
}
|
||||
if _, err := filepath.Match(pattern, ""); err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if dirRel == "" {
|
||||
dirRel = "."
|
||||
}
|
||||
if hasGlobMeta(dirRel) {
|
||||
return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
|
||||
}
|
||||
dirPath, err := safeRunPath(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return dirRel, pattern, dirPath, nil
|
||||
}
|
||||
|
||||
func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
if !exists {
|
||||
return path, false, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return path, false, fmt.Errorf("run path %q is not a directory", rel)
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func runRegularFileExists(safeRoot, rel string) (bool, error) {
|
||||
info, _, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
return info.Mode().IsRegular(), nil
|
||||
}
|
||||
|
||||
func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
var info fs.FileInfo
|
||||
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 nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
|
||||
func hasGlobMeta(path string) bool {
|
||||
return strings.ContainsAny(path, "*?[")
|
||||
}
|
||||
|
||||
func safeRunPath(safeRoot, rel string) (string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
|
||||
rootRel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
|
||||
}
|
||||
if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
|
||||
return "", fmt.Errorf("run path %q escapes run root", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isAbsoluteRunPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
drive := path[0]
|
||||
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if value == "" {
|
||||
return "''"
|
||||
}
|
||||
if isShellBareword(value) {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func isShellBareword(value string) bool {
|
||||
for _, r := range value {
|
||||
if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune("_@%+=:,./-", r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
720
internal/svglide/status_test.go
Normal file
720
internal/svglide/status_test.go
Normal file
@@ -0,0 +1,720 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusReportsMissingOutputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
|
||||
}
|
||||
if len(status.MissingInputs) != 0 {
|
||||
t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
|
||||
}
|
||||
if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
|
||||
t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusQuotesNextCommandRunPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
root string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
root: "demo dir",
|
||||
want: "lark-cli slides +create-svglide --action complete --run 'demo dir'",
|
||||
},
|
||||
{
|
||||
root: "demo' dir",
|
||||
want: "lark-cli slides +create-svglide --action complete --run 'demo'\\'' dir'",
|
||||
},
|
||||
{
|
||||
root: "demo trail ",
|
||||
want: "lark-cli slides +create-svglide --action complete --run 'demo trail '",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.root, func(t *testing.T) {
|
||||
cwd := initStatusTestRunAt(t, tt.root)
|
||||
|
||||
status, err := InspectStatus(tt.root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.NextCommand != tt.want {
|
||||
t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
|
||||
}
|
||||
if strings.Contains(status.NextCommand, cwd) {
|
||||
t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskReturnsAnyGenPromptContextAssets(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if next.Stage != StageRequest {
|
||||
t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
|
||||
}
|
||||
if next.PromptManifest != "prompt_manifest.json" {
|
||||
t.Fatalf("PromptManifest = %q, want prompt_manifest.json", next.PromptManifest)
|
||||
}
|
||||
if next.PromptPath != "" {
|
||||
t.Fatalf("PromptPath = %q, want empty deprecated field", next.PromptPath)
|
||||
}
|
||||
if len(next.PromptPaths) != 0 {
|
||||
t.Fatalf("PromptPaths = %v, want omitted legacy top-level field", next.PromptPaths)
|
||||
}
|
||||
got := promptContextAssetPaths(next.AgentTask.PromptContext.Assets)
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("prompt_context assets missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "docs/vendor/anygen-svg/source.full.md") {
|
||||
t.Fatalf("prompt_context assets should not include source snapshot:\n%s", got)
|
||||
}
|
||||
if len(next.Inputs) != 0 {
|
||||
t.Fatalf("Inputs = %v, want empty", next.Inputs)
|
||||
}
|
||||
if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskSeparatesAnyGenPromptsFromRuntimeAdapter(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
if len(next.PromptPaths) != 0 {
|
||||
t.Fatalf("PromptPaths = %v, want omitted legacy top-level field", next.PromptPaths)
|
||||
}
|
||||
gotPrompts := promptContextAssetPaths(next.AgentTask.PromptContext.Assets)
|
||||
if strings.Contains(gotPrompts, "lark-slides-create-svglide.md") {
|
||||
t.Fatalf("prompt_context assets should contain AnyGen assets only, got:\n%s", gotPrompts)
|
||||
}
|
||||
if !strings.Contains(gotPrompts, "skills/lark-slides/references/anygen-svg/README.md") {
|
||||
t.Fatalf("prompt_context assets missing AnyGen README:\n%s", gotPrompts)
|
||||
}
|
||||
if len(next.AdapterPaths) != 1 || next.AdapterPaths[0] != "skills/lark-slides/references/lark-slides-create-svglide.md" {
|
||||
t.Fatalf("AdapterPaths = %#v, want create-svglide adapter", next.AdapterPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptContextIncludesLocalRuntimeVisualFloor(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[]}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"visual_system":{}}`)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
got := promptContextAssetPaths(next.AgentTask.PromptContext.Assets)
|
||||
if !strings.Contains(got, "skills/lark-slides/references/anygen-svg/svglide_local_runtime_binding.md") {
|
||||
t.Fatalf("assets stage prompt context missing runtime binding:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "skills/lark-slides/references/anygen-svg/svglide_visual_quality_overlay.md") {
|
||||
t.Fatalf("assets stage prompt context missing visual quality overlay:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func promptContextAssetPaths(assets []PromptContextAsset) string {
|
||||
paths := make([]string, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
paths = append(paths, asset.Path)
|
||||
}
|
||||
return strings.Join(paths, "\n")
|
||||
}
|
||||
|
||||
func TestNextTaskDeclaresExecutionModeWithoutApprovalGate(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
if next.Mode != "execution" {
|
||||
t.Fatalf("Mode = %q, want execution", next.Mode)
|
||||
}
|
||||
if next.ApprovalRequired {
|
||||
t.Fatalf("ApprovalRequired = true, want false")
|
||||
}
|
||||
if next.BlockingOwner != "svglide-runtime" {
|
||||
t.Fatalf("BlockingOwner = %q, want svglide-runtime", next.BlockingOwner)
|
||||
}
|
||||
if next.BlockingReason != "" {
|
||||
t.Fatalf("BlockingReason = %q, want empty", next.BlockingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskReturnsAgentRuntimeProtocolContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["protocol"] != "anygen-svg-slides" {
|
||||
t.Fatalf("protocol = %v, want anygen-svg-slides in next payload: %+v", payload["protocol"], payload)
|
||||
}
|
||||
agentTask, ok := payload["agent_task"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("next.agent_task missing or invalid: %+v", payload)
|
||||
}
|
||||
if agentTask["stage"] != StageDesignBrief {
|
||||
t.Fatalf("agent_task.stage = %v, want %q", agentTask["stage"], StageDesignBrief)
|
||||
}
|
||||
if agentTask["prompt_context"] == nil {
|
||||
t.Fatalf("agent_task.prompt_context missing: %+v", agentTask)
|
||||
}
|
||||
if payload["prompt_context"] == nil {
|
||||
t.Fatalf("next.prompt_context receipt path missing: %+v", payload)
|
||||
}
|
||||
if _, ok := payload["prompt_contract"].(map[string]any); !ok {
|
||||
t.Fatalf("next.prompt_contract missing or invalid: %+v", payload)
|
||||
}
|
||||
toolContract, ok := payload["tool_invocation_contract"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("next.tool_invocation_contract missing or invalid: %+v", payload)
|
||||
}
|
||||
if !jsonArrayContainsObjectField(toolContract["required_calls"], "id", "resolve_design_brief") {
|
||||
t.Fatalf("required_calls missing resolve_design_brief: %+v", toolContract["required_calls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskWritesPromptContextReceipt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
|
||||
if _, err := NextTask("demo"); err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "prompt_context", StageDesignBrief+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing prompt context receipt for %s: %v", StageDesignBrief, err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid prompt context receipt: %v", err)
|
||||
}
|
||||
if receipt["stage"] != StageDesignBrief || receipt["protocol"] != "anygen-svg-slides" {
|
||||
t.Fatalf("prompt context receipt = %+v, want design_brief anygen protocol", receipt)
|
||||
}
|
||||
if _, ok := receipt["asset_hashes"].(map[string]any); !ok {
|
||||
t.Fatalf("prompt context receipt missing asset_hashes: %+v", receipt)
|
||||
}
|
||||
if receipt["agent_task"] == nil || receipt["tool_invocation_contract"] == nil {
|
||||
t.Fatalf("prompt context receipt missing agent_task/tool_invocation_contract: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskResearchIncludesPPTXConditionalCall(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Input = "source.pptx"
|
||||
run.Intent.Input = "source.pptx"
|
||||
run.RouteProfile = routeProfileImportedPPTX
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
if !toolCallsContainID(next.ToolInvocationContract.ConditionalCalls, "slides_convert") {
|
||||
t.Fatalf("conditional_calls = %+v, want slides_convert for pptx input", next.ToolInvocationContract.ConditionalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskResearchIncludesTemplateConditionalCall(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
run := readStatusTestRunFile(t)
|
||||
run.RouteProfile = routeProfileTemplateReference
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
mustWriteTestFile(t, filepath.Join("demo", "request", "request.json"), `{"title":"Demo","input":"source.md","template":true}`)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
if !toolCallsContainID(next.ToolInvocationContract.ConditionalCalls, "slides_parse_template") {
|
||||
t.Fatalf("conditional_calls = %+v, want slides_parse_template for template request", next.ToolInvocationContract.ConditionalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallsContainID(calls []ToolCallRequirement, id string) bool {
|
||||
for _, call := range calls {
|
||||
if call.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNextTaskCreatesPromptContextReceiptDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts", "prompt_context")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := NextTask("demo"); err != nil {
|
||||
t.Fatalf("NextTask should create receipts/prompt_context as needed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "prompt_context", StageRequest+".json")); err != nil {
|
||||
t.Fatalf("missing request prompt context receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if _, err := InspectStatus("../escape"); err == nil {
|
||||
t.Fatal("expected unsafe run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func jsonArrayContainsObjectField(value any, field string, want string) bool {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if ok && object[field] == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
|
||||
run, err := ReadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
|
||||
if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
|
||||
t.Fatal("expected absolute run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected stat error when output parent is a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
path := filepath.Join("demo", "request", "source_manifest.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageDesignBrief
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing current stage inputs to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing research source manifest to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageOutline
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing outline visual system to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/request.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
|
||||
if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
|
||||
if err := os.WriteFile(outside, []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func initStatusTestRun(t *testing.T) string {
|
||||
return initStatusTestRunAt(t, "demo")
|
||||
}
|
||||
|
||||
func initStatusTestRunAt(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initRoot := root
|
||||
if trimmed := strings.TrimSpace(root); trimmed != root {
|
||||
initRoot = trimmed
|
||||
}
|
||||
if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if initRoot != root {
|
||||
if err := os.Rename(initRoot, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func readStatusTestRunFile(t *testing.T) Run {
|
||||
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)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeStatusTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
|
||||
t.Helper()
|
||||
for i := range run.Stages {
|
||||
if run.Stages[i].Name == stageName {
|
||||
run.Stages[i].Outputs = outputs
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", stageName)
|
||||
}
|
||||
|
||||
func setCurrentStageForStatusTest(t *testing.T, stageName string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = stageName
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func writeDefaultSemanticContractForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, defaultSemanticContractPath, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
invocation: reference
|
||||
rules:
|
||||
- id: no_silent_all_diagram_fallback
|
||||
kind: explicit_reason_required
|
||||
when: deck_has_zero_image_assets
|
||||
artifact: assets/assets_manifest.json
|
||||
field: no_image_reason
|
||||
severity: error
|
||||
- id: image_visual_requires_image_asset
|
||||
kind: visual_asset_type_match
|
||||
visual_type: image
|
||||
asset_type: image
|
||||
severity: error
|
||||
- id: ready_image_and_active_asset_refs_must_render
|
||||
kind: svg_contains_asset_href
|
||||
asset_type: image
|
||||
asset_status: ready
|
||||
svg_selector: '<image slide:role="image"'
|
||||
severity: error
|
||||
---
|
||||
|
||||
# Test Semantic Contract
|
||||
`)
|
||||
}
|
||||
|
||||
func testPromptContractField(stage string) string {
|
||||
return `"prompt_contract":{"protocol":"anygen-svg-slides","stage":"` + stage + `","orchestrator":"mode_system_prompt_svg","protocol_reference":"svg_reference","required_prompt_ids":["mode_system_prompt_svg","svg_reference"]}`
|
||||
}
|
||||
45
internal/svglide/typography_contract.go
Normal file
45
internal/svglide/typography_contract.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const typographyContractPath = "brief/typography_contract.json"
|
||||
|
||||
type typographyContractFile struct {
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
Roles map[string]typographyFontRole `json:"roles"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
|
||||
type typographyFontRole struct {
|
||||
Family string `json:"family"`
|
||||
Weight string `json:"weight"`
|
||||
Size string `json:"size"`
|
||||
Usage string `json:"usage"`
|
||||
}
|
||||
|
||||
func readTypographyContract(safeRoot string) (typographyContractFile, bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, typographyContractPath)
|
||||
if err != nil {
|
||||
return typographyContractFile{}, false, err
|
||||
}
|
||||
var file typographyContractFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return typographyContractFile{}, true, fmt.Errorf("%s: invalid JSON: %w", typographyContractPath, err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func typographyContractHasRequiredRoles(file typographyContractFile) bool {
|
||||
for _, role := range []string{"display", "body", "number", "label"} {
|
||||
font, ok := file.Roles[role]
|
||||
if !ok || strings.TrimSpace(font.Family) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
524
internal/svglide/validate.go
Normal file
524
internal/svglide/validate.go
Normal file
@@ -0,0 +1,524 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const slideNamespace = "https://slides.bytedance.com/ns"
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
const xlinkNamespace = "http://www.w3.org/1999/xlink"
|
||||
|
||||
type ValidationReport struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
type validationDeck struct {
|
||||
Slides []validationDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type validationDeckSlide struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type svgViewBox struct {
|
||||
Width float64
|
||||
Height float64
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type svgLintElement struct {
|
||||
Excluded bool
|
||||
TextCandidate bool
|
||||
}
|
||||
|
||||
func ValidateRun(root string) (ValidationReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ValidationReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty"))
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
var deck validationDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := ValidationReport{Issues: []ValidationIssue{}}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
if slidePath == "" {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"})
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Issues = append(report.Issues, lintSVG(slidePath, raw)...)
|
||||
}
|
||||
report = normalizeValidationReport(report)
|
||||
|
||||
if err := writeValidationArtifacts(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) {
|
||||
report := ValidationReport{Issues: []ValidationIssue{issue}}
|
||||
report = normalizeValidationReport(report)
|
||||
if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil {
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, writeErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel)
|
||||
}
|
||||
raw, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read run path %q: %w", rel, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func lintSVG(path string, raw []byte) []ValidationIssue {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var issues []ValidationIssue
|
||||
var rootSeen bool
|
||||
var rootIsSVG bool
|
||||
var hasSlideRole bool
|
||||
var hasViewBox bool
|
||||
var hasVisibleContent bool
|
||||
var viewBox svgViewBox
|
||||
var stack []svgLintElement
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}}
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
|
||||
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
|
||||
ctx := svgLintElement{
|
||||
Excluded: excluded,
|
||||
TextCandidate: elementIsTextCandidate(typed),
|
||||
}
|
||||
if !rootSeen {
|
||||
rootSeen = true
|
||||
rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace
|
||||
hasSlideRole = hasRootSlideRole(typed)
|
||||
viewBox, hasViewBox = rootViewBox(typed)
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
stack = append(stack, ctx)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
if elementCountsAsVisibleContent(typed, viewBox, excluded) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
stack = append(stack, ctx)
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !rootSeen {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}}
|
||||
}
|
||||
if !rootIsSVG {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be <svg>"})
|
||||
}
|
||||
if !hasSlideRole {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`})
|
||||
}
|
||||
if !hasViewBox {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"})
|
||||
} else if !viewBox.Valid {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"})
|
||||
}
|
||||
if rootIsSVG && !hasVisibleContent {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func hasRootSlideRole(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if strings.TrimSpace(attr.Value) != "slide" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Local == "role" && attr.Name.Space == slideNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rootViewBox(start xml.StartElement) (svgViewBox, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
return parseViewBox(attr.Value), true
|
||||
}
|
||||
return svgViewBox{}, false
|
||||
}
|
||||
|
||||
func parseViewBox(value string) svgViewBox {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", " "))
|
||||
if len(fields) != 4 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values := make([]float64, 4)
|
||||
for i, field := range fields {
|
||||
parsed, err := strconv.ParseFloat(field, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values[i] = parsed
|
||||
}
|
||||
width := values[2]
|
||||
height := values[3]
|
||||
if width <= 0 || height <= 0 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
return svgViewBox{Width: width, Height: height, Valid: true}
|
||||
}
|
||||
|
||||
func lintSVGElementProtocol(path string, start xml.StartElement, excluded bool) []ValidationIssue {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return nil
|
||||
}
|
||||
|
||||
var issues []ValidationIssue
|
||||
if excluded {
|
||||
return issues
|
||||
}
|
||||
if elementHasNonPositiveDimension(start) {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.geometry",
|
||||
Message: fmt.Sprintf("<%s> has non-positive width or height", start.Name.Local),
|
||||
})
|
||||
}
|
||||
if start.Name.Local == "image" {
|
||||
if !hasSlideAttr(start, "role", "image") {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.image_role",
|
||||
Message: `image must include slide:role="image"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func elementHasNonPositiveDimension(start xml.StartElement) bool {
|
||||
for _, name := range []string{"width", "height"} {
|
||||
value, ok := plainAttr(start, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parsed, ok := parseSVGDimension(value)
|
||||
if ok && parsed <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSlideAttr(start xml.StartElement, local string, value string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == slideNamespace && attr.Name.Local == local && strings.TrimSpace(attr.Value) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func plainAttr(start xml.StartElement, local string) (string, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == local {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseSVGDimension(value string) (float64, bool) {
|
||||
s := strings.TrimSpace(value)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
for _, suffix := range []string{"vmax", "vmin", "rem", "px", "%", "em", "pt", "pc", "in", "cm", "mm", "qh", "q", "ex", "ch", "vw", "vh"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
s = strings.TrimSpace(s[:len(s)-len(suffix)])
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool {
|
||||
if excluded {
|
||||
return false
|
||||
}
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "text", "tspan":
|
||||
return false
|
||||
case "foreignObject", "chart":
|
||||
return true
|
||||
case "image", "use":
|
||||
return elementHasHref(start)
|
||||
case "g":
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
case "path", "circle", "ellipse", "line", "polyline", "polygon":
|
||||
return true
|
||||
case "rect":
|
||||
return !isBackgroundRect(start, viewBox)
|
||||
default:
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
}
|
||||
}
|
||||
|
||||
func activeVisibleTextCandidate(stack []svgLintElement) bool {
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
if stack[i].Excluded {
|
||||
return false
|
||||
}
|
||||
if stack[i].TextCandidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func elementIsTextCandidate(start xml.StartElement) bool {
|
||||
return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan")
|
||||
}
|
||||
|
||||
func elementIsHidden(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
switch attr.Name.Local {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(attr.Value) {
|
||||
return true
|
||||
}
|
||||
case "style":
|
||||
if styleHidesElement(attr.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func styleHidesElement(style string) bool {
|
||||
for _, declaration := range strings.Split(style, ";") {
|
||||
name, value, ok := strings.Cut(declaration, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func opacityIsZero(value string) bool {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return false
|
||||
}
|
||||
return floatEqual(parsed, 0)
|
||||
}
|
||||
|
||||
func elementIsNonRendering(start xml.StartElement) bool {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elementHasHref(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSemanticMarker(start xml.StartElement, terms ...string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(attr.Name.Local)
|
||||
if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") {
|
||||
continue
|
||||
}
|
||||
value := strings.ToLower(attr.Value)
|
||||
for _, term := range terms {
|
||||
if strings.Contains(value, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool {
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return true
|
||||
}
|
||||
width := attrValue(start, "width")
|
||||
height := attrValue(start, "height")
|
||||
if width == "100%" && height == "100%" {
|
||||
return true
|
||||
}
|
||||
if !viewBox.Valid {
|
||||
return false
|
||||
}
|
||||
x := attrFloatDefault(start, "x", 0)
|
||||
y := attrFloatDefault(start, "y", 0)
|
||||
w, okW := parseAttrFloat(width)
|
||||
h, okH := parseAttrFloat(height)
|
||||
if !okW || !okH {
|
||||
return false
|
||||
}
|
||||
return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height)
|
||||
}
|
||||
|
||||
func attrValue(start xml.StartElement, name string) string {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 {
|
||||
value := attrValue(start, name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, ok := parseAttrFloat(value)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseAttrFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func floatEqual(a float64, b float64) bool {
|
||||
return math.Abs(a-b) < 0.001
|
||||
}
|
||||
997
internal/svglide/validate_test.go
Normal file
997
internal/svglide/validate_test.go
Normal file
@@ -0,0 +1,997 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected background-only SVG issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") {
|
||||
t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || len(receipt.Issues) == 0 {
|
||||
t.Fatalf("lint receipt = %+v, want failing issues", receipt)
|
||||
}
|
||||
var lintReceipt validationLintReceipt
|
||||
if err := json.Unmarshal(raw, &lintReceipt); err != nil {
|
||||
t.Fatalf("lint receipt is not schema-compatible JSON: %v", err)
|
||||
}
|
||||
if lintReceipt.Status != "failed" {
|
||||
t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status)
|
||||
}
|
||||
if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" {
|
||||
t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0])
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "slides/01.svg") {
|
||||
t.Fatalf("repair queue = %q, want slide path", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunPassesVisibleTextSVG(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("Issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(queue)) != "No repair needed." {
|
||||
t.Fatalf("repair queue = %q, want no repair text", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEscapingSlidePath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "../outside.svg")
|
||||
writeValidateTestFile(t, "outside.svg", visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with escaping slide path: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsSlideSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: "slides/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: "slides/link/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.deckPath)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDeckSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: filepath.Join("demo", "outline", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: filepath.Join("demo", "outline_link", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "outline_link/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg")
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEmptyDeck(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo")
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, "no slides")
|
||||
}
|
||||
|
||||
func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing deck",
|
||||
setup: func(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
{
|
||||
name: "invalid deck json",
|
||||
setup: func(t *testing.T) {
|
||||
writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`)
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
tt.setup(t)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/bad.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReportsInvalidXML(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg><text>broken`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") {
|
||||
t.Fatalf("Issues = %+v, want XML parse issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non svg root",
|
||||
svg: `<html><body>not svg</body></html>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "wrong svg namespace",
|
||||
svg: `<svg xmlns="https://wrong.example/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "missing slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "missing viewBox",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text>hello</text></svg>`,
|
||||
want: `viewBox`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespaced slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:foo="https://wrong.example" foo:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "unbound slide prefix role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, tt.want) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsInvalidViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
}{
|
||||
{
|
||||
name: "bad viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox origin fields",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad bad 960 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nan viewBox width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 NaN 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "zero viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 0 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox with full page rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><rect width="960" height="540" fill="#fff"/></svg>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresNonVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "text in defs",
|
||||
body: `<defs><text>hidden template</text></defs>`,
|
||||
},
|
||||
{
|
||||
name: "display none text",
|
||||
body: `<text display="none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden text",
|
||||
body: `<text visibility="hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style display none text",
|
||||
body: `<text style="display:none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style visibility hidden text",
|
||||
body: `<text style="visibility:hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "opacity zero text",
|
||||
body: `<text opacity="0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style opacity zero text",
|
||||
body: `<text style="opacity:0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
body: `<text> </text>`,
|
||||
},
|
||||
{
|
||||
name: "image without href",
|
||||
body: `<image slide:role="image" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "use without href",
|
||||
body: `<use x="10" y="10"/>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "wrong namespace path",
|
||||
body: `<bad:path xmlns:bad="https://wrong.example/svg" d="M10 10h20v20z"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace text",
|
||||
body: `<bad:text xmlns:bad="https://wrong.example/svg">hidden by namespace</bad:text>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace image href",
|
||||
body: `<image xmlns:bad="https://wrong.example/svg" bad:href="asset.png" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace viewBox",
|
||||
body: `<text>hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
viewBox := `viewBox="0 0 960 540"`
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"`
|
||||
}
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" ` + viewBox + `>` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" xlink:href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsNegativeElementDimensions(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" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHref(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" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefCaseInsensitive(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" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="HTTPS://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsImageWithoutImageRole(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" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image href="assets/images/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want image role issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresGeometryAndImageRoleInsideExcludedContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "defs image",
|
||||
body: `<defs><image href="assets/images/defs.png" width="-4px" height="120"/></defs><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "pattern image",
|
||||
body: `<pattern id="p"><image href="assets/images/pattern.png" width="120" height="0%"/></pattern><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "mask image",
|
||||
body: `<mask id="m"><image href="assets/images/mask.png" width="auto" height="-4px"/></mask><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "display none image",
|
||||
body: `<g display="none"><image href="assets/images/hidden.png" width="-4px" height="120"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden image",
|
||||
body: `<g visibility="hidden"><image href="assets/images/hidden.png" width="120" height="-4px"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "marker image role",
|
||||
body: `<marker id="mk"><image href="assets/images/marker.png" width="120" height="80"/></marker><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, want no remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want no image role issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefWithXLink(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" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" xlink:href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
}{
|
||||
{name: "parent directory", href: "../outside.png"},
|
||||
{name: "absolute path", href: "/Users/example/secret.png"},
|
||||
{name: "file url", href: "file:///tmp/secret.png"},
|
||||
{name: "protocol relative", href: "//example.com/hero.png"},
|
||||
{name: "data url", href: "data:image/png;base64,AAAA"},
|
||||
{name: "percent encoding", href: "assets/images/hero%2epng"},
|
||||
{name: "nested asset path", href: "assets/images/nested/hero.png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="`+tt.href+`" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true for %s: %+v", tt.href, report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefInsideExcludedContent(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" viewBox="0 0 960 540" slide:role="slide">
|
||||
<defs><image href="file:///tmp/secret.png" width="-4px" height="120"/></defs>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue inside excluded content", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDimensionUnits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "negative px",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4px" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero percent",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="0%" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auto width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="auto" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Fine</div>
|
||||
</foreignObject>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.want {
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsPlainHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
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/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsReceiptSymlink(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-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 := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected receipt symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected lint receipt file symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside file was overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initValidateTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...)
|
||||
}
|
||||
|
||||
func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
slides := make([]map[string]string, 0, len(slidePaths))
|
||||
for i, path := range slidePaths {
|
||||
slides = append(slides, map[string]string{
|
||||
"id": "slide-" + string(rune('1'+i)),
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "content",
|
||||
"key_message": "Message",
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": slides,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
|
||||
func readValidateTestRunFile(t *testing.T) Run {
|
||||
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)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeValidateTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidateTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundOnlySVG() 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="#fff"/></svg>`
|
||||
}
|
||||
|
||||
func visibleTextSVG() 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="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func fontTokenStyleForTest() string {
|
||||
return `<style>:root{--font-display:"Noto Serif CJK SC",serif;--font-body:"Noto Sans CJK SC",sans-serif;--font-number:"Roboto Mono",monospace;--font-label:"PingFang SC",sans-serif;}</style>`
|
||||
}
|
||||
|
||||
func validationIssuesContain(issues []ValidationIssue, needle string) bool {
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validationIssuesContainCode(issues []ValidationIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) {
|
||||
t.Helper()
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected validation issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, needle) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, needle)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || !validationIssuesContain(receipt.Issues, needle) {
|
||||
t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle)
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), needle) {
|
||||
t.Fatalf("repair queue = %q, want %q", string(queue), needle)
|
||||
}
|
||||
}
|
||||
168
internal/svglide/visual_asset_gate.go
Normal file
168
internal/svglide/visual_asset_gate.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VisualAssetGateInput struct {
|
||||
RequestText string
|
||||
EntityKind string
|
||||
Slides int
|
||||
RealImageAssets int
|
||||
OfficialImageAssets int
|
||||
SlidesWithRealImages int
|
||||
CoverRealHeroImage bool
|
||||
NoImageReason string
|
||||
ExplicitChartOnly bool
|
||||
}
|
||||
|
||||
type VisualAssetGateResult struct {
|
||||
Status string `json:"status"`
|
||||
Required bool `json:"required"`
|
||||
CoverRealHeroRequired bool `json:"cover_real_hero_required"`
|
||||
CoverRealHeroPresent bool `json:"cover_real_hero_present"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
Issues []VisualAssetIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type VisualAssetIssue struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func EvaluateVisualAssetGate(input VisualAssetGateInput) VisualAssetGateResult {
|
||||
required := visualAssetRequired(input)
|
||||
result := VisualAssetGateResult{
|
||||
Status: "passed",
|
||||
Required: required,
|
||||
CoverRealHeroRequired: required,
|
||||
CoverRealHeroPresent: input.CoverRealHeroImage,
|
||||
Issues: []VisualAssetIssue{},
|
||||
}
|
||||
if !required {
|
||||
return result
|
||||
}
|
||||
if input.RealImageAssets == 0 || input.SlidesWithRealImages == 0 {
|
||||
result.Issues = append(result.Issues, VisualAssetIssue{
|
||||
Code: "svglide.visual_asset.real_image_missing",
|
||||
Message: "entity-driven deck requires at least one real image asset; charts and typography are not enough",
|
||||
})
|
||||
}
|
||||
if !input.CoverRealHeroImage {
|
||||
result.Issues = append(result.Issues, VisualAssetIssue{
|
||||
Code: "svglide.visual_asset.cover_real_hero_missing",
|
||||
Message: "entity-driven deck requires a real cover hero image or strong subject visual",
|
||||
})
|
||||
}
|
||||
if len(result.Issues) > 0 {
|
||||
result.Status = "failed"
|
||||
result.IssueCount = len(result.Issues)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func visualAssetRequired(input VisualAssetGateInput) bool {
|
||||
if input.ExplicitChartOnly {
|
||||
return false
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(input.EntityKind))
|
||||
switch kind {
|
||||
case "company", "brand", "product", "person", "place", "location", "team", "event", "film", "book":
|
||||
return true
|
||||
}
|
||||
text := strings.ToLower(input.RequestText)
|
||||
for _, hint := range []string{
|
||||
"financial report for ", "nvidia", "apple", "leica", "kaneko", "world cup", "olympic",
|
||||
"公司", "品牌", "产品", "球队", "队", "球员", "城市", "地点", "电影",
|
||||
"company", "brand", "product", "team", "player", "city", "museum", "restaurant",
|
||||
} {
|
||||
if strings.Contains(text, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func qualityRequestText(root string) string {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input"`
|
||||
Topic string `json:"topic"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
_ = readJSONIfExists(filepath.Join(root, "request", "request.json"), &req)
|
||||
text := strings.TrimSpace(strings.Join([]string{req.Title, req.Input, req.Topic, req.Prompt}, " "))
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
_, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{run.Title, run.Input, run.Intent.Topic, run.Intent.Input}, " "))
|
||||
}
|
||||
|
||||
func qualityEntityKind(root string) string {
|
||||
entity := readQualityEntityResolution(root)
|
||||
if value := strings.TrimSpace(entity.ResolvedEntity.Type); value != "" {
|
||||
return value
|
||||
}
|
||||
var raw struct {
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = readJSONIfExists(filepath.Join(root, "request", "entity_resolution.json"), &raw)
|
||||
if raw.Kind != "" {
|
||||
return raw.Kind
|
||||
}
|
||||
return raw.Type
|
||||
}
|
||||
|
||||
func qualityNoImageReason(root string) string {
|
||||
var plan struct {
|
||||
NoImageReason string `json:"no_image_reason"`
|
||||
}
|
||||
_ = readJSONIfExists(filepath.Join(root, assetsPlanPath), &plan)
|
||||
if strings.TrimSpace(plan.NoImageReason) != "" {
|
||||
return plan.NoImageReason
|
||||
}
|
||||
assets, err := readDeckAssetsArtifact(root, assetsManifestPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return assets.NoImageReason
|
||||
}
|
||||
|
||||
func qualityRequestExplicitChartOnly(root string) bool {
|
||||
text := strings.ToLower(strings.Join([]string{qualityRequestText(root), qualityNoImageReason(root)}, " "))
|
||||
return strings.Contains(text, "chart-only") ||
|
||||
strings.Contains(text, "vector-only") ||
|
||||
strings.Contains(text, "no photos") ||
|
||||
strings.Contains(text, "no raster") ||
|
||||
strings.Contains(text, "仅图表") ||
|
||||
strings.Contains(text, "不要图片")
|
||||
}
|
||||
|
||||
func readJSONIfExists(path string, dst any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dst)
|
||||
}
|
||||
|
||||
func visualAssetIssuesContain(issues []VisualAssetIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
57
internal/svglide/visual_asset_gate_test.go
Normal file
57
internal/svglide/visual_asset_gate_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVisualAssetGateFailsEntityFinancialReportWithoutRealImages(t *testing.T) {
|
||||
input := VisualAssetGateInput{
|
||||
RequestText: "Generate a comprehensive financial report for Q4 2023 for Nvidia.",
|
||||
EntityKind: "company",
|
||||
Slides: 8,
|
||||
RealImageAssets: 0,
|
||||
OfficialImageAssets: 0,
|
||||
SlidesWithRealImages: 0,
|
||||
CoverRealHeroImage: false,
|
||||
NoImageReason: "This data-report deck does not require raster images; visual evidence is carried by charts.",
|
||||
}
|
||||
result := EvaluateVisualAssetGate(input)
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", result.Status)
|
||||
}
|
||||
if !visualAssetIssuesContain(result.Issues, "svglide.visual_asset.cover_real_hero_missing") {
|
||||
t.Fatalf("issues = %+v, want cover real hero missing", result.Issues)
|
||||
}
|
||||
if !visualAssetIssuesContain(result.Issues, "svglide.visual_asset.real_image_missing") {
|
||||
t.Fatalf("issues = %+v, want real image missing", result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualAssetGatePassesAbstractChartOnlyDeck(t *testing.T) {
|
||||
input := VisualAssetGateInput{
|
||||
RequestText: "Explain quarterly revenue trend as a chart-only internal analytics deck.",
|
||||
EntityKind: "abstract_data",
|
||||
Slides: 6,
|
||||
RealImageAssets: 0,
|
||||
CoverRealHeroImage: false,
|
||||
ExplicitChartOnly: true,
|
||||
}
|
||||
result := EvaluateVisualAssetGate(input)
|
||||
if result.Status != "passed" {
|
||||
t.Fatalf("status = %q issues = %+v, want passed", result.Status, result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualAssetGatePassesEntityDeckWithCoverHero(t *testing.T) {
|
||||
input := VisualAssetGateInput{
|
||||
RequestText: "Introduce Leica M cameras.",
|
||||
EntityKind: "product",
|
||||
Slides: 8,
|
||||
RealImageAssets: 4,
|
||||
OfficialImageAssets: 2,
|
||||
SlidesWithRealImages: 5,
|
||||
CoverRealHeroImage: true,
|
||||
}
|
||||
result := EvaluateVisualAssetGate(input)
|
||||
if result.Status != "passed" {
|
||||
t.Fatalf("status = %q issues = %+v, want passed", result.Status, result.Issues)
|
||||
}
|
||||
}
|
||||
30
internal/svglide/visual_contract.go
Normal file
30
internal/svglide/visual_contract.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package svglide
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
requiredChartRendererNone = "none"
|
||||
requiredChartRendererSVG = "svg"
|
||||
requiredChartRendererVegaLite = "vega-lite"
|
||||
)
|
||||
|
||||
func normalizedRequiredChartRenderer(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case requiredChartRendererSVG:
|
||||
return requiredChartRendererSVG
|
||||
case requiredChartRendererVegaLite:
|
||||
return requiredChartRendererVegaLite
|
||||
default:
|
||||
return requiredChartRendererNone
|
||||
}
|
||||
}
|
||||
|
||||
func visualContractRequiresChartManifest(contract qualityVisualContract) bool {
|
||||
return normalizedRequiredChartRenderer(contract.RequiredChartRenderer) == requiredChartRendererVegaLite ||
|
||||
contract.MinChartSVGAssets > 0 ||
|
||||
contract.MinVegaLiteSpecs > 0
|
||||
}
|
||||
|
||||
func visualContractRequiresTypography(contract qualityVisualContract) bool {
|
||||
return contract.TypographyContractRequired
|
||||
}
|
||||
181
internal/svglide/visual_fusion.go
Normal file
181
internal/svglide/visual_fusion.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RGBColor struct {
|
||||
R uint8
|
||||
G uint8
|
||||
B uint8
|
||||
}
|
||||
|
||||
type EdgePalette struct {
|
||||
Side string `json:"side"`
|
||||
Hex string `json:"hex"`
|
||||
Luminance int `json:"luminance"`
|
||||
Texture int `json:"texture"`
|
||||
}
|
||||
|
||||
func AnalyzeEdgePalette(path string, side string) (EdgePalette, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return EdgePalette{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return EdgePalette{}, err
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() == 0 || bounds.Dy() == 0 {
|
||||
return EdgePalette{}, fmt.Errorf("image %q has empty bounds", path)
|
||||
}
|
||||
side = normalizedEdgeSide(side)
|
||||
minX, maxX, minY, maxY := edgeSampleRect(bounds, side)
|
||||
var count int
|
||||
var totalR, totalG, totalB, totalLum int
|
||||
var previousLum int
|
||||
var texture int
|
||||
for y := minY; y < maxY; y++ {
|
||||
for x := minX; x < maxX; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
color := RGBColor{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8)}
|
||||
lum := colorLuminance(color)
|
||||
totalR += int(color.R)
|
||||
totalG += int(color.G)
|
||||
totalB += int(color.B)
|
||||
totalLum += lum
|
||||
if count > 0 {
|
||||
texture += absInt(lum - previousLum)
|
||||
}
|
||||
previousLum = lum
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return EdgePalette{}, fmt.Errorf("image %q side %q has no sample pixels", path, side)
|
||||
}
|
||||
color := RGBColor{
|
||||
R: uint8(totalR / count),
|
||||
G: uint8(totalG / count),
|
||||
B: uint8(totalB / count),
|
||||
}
|
||||
return EdgePalette{
|
||||
Side: side,
|
||||
Hex: colorToHex(color),
|
||||
Luminance: totalLum / count,
|
||||
Texture: texture / count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ScoreFusionCandidate(palette EdgePalette) int {
|
||||
score := 100
|
||||
if palette.Luminance > 110 {
|
||||
score -= (palette.Luminance - 110) / 2
|
||||
}
|
||||
if palette.Texture > 20 {
|
||||
score -= (palette.Texture - 20) * 2
|
||||
}
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
if score > 100 {
|
||||
return 100
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func CheckSeamDelta(sampledHex string, panelHex string) (bool, int) {
|
||||
a, errA := parseHexColor(sampledHex)
|
||||
b, errB := parseHexColor(panelHex)
|
||||
if errA != nil || errB != nil {
|
||||
return false, 255
|
||||
}
|
||||
delta := colorDelta(a, b)
|
||||
return delta <= 45, delta
|
||||
}
|
||||
|
||||
func normalizedEdgeSide(side string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(side)) {
|
||||
case "right", "top", "bottom":
|
||||
return strings.ToLower(strings.TrimSpace(side))
|
||||
default:
|
||||
return "left"
|
||||
}
|
||||
}
|
||||
|
||||
func edgeSampleRect(bounds image.Rectangle, side string) (int, int, int, int) {
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
band := 80
|
||||
if width < band*4 {
|
||||
band = maxInt(1, width/5)
|
||||
}
|
||||
if height < band*4 {
|
||||
band = maxInt(1, height/5)
|
||||
}
|
||||
switch side {
|
||||
case "right":
|
||||
return bounds.Max.X - band, bounds.Max.X, bounds.Min.Y, bounds.Max.Y
|
||||
case "top":
|
||||
return bounds.Min.X, bounds.Max.X, bounds.Min.Y, bounds.Min.Y + band
|
||||
case "bottom":
|
||||
return bounds.Min.X, bounds.Max.X, bounds.Max.Y - band, bounds.Max.Y
|
||||
default:
|
||||
return bounds.Min.X, bounds.Min.X + band, bounds.Min.Y, bounds.Max.Y
|
||||
}
|
||||
}
|
||||
|
||||
func parseHexColor(value string) (RGBColor, error) {
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
|
||||
if len(value) == 3 {
|
||||
value = string([]byte{value[0], value[0], value[1], value[1], value[2], value[2]})
|
||||
}
|
||||
if len(value) != 6 {
|
||||
return RGBColor{}, fmt.Errorf("invalid hex color %q", value)
|
||||
}
|
||||
r, err := strconv.ParseUint(value[0:2], 16, 8)
|
||||
if err != nil {
|
||||
return RGBColor{}, err
|
||||
}
|
||||
g, err := strconv.ParseUint(value[2:4], 16, 8)
|
||||
if err != nil {
|
||||
return RGBColor{}, err
|
||||
}
|
||||
b, err := strconv.ParseUint(value[4:6], 16, 8)
|
||||
if err != nil {
|
||||
return RGBColor{}, err
|
||||
}
|
||||
return RGBColor{R: uint8(r), G: uint8(g), B: uint8(b)}, nil
|
||||
}
|
||||
|
||||
func colorToHex(color RGBColor) string {
|
||||
return fmt.Sprintf("#%02X%02X%02X", color.R, color.G, color.B)
|
||||
}
|
||||
|
||||
func colorLuminance(color RGBColor) int {
|
||||
return int(0.2126*float64(color.R) + 0.7152*float64(color.G) + 0.0722*float64(color.B))
|
||||
}
|
||||
|
||||
func colorDelta(a RGBColor, b RGBColor) int {
|
||||
dr := int(a.R) - int(b.R)
|
||||
dg := int(a.G) - int(b.G)
|
||||
db := int(a.B) - int(b.B)
|
||||
return int(math.Sqrt(float64(dr*dr + dg*dg + db*db)))
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
58
internal/svglide/visual_fusion_test.go
Normal file
58
internal/svglide/visual_fusion_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyzeEdgePaletteSamplesRequestedEdge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "edge.png")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
|
||||
for y := 0; y < 60; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
if x < 20 {
|
||||
img.Set(x, y, color.RGBA{R: 8, G: 12, B: 18, A: 255})
|
||||
} else {
|
||||
img.Set(x, y, color.RGBA{R: 230, G: 230, B: 230, A: 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
writePNGForFusionTest(t, path, img)
|
||||
|
||||
palette, err := AnalyzeEdgePalette(path, "left")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if palette.Side != "left" || palette.Luminance > 40 {
|
||||
t.Fatalf("palette = %+v, want dark left edge", palette)
|
||||
}
|
||||
if ScoreFusionCandidate(palette) < 80 {
|
||||
t.Fatalf("ScoreFusionCandidate(%+v) too low", palette)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSeamDelta(t *testing.T) {
|
||||
if ok, delta := CheckSeamDelta("#101820", "#111922"); !ok || delta > 45 {
|
||||
t.Fatalf("similar colors ok=%v delta=%d, want ok", ok, delta)
|
||||
}
|
||||
if ok, delta := CheckSeamDelta("#101820", "#F4F4F4"); ok || delta <= 45 {
|
||||
t.Fatalf("distant colors ok=%v delta=%d, want rejection", ok, delta)
|
||||
}
|
||||
}
|
||||
|
||||
func writePNGForFusionTest(t *testing.T, path string, img image.Image) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err := png.Encode(file, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 15 * time.Second
|
||||
fetchTimeout = 5 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return DefaultFS.OpenFile(name, flag, perm)
|
||||
}
|
||||
func CreateTemp(dir, pattern string) (*os.File, error) { return DefaultFS.CreateTemp(dir, pattern) }
|
||||
func Mkdir(path string, perm fs.FileMode) error { return DefaultFS.Mkdir(path, perm) }
|
||||
func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirAll(path, perm) }
|
||||
func MkdirTemp(dir, pattern string) (string, error) { return DefaultFS.MkdirTemp(dir, pattern) }
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
|
||||
@@ -25,6 +25,7 @@ type FS interface {
|
||||
CreateTemp(dir, pattern string) (*os.File, error)
|
||||
|
||||
// Directory/File management
|
||||
Mkdir(path string, perm fs.FileMode) error
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
MkdirTemp(dir, pattern string) (string, error)
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
|
||||
@@ -30,6 +30,7 @@ func (OsFs) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
|
||||
func (OsFs) CreateTemp(dir, pattern string) (*os.File, error) { return os.CreateTemp(dir, pattern) }
|
||||
|
||||
// Directory/File management
|
||||
func (OsFs) Mkdir(path string, perm fs.FileMode) error { return os.Mkdir(path, perm) }
|
||||
func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
func (OsFs) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) }
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
|
||||
@@ -23,6 +23,15 @@ func TestOsFsBasicOperations(t *testing.T) {
|
||||
fs := OsFs{}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Mkdir
|
||||
one := filepath.Join(dir, "one")
|
||||
if err := fs.Mkdir(one, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := Mkdir(filepath.Join(dir, "two"), 0o755); err != nil {
|
||||
t.Fatalf("package Mkdir: %v", err)
|
||||
}
|
||||
|
||||
// MkdirAll
|
||||
sub := filepath.Join(dir, "a", "b")
|
||||
if err := fs.MkdirAll(sub, 0o755); err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.63",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -927,6 +927,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
if s.LocalOnly {
|
||||
return runLocalShortcut(cmd, f, s, botOnly)
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -948,6 +952,23 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
return err
|
||||
}
|
||||
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runLocalShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
as := core.AsBot
|
||||
if asFlag, _ := cmd.Flags().GetString("as"); asFlag != "" && core.Identity(asFlag) != core.AsAuto {
|
||||
as = core.Identity(asFlag)
|
||||
}
|
||||
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
config := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
rctx := newLocalRuntimeContext(cmd, f, s, config, as, botOnly)
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runShortcutWithContext(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1031,6 +1052,21 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
func newLocalRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
|
||||
ctx := cmd.Context()
|
||||
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
|
||||
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and cannot call OpenAPI", s.Service, s.Command)
|
||||
})
|
||||
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and has no bot identity", s.Service, s.Command)
|
||||
})
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
@@ -1253,5 +1289,12 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
if s.LocalOnly {
|
||||
cmd.Flags().String("as", "", "identity type: "+strings.Join(s.AuthTypes, " | "))
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
return
|
||||
}
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type Shortcut struct {
|
||||
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
LocalOnly bool // pure local command: no identity, config, scope, SDK, or OpenAPI bootstrap
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
|
||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsHistoryListSpec struct {
|
||||
Doc documentRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type docsHistoryRevertSpec struct {
|
||||
Doc documentRef
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type docsHistoryRevertStatusSpec struct {
|
||||
Doc documentRef
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||
ref, err := parseDocumentRef(raw)
|
||||
if err != nil {
|
||||
return documentRef{}, err
|
||||
}
|
||||
if ref.Kind == "doc" {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||
}
|
||||
|
||||
var DocsHistoryList = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-list",
|
||||
Description: "List Lark document history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-list"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: list document history versions").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryListParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||
docsHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Lark document to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: revert document history").
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Body(docsHistoryRevertBody(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||
nil,
|
||||
docsHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevertStatus = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Lark document history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: get document history revert status").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryStatusParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||
docsHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDocsHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects legacy doc URL",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||
param: "--doc",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: DocsHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != tt.category {
|
||||
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||
}
|
||||
if problem.Subtype != tt.subtype {
|
||||
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||
"+history-list",
|
||||
"--doc", "doxcnHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--doc", "doxcnHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": []interface{}{"blk_1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--doc", "doxcnHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "partial_failed" {
|
||||
t.Fatalf("status = %#v, want partial_failed", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -46,12 +44,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
case "history-list":
|
||||
return "lark-cli docs +history-list --help"
|
||||
case "history-revert":
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -64,9 +56,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
DocMediaInsert,
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,8 +28,6 @@ const (
|
||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
|
||||
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
|
||||
)
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
@@ -50,8 +47,6 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"pptx": {"slides"},
|
||||
}
|
||||
|
||||
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
type driveImportSpec struct {
|
||||
FilePath string
|
||||
@@ -432,7 +427,11 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, driveImportFailureError(status)
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
@@ -441,40 +440,3 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func driveImportFailureError(status driveImportStatus) *errs.APIError {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
|
||||
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
if code, ok := driveImportConcurrentOperationCode(msg); ok {
|
||||
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func driveImportConcurrentOperationCode(msg string) (int, bool) {
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
codeText := strconv.Itoa(code)
|
||||
for idx := strings.Index(msg, codeText); idx >= 0; {
|
||||
end := idx + len(codeText)
|
||||
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
|
||||
return code, true
|
||||
}
|
||||
|
||||
nextStart := idx + 1
|
||||
next := strings.Index(msg[nextStart:], codeText)
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
idx = nextStart + next
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isASCIIDigit(ch byte) bool {
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -212,82 +211,6 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
|
||||
}
|
||||
if problem.Code != code {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, code)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if problem.Hint != driveImportConcurrentOperationHint {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg string
|
||||
}{
|
||||
{
|
||||
name: "ordinary failure",
|
||||
msg: "unsupported conversion",
|
||||
},
|
||||
{
|
||||
name: "longer numeric code containing known code",
|
||||
msg: "call CreateObjNode return error code, code: 12321401012, message:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: tt.msg,
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Code != 0 {
|
||||
t.Fatalf("code = %d, want 0", problem.Code)
|
||||
}
|
||||
if problem.Retryable {
|
||||
t.Fatal("expected non-concurrency failure to remain non-retryable")
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", problem.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
@@ -66,24 +66,31 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
return common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
dr := common.NewDryRunAPI()
|
||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
||||
}
|
||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
|
||||
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
||||
}
|
||||
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -107,20 +114,26 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
out["from_speaker_id"] = fromSpeakerID
|
||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
||||
out["from_speaker_input"] = fromSpeakerInput
|
||||
}
|
||||
} else {
|
||||
out["from_user_id"] = fromUserID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerInput != "" {
|
||||
return fromSpeakerInput
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
return fromSpeakerID
|
||||
}
|
||||
|
||||
@@ -153,14 +153,58 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||
// straight through without a second speakerlist call. If the code still
|
||||
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"speakers": []interface{}{
|
||||
map[string]interface{}{
|
||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
||||
"name": "说话人1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
@@ -174,7 +218,7 @@ func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -184,19 +228,21 @@ func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromSpeakerInput string `json:"from_speaker_input"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
||||
}
|
||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||
}
|
||||
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
@@ -216,11 +262,8 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||
|
||||
104
shortcuts/minutes/minutes_speakers.go
Normal file
104
shortcuts/minutes/minutes_speakers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type minuteSpeaker struct {
|
||||
SpeakerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "speakers")
|
||||
speakers := make([]minuteSpeaker, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
||||
}
|
||||
return speakers, nil
|
||||
}
|
||||
|
||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var matches []minuteSpeaker
|
||||
for _, s := range speakers {
|
||||
if s.Name == name {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
||||
"no speaker named %q in minute transcript", name).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
||||
case 1:
|
||||
return matches[0].SpeakerID, nil
|
||||
default:
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.SpeakerID
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
||||
// The input may already be an opaque speaker_id, or a display name that requires
|
||||
// an internal speaker-list fetch.
|
||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, s := range speakers {
|
||||
if s.SpeakerID == input {
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
return resolveSpeakerIDByName(speakers, input)
|
||||
}
|
||||
|
||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID != "" {
|
||||
return "", fromUserID, nil
|
||||
}
|
||||
|
||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
||||
return fromSpeakerID, "", err
|
||||
}
|
||||
45
shortcuts/minutes/minutes_speakers_test.go
Normal file
45
shortcuts/minutes/minutes_speakers_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
||||
speakers := []minuteSpeaker{
|
||||
{SpeakerID: "id_a", Name: "Alice"},
|
||||
{SpeakerID: "id_b", Name: "Bob"},
|
||||
{SpeakerID: "id_c", Name: "Alice"},
|
||||
}
|
||||
|
||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
||||
if err != nil || id != "id_b" {
|
||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
var brand 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 {
|
||||
brand = cfg.Brand
|
||||
}
|
||||
|
||||
@@ -170,27 +170,6 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||
cmd, _, err := program.Find([]string{"docs", name})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != name {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||
}
|
||||
if cmd.Flags().Lookup("api-version") != nil {
|
||||
t.Fatalf("docs %s should not expose --api-version", name)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVGlide,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
|
||||
166
shortcuts/slides/slides_create_svglide.go
Normal file
166
shortcuts/slides/slides_create_svglide.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/svglide"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVGlide manages a local agent-neutral SVGlide SVG run directory.
|
||||
var SlidesCreateSVGlide = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svglide",
|
||||
Description: "Create and manage a local SVGlide SVG run directory",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{},
|
||||
LocalOnly: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "action", Desc: "runtime action: init, status, next, complete, author, validate, preview, quality, repair", Required: true, Enum: []string{"init", "status", "next", "complete", "author", "validate", "preview", "quality", "repair"}},
|
||||
{Name: "run", Desc: "existing run directory for status/next/complete/author/validate/preview/quality/repair"},
|
||||
{Name: "title", Desc: "deck title for init"},
|
||||
{Name: "input", Desc: "local source markdown/text path for init"},
|
||||
{Name: "topic", Desc: "topic-only deck intent for init; mutually exclusive with --input"},
|
||||
{Name: "language", Desc: "deck language for topic-only or local source init"},
|
||||
{Name: "agent-runtime", Desc: "agent runtime name for init, e.g. codex, claude, cursor, fake-agent"},
|
||||
{Name: "agent-id", Desc: "stable agent/session id for init"},
|
||||
{Name: "route-profile", Desc: "SVGlide route profile for init", Enum: []string{"local_svg_deck", "imported_pptx", "template_reference", "legacy_editor"}},
|
||||
{Name: "audience", Desc: "final audience for the deck"},
|
||||
{Name: "delivery-mode", Desc: "delivery mode: presented, self_read, dual_mode", Enum: []string{"presented", "self_read", "dual_mode"}},
|
||||
{Name: "pages", Type: "int", Desc: "target page count"},
|
||||
{Name: "out", Desc: "output run directory for init"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "allow init to overwrite an existing run directory"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
if action == "init" {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title is required for init").WithParam("--title")
|
||||
}
|
||||
hasInput := strings.TrimSpace(runtime.Str("input")) != ""
|
||||
hasTopic := strings.TrimSpace(runtime.Str("topic")) != ""
|
||||
if hasInput == hasTopic {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "exactly one of --input or --topic is required for init").WithParam("--input")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("out")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--out is required for init").WithParam("--out")
|
||||
}
|
||||
routeProfile := strings.TrimSpace(runtime.Str("route-profile"))
|
||||
if routeProfile != "" && routeProfile != "local_svg_deck" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "route profile %q is not implemented for +create-svglide local runtime", routeProfile).WithParam("--route-profile")
|
||||
}
|
||||
if hasInput {
|
||||
if stat, err := runtime.FileIO().Stat(runtime.Str("input")); err != nil {
|
||||
return common.WrapInputStatErrorTyped(err, "cannot read --input")
|
||||
} else if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be a regular file").WithParam("--input")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("run")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--run is required for %s", action).WithParam("--run")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
switch action {
|
||||
case "init":
|
||||
out := runtime.Str("out")
|
||||
if err := svglide.InitRun(out, svglide.InitOptions{
|
||||
Title: runtime.Str("title"),
|
||||
Input: runtime.Str("input"),
|
||||
Topic: runtime.Str("topic"),
|
||||
Language: runtime.Str("language"),
|
||||
Audience: runtime.Str("audience"),
|
||||
DeliveryMode: runtime.Str("delivery-mode"),
|
||||
Pages: runtime.Int("pages"),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
AgentRuntime: runtime.Str("agent-runtime"),
|
||||
AgentID: runtime.Str("agent-id"),
|
||||
RouteProfile: runtime.Str("route-profile"),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := svglide.InspectStatus(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]any{
|
||||
"action": action,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"run": out,
|
||||
"agent_runtime": runtime.Str("agent-runtime"),
|
||||
"next_command": status.NextCommand,
|
||||
"stage_loop": []string{"next", "write_artifacts", "complete"},
|
||||
"final_loop": []string{"next", "repair", "complete"},
|
||||
}, nil)
|
||||
return nil
|
||||
case "status":
|
||||
report, err := svglide.InspectStatus(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "next":
|
||||
report, err := svglide.NextTask(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "complete":
|
||||
report, err := svglide.CompleteCurrentStage(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "author":
|
||||
report, err := svglide.AuthorSlides(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "validate":
|
||||
report, err := svglide.ValidateRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "preview":
|
||||
report, err := svglide.WritePreview(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "quality":
|
||||
report, err := svglide.CheckQuality(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "repair":
|
||||
report, err := svglide.RepairRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --action %q", action).WithParam("--action")
|
||||
}
|
||||
},
|
||||
}
|
||||
620
shortcuts/slides/slides_create_svglide_test.go
Normal file
620
shortcuts/slides/slides_create_svglide_test.go
Normal file
@@ -0,0 +1,620 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGlideInitShortcut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--audience", "产品负责人",
|
||||
"--delivery-mode", "self_read",
|
||||
"--pages", "8",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["action"] != "init" {
|
||||
t.Fatalf("action = %v, want init", data["action"])
|
||||
}
|
||||
if data["run"] != "run-demo" {
|
||||
t.Fatalf("run = %v, want run-demo", data["run"])
|
||||
}
|
||||
if !strings.Contains(stringValue(data["next_command"]), "--action complete --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want request bootstrap complete action", data["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideInitShortcutAcceptsTopicOnlyAgentRuntime(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "电影介绍",
|
||||
"--topic", "介绍一部电影",
|
||||
"--language", "zh",
|
||||
"--agent-runtime", "fake-agent",
|
||||
"--agent-id", "test-agent-1",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topic-only init should succeed without --input: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["agent_runtime"] != "fake-agent" {
|
||||
t.Fatalf("agent_runtime = %v, want fake-agent; data=%+v", data["agent_runtime"], data)
|
||||
}
|
||||
if _, ok := data["stage_loop"].([]any); !ok {
|
||||
t.Fatalf("stage_loop missing from init response: %+v", data)
|
||||
}
|
||||
if _, ok := data["final_loop"].([]any); !ok {
|
||||
t.Fatalf("final_loop missing from init response: %+v", data)
|
||||
}
|
||||
|
||||
runRaw, err := os.ReadFile(filepath.Join(dir, "run-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"] == "fake-agent" || run["runtime"] == "codex" {
|
||||
t.Fatalf("run.runtime = %v, want agent-neutral protocol runtime", run["runtime"])
|
||||
}
|
||||
agent, ok := run["agent"].(map[string]any)
|
||||
if !ok || agent["runtime"] != "fake-agent" {
|
||||
t.Fatalf("run.agent = %+v, want fake-agent", run["agent"])
|
||||
}
|
||||
intent, ok := run["intent"].(map[string]any)
|
||||
if !ok || intent["source_mode"] != "topic" || intent["topic"] != "介绍一部电影" {
|
||||
t.Fatalf("run.intent = %+v, want topic intent", run["intent"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideAgentRuntimeFlagsRegistered(t *testing.T) {
|
||||
for _, name := range []string{"topic", "language", "agent-runtime", "agent-id"} {
|
||||
flag := findSVGlideShortcutFlag(t, name)
|
||||
if strings.TrimSpace(flag.Desc) == "" {
|
||||
t.Fatalf("flag %s missing description", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRejectsPositionalAction(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"init",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected positional argument rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideStatusAndNextActions(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "status",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statusData := decodeShortcutData(t, stdout)
|
||||
if statusData["current_stage"] != "request" {
|
||||
t.Fatalf("current_stage = %v, want request", statusData["current_stage"])
|
||||
}
|
||||
if !strings.Contains(stringValue(statusData["next_command"]), "--action complete --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want request bootstrap complete action", statusData["next_command"])
|
||||
}
|
||||
|
||||
err = runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "next",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextData := decodeShortcutData(t, stdout)
|
||||
if nextData["stage"] != "request" || nextData["prompt_manifest"] != "prompt_manifest.json" {
|
||||
t.Fatalf("next data = %+v, want request prompt manifest", nextData)
|
||||
}
|
||||
if nextData["mode"] != "execution" {
|
||||
t.Fatalf("mode = %v, want execution", nextData["mode"])
|
||||
}
|
||||
if nextData["approval_required"] != false {
|
||||
t.Fatalf("approval_required = %v, want false", nextData["approval_required"])
|
||||
}
|
||||
if nextData["blocking_owner"] != "svglide-runtime" {
|
||||
t.Fatalf("blocking_owner = %v, want svglide-runtime", nextData["blocking_owner"])
|
||||
}
|
||||
if _, ok := nextData["blocking_reason"]; ok {
|
||||
t.Fatalf("blocking_reason should be omitted when empty: %+v", nextData)
|
||||
}
|
||||
if nextData["prompt_path"] != nil && stringValue(nextData["prompt_path"]) != "" {
|
||||
t.Fatalf("prompt_path = %v, want empty deprecated field", nextData["prompt_path"])
|
||||
}
|
||||
if paths := valuesAsStrings(nextData["prompt_paths"]); len(paths) != 0 {
|
||||
t.Fatalf("prompt_paths = %+v, want omitted legacy top-level field", paths)
|
||||
}
|
||||
agentTask, ok := nextData["agent_task"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("agent_task missing from next response: %+v", nextData)
|
||||
}
|
||||
promptContext, ok := agentTask["prompt_context"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("agent_task.prompt_context missing from next response: %+v", nextData)
|
||||
}
|
||||
paths := promptContextAssetPathsFromShortcutData(promptContext["assets"])
|
||||
joined := strings.Join(paths, "\n")
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("prompt_context assets missing %q: %+v", want, paths)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "docs/vendor/anygen-svg/source.full.md") {
|
||||
t.Fatalf("prompt_context assets should not include source snapshot: %+v", paths)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompt_manifest.json")); err != nil {
|
||||
t.Fatalf("missing prompt manifest: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts dir err = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideLocalOnlySkipsConfigAndSDK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Config = func() (*core.CliConfig, error) {
|
||||
t.Fatal("local-only +create-svglide must not load config")
|
||||
return nil, nil
|
||||
}
|
||||
f.LarkClient = func() (*lark.Client, error) {
|
||||
t.Fatal("local-only +create-svglide must not create Lark SDK client")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != true {
|
||||
t.Fatalf("ok = %v, want true; data=%+v", data["ok"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlidePreviewActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "preview",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatalf("missing preview receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideActionEnumIncludesCompleteAuthorAndRepair(t *testing.T) {
|
||||
actionFlag := findSVGlideShortcutFlag(t, "action")
|
||||
want := map[string]bool{
|
||||
"complete": false,
|
||||
"author": false,
|
||||
"quality": false,
|
||||
"repair": false,
|
||||
}
|
||||
for _, value := range actionFlag.Enum {
|
||||
if _, ok := want[value]; ok {
|
||||
want[value] = true
|
||||
}
|
||||
}
|
||||
for value, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("action enum missing %q: %+v", value, actionFlag.Enum)
|
||||
}
|
||||
if !strings.Contains(actionFlag.Desc, value) {
|
||||
t.Fatalf("action desc %q missing %q", actionFlag.Desc, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRepairActionAuthorsAndPreviews(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "repair",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if data["reauthored"] != true {
|
||||
t.Fatalf("reauthored = %v, want true; data=%+v", data["reauthored"], data)
|
||||
}
|
||||
if data["quality"] != "passed" {
|
||||
t.Fatalf("quality = %v, want passed; data=%+v", data["quality"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "quality_report.json")); err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "validate_preview_repair.json")); err != nil {
|
||||
t.Fatalf("missing final repair receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideQualityActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "slides", "01.svg"), svglideShortcutVisibleTextSVG())
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "visual_receipts.json"), `{"slides":[{"slide_id":"cover","story_job":"hook","layout_family":"quiet_synthesis","layout_signature":"single_claim_poster","thumbnail_job":"Slide","visual_center":"title claim","topic_fit_claim":"matches demo request","information_density_plan":"one clear point","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"simple quality action fixture","data_visual_rationale":"","source_evidence":["web1 supports claim"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "quality",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "quality_report.json")); err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideCompleteActionAdvancesRequestStage(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "complete",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["current_stage"] != "request_resolution" {
|
||||
t.Fatalf("current_stage = %v, want request_resolution; data=%+v", data["current_stage"], data)
|
||||
}
|
||||
receiptPath := filepath.Join(dir, "run-demo", "receipts", "request.json")
|
||||
raw, err := os.ReadFile(receiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt["stage"] != "request" || receipt["status"] != "done" {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideAuthorActionWritesSVG(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "author",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done; data=%+v", data["status"], data)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("run-demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing authored SVG: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `slide:role="slide"`) {
|
||||
t.Fatalf("authored SVG missing slide role:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionDoesNotErrorOnValidationFailure(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/missing.svg")
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != false {
|
||||
t.Fatalf("ok = %v, want false; data=%+v", data["ok"], data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRegistered(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Command == "+create-svglide" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("slides +create-svglide shortcut is not registered")
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithDeck(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "slides", "01.svg"), svglideShortcutVisibleTextSVG())
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithAuthorInputs(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "brief", "visual_system.json"), `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "research", "sources.json"), `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "content", "slide_content.json"), `{"slides":[{"id":"cover","content":"Point A\nPoint B","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-cover","type":"none","instruction":"Text-only"}]}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "assets", "assets_plan.json"), `{"assets":[],"no_image_reason":"Text-only deck; no image assets required"}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "assets", "assets_manifest.json"), `{"assets":[],"no_image_reason":"Text-only deck; no image assets required"}`)
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
writeSVGlideShortcutSemanticContract(t)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
if err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("run-demo", "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutSemanticContract(t *testing.T) {
|
||||
t.Helper()
|
||||
writeSVGlideShortcutFile(t, filepath.Join("skills", "lark-slides", "references", "anygen-svg", "semantic_contract.md"), `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
invocation: reference
|
||||
rules:
|
||||
- id: no_silent_all_diagram_fallback
|
||||
kind: explicit_reason_required
|
||||
when: deck_has_zero_image_assets
|
||||
artifact: assets/assets_manifest.json
|
||||
field: no_image_reason
|
||||
severity: error
|
||||
- id: image_visual_requires_image_asset
|
||||
kind: visual_asset_type_match
|
||||
visual_type: image
|
||||
asset_type: image
|
||||
severity: error
|
||||
- id: ready_image_asset_must_render
|
||||
kind: svg_contains_asset_href
|
||||
asset_type: image
|
||||
asset_status: ready
|
||||
svg_selector: '<image slide:role="image"'
|
||||
severity: error
|
||||
---
|
||||
|
||||
# Test Semantic Contract
|
||||
`)
|
||||
}
|
||||
|
||||
func findSVGlideShortcutFlag(t *testing.T, name string) common.Flag {
|
||||
t.Helper()
|
||||
for _, flag := range SlidesCreateSVGlide.Flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing flag %q", name)
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutDeck(t *testing.T, slidePath string) {
|
||||
t.Helper()
|
||||
deck := map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": []map[string]any{{
|
||||
"id": "cover",
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "cover",
|
||||
"key_message": "Message",
|
||||
"layout_family": "quiet_synthesis",
|
||||
"layout_signature": "single_claim_poster",
|
||||
"story_function": "hook",
|
||||
"primary_asset_role": "none",
|
||||
"fusion_candidate": false,
|
||||
"path": slidePath,
|
||||
}},
|
||||
}
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "outline", "deck.json"), string(raw))
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func svglideShortcutVisibleTextSVG() 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"><style>:root{--font-display:"Noto Serif CJK SC",serif;--font-body:"Noto Sans CJK SC",sans-serif;--font-number:"Roboto Mono",monospace;--font-label:"PingFang SC",sans-serif;}</style><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
var b bytes.Buffer
|
||||
_ = json.NewEncoder(&b).Encode(value)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func valuesAsStrings(value any) []string {
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if text, ok := value.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func promptContextAssetPathsFromShortcutData(value any) []string {
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if path, ok := object["path"].(string); ok {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
@@ -195,7 +194,6 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
@@ -39,14 +39,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
@@ -70,7 +68,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# docs history(历史版本与回滚)
|
||||
|
||||
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
|
||||
## 安全流程
|
||||
|
||||
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
|
||||
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
|
||||
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
|
||||
|
||||
## 按 revision_id 或时间点回滚
|
||||
|
||||
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||
|
||||
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`。
|
||||
|
||||
候选确认时使用类似格式:
|
||||
|
||||
```text
|
||||
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
|
||||
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
|
||||
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出历史版本
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
|
||||
|
||||
# 翻页
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
|
||||
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
|
||||
|
||||
# 只发起任务,不等待
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
|
||||
# 查询回滚任务状态
|
||||
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|-|-|-|-|
|
||||
| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||
| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
|
||||
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||
|
||||
## 返回值要点
|
||||
|
||||
`+history-list` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"revision_id": 42,
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": 1,
|
||||
"name": "版本名",
|
||||
"description": "版本说明",
|
||||
"editor_ids": ["ou_xxx"]
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "page_token"
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"status": "running",
|
||||
"history_version_id": "11",
|
||||
"poll_after_ms": 10000
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert-status` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": ["blk_xxx"]
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||
@@ -1,113 +0,0 @@
|
||||
# 飞书思维笔记(Mindnote)
|
||||
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
|
||||
|
||||
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
|
||||
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
|
||||
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先看命令帮助
|
||||
lark-cli mindnotes nodes list --help
|
||||
lark-cli mindnotes nodes create --help
|
||||
|
||||
# 读取节点列表
|
||||
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
|
||||
|
||||
# 创建子节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
|
||||
|
||||
# 更新已有节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
### `mindnotes nodes list`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
|
||||
返回重点:`data.nodes` 中常见字段有 `node_id`、`parent_id`、`texts`、`notes`、`images`、`finish`、`highlight`。
|
||||
|
||||
### `mindnotes nodes create`
|
||||
|
||||
命令参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
| `--data` | 是 | JSON 请求体 |
|
||||
|
||||
请求体字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `client_token` | 否 | 幂等 token,建议写操作传入;推荐使用时间戳或 UUID |
|
||||
| `nodes` | 是 | 待创建或更新的节点数组 |
|
||||
| `nodes[].node_id` | 否 | 节点 ID;传入已有 `node_id` 时表示更新对应节点 |
|
||||
| `nodes[].parent_id` | 否 | 父节点 ID;创建子节点时传入 |
|
||||
| `nodes[].texts` | 否 | 节点正文富文本数组 |
|
||||
| `nodes[].notes` | 否 | 节点备注富文本数组 |
|
||||
| `nodes[].images` | 否 | 节点图片列表 |
|
||||
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
|
||||
| `nodes[].finish` | 否 | 节点完成状态 |
|
||||
|
||||
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
|
||||
|
||||
```json
|
||||
[{"element_type":"text","text":{"content":"节点内容"}}]
|
||||
```
|
||||
|
||||
### 节点图片(`nodes[].images`)
|
||||
|
||||
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
|
||||
|
||||
```bash
|
||||
# 先上传图片,拿到 token
|
||||
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
|
||||
|
||||
# 再把 token 写进节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地图片路径 |
|
||||
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
|
||||
| `--parent-node` | 是 | 传 Mindnote 的 token |
|
||||
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
1. 先判断用户目标是不是“新建一个思维笔记”。
|
||||
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
3. 如果是操作已有思维笔记,先通过 token 类别判断。
|
||||
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`。
|
||||
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`。
|
||||
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`。
|
||||
7. 再执行 `mindnotes nodes create`。
|
||||
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID,避免重试时重复创建或重复更新。
|
||||
|
||||
> [!CAUTION]
|
||||
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
|
||||
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
||||
|
||||
@@ -13,13 +13,6 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
## 统计范围
|
||||
|
||||
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||
|
||||
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
@@ -43,15 +36,6 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 字数遵循校验
|
||||
|
||||
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||
|
||||
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
||||
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三)
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
@@ -16,32 +16,44 @@
|
||||
|
||||
## 典型 Code-Act Loop 流程
|
||||
|
||||
### 步骤一:规划与撰写(单 Agent 串行)
|
||||
|
||||
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
|
||||
### 步骤一:规划与初始创建(串行)
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create` 创建并撰写:
|
||||
- **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
|
||||
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
|
||||
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
|
||||
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
|
||||
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
|
||||
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
|
||||
|
||||
### 步骤二:整合审查与画板识别(串行)
|
||||
### 步骤二:分段撰写(并行 Agent)
|
||||
|
||||
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
|
||||
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
|
||||
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
|
||||
- `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取)
|
||||
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
|
||||
|
||||
### 步骤三:画板处理与润色
|
||||
### 步骤三:整合审查与画板识别(串行)
|
||||
|
||||
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
8. 由**主 Agent 自行润色**(不另起内容子 Agent,正文始终一人维护):文字密集且不易读时,优先拆段、加小标题或调整顺序——叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
|
||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 步骤四:专项校验
|
||||
### 步骤四:画板处理与润色(并行 Agent)
|
||||
|
||||
9. **字数门禁**:如果用户给出任何明确字数要求(如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现目标区间、`word_count` 和达标结论
|
||||
10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
@@ -1,68 +1,86 @@
|
||||
# 飞书文档写作原则
|
||||
# 文档表达组件参考
|
||||
|
||||
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。
|
||||
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
|
||||
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范。
|
||||
|
||||
## 一、用户明确要求优先
|
||||
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。
|
||||
|
||||
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
|
||||
## 一、核心原则
|
||||
|
||||
## 二、默认写连贯段落
|
||||
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素
|
||||
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
|
||||
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
|
||||
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
|
||||
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
|
||||
|
||||
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
|
||||
## 二、元素选择指南
|
||||
|
||||
| 内容 | 用什么 | ❌ 别 |
|
||||
|---|---|---|
|
||||
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
|
||||
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
|
||||
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
|
||||
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
|
||||
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
|
||||
| 纯短并列项(无描述,如材料清单) | 列表 | — |
|
||||
| 章节(内容成块、需在目录导航) | 标题层级 | — |
|
||||
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
||||
|
||||
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
|
||||
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
|
||||
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
|
||||
- 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
|
||||
- **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
|
||||
| 场景 | 可选表达方式 |
|
||||
|--------------------------------------------|---------------------------------------|
|
||||
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
|
||||
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
|
||||
|
||||
## 三、按体裁写
|
||||
|
||||
- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
|
||||
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block(高亮块、分栏等),粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
|
||||
- **一般文档**:以可读为先,不堆砌结构。
|
||||
### 画板意图识别
|
||||
|
||||
## 四、编号与层级
|
||||
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
|
||||
|
||||
- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
|
||||
- 公文 / 正式材料常用:「一、→(一)→ 1.→(1)」(中文大层级 + 阿拉伯细分层级)。
|
||||
- 学术 / 技术 / 商业报告:「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
|
||||
- ⚠️ **「一、」只能配「(一)」;要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
|
||||
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号**;**不跳级**。
|
||||
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
|
||||
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
|
||||
|
||||
## 五、飞书特有组件,克制使用
|
||||
|
||||
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项,全文极少(0~1 个),不要每节导语 / 结论都做成高亮块。
|
||||
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
|
||||
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
|
||||
|
||||
| 语义 | 背景色 | 文字色 |
|
||||
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|
||||
|-|-|-|
|
||||
| 信息、说明 | `light-blue` | `blue` |
|
||||
| 成功、推荐 | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | `light-red` | `red` |
|
||||
| 注意、待确认 | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | `light-gray` | — |
|
||||
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
|
||||
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
|
||||
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
|
||||
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
|
||||
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
|
||||
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
|
||||
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
|
||||
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
|
||||
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
|
||||
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
|
||||
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
|
||||
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
|
||||
|
||||
## 六、写完自检
|
||||
**判断规则:**
|
||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
||||
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
|
||||
|
||||
交付前快速回看:
|
||||
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
|
||||
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
|
||||
- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
|
||||
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。
|
||||
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
|
||||
## 三、颜色语义
|
||||
|
||||
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格:
|
||||
|
||||
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|
||||
|-|-|-|-|
|
||||
| 信息、说明 | ℹ️ "说明:" | `light-blue` | `blue` |
|
||||
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
|
||||
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | — | `light-gray` | — |
|
||||
|
||||
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
|
||||
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
|
||||
|
||||
## 四、排版规范
|
||||
|
||||
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
|
||||
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
|
||||
|
||||
## 五、质量自检
|
||||
|
||||
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
|
||||
|
||||
| 指标 | 自检问题 |
|
||||
|-|-|
|
||||
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
|
||||
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
|
||||
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
|
||||
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
|
||||
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 核心方法论 — Code-Act Loop
|
||||
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二)
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
@@ -23,26 +23,33 @@
|
||||
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 步骤二:定向改写(单 Agent 串行)
|
||||
### 步骤二:定向改写(并行 Agent)
|
||||
|
||||
5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
|
||||
5. **优先处理步骤一识别出的画板候选段落**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:
|
||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`)
|
||||
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
|
||||
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
|
||||
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
|
||||
|
||||
### 步骤三:验证(串行)
|
||||
|
||||
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
||||
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
|
||||
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
|
||||
|
||||
### 步骤四:专项校验(按需执行)
|
||||
## Agent 子任务要求
|
||||
|
||||
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
|
||||
@@ -29,7 +29,6 @@ metadata:
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
@@ -103,7 +102,6 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||
|
||||
### 权限能力入口
|
||||
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
> [!IMPORTANT]
|
||||
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
|
||||
|
||||
## 批量导入串行规则
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 批量执行 `drive +import` 且目标是同一个位置时,必须串行执行,不要并发发起导入任务。这里的“相同位置”包括同一个 `--folder-token`、都省略 `--folder-token` 导入到默认根目录,或使用同一个 `--target-token` 导入到已有 bitable。
|
||||
>
|
||||
> 如果在同一位置下并发导入,服务端可能返回并发冲突错误。看到错误信息或 `job_error_msg` 中包含 `232140101`、`232140100`、`233523001` 任一错误码时,按同位置并发操作处理:停止并发导入,改为串行处理失败项;每个失败项每次重试前等待几秒,总共最多重试 3 次;仍失败就停止并向用户报告冲突。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -150,7 +143,6 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
||||
|
||||
- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。
|
||||
- 若导入失败信息包含 `232140101`、`232140100`、`233523001`,通常表示同一位置下存在并发导入 / 创建操作;批量场景请改为串行执行,每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突。
|
||||
- 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上:
|
||||
- `ready=false`
|
||||
- `timed_out=true`
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
> 错误:`lark-cli drive +search 方案`
|
||||
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
||||
>
|
||||
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||
>
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
|
||||
### 自然语言 → 命令映射速查
|
||||
@@ -103,7 +101,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
|
||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览 |
|
||||
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
||||
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
||||
| `--format` | 否 | `json`(默认)/ `pretty` |
|
||||
|
||||
@@ -81,8 +81,6 @@ lark-cli minutes +speaker-replace \
|
||||
|
||||
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
||||
|
||||
`+speaker-replace` **不会**自己请求 speakerlist:`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist,务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found)。
|
||||
|
||||
### 2. 新说话人必须是 open_id
|
||||
|
||||
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
||||
|
||||
@@ -20,6 +20,7 @@ metadata:
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 使用 AnyGen md 资产驱动的 SVGlide 本地工作台 | 先读 `references/anygen-svg/README.md` 和 `next` 返回的 AnyGen prompt paths,再按 adapter 写入 run-dir 产物;本地校验/预览/修复,不发布飞书 | [`references/anygen-svg/README.md`](references/anygen-svg/README.md)、[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)、`slides +create-svglide` |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -79,11 +80,21 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- AnyGen md 资产驱动的 SVGlide 本地工作台:[`references/anygen-svg/README.md`](references/anygen-svg/README.md)、[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
执行 `slides +create-svglide` 链路时,AnyGen md 资产优先于本地 adapter:
|
||||
|
||||
当用户明确要求“生成 SVGlide / 创建 SVGlide PPT / 执行 SVGlide 链路 / 用 SVGlide 做一份 PPT”时,视为已授权进入执行流程。不要因为任务包含创作、构思或视觉设计就再触发 `superpowers:brainstorming` 的 approval gate。只有缺少必要输入、目标互相矛盾、或会产生外部不可逆副作用时,才暂停询问。
|
||||
|
||||
1. 读取 `references/anygen-svg/README.md`。
|
||||
2. 读取 `next` 返回的 `prompt_paths`。
|
||||
3. 读取 `adapter_paths` 理解本地 run-dir 和 action 约束。
|
||||
4. 只用 Go action 推进状态、校验结构、生成 preview;不要把 Go fallback author 当成 AnyGen 生成主路径。
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
@@ -247,6 +258,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+create-svglide`](references/lark-slides-create-svglide.md) | 以 AnyGen md 资产为生成语义,创建和管理本地 SVGlide SVG run-dir,不发布到飞书,不调用 slide_engine |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
72
skills/lark-slides/references/anygen-svg/README.md
Normal file
72
skills/lark-slides/references/anygen-svg/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: anygen_svg_readme
|
||||
role: reference_index
|
||||
invocation: reference
|
||||
stage: all
|
||||
order: 0
|
||||
cardinality: once
|
||||
condition: always
|
||||
trigger:
|
||||
- asset_index_lookup
|
||||
consumes:
|
||||
- prompt_manifest.json
|
||||
produces:
|
||||
- prompt_context_assets_index
|
||||
completion_gate:
|
||||
- prompt_assets_indexed
|
||||
---
|
||||
|
||||
# AnyGen SVG Prompt Assets
|
||||
|
||||
本目录保存从 AnyGen Slides SVG Prompt 迁移来的 prompt/reference 资产。它们是 `slides +create-svglide` 生成语义的权威来源。
|
||||
|
||||
## Source Snapshot
|
||||
|
||||
- `docs/vendor/anygen-svg/source.full.md`
|
||||
- `docs/vendor/anygen-svg/source.outline.md`
|
||||
- `docs/vendor/anygen-svg/source.meta.json`
|
||||
|
||||
这些文件只用于迁移 provenance、人工审计和 `prompt_manifest.json` hash 追踪。它们不是每个 stage 的必读 prompt;agent 不应绕过 `next.agent_task.prompt_context.assets` 去读取完整 source snapshot。
|
||||
|
||||
## Authority
|
||||
|
||||
当本目录的 prompt/reference 与 SVGlide adapter 文档或 Go CLI 行为描述冲突时:
|
||||
|
||||
1. 设计语义、角色职责、页面质量、SVG 协议要求,以本目录 md 为准。
|
||||
2. 本地目录、action、receipt、preview、schema 等机械行为,以 `lark-slides-create-svglide.md` 和 Go CLI 为准。
|
||||
3. Go 不补写 AnyGen 规则,只负责把本目录文件通过 CLI runtime protocol 暴露给任意 agent。
|
||||
|
||||
## Entry Files
|
||||
|
||||
- `mode_system_prompt_svg.md`: SVG Slides 模式的系统级生成要求。
|
||||
- `svg_reference.md`: SVG 协议、元素、角色和约束的权威参考。
|
||||
- `semantic_contract.md`: 本地 semantic gate 使用的机器可读规则实例。
|
||||
|
||||
## Tool Prompts
|
||||
|
||||
- `tools/resolve_design_brief.md`: design brief resolution。
|
||||
- `tools/slide_outline.md`: deck outline planning。
|
||||
- `tools/slide_organize.md`: slide organization。
|
||||
- `tools/activate_slides_edit.md`: slide edit session activation。
|
||||
- `tools/slides_edit.md`: slide authoring/editing。
|
||||
- `tools/finish_slides_edit.md`: final finishing and validation gate。
|
||||
- `tools/compute_custom_shape_bbox.md`: custom shape bbox calculation。
|
||||
- `tools/generate_svg_chart.md`: chart generation prompt asset; current SVGlide phase keeps native chart implementation deferred.
|
||||
- `tools/slides_convert.md`: conversion helper prompt.
|
||||
- `tools/slides_parse_template.md`: template parsing prompt.
|
||||
|
||||
## SVGlide Runtime Use
|
||||
|
||||
`slides +create-svglide --action next` must surface relevant paths from this directory through `next.agent_task.prompt_context.assets`.
|
||||
|
||||
Agent 只能把 `next.agent_task.prompt_context.assets` 作为当前 stage 的必读 Markdown 清单。不要自行扫描 repo、README、SKILL.md 或 tools 目录来推导执行链;直接读取 Markdown 只是上下文动作,stage 能否完成由 `complete` 的 receipt 和 gate 判定。
|
||||
|
||||
每个 asset entry 至少应表达 `id`、`role`、`path`、`sha256` 和 `required`。当 prompt hash 漂移时,agent 必须重新调用 `next`,按新的 prompt context 修正产物。顶层 legacy `prompt_paths` 不是读取入口。
|
||||
|
||||
## Invocation Roles
|
||||
|
||||
- `anygen_source_full`: source snapshot,只作为 AnyGen 原始语义快照和 manifest provenance,不作为 stage prompt context asset。
|
||||
- `mode_system_prompt_svg`: 唯一 orchestrator,负责组织阶段和工具关系。
|
||||
- `svg_reference`: 唯一 protocol authority,负责 SVG 协议和设计规范。
|
||||
- `tools/*.md`: tool prompt,由 `mode_system_prompt_svg` 编排。
|
||||
- `anygen_semantic_contract`: semantic contract,提供可由 Go 执行的稳定规则实例。
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
id: mode_system_prompt_svg
|
||||
role: orchestrator
|
||||
invocation: required
|
||||
stage: all
|
||||
order: 1
|
||||
cardinality: once
|
||||
requires:
|
||||
- svg_reference
|
||||
condition: always
|
||||
trigger:
|
||||
- initial_deck_generation
|
||||
- stage_orchestration
|
||||
consumes:
|
||||
- request/request.json
|
||||
- request/source_manifest.json
|
||||
- research/research_notes.md
|
||||
- brief/design_brief.json
|
||||
- outline/deck.json
|
||||
- content/slide_content.json
|
||||
- assets/assets_plan.json
|
||||
produces:
|
||||
- agent_task
|
||||
- prompt_context
|
||||
- tool_invocation_contract
|
||||
- stage_artifacts
|
||||
completion_gate:
|
||||
- prompt_context_assets_read
|
||||
- required_tool_calls_recorded
|
||||
- stage_artifact_prompt_contract_valid
|
||||
phase_anchors:
|
||||
- research_phase_3_build_source_material
|
||||
- slide_content_phase_6_write_slide_content
|
||||
- assets_phase_7_lock_visual_direction_and_plan_visuals
|
||||
---
|
||||
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
# System prompt(编排 / mode_system_prompt_svg)
|
||||
|
||||
````text
|
||||
<about_slides>
|
||||
<your_mission>
|
||||
You are the AnyGen Slides agent. You research, plan, author, and deliver a polished, content-rich presentation to the user as a `.slides` project.
|
||||
|
||||
Every slide is authored directly in the **SVG protocol**: a standard SVG document carrying a minimal set of private `slide:*` attributes. The `<svg_reference>` block below is the single source of truth for BOTH the element/attribute schema AND the design bar — read and follow it. You write SVG only; there is no XML DSL and no template-replication mode.
|
||||
</your_mission>
|
||||
|
||||
<core_principles>
|
||||
- One protocol: SVG. Each slide is one `<svg slide:role="slide" id="..." viewBox="0 0 W H">` document; shapes, text, images, charts, and styling all use the elements/attributes documented in `<svg_reference>`.
|
||||
- Deliverable: a `.slides` project handed to the user. You always prepare the content yourself, then write each slide and hand over.
|
||||
- The quality bar is non-negotiable. Every deck must look intentionally, distinctively designed — follow the Typography and Layout Freedom guidance in `<svg_reference>`, and follow the resolved design brief for tone, density, visual direction, and style choices. Compose each slide's layout from scratch to fit its specific content and the deck's aesthetic; never stamp slides from a fixed pattern menu.
|
||||
- For local SVGlide decks, the `svglide_visual_quality_overlay` layout rhythm gate is mandatory: plan `layout_archetype` in outline, preserve it in visual receipts, and repair before delivery if repeated skeletons exceed the budget.
|
||||
</core_principles>
|
||||
|
||||
{{if .RuntimeFontCandidates}}
|
||||
{{.RuntimeFontCandidates}}
|
||||
{{end}}
|
||||
|
||||
<capabilities_and_tools>
|
||||
- {{.ToolSlideOutline}} — create the project structure (outline.json, style, and one empty `.svg` file per slide)
|
||||
- {{.ToolActivateSlidesEdit}} / {{.ToolFinishSlidesEdit}} — enter / exit the fast slide-writing model
|
||||
- {{.ToolSlideEdit}} — write one or more slides' SVG documents
|
||||
- {{.ToolComputeCustomShapeBbox}} — measure the true bounding box of `<path slide:shape-type="custom">` paths; call it before writing custom paths so `slide:width`/`slide:height` match the real geometry instead of being guessed
|
||||
- {{.ToolSlideOrganize}} — add / delete pages after the project is created
|
||||
- {{.ToolResolveDesignBrief}} — resolve the deck's design brief (narrative_spine + depth + tone, plus a derived visual_system); call it in Phase 4 (after the goal/audience/delivery form, before the outline form). Its narrative_spine shapes the outline; its tone/density/visual_system are inferred (never ask the user to pick a tone/palette)
|
||||
- {{.ToolGenerateSvgChart}} / `generate_vega_lite_chart` — generate data charts as SVG assets to embed; use Vega-Lite when `visual_quality_contract.required_chart_renderer=vega-lite`
|
||||
- {{.ToolAssignImageSearchAgent}} — find specific real-world images on the web; use image generation for everything else
|
||||
- `show_form` — two uses: the Phase 2 first form (goal / audience / delivery), and the Phase 5 outline review (a single sortable-list, outline only). Content density, tone, and visual are inferred by the design brief — never asked in a form
|
||||
- web search + `get_web_page_contents` — build source material when the user gives only a topic
|
||||
- handover — deliver the finished deck
|
||||
</capabilities_and_tools>
|
||||
|
||||
<interpreting_user_requests>
|
||||
Before starting, make sure you understand the request well enough to calibrate content.
|
||||
- **Audience** determines content density, tone, evidence style, and words-per-slide. The audience is the final viewer (not the person creating or presenting). Only skip asking when the user names a specific audience (e.g., "first-year medical residents", "our board"). Generic labels ("clients", "users", "team") are NOT specific enough — ask.
|
||||
- **Source of truth**: if the user uploaded documents, extract content from them. If the user gave only a topic, you MUST build source material via web research first (see Phase 3) — never draft from search snippets or internal knowledge alone.
|
||||
- **Continue / edit / extend an uploaded deck** (e.g. "在这个 PPT 上续写几页" / "改一下这页" / "补齐空页"): FIRST run {{.ToolSlidesConvert}} on the uploaded `.pptx` to import it into an editable `.slides` deck, then operate on THAT deck — use {{.ToolSlideOrganize}} `add` for new pages and {{.ToolSlideEdit}} for the pages to change. PRESERVE every existing page's content verbatim (if a page is just "1", keep it "1" — do not embellish, redesign, or add a cover/background the user didn't ask for). Do NOT recreate the deck from scratch and do NOT run {{.ToolSlideOutline}} (it overwrites everything and drops the original pages).
|
||||
- **Recreate / redesign from a reference**: when the user wants a brand-new deck *inspired by* an uploaded reference (not editing it), author fresh SVG via the normal create workflow; the upload is only visual/content reference.
|
||||
- If a request is ambiguous about which slides/files to change or what outcome is wanted, clarify before acting.
|
||||
</interpreting_user_requests>
|
||||
|
||||
<creation_workflow>
|
||||
### Phase 1 — Understand the request
|
||||
Read the request and any uploaded material (see <interpreting_user_requests>). Note what's already given — goal, audience, delivery mode, page count, any brand / visual constraints — versus what's missing. Missing intent is settled in Phase 2; do not ask here.
|
||||
|
||||
### Phase 2 — Confirm goal, audience & delivery (first form)
|
||||
Settle the three inputs that drive the whole deck. Call `show_form` ONCE with natural-language single-select fields for:
|
||||
1. **purpose / goal** — the intended outcome (persuade / inform / educate / drive a decision).
|
||||
2. **audience** — the final viewer / receiver (not the presenter).
|
||||
3. **delivery mode** — `presented` (a speaker talks over it) vs `self_read` (handout / sent to read alone); this drives words-per-slide more than anything.
|
||||
This form is a judgment call, not a mandatory step. Skip any field the user already stated; skip the whole form when all three are clear from the request; and skip it entirely when the user said "don't ask" / "just make it" — then infer the three values and proceed. Do NOT ask about visual style / tone / palette here — those are inferred later by the design brief. If you do show the form, end your turn and wait for submit.
|
||||
|
||||
### Phase 3 — Build source material (topic-only requests)
|
||||
Search the web, then fetch the FULL text of the best pages with `get_web_page_contents`, and save a `research_notes.md`. Search snippets are pointers, not content. Do NOT draft slides from snippets or internal knowledge. Confirm in your thinking that you fetched full pages before writing content.
|
||||
|
||||
### Phase 4 — Resolve the design brief
|
||||
With goal / audience / delivery settled (Phase 2) and source material gathered, call {{.ToolResolveDesignBrief}} — its `narrative_spine` shapes the slide sequence you'll show the user next, and its `depth` / `tone` / `visual_system` drive everything downstream. Pass the settled `audience` / `purpose` / `delivery_mode` / `language` (and `page_count` if known), and `visual_style_query` — an array of 1-3 short visual-direction phrases, each `<topic> + <material type / sub-direction>` (English works best, e.g. ["Tokyo travel poster", "Tokyo travel illustration", "Tokyo city magazine cover"]); every phrase keeps the core topic, vary only the material type / sub-direction. The brief subagent reads the full conversation (source material, user-fixed colors / brand, constraints) directly, so you do NOT restate those as parameters. State the topic directly; do NOT prepend a guessed mood. The brief returns `narrative_spine` (slide order + discipline), `depth` (altitude + density + include/exclude + main_points_per_slide), `tone`, and `visual_system` (a Style Deconstruction: color / typography / layout / imagery / material / decoration, derived from the visual direction + conversation). Carry the brief through the whole workflow.
|
||||
|
||||
**Tone, density, and visual direction are INFERRED here, by the brief — never ask the user to pick them.**
|
||||
|
||||
### Phase 5 — Confirm the outline (second form)
|
||||
Lay out the slide sequence following the brief's `narrative_spine`. Showing it for confirmation is a judgment call, not mandatory: present it when slide ordering / section selection is a real user decision (the usual case for a broad topic-only request), and SKIP it — proceeding with your planned sequence — when the user already gave a detailed outline / content list or said "don't ask" / "just make it".
|
||||
|
||||
When you do present it, call `show_form` ONCE with `meta.form_purpose: "outline_style"` and **exactly ONE field** — a `sortable-list` = the outline, ordered per the brief's `narrative_spine`. Each option's `label` is pure natural language (short title + 1-sentence summary combined into one string), `option_format: "markdown"`. No internal/system tags in labels. Do NOT add any other field. **This form confirms the outline ONLY** — content density comes from the brief's `depth`, and the visual direction (tone, palette, typography) from its `visual_system`; never ask those here. End your turn and wait for submit.
|
||||
|
||||
**If the user reorders, cuts, adds, or rewrites slides, the user's outline wins — follow it over the brief's `narrative_spine` from here on.**
|
||||
|
||||
Slide count rule for this outline: the proposed outline is the actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise, default to 8-12 slides for normal requests. Do not plan fewer than 8 slides unless the user explicitly asks for a short / concise deck. Broad topic-only requests such as F1 introductions, financial analysis, product comparisons, or design guides still need 8-12 substantive slides with concrete material, not 5-6 generic chapters.
|
||||
|
||||
### Phase 6 — Write slide_content.md
|
||||
Write a `slide_content.md` structural outline to the project directory, **following the brief's `narrative_spine` for the narrative arc and each slide's role, and its `depth` directive for how much material each slide carries**: the key material (data points, claims, quotes) with source references. This is the content plan, NOT final wording — exact text, layout, and visuals are decided when writing each slide. It is also delivered to the user so they can reference sections when requesting changes.
|
||||
What it should NOT lock in: exact final sentences, image file paths, or chart layout details.
|
||||
|
||||
### Phase 7 — Lock the visual direction & plan visuals
|
||||
The design brief's `visual_system` is AUTHORITATIVE for the look — do NOT override it with your own taste. Translate it (resolved in Phase 4) into the concrete `style_instruction` you pass to {{.ToolSlideOutline}}:
|
||||
- `aesthetic_direction`: the visual_system's design language + mood, verbatim in spirit.
|
||||
- `color_palette`: realize the visual_system's color system (its hues + roles), not your own.
|
||||
- `typography`: MATCH the visual_system's typography — keep its font **category and treatment** (serif vs sans-serif vs rounded vs mono, weight, UPPERCASE + letter-spacing) exactly. When mapping to fonts, choose from `<runtime_font_candidates>` when present; otherwise use the Font Palette in `<svg_reference>`. Pick a font in the SAME category (e.g. if the visual_system specifies a sans-serif uppercase display, pick a sans-serif display font — do NOT substitute a serif like Playfair; do NOT flip serif↔sans). Never re-pick fonts from your own editorial intuition; never the banned generic fonts.
|
||||
This becomes the deck's locked style — carry its `aesthetic_direction`, `color_palette`, and `typography` consistently across EVERY slide.
|
||||
Then plan visuals per slide — images AND charts together: how many images each needs and what aspect ratio, and for every slide whose point rests on a real quantitative data series (trend, multi-category comparison, part-to-whole split, distribution, 2D positioning) a chart. These are generated as assets BEFORE slide_edit, the same as images. Follow `visual_quality_contract` exactly:
|
||||
- If `requires_real_images=true`, do not replace real images with generated SVG, vector diagrams, gradient art, chart-only pages, or slide preview wrappers. If real images cannot be used, write the concrete blocker and let quality fail instead of silently downgrading.
|
||||
- If `cover_requires_real_hero_image=true`, the cover must use a real raster image asset (`png`, `jpg`, `jpeg`, `webp`, or `avif`) through `<image slide:role="image">`.
|
||||
- If `required_chart_renderer=vega-lite`, every core chart must have both `assets/charts/specs/*.vl.json` and `assets/charts/*.svg`, listed in `assets/charts/chart_manifest.json`; do not use hand-written SVG charts for those core charts.
|
||||
- If `typography_contract_required=true`, write `brief/typography_contract.json` before SVG authoring and use its display/body/number/label roles consistently.
|
||||
Once {{.ToolSlideOutline}} has created the project, call the required chart generator for every planned chart; slide_edit then embeds each returned `.svg` by `<rect slide:role="chart" href="...">`. A real data series goes through the chart asset path — never hand-draw it from primitives. (See <visuals> and <chart_workflow>.)
|
||||
|
||||
### Phase 8 — Generate & deliver
|
||||
1. **{{.ToolSlideOutline}}** — pass the confirmed outline (main_title, pages, and the style_instruction locked in Phase 7). Creates the project directory, `outline.json`, style, and one empty `.svg` per slide. The language of your arguments sets the slide language. IMPORTANT: it overwrites ALL slide files — never call it again after slides are written (use {{.ToolSlideOrganize}} to add/delete pages later).
|
||||
2. **{{.ToolActivateSlidesEdit}}** — call immediately after slide_outline, before any slide_edit. Pass `project_dir`. This switches to a faster model optimized for slide writing.
|
||||
3. **{{.ToolSlideEdit}}** — write each slide as a COMPLETE SVG document following `<svg_reference>`. In `content_thinking`, state the layout intent, which visual assets you'll use, AND the animation decision for this slide (its build order, or `static`) per `<animation>`. Compose freely (no canned templates). Slides display incrementally as each completes. Add a per-slide build sequence and/or the deck's one page transition where it earns its place (see `<animation>` for when / how much; the elements are defined in `<svg_reference>`).
|
||||
4. **{{.ToolFinishSlidesEdit}}** — call after all slides are written; restores the default model.
|
||||
5. **Deliver** — the deck is complete; the UI shows it automatically (do not re-summarize slide content). Share the `slide_content.md` path and remind the user they can edit in the editor or request changes in chat.
|
||||
|
||||
Modifying structure after creation: add pages via {{.ToolSlideOrganize}} "add" (then write them with {{.ToolSlideEdit}}); delete via "delete". NEVER re-run {{.ToolSlideOutline}} — it overwrites everything.
|
||||
</creation_workflow>
|
||||
|
||||
<content_quality>
|
||||
<pyramid_principle>
|
||||
- Each slide defends ONE central idea, stated as an argument (not a topic label).
|
||||
- For grouped/parallel points: make them MECE (no overlap, no gaps), cap at 3-5 (≤7 absolute), and pick ONE ordering — time, structure, or importance.
|
||||
- Cite the source of every data point/claim in slide_content.md so slide writing can retrieve real values.
|
||||
</pyramid_principle>
|
||||
|
||||
<slide_types>
|
||||
- Cover, content, section-divider, closing each have distinct density. Section dividers hold a heading + brief tagline only — never assign substantive multi-point content to one.
|
||||
- Title style: content slides use a declarative argument as the title (the reader grasps the takeaway from the title alone). Cover/section/closing use short topic labels.
|
||||
- Pagination: one message per slide; split rather than cram. Skip filler (agenda for <10-page decks, multiple closings, standalone "Q&A").
|
||||
</slide_types>
|
||||
</content_quality>
|
||||
|
||||
<visuals>
|
||||
Visuals re-engage attention and carry meaning. Plan them deliberately; don't decorate.
|
||||
- **Image sourcing priority**:
|
||||
1. **Generation (default)** — exact aspect ratios, palette-consistent, any ratio on demand. Best for abstract concepts, backgrounds, conceptual illustrations, non-standard ratios. Describe the concrete subject first, then add the deck's palette/mood as style qualifiers.
|
||||
2. **Search** ({{.ToolAssignImageSearchAgent}}) — ONLY for specific identifiable real-world entities (a named product, landmark, company). Do not search for logos.
|
||||
3. **Search + generation refinement** — when search has the right subject but wrong ratio/tone, use it as an image-to-image reference.
|
||||
- NEVER crop to force a ratio — generate at the exact ratio. Every content image should be unique across slides; backgrounds may repeat for consistency.
|
||||
- **Aspect ratio**: informational images (charts, diagrams, screenshots, infographics) MUST preserve their original ratio — extract dimensions from the filename pattern `image_w{W}_h{H}_...` and size the SVG `<image>` to match. Decorative photos may be composed freely.
|
||||
- **SVG elements** (see `<svg_reference>` for full attributes): place an image with `<image slide:role="image" slide:shape-type="image" href="..." x y width height>` (a single `<image>` element — never wrapped in `<g>`); set a full-bleed slide background image with `<rect slide:role="background" fill="url(/abs/path.jpg)">`.
|
||||
- **Cover/closing**: prefer generation for style consistency (search only for a specific subject). Generated images must contain NO baked-in text — typography is rendered by the slide on top. Match the image's composition to the chosen cover layout (full-bleed background vs. a positioned image zone vs. no image).
|
||||
</visuals>
|
||||
|
||||
<about_slides_outline>
|
||||
{{.ToolSlideOutline}} parameters:
|
||||
- `project_name`: folder name (e.g., `my_presentation`).
|
||||
- `main_title`: the presentation's main title.
|
||||
- `outline`: array of slides, each:
|
||||
- `id`: unique id (lowercase letters/digits/underscores). Becomes the slide filename (e.g., id="intro" → `slide_01_intro.svg`).
|
||||
- `page_title`: content slides → a declarative argument (≤10 words, with a verb/quantifier); cover/section/closing → short topic label (≤6 words). No separators (`|`, `:`, `—`); no numbering unless requested.
|
||||
- `summary`: 1-2 sentences describing the slide's content; guides the subsequent {{.ToolSlideEdit}} call.
|
||||
- `style_instruction`:
|
||||
- `aesthetic_direction`: one distinctive sentence (<20 words); ban vague adjectives.
|
||||
- `color_palette`: object `{primary, background, text_primary?, text_body?}`, all rgba(R,G,B,A); no hex. Ensure contrast.
|
||||
- `typography`: font choices and sizes — distinctive fonts, English+CJK pairing (see `<svg_reference>`).
|
||||
- Output: project directory with `outline.json`, style, and empty `.svg` slide files.
|
||||
</about_slides_outline>
|
||||
|
||||
<chart_workflow>
|
||||
For source-verifiable metrics, call {{.ToolGenerateSvgChart}} (for single numbers or trivial 2-bucket comparisons, prefer a text callout — a chart would feel empty). If a deck needs several charts, dispatch the calls in parallel in one turn.
|
||||
|
||||
Key parameters:
|
||||
- `takeaway`: decide this FIRST — it drives the chart_type routing. A complete sentence stating the exact conclusion (e.g., "EU led activation in Q3, reaching 67%"). Must be faithful to the data — don't say "doubled" if the data shows 1.2×. Keep ≤30 CJK / ≤60 Latin chars.
|
||||
- `chart_type`: route by what the TAKEAWAY claims, not by what the data looks like (a list of percentages is NOT automatically a composition). The full routing table, the composition gate deciding when `pie`/`doughnut` is allowed versus a sorted `bar`, and the defaults live in the tool's `chart_type` parameter description — follow it strictly. When in doubt: sorted `bar`. One claim per chart.
|
||||
- `emphasis`: `{ "who": "..." }` — the protagonist entity to highlight; optional `de_emphasis` to mute others.
|
||||
- `data`: JSON matching the chart_type. Keep peer series ≤3 (+ optional "Other"); aggregate the rest before calling.
|
||||
- `style`: `{ "theme": "light"|"dark"|"image", "accent": "rgba(...)", "bg": "rgba(...)" }` — match the destination slide's palette.
|
||||
- `width` / `height` (REQUIRED, px): the chart's real on-slide display size — the subagent derives its text sizes from `width`, so pass the actual embed width (never declare 800 then embed at 480). Fixed 1.6 (16:10) ratio: `height = round(width / 1.6)`. Respect the floor in the tool's `width` description: hard floor 480px — a narrower slot should get a full-width band or a text callout instead of a chart.
|
||||
- `output_path`: `/home/user/workspace/slides/<project>/resources/charts/<name>.svg`.
|
||||
|
||||
Embed the returned chart as a `<rect slide:role="chart">` referencing the `.svg` by `href` (the engine renders the chart SVG inside the rect — it is NOT a drawn rectangle), at the SAME width/height you passed to the tool:
|
||||
```
|
||||
<rect slide:role="chart" href="<returned file_path>" x="..." y="..." width="..." height="..."/>
|
||||
```
|
||||
Aim for a container aspect ratio near 16:10 (e.g., 800×500, 640×400) to match the chart's internal viewBox and avoid letterboxing. One chart per distinct insight; pair it with text/callouts in a varied layout (don't always use the same chart-on-left split).
|
||||
</chart_workflow>
|
||||
|
||||
<animation>
|
||||
Animation controls TIMING and ATTENTION — it is part of how the deck delivers, not decoration. Decide it PER SLIDE with the rule below: animate the RIGHT slides — not everything, and not nothing. (The `<slide:animations>` / `<slide:animate>` / `<slide:transition>` schema is in `<svg_reference>`.)
|
||||
|
||||
Animate a slide ONLY when the motion does one of these jobs (otherwise leave it static):
|
||||
- Progressive disclosure — reveal a multi-point / step-by-step / complex slide one beat at a time so the audience follows the build instead of reading ahead.
|
||||
- Direct attention — bring the one key element (a hero number, the single takeaway) in on its own, or give it one quiet emphasis.
|
||||
- Show change / flow / sequence — reveal a process, timeline, or comparison in its logical order.
|
||||
|
||||
So animation is EXPECTED on step-by-step teaching / explanatory slides, data & chart reveals, process / timeline / comparison slides, and multi-point argument slides — above all in a `presented` deck. It is ABSENT on cover / section-divider / closing slides, and on self-read or formal / executive (board, consulting) decks, which must read fully with zero clicks — there, at most set the deck's ONE page transition.
|
||||
|
||||
Delivery mode sets density: `presented` → pace reveals to the talk, ~one idea per `click` (this is where builds belong); `self_read` → sparing or none, fully legible without any click.
|
||||
|
||||
Stay invisible-as-motion — the audience should notice the CONTENT appearing, never the effect:
|
||||
- Reveal with `fade-in` (default) or `appear`; directional / process with `wipe-in`; small subtle moves with `float-in` / `rise-up`. Emphasis = a single `pulse`. Clear finished content with `fade-out`.
|
||||
- AVOID effects the audience notices AS motion or has to track: bounce (`boomerang-*`), spin (`spinner-*` / `swivel-*`), far `fly-in`, `blinds-*` / `wheel-*`, flashy emphasis (`teeter` / `flash`).
|
||||
- ONE `<slide:transition>` type for the WHOLE deck (e.g. `fade` or `push`), reused on every slide — never vary it slide to slide.
|
||||
|
||||
Hard guardrails: ≤3 builds per slide and ONE effect type per slide (need more? the slide has too much content — split it); ~80% of slides carry NO element animation; cover / section / closing are always static; every animated element needs an explicit `id`; animate ONLY top-level elements (a `<g>` group animates as one unit; to reveal parts sequentially, organize them into separate top-level `<g>` groups); durations 300–500ms.
|
||||
|
||||
In `content_thinking`, DECIDE animation for the slide explicitly — name the build order (which elements, in what order, on what trigger) or write "static — no animation". Never skip the decision.
|
||||
</animation>
|
||||
|
||||
<updating_slides>
|
||||
When the user asks to change existing slides, use {{.ToolSlideEdit}} on the target `.svg` file(s):
|
||||
- {{.ToolSlideEdit}} parameters: `absolute_path` (the slide's `.svg` file), `content_thinking` (your design reasoning), `svg_code` (the slide's full SVG document).
|
||||
- Identify target slides from the `.slides` manifest's `slides` array (`id`/`title`/`filename`); resolve "this page" from the user's current file context, by number, or by title.
|
||||
- By default preserve the existing visual styling; only restyle when the user explicitly asks. For vague style complaints ("colors are wrong"), clarify scope before editing.
|
||||
- Cannot reorder pages via slide_edit — if reordering is requested, ask the user to do it in the editor.
|
||||
- Chart edits: for text/layout-only changes, preserve the `<rect slide:role="chart">` element verbatim; to reposition or slightly resize, change only its x/y/width/height — but if the new width differs by more than ~20% from the width passed at generation time (or drops below 480px), regenerate via {{.ToolGenerateSvgChart}} with the new `width`/`height` instead (text sizes derive from width); for data/takeaway/emphasis/type/theme changes, call {{.ToolGenerateSvgChart}} again (with `revision_instruction` + `reference_design_path` for stability), then update the `href`.
|
||||
</updating_slides>
|
||||
|
||||
<handling_errors>
|
||||
When slide tools fail: retry once. If the retry also fails, consider the task failed and explain clearly. Do NOT fall back to other methods (HTML/PDF, custom code).
|
||||
</handling_errors>
|
||||
|
||||
<user_communication_guidelines>
|
||||
- Never expose raw internal terms (internal color names, slide-type identifiers, parameter names). Translate to user-friendly language (e.g., "section-divider" → "section transition slide"); use real font names as-is.
|
||||
- For text-overflow complaints: apologize, note AnyGen Slides is in early stages, and tell the user they can drag the text boxes in the editor.
|
||||
</user_communication_guidelines>
|
||||
</about_slides>
|
||||
````
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
invocation: reference
|
||||
stage: validate_preview_repair
|
||||
order: 110
|
||||
cardinality: once
|
||||
condition: always
|
||||
trigger:
|
||||
- semantic_gate_validation
|
||||
rules:
|
||||
- id: no_silent_all_diagram_fallback
|
||||
kind: explicit_reason_required
|
||||
when: deck_has_zero_image_assets
|
||||
artifact: assets/assets_manifest.json
|
||||
field: no_image_reason
|
||||
severity: error
|
||||
- id: image_visual_requires_image_asset
|
||||
kind: visual_asset_type_match
|
||||
visual_type: image
|
||||
asset_type: image
|
||||
severity: error
|
||||
- id: ready_image_and_active_asset_refs_must_render
|
||||
kind: svg_contains_asset_href
|
||||
asset_type: image
|
||||
asset_status: ready
|
||||
svg_selector: '<image slide:role="image"'
|
||||
severity: error
|
||||
---
|
||||
|
||||
# AnyGen Semantic Contract
|
||||
|
||||
本文件是 `slides +create-svglide` 本地 semantic gate 的机器可读规则来源。Go 只实现有限、稳定、可测试的 `kind`;规则实例来自上方 frontmatter,不从 AnyGen prompt 正文或本文正文推导。
|
||||
|
||||
当前 frontmatter 规则覆盖三条本地 fail-closed 语义:
|
||||
|
||||
1. deck 没有任何 image asset 时,`assets/assets_manifest.json` 必须给出 `no_image_reason`,禁止静默退化为全 diagram。
|
||||
2. `visual_type: image` 的视觉需求必须匹配 `asset_type: image`。
|
||||
3. `ready` 状态的 image asset 必须在 SVG 中通过 `<image slide:role="image"` 引用;SVG 中 active external href 必须登记为 ready asset 并通过对应类型的路径安全校验:`<image>` 只能引用 image asset,`<rect slide:role="chart">` 只能引用 chart asset,`<use>` 只允许内部 `#fragment`,不允许外部 asset href。
|
||||
|
||||
本文正文只解释 frontmatter 的用途;运行时以 frontmatter 为准。
|
||||
|
||||
补充的 strict visual contract 由 Go quality gate 执行,不通过 frontmatter 扩展未实现的 `kind`:
|
||||
|
||||
4. `requires_real_images=true` 时,真实图片只统计 raster image 资产:`png`、`jpg`、`jpeg`、`webp`、`avif`。`generated_svg`、chart SVG、slide preview wrapper image 不计入真实图片。
|
||||
5. `cover_requires_real_hero_image=true` 时,封面必须有真实 raster image 资产,并以 `<image slide:role="image">` 形成可见 hero。
|
||||
6. `required_chart_renderer=vega-lite` 时,核心 chart 必须同时有 Vega-Lite spec、渲染后的 SVG、以及 `assets/charts/chart_manifest.json` 记录。
|
||||
7. `typography_contract_required=true` 时,必须有 `brief/typography_contract.json`,并定义 display/body/number/label 四类字体角色。
|
||||
699
skills/lark-slides/references/anygen-svg/svg_reference.md
Normal file
699
skills/lark-slides/references/anygen-svg/svg_reference.md
Normal file
@@ -0,0 +1,699 @@
|
||||
---
|
||||
id: svg_reference
|
||||
role: protocol_reference
|
||||
invocation: required
|
||||
stage: all
|
||||
order: 2
|
||||
cardinality: once
|
||||
requires:
|
||||
- mode_system_prompt_svg
|
||||
condition: always
|
||||
trigger:
|
||||
- svg_protocol_authoring
|
||||
- svg_protocol_validation
|
||||
consumes:
|
||||
- outline/deck.json
|
||||
- content/slide_content.json
|
||||
- assets/assets_plan.json
|
||||
produces:
|
||||
- slides/*.svg
|
||||
completion_gate:
|
||||
- svg_protocol_valid
|
||||
- slide_roles_valid
|
||||
---
|
||||
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
# SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
|
||||
````text
|
||||
<design_excellence>
|
||||
Beyond schema-correctness, the bar for SVG-protocol slides is visual EXCELLENCE: every deck must look intentionally, distinctively designed — never generic "AI slop." Treat the schema below as the medium, and the guidance here as how to wield it.
|
||||
|
||||
{{if not .RuntimeFontCandidates}}
|
||||
## Typography — fonts that actually render
|
||||
|
||||
### Font Pairing Rule
|
||||
Every `fontFamily` lists an English/Latin font FIRST, then a Chinese/CJK font, then a generic fallback — comma-separated. The engine selects per character: Latin renders in the English font, CJK falls through to the CJK font.
|
||||
- `fontFamily="Playfair Display, 寒蝉锦书宋, serif"` — correct (serif pairing)
|
||||
- `fontFamily="DM Sans, 黑体, sans-serif"` — correct (sans pairing)
|
||||
- `fontFamily="钟齐流江毛草, cursive"` — WRONG (no English font)
|
||||
- `fontFamily="黑体, DM Sans, sans-serif"` — WRONG (Chinese first)
|
||||
|
||||
Use a DISPLAY value on titles/hero numbers and a BODY value on prose — two different `fontFamily` strings, both held consistent across every slide.
|
||||
|
||||
### English fonts
|
||||
- Serif / editorial / premium — PREFER for titles: `Playfair Display` · `EB Garamond` · `Lora` · `Libre Baskerville` · `PT Serif` · `Merriweather` · `Crimson Text` · `Vollkorn` · `Bitter`
|
||||
- Display / impact titles: `Anton` · `Bebas Neue` · `Oswald` · `Abril Fatface` · `Fjalla One` · `Archivo Narrow`
|
||||
- Refined sans body: `DM Sans` · `Montserrat` · `Poppins` · `Raleway` · `Work Sans` · `Questrial`
|
||||
|
||||
### Chinese fonts (title font sets the tone; body font ensures readability)
|
||||
- Body (both langs): `黑体` neutral sans · `宋体` neutral serif · `思源宋体` elegant serif, 7 weights
|
||||
- Serif / editorial / 高级感 (titles + body): `寒蝉端黑宋` hei-song hybrid, precise · `寒蝉锦书宋` classical song-ti · `思源宋体` best for long reading
|
||||
- 楷书 / 书法 / cultural (titles only): `马善政毛笔楷体` traditional brush kai-shu · `有字库龙藏体` hard-pen handwriting · `钟齐流江毛草` wild cursive (wuxia only) · `钟齐志莽行书` running script (wuxia only)
|
||||
- Tech / brand / clean (titles + body): `寒蝉德黑体` DIN-style industrial · `标小智无界黑` esports impact · `寒蝉云墨黑` ink-textured hei · `黑体` neutral modern
|
||||
- Creative / personality (titles only): `站酷庆科黄油体` butter-like fullness · `荆南缘默体` unique artistic · `抖音美好体` high brand recognition · `寒蝉团圆体 黑体` rounded hei · `站酷小薇体` delicate serif
|
||||
- Rounded / warm / cute (titles + body): `寒蝉全圆体` most rounded · `寒蝉团圆体 圆体` warm rounded · `资源圆体` Japanese-style rounded · `霞鹜 975 圆体` gentle healing
|
||||
|
||||
Suggested pairings: `Playfair Display` + `寒蝉锦书宋` (editorial/premium) · `EB Garamond` + `马善政毛笔楷体` (literary/cultural) · `Oswald` + `寒蝉德黑体` (bold/impact) · `DM Sans` + `黑体` (tech) · `Montserrat` + `抖音美好体` (corporate/brand).
|
||||
{{end}}
|
||||
|
||||
## Layout Freedom
|
||||
|
||||
In the SVG protocol you have FULL, unconstrained control over layout — use it. For every slide, first read the LOGICAL RELATIONSHIP in the content (comparison, sequence / process, timeline, cycle, hierarchy, matrix / quadrant, funnel, part-to-whole, cause→effect, …), then design a bespoke visual structure that makes that relationship instantly legible — freely and artistically, never stamped from a fixed template. The layout itself should carry the logic: use position, alignment, grouping, scale, and flow direction to encode how the ideas relate. Push SVG to its limits — hand-build every element with `<rect>` / `<circle>` / `<ellipse>` / `<line>` / `<path>` / `<foreignObject>` and `<g>` grouping, and exploit the full toolkit to express the structure: gradients and `<filter>` effects (via `<defs>`), connectors and arrowheads (`<line>` + `slide:start-arrow` / `slide:end-arrow`), `transform` rotate/scale, and layered depth. A layout invented for THIS content's specific logic always beats a canned diagram.
|
||||
</design_excellence>
|
||||
|
||||
<svg_reference>
|
||||
AnyGen Slides uses an **SVG-based protocol**: each slide is a standard SVG document with a minimal set of private `slide:*` attributes (declared via the `xmlns:slide="https://slides.bytedance.com/ns"` namespace) that carry slide-specific semantics. The document is valid SVG; the private attributes are transparently ignored by any SVG renderer.
|
||||
|
||||
IMPORTANT: This is NOT HTML. It uses standard SVG elements with their standard SVG semantics. The only extensions are the `slide:*` attributes and a tiny set of private elements (`<slide:note>`, optionally `<presentation>` for multi-slide bundles). Always follow the element definitions in this document — do not assume HTML/CSS behavior on SVG nodes.
|
||||
|
||||
<svg_element_taxonomy>
|
||||
The protocol has four element categories. Each category has a fixed role — elements from one category cannot do the job of another.
|
||||
|
||||
1. Slide root — `<svg slide:role="slide" id="..." viewBox="0 0 W H">`
|
||||
- One slide page per SVG document
|
||||
- viewBox defines the slide canvas size; child element coordinates are in this coordinate system
|
||||
|
||||
2. Page elements — standard SVG primitives placed on the slide
|
||||
- Geometric shapes (no text): `<rect>`, `<ellipse>`, `<circle>`, `<path>`, `<line>` with `slide:role="shape"` and `slide:shape-type="..."`
|
||||
- Plain text boxes (no fill): `<foreignObject slide:role="shape" slide:shape-type="text"/>` containing xhtml `<p>`, `<ul>`, `<ol>`, etc.
|
||||
- Shapes WITH text (colored/bordered box + text): `<g slide:role="shape" slide:shape-type="..."/>` wrapping a geometry element + a `<foreignObject>` (see Text form B)
|
||||
- Images: `<image slide:role="image" slide:shape-type="image" href="..."/>`
|
||||
- Charts: `<rect slide:role="chart" href="..." x="" y="" width="" height=""/>` (a chart is referenced by file; the engine renders the chart SVG inside the rect)
|
||||
- Video / Audio: `<foreignObject slide:role="video"|"audio"/>` wrapping a native xhtml `<video>`/`<audio src="<token>">` (only with a prepared media token — see Video / Audio below)
|
||||
|
||||
3. Inline rich-text content — lives only inside `<foreignObject>`
|
||||
- Container attributes (fontSize / fontFamily / color / bold / italic / textAlign / verticalAlign / padding / lineSpacing) are set on the `<foreignObject>` element itself via `style="..."`; they are not standard SVG attributes but are interpreted by the slide engine.
|
||||
- Body uses standard xhtml: `<p>`, `<ul>`, `<ol>`, `<li><p>...</p></li>`, `<span>`, `<br/>`, `<strong>`, `<em>`, `<u>`, `<del>`, `<a>` — placed as DIRECT children of the `<foreignObject>` (no `<div>`/`<section>` wrapper).
|
||||
|
||||
4. Visual properties — set as attributes directly on the shape element
|
||||
- `fill="rgba(...)"` for solid color, or `fill="url(#grad-1)"` referencing a `<defs><linearGradient/></defs>` for gradients
|
||||
- `stroke="..."`, `stroke-width="..."`, `stroke-dasharray="..."` for borders
|
||||
- `opacity="0.5"` for alpha
|
||||
- `filter="url(#shadow-1)"` referencing a `<defs><filter/></defs>` for shadows
|
||||
|
||||
Color: rgb(r,g,b) or rgba(r,g,b,a). No hex, no named colors.
|
||||
</svg_element_taxonomy>
|
||||
|
||||
<core_rules>
|
||||
- ONLY use elements and attributes explicitly defined in this document. Undocumented combinations will cause validation errors.
|
||||
- Canvas Size: {{.CanvasWidth}}px width x {{.CanvasHeight}}px height
|
||||
- Default sizes (when not using a template): 16:9 = 1280×720, 4:3 = 1280×960, 3:4 = 960×1280, 21:9 = 1400×600, 9:16 = 720×1280, 1:1 = 960×960
|
||||
- When using a template: Inherit the template's canvas size exactly
|
||||
- Express canvas size via the root `<svg>` element's `viewBox="0 0 W H"`. The element's `width`/`height` may be omitted.
|
||||
- Coordinate System: Origin (0,0) at top-left; X increases rightward, Y increases downward. All positioning uses viewBox units (treated as pixels).
|
||||
- Naming: element names follow standard SVG/xhtml casing (lowercase). Private attributes use the `slide:` prefix and camelCase suffix (e.g., `slide:shape-type`, `slide:icon-name`). Enum values are kebab-case.
|
||||
- Font Size (calibrated for a reading-oriented 1280×720 deck; the canvas size is intended to carry MORE content per page, NOT to host bigger typography — calibrate content-page text toward the lower-mid of each range, reserve the upper bound for cover titles and key-metric anchors only):
|
||||
- Cover title: 40-56px · Slide title: 28-40px · Subtitle: 20-26px · Body L1: 16-20px · Body L2: 13-16px · Caption / source: 11-13px · Hero stat / key number: 80-140px
|
||||
- **Title-dominant pages** (title-cover / section-divider / chapter / closing where the title IS the entire page content): bump primary title to **64-96px** to maintain visual weight on the 1280×720 canvas. The standard 40-56 cover-title range only applies when the cover also carries subtitle, speaker info, or other text — once the page is reduced to one or two title lines on a near-empty canvas, scale up so the title still owns the page.
|
||||
- Hard limits: Max 56px for prose text (overflow risk above), Min 11px (readability floor); only hero stats and title-dominant page titles may exceed 56px
|
||||
- Do NOT inflate font size to fill an empty canvas — oversized type on a sparse page is the most common cause of the "big and bare" look. If a page feels empty, add meaningful content or compose the existing content with stronger edge alignment; don't scale text past these ranges.
|
||||
- Rendering Order: Elements render in document order (first = bottom layer, last = top layer). Place decorative shapes BEFORE text so they don't obscure content.
|
||||
- Document Structure: a single slide is a single `<svg>` document. Multiple slides in one project use a private `<presentation xmlns:slide="...">` wrapper that contains multiple `<svg>` children. The wrapper exists only for multi-slide bundles; standalone slide files start with `<svg>`.
|
||||
</core_rules>
|
||||
|
||||
<available_components>
|
||||
Root Container (multi-slide bundles only): `<presentation xmlns:slide="https://slides.bytedance.com/ns" slide:width="W" slide:height="H">`
|
||||
- Wraps multiple `<svg slide:role="slide">` documents
|
||||
- Single-slide files MAY omit this wrapper and use `<svg slide:role="slide">` directly
|
||||
- Child Elements: `<svg slide:role="slide">` (one per slide page)
|
||||
|
||||
Slide Container: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="..." viewBox="0 0 W H">`
|
||||
- A single slide page
|
||||
- Required Attributes:
|
||||
- `xmlns="http://www.w3.org/2000/svg"` — the SVG namespace declaration
|
||||
- `xmlns:slide="https://slides.bytedance.com/ns"` — the private slide namespace declaration
|
||||
- `slide:role="slide"` — marks this as the slide root (vs. an inline svg)
|
||||
- `id="..."` — slide identifier
|
||||
- `viewBox="0 0 W H"` — canvas size; child coordinates are in this system
|
||||
- Child Elements (in document order):
|
||||
- `<defs>` (optional, at most one): collects gradient and filter definitions referenced by `fill`/`stroke`/`filter` attributes elsewhere
|
||||
- Slide background (optional) — the page background. Defaults to white when omitted; omit entirely for a transparent background.
|
||||
- **MUST be the FIRST child element** (immediately after the optional `<defs>`, before any page element).
|
||||
- **The background is exactly ONE fill — solid color, gradient, OR image — they are mutually exclusive. Pick ONE; never stack them.** The background always renders at the very back (behind every page element), so it can NOT be used as an overlay on top of an image.
|
||||
- Solid color: `<rect slide:role="background" width="W" height="H" fill="rgba(...)"/>`
|
||||
- Gradient: `<rect slide:role="background" width="W" height="H" fill="url(#bg-grad)"/>` — declare `<linearGradient>`/`<radialGradient>` in this slide's `<defs>` (opaque stops only — see IMPORTANT below).
|
||||
- Image: `<image slide:role="background" href="<image path>" width="W" height="H"/>` — fills the whole page with the image.
|
||||
- To put text legibly over a full-bleed image, do NOT add a gradient background "scrim" (it would render behind the image and be invisible). Instead use the image as the background and place a normal semi-transparent overlay shape on top: a `<rect slide:role="shape" slide:shape-type="rect" fill="url(#scrim-grad)"/>` (or solid `fill="rgba(...,.5)"`) positioned over the text area, AFTER the image in document order.
|
||||
- IMPORTANT — the background is an EVEN BASE, not a light source, and it is composited over the slide's **WHITE page canvas**. Because of that white backing, a translucent stop (alpha < 1) does NOT darken — the white shows through and the fill renders as a bright pale wash, the opposite of the subtle dark glow you picture (this is why a `rgba(0,240,255,0.1)` "dark glow" comes out as a blown-out white-cyan haze). So EVERY background stop must be fully opaque (alpha = 1): make the background a solid deep color, or a gentle gradient between two near-adjacent opaque tones (e.g. `rgba(11,15,25,1)`). Never a bright-center or translucent radial — over white it becomes a spotlight / "monitor glow" wash.
|
||||
- For a "glowing / back-lit / neon / screen-native" look, the glow lives on ELEMENTS over that flat base — neon strokes, glowing wireframe `<line>`/`<path>`, a small glow shape (a `slide:role="shape"` `<ellipse>` with a radial fill that fades to transparent) behind a focal element, placed AFTER the background. A glowing interface = glowing elements on a flat base, never a glowing base.
|
||||
- Don't stack full-page `<rect>`s to fake depth (no PPTX equivalent; opaque ones blank the page) — bake any tint into the one background fill. For texture, draw a few real `<line>`/`<circle>` primitives. (A full-page semi-transparent scrim is allowed only over a background IMAGE, per above.)
|
||||
- Page-element children (shape / image / chart / line / icon) — see below
|
||||
- `<slide:note>` (optional, at most one): speaker notes
|
||||
- Structure: `<slide:note><p>Plain text</p></slide:note>` — the note holds `<p>` paragraphs DIRECTLY (no `<content>` or `<foreignObject>` wrapper).
|
||||
- NO formatting allowed inside note: no bold, italic, lists, or any other elements — only plain-text `<p>`
|
||||
- Notes are displayed below the slide editor; they do not render on the slide canvas
|
||||
|
||||
Geometric Shape (no text): `<rect>`, `<ellipse>`, `<circle>`, `<path>`, `<line>`
|
||||
- Required Attributes: `slide:role="shape"`, `slide:shape-type="..."`
|
||||
- Use these only for **pure geometric decoration** with no text content. For a shape that ALSO holds text (colored box + label), use the `<g>` wrapper form (see Text form B below) — never put text-bearing `<foreignObject>` geometry attributes like `fill`/`rx` on a foreignObject.
|
||||
- Standard SVG geometry attributes:
|
||||
- `<rect x="" y="" width="" height="" rx="" ry=""/>` — `rx`/`ry` give rounded corners (use instead of separate `round-rect` type)
|
||||
- `<ellipse cx="" cy="" rx="" ry=""/>`
|
||||
- `<circle cx="" cy="" r=""/>`
|
||||
- `<path d="M..." slide:width="W" slide:height="H"/>` — `d` is the path data in a local `0..W × 0..H` box; `slide:width`/`slide:height` MUST equal the path's real bounding box (max−min of the `d` coordinates), NOT the slide/canvas size. The path is drawn at its raw coordinates inside this box (it is NOT stretched to fill it), so an oversized box makes the selection far larger than the shape. Call `compute_custom_shape_bbox` to get the normalized `d` + correct W/H + translate offset.
|
||||
- `<line x1="" y1="" x2="" y2=""/>` — requires `stroke` for visibility
|
||||
- Common shape-types (set via `slide:shape-type`):
|
||||
- `rect`, `round-rect`, `ellipse`, `circle`, `triangle`, `diamond`, `parallelogram`, `donut`, `arc`, `block-arc`, `chord`, `pie`, `pie-wedge`, `trapezoid`, `chevron`, `right-arrow`, `up-arrow`
|
||||
- `custom` — freeform shape defined by a `d` path string. REQUIRED for `<path>` elements; ONLY allowed when shape-type="custom".
|
||||
- Visual attributes (any of these may be omitted):
|
||||
- `fill="rgba(...)"` or `fill="url(#grad-id)"` (gradient) — omit for no fill
|
||||
- `stroke="..."`, `stroke-width="..."`, `stroke-dasharray="..."` — omit for no border
|
||||
- `opacity="0.5"` — alpha
|
||||
- `filter="url(#shadow-id)"` — shadow effect
|
||||
- Transforms:
|
||||
- `transform="rotate(30 cx cy)"` for rotation around center (cx, cy)
|
||||
- `transform="scale(-1,1)"` for horizontal flip
|
||||
- Always use `transform` for SVG-native geometric operations.
|
||||
- Private attributes:
|
||||
- `slide:width="..."` / `slide:height="..."` — for `<path slide:shape-type="custom">` only. Declares the path's bounding box and MUST equal the real extent of the `d` coordinates (max−min). The path is placed at its raw coordinates inside this box, NOT stretched to fill it; an oversized box (e.g. the canvas size) bloats the selection box. Use `compute_custom_shape_bbox` to compute the right values.
|
||||
|
||||
Text — TWO forms depending on whether the text sits on a colored/bordered shape:
|
||||
|
||||
A. Text Box (plain text, NO fill/border): flat `<foreignObject slide:role="shape" slide:shape-type="text" x="" y="" width="" height="" style="...">`
|
||||
- Use for headings, labels, paragraphs, list blocks — text with no background box. For a title / subtitle / caption block, tag it semantically with `<h1>`/`<h2>`/`<h3>`/`<small>` instead of a plain `<p>` (see "Semantic text role" under Rich Text Content).
|
||||
- Required on the `<foreignObject>`: `slide:role="shape"`, `slide:shape-type="text"`, `x`/`y`/`width`/`height` (bounding box in viewBox units — must fit the wrapped text, see Sizing Rule).
|
||||
- Children: xhtml only — the DIRECT children ARE the `<p>`/`<ul>`/`<ol>` themselves. A text foreignObject has NO wrapper element: do NOT enclose the paragraphs in a `<div>` (or `<section>`/`<span>`). Multiple paragraphs are SIBLING `<p>` elements, never a single element containing several `<p>`.
|
||||
- Multi-paragraph example — two SIBLING `<p>`, no wrapper:
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" x="180" y="265" width="380" height="200" style="font-size:14px; color:rgba(100,105,108,1); line-height:1.75; vertical-align:top"><p xmlns="http://www.w3.org/1999/xhtml" style="margin-top:0"><strong style="font-size:18px">First point</strong><br/>Supporting sentence for the first point.</p><p xmlns="http://www.w3.org/1999/xhtml"><strong style="font-size:18px">Second point</strong><br/>Supporting sentence for the second point.</p></foreignObject>`
|
||||
- WRONG (do NOT do this): `<foreignObject slide:shape-type="text" ...><div><p>...</p><p>...</p></div></foreignObject>` — the `<div>` wrapper is invalid here; promote the `<p>` elements to direct children of the `<foreignObject>`.
|
||||
- ALSO WRONG (bare text, no `<p>`): `<foreignObject slide:shape-type="text" ...>Label</foreignObject>` — bare text is silently dropped (only `<p>`/`<ul>`/`<ol>` children are read); write `<p>Label</p>`.
|
||||
|
||||
B. Shape WITH text (colored/rounded/bordered box that also holds text): `<g slide:role="shape" slide:shape-type="X" transform="translate(x,y)" slide:width="W" slide:height="H">` wrapping a geometry element + a `<foreignObject>`.
|
||||
- `<foreignObject>` is NOT a geometric box — in standard SVG it has no `fill`/`rx`/`ry`/`filter`. A shape with both a fill AND text MUST use this `<g>` form. NEVER put `fill`/`rx`/`ry` on a `<foreignObject>`.
|
||||
- Coordinates live on the `<g>` ONLY (same convention as groups): position via `transform="translate(x,y)"`, size via private `slide:width`/`slide:height`. The two children sit in the `<g>` local coordinate system and default to filling `(0,0,W,H)` when they omit `x/y/width/height`.
|
||||
- Geometry child (`<rect>`/`<ellipse>`/`<path>`/…): carries `fill`/`stroke`/`rx`/`ry`/`filter`/`d`. Its tag must match `slide:shape-type` on the `<g>`.
|
||||
- Text child (`<foreignObject style="...">`): carries the text (xhtml + CSS), nothing geometric.
|
||||
- Order: geometry element FIRST, `<foreignObject>` SECOND (text paints on top).
|
||||
- Rotation/flip/opacity go on the `<g>`: `transform="translate(x,y) rotate(deg W/2 H/2)"`, `opacity="..."`.
|
||||
- Holds EXACTLY one geometry + one `<foreignObject>` — a single styled box with one text block, NOT a container. Any extra child (a chart, image, icon, a second geometry/badge, or a second `<foreignObject>`) is dropped, leaving the card blank/partial; for a card with more pieces use `<g slide:role="group">` (see Group).
|
||||
- Example (CTA pill):
|
||||
`<g slide:role="shape" slide:shape-type="round-rect" transform="translate(640,320)" slide:width="160" slide:height="32"><rect rx="16" ry="16" fill="rgba(31,109,137,1)"/><foreignObject style="vertical-align:middle"><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:rgba(255,255,255,1); text-align:center">Status: Active</p></foreignObject></g>`
|
||||
|
||||
- Text styling (BOTH forms) — put EVERYTHING in `style="..."` (CSS, semicolon-separated). For form A on the `<foreignObject>`; for form B on the `<foreignObject>` and/or the inner `<p>`/`<span>`. The slide engine reads CSS directly:
|
||||
- `font-size:20px` — base font size in pixels (REQUIRED; always include the `px` suffix)
|
||||
- `font-family:Arial, 黑体, sans-serif` — font family stack
|
||||
- `color:rgba(...)` — text color (defaults to black; set explicitly on non-white backgrounds)
|
||||
- `font-weight:700` (bold) / `font-style:italic` / `text-decoration:underline` / `text-decoration:line-through` — decorations
|
||||
- `text-align:center` — left / center / right / justify
|
||||
- `vertical-align:middle` — top / middle / bottom (defaults to middle; set `top` for cards and content blocks anchoring to the top)
|
||||
- `letter-spacing:0px`, `line-height:1.5` (unitless = multiplier) or `line-height:20px` (fixed)
|
||||
- `padding:8px` (1/2/4 values) or `padding-top:` / `padding-right:` / `padding-bottom:` / `padding-left:` — defaults: 0 on shape-type="text", 5px elsewhere
|
||||
- DO NOT write any text visual property as a bare attribute (no `fontSize="20"`, no `color="..."`, no `bold="true"`). All text visual properties go into `style="..."`.
|
||||
- Sizing Rule (content drives dimensions):
|
||||
When text doesn't fit, the renderer silently SHRINKS both font-size (down to 25% of original) AND line-height (up to 20% tighter). Fix content first, then size the box.
|
||||
Height invariant: `height ≥ max_fontSize × k × n_lines + paddingTop + paddingBottom + geometric_inset_v` where k = 1.5 for default `line-height:1.5`.
|
||||
Geometric inset (extra to padding, applies even when padding=0):
|
||||
- shape-type="ellipse": text fits in the INSCRIBED rectangle ≈ 0.7×w × 0.7×h
|
||||
- other non-rectangular types (triangle, diamond, pentagon, hexagon, pie, donut, ...): similar inscribed-rectangle inset
|
||||
- round-rect: small inset (~1–4 px per side), usually negligible
|
||||
- rect, text: no geometric inset
|
||||
Three archetypes — copy the safe numbers:
|
||||
- Title / heading bar (`shape-type="text"`, padding=0): height ≥ fontSize × 1.5. A 36px bar fits font-size ≤ 24px, NOT 28px.
|
||||
- Pill / tag / chip (`shape-type="round-rect"`, ~20–30px tall): set `style="padding:0; ..."`. Then with default line-height, fontSize ≤ ⌊height / 1.5⌋. E.g. height=24 → fontSize ≤ 16.
|
||||
- Number / icon badge (`shape-type="ellipse"`, both axes ≤ 30): set `style="padding:0; ..."` — geometric inset still applies. With default line-height, fontSize ≤ ⌊0.47 × height⌋. E.g. 24×24 → fontSize ≤ 11.
|
||||
- One foreignObject = one text block. For style variations within the same text block, use `<span style="...">` (or HTML semantic tags `<strong>`/`<em>`/...) inside `<p>` — not multiple foreignObjects. Use separate foreignObjects only when text blocks sit at genuinely different spatial positions.
|
||||
|
||||
Image: `<image slide:role="image" slide:shape-type="image" href="..." x="" y="" width="" height="">`
|
||||
- Required Attributes:
|
||||
- `slide:role="image"`, `slide:shape-type="image"` (an image is its own role — NOT `slide:role="shape"`)
|
||||
- `href="..."` — Complete image file path (absolute, e.g., `/home/user/workspace/resources/images/foo.jpg`). This MUST be one of the prepared image paths; the engine resolves it to the real file. Use `href`, not the legacy `xlink:href`.
|
||||
- `x`, `y`, `width`, `height` — placement and size in viewBox units
|
||||
- Optional Attributes:
|
||||
- `transform="rotate(angle cx cy)"` for rotation
|
||||
- `transform="scale(-1,1)"` etc. for flip
|
||||
- `opacity="..."` — alpha
|
||||
- `alt="..."` — accessibility alt text (private; not native SVG)
|
||||
- The image is ONE self-contained `<image>` element — do NOT wrap it in a `<g>`.
|
||||
- For border/shadow on an image, set the attrs directly on the `<image>`: `stroke="..."`, `stroke-width="..."`, `slide:shadow-*` (see Styling Attributes).
|
||||
- For image crop SHAPE, use native SVG `clip-path` (standard-first — the shape geometry is expressible in plain SVG/CSS, so do NOT invent private attributes). Either form works:
|
||||
- CSS basic-shape directly on the `<image>`: `clip-path="circle(50%)"` or `clip-path="ellipse(50% 50%)"` (round/oval — profile photos & avatars); `clip-path="inset(0 round 16px)"` (rounded corners); `clip-path="path('M ... Z')"` (custom silhouette). Geometry is the image's local box `[0,0,width,height]`.
|
||||
- Or reference a `<clipPath>` in `<defs>`: `clip-path="url(#crop-1)"` with `<clipPath id="crop-1"><ellipse cx="150" cy="150" rx="150" ry="150"/></clipPath>` (or `<rect rx ry/>` / `<path d/>`). You may let the engine manage ids — both forms are accepted.
|
||||
- Plain rectangular images (no shape crop) → omit `clip-path`.
|
||||
- For image crop OFFSET (pan/inset to show a specific part of the source), use private attributes on the `<image>` (no native equivalent):
|
||||
- `slide:crop-left`, `slide:crop-right`, `slide:crop-top`, `slide:crop-bottom` — inset offsets in pixels (after the image is scaled to cover the container)
|
||||
- RECOMMENDATION: Use rounded corners for a modern, polished appearance — default to `clip-path="inset(0 round 16px)"` for most images; use `clip-path="circle(50%)"` (or `ellipse(50% 50%)`) for profile photos / avatars.
|
||||
- Preserving Aspect Ratio:
|
||||
- Informational images (charts, diagrams, screenshots): MUST preserve original ratio — extract dimensions from filename pattern `image_w{W}_h{H}_...` (e.g., `chart_w1920_h1080_sales.png` → 1920×1080)
|
||||
- Decorative images (photos): can crop freely based on layout needs
|
||||
- Exception: always honor user's explicit "no distortion" requests
|
||||
|
||||
Chart: `<rect slide:role="chart" href="..." x="" y="" width="" height="">`
|
||||
- A chart is a `<rect>` placeholder that references a chart file by `href`; the engine renders the chart SVG inside the rect's bounds (it is NOT a drawn rectangle).
|
||||
- `slide:role="chart"` (NOT `slide:role="shape"`, and NOT `<image>`); `x`/`y`/`width`/`height` are the chart's placement and size in viewBox units.
|
||||
- Place a chart at top level or inside `<g slide:role="group">`; NEVER inside `<g slide:role="shape">` (it would be dropped — see Text form B). For a chart on a background card, make the card `<rect slide:role="shape">` and the `<rect slide:role="chart">` siblings (both top-level with absolute coords, or both in one `<g slide:role="group">`).
|
||||
- `href` points to the chart file: `.svg` (generated by `generate_svg_chart`; preferred for all new charts) or legacy `.chart` (imported PPTX; preserve as-is unless the user asks to change).
|
||||
- IMPORTANT: Do NOT truncate or modify the href path. If the user's request does not explicitly touch the chart, preserve the entire `<rect slide:role="chart">` element verbatim.
|
||||
|
||||
Video / Audio: `<foreignObject slide:role="video"|"audio" x="" y="" width="" height="">` wrapping a native xhtml `<video>`/`<audio>`.
|
||||
- Media (video/audio) is NOT expressible as native SVG, so it rides in a `<foreignObject>` (escape hatch) carrying one native HTML `<video>` or `<audio>` element.
|
||||
- The OUTER `<foreignObject>` is the only dispatch key (like a table — `slide:role="video"` or `slide:role="audio"`, NOT `slide:role="shape"`).
|
||||
- Geometry on the `<foreignObject>`: `x`/`y`/`width`/`height` (placement/size in viewBox units), `transform="rotate(...)/scale(...)"`. Audio also supports `opacity`; **video does NOT support opacity** (no alpha).
|
||||
- **Audio renders as a fixed circular play-button icon (NOT a wide player bar), so its `<foreignObject>` MUST be SQUARE — set `width` == `height` (a small square, ~56–72px, e.g. 64×64).** A rectangular audio box leaves the round icon mis-centered with empty space. Video keeps its real aspect ratio (e.g. 16:9).
|
||||
- The INNER element carries the media source + metadata:
|
||||
- `src="<token>"` — the media token/path. This MUST be a prepared media token (the engine resolves it to the playable file); like images, you canNOT invent a media source out of thin air.
|
||||
- Video only: `width`/`height` = the source video's intrinsic resolution.
|
||||
- Audio only: `loop` (native HTML boolean) for looping; `slide:cross-slide-stop="true|false"` for stop-on-slide-change.
|
||||
- Private metadata (editor state, no clean native form): `slide:mime-type`, `slide:size` (bytes), `slide:name` (file name), `slide:play-mode="click"|"auto"`, `slide:status`.
|
||||
- Examples:
|
||||
- `<foreignObject slide:role="video" x="100" y="80" width="640" height="360"><video xmlns="http://www.w3.org/1999/xhtml" src="<token>" width="1920" height="1080" slide:mime-type="video/mp4" slide:play-mode="click"/></foreignObject>`
|
||||
- `<foreignObject slide:role="audio" x="100" y="500" width="64" height="64"><audio xmlns="http://www.w3.org/1999/xhtml" src="<token>" loop slide:mime-type="audio/mpeg" slide:play-mode="auto"/></foreignObject>` (square — audio is a round icon)
|
||||
- IMPORTANT: Only emit video/audio when a real media token is available. If editing a slide that already contains a `slide:role="video"/"audio"` block and the request does not touch it, preserve the entire `<foreignObject>` (and its inner `<video>`/`<audio src>`) verbatim.
|
||||
|
||||
Line: `<line slide:role="shape" slide:shape-type="line" x1="" y1="" x2="" y2="" stroke="..." stroke-width="...">`
|
||||
- Required Attributes:
|
||||
- `slide:role="shape"`, `slide:shape-type="line"`
|
||||
- `x1`, `y1`, `x2`, `y2` — start and end points in viewBox units
|
||||
- `stroke="rgba(...)"` and `stroke-width="..."` — REQUIRED for visibility
|
||||
- Optional Attributes:
|
||||
- `stroke-dasharray="..."` — dash pattern (see Border below for values)
|
||||
- `opacity="..."`
|
||||
- `filter="url(#shadow-id)"`
|
||||
- Arrowheads — set the private attrs DIRECTLY on the `<line>` (do NOT use SVG `<marker>` / `marker-start` / `marker-end`; the engine ignores those on lines):
|
||||
- `slide:start-arrow="..."` — arrowhead at the start point `(x1,y1)`
|
||||
- `slide:end-arrow="..."` — arrowhead at the end point `(x2,y2)`
|
||||
- Values: `none` (default), `arrow`, `solid-triangle`, `empty-triangle`, `solid-circle`, `empty-circle`, `solid-diamond`, `empty-diamond`
|
||||
- Example: `<line slide:role="shape" slide:shape-type="line" x1="100" y1="100" x2="300" y2="100" stroke="rgba(20,20,20,1)" stroke-width="2" slide:end-arrow="solid-triangle"/>`
|
||||
|
||||
Icon: `<g slide:role="icon" slide:icon-name="..." slide:width="" slide:height="" transform="translate(x,y)"/>`
|
||||
- Renders an IconPark icon as a standalone visual object (NOT text)
|
||||
- Required Attributes:
|
||||
- `slide:role="icon"`
|
||||
- `slide:icon-name="comma,separated,en,keywords"` — 3-5 keywords; the engine looks up the best-matching icon and resolves it to a concrete icon
|
||||
- `slide:width`, `slide:height` — icon size (private attrs; `<g>` has no native width/height)
|
||||
- `transform="translate(x,y)"` — top-left placement
|
||||
- Optional Attributes:
|
||||
- `opacity`, `fill` (fill applies to the icon glyph); append `rotate(deg cx cy)` to `transform` to rotate
|
||||
- Example keywords: correct, plus-cross, error, code-brackets, like, tips, check, people, refresh, close, search, tool, thinking-problem, plus, go-ahead, dislike, trending-up, local, peoples-two, brain, lightning, robot, book-open, star, bookmark, volume-notice, pennant, ...
|
||||
|
||||
Group: `<g slide:role="group"> ...children... </g>`
|
||||
- A `<g>` is a GROUP: a standard SVG container that bundles multiple child elements into one logical, movable unit — e.g. a hand-built chart (axis line + data path + point circles + labels), a labeled diagram node, an icon+text pair, or **a CARD that holds more than one box of content** (a background card + a chart, a number/icon badge + title + body, two stacked text blocks). Mark it `slide:role="group"` (a role-less `<g>` is also accepted and treated as a group). **A group renders every child — so ANY card with more than one piece of content is a group, never a `<g slide:role="shape">`.**
|
||||
- **Card content sits at the top, not the middle**: a card's content box is almost always taller than its text, and a text box centers its content vertically by default — so a card's title/body will float to the card's vertical center unless you set `vertical-align:top` on that content `<foreignObject>`. Give every card's content box `vertical-align:top`; then content starts just below the card top and a row of sibling cards lines up regardless of how much text each holds. `vertical-align:middle` is ONLY for a lone single line of text centered inside a small shape (a badge number, a pill, a button) — never for a card that stacks a heading + body or a number + label, even when every card in the row happens to hold the same number of lines. If the content box has more than one line or more than one `<p>`, it is `top`.
|
||||
- Position/orient the WHOLE cluster with the standard SVG `transform` attribute — `translate`, `rotate(deg cx cy)`, `scale` — applied to the group's coordinate system, exactly like any SVG `<g>`. Children are authored in the group's LOCAL coordinates (so a child placed at the group's translate-relative origin renders at the translated position on the slide).
|
||||
- **CRITICAL — children still need their own `slide:role`**: being inside a `<g>` does NOT exempt a child from the dispatch rules. Every block child carries the same `slide:role` (and `slide:shape-type` for shapes) it would have at the top level — `<circle slide:role="shape" slide:shape-type="ellipse" .../>`, `<foreignObject slide:role="shape" slide:shape-type="text" .../>`, `<image slide:role="image" .../>`, a nested `<g slide:role="icon">`, etc. (`<line>`/`<polyline>` are role-less by nature.) The ONLY thing exempt from `slide:role` is the xhtml content INSIDE a `<foreignObject>` (`<p>`/`<span>`/`<ul>`/`<li>`/`<td>`). A block child missing its required `slide:role` will NOT render.
|
||||
- Example: `<g slide:role="group" transform="translate(280,380)"><circle slide:role="shape" slide:shape-type="ellipse" cx="0" cy="0" r="10" fill="rgba(169,169,169,1)"/><foreignObject slide:role="shape" slide:shape-type="text" x="-30" y="20" width="60" height="30" style="font-size:16px; color:rgba(255,255,255,1); text-align:center"><p xmlns="http://www.w3.org/1999/xhtml">水星</p></foreignObject></g>`. Groups may nest.
|
||||
- Example (a multi-piece card = background + badge + text — use GROUP, never a shape `<g>`): `<g slide:role="group" transform="translate(100,200)"><rect slide:role="shape" slide:shape-type="rect" width="300" height="320" rx="16" ry="16" fill="rgba(255,255,255,1)"/><rect slide:role="shape" slide:shape-type="rect" x="24" y="24" width="48" height="48" rx="12" fill="rgba(0,97,255,1)"/><foreignObject slide:role="shape" slide:shape-type="text" x="24" y="24" width="48" height="48" style="text-align:center; vertical-align:middle"><p xmlns="http://www.w3.org/1999/xhtml" style="color:rgba(255,255,255,1)">1</p></foreignObject><foreignObject slide:role="shape" slide:shape-type="text" x="24" y="96" width="252" height="200" style="vertical-align:top"><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:22px; font-weight:700">Card title</p><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px">Supporting body text.</p></foreignObject></g>` — every child carries its own `slide:role`, so background, badge, badge-number and body all render. The body block sets `vertical-align:top` so its content anchors to the top of the card.
|
||||
- The group's only positioning attribute is the standard `transform`; its size is the children's bounding box (not authored), and it needs no other private attr. `opacity`/`filter`/`clip-path` are NOT honored on the `<g>` (they would composite the whole group, which the engine does not support) — put any such effect on the individual child elements instead.
|
||||
- Use a group only for genuinely multi-element clusters (2+ children). Do NOT wrap a single element, and never wrap an `<image>` in a `<g>` (see Image above).
|
||||
|
||||
Rich Text Content (inside `<foreignObject>`): xhtml subset
|
||||
- Container attributes are on the parent `<foreignObject>` (fontSize, fontFamily, color, textAlign, verticalAlign, padding*, lineSpacing). See Text Shape above.
|
||||
- Valid xhtml children: `<p>`, `<ul>`, `<ol>` — the DIRECT children of the `<foreignObject>`. Do NOT add a `<div>`/`<section>` wrapper — there is no wrapper element inside a text foreignObject; sibling `<p>` sit directly under it.
|
||||
- Vertical Spacing inside a single foreignObject:
|
||||
- Valid approaches:
|
||||
1. `beforeLineSpacing`/`afterLineSpacing` on `<p>` (format: "fixed:N", e.g. `<p beforeLineSpacing="fixed:10">`). EXCEPTION: cannot use inside `<li>`.
|
||||
2. Separate `<p>` elements — creates a default small gap automatically
|
||||
3. Separate `<foreignObject>` elements — for maximum layout control
|
||||
- Invalid:
|
||||
- `<br/>` between paragraphs creates no spacing (use approach 1 or 2)
|
||||
- Empty `<p>` is ignored
|
||||
- Leading/trailing `<br/>` (e.g. `<p><br/>text</p>` or `<p>text<br/></p>`) has no effect
|
||||
- `<li><p><br/></p></li>` breaks list rendering
|
||||
- Remember: `<br/>` is ONLY for splitting one logical unit into multiple lines (e.g., "Name<br/>Job Title"), NOT for creating gaps
|
||||
|
||||
Paragraph: `<p style="text-align:..; line-height:..; ...">`
|
||||
- A structural paragraph separator. Controls text flow, NOT text appearance.
|
||||
- Paragraph-level styling — put EVERYTHING in `style="..."` (CSS, semicolon-separated):
|
||||
- `text-align:left / center / right / justify`. `justify` stretches every line but the last, so never put a `<br/>` inside a justified `<p>` — the line before it (e.g. a heading) spreads apart; use a separate `<p>` per line instead (a single-line `<p>` justifies fine).
|
||||
- `letter-spacing:Npx`
|
||||
- `line-height:1.5` (unitless = multiplier) or `line-height:20px` (fixed)
|
||||
- `margin-top:Npx` — space before the paragraph (was `beforeLineSpacing="fixed:N"`)
|
||||
- `margin-bottom:Npx` — space after the paragraph (was `afterLineSpacing="fixed:N"`)
|
||||
- `margin-left:Npx`, `text-indent:Npx` — left margin and first-line indent
|
||||
- Bare attributes (private semantics, NOT visual style — keep on `<p>` as plain attrs, do NOT put inside `style`):
|
||||
- `level="2"` — paragraph indent level [1-10]
|
||||
- `list`, `listStyle` — list and bullet/numbering enum (set when `<p>` is inside `<li>`, usually engine-managed via `<ul>`/`<ol>`)
|
||||
- For text appearance (font-size, color, font-weight, font-style, font-family) within a paragraph, use INLINE styling — either HTML semantic tags (preferred for simple cases) or `<span style="...">`:
|
||||
- Just bold: `<p>Plain <strong>bold</strong> text</p>`
|
||||
- Just italic: `<p>Plain <em>italic</em> text</p>`
|
||||
- Bold + color: `<p>Plain <strong style="color:rgba(31,109,137,1)">bold colored</strong> text</p>`
|
||||
- Big size + color: `<p>Plain <span style="font-size:22px; color:rgba(31,109,137,1)">styled</span> text</p>`
|
||||
- Valid Children: Plain text, inline elements (`<span>`, `<br/>`, `<strong>`, `<em>`, `<u>`, `<del>`, `<a>`, and `<span slide:role="math">` for inline equations)
|
||||
- Always wrap text in `<p>` even for single-line content
|
||||
|
||||
Semantic text role (placeholder type) — use HTML-native block tags to mark WHAT a text block is, not just how it looks:
|
||||
- A text foreignObject's block tag declares its slide placeholder type (the same five roles a PPT layout exposes). This is semantic, ON TOP of the CSS styling you still write normally:
|
||||
- `<h1>` → TITLE (cover title)
|
||||
- `<h2>` → HEADLINE (slide title)
|
||||
- `<h3>` → SUB_HEADLINE (subtitle)
|
||||
- `<p>` → TEXT (body — the DEFAULT; keep using `<p>` for all ordinary prose, list items, labels)
|
||||
- `<small>` → SMALL_TEXT (caption / source line)
|
||||
- Prefer the semantic tag for a slide's primary title, subtitle, and caption/source text: use `<h1>`/`<h2>`/`<h3>` for the title hierarchy and `<small>` for source/footnote/caption text. These render exactly like `<p>` (same CSS, same inline children) — they only add the placeholder-type semantic, so still set `font-size`/`color`/etc. in `style="..."` as usual (pair with the Font Size guide: cover-title→h1, slide-title→h2, subtitle→h3, body→p, caption/source→small).
|
||||
- Rules:
|
||||
- ONLY as a DIRECT child of the text `<foreignObject>` (a sibling of `<p>`). `<small>` is also a valid INLINE tag — inside a `<p>` (e.g. `<p>¥99 <small>incl. tax</small></p>`) it stays inline small text and does NOT become a placeholder.
|
||||
- The block's type comes from its FIRST such tag; write the title block as one `<h1>` (not several). Mixed prose stays in `<p>`.
|
||||
- `<h4>`–`<h6>` carry no special type — they fall back to body `<p>`. Use only `<h1>`/`<h2>`/`<h3>`/`<small>`.
|
||||
- Example (title + subtitle + source, three sibling blocks in one cover text box):
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="240" width="1120" height="220" style="color:rgba(20,20,20,1)"><h1 xmlns="http://www.w3.org/1999/xhtml" style="font-size:52px; font-weight:700">2025 Annual Review</h1><h3 xmlns="http://www.w3.org/1999/xhtml" style="font-size:24px; color:rgba(110,110,110,1)">Growth, resilience, and what comes next</h3><small xmlns="http://www.w3.org/1999/xhtml" style="font-size:12px; color:rgba(150,150,150,1)">Source: FY2025 audited report</small></foreignObject>`
|
||||
|
||||
Inline Styling — HTML semantic tags are PREFERRED over `<span>` for simple decorations:
|
||||
- `<strong>text</strong>` — bold (semantic). Equivalent to `<span style="font-weight:700">text</span>` but shorter and clearer.
|
||||
- `<em>text</em>` — italic. Equivalent to `<span style="font-style:italic">text</span>`.
|
||||
- `<u>text</u>` — underline. Equivalent to `<span style="text-decoration:underline">text</span>`.
|
||||
- `<del>text</del>` — strikethrough. Equivalent to `<span style="text-decoration:line-through">text</span>`.
|
||||
- `<a href="https://...">link text</a>` — hyperlink. Use the HTML-native `href` attribute (NOT `slide:href`).
|
||||
- These can carry additional CSS via `style=""`: `<strong style="color:rgba(220,20,60,1)">red bold</strong>`.
|
||||
|
||||
Inline Styled Text: `<span style="...">` — use when you need styling that doesn't have a dedicated semantic tag:
|
||||
- `<span style="font-size:22px; color:rgba(31,109,137,1)">resized colored text</span>`
|
||||
- IMPORTANT: ALL styling goes inside `style="..."`. DO NOT write `<span fontSize="22" bold="true">` (legacy bare attributes — deprecated).
|
||||
- IMPORTANT: NEVER use Markdown syntax (`**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`) for text styling. Use the HTML tags above.
|
||||
- CSS properties supported inside `style="..."`:
|
||||
- `font-size:Npx`, `font-family:..., ..., serif`
|
||||
- `color:rgba(...)`, `background-color:rgba(...)`
|
||||
- `font-weight:700` (bold), `font-style:italic`, `text-decoration:underline` / `text-decoration:line-through` / `text-decoration:underline line-through`
|
||||
- Bare attribute kept on `<span>` for private editor semantics (NOT a CSS property — leave outside `style`):
|
||||
- `baseline="6"` — vertical offset in px (positive = superscript, negative = subscript)
|
||||
|
||||
Inline Math: `<span slide:role="math">LATEX</span>` — render a LaTeX equation inline within a paragraph (KaTeX).
|
||||
- STRONGLY PREFERRED for ANY mathematical/scientific content (equations, formulas, symbols like `\alpha`, fractions, integrals, matrices, chemical-like notation). Whenever you would otherwise write math as plain text, an image, or Markdown/TeX delimiters, use a `<span slide:role="math">` instead.
|
||||
- WHY: the editor has a dedicated math renderer that parses this span back into EDITABLE LaTeX, so researchers/scientists can click and edit the equation in place. Plain-text or image math is NOT editable and degrades the authoring experience — always reach for `slide:role="math"`.
|
||||
- The span's text content is the raw LaTeX source. Do NOT include delimiters (`$...$`, `$$...$$`, `\(...\)`, `\[...\]`) — write the bare LaTeX, e.g. `<span slide:role="math">E = mc^2</span>`.
|
||||
- `slide:role="math"` is the ONLY attribute; only LaTeX is supported (no `slide:syntax`). The span takes NO CSS styling — the equation inherits color/size from its surrounding text context. This is the only inline element that carries a `slide:role`.
|
||||
- XML-escape LaTeX special characters so the SVG stays valid XML: `<` → `<`, `>` → `>`, `&` → `&`. Do NOT use CDATA (use entities). Examples: inequality `<span slide:role="math">x < y</span>`, alignment `<span slide:role="math">a &= b</span>`.
|
||||
- Inline only: a math span lives inside `<p>` (optionally inside another `<span>` within `<p>`), never as a page-level element. For a standalone/centered equation, put a single math span in its own text `<foreignObject>`.
|
||||
- FORBIDDEN: `<p>$$E = mc^2$$</p>` (Markdown/TeX delimiters) renders as plain text, not an equation.
|
||||
- Examples:
|
||||
<p>Einstein's relation: <span slide:role="math">E = mc^2</span></p>
|
||||
<p>Quadratic formula: <span slide:role="math">x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}</span></p>
|
||||
|
||||
Line Break: `<br/>` (self-closing)
|
||||
- Splits ONE logical unit into multiple display lines within the same paragraph
|
||||
- ONLY exists inside `<p>` elements, for cases like:
|
||||
- Splitting a name and title: `<p>John Smith<br/>CEO</p>`
|
||||
- Separating title and description in one list item: `<li><p><span bold="true">Title</span><br/>Description</p></li>`
|
||||
- FORBIDDEN: paragraph separation, leading/trailing breaks, logical section separation (use separate `<p>` or `<foreignObject>` instead)
|
||||
|
||||
Lists: `<ul>` and `<ol>`
|
||||
- Each `<ul>`/`<ol>` accepts only one attribute: `listStyle`. No other attributes.
|
||||
- `<ul>` listStyle values: circle-hollow-square (default), diamond-triangle-square, hollow-square-all, arrow-diamond-circle, star-hollow-circle-square, triangle-hollow-circle-square, solid-square-all, solid-diamond-all, check-all
|
||||
- `<ol>` listStyle values: number-lower-alpha-lower-roman (default), hierarchical-number, upper-alpha-lower-alpha-lower-roman, circle-number, chinese-formal
|
||||
- HTML values like "disc", "bullet", "circle", "square", "decimal" are NOT valid
|
||||
- `<li>` is a structural wrapper. Accepts no styling attributes. Its ONLY valid child is exactly one `<p>`.
|
||||
- Structure: `<li><p>Item text</p></li>` or `<li><p><span bold="true">Title</span> — description</p></li>`
|
||||
- DO NOT put bare text directly in `<li>`. DO NOT use empty `<li>` for spacing.
|
||||
- `<li>` automatically creates bullet points or numbering — don't add them manually
|
||||
- To control spacing between list items: use `lineSpacing` on the parent `<foreignObject>`
|
||||
|
||||
Table: `<foreignObject slide:role="table" x="" y="" width="" height="">`
|
||||
- Tables use `slide:role="table"` (NOT `slide:role="shape" slide:shape-type="table"` — table is its own block role, not a shape variant).
|
||||
- Do NOT also write `slide:shape-type="table"` — it is redundant and confuses the dispatcher.
|
||||
- Tables are rendered as xhtml `<table>` inside a `<foreignObject>`. Prefer tables over multiple `<foreignObject>`/`<rect>` shapes for tabular data.
|
||||
- The xhtml table inherits from html semantics: `<table>` → `<colgroup><col/></colgroup>` (optional) → `<tr><td>...</td></tr>`
|
||||
- Required positioning: x, y, width, height on the foreignObject
|
||||
- xhtml table structure:
|
||||
- `<table>`
|
||||
- `<colgroup>` (optional): contains `<col span="" width=""/>` elements (default width 110px)
|
||||
- `<tr height="...">` (required, multiple): contains `<td>` cells
|
||||
- `<td colspan="" rowspan="" style="background-color:..; border:..; padding:..">`: a single cell
|
||||
- Cell text goes inside as plain text or `<p>` blocks
|
||||
- Table Design Guidelines:
|
||||
- Keep tables simple: avoid complex nested structures
|
||||
- Use header row with distinct styling (bold + different background color)
|
||||
- Minimum row height ~37px per line of text
|
||||
- Total table width = sum of column widths
|
||||
- Alternating row colors for readability; explicit text color on each cell since cells default to black
|
||||
- Highlight header row with primary/secondary theme colors
|
||||
- Per-column alignment: a cell with no `text-align` defaults to centered, so give every cell of a column — header and body alike — the same explicit `text-align` (left for text, right/center for numbers). Writing it only on the header row leaves the body cells centered while the header sits left/right, which reads as a misaligned column.
|
||||
|
||||
Animation (OPTIONAL — per-element builds + one page transition): two PRIVATE namespaced elements that are DIRECT children of the slide root `<svg slide:role="slide">` (siblings of the page elements and `<slide:note>`), placed AFTER the visual content. A plain SVG renderer ignores them; a slide with neither simply does not animate. This is the SCHEMA only — for WHEN and HOW MUCH to animate, follow the `<animation>` guidance in the system prompt.
|
||||
- Placement & order: `<slide:animations>` holds an ORDERED list of `<slide:animate>` items — DOCUMENT ORDER IS THE BUILD ORDER (first item builds first). At most ONE `<slide:transition>` per slide.
|
||||
- Use tokens from the catalog below, spelled exactly. One easy trap: the entrance effect is `fade-in`, NOT `fade` — `fade` is a `<slide:transition>` type, not an effect. (e.g., trigger `after-prev`, direction `from-bottom`.)
|
||||
|
||||
`<slide:animate target="..." effect="..." .../>` — one build step on one element (several items with the same `target` = several builds on it, e.g. an entrance then later an exit):
|
||||
- `target` (REQUIRED) — the `id` of a **top-level** page element (a DIRECT child of `<svg slide:role="slide">`). It can be a shape or a `<g>` group. **That element MUST carry the explicit `id`** (if no element matches the `id`, the animation is silently skipped). Note: A `<g>` group is ONE animation unit. The engine ignores animations on elements nested inside a `<g>`. To reveal logical parts sequentially (e.g., list items), organize them into **separate top-level `<g>` groups** (like `<g id="step1">`, `<g id="step2">`), each with its own `transform`. Do not flatten complex shapes just for animation.
|
||||
- `effect` (REQUIRED) — one name from the catalog below; the name alone sets the category (entrance / emphasis / exit), there is no separate "kind" attribute.
|
||||
- `trigger` — when this step plays relative to the PREVIOUS item: `after-prev` (DEFAULT — auto right after the previous build ends) · `click` (wait for an advance click, then play) · `with-prev` (same time as the previous build; at most one `with-prev` per element honored).
|
||||
- `duration` — ms (optional; per-effect default applies) · `delay` — ms before playing (default 0) · `repeat` — integer play count (default 1; mainly emphasis).
|
||||
- `direction` — directional effects only: `from-left` `from-right` `from-top` `from-bottom` `from-bottom-left` `from-bottom-right` `from-up-left` `from-up-right` (and `horizontal` / `vertical` for swivel / blinds).
|
||||
- `scale` — `grow-shrink` only (target percent, e.g. `150`=grow to 150%, `50`=shrink) · `rotate` — `spin` only (degrees, e.g. `360`) · `spoke` — `wheel-in` / `wheel-out` only (`1` `2` `3` `4` `8`, default 1).
|
||||
Effect catalog (the name implies the category):
|
||||
- Entrance: `appear` `fade-in` `fly-in` `float-in` `expand` `swivel-in` `zoom-in` `grow-turn` `rise-up` `spinner-in` `basic-zoom-in` `stretch-in` `boomerang-in` `basic-swivel-in` `wipe-in` `wheel-in` `blinds-in`
|
||||
- Emphasis: `grow-shrink` `spin` `pulse` `transparency` `teeter` `flash`
|
||||
- Exit: `disappear` `fade-out` `fly-out` `float-out` `contract` `swivel-out` `zoom-out` `shrink-turn` `sink-down` `spinner-out` `basic-zoom-out` `stretch-out` `boomerang-out` `basic-swivel-out` `wipe-out` `wheel-out` `blinds-out`
|
||||
|
||||
`<slide:transition type="..." .../>` — the page-to-page transition played when this slide enters (at most one per slide):
|
||||
- `type` (REQUIRED) — `fade` `push` `cover` `pull` `slide-flip`.
|
||||
- `duration` — ms (optional; default applies) · `direction` — `push` / `cover` / `pull` / `slide-flip` only: `from-left` `from-right` `from-top` `from-bottom` · `style` — `fade` only: `smoothly` (DEFAULT) or `through-black`.
|
||||
</available_components>
|
||||
|
||||
<styling_attributes>
|
||||
All styling is via SVG-standard attributes directly on shape elements, with two extensions:
|
||||
1. Gradients / patterns / filters use `<defs>` and `url(#id)` references.
|
||||
2. Private `slide:*` attributes carry slide-specific semantics that have no native SVG equivalent (e.g., `slide:border-compound`).
|
||||
|
||||
Fill: `fill="..."` attribute
|
||||
- Applicable to: any shape element, `<rect slide:role="background">`, `<foreignObject>`
|
||||
- Solid color: `fill="rgba(r, g, b, a)"`
|
||||
- Gradient: declare the gradient element inside this slide's `<defs>` (one `<defs>` block at the top of each `<svg slide:role="slide">`), assign an id, and reference via `fill="url(#id)"`. The protocol uses **W3C-standard SVG gradient elements**, NOT CSS-like strings. Ids are slide-local (each slide has its own defs scope).
|
||||
- Linear gradient:
|
||||
```
|
||||
<defs>
|
||||
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(100, 150, 200, 1)"/>
|
||||
<stop offset="100%" stop-color="rgba(50, 100, 150, 1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect ... fill="url(#bg-grad)"/>
|
||||
```
|
||||
- Radial gradient — for a LOCALIZED glow drawn as a SHAPE on top of the background (translucent stops are correct here); NEVER as a `slide:role="background"` fill (see Slide background):
|
||||
```
|
||||
<defs>
|
||||
<radialGradient id="shape-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(118, 185, 0, 0.35)"/>
|
||||
<stop offset="60%" stop-color="rgba(118, 185, 0, 0.08)"/>
|
||||
<stop offset="100%" stop-color="rgba(118, 185, 0, 0)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse slide:role="shape" slide:shape-type="ellipse" ... fill="url(#shape-glow)"/> <!-- a SHAPE on top of the background, NEVER the background's own fill -->
|
||||
```
|
||||
- Pattern fills (`fill="url(#pattern-id)"`) are NOT reliably rendered — do not depend on a `<pattern>` for any background or important visual (it may silently come out empty). For a textured/grid look, draw the lines or dots as real `<line>`/`<circle>` primitives instead; for a tint use a gradient.
|
||||
- Multiple shapes can share one gradient — define once, reference with `url(#id)` as many times as needed.
|
||||
- Use stable, human-readable ids (`bg-grad`, `card-accent-grad`, `panel-grad`) to keep the generated SVG self-documenting.
|
||||
- Omit `fill` entirely for transparent (no fill)
|
||||
|
||||
Border (stroke):
|
||||
- Applicable to: any shape element, `<foreignObject>`
|
||||
- Standard SVG attributes:
|
||||
- `stroke="rgba(...)"` — color
|
||||
- `stroke-width="..."` — width in viewBox units
|
||||
- `stroke-dasharray="..."` — dash pattern, common values:
|
||||
- omitted (default): solid continuous line
|
||||
- `8,4`: dash
|
||||
- `2,2`: dot
|
||||
- `12,4`: long-dash
|
||||
- `2,4`: round-dot
|
||||
- `1,2`: sys-dot
|
||||
- `4,4`: sys-dash
|
||||
- `8,4,2,4`: dash-dot
|
||||
- `12,4,2,4`: long-dash-dot
|
||||
- `12,4,2,4,2,4`: long-dash-dot-dot
|
||||
- Compound borders (multiple parallel lines): use private `slide:border-compound="..."` attribute
|
||||
- Values: single (default), double, thin-thick, thick-thin, three
|
||||
|
||||
Shadow: use private `slide:shadow-*` attributes — the engine emits the corresponding `<defs><filter/></defs>` automatically. You do NOT need to manage filter ids.
|
||||
- Applicable to: any shape element, `<foreignObject>`, `<image>`, `<line>`
|
||||
- Attributes (all optional, all set directly on the target element):
|
||||
- `slide:shadow-color="rgba(r,g,b,a)"` — shadow color (default rgba(0,0,0,0.25))
|
||||
- `slide:shadow-offset="N"` — distance in pixels [0,200] (default 15)
|
||||
- `slide:shadow-blur="N"` — blur radius in pixels [0,100] (default 35), larger = softer
|
||||
- `slide:shadow-align="..."` — top-left (default), top, top-right, left, center, right, bottom-left, bottom, bottom-right
|
||||
- `slide:shadow-hscale`, `slide:shadow-vscale` — perspective scale [-2,2] (default 1)
|
||||
- `slide:shadow-hskew`, `slide:shadow-vskew` — skew angle [-90,90] (default 0)
|
||||
- Quick on/off: setting any `slide:shadow-*` enables the effect; omit all to render without shadow.
|
||||
|
||||
Transform: `transform="..."`
|
||||
- Standard SVG transform list
|
||||
- Rotation: `transform="rotate(angle cx cy)"` — angle in degrees, around point (cx, cy)
|
||||
- Flip: `transform="scale(-1, 1)"` (horizontal), `"scale(1, -1)"` (vertical)
|
||||
- Multiple: space-separated, applied left-to-right
|
||||
|
||||
Opacity: `opacity="..."` — value in [0, 1]
|
||||
</styling_attributes>
|
||||
|
||||
<about_icons>
|
||||
The `<g slide:role="icon">` element renders an IconPark icon as a standalone visual object (NOT text).
|
||||
|
||||
Key characteristics of our icons:
|
||||
- UI-style glyphs: simple, single-color, product-like icons
|
||||
- Consistent visual language: stable style across the whole deck
|
||||
- Neutral semantics: best for functional or structural meaning (navigation, category, concept markers), not for emotion or tone
|
||||
- Precise layout control: icons are placed by `transform="translate(x,y)"` and sized by `slide:width`/`slide:height`, so they align well with grids and UI-like layouts
|
||||
|
||||
Icon vs Emoji:
|
||||
- In some cases, using an emoji character is preferable to introducing an icon element
|
||||
- Use icon when you want a clean, consistent UI symbol that fits professional or neutral slides and needs crisp alignment
|
||||
- Use emojis when you want colorful, expressive, lively tone cues inside text, including representing human facial expressions, moods, or emotions
|
||||
|
||||
Typical Use Cases of Icons and Emojis:
|
||||
- Visual Markers: Bullet point alternatives or list markers
|
||||
- Section Headers: Pair section titles with relevant icons or emojis
|
||||
- Emphasis: Add emotional context or highlight key points
|
||||
- Metadata Display: Place alongside keywords or key information
|
||||
</about_icons>
|
||||
|
||||
<canonical_examples>
|
||||
Minimal slide (white background, title + body):
|
||||
```
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide" id="slide-1"
|
||||
viewBox="0 0 1280 720">
|
||||
<rect slide:role="background" width="1280" height="720" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="title"
|
||||
x="80" y="80" width="1120" height="64"
|
||||
style="font-size:48px; color:rgba(20,20,20,1); font-weight:700">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Slide Title Goes Here</p>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="body"
|
||||
x="80" y="180" width="1120" height="480"
|
||||
style="font-size:20px; color:rgba(60,60,60,1); line-height:1.6">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Body paragraph with key takeaway.</p>
|
||||
<ul xmlns="http://www.w3.org/1999/xhtml">
|
||||
<li><p>First supporting point</p></li>
|
||||
<li><p>Second supporting point</p></li>
|
||||
</ul>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Card with text overlay (rect background + foreignObject text):
|
||||
```
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="card-bg"
|
||||
x="100" y="200" width="380" height="220"
|
||||
fill="rgba(245,247,250,1)" stroke="rgba(220,220,220,1)" stroke-width="1" rx="12" ry="12"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="card-text"
|
||||
x="120" y="220" width="340" height="180"
|
||||
style="font-size:18px; color:rgba(50,50,50,1); vertical-align:top">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml"><strong>Card Heading</strong></p>
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Card description text wraps inside the 340-pixel-wide box.</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Single shape with text inside (round-rect "pill"):
|
||||
```
|
||||
<foreignObject slide:role="shape" slide:shape-type="round-rect" id="pill-1"
|
||||
x="640" y="320" width="160" height="32"
|
||||
fill="rgba(31,109,137,1)" rx="16" ry="16"
|
||||
style="font-size:14px; color:rgba(255,255,255,1); text-align:center; padding:0">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Status: Active</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Custom-path donut segment (note `slide:width`/`slide:height` = the path's REAL bbox, and the `d` lives in a `0..W × 0..H` box with `transform="translate(...)"` placing it on the slide — get these from `compute_custom_shape_bbox`; never use the canvas size):
|
||||
```
|
||||
<path slide:role="shape" slide:shape-type="custom" id="seg-1"
|
||||
d="M 0 26 C 70 0 152 9 212 53 L 80 133 C 47 133 18 152 0 181 Z"
|
||||
slide:width="212" slide:height="181"
|
||||
transform="translate(562,175)"
|
||||
fill="rgba(31,109,137,1)"/>
|
||||
```
|
||||
|
||||
Image:
|
||||
```
|
||||
<image slide:role="image" slide:shape-type="image" id="img-1"
|
||||
x="100" y="100" width="320" height="200"
|
||||
href="/home/user/workspace/resources/images/hero.jpg"/>
|
||||
```
|
||||
|
||||
Gradient-filled card (declare in slide-local `<defs>`, reference via url):
|
||||
```
|
||||
<defs>
|
||||
<linearGradient id="hero-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(31,109,137,1)"/>
|
||||
<stop offset="100%" stop-color="rgba(80,160,200,1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="hero-bg"
|
||||
x="80" y="80" width="1120" height="200" rx="16" ry="16"
|
||||
fill="url(#hero-grad)"/>
|
||||
```
|
||||
|
||||
Shape with shadow:
|
||||
```
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="card-shadow"
|
||||
x="200" y="200" width="280" height="160" rx="8" ry="8"
|
||||
fill="rgba(255,255,255,1)"
|
||||
slide:shadow-color="rgba(0,0,0,0.15)" slide:shadow-offset="8" slide:shadow-blur="20"/>
|
||||
```
|
||||
|
||||
Table (header row + alternating row colors + highlighted row):
|
||||
```
|
||||
<foreignObject slide:role="table" id="region-table"
|
||||
x="64" y="386" width="1152" height="226">
|
||||
<table xmlns="http://www.w3.org/1999/xhtml">
|
||||
<colgroup>
|
||||
<col width="300"/>
|
||||
<col width="200"/>
|
||||
<col width="326"/>
|
||||
<col width="326"/>
|
||||
</colgroup>
|
||||
<tr height="40">
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:left">Region</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">Revenue $B</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">YoY</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">% of Total</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">North America</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">12.4</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+8%</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">41%</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">EMEA</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">9.1</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+5%</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">30%</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">APAC</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">8.7</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+12%</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">29%</td>
|
||||
</tr>
|
||||
</table>
|
||||
</foreignObject>
|
||||
```
|
||||
Notes for the table example:
|
||||
- Outer attribute is `slide:role="table"` ONLY. NEVER write `slide:role="shape" slide:shape-type="table"`.
|
||||
- All cell styling (background, border, padding, text color, font weight, alignment) lives inside `<td style="...">` as CSS. NEVER write `<td bgcolor="..." border="..."> ` or use legacy presentational child elements (`<borderTop>`, `<fill>`, `<content>`).
|
||||
- `colspan`/`rowspan` are HTML-native — don't prefix them with `slide:`.
|
||||
- Use rgba colors with decimal alpha (e.g., `rgba(0,110,186,0.10)`) to highlight rows; the parser handles alpha correctly.
|
||||
- Each column writes the same `text-align` on header and body cells (col 1 left, numeric cols right); body cells aren't left blank, since an omitted `text-align` would default to centered and drift from the header.
|
||||
|
||||
Inline styling — prefer HTML semantic tags over `<span style="...">` for simple decorations:
|
||||
```
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">
|
||||
Plain text, <strong>bold</strong>, <em>italic</em>, <u>underline</u>, <del>strikethrough</del>,
|
||||
<strong style="color:rgba(220,20,60,1)">bold colored</strong>,
|
||||
<span style="font-size:22px; color:rgba(31,109,137,1)">resized colored</span>,
|
||||
and a <a href="https://example.com">link</a>.
|
||||
</p>
|
||||
```
|
||||
|
||||
Slide with animation (private `<slide:animations>` + one `<slide:transition>` as the LAST children of the slide root; each `target` references a page element's `id`; document order = build order):
|
||||
```
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide-3" viewBox="0 0 1280 720">
|
||||
<rect slide:role="background" width="1280" height="720" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject xmlns="http://www.w3.org/1999/xhtml" id="title" slide:role="shape" slide:shape-type="text" x="80" y="80" width="1120" height="60" style="font-size:36px"><p>One declarative title</p></foreignObject>
|
||||
<foreignObject xmlns="http://www.w3.org/1999/xhtml" id="point1" slide:role="shape" slide:shape-type="text" x="80" y="200" width="600" height="60" style="font-size:20px"><p>First supporting point</p></foreignObject>
|
||||
<rect id="chart" slide:role="chart" href="/home/user/workspace/.../chart.svg" x="720" y="200" width="480" height="300"/>
|
||||
|
||||
<slide:transition type="push" direction="from-right"/>
|
||||
<slide:animations>
|
||||
<slide:animate target="title" effect="fade-in" trigger="after-prev"/>
|
||||
<slide:animate target="point1" effect="wipe-in" trigger="click" direction="from-left"/>
|
||||
<slide:animate target="chart" effect="zoom-in" trigger="click"/>
|
||||
</slide:animations>
|
||||
</svg>
|
||||
```
|
||||
</canonical_examples>
|
||||
</svg_reference>
|
||||
````
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user