Route workspace Word delivery through the shared renderer
Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter. Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally. Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only. Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
This commit is contained in:
@@ -27,12 +27,15 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.77.1 // indirect
|
modernc.org/libc v1.77.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.1 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -25,6 +26,10 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
|
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
@@ -37,6 +42,8 @@ github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01j
|
|||||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -55,6 +62,8 @@ golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
|||||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build 生成 docx 字节。title 为文档标题;为空时取首个标题块,再无则不带 Title 段。
|
// legacyBuild is retained only for an explicitly enabled migration comparison.
|
||||||
func Build(title, markdown string) ([]byte, error) {
|
// Production callers must use Generate and never fall back here after a shared
|
||||||
|
// renderer failure.
|
||||||
|
func legacyBuild(title, markdown string) ([]byte, error) {
|
||||||
doc := parseMarkdown(markdown)
|
doc := parseMarkdown(markdown)
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const sampleMinutes = "# 研发周会纪要\n" + `
|
|||||||
|
|
||||||
func buildSample(t *testing.T) []byte {
|
func buildSample(t *testing.T) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := Build("", sampleMinutes)
|
b, err := legacyBuild("", sampleMinutes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Build: %v", err)
|
t.Fatalf("Build: %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func TestBuildHyperlinkRels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildEscapesXML(t *testing.T) {
|
func TestBuildEscapesXML(t *testing.T) {
|
||||||
b, err := Build("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
|
b, err := legacyBuild("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Build: %v", err)
|
t.Fatalf("Build: %v", err)
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ func TestBuildEscapesXML(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildPlainTextFallback(t *testing.T) {
|
func TestBuildPlainTextFallback(t *testing.T) {
|
||||||
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
|
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
|
||||||
b, err := Build("", "第一行\n第二行\n\n第二段")
|
b, err := legacyBuild("", "第一行\n第二行\n\n第二段")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Build: %v", err)
|
t.Fatalf("Build: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
markdownrenderer "agent-document-renderer/markdown"
|
||||||
|
"agent-document-renderer/model"
|
||||||
|
"agent-document-renderer/profile"
|
||||||
|
"agent-document-renderer/profiles"
|
||||||
|
"agent-document-renderer/render"
|
||||||
|
"agent-document-renderer/word"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
WorkspaceProjectID = "workspace"
|
||||||
|
ManifestSchemaVersion = "backend-document-delivery/v1"
|
||||||
|
RendererIdentity = "agent-document-renderer@d449b9f779f2f968f8ac556b89940a6c9aaf3fdc"
|
||||||
|
AdapterIdentity = RendererIdentity + "/word-adapter-v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRDMRequired = errors.New("docgen: controlled or project document requires RDM")
|
||||||
|
ErrProfileMismatch = errors.New("docgen: organization-default profile mismatch")
|
||||||
|
ErrResourceLimit = errors.New("docgen: resource limit exceeded")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Artifact struct {
|
||||||
|
Name string
|
||||||
|
MIME string
|
||||||
|
SHA256 string
|
||||||
|
Bytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
DocumentID string
|
||||||
|
Title string
|
||||||
|
DocumentType string
|
||||||
|
Language string
|
||||||
|
Markdown []byte
|
||||||
|
Mode render.Mode
|
||||||
|
FrozenAt time.Time
|
||||||
|
RequestedBy string
|
||||||
|
ProfileID string
|
||||||
|
ProfileVersion string
|
||||||
|
Controlled bool
|
||||||
|
ProjectDocument bool
|
||||||
|
LegacyComparison bool
|
||||||
|
Limits Limits
|
||||||
|
}
|
||||||
|
|
||||||
|
type Limits struct {
|
||||||
|
MaxMarkdownBytes int
|
||||||
|
MaxNodes int
|
||||||
|
MaxDepth int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiagnosticError struct {
|
||||||
|
Stage string
|
||||||
|
Code string
|
||||||
|
Diagnostics []render.Diagnostic
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failure *DiagnosticError) Error() string {
|
||||||
|
if failure == nil {
|
||||||
|
return "docgen: diagnostic failure"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("docgen: %s: %s: %v", failure.Stage, failure.Code, failure.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failure *DiagnosticError) Unwrap() error {
|
||||||
|
if failure == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return failure.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComparisonReceipt struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LegacySHA256 string `json:"legacySha256,omitempty"`
|
||||||
|
SharedSHA256 string `json:"sharedSha256,omitempty"`
|
||||||
|
IntentionalDifferences []string `json:"intentionalDifferences,omitempty"`
|
||||||
|
AccidentalDifferences []string `json:"accidentalDifferences,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestArtifact struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileReceipt struct {
|
||||||
|
ProfileID string `json:"profileId"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SnapshotHash string `json:"snapshotHash"`
|
||||||
|
Candidate bool `json:"candidate"`
|
||||||
|
ReviewStatus string `json:"reviewStatus"`
|
||||||
|
PublicationAllowed bool `json:"publicationAllowed"`
|
||||||
|
Unverified []string `json:"unverified"`
|
||||||
|
ProfileSHA256 string `json:"profileSha256"`
|
||||||
|
AssetManifestSHA256 string `json:"assetManifestSha256"`
|
||||||
|
ReviewSHA256 string `json:"reviewSha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manifest struct {
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
RendererIdentity string `json:"rendererIdentity"`
|
||||||
|
AdapterIdentity string `json:"adapterIdentity"`
|
||||||
|
SelectionSource string `json:"selectionSource"`
|
||||||
|
Profile ProfileReceipt `json:"profile"`
|
||||||
|
DocumentRootID string `json:"documentRootId"`
|
||||||
|
Artifacts []ManifestArtifact `json:"artifacts"`
|
||||||
|
Renderer render.Manifest `json:"renderer"`
|
||||||
|
Diagnostics []render.Diagnostic `json:"diagnostics,omitempty"`
|
||||||
|
NodeAnchors map[string]string `json:"nodeAnchors"`
|
||||||
|
Comparison ComparisonReceipt `json:"comparison"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Delivery struct {
|
||||||
|
Document model.CanonicalDocument
|
||||||
|
Profile ProfileReceipt
|
||||||
|
Markdown Artifact
|
||||||
|
DOCX Artifact
|
||||||
|
Manifest Artifact
|
||||||
|
Archive Artifact
|
||||||
|
Comparison ComparisonReceipt
|
||||||
|
}
|
||||||
|
|
||||||
|
type generatorDependencies struct {
|
||||||
|
loadDefault func() (profiles.Bundle, error)
|
||||||
|
newRenderer func(*profile.AssetReader) render.Renderer
|
||||||
|
legacyBuild func(string, string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultGeneratorDependencies() generatorDependencies {
|
||||||
|
return generatorDependencies{
|
||||||
|
loadDefault: profiles.LoadCompanyDefault,
|
||||||
|
newRenderer: func(assets *profile.AssetReader) render.Renderer { return word.NewAdapter(assets) },
|
||||||
|
legacyBuild: legacyBuild,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Generate(ctx context.Context, request Request) (Delivery, error) {
|
||||||
|
return generate(ctx, request, defaultGeneratorDependencies())
|
||||||
|
}
|
||||||
|
|
||||||
|
func generate(ctx context.Context, request Request, dependencies generatorDependencies) (Delivery, error) {
|
||||||
|
if err := contextErr(ctx); err != nil {
|
||||||
|
return Delivery{}, err
|
||||||
|
}
|
||||||
|
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
|
||||||
|
return Delivery{}, ErrRDMRequired
|
||||||
|
}
|
||||||
|
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
|
||||||
|
return Delivery{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
|
||||||
|
}
|
||||||
|
limits, err := normalizeLimits(request.Limits)
|
||||||
|
if err != nil {
|
||||||
|
return Delivery{}, err
|
||||||
|
}
|
||||||
|
if len(request.Markdown) > limits.MaxMarkdownBytes {
|
||||||
|
return Delivery{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
|
||||||
|
}
|
||||||
|
bundle, err := dependencies.loadDefault()
|
||||||
|
if err != nil {
|
||||||
|
return Delivery{}, fmt.Errorf("docgen: load company default: %w", err)
|
||||||
|
}
|
||||||
|
profileID, profileVersion := bundle.Snapshot.ProfileIdentity()
|
||||||
|
if request.ProfileID != "" || request.ProfileVersion != "" {
|
||||||
|
if request.ProfileID != profileID || request.ProfileVersion != profileVersion {
|
||||||
|
return Delivery{}, fmt.Errorf("%w: requested %s@%s, available %s@%s", ErrProfileMismatch,
|
||||||
|
request.ProfileID, request.ProfileVersion, profileID, profileVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source, firstImport, err := workspaceMarkdown(request, profileID, profileVersion)
|
||||||
|
if err != nil {
|
||||||
|
return Delivery{}, err
|
||||||
|
}
|
||||||
|
parsed, err := markdownrenderer.Parse(source, markdownrenderer.Options{
|
||||||
|
Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes + 64<<10,
|
||||||
|
MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, markdownrenderer.ErrDocumentLimit) {
|
||||||
|
return Delivery{}, fmt.Errorf("%w: %v", ErrResourceLimit, err)
|
||||||
|
}
|
||||||
|
code := "markdown_invalid"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, markdownrenderer.ErrUnknownMetadata):
|
||||||
|
code = "unknown_metadata"
|
||||||
|
case errors.Is(err, markdownrenderer.ErrHiddenHTML):
|
||||||
|
code = "unsafe_markdown"
|
||||||
|
case errors.Is(err, markdownrenderer.ErrUnsafeDirective) && strings.Contains(err.Error(), "unknown directive"):
|
||||||
|
code = "unknown_directive"
|
||||||
|
case errors.Is(err, markdownrenderer.ErrUnsafeDirective):
|
||||||
|
code = "unsafe_markdown"
|
||||||
|
}
|
||||||
|
return Delivery{}, diagnosticFailure("parse", code, err, nil)
|
||||||
|
}
|
||||||
|
if err := validateWorkspaceMetadata(request, parsed.Metadata, profileID, profileVersion); err != nil {
|
||||||
|
return Delivery{}, err
|
||||||
|
}
|
||||||
|
if err := contextErr(ctx); err != nil {
|
||||||
|
return Delivery{}, err
|
||||||
|
}
|
||||||
|
result, err := renderWithContext(ctx, dependencies.newRenderer(bundle.Assets), render.Request{
|
||||||
|
Document: parsed.Document,
|
||||||
|
Profile: bundle.Snapshot,
|
||||||
|
Mode: request.Mode,
|
||||||
|
FrozenAt: request.FrozenAt,
|
||||||
|
RequestedBy: request.RequestedBy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
code := "renderer_failed"
|
||||||
|
if strings.Contains(err.Error(), "asset") || strings.Contains(err.Error(), "hash") {
|
||||||
|
code = "asset_mismatch"
|
||||||
|
}
|
||||||
|
return Delivery{}, diagnosticFailure("render", code, err, result.Diagnostics)
|
||||||
|
}
|
||||||
|
base := safeBaseName(request.Title)
|
||||||
|
delivery := Delivery{
|
||||||
|
Document: parsed.Document,
|
||||||
|
Profile: ProfileReceipt{
|
||||||
|
ProfileID: profileID, Version: profileVersion, SnapshotHash: bundle.Snapshot.SnapshotHash(),
|
||||||
|
Candidate: true, ReviewStatus: bundle.Review.Status, PublicationAllowed: bundle.Review.PublicationAllowed,
|
||||||
|
Unverified: append([]string(nil), bundle.Review.Unverified...), ProfileSHA256: bundle.Review.ProfileSHA256,
|
||||||
|
AssetManifestSHA256: bundle.Review.AssetManifestSHA256, ReviewSHA256: bundle.Review.ReviewSHA256,
|
||||||
|
},
|
||||||
|
Markdown: artifact(base+".md", "text/markdown; charset=utf-8", parsed.Markdown),
|
||||||
|
DOCX: artifact(base+".docx", result.MIME, result.Artifact),
|
||||||
|
}
|
||||||
|
if request.LegacyComparison {
|
||||||
|
legacy, err := dependencies.legacyBuild(request.Title, string(request.Markdown))
|
||||||
|
if err != nil {
|
||||||
|
return Delivery{}, fmt.Errorf("docgen: legacy migration comparison: %w", err)
|
||||||
|
}
|
||||||
|
legacySum := sha256.Sum256(legacy)
|
||||||
|
delivery.Comparison = ComparisonReceipt{
|
||||||
|
Enabled: true, LegacySHA256: hex.EncodeToString(legacySum[:]), SharedSHA256: delivery.DOCX.SHA256,
|
||||||
|
IntentionalDifferences: []string{
|
||||||
|
"shared output removes the legacy duplicate title paragraph",
|
||||||
|
"shared output adds stable node bookmarks and deterministic package metadata",
|
||||||
|
"shared output uses the pending organization-default candidate page and footer rules",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest := Manifest{
|
||||||
|
SchemaVersion: ManifestSchemaVersion, RendererIdentity: RendererIdentity, AdapterIdentity: AdapterIdentity,
|
||||||
|
SelectionSource: "organization-default",
|
||||||
|
Profile: delivery.Profile,
|
||||||
|
DocumentRootID: parsed.Document.Root.ID,
|
||||||
|
Artifacts: []ManifestArtifact{
|
||||||
|
manifestArtifact(delivery.Markdown), manifestArtifact(delivery.DOCX),
|
||||||
|
},
|
||||||
|
Renderer: result.Manifest, Diagnostics: append([]render.Diagnostic(nil), result.Diagnostics...),
|
||||||
|
NodeAnchors: cloneStringMap(result.NodeAnchors), Comparison: delivery.Comparison,
|
||||||
|
}
|
||||||
|
manifestRaw, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return Delivery{}, fmt.Errorf("docgen: marshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
manifestRaw = append(manifestRaw, '\n')
|
||||||
|
delivery.Manifest = artifact(base+".manifest.json", "application/json", manifestRaw)
|
||||||
|
archiveRaw, err := buildArchive(delivery.Markdown, delivery.DOCX, delivery.Manifest)
|
||||||
|
if err != nil {
|
||||||
|
return Delivery{}, err
|
||||||
|
}
|
||||||
|
delivery.Archive = artifact(base+".zip", "application/zip", archiveRaw)
|
||||||
|
return delivery, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderOutcome struct {
|
||||||
|
result render.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderWithContext(ctx context.Context, renderer render.Renderer, request render.Request) (render.Result, error) {
|
||||||
|
outcomes := make(chan renderOutcome, 1)
|
||||||
|
go func() {
|
||||||
|
result, err := renderer.Render(ctx, request)
|
||||||
|
outcomes <- renderOutcome{result: result, err: err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return render.Result{}, ctx.Err()
|
||||||
|
case outcome := <-outcomes:
|
||||||
|
return outcome.result, outcome.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticFailure(stage, code string, err error, diagnostics []render.Diagnostic) error {
|
||||||
|
cloned := append([]render.Diagnostic(nil), diagnostics...)
|
||||||
|
if len(cloned) == 0 {
|
||||||
|
cloned = []render.Diagnostic{{Code: code, Severity: render.SeverityError, Message: err.Error()}}
|
||||||
|
}
|
||||||
|
return &DiagnosticError{Stage: stage, Code: code, Diagnostics: cloned, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLimits(input Limits) (Limits, error) {
|
||||||
|
if input.MaxMarkdownBytes < 0 || input.MaxNodes < 0 || input.MaxDepth < 0 {
|
||||||
|
return Limits{}, fmt.Errorf("%w: limits cannot be negative", ErrResourceLimit)
|
||||||
|
}
|
||||||
|
if input.MaxMarkdownBytes == 0 {
|
||||||
|
input.MaxMarkdownBytes = 2 << 20
|
||||||
|
}
|
||||||
|
if input.MaxNodes == 0 {
|
||||||
|
input.MaxNodes = 10_000
|
||||||
|
}
|
||||||
|
if input.MaxDepth == 0 {
|
||||||
|
input.MaxDepth = 64
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, bool, error) {
|
||||||
|
if strings.TrimSpace(request.DocumentID) == "" || strings.TrimSpace(request.Title) == "" ||
|
||||||
|
strings.TrimSpace(request.DocumentType) == "" || strings.TrimSpace(request.Language) == "" {
|
||||||
|
return nil, false, errors.New("docgen: document ID, title, document type, and language are required")
|
||||||
|
}
|
||||||
|
if request.Mode == "" || request.FrozenAt.IsZero() || strings.TrimSpace(request.RequestedBy) == "" {
|
||||||
|
return nil, false, errors.New("docgen: mode, frozen time, and requester are required")
|
||||||
|
}
|
||||||
|
if bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---")) {
|
||||||
|
return bytes.Clone(request.Markdown), false, nil
|
||||||
|
}
|
||||||
|
quote := func(value string) string {
|
||||||
|
raw, _ := json.Marshal(value)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
var header strings.Builder
|
||||||
|
header.WriteString("---\n")
|
||||||
|
fmt.Fprintf(&header, "documentId: %s\n", quote(request.DocumentID))
|
||||||
|
fmt.Fprintf(&header, "projectId: %s\n", quote(WorkspaceProjectID))
|
||||||
|
fmt.Fprintf(&header, "documentType: %s\n", quote(request.DocumentType))
|
||||||
|
fmt.Fprintf(&header, "title: %s\n", quote(request.Title))
|
||||||
|
fmt.Fprintf(&header, "language: %s\n", quote(request.Language))
|
||||||
|
fmt.Fprintf(&header, "profileId: %s\n", quote(profileID))
|
||||||
|
fmt.Fprintf(&header, "profileVersion: %s\n", quote(profileVersion))
|
||||||
|
header.WriteString("controlled: false\n---\n\n")
|
||||||
|
header.Write(bytes.TrimSpace(request.Markdown))
|
||||||
|
header.WriteByte('\n')
|
||||||
|
return []byte(header.String()), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
|
||||||
|
if metadata.Controlled || metadata.ProjectID != WorkspaceProjectID {
|
||||||
|
return ErrRDMRequired
|
||||||
|
}
|
||||||
|
if metadata.ProfileID != profileID || metadata.ProfileVersion != profileVersion {
|
||||||
|
return fmt.Errorf("%w: Markdown binds %s@%s, available %s@%s", ErrProfileMismatch,
|
||||||
|
metadata.ProfileID, metadata.ProfileVersion, profileID, profileVersion)
|
||||||
|
}
|
||||||
|
if metadata.DocumentID != request.DocumentID || metadata.DocumentType != request.DocumentType ||
|
||||||
|
metadata.Title != request.Title || metadata.Language != request.Language {
|
||||||
|
return errors.New("docgen: persisted Markdown metadata does not match the frozen request")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func artifact(name, mime string, raw []byte) Artifact {
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
// Callers transfer ownership of freshly produced buffers to the delivery.
|
||||||
|
return Artifact{Name: name, MIME: mime, SHA256: hex.EncodeToString(sum[:]), Bytes: raw}
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestArtifact(value Artifact) ManifestArtifact {
|
||||||
|
return ManifestArtifact{Name: value.Name, MIME: value.MIME, Size: len(value.Bytes), SHA256: value.SHA256}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildArchive(artifacts ...Artifact) ([]byte, error) {
|
||||||
|
items := append([]Artifact(nil), artifacts...)
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
archive := zip.NewWriter(&buffer)
|
||||||
|
for _, item := range items {
|
||||||
|
header := &zip.FileHeader{Name: item.Name, Method: zip.Deflate}
|
||||||
|
header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
header.SetMode(0o644)
|
||||||
|
writer, err := archive.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docgen: create archive entry %s: %w", item.Name, err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write(item.Bytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("docgen: write archive entry %s: %w", item.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("docgen: close delivery archive: %w", err)
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(source map[string]string) map[string]string {
|
||||||
|
clone := make(map[string]string, len(source))
|
||||||
|
for key, value := range source {
|
||||||
|
clone[key] = value
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeBaseName(title string) string {
|
||||||
|
return strings.TrimSuffix(SuggestName(title), ".docx")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextErr(ctx context.Context) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("docgen: render canceled: %w", ctx.Err())
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package docgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
markdownrenderer "agent-document-renderer/markdown"
|
||||||
|
"agent-document-renderer/model"
|
||||||
|
"agent-document-renderer/profile"
|
||||||
|
"agent-document-renderer/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
const representativeMarkdown = `# 研发周会纪要
|
||||||
|
|
||||||
|
这是普通工作区交付物的正文。
|
||||||
|
|
||||||
|
## 要点
|
||||||
|
|
||||||
|
- 统一解析入口
|
||||||
|
- 保留 Markdown
|
||||||
|
|
||||||
|
| 项目 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 解析器 | 统一 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
:::callout warning
|
||||||
|
公司默认规则仍待人工视觉评审。
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::page-break
|
||||||
|
|
||||||
|
分页后的结论。
|
||||||
|
`
|
||||||
|
|
||||||
|
func workspaceRequest(markdown []byte) Request {
|
||||||
|
return Request{
|
||||||
|
DocumentID: "workspace-minutes",
|
||||||
|
Title: "研发周会纪要",
|
||||||
|
DocumentType: "general",
|
||||||
|
Language: "zh-CN",
|
||||||
|
Markdown: markdown,
|
||||||
|
Mode: render.ModePreview,
|
||||||
|
FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC),
|
||||||
|
RequestedBy: "alice",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
first, err := Generate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Generate: %v", err)
|
||||||
|
}
|
||||||
|
second, err := Generate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Generate: %v", err)
|
||||||
|
}
|
||||||
|
for _, pair := range []struct {
|
||||||
|
name string
|
||||||
|
a Artifact
|
||||||
|
b Artifact
|
||||||
|
}{
|
||||||
|
{"Markdown", first.Markdown, second.Markdown},
|
||||||
|
{"DOCX", first.DOCX, second.DOCX},
|
||||||
|
{"manifest", first.Manifest, second.Manifest},
|
||||||
|
{"archive", first.Archive, second.Archive},
|
||||||
|
} {
|
||||||
|
if pair.a.SHA256 == "" || pair.a.SHA256 != pair.b.SHA256 || !bytes.Equal(pair.a.Bytes, pair.b.Bytes) {
|
||||||
|
t.Errorf("%s is not byte deterministic: first=%s second=%s", pair.name, pair.a.SHA256, pair.b.SHA256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(first.Manifest.Bytes, &manifest); err != nil {
|
||||||
|
t.Fatalf("decode manifest: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.SchemaVersion != ManifestSchemaVersion || manifest.RendererIdentity != RendererIdentity ||
|
||||||
|
manifest.SelectionSource != "organization-default" || !manifest.Profile.Candidate ||
|
||||||
|
manifest.Profile.ReviewStatus != "pending-human-visual-review" || manifest.Profile.PublicationAllowed {
|
||||||
|
t.Fatalf("manifest profile provenance = %#v", manifest)
|
||||||
|
}
|
||||||
|
if manifest.Profile.ProfileSHA256 == "" || manifest.Profile.AssetManifestSHA256 == "" || manifest.Profile.ReviewSHA256 == "" {
|
||||||
|
t.Fatalf("manifest omitted source hashes: %#v", manifest.Profile)
|
||||||
|
}
|
||||||
|
listed := map[string]string{}
|
||||||
|
for _, item := range manifest.Artifacts {
|
||||||
|
listed[item.Name] = item.SHA256
|
||||||
|
}
|
||||||
|
if listed[first.Markdown.Name] != first.Markdown.SHA256 || listed[first.DOCX.Name] != first.DOCX.SHA256 {
|
||||||
|
t.Fatalf("manifest artifact hashes = %#v, want Markdown=%s DOCX=%s", listed, first.Markdown.SHA256, first.DOCX.SHA256)
|
||||||
|
}
|
||||||
|
if manifest.Renderer.ArtifactHash != first.DOCX.SHA256 || manifest.Renderer.FrozenAt != request.FrozenAt || manifest.Renderer.RequestedBy != request.RequestedBy {
|
||||||
|
t.Fatalf("renderer receipt does not match frozen request: %#v", manifest.Renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(first.Archive.Bytes), int64(len(first.Archive.Bytes)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open delivery archive: %v", err)
|
||||||
|
}
|
||||||
|
archiveFiles := map[string][]byte{}
|
||||||
|
for _, file := range zr.File {
|
||||||
|
stream, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(stream)
|
||||||
|
stream.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archiveFiles[file.Name] = raw
|
||||||
|
}
|
||||||
|
for _, item := range []Artifact{first.Markdown, first.DOCX, first.Manifest} {
|
||||||
|
if !bytes.Equal(archiveFiles[item.Name], item.Bytes) {
|
||||||
|
t.Errorf("archive %s bytes do not match delivered artifact", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) {
|
||||||
|
delivery, err := Generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reparsed, err := markdownrenderer.Parse(delivery.Markdown.Bytes, markdownrenderer.Options{
|
||||||
|
FirstImport: false,
|
||||||
|
MaxBytes: 2 << 20,
|
||||||
|
MaxNodes: 10_000,
|
||||||
|
MaxDepth: 64,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("persisted Markdown is not independently parseable: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(reparsed.Document, delivery.Document) {
|
||||||
|
t.Fatalf("canonical nodes changed across the persisted Markdown entry\nfirst: %#v\nsecond: %#v", delivery.Document, reparsed.Document)
|
||||||
|
}
|
||||||
|
if reparsed.Metadata.DocumentID != "workspace-minutes" || reparsed.Metadata.ProjectID != WorkspaceProjectID ||
|
||||||
|
reparsed.Metadata.DocumentType != "general" || reparsed.Metadata.Title != "研发周会纪要" ||
|
||||||
|
reparsed.Metadata.Language != "zh-CN" || reparsed.Metadata.ProfileID != "company.default" ||
|
||||||
|
reparsed.Metadata.ProfileVersion != "1.0.0" || reparsed.Metadata.Controlled {
|
||||||
|
t.Fatalf("controlled metadata changed: %#v", reparsed.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantKinds := map[model.NodeKind]int{
|
||||||
|
model.NodeHeading: 2,
|
||||||
|
model.NodeParagraph: 2,
|
||||||
|
model.NodeList: 1,
|
||||||
|
model.NodeTable: 1,
|
||||||
|
model.NodeImage: 1,
|
||||||
|
model.NodeCallout: 1,
|
||||||
|
model.NodePageBreak: 1,
|
||||||
|
}
|
||||||
|
gotKinds := map[model.NodeKind]int{}
|
||||||
|
seenIDs := map[string]bool{}
|
||||||
|
var visit func(model.Node)
|
||||||
|
visit = func(node model.Node) {
|
||||||
|
if node.ID == "" || seenIDs[node.ID] {
|
||||||
|
t.Fatalf("node ID is missing or unstable: %q", node.ID)
|
||||||
|
}
|
||||||
|
seenIDs[node.ID] = true
|
||||||
|
gotKinds[node.Kind]++
|
||||||
|
for _, child := range node.Children {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(delivery.Document.Root)
|
||||||
|
for kind, want := range wantKinds {
|
||||||
|
if got := gotKinds[kind]; got != want {
|
||||||
|
t.Errorf("%s nodes = %d, want %d", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(delivery.Markdown.Bytes), "<!-- node-id:") {
|
||||||
|
t.Fatal("persisted Markdown omitted stable node markers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateReusesPersistedMarkdownWithoutChangingNodeIdentity(t *testing.T) {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
first, err := Generate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Markdown = first.Markdown.Bytes
|
||||||
|
second, err := Generate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate persisted Markdown: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(first.Document, second.Document) {
|
||||||
|
t.Fatalf("canonical node identities changed\nfirst: %#v\nsecond: %#v", first.Document, second.Document)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(first.Markdown.Bytes, second.Markdown.Bytes) || !bytes.Equal(first.DOCX.Bytes, second.DOCX.Bytes) {
|
||||||
|
t.Fatal("identical persisted Markdown and frozen inputs changed delivery bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsUnknownPersistedMetadata(t *testing.T) {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
first, err := Generate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Markdown = bytes.Replace(first.Markdown.Bytes, []byte("controlled: false\n"), []byte("controlled: false\nscript: build.sh\n"), 1)
|
||||||
|
_, err = Generate(context.Background(), request)
|
||||||
|
var diagnostic *DiagnosticError
|
||||||
|
if !errors.As(err, &diagnostic) || diagnostic.Stage != "parse" || diagnostic.Code != "unknown_metadata" {
|
||||||
|
t.Fatalf("unknown metadata error = %#v (%v)", diagnostic, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsControlledAndProjectRoutesBeforeDefaultFallback(t *testing.T) {
|
||||||
|
for _, mutate := range []func(*Request){
|
||||||
|
func(request *Request) { request.Controlled = true },
|
||||||
|
func(request *Request) { request.ProjectDocument = true },
|
||||||
|
} {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
mutate(&request)
|
||||||
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrRDMRequired) {
|
||||||
|
t.Fatalf("Generate route error = %v, want ErrRDMRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRoutesReviewAndApprovalModesToRDM(t *testing.T) {
|
||||||
|
for _, mode := range []render.Mode{render.ModeReview, render.ModeApproved} {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
request.Mode = mode
|
||||||
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrRDMRequired) {
|
||||||
|
t.Errorf("mode %s error = %v, want ErrRDMRequired", mode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsProfileAndAssetMismatch(t *testing.T) {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
request.ProfileID = "company.other"
|
||||||
|
request.ProfileVersion = "9.9.9"
|
||||||
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrProfileMismatch) {
|
||||||
|
t.Fatalf("profile mismatch error = %v, want ErrProfileMismatch", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request = workspaceRequest([]byte(strings.ReplaceAll(representativeMarkdown,
|
||||||
|
"asset:company.default.sample-image@1.0.0", "asset:undeclared.image@1.0.0")))
|
||||||
|
if _, err := Generate(context.Background(), request); err == nil || !strings.Contains(err.Error(), "undeclared asset") {
|
||||||
|
t.Fatalf("asset mismatch error = %v, want an honest undeclared-asset diagnostic", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateEnforcesLimitsAndCancellation(t *testing.T) {
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
request.Limits.MaxMarkdownBytes = 32
|
||||||
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrResourceLimit) {
|
||||||
|
t.Fatalf("limit error = %v, want ErrResourceLimit", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := Generate(canceled, workspaceRequest([]byte(representativeMarkdown))); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateReturnsTypedDiagnosticsForUnsafeMarkdownAndAssets(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
markdown string
|
||||||
|
stage string
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"script", "# Report\n\n<script>alert(1)</script>\n", "parse", "unsafe_markdown"},
|
||||||
|
{"external asset", "# Report\n\n\n", "parse", "unsafe_markdown"},
|
||||||
|
{"unknown directive", "# Report\n\n:::execute script=build.sh\n", "parse", "unknown_directive"},
|
||||||
|
{"undeclared asset", "# Report\n\n\n", "render", "asset_mismatch"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
delivery, err := Generate(context.Background(), workspaceRequest([]byte(test.markdown)))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Generate succeeded for unsafe input")
|
||||||
|
}
|
||||||
|
var diagnostic *DiagnosticError
|
||||||
|
if !errors.As(err, &diagnostic) {
|
||||||
|
t.Fatalf("error %T %v is not a DiagnosticError", err, err)
|
||||||
|
}
|
||||||
|
if diagnostic.Stage != test.stage || diagnostic.Code != test.code || strings.TrimSpace(diagnostic.Error()) == "" {
|
||||||
|
t.Fatalf("diagnostic = %#v, want stage=%s code=%s", diagnostic, test.stage, test.code)
|
||||||
|
}
|
||||||
|
if len(delivery.Markdown.Bytes)+len(delivery.DOCX.Bytes)+len(delivery.Manifest.Bytes)+len(delivery.Archive.Bytes) != 0 {
|
||||||
|
t.Fatal("failed render returned partial delivery bytes")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingRenderer struct{ err error }
|
||||||
|
|
||||||
|
func (renderer failingRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
||||||
|
return []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}, renderer.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (renderer failingRenderer) Render(context.Context, render.Request) (render.Result, error) {
|
||||||
|
return render.Result{Diagnostics: []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}}, renderer.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingRenderer struct {
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (renderer blockingRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (renderer blockingRenderer) Render(context.Context, render.Request) (render.Result, error) {
|
||||||
|
close(renderer.started)
|
||||||
|
<-renderer.release
|
||||||
|
return render.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationDuringRendererReturnsPromptly(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
deps := defaultGeneratorDependencies()
|
||||||
|
deps.newRenderer = func(*profile.AssetReader) render.Renderer {
|
||||||
|
return blockingRenderer{started: started, release: release}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := generate(ctx, workspaceRequest([]byte(representativeMarkdown)), deps)
|
||||||
|
result <- err
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Generate did not return after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRendererFailureNeverFallsBackToLegacy(t *testing.T) {
|
||||||
|
forced := errors.New("forced renderer failure")
|
||||||
|
legacyCalls := 0
|
||||||
|
deps := defaultGeneratorDependencies()
|
||||||
|
deps.newRenderer = func(*profile.AssetReader) render.Renderer { return failingRenderer{err: forced} }
|
||||||
|
deps.legacyBuild = func(string, string) ([]byte, error) {
|
||||||
|
legacyCalls++
|
||||||
|
return []byte("legacy"), nil
|
||||||
|
}
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
request.LegacyComparison = true
|
||||||
|
if _, err := generate(context.Background(), request, deps); !errors.Is(err, forced) {
|
||||||
|
t.Fatalf("generate error = %v, want forced renderer failure", err)
|
||||||
|
}
|
||||||
|
if legacyCalls != 0 {
|
||||||
|
t.Fatalf("legacy renderer called %d times after shared renderer failure", legacyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
|
||||||
|
legacyCalls := 0
|
||||||
|
deps := defaultGeneratorDependencies()
|
||||||
|
deps.legacyBuild = func(string, string) ([]byte, error) {
|
||||||
|
legacyCalls++
|
||||||
|
return []byte("legacy-comparison-only"), nil
|
||||||
|
}
|
||||||
|
request := workspaceRequest([]byte(representativeMarkdown))
|
||||||
|
withoutFlag, err := generate(context.Background(), request, deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if legacyCalls != 0 || withoutFlag.Comparison.Enabled {
|
||||||
|
t.Fatalf("disabled comparison called legacy renderer: calls=%d receipt=%#v", legacyCalls, withoutFlag.Comparison)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.LegacyComparison = true
|
||||||
|
withFlag, err := generate(context.Background(), request, deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" {
|
||||||
|
t.Fatalf("enabled comparison receipt=%#v calls=%d", withFlag.Comparison, legacyCalls)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(withoutFlag.DOCX.Bytes, withFlag.DOCX.Bytes) {
|
||||||
|
t.Fatal("legacy comparison replaced the shared-renderer production output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) {
|
||||||
|
const common = "# 研发周会纪要\n\n正文结论。\n\n## 要点\n\n- 统一解析入口\n- 保留 Markdown\n\n| 项目 | 状态 |\n|---|---|\n| 解析器 | 统一 |\n"
|
||||||
|
request := workspaceRequest([]byte(common))
|
||||||
|
request.LegacyComparison = true
|
||||||
|
delivery, err := Generate(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacy, err := legacyBuild(request.Title, common)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacyText := docxVisibleText(t, legacy)
|
||||||
|
sharedText := docxVisibleText(t, delivery.DOCX.Bytes)
|
||||||
|
for _, value := range []string{"研发周会纪要", "正文结论。", "要点", "统一解析入口", "保留 Markdown", "项目", "状态", "解析器", "统一"} {
|
||||||
|
if !strings.Contains(legacyText, value) || !strings.Contains(sharedText, value) {
|
||||||
|
t.Errorf("representative content %q missing: legacy=%q shared=%q", value, legacyText, sharedText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(delivery.Comparison.AccidentalDifferences) != 0 {
|
||||||
|
t.Fatalf("accidental differences remain: %#v", delivery.Comparison.AccidentalDifferences)
|
||||||
|
}
|
||||||
|
foundTitleDifference := false
|
||||||
|
for _, difference := range delivery.Comparison.IntentionalDifferences {
|
||||||
|
if strings.Contains(difference, "duplicate title") {
|
||||||
|
foundTitleDifference = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundTitleDifference {
|
||||||
|
t.Fatalf("comparison did not record the legacy duplicate-title correction: %#v", delivery.Comparison.IntentionalDifferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docxVisibleText(t *testing.T, raw []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, file := range zr.File {
|
||||||
|
if file.Name != "word/document.xml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stream, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoder := xml.NewDecoder(stream)
|
||||||
|
var text strings.Builder
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
stream.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if chars, ok := token.(xml.CharData); ok {
|
||||||
|
text.Write(chars)
|
||||||
|
text.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.Close()
|
||||||
|
return text.String()
|
||||||
|
}
|
||||||
|
t.Fatal("word/document.xml missing")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package ingest
|
package ingest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-document-renderer/render"
|
||||||
core "agent-system-core"
|
core "agent-system-core"
|
||||||
|
|
||||||
"agent-system-backend/internal/docgen"
|
"agent-system-backend/internal/docgen"
|
||||||
@@ -13,11 +16,14 @@ import (
|
|||||||
// 预览」链路闭合的保证(生成侧改动须两头痛检查)。
|
// 预览」链路闭合的保证(生成侧改动须两头痛检查)。
|
||||||
func TestParseDocxFromDocgen(t *testing.T) {
|
func TestParseDocxFromDocgen(t *testing.T) {
|
||||||
md := "# 周会纪要\n\n## 一、基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-09-30 |\n\n## 二、要点\n\n- 架构定案\n- 排版齐全\n\n结论段。"
|
md := "# 周会纪要\n\n## 一、基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-09-30 |\n\n## 二、要点\n\n- 架构定案\n- 排版齐全\n\n结论段。"
|
||||||
b, err := docgen.Build("", md)
|
delivery, err := docgen.Generate(context.Background(), docgen.Request{
|
||||||
|
DocumentID: "roundtrip", Title: "周会纪要", DocumentType: "general", Language: "zh-CN",
|
||||||
|
Markdown: []byte(md), Mode: render.ModePreview, FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), RequestedBy: "test",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("docgen.Build: %v", err)
|
t.Fatalf("docgen.Generate: %v", err)
|
||||||
}
|
}
|
||||||
blocks, err := parseDocx(b)
|
blocks, err := parseDocx(delivery.DOCX.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parseDocx 回读失败: %v", err)
|
t.Fatalf("parseDocx 回读失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -28,8 +34,8 @@ func TestParseDocxFromDocgen(t *testing.T) {
|
|||||||
var sawArch bool
|
var sawArch bool
|
||||||
for _, blk := range blocks {
|
for _, blk := range blocks {
|
||||||
switch {
|
switch {
|
||||||
case blk.Type == core.BlockHeading && blk.Level == 3 && blk.Text == "周会纪要":
|
case blk.Type == core.BlockHeading && blk.Level == 1 && blk.Text == "周会纪要":
|
||||||
title++ // Title 样式在回读侧归 level 3(headingLevel 的 title 映射)。
|
title++ // shared renderer preserves the canonical H1 instead of adding a duplicate Title paragraph.
|
||||||
case blk.Type == core.BlockHeading && blk.Level == 2:
|
case blk.Type == core.BlockHeading && blk.Level == 2:
|
||||||
h2++
|
h2++
|
||||||
if blk.Text != "一、基本信息" && blk.Text != "二、要点" {
|
if blk.Text != "一、基本信息" && blk.Text != "二、要点" {
|
||||||
@@ -48,7 +54,7 @@ func TestParseDocxFromDocgen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if title != 1 {
|
if title != 1 {
|
||||||
t.Errorf("标题段(Title)回读数 = %d, want 1(blocks: %+v)", title, blocks)
|
t.Errorf("canonical H1 回读数 = %d, want 1(blocks: %+v)", title, blocks)
|
||||||
}
|
}
|
||||||
if h2 != 2 {
|
if h2 != 2 {
|
||||||
t.Errorf("H2 数量 = %d, want 2(blocks: %+v)", h2, blocks)
|
t.Errorf("H2 数量 = %d, want 2(blocks: %+v)", h2, blocks)
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ package runmgr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-document-renderer/render"
|
||||||
"agent-system-backend/internal/bus"
|
"agent-system-backend/internal/bus"
|
||||||
"agent-system-backend/internal/docgen"
|
"agent-system-backend/internal/docgen"
|
||||||
"agent-system-backend/internal/settings"
|
"agent-system-backend/internal/settings"
|
||||||
@@ -120,12 +124,16 @@ func TestExecuteToolNoArtifactByDefault(t *testing.T) {
|
|||||||
func TestGeneratedDocxIsSearchable(t *testing.T) {
|
func TestGeneratedDocxIsSearchable(t *testing.T) {
|
||||||
c, st := newExecEnv(t)
|
c, st := newExecEnv(t)
|
||||||
const marker = "zebraindexword"
|
const marker = "zebraindexword"
|
||||||
docx, err := docgen.Build("纪要", "结论:"+marker)
|
delivery, err := docgen.Generate(context.Background(), docgen.Request{
|
||||||
|
DocumentID: "searchable", Title: "纪要", DocumentType: "general", Language: "zh-CN",
|
||||||
|
Markdown: []byte("结论:" + marker), Mode: render.ModePreview,
|
||||||
|
FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), RequestedBy: "alice",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
mime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
if _, err := c.WriteArtifact("note.docx", "document", mime, docx); err != nil {
|
if _, err := c.WriteArtifact("note.docx", "document", mime, delivery.DOCX.Bytes); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: marker, ProjectID: c.run.ProjectID, TopK: 5})
|
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: marker, ProjectID: c.run.ProjectID, TopK: 5})
|
||||||
@@ -146,3 +154,75 @@ func TestGeneratedDocxIsSearchable(t *testing.T) {
|
|||||||
t.Fatalf("unparseable docx bytes should not be indexed, got %d hits", len(hits))
|
t.Fatalf("unparseable docx bytes should not be indexed, got %d hits", len(hits))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
|
||||||
|
databasePath := filepath.Join(t.TempDir(), "renderer-restart.db")
|
||||||
|
st, err := store.OpenSQLite(databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
if err := registry.RegisterBuiltin(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
project, err := st.CreateProject("alice", "p1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conversation, err := st.CreateConversation("alice", project.ID, "c1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eventBus := bus.New()
|
||||||
|
events := newEventBuffer(st, eventBus)
|
||||||
|
eventsStopped := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !eventsStopped {
|
||||||
|
events.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
manager := &Manager{st: st, bus: eventBus, registry: registry, settings: settings.NewService(st), events: events}
|
||||||
|
run := &runCtx{
|
||||||
|
mgr: manager, run: core.Run{ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
|
||||||
|
prof: core.AgentProfile{}, eff: manager.settings.Effective("alice", ""), ctx: context.Background(),
|
||||||
|
}
|
||||||
|
args := json.RawMessage(`{"name":"重启纪要","documentId":"restart-minutes","documentType":"general","language":"zh-CN","markdown":"# 重启纪要\n\n缓存身份保持。\n","mode":"preview","frozenAt":"2026-10-15T12:00:00Z","controlled":false,"projectDocument":false}`)
|
||||||
|
result := run.ExecuteTool(core.ToolCallRequest{CallID: "render-restart", Name: "render_document", Arguments: args})
|
||||||
|
if result.Status != "ok" {
|
||||||
|
t.Fatalf("render_document: %s", result.Output)
|
||||||
|
}
|
||||||
|
artifacts, err := st.ListArtifacts("alice", project.ID)
|
||||||
|
if err != nil || len(artifacts) != 4 {
|
||||||
|
t.Fatalf("stored artifacts=%d err=%v", len(artifacts), err)
|
||||||
|
}
|
||||||
|
want := map[string]string{}
|
||||||
|
for _, item := range artifacts {
|
||||||
|
raw, err := st.ArtifactContentByID(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
want[item.ID] = hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
events.stop()
|
||||||
|
eventsStopped = true
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := store.OpenSQLite(databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reopened.Close()
|
||||||
|
for artifactID, wantHash := range want {
|
||||||
|
raw, err := reopened.ArtifactContentByID(artifactID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("artifact %s after restart: %v", artifactID, err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
if got := hex.EncodeToString(sum[:]); got != wantHash {
|
||||||
|
t.Errorf("artifact %s hash after restart = %s, want %s", artifactID, got, wantHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func TestComposeReplacesDraftAndPinsArtifact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sawFinalizing := false
|
sawFinalizing := false
|
||||||
deltaAfter := false
|
deltaAfter := false
|
||||||
for _, e := range rc.events {
|
for _, e := range rc.emittedEvents() {
|
||||||
if e.typ == core.EvRunPhase {
|
if e.typ == core.EvRunPhase {
|
||||||
p, ok := e.payload.(core.RunPhasePayload)
|
p, ok := e.payload.(core.RunPhasePayload)
|
||||||
if ok && p.Phase == core.PhaseFinalizing {
|
if ok && p.Phase == core.PhaseFinalizing {
|
||||||
@@ -321,7 +321,7 @@ func TestComposeUserPromptOmitsArtifactIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hasPhase(rc *fakeRunCtx, phase core.RunPhase) bool {
|
func hasPhase(rc *fakeRunCtx, phase core.RunPhase) bool {
|
||||||
for _, e := range rc.events {
|
for _, e := range rc.emittedEvents() {
|
||||||
if e.typ != core.EvRunPhase {
|
if e.typ != core.EvRunPhase {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1339,7 +1339,7 @@ var serialTools = map[string]bool{
|
|||||||
toolPlan: true, toolTodos: true,
|
toolPlan: true, toolTodos: true,
|
||||||
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
|
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
|
||||||
toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true,
|
toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true,
|
||||||
"run_shell": true, "run_python": true, "register_artifact": true,
|
"run_shell": true, "run_python": true, "register_artifact": true, "render_document": true,
|
||||||
"edit": true, "multi_edit": true,
|
"edit": true, "multi_edit": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestToolExecModeAndSerialSet(t *testing.T) {
|
|||||||
t.Fatalf("parallelTools=false must fall back to sequential, got %q", got)
|
t.Fatalf("parallelTools=false must fall back to sequential, got %q", got)
|
||||||
}
|
}
|
||||||
// 顺序敏感工具必须串行;可并行的读检索与委派不得误入。
|
// 顺序敏感工具必须串行;可并行的读检索与委派不得误入。
|
||||||
for _, name := range []string{"update_plan", "update_todos", "submit_card", "card_begin", "card_append", "card_commit", "run_workflow", "squad_orchestrate", "run_shell", "run_python", "register_artifact"} {
|
for _, name := range []string{"update_plan", "update_todos", "submit_card", "card_begin", "card_append", "card_commit", "run_workflow", "squad_orchestrate", "run_shell", "run_python", "register_artifact", "render_document"} {
|
||||||
if !serialTools[name] {
|
if !serialTools[name] {
|
||||||
t.Fatalf("serialTools missing %s", name)
|
t.Fatalf("serialTools missing %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type fakeRunCtx struct {
|
|||||||
prof core.AgentProfile
|
prof core.AgentProfile
|
||||||
cardflow bool
|
cardflow bool
|
||||||
events []emittedEvent
|
events []emittedEvent
|
||||||
|
eventsMu sync.Mutex
|
||||||
assistant []core.Part
|
assistant []core.Part
|
||||||
cards []core.ActivityCard
|
cards []core.ActivityCard
|
||||||
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
||||||
@@ -68,9 +70,16 @@ func (f *fakeRunCtx) Settings() core.EffectiveSettings {
|
|||||||
return eff
|
return eff
|
||||||
}
|
}
|
||||||
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
|
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
|
||||||
|
f.eventsMu.Lock()
|
||||||
|
defer f.eventsMu.Unlock()
|
||||||
f.events = append(f.events, emittedEvent{typ, payload})
|
f.events = append(f.events, emittedEvent{typ, payload})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeRunCtx) emittedEvents() []emittedEvent {
|
||||||
|
f.eventsMu.Lock()
|
||||||
|
defer f.eventsMu.Unlock()
|
||||||
|
return append([]emittedEvent(nil), f.events...)
|
||||||
|
}
|
||||||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
|
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
|
||||||
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
||||||
f.usageTotal.PromptTokens += u.PromptTokens
|
f.usageTotal.PromptTokens += u.PromptTokens
|
||||||
@@ -343,7 +352,7 @@ func TestLLMRunnerPhaseEventsPerTurn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var phases []string
|
var phases []string
|
||||||
for _, e := range rc.events {
|
for _, e := range rc.emittedEvents() {
|
||||||
if e.typ == core.EvRunPhase {
|
if e.typ == core.EvRunPhase {
|
||||||
p, ok := e.payload.(core.RunPhasePayload)
|
p, ok := e.payload.(core.RunPhasePayload)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1073,7 +1082,7 @@ func TestToolCallDeltaBeforeExecution(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var order []core.StreamEventType
|
var order []core.StreamEventType
|
||||||
for _, ev := range rc.events {
|
for _, ev := range rc.emittedEvents() {
|
||||||
switch ev.typ {
|
switch ev.typ {
|
||||||
case core.EvToolCallDelta, core.EvToolCall, core.EvToolResult:
|
case core.EvToolCallDelta, core.EvToolCall, core.EvToolResult:
|
||||||
order = append(order, ev.typ)
|
order = append(order, ev.typ)
|
||||||
@@ -1089,7 +1098,7 @@ func TestToolCallDeltaBeforeExecution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var delta core.ToolCallDeltaPayload
|
var delta core.ToolCallDeltaPayload
|
||||||
for _, ev := range rc.events {
|
for _, ev := range rc.emittedEvents() {
|
||||||
if ev.typ == core.EvToolCallDelta {
|
if ev.typ == core.EvToolCallDelta {
|
||||||
delta = ev.payload.(core.ToolCallDeltaPayload)
|
delta = ev.payload.(core.ToolCallDeltaPayload)
|
||||||
break
|
break
|
||||||
@@ -1099,7 +1108,7 @@ func TestToolCallDeltaBeforeExecution(t *testing.T) {
|
|||||||
t.Fatalf("first delta = %+v", delta)
|
t.Fatalf("first delta = %+v", delta)
|
||||||
}
|
}
|
||||||
var result core.ToolResultPayload
|
var result core.ToolResultPayload
|
||||||
for _, ev := range rc.events {
|
for _, ev := range rc.emittedEvents() {
|
||||||
if ev.typ == core.EvToolResult {
|
if ev.typ == core.EvToolResult {
|
||||||
result = ev.payload.(core.ToolResultPayload)
|
result = ev.payload.(core.ToolResultPayload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,25 +174,48 @@ func (m *MockRunner) Execute(rc RunCtx) error {
|
|||||||
case strings.Contains(user, "#doc"):
|
case strings.Contains(user, "#doc"):
|
||||||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
|
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
|
||||||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
|
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
|
||||||
// 真 docx 演示(mock 专用确定性生成器——真实链路由 Agent 经 run_python +
|
// 普通工作区演示与真实 Agent 路径共用 canonical renderer。Markdown 先落
|
||||||
// register_artifact 自产自登记):mock 模式即可看到「带排版 Word + 交付物
|
// 工件,再保存 DOCX、manifest 和带哈希清单的完整归档。
|
||||||
// 卡片」全链路——标题层级/表格/列表都在文档字节里。
|
|
||||||
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
|
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
|
||||||
buf, derr := docgen.Build("", md)
|
run := rc.Run()
|
||||||
|
frozenAt := run.CreatedAt.UTC()
|
||||||
|
if frozenAt.IsZero() {
|
||||||
|
frozenAt = time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
requester := string(run.Owner)
|
||||||
|
if requester == "" {
|
||||||
|
requester = "mock-runner"
|
||||||
|
}
|
||||||
|
delivery, derr := docgen.Generate(rc.Context(), docgen.Request{
|
||||||
|
DocumentID: "mock-demo-minutes", Title: "demo-会议纪要", DocumentType: "general", Language: "zh-CN",
|
||||||
|
Markdown: []byte(md), Mode: "preview", FrozenAt: frozenAt, RequestedBy: requester,
|
||||||
|
})
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
addText("演示文档生成失败:" + derr.Error())
|
addText("演示文档生成失败:" + derr.Error())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
addText("Word 文档已生成——点击下方交付卡片右栏预览,或 ⌘1 工件面板查看。")
|
var written []core.Artifact
|
||||||
art, err := rc.WriteArtifact("demo-会议纪要.docx", "document",
|
for _, item := range []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive} {
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", buf)
|
kind := "document"
|
||||||
if err == nil {
|
if item.MIME == "application/json" {
|
||||||
|
kind = "data"
|
||||||
|
}
|
||||||
|
art, err := rc.WriteArtifact(item.Name, kind, item.MIME, item.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
addText("演示文档工件保存失败:" + err.Error())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
written = append(written, art)
|
||||||
|
}
|
||||||
|
if len(written) == 4 {
|
||||||
|
art := written[len(written)-1]
|
||||||
|
addText("Markdown、Word、渲染清单和完整归档已生成;清单列出可复核 SHA-256。点击下方交付卡片下载归档,或在工件面板查看单项。")
|
||||||
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
|
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
|
||||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||||||
// 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现——
|
// 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现——
|
||||||
// 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard
|
// 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard
|
||||||
// 返回空 ID,跳过卡部件。
|
// 返回空 ID,跳过卡部件。
|
||||||
if fc, ferr := rc.SubmitCard(artifactFacadeCard("run_python", art, "演示 Word 工件已生成(#doc,带排版)")); ferr == nil && fc.ID != "" {
|
if fc, ferr := rc.SubmitCard(artifactFacadeCard("render_document", art, "演示文档包已生成(Markdown + DOCX + manifest)")); ferr == nil && fc.ID != "" {
|
||||||
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -566,14 +566,33 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
t.Fatal("list artifacts failed")
|
t.Fatal("list artifacts failed")
|
||||||
}
|
}
|
||||||
list := raw.([]any)
|
list := raw.([]any)
|
||||||
if len(list) == 0 {
|
if len(list) != 4 {
|
||||||
t.Fatal("artifact not created")
|
t.Fatalf("#doc artifacts = %d, want Markdown, DOCX, manifest, archive", len(list))
|
||||||
|
}
|
||||||
|
var art map[string]any
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, rawArtifact := range list {
|
||||||
|
candidate := rawArtifact.(map[string]any)
|
||||||
|
name := candidate["name"].(string)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(name, ".manifest.json"):
|
||||||
|
seen["manifest"] = true
|
||||||
|
case strings.HasSuffix(name, ".docx"):
|
||||||
|
seen["docx"] = true
|
||||||
|
art = candidate
|
||||||
|
case strings.HasSuffix(name, ".md"):
|
||||||
|
seen["markdown"] = true
|
||||||
|
case strings.HasSuffix(name, ".zip"):
|
||||||
|
seen["archive"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if art == nil || len(seen) != 4 {
|
||||||
|
t.Fatalf("#doc artifact set incomplete: %#v", list)
|
||||||
}
|
}
|
||||||
art := list[0].(map[string]any)
|
|
||||||
req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil)
|
req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
// mock #doc 走 docgen:内容是 zip 容器,标题/表格排版在 word/document.xml 里。
|
// mock #doc 走共享 renderer:DOCX 仍可回读标题和表格。
|
||||||
body := rec.Body.Bytes()
|
body := rec.Body.Bytes()
|
||||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -612,7 +631,7 @@ func TestArtifactFacadeCard(t *testing.T) {
|
|||||||
var facade map[string]any
|
var facade map[string]any
|
||||||
for _, ci := range cards {
|
for _, ci := range cards {
|
||||||
c := ci.(map[string]any)
|
c := ci.(map[string]any)
|
||||||
if c["kind"] == "result" && c["title"] == "工具大结果 · demo-会议纪要.docx" {
|
if c["kind"] == "result" && c["title"] == "工具大结果 · demo-会议纪要.zip" {
|
||||||
facade = c
|
facade = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-document-renderer/render"
|
||||||
|
"agent-system-backend/internal/docgen"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerDocumentRenderer(r *Registry) error {
|
||||||
|
return r.Register(core.ToolDesc{
|
||||||
|
Name: "render_document", Source: core.SourceBuiltin, Edits: true, Risk: "medium",
|
||||||
|
Description: "把普通工作区 Markdown 通过统一文档渲染器生成 Markdown、DOCX、manifest 和可下载归档。只允许非受控工作区交付;项目受控文档必须交给 RDM。公司默认规则仍是待视觉评审候选,状态会写入 manifest。",
|
||||||
|
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||||
|
`"name":{"type":"string"},"documentId":{"type":"string"},"documentType":{"type":"string"},` +
|
||||||
|
`"language":{"type":"string"},"markdown":{"type":"string"},` +
|
||||||
|
`"mode":{"type":"string","enum":["working","preview"]},"frozenAt":{"type":"string"},` +
|
||||||
|
`"controlled":{"type":"boolean"},"projectDocument":{"type":"boolean"}` +
|
||||||
|
`},"required":["name","documentId","markdown","frozenAt","controlled","projectDocument"]}`),
|
||||||
|
}, executeDocumentRenderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
type documentRendererArgs struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DocumentID string `json:"documentId"`
|
||||||
|
DocumentType string `json:"documentType"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Markdown string `json:"markdown"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
FrozenAt string `json:"frozenAt"`
|
||||||
|
Controlled *bool `json:"controlled"`
|
||||||
|
ProjectDocument *bool `json:"projectDocument"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error) {
|
||||||
|
var args documentRendererArgs
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&args); err != nil {
|
||||||
|
return "", fmt.Errorf("render_document: bad args: %w", err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||||
|
return "", errors.New("render_document: arguments must contain one JSON object")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(args.Name) == "" || strings.TrimSpace(args.DocumentID) == "" || strings.TrimSpace(args.Markdown) == "" {
|
||||||
|
return "", errors.New("render_document: name, documentId, and markdown are required")
|
||||||
|
}
|
||||||
|
if args.Controlled == nil || args.ProjectDocument == nil {
|
||||||
|
return "", errors.New("render_document: controlled and projectDocument must be explicit")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rc.OwnerID()) == "" {
|
||||||
|
return "", errors.New("render_document: trusted requester identity is required")
|
||||||
|
}
|
||||||
|
frozenAt, err := time.Parse(time.RFC3339Nano, args.FrozenAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("render_document: frozenAt must be RFC3339: %w", err)
|
||||||
|
}
|
||||||
|
mode := render.Mode(args.Mode)
|
||||||
|
if mode == "" {
|
||||||
|
mode = render.ModePreview
|
||||||
|
}
|
||||||
|
if mode != render.ModeWorking && mode != render.ModePreview {
|
||||||
|
return "", fmt.Errorf("render_document: mode %q is unavailable for an ordinary workspace document", mode)
|
||||||
|
}
|
||||||
|
documentType := strings.TrimSpace(args.DocumentType)
|
||||||
|
if documentType == "" {
|
||||||
|
documentType = "general"
|
||||||
|
}
|
||||||
|
language := strings.TrimSpace(args.Language)
|
||||||
|
if language == "" {
|
||||||
|
language = "zh-CN"
|
||||||
|
}
|
||||||
|
delivery, err := docgen.Generate(execParentCtx(rc), docgen.Request{
|
||||||
|
DocumentID: args.DocumentID, Title: args.Name, DocumentType: documentType, Language: language,
|
||||||
|
Markdown: []byte(args.Markdown), Mode: mode, FrozenAt: frozenAt.UTC(), RequestedBy: rc.OwnerID(),
|
||||||
|
Controlled: *args.Controlled, ProjectDocument: *args.ProjectDocument,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("render_document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts := []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive}
|
||||||
|
for _, item := range artifacts {
|
||||||
|
if err := checkToolExternalEffect(rc); err != nil {
|
||||||
|
return "", fmt.Errorf("render_document: permission recheck before %s: %w", item.Name, err)
|
||||||
|
}
|
||||||
|
if _, err := rc.WriteArtifact(item.Name, artifactKindOf(item.Name, item.MIME), item.MIME, item.Bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("render_document: persist %s: %w", item.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type outputArtifact struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
output := struct {
|
||||||
|
ProfileStatus string `json:"profileStatus"`
|
||||||
|
PublicationAllowed bool `json:"publicationAllowed"`
|
||||||
|
Artifacts []outputArtifact `json:"artifacts"`
|
||||||
|
}{ProfileStatus: delivery.Profile.ReviewStatus, PublicationAllowed: delivery.Profile.PublicationAllowed}
|
||||||
|
for _, item := range artifacts {
|
||||||
|
output.Artifacts = append(output.Artifacts, outputArtifact{Name: item.Name, SHA256: item.SHA256, Size: len(item.Bytes)})
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(output)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(encoded), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-system-backend/internal/docgen"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type capturedArtifact struct {
|
||||||
|
metadata core.Artifact
|
||||||
|
content []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type documentRendererCtx struct {
|
||||||
|
ctx context.Context
|
||||||
|
artifacts []capturedArtifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *documentRendererCtx) ProjectID() string { return "conversation-project" }
|
||||||
|
func (c *documentRendererCtx) ConversationID() string { return "conversation" }
|
||||||
|
func (c *documentRendererCtx) OwnerID() string { return "alice" }
|
||||||
|
func (c *documentRendererCtx) WorkspaceRoot() string { return "" }
|
||||||
|
func (c *documentRendererCtx) SettingBool(string, bool) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func (c *documentRendererCtx) Context() context.Context {
|
||||||
|
if c.ctx != nil {
|
||||||
|
return c.ctx
|
||||||
|
}
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
func (c *documentRendererCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (c *documentRendererCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
|
||||||
|
func (c *documentRendererCtx) PeerConversationMessages(string, int) ([]core.Message, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (c *documentRendererCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||||
|
artifact := core.Artifact{ID: core.ID("art-" + name), ProjectID: "conversation-project", Owner: "alice", Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}
|
||||||
|
c.artifacts = append(c.artifacts, capturedArtifact{metadata: artifact, content: append([]byte(nil), content...)})
|
||||||
|
return artifact, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
if err := registry.RegisterBuiltin(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
arguments, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "研发纪要", "documentId": "workspace-minutes", "documentType": "general", "language": "zh-CN",
|
||||||
|
"markdown": "# 研发纪要\n\n正文。\n", "mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
|
||||||
|
"controlled": false, "projectDocument": false,
|
||||||
|
})
|
||||||
|
ctx := &documentRendererCtx{}
|
||||||
|
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-1", Name: "render_document", Arguments: arguments})
|
||||||
|
if result.Status != "ok" {
|
||||||
|
t.Fatalf("render_document: %s", result.Output)
|
||||||
|
}
|
||||||
|
if len(ctx.artifacts) != 4 {
|
||||||
|
t.Fatalf("artifacts = %d, want Markdown, DOCX, manifest, archive", len(ctx.artifacts))
|
||||||
|
}
|
||||||
|
wantSuffixes := []string{".md", ".docx", ".manifest.json", ".zip"}
|
||||||
|
for index, suffix := range wantSuffixes {
|
||||||
|
if !strings.HasSuffix(ctx.artifacts[index].metadata.Name, suffix) {
|
||||||
|
t.Errorf("artifact %d name = %q, want suffix %s", index, ctx.artifacts[index].metadata.Name, suffix)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(ctx.artifacts[index].content)
|
||||||
|
if hash := hex.EncodeToString(sum[:]); !strings.Contains(result.Output, hash) {
|
||||||
|
t.Errorf("tool output omitted %s hash %s", suffix, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var manifest docgen.Manifest
|
||||||
|
if err := json.Unmarshal(ctx.artifacts[2].content, &manifest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if manifest.Renderer.RequestedBy != "alice" || manifest.Renderer.FrozenAt.Format("2006-01-02T15:04:05Z07:00") != "2026-10-15T12:00:00Z" {
|
||||||
|
t.Fatalf("untrusted or unfrozen render identity: %#v", manifest.Renderer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDocumentToolRequiresExplicitRouteDeclaration(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
if err := registry.RegisterBuiltin(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
arguments, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n",
|
||||||
|
"mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
|
||||||
|
})
|
||||||
|
ctx := &documentRendererCtx{}
|
||||||
|
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-3", Name: "render_document", Arguments: arguments})
|
||||||
|
if result.Status != "error" || !strings.Contains(result.Output, "controlled and projectDocument must be explicit") {
|
||||||
|
t.Fatalf("ambiguous route result = %#v", result)
|
||||||
|
}
|
||||||
|
if len(ctx.artifacts) != 0 {
|
||||||
|
t.Fatalf("ambiguous route wrote %d artifacts", len(ctx.artifacts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDocumentToolRejectsControlledRouteWithoutArtifacts(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
if err := registry.RegisterBuiltin(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
arguments, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "受控文档", "documentId": "controlled", "documentType": "dhf", "language": "zh-CN",
|
||||||
|
"markdown": "# 受控文档\n", "mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
|
||||||
|
"controlled": true, "projectDocument": false,
|
||||||
|
})
|
||||||
|
ctx := &documentRendererCtx{}
|
||||||
|
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-2", Name: "render_document", Arguments: arguments})
|
||||||
|
if result.Status != "error" || !strings.Contains(result.Output, "RDM") {
|
||||||
|
t.Fatalf("controlled render result = %#v", result)
|
||||||
|
}
|
||||||
|
if len(ctx.artifacts) != 0 {
|
||||||
|
t.Fatalf("controlled fallback wrote %d artifacts", len(ctx.artifacts))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// 执行原语工具族(agent-native 交付,2026-10-01 用户定调):文档/表格/演示稿
|
// 执行原语工具族(agent-native 交付,2026-10-01 用户定调):文档/表格/演示稿
|
||||||
// 等产物生成是 Agent 的能力——系统只提供原语与登记桥,生成方法沉淀在提示词库
|
// 等产物生成是 Agent 的能力。系统提供执行原语、登记桥,以及经过安全验证的
|
||||||
// (base.deliverables 配方),不提供 write_docx 这类领域工具。
|
// render_document 统一 Word 入口;不提供可绕过 renderer 的 write_docx 工具。
|
||||||
//
|
//
|
||||||
// 信任模型:本地单用户工作台(单二进制自托管)。run_shell/run_python 的脚本
|
// 信任模型:本地单用户工作台(单二进制自托管)。run_shell/run_python 的脚本
|
||||||
// 一律先落盘为会话工作区内的文件再交给白名单解释器(bash/python3)执行,
|
// 一律先落盘为会话工作区内的文件再交给白名单解释器(bash/python3)执行,
|
||||||
@@ -90,7 +90,7 @@ func execSandbox(rc ToolExecCtx) (string, error) {
|
|||||||
func RegisterExecTools(r *Registry) error {
|
func RegisterExecTools(r *Registry) error {
|
||||||
return r.Register(core.ToolDesc{
|
return r.Register(core.ToolDesc{
|
||||||
Name: "register_artifact", Source: core.SourceBuiltin,
|
Name: "register_artifact", Source: core.SourceBuiltin,
|
||||||
Description: "把会话工作区内已生成的文件登记为交付物工件(docx/xlsx/pptx/pdf/图片/数据等):登记后文件出现在对话交付卡片与工件面板,可预览、下载。二进制文件不能用 fs_write_file(只收文本)——先由 run_python/run_shell 生成到工作区,再用本工具登记。",
|
Description: "把会话工作区内已生成的文件登记为交付物工件(xlsx/pptx/pdf/图片/数据及迁移对照文件):登记后可预览、下载。普通 Word 使用 render_document,项目或受控 Word 交给 RDM;二进制文件不能用 fs_write_file。",
|
||||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||||
`"path":{"type":"string","description":"工作区内相对路径(如 报告.docx)"}}` +
|
`"path":{"type":"string","description":"工作区内相对路径(如 报告.docx)"}}` +
|
||||||
`,"required":["path"]}`),
|
`,"required":["path"]}`),
|
||||||
|
|||||||
@@ -267,11 +267,13 @@ func mimeOfExt(name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rejectBinaryOfficeWrite 拒绝把字符串直接写成伪二进制 Office/PDF。
|
// rejectBinaryOfficeWrite 拒绝把字符串直接写成伪二进制 Office/PDF。
|
||||||
// 带排版的文件由 run_python 生成后再 register_artifact 登记。
|
// 普通 Word 由 render_document 生成;其他带排版二进制由受控生成器产出后登记。
|
||||||
func rejectBinaryOfficeWrite(name string) error {
|
func rejectBinaryOfficeWrite(name string) error {
|
||||||
switch strings.ToLower(filepath.Ext(name)) {
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
case ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf":
|
case ".doc", ".docx":
|
||||||
return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件。请用 run_python 生成到工作区后,用 register_artifact 登记", filepath.Ext(name))
|
return fmt.Errorf("%s 是 Word 二进制扩展名,不能写入字符串;普通 Word 请用 render_document,项目或受控 Word 交给 RDM", filepath.Ext(name))
|
||||||
|
case ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf":
|
||||||
|
return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件;请用受控生成器产出后再登记", filepath.Ext(name))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,13 +165,13 @@ func TestFSUnconfiguredRoot(t *testing.T) {
|
|||||||
func TestBinaryOfficeWriteRejected(t *testing.T) {
|
func TestBinaryOfficeWriteRejected(t *testing.T) {
|
||||||
r := newFSRegistry(t)
|
r := newFSRegistry(t)
|
||||||
rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true}
|
rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true}
|
||||||
for _, args := range []struct{ name, body string }{
|
for _, args := range []struct{ name, body, guidance string }{
|
||||||
{"fs_write_file", `{"path":"a.docx","content":"plain"}`},
|
{"fs_write_file", `{"path":"a.docx","content":"plain"}`, "render_document"},
|
||||||
{"fs_write_file", `{"path":"a.pdf","content":"plain"}`},
|
{"fs_write_file", `{"path":"a.pdf","content":"plain"}`, "受控生成器"},
|
||||||
{"write_artifact", `{"name":"a.xlsx","content":"plain"}`},
|
{"write_artifact", `{"name":"a.xlsx","content":"plain"}`, "受控生成器"},
|
||||||
} {
|
} {
|
||||||
res := call(t, r, rc, args.name, args.body)
|
res := call(t, r, rc, args.name, args.body)
|
||||||
if res.Status != "error" || !strings.Contains(res.Output, "run_python") {
|
if res.Status != "error" || !strings.Contains(res.Output, args.guidance) {
|
||||||
t.Errorf("%s %s: %s %s", args.name, args.body, res.Status, res.Output)
|
t.Errorf("%s %s: %s %s", args.name, args.body, res.Status, res.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ func (r *Registry) RegisterBuiltin() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := registerDocumentRenderer(r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := registerQualityReview(r); err != nil {
|
if err := registerQualityReview(r); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user