diff --git a/go.mod b/go.mod
index 4b68924..f976403 100644
--- a/go.mod
+++ b/go.mod
@@ -27,12 +27,15 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/ncruces/go-strftime v1.0.0 // 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/sys v0.48.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/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
diff --git a/go.sum b/go.sum
index b4fc65f..1ac67f1 100644
--- a/go.sum
+++ b/go.sum
@@ -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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/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/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/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/docgen/docgen.go b/internal/docgen/docgen.go
index 47266ed..af83084 100644
--- a/internal/docgen/docgen.go
+++ b/internal/docgen/docgen.go
@@ -18,8 +18,10 @@ import (
"strings"
)
-// Build 生成 docx 字节。title 为文档标题;为空时取首个标题块,再无则不带 Title 段。
-func Build(title, markdown string) ([]byte, error) {
+// legacyBuild is retained only for an explicitly enabled migration comparison.
+// 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)
title = strings.TrimSpace(title)
if title == "" {
diff --git a/internal/docgen/docgen_test.go b/internal/docgen/docgen_test.go
index af0736d..1cd5228 100644
--- a/internal/docgen/docgen_test.go
+++ b/internal/docgen/docgen_test.go
@@ -44,7 +44,7 @@ const sampleMinutes = "# 研发周会纪要\n" + `
func buildSample(t *testing.T) []byte {
t.Helper()
- b, err := Build("", sampleMinutes)
+ b, err := legacyBuild("", sampleMinutes)
if err != nil {
t.Fatalf("Build: %v", err)
}
@@ -172,7 +172,7 @@ func TestBuildHyperlinkRels(t *testing.T) {
}
func TestBuildEscapesXML(t *testing.T) {
- b, err := Build("标题&'", "包含 <标签> & \"引号\" 的正文")
+ b, err := legacyBuild("标题&'", "包含 <标签> & \"引号\" 的正文")
if err != nil {
t.Fatalf("Build: %v", err)
}
@@ -202,7 +202,7 @@ func TestBuildEscapesXML(t *testing.T) {
func TestBuildPlainTextFallback(t *testing.T) {
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
- b, err := Build("", "第一行\n第二行\n\n第二段")
+ b, err := legacyBuild("", "第一行\n第二行\n\n第二段")
if err != nil {
t.Fatalf("Build: %v", err)
}
diff --git a/internal/docgen/renderer.go b/internal/docgen/renderer.go
new file mode 100644
index 0000000..2304040
--- /dev/null
+++ b/internal/docgen/renderer.go
@@ -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
+ }
+}
diff --git a/internal/docgen/renderer_integration_test.go b/internal/docgen/renderer_integration_test.go
new file mode 100644
index 0000000..c3cacdc
--- /dev/null
+++ b/internal/docgen/renderer_integration_test.go
@@ -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), "