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:
weichen
2026-10-15 19:41:14 +00:00
parent b0dd533385
commit a21a3b57b4
20 changed files with 1340 additions and 46 deletions
+3
View File
@@ -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
+9
View File
@@ -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=
+4 -2
View File
@@ -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 == "" {
+3 -3
View File
@@ -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)
} }
+423
View File
@@ -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
| 项目 | 状态 |
|---|---|
| 解析器 | 统一 |
![公司默认样图](asset:company.default.sample-image@1.0.0)
:::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![remote](https://example.com/a.png)\n", "parse", "unsafe_markdown"},
{"unknown directive", "# Report\n\n:::execute script=build.sh\n", "parse", "unknown_directive"},
{"undeclared asset", "# Report\n\n![missing](asset:missing@1.0.0)\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 ""
}
+12 -6
View File
@@ -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)
+82 -2
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -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
} }
+1 -1
View File
@@ -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,
} }
+1 -1
View File
@@ -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)
} }
+13 -4
View File
@@ -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)
} }
+32 -9
View File
@@ -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}})
} }
} }
+24 -5
View File
@@ -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
} }
} }
+117
View File
@@ -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
}
+125
View File
@@ -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))
}
}
+3 -3
View File
@@ -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"]}`),
+5 -3
View File
@@ -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
} }
+5 -5
View File
@@ -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)
} }
} }
+3
View File
@@ -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
} }