Close Task 10 renderer delivery gaps

Why:
- Independent review found trusted-boundary, semantic parity, replay-budget,
  comparison, persistence, cancellation, provenance, and routing gaps.
- Storage migration must include the new durable delivery table.

Evidence:
- go test -count=1 ./...
- go test -race ./internal/docgen ./internal/tools ./internal/runmgr
- go test ./... (deps/agent-document-renderer)
- go vet ./...
- go build ./...
- CGO_ENABLED=0 go build -trimpath ./cmd/agent-system
- Python house-style unittest suite and git diff --check

Impact:
- Word delivery now uses host-owned freezes, Markdown-first resumable persistence,
  closed registration boundaries, semantic shared rendering, and real comparison.
- Controlled/project documents still fail closed to RDM and non-Word flows remain
  unchanged.

Refs:
- Unified Document Renderer implementation plan Task 10
- /tmp/task10-backend-review-last.md
- Renderer d5cd8c126045a27d5a5cc5da23f64d3c737e116a
This commit is contained in:
weichen
2026-10-15 21:44:03 +00:00
parent 35bd87cad8
commit fec2d16cc6
32 changed files with 2159 additions and 501 deletions
+1
View File
@@ -286,6 +286,7 @@ func cmdBackup(args []string) error {
var copyOrder = []string{
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "project_work_outbox", "events", "artifacts",
"document_render_deliveries",
"hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules",
"sessions", "api_keys",
+337
View File
@@ -0,0 +1,337 @@
package docgen
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"agent-document-renderer/model"
)
const (
comparisonExceptionTitle = "RDR-T10-CMP-001"
comparisonExceptionCorporate = "RDR-T10-CMP-002"
comparisonExceptionAnchors = "RDR-T10-CMP-003"
comparisonExceptionSafeLinks = "RDR-T10-CMP-004"
)
type comparisonBlock struct {
Kind string `json:"kind"`
Level int `json:"level,omitempty"`
Text []string `json:"text,omitempty"`
Rows [][]string `json:"rows,omitempty"`
}
type docxComparison struct {
Paragraphs []string `json:"paragraphs"`
PageSize map[string]string `json:"pageSize"`
Margins map[string]string `json:"margins"`
Headers int `json:"headers"`
Footers int `json:"footers"`
Bookmarks int `json:"bookmarks"`
ExternalLinks int `json:"externalLinks"`
ForbiddenObject bool `json:"forbiddenObject"`
Semantics map[string]bool `json:"semantics"`
}
func compareLegacyAndShared(title string, source []byte, canonical model.CanonicalDocument, legacy, shared []byte) ComparisonReceipt {
receipt := ComparisonReceipt{Enabled: true, LegacySHA256: digestBytes(legacy), SharedSHA256: digestBytes(shared)}
legacyStructure := legacyComparisonStructure(string(source))
sharedStructure := canonicalComparisonStructure(canonical)
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("canonical-structure", legacyStructure, sharedStructure, ""))
legacyDOCX, legacyErr := inspectComparisonDOCX(legacy)
sharedDOCX, sharedErr := inspectComparisonDOCX(shared)
if legacyErr != nil || sharedErr != nil {
detail := fmt.Sprintf("legacy=%v shared=%v", legacyErr, sharedErr)
for _, name := range []string{"visible-text", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics"} {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: name, Status: "unassessed", Detail: detail})
}
return finishComparison(receipt)
}
legacyParagraphs := append([]string(nil), legacyDOCX.Paragraphs...)
if len(legacyParagraphs) >= 2 && legacyParagraphs[0] == normalizeComparisonText(title) && legacyParagraphs[1] == normalizeComparisonText(title) {
withoutDuplicate := append([]string(nil), legacyParagraphs[1:]...)
if equalJSON(withoutDuplicate, sharedDOCX.Paragraphs) {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs,
comparisonExceptionTitle, "legacy synthetic Title duplicated the first canonical heading"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
}
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
}
if equalJSON(legacyDOCX.PageSize, sharedDOCX.PageSize) {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-size", legacyDOCX.PageSize, sharedDOCX.PageSize, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: "page-size", Status: "accidental", LegacyHash: digestJSON(legacyDOCX.PageSize), SharedHash: digestJSON(sharedDOCX.PageSize), Detail: "page size changed"})
}
if equalJSON(legacyDOCX.Margins, sharedDOCX.Margins) {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins,
comparisonExceptionCorporate, "organization-default candidate owns page margins"))
}
if legacyDOCX.Headers == sharedDOCX.Headers && legacyDOCX.Footers == sharedDOCX.Footers {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers}, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers},
comparisonExceptionCorporate, "organization-default candidate adds reviewed header/footer rules"))
}
if legacyDOCX.Bookmarks == sharedDOCX.Bookmarks {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
} else if legacyDOCX.Bookmarks == 0 && sharedDOCX.Bookmarks > 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks,
comparisonExceptionAnchors, "shared output anchors canonical node IDs"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
}
if legacyDOCX.ExternalLinks == sharedDOCX.ExternalLinks {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
} else if legacyDOCX.ExternalLinks > 0 && sharedDOCX.ExternalLinks == 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks,
comparisonExceptionSafeLinks, "shared links are inert labels with no external relationship or target leakage"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
}
receipt.Dimensions = append(receipt.Dimensions,
compareExactDimension("security-objects", legacyDOCX.ForbiddenObject, sharedDOCX.ForbiddenObject, ""),
compareExactDimension("word-semantics", legacyDOCX.Semantics, sharedDOCX.Semantics, ""),
)
return finishComparison(receipt)
}
func finishComparison(receipt ComparisonReceipt) ComparisonReceipt {
for _, dimension := range receipt.Dimensions {
switch dimension.Status {
case "intentional":
receipt.IntentionalDifferences = append(receipt.IntentionalDifferences, dimension.Name+": "+dimension.Detail)
case "accidental":
receipt.AccidentalDifferences = append(receipt.AccidentalDifferences, dimension.Name+": "+dimension.Detail)
case "unassessed":
receipt.Unassessed = append(receipt.Unassessed, dimension.Name+": "+dimension.Detail)
}
}
switch {
case len(receipt.Unassessed) > 0:
receipt.Assessment = "unassessed"
case len(receipt.AccidentalDifferences) > 0:
receipt.Assessment = "differences"
default:
receipt.Assessment = "assessed"
}
return receipt
}
func compareExactDimension(name string, legacy, shared any, detail string) ComparisonDimension {
status := "match"
if !equalJSON(legacy, shared) {
status = "accidental"
if detail == "" {
detail = "normalized values differ"
}
}
return ComparisonDimension{Name: name, Status: status, LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), Detail: detail}
}
func intentionalDimension(name string, legacy, shared any, exceptionID, detail string) ComparisonDimension {
return ComparisonDimension{Name: name, Status: "intentional", LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), ExceptionID: exceptionID, Detail: detail}
}
func legacyComparisonStructure(source string) []comparisonBlock {
document := parseMarkdown(source)
blocks := make([]comparisonBlock, 0, len(document.blocks))
for _, block := range document.blocks {
entry := comparisonBlock{Level: block.level}
switch block.kind {
case bHeading:
entry.Kind, entry.Text = "heading", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
case bPara:
entry.Kind, entry.Text = "paragraph", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
case bQuote:
entry.Kind, entry.Text = "quote", []string{normalizeComparisonText(strings.Join(block.lines, "\n"))}
case bCode:
entry.Kind, entry.Text = "code-block", []string{strings.Join(block.lines, "\n") + "\n"}
case bList:
entry.Kind = "list"
for _, item := range block.items {
entry.Text = append(entry.Text, visibleLegacyRuns(document.parseInline(item.text)))
}
case bTable:
entry.Kind = "table"
for _, row := range block.rows {
visible := make([]string, len(row))
for index := range row {
visible[index] = visibleLegacyRuns(document.parseInline(row[index]))
}
entry.Rows = append(entry.Rows, visible)
}
case bHR:
entry.Kind = "thematic-break"
}
blocks = append(blocks, entry)
}
return blocks
}
func canonicalComparisonStructure(document model.CanonicalDocument) []comparisonBlock {
blocks := make([]comparisonBlock, 0, len(document.Root.Children))
for _, node := range document.Root.Children {
entry := comparisonBlock{Kind: string(node.Kind)}
if node.Kind == model.NodeHeading {
fmt.Sscanf(node.Semantic, "heading%d", &entry.Level)
}
switch node.Kind {
case model.NodeHeading, model.NodeParagraph, model.NodeQuote, model.NodeCodeBlock, model.NodeCallout:
entry.Text = []string{normalizeComparisonNodeText(node)}
case model.NodeList:
for _, item := range node.Children {
entry.Text = append(entry.Text, normalizeComparisonNodeText(item))
}
case model.NodeTable:
for _, row := range node.Children {
values := make([]string, len(row.Children))
for index := range row.Children {
values[index] = normalizeComparisonNodeText(row.Children[index])
}
entry.Rows = append(entry.Rows, values)
}
default:
continue
}
blocks = append(blocks, entry)
}
return blocks
}
func normalizeComparisonNodeText(node model.Node) string {
if node.Kind == model.NodeCodeBlock {
return node.Text
}
return normalizeComparisonText(node.Text)
}
func visibleLegacyRuns(runs []run) string {
var value strings.Builder
for _, run := range runs {
value.WriteString(run.text)
}
return normalizeComparisonText(value.String())
}
func normalizeComparisonText(value string) string { return strings.Join(strings.Fields(value), " ") }
func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return docxComparison{}, err
}
parts := make(map[string][]byte, len(reader.File))
for _, file := range reader.File {
stream, err := file.Open()
if err != nil {
return docxComparison{}, err
}
content, err := io.ReadAll(stream)
stream.Close()
if err != nil {
return docxComparison{}, err
}
parts[file.Name] = content
}
document, found := parts["word/document.xml"]
if !found {
return docxComparison{}, errors.New("word/document.xml missing")
}
result := docxComparison{PageSize: map[string]string{}, Margins: map[string]string{}, Semantics: map[string]bool{}}
decoder := xml.NewDecoder(bytes.NewReader(document))
var paragraph strings.Builder
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return docxComparison{}, err
}
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
case "pgSz":
copyAttributes(result.PageSize, value.Attr, "w", "h", "orient")
case "pgMar":
copyAttributes(result.Margins, value.Attr, "top", "right", "bottom", "left", "header", "footer", "gutter")
case "bookmarkStart":
result.Bookmarks++
}
case xml.CharData:
paragraph.Write(value)
case xml.EndElement:
if value.Name.Local == "p" {
if text := normalizeComparisonText(paragraph.String()); text != "" {
result.Paragraphs = append(result.Paragraphs, text)
}
paragraph.Reset()
}
}
}
for name, content := range parts {
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "word/header") && strings.HasSuffix(lower, ".xml") {
result.Headers++
}
if strings.HasPrefix(lower, "word/footer") && strings.HasSuffix(lower, ".xml") {
result.Footers++
}
if strings.HasSuffix(lower, ".rels") {
result.ExternalLinks += strings.Count(strings.ToLower(string(content)), `targetmode="external"`)
}
if strings.Contains(lower, "vba") || strings.Contains(lower, "activex") || strings.Contains(lower, "embeddings") {
result.ForbiddenObject = true
}
}
xmlText := string(document)
result.Semantics = map[string]bool{
"bold": strings.Contains(xmlText, "<w:b"), "italic": strings.Contains(xmlText, "<w:i"),
"strike": strings.Contains(xmlText, "<w:strike"), "inline-code": strings.Contains(xmlText, "inlineCode") || strings.Contains(xmlText, "Consolas"),
"link-label": strings.Contains(xmlText, `w:val="link"`) || strings.Contains(xmlText, `w:val="Hyperlink"`),
"quote": strings.Contains(strings.ToLower(xmlText), `w:val="quote"`), "code-block": strings.Contains(strings.ToLower(xmlText), `w:val="code`),
"list": strings.Contains(xmlText, "<w:numPr>"), "table": strings.Contains(xmlText, "<w:tbl>"),
}
return result, nil
}
func copyAttributes(target map[string]string, attributes []xml.Attr, names ...string) {
wanted := make(map[string]bool, len(names))
for _, name := range names {
wanted[name] = true
}
for _, attribute := range attributes {
if wanted[attribute.Name.Local] {
target[attribute.Name.Local] = attribute.Value
}
}
}
func equalJSON(left, right any) bool { return bytes.Equal(canonicalJSON(left), canonicalJSON(right)) }
func digestJSON(value any) string { return digestBytes(canonicalJSON(value)) }
func canonicalJSON(value any) []byte {
raw, _ := json.Marshal(value)
return raw
}
func digestBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,39 @@
package main
import (
"bytes"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func main() {
check := flag.Bool("check", false, "fail when the generated identity differs from the checked-out renderer")
flag.Parse()
repository := gitOutput("rev-parse", "--show-toplevel")
revision := gitOutput("-C", filepath.Join(repository, "deps", "agent-document-renderer"), "rev-parse", "HEAD")
content := []byte(fmt.Sprintf("// Code generated from deps/agent-document-renderer; DO NOT EDIT.\n\npackage docgen\n\nconst rendererRevision = %q\n\nconst RendererIdentity = \"agent-document-renderer@\" + rendererRevision\n", revision))
target := filepath.Join(repository, "internal", "docgen", "renderer_identity_generated.go")
if *check {
current, err := os.ReadFile(target)
if err != nil || !bytes.Equal(current, content) {
fmt.Fprintf(os.Stderr, "renderer identity is stale for %s\n", revision)
os.Exit(1)
}
return
}
if err := os.WriteFile(target, content, 0o644); err != nil {
panic(err)
}
}
func gitOutput(arguments ...string) string {
output, err := exec.Command("git", arguments...).Output()
if err != nil {
panic(err)
}
return strings.TrimSpace(string(output))
}
+40
View File
@@ -0,0 +1,40 @@
package docgen
import (
"time"
"agent-document-renderer/render"
)
type OperationRequest struct {
OperationID string `json:"operationId"`
DocumentID string `json:"documentId"`
Title string `json:"name"`
DocumentType string `json:"documentType,omitempty"`
Language string `json:"language,omitempty"`
Markdown []byte `json:"-"`
Mode render.Mode `json:"mode"`
Controlled bool `json:"controlled"`
ProjectDocument bool `json:"projectDocument"`
}
type ReceiptArtifact struct {
Role string `json:"role"`
ID string `json:"id"`
Name string `json:"name"`
MIME string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type OperationReceipt struct {
OperationID string `json:"operationId"`
Outcome string `json:"outcome"`
State string `json:"state"`
FrozenAt time.Time `json:"frozenAt"`
DeliveryKey string `json:"deliveryKey,omitempty"`
Renderer string `json:"rendererIdentity"`
CacheHit bool `json:"cacheHit,omitempty"`
Artifacts []ReceiptArtifact `json:"artifacts,omitempty"`
Error string `json:"error,omitempty"`
}
+211 -87
View File
@@ -24,10 +24,14 @@ import (
const (
WorkspaceProjectID = "workspace"
ManifestSchemaVersion = "backend-document-delivery/v1"
RendererIdentity = "agent-document-renderer@d449b9f779f2f968f8ac556b89940a6c9aaf3fdc"
AdapterIdentity = RendererIdentity + "/word-adapter-v1"
defaultProfileID = "company.default"
defaultProfileVersion = "1.0.0"
defaultRenderCapacity = 4
)
var renderCapacity = make(chan struct{}, defaultRenderCapacity)
var (
ErrRDMRequired = errors.New("docgen: controlled or project document requires RDM")
ErrProfileMismatch = errors.New("docgen: organization-default profile mismatch")
@@ -42,20 +46,20 @@ type Artifact struct {
}
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
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
LegacySource []byte
Limits Limits
}
type Limits struct {
@@ -85,12 +89,28 @@ func (failure *DiagnosticError) Unwrap() error {
return failure.Err
}
type ComparisonDimension struct {
Name string `json:"name"`
Status string `json:"status"`
LegacyHash string `json:"legacyHash,omitempty"`
SharedHash string `json:"sharedHash,omitempty"`
ExceptionID string `json:"exceptionId,omitempty"`
Detail string `json:"detail,omitempty"`
}
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"`
Enabled bool `json:"enabled"`
Assessment string `json:"assessment,omitempty"`
LegacySHA256 string `json:"legacySha256,omitempty"`
SharedSHA256 string `json:"sharedSha256,omitempty"`
Dimensions []ComparisonDimension `json:"dimensions,omitempty"`
IntentionalDifferences []string `json:"intentionalDifferences,omitempty"`
AccidentalDifferences []string `json:"accidentalDifferences,omitempty"`
Unassessed []string `json:"unassessed,omitempty"`
}
type RenderOptions struct {
MigrationComparison bool
}
type ManifestArtifact struct {
@@ -130,6 +150,7 @@ type Manifest struct {
type Delivery struct {
Document model.CanonicalDocument
Profile ProfileReceipt
Renderer render.Manifest
Markdown Artifact
DOCX Artifact
Manifest Artifact
@@ -137,15 +158,87 @@ type Delivery struct {
Comparison ComparisonReceipt
}
type PreparedDocument struct {
Document model.CanonicalDocument
Markdown Artifact
CanonicalHash string
}
// Prepare canonicalizes and validates authoritative Markdown without loading a
// profile or invoking the Word adapter. Durable callers persist its Markdown
// artifact before any rendering work.
func Prepare(ctx context.Context, request Request) (PreparedDocument, error) {
if err := contextErr(ctx); err != nil {
return PreparedDocument{}, err
}
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
return PreparedDocument{}, ErrRDMRequired
}
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
return PreparedDocument{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
}
limits, err := normalizeLimits(request.Limits)
if err != nil {
return PreparedDocument{}, err
}
if len(request.Markdown) > limits.MaxMarkdownBytes {
return PreparedDocument{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
}
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
var parsed markdownrenderer.Result
if materialized {
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
if err != nil {
return PreparedDocument{}, classifyParseError(err)
}
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
return PreparedDocument{}, err
}
if parsed.Metadata.ProfileID != defaultProfileID || parsed.Metadata.ProfileVersion != defaultProfileVersion {
return PreparedDocument{}, fmt.Errorf("%w: Markdown binds %s@%s, expected %s@%s", ErrProfileMismatch,
parsed.Metadata.ProfileID, parsed.Metadata.ProfileVersion, defaultProfileID, defaultProfileVersion)
}
} else {
if request.ProfileID != "" || request.ProfileVersion != "" {
if request.ProfileID != defaultProfileID || request.ProfileVersion != defaultProfileVersion {
return PreparedDocument{}, fmt.Errorf("%w: requested %s@%s, expected %s@%s", ErrProfileMismatch,
request.ProfileID, request.ProfileVersion, defaultProfileID, defaultProfileVersion)
}
}
source, err := workspaceMarkdown(request, defaultProfileID, defaultProfileVersion)
if err != nil {
return PreparedDocument{}, err
}
if len(source) > limits.MaxMarkdownBytes {
return PreparedDocument{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
}
parsed, err = parseMarkdownContext(ctx, source, true, limits)
if err != nil {
return PreparedDocument{}, classifyParseError(err)
}
}
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
return PreparedDocument{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
}
canonicalHash, err := parsed.Document.HashContext(ctx)
if err != nil {
return PreparedDocument{}, err
}
return PreparedDocument{
Document: parsed.Document, CanonicalHash: canonicalHash,
Markdown: artifact(safeBaseName(request.Title)+".md", "text/markdown; charset=utf-8", parsed.Markdown),
}, nil
}
type generatorDependencies struct {
loadDefault func() (profiles.Bundle, error)
loadDefault func(context.Context) (profiles.Bundle, error)
newRenderer func(*profile.AssetReader) render.Renderer
legacyBuild func(string, string) ([]byte, error)
}
func defaultGeneratorDependencies() generatorDependencies {
return generatorDependencies{
loadDefault: profiles.LoadCompanyDefault,
loadDefault: profiles.LoadCompanyDefaultContext,
newRenderer: func(assets *profile.AssetReader) render.Renderer { return word.NewAdapter(assets) },
legacyBuild: legacyBuild,
}
@@ -156,6 +249,14 @@ func Generate(ctx context.Context, request Request) (Delivery, error) {
}
func generate(ctx context.Context, request Request, dependencies generatorDependencies) (Delivery, error) {
return generateWithOptions(ctx, request, RenderOptions{}, dependencies)
}
func GenerateWithOptions(ctx context.Context, request Request, options RenderOptions) (Delivery, error) {
return generateWithOptions(ctx, request, options, defaultGeneratorDependencies())
}
func generateWithOptions(ctx context.Context, request Request, options RenderOptions, dependencies generatorDependencies) (Delivery, error) {
if err := contextErr(ctx); err != nil {
return Delivery{}, err
}
@@ -172,7 +273,18 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
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()
var parsed markdownrenderer.Result
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
if materialized {
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
if err != nil {
return Delivery{}, classifyParseError(err)
}
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
return Delivery{}, err
}
}
bundle, err := dependencies.loadDefault(ctx)
if err != nil {
return Delivery{}, fmt.Errorf("docgen: load company default: %w", err)
}
@@ -183,30 +295,21 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
request.ProfileID, request.ProfileVersion, profileID, profileVersion)
}
}
source, firstImport, err := workspaceMarkdown(request, profileID, profileVersion)
if err != nil {
return Delivery{}, err
if !materialized {
source, sourceErr := workspaceMarkdown(request, profileID, profileVersion)
if sourceErr != nil {
return Delivery{}, sourceErr
}
if len(source) > limits.MaxMarkdownBytes {
return Delivery{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
}
parsed, err = parseMarkdownContext(ctx, source, true, limits)
if err != nil {
return Delivery{}, classifyParseError(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 len(parsed.Markdown) > limits.MaxMarkdownBytes {
return Delivery{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
}
if err := validateWorkspaceMetadata(request, parsed.Metadata, profileID, profileVersion); err != nil {
return Delivery{}, err
@@ -214,13 +317,17 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
if err := contextErr(ctx); err != nil {
return Delivery{}, err
}
result, err := renderWithContext(ctx, dependencies.newRenderer(bundle.Assets), render.Request{
if err := acquireRenderCapacity(ctx); err != nil {
return Delivery{}, err
}
result, err := dependencies.newRenderer(bundle.Assets).Render(ctx, render.Request{
Document: parsed.Document,
Profile: bundle.Snapshot,
Mode: request.Mode,
FrozenAt: request.FrozenAt,
RequestedBy: request.RequestedBy,
})
releaseRenderCapacity()
if err != nil {
code := "renderer_failed"
if strings.Contains(err.Error(), "asset") || strings.Contains(err.Error(), "hash") {
@@ -230,7 +337,7 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
}
base := safeBaseName(request.Title)
delivery := Delivery{
Document: parsed.Document,
Document: parsed.Document, Renderer: result.Manifest,
Profile: ProfileReceipt{
ProfileID: profileID, Version: profileVersion, SnapshotHash: bundle.Snapshot.SnapshotHash(),
Candidate: true, ReviewStatus: bundle.Review.Status, PublicationAllowed: bundle.Review.PublicationAllowed,
@@ -240,26 +347,22 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
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 options.MigrationComparison {
comparisonSource := request.LegacySource
if len(comparisonSource) == 0 {
comparisonSource = request.Markdown
}
legacy, err := dependencies.legacyBuild(request.Title, string(comparisonSource))
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",
},
}
delivery.Comparison = compareLegacyAndShared(request.Title, comparisonSource, parsed.Document, legacy, delivery.DOCX.Bytes)
}
manifest := Manifest{
SchemaVersion: ManifestSchemaVersion, RendererIdentity: RendererIdentity, AdapterIdentity: AdapterIdentity,
SelectionSource: "organization-default",
Profile: delivery.Profile,
DocumentRootID: parsed.Document.Root.ID,
Profile: delivery.Profile,
DocumentRootID: parsed.Document.Root.ID,
Artifacts: []ManifestArtifact{
manifestArtifact(delivery.Markdown), manifestArtifact(delivery.DOCX),
},
@@ -280,25 +383,42 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
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}
}()
func acquireRenderCapacity(ctx context.Context) error {
select {
case <-ctx.Done():
return render.Result{}, ctx.Err()
case outcome := <-outcomes:
return outcome.result, outcome.err
return ctx.Err()
case renderCapacity <- struct{}{}:
return nil
}
}
func releaseRenderCapacity() { <-renderCapacity }
func parseMarkdownContext(ctx context.Context, source []byte, firstImport bool, limits Limits) (markdownrenderer.Result, error) {
return markdownrenderer.ParseContext(ctx, source, markdownrenderer.Options{
Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes,
MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth,
})
}
func classifyParseError(err error) error {
if errors.Is(err, markdownrenderer.ErrDocumentLimit) {
return 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 diagnosticFailure("parse", code, err, nil)
}
func diagnosticFailure(stage, code string, err error, diagnostics []render.Diagnostic) error {
cloned := append([]render.Diagnostic(nil), diagnostics...)
if len(cloned) == 0 {
@@ -323,16 +443,13 @@ func normalizeLimits(input Limits) (Limits, error) {
return input, nil
}
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, bool, error) {
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, 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")
return nil, 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
return nil, errors.New("docgen: mode, frozen time, and requester are required")
}
quote := func(value string) string {
raw, _ := json.Marshal(value)
@@ -350,17 +467,13 @@ func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byt
header.WriteString("controlled: false\n---\n\n")
header.Write(bytes.TrimSpace(request.Markdown))
header.WriteByte('\n')
return []byte(header.String()), true, nil
return []byte(header.String()), nil
}
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
func validateWorkspaceRoute(request Request, metadata markdownrenderer.Metadata) 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")
@@ -368,6 +481,17 @@ func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metada
return nil
}
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
if err := validateWorkspaceRoute(request, metadata); err != nil {
return err
}
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)
}
return nil
}
func artifact(name, mime string, raw []byte) Artifact {
sum := sha256.Sum256(raw)
// Callers transfer ownership of freshly produced buffers to the delivery.
+3
View File
@@ -0,0 +1,3 @@
package docgen
//go:generate go run ./internal/genrendereridentity
@@ -0,0 +1,7 @@
// Code generated from deps/agent-document-renderer; DO NOT EDIT.
package docgen
const rendererRevision = "d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
const RendererIdentity = "agent-document-renderer@" + rendererRevision
+227 -38
View File
@@ -8,14 +8,18 @@ import (
"encoding/xml"
"errors"
"io"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
markdownrenderer "agent-document-renderer/markdown"
"agent-document-renderer/model"
"agent-document-renderer/profile"
"agent-document-renderer/profiles"
"agent-document-renderer/render"
)
@@ -56,6 +60,23 @@ func workspaceRequest(markdown []byte) Request {
}
}
const semanticMarkdown = `# **Semantic** report
Paragraph with **bold**, *italic*, ~~strike~~, ` + "`inline code`" + `, and [safe **label**](https://example.com/private-target).
> Quoted **evidence** with *emphasis*.
` + "```go" + `
fmt.Println("safe")
` + "```" + `
- **bold item**
| Item | Meaning |
|---|---|
| ` + "`code cell`" + ` | [table label](#semantic) |
`
func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) {
request := workspaceRequest([]byte(representativeMarkdown))
first, err := Generate(context.Background(), request)
@@ -186,6 +207,125 @@ func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) {
}
}
func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *testing.T) {
request := workspaceRequest([]byte(semanticMarkdown))
request.Title = "Semantic report"
delivery, err := Generate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
var quote, code *model.Node
var hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink bool
var walkInline func([]model.Inline)
walkInline = func(inlines []model.Inline) {
for index := range inlines {
switch inlines[index].Kind {
case model.InlineStrong:
hasStrong = true
case model.InlineEmphasis:
hasEmphasis = true
case model.InlineStrike:
hasStrike = true
case model.InlineCode:
hasInlineCode = true
case model.InlineLink:
hasLink = true
}
walkInline(inlines[index].Children)
}
}
var walk func(*model.Node)
walk = func(node *model.Node) {
if node.Kind == model.NodeQuote {
quote = node
}
if node.Kind == model.NodeCodeBlock {
code = node
}
walkInline(node.Inlines)
for index := range node.Children {
walk(&node.Children[index])
}
}
walk(&delivery.Document.Root)
if quote == nil || code == nil || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
t.Fatalf("semantic canonical document incomplete: quote=%v code=%v strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, code != nil, hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
}
documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml")
for _, fragment := range []string{`<w:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`} {
if !strings.Contains(documentXML, fragment) {
t.Errorf("Word output missing %s", fragment)
}
}
if bytes.Contains(delivery.DOCX.Bytes, []byte("https://example.com/private-target")) || strings.Contains(documentXML, "TargetMode=\"External\"") {
t.Fatal("safe link target leaked into DOCX package")
}
replayed := request
replayed.Markdown = delivery.Markdown.Bytes
second, err := Generate(context.Background(), replayed)
if err != nil || !reflect.DeepEqual(delivery.Document, second.Document) {
t.Fatalf("semantic Markdown did not replay canonically: err=%v", err)
}
}
func TestMaterializedMarkdownMustFitAndReplayAtOneBudget(t *testing.T) {
request := workspaceRequest([]byte("# T\n\nsmall body\n"))
request.Title = "T"
request.Limits.MaxMarkdownBytes = 256
if delivery, err := Generate(context.Background(), request); !errors.Is(err, ErrResourceLimit) {
t.Fatalf("materialized overflow returned delivery=%d bytes err=%v, want ErrResourceLimit", len(delivery.Markdown.Bytes), err)
}
request.Limits.MaxMarkdownBytes = 0
first, err := Generate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.Markdown = first.Markdown.Bytes
request.Limits.MaxMarkdownBytes = len(first.Markdown.Bytes)
second, err := Generate(context.Background(), request)
if err != nil || !bytes.Equal(first.Markdown.Bytes, second.Markdown.Bytes) {
t.Fatalf("exact-budget replay failed: err=%v first=%d second=%d", err, len(first.Markdown.Bytes), len(second.Markdown.Bytes))
}
}
func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T) {
source := []byte("---\ndocumentId: controlled\nprojectId: workspace\ndocumentType: general\ntitle: Controlled\nlanguage: zh-CN\nprofileId: company.default\nprofileVersion: 1.0.0\ncontrolled: true\n---\n\n<!-- node-id:heading -->\n# Controlled\n")
request := workspaceRequest(source)
request.DocumentID = "controlled"
request.Title = "Controlled"
loads := 0
deps := defaultGeneratorDependencies()
baseLoad := deps.loadDefault
deps.loadDefault = func(ctx context.Context) (profiles.Bundle, error) {
loads++
return baseLoad(ctx)
}
_, err := generate(context.Background(), request, deps)
if !errors.Is(err, ErrRDMRequired) || loads != 0 {
t.Fatalf("persisted controlled route err=%v profileLoads=%d, want ErrRDMRequired before load", err, loads)
}
}
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
const want = "agent-document-renderer@d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
if RendererIdentity != want {
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
}
}
func TestRendererIdentityIsGeneratedFromCheckedOutComponent(t *testing.T) {
repository, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
command := exec.Command("go", "run", "./internal/docgen/internal/genrendereridentity", "-check")
command.Dir = repository
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("renderer identity generation check: %v\n%s", err, output)
}
}
func TestGenerateReusesPersistedMarkdownWithoutChangingNodeIdentity(t *testing.T) {
request := workspaceRequest([]byte(representativeMarkdown))
first, err := Generate(context.Background(), request)
@@ -313,28 +453,35 @@ func (renderer failingRenderer) Render(context.Context, render.Request) (render.
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{}
type cleanupRenderer struct {
active *atomic.Int32
started chan struct{}
canceled chan struct{}
release chan struct{}
}
func (renderer blockingRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
func (renderer cleanupRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
return nil, nil
}
func (renderer blockingRenderer) Render(context.Context, render.Request) (render.Result, error) {
func (renderer cleanupRenderer) Render(ctx context.Context, _ render.Request) (render.Result, error) {
renderer.active.Add(1)
defer renderer.active.Add(-1)
close(renderer.started)
<-ctx.Done()
close(renderer.canceled)
<-renderer.release
return render.Result{}, nil
return render.Result{}, ctx.Err()
}
func TestCancellationDuringRendererReturnsPromptly(t *testing.T) {
func TestCancellationWaitsForRendererUnwindAndRecoversCapacity(t *testing.T) {
var active atomic.Int32
started := make(chan struct{})
canceled := 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}
return cleanupRenderer{active: &active, started: started, canceled: canceled, release: release}
}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
@@ -344,13 +491,20 @@ func TestCancellationDuringRendererReturnsPromptly(t *testing.T) {
}()
<-started
cancel()
<-canceled
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v, want context.Canceled", err)
t.Fatalf("Generate returned before renderer cleanup: %v", err)
default:
}
close(release)
select {
case err := <-result:
if !errors.Is(err, context.Canceled) || active.Load() != 0 {
t.Fatalf("cancellation err=%v active=%d, want canceled and zero capacity", err, active.Load())
}
case <-time.After(time.Second):
t.Fatal("Generate did not return after cancellation")
t.Fatal("Generate did not return after renderer cleanup")
}
}
@@ -364,8 +518,7 @@ func TestRendererFailureNeverFallsBackToLegacy(t *testing.T) {
return []byte("legacy"), nil
}
request := workspaceRequest([]byte(representativeMarkdown))
request.LegacyComparison = true
if _, err := generate(context.Background(), request, deps); !errors.Is(err, forced) {
if _, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps); !errors.Is(err, forced) {
t.Fatalf("generate error = %v, want forced renderer failure", err)
}
if legacyCalls != 0 {
@@ -389,12 +542,11 @@ func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
t.Fatalf("disabled comparison called legacy renderer: calls=%d receipt=%#v", legacyCalls, withoutFlag.Comparison)
}
request.LegacyComparison = true
withFlag, err := generate(context.Background(), request, deps)
withFlag, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
if err != nil {
t.Fatal(err)
}
if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" {
if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" || withFlag.Comparison.Assessment != "unassessed" || len(withFlag.Comparison.Unassessed) == 0 {
t.Fatalf("enabled comparison receipt=%#v calls=%d", withFlag.Comparison, legacyCalls)
}
if !bytes.Equal(withoutFlag.DOCX.Bytes, withFlag.DOCX.Bytes) {
@@ -403,35 +555,47 @@ func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
}
func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) {
const common = "# 研发周会纪要\n\n正文结论。\n\n## 要点\n\n- 统一解析入口\n- 保留 Markdown\n\n| 项目 | 状态 |\n|---|---|\n| 解析器 | 统一 |\n"
const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\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)
delivery, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies())
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 {
if delivery.Comparison.Assessment != "assessed" || len(delivery.Comparison.AccidentalDifferences) != 0 || len(delivery.Comparison.Unassessed) != 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
exceptions := map[string]bool{}
for _, dimension := range delivery.Comparison.Dimensions {
if dimension.Status == "intentional" {
exceptions[dimension.ExceptionID] = true
}
}
if !foundTitleDifference {
t.Fatalf("comparison did not record the legacy duplicate-title correction: %#v", delivery.Comparison.IntentionalDifferences)
for _, id := range []string{"RDR-T10-CMP-001", "RDR-T10-CMP-002", "RDR-T10-CMP-003", "RDR-T10-CMP-004"} {
if !exceptions[id] {
t.Errorf("comparison omitted reviewed exception %s: %#v", id, delivery.Comparison.Dimensions)
}
}
}
func TestLegacyComparisonReportsAccidentalAndUnassessedDimensions(t *testing.T) {
request := workspaceRequest([]byte("# Expected\n\nExpected body.\n"))
deps := defaultGeneratorDependencies()
deps.legacyBuild = func(title, _ string) ([]byte, error) { return legacyBuild(title, "# Wrong\n\nLost body.\n") }
accidental, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
if err != nil {
t.Fatal(err)
}
if accidental.Comparison.Assessment != "differences" || len(accidental.Comparison.AccidentalDifferences) == 0 {
t.Fatalf("content loss was not accidental: %#v", accidental.Comparison)
}
deps.legacyBuild = func(string, string) ([]byte, error) { return []byte("not a DOCX"), nil }
unassessed, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
if err != nil {
t.Fatal(err)
}
if unassessed.Comparison.Assessment != "unassessed" || len(unassessed.Comparison.Unassessed) == 0 {
t.Fatalf("invalid legacy sample was reported as assessed: %#v", unassessed.Comparison)
}
}
@@ -471,3 +635,28 @@ func docxVisibleText(t *testing.T, raw []byte) string {
t.Fatal("word/document.xml missing")
return ""
}
func docxPart(t *testing.T, raw []byte, name string) 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 != name {
continue
}
stream, err := file.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(stream)
stream.Close()
if err != nil {
t.Fatal(err)
}
return string(content)
}
t.Fatalf("DOCX part %s missing", name)
return ""
}
@@ -11,7 +11,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
1. **先判路由**:普通、非受控的工作区 Word 可用组织默认候选;项目文档、受控文档、专用模板或审签输出必须交给 RDM,禁止调用 Backend 默认规则回退。组织默认候选的 manifest 状态固定显示 `pending-human-visual-review`,不能描述成已发布或已视觉批准。Excel / PPT / 图片再按用户指令、模板、参考文档、会话样式和默认主题的顺序定样式。
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
3. **生成 Word**:先用 `time_now` 冻结 `frozenAt`,再调用 `render_document`,传稳定 `documentId`、标题 `name`、Markdown、语言和 `working|preview` 模式,并显式传 `controlled=false`、`projectDocument=false`;任何一项不是 false 就停止并交给 RDM。Markdown 用 `#`-`######` 标题、段落、`-`/`1.` 列表、GFM 表格、`asset:` 图片、`:::callout warning`/`:::` 提示块和 `:::page-break`。工具先保存带稳定 node ID 的 Markdown,再生成确定性 DOCX、manifest 和完整 zip;返回值逐项列出 SHA-256。失败时按诊断修 Markdown,绝不调用 legacy Word 路径回退。
3. **生成 Word**:调用 `render_document`,传本次交付稳定且重试复用的 `operationId`、稳定 `documentId`、标题 `name`、Markdown、语言和 `working|preview` 模式,并显式传 `controlled=false`、`projectDocument=false`;任何一项不是 false 就停止并交给 RDM。时间与 requester 由宿主冻结,禁止传入或声称自定 `frozenAt`。Markdown 用 `#`-`######` 标题、段落、引用、围栏/行内代码、粗体、斜体、删除线、安全链接标签、`-`/`1.` 列表、GFM 表格、`asset:` 图片、`:::callout warning`/`:::` 提示块和 `:::page-break`。工具先保存带稳定 node ID 的 Markdown,再生成确定性 DOCX、manifest 和完整 zip;返回值逐项列出稳定 ID 与 SHA-256。失败时按回执状态重试同一 operationId,绝不调用 legacy Word 路径回退。
4. **生成其他格式并自检**:Excel/PPT/图片用 `run_python` + house-style 对应 kit;用 lint/outline 回读。Word 的 OPC、安全、锚点与回读校验已经由统一 renderer 执行,复核 manifest 中的诊断、组织默认候选状态和 Markdown/DOCX 哈希。
5. **登记**:`render_document` 已登记 Markdown、DOCX、manifest 和 zip,不要重复登记。其他格式继续用 `register_artifact`。
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
@@ -5,6 +5,6 @@ version: 1
title: render_document 工具描述
description: 普通工作区 Markdown 到统一 Word 交付包的安全渲染入口
---
把普通、非受控的工作区 Markdown 交给共享 canonical parser、组织默认候选 profile 和 Word adapter。先用 time_now 取得 frozenAt;必须显式传 `controlled=false` 和 `projectDocument=false`,缺任一字段即拒绝;requester 由系统可信身份填写,不要作为参数伪造。成功会按顺序保存 Markdown、DOCX、manifest 和 zip,并返回每项 SHA-256。
把普通、非受控的工作区 Markdown 交给共享 canonical parser、组织默认候选 profile 和 Word adapter。必须传稳定且重试复用的 operationId,并显式传 `controlled=false` 和 `projectDocument=false`;缺任一字段即拒绝。frozenAt 与 requester 由宿主首次操作时创建并持久化,调用方不能提交。成功回执包含 Markdown、DOCX、manifest、zip 的稳定 ID 与 SHA-256;失败回执明确 partial 或 unknown,重试必须沿用同一 operationId。
只允许 working 或 preview。项目文档、受控文档、专用模板、审阅稿或批准稿必须交给 RDM;失败不得改走 legacy Word builder。当前公司默认规则状态是 pending-human-visual-review,只可用于普通工作/预览交付,不能称为已发布或已视觉批准。
+174
View File
@@ -0,0 +1,174 @@
package runmgr
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"agent-system-backend/internal/docgen"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func (t *toolCtx) ExecuteDocumentDelivery(ctx context.Context, request docgen.OperationRequest) (docgen.OperationReceipt, error) {
receipt, err := t.rc.executeDocumentDelivery(ctx, request)
if receipt.Outcome == "complete" || receipt.Outcome == "unknown" {
for _, artifact := range receipt.Artifacts {
if artifact.Role != string(store.DocumentArtifactArchive) {
continue
}
t.arts = append(t.arts, core.Artifact{
ID: core.ID(artifact.ID), ProjectID: t.rc.run.ProjectID, Owner: t.rc.run.Owner,
Name: artifact.Name, Kind: "document", MIME: artifact.MIME, Size: artifact.Size,
})
break
}
}
return receipt, err
}
func (c *runCtx) executeDocumentDelivery(ctx context.Context, operation docgen.OperationRequest) (docgen.OperationReceipt, error) {
if ctx == nil {
return docgen.OperationReceipt{}, errors.New("document delivery: nil context")
}
if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil {
return docgen.OperationReceipt{}, err
}
requestHash, err := documentOperationHash(operation)
if err != nil {
return docgen.OperationReceipt{}, err
}
record, err := c.mgr.st.BeginDocumentDeliveryAccountChecked(c.run.Owner, store.DocumentDeliveryBeginInput{
OperationID: operation.OperationID, RequestSHA256: requestHash,
ProjectID: string(c.run.ProjectID), ConversationID: string(c.run.ConversationID), SourceRun: string(c.run.ID),
DocumentID: operation.DocumentID, Mode: string(operation.Mode), RendererIdentity: docgen.RendererIdentity,
}, c.mgr.accountAllowImplicitLocal)
if err != nil {
return receiptFromDelivery(record, "partial", false, err), err
}
if record.State == store.DocumentDeliveryComplete {
return receiptFromDelivery(record, "complete", true, nil), nil
}
request := docgen.Request{
DocumentID: operation.DocumentID, Title: operation.Title, DocumentType: operation.DocumentType, Language: operation.Language,
Markdown: operation.Markdown, Mode: operation.Mode, FrozenAt: record.FrozenAt, RequestedBy: c.run.Owner,
Controlled: operation.Controlled, ProjectDocument: operation.ProjectDocument,
}
if record.MarkdownArtifactID != "" {
persisted, readErr := c.mgr.st.DocumentDeliveryMarkdown(c.run.Owner, operation.OperationID)
if readErr != nil {
return receiptFromDelivery(record, "unknown", false, readErr), readErr
}
request.Markdown = persisted
}
prepared, err := docgen.Prepare(ctx, request)
if err != nil {
return receiptFromDelivery(record, "partial", false, err), err
}
if record.MarkdownArtifactID == "" {
record, err = c.mgr.st.PersistDocumentDeliveryMarkdownAccountChecked(c.run.Owner, operation.OperationID,
deliveryArtifactInput(store.DocumentArtifactMarkdown, prepared.Markdown), prepared.CanonicalHash,
c.mgr.accountAllowImplicitLocal)
if err != nil {
outcome := "partial"
if errors.Is(err, store.ErrDocumentDeliveryOutcomeUnknown) {
outcome = "unknown"
}
return receiptFromDelivery(record, outcome, false, err), err
}
}
request.LegacySource = append([]byte(nil), operation.Markdown...)
request.Markdown = prepared.Markdown.Bytes
if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil {
return receiptFromDelivery(record, "partial", false, err), err
}
delivery, err := docgen.GenerateWithOptions(ctx, request, docgen.RenderOptions{MigrationComparison: c.mgr.documentMigrationComparison})
if err != nil {
record, markErr := c.mgr.st.MarkDocumentDeliveryFailedAccountChecked(c.run.Owner, operation.OperationID, err.Error(), c.mgr.accountAllowImplicitLocal)
if markErr != nil {
return receiptFromDelivery(record, "unknown", false, markErr), errors.Join(err, markErr)
}
return receiptFromDelivery(record, "partial", false, err), err
}
if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil {
return receiptFromDelivery(record, "partial", false, err), err
}
deliveryKey := documentDeliveryKey(delivery)
record, err = c.mgr.st.CommitDocumentDeliveryDerivedAccountChecked(c.run.Owner, operation.OperationID,
deliveryKey, delivery.Profile.SnapshotHash, delivery.Renderer.ReviewHash,
[]store.DocumentDeliveryArtifactInput{
deliveryArtifactInput(store.DocumentArtifactDOCX, delivery.DOCX),
deliveryArtifactInput(store.DocumentArtifactManifest, delivery.Manifest),
deliveryArtifactInput(store.DocumentArtifactArchive, delivery.Archive),
}, c.mgr.accountAllowImplicitLocal)
if err != nil {
outcome := "partial"
if errors.Is(err, store.ErrDocumentDeliveryOutcomeUnknown) {
outcome = "unknown"
}
return receiptFromDelivery(record, outcome, false, err), err
}
return receiptFromDelivery(record, "complete", false, nil), nil
}
func documentOperationHash(operation docgen.OperationRequest) (string, error) {
payload := struct {
DocumentID string `json:"documentId"`
Title string `json:"title"`
DocumentType string `json:"documentType"`
Language string `json:"language"`
Markdown string `json:"markdown"`
Mode string `json:"mode"`
Controlled bool `json:"controlled"`
ProjectDocument bool `json:"projectDocument"`
}{
DocumentID: operation.DocumentID, Title: operation.Title, DocumentType: operation.DocumentType,
Language: operation.Language, Markdown: string(operation.Markdown), Mode: string(operation.Mode),
Controlled: operation.Controlled, ProjectDocument: operation.ProjectDocument,
}
raw, err := json.Marshal(payload)
if err != nil {
return "", err
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:]), nil
}
func documentDeliveryKey(delivery docgen.Delivery) string {
manifest := delivery.Renderer
value := strings.Join([]string{manifest.DocumentHash, manifest.ProfileHash, manifest.ReviewHash, string(manifest.Mode), docgen.RendererIdentity}, "\x00")
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func deliveryArtifactInput(role store.DocumentDeliveryArtifactRole, artifact docgen.Artifact) store.DocumentDeliveryArtifactInput {
kind := "document"
if artifact.MIME == "application/json" {
kind = "data"
}
return store.DocumentDeliveryArtifactInput{
Role: role, Name: artifact.Name, Kind: kind, MIME: artifact.MIME,
SHA256: artifact.SHA256, Content: artifact.Bytes,
}
}
func receiptFromDelivery(record store.DocumentDeliveryRecord, outcome string, cacheHit bool, cause error) docgen.OperationReceipt {
receipt := docgen.OperationReceipt{
OperationID: record.OperationID, Outcome: outcome, State: string(record.State), FrozenAt: record.FrozenAt,
DeliveryKey: record.DeliveryKey, Renderer: record.RendererIdentity, CacheHit: cacheHit,
}
if cause != nil {
receipt.Error = cause.Error()
}
for _, item := range record.Artifacts {
receipt.Artifacts = append(receipt.Artifacts, docgen.ReceiptArtifact{
Role: string(item.Role), ID: string(item.Artifact.ID), Name: item.Artifact.Name,
MIME: item.Artifact.MIME, Size: item.Artifact.Size, SHA256: item.SHA256,
})
}
return receipt
}
+170
View File
@@ -0,0 +1,170 @@
package runmgr
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/docgen"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
func TestDocumentDeliveryReportsPartialAndUnknownReceiptsWithStableRetry(t *testing.T) {
tests := []struct {
name string
stage string
wantOutcome string
wantCount int
}{
{"derived staging", store.DocumentDeliveryFaultAfterManifestBlob, "partial", 1},
{"post commit", store.DocumentDeliveryFaultAfterDerivedCommit, "unknown", 4},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
run, st := newDocumentDeliveryRun(t)
st.SetDocumentDeliveryFault(test.stage, errors.New("injected "+test.stage))
arguments := json.RawMessage(`{"operationId":"receipt-operation","name":"Receipt","documentId":"receipt","documentType":"general","language":"en","markdown":"# Receipt\n\nbody\n","mode":"preview","controlled":false,"projectDocument":false}`)
failed := run.ExecuteTool(core.ToolCallRequest{CallID: "delivery-failed", Name: "render_document", Arguments: arguments})
if failed.Status != "error" {
t.Fatalf("fault result = %#v", failed)
}
var receipt docgen.OperationReceipt
if err := json.Unmarshal([]byte(strings.TrimPrefix(failed.Output, "render_document: ")), &receipt); err != nil {
t.Fatalf("decode failure receipt: %v\n%s", err, failed.Output)
}
if receipt.Outcome != test.wantOutcome || len(receipt.Artifacts) != test.wantCount || receipt.FrozenAt.IsZero() {
t.Fatalf("failure receipt = %#v", receipt)
}
firstIDs := receiptArtifactIdentity(receipt.Artifacts)
retry := run.ExecuteTool(core.ToolCallRequest{CallID: "delivery-retry", Name: "render_document", Arguments: arguments})
if retry.Status != "ok" {
t.Fatalf("retry failed: %s", retry.Output)
}
var completed docgen.OperationReceipt
if err := json.Unmarshal([]byte(retry.Output), &completed); err != nil {
t.Fatal(err)
}
if completed.Outcome != "complete" || len(completed.Artifacts) != 4 || !completed.FrozenAt.Equal(receipt.FrozenAt) {
t.Fatalf("completed receipt = %#v", completed)
}
for role, identity := range firstIDs {
if got := receiptArtifactIdentity(completed.Artifacts)[role]; got != identity {
t.Errorf("artifact %s changed on retry: %s -> %s", role, identity, got)
}
}
})
}
}
func TestDocumentMigrationComparisonRequiresTrustedManagerCapability(t *testing.T) {
run, st := newDocumentDeliveryRun(t)
arguments := json.RawMessage(`{"operationId":"comparison-operation","name":"Comparison","documentId":"comparison","documentType":"general","language":"en","markdown":"# Comparison\n\nBody with **bold** and [safe](https://example.com/private).\n","mode":"preview","controlled":false,"projectDocument":false,"migrationComparison":true}`)
rejected := run.ExecuteTool(core.ToolCallRequest{CallID: "comparison-model", Name: "render_document", Arguments: arguments})
if rejected.Status != "error" || !strings.Contains(rejected.Output, "unknown field") {
t.Fatalf("model enabled comparison: %#v", rejected)
}
run.mgr.documentMigrationComparison = true
arguments = json.RawMessage(`{"operationId":"comparison-operation","name":"Comparison","documentId":"comparison","documentType":"general","language":"en","markdown":"# Comparison\n\nBody with **bold** and [safe](https://example.com/private).\n","mode":"preview","controlled":false,"projectDocument":false}`)
result := run.ExecuteTool(core.ToolCallRequest{CallID: "comparison-host", Name: "render_document", Arguments: arguments})
if result.Status != "ok" {
t.Fatalf("trusted comparison: %s", result.Output)
}
var receipt docgen.OperationReceipt
if err := json.Unmarshal([]byte(result.Output), &receipt); err != nil {
t.Fatal(err)
}
var manifestID string
for _, artifact := range receipt.Artifacts {
if artifact.Role == string(store.DocumentArtifactManifest) {
manifestID = artifact.ID
}
}
raw, err := st.ArtifactContent("alice", manifestID)
if err != nil {
t.Fatal(err)
}
var manifest docgen.Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
if !manifest.Comparison.Enabled || manifest.Comparison.Assessment == "" || len(manifest.Comparison.Dimensions) == 0 {
t.Fatalf("trusted comparison was not assessed: %#v", manifest.Comparison)
}
}
func TestDocumentRendererFailureKeepsAuthoritativeMarkdownForRetry(t *testing.T) {
run, st := newDocumentDeliveryRun(t)
arguments := json.RawMessage(`{"operationId":"renderer-failure","name":"Failure","documentId":"failure","documentType":"general","language":"en","markdown":"# Failure\n\n![missing](asset:missing@1.0.0)\n","mode":"preview","controlled":false,"projectDocument":false}`)
first := run.ExecuteTool(core.ToolCallRequest{CallID: "renderer-failure-1", Name: "render_document", Arguments: arguments})
if first.Status != "error" {
t.Fatalf("renderer failure result = %#v", first)
}
var firstReceipt docgen.OperationReceipt
if err := json.Unmarshal([]byte(strings.TrimPrefix(first.Output, "render_document: ")), &firstReceipt); err != nil {
t.Fatal(err)
}
if firstReceipt.Outcome != "partial" || len(firstReceipt.Artifacts) != 1 || firstReceipt.Artifacts[0].Role != string(store.DocumentArtifactMarkdown) {
t.Fatalf("renderer failure lost Markdown receipt: %#v", firstReceipt)
}
markdown, err := st.ArtifactContent("alice", firstReceipt.Artifacts[0].ID)
if err != nil || !strings.Contains(string(markdown), "<!-- node-id:") {
t.Fatalf("authoritative Markdown missing markers: %v %q", err, markdown)
}
second := run.ExecuteTool(core.ToolCallRequest{CallID: "renderer-failure-2", Name: "render_document", Arguments: arguments})
var secondReceipt docgen.OperationReceipt
if second.Status != "error" || json.Unmarshal([]byte(strings.TrimPrefix(second.Output, "render_document: ")), &secondReceipt) != nil ||
len(secondReceipt.Artifacts) != 1 || secondReceipt.Artifacts[0].ID != firstReceipt.Artifacts[0].ID || secondReceipt.Artifacts[0].SHA256 != firstReceipt.Artifacts[0].SHA256 {
t.Fatalf("renderer failure retry changed Markdown identity: first=%#v second=%#v result=%#v", firstReceipt, secondReceipt, second)
}
}
func newDocumentDeliveryRun(t *testing.T) (*runCtx, *store.Store) {
t.Helper()
st := storeForRunManagerTest(t)
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "delivery")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("alice", project.ID, "delivery", "")
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
if err := registry.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
eventBus := bus.New()
events := newEventBuffer(st, eventBus)
t.Cleanup(events.stop)
manager := &Manager{st: st, bus: eventBus, registry: registry, settings: settings.NewService(st), events: events}
return &runCtx{
mgr: manager, run: core.Run{ID: "run-delivery", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
prof: core.AgentProfile{}, eff: manager.settings.Effective("alice", ""), ctx: context.Background(),
}, st
}
func storeForRunManagerTest(t *testing.T) *store.Store {
t.Helper()
_, st := newExecEnv(t)
return st
}
func receiptArtifactIdentity(artifacts []docgen.ReceiptArtifact) map[string]string {
result := make(map[string]string, len(artifacts))
for _, artifact := range artifacts {
result[artifact.Role] = artifact.ID + ":" + artifact.SHA256
}
return result
}
+37 -13
View File
@@ -10,6 +10,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -70,9 +71,10 @@ func TestExecuteToolBackfillsArtifact(t *testing.T) {
}
}
// run_python 生成文件后 register_artifact 必须把工件回填到 ToolCallResult.Artifact。
func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
c, _ := newExecEnv(t)
// Arbitrary Python may create local DOCX bytes, but cannot publish them as a
// trusted Word delivery without the host renderer operation.
func TestRunPythonDOCXCannotRegisterAsTrustedDelivery(t *testing.T) {
c, st := newExecEnv(t)
root := t.TempDir()
c.mgr.SetWorkspaceRoot(root)
if err := tools.RegisterExecTools(c.mgr.registry); err != nil {
@@ -94,14 +96,8 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
CallID: "reg1", Name: "register_artifact",
Arguments: json.RawMessage(`{"path":"note.docx"}`),
})
if res.Status != "ok" {
t.Fatalf("register_artifact: %s", res.Output)
}
if res.Artifact == nil || res.Artifact.Name != "note.docx" {
t.Fatalf("ToolCallResult.Artifact 未回填: %+v", res.Artifact)
}
if res.Artifact.MIME != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
t.Errorf("docx mime: %s", res.Artifact.MIME)
if res.Status != "error" || res.Artifact != nil || !strings.Contains(res.Output, "render_document") {
t.Fatalf("direct DOCX registration was not rejected: %+v", res)
}
b, err := os.ReadFile(filepath.Join(root, "alice", c.run.ConversationID, "note.docx"))
if err != nil {
@@ -110,6 +106,10 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
if string(b[:4]) != "PK\x03\x04" {
t.Fatalf("stored bytes: %q", b)
}
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
if err != nil || len(artifacts) != 0 {
t.Fatalf("rejected DOCX became visible: %+v err=%v", artifacts, err)
}
}
// 空手工具(如 time_now)不得凭空挂 Artifact。
@@ -161,6 +161,9 @@ func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
if err := registry.RegisterBuiltin(); err != nil {
t.Fatal(err)
@@ -183,14 +186,18 @@ func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
})
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"},
mgr: manager, run: core.Run{ID: "run-before-restart", 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}`)
args := json.RawMessage(`{"operationId":"restart-operation","name":"重启纪要","documentId":"restart-minutes","documentType":"general","language":"zh-CN","markdown":"# 重启纪要\n\n缓存身份保持。\n","mode":"preview","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)
}
var firstReceipt docgen.OperationReceipt
if err := json.Unmarshal([]byte(result.Output), &firstReceipt); err != nil || firstReceipt.FrozenAt.IsZero() {
t.Fatalf("first receipt=%#v err=%v", firstReceipt, err)
}
artifacts, err := st.ListArtifacts("alice", project.ID)
if err != nil || len(artifacts) != 4 {
t.Fatalf("stored artifacts=%d err=%v", len(artifacts), err)
@@ -215,6 +222,23 @@ func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
t.Fatal(err)
}
defer reopened.Close()
reopenedBus := bus.New()
reopenedEvents := newEventBuffer(reopened, reopenedBus)
defer reopenedEvents.stop()
reopenedManager := &Manager{st: reopened, bus: reopenedBus, registry: registry, settings: settings.NewService(reopened), events: reopenedEvents}
reopenedRun := &runCtx{
mgr: reopenedManager, run: core.Run{ID: "run-after-restart", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
prof: core.AgentProfile{}, eff: reopenedManager.settings.Effective("alice", ""), ctx: context.Background(),
}
retryResult := reopenedRun.ExecuteTool(core.ToolCallRequest{CallID: "render-restart-retry", Name: "render_document", Arguments: args})
if retryResult.Status != "ok" {
t.Fatalf("restart retry: %s", retryResult.Output)
}
var retryReceipt docgen.OperationReceipt
if err := json.Unmarshal([]byte(retryResult.Output), &retryReceipt); err != nil || !retryReceipt.CacheHit ||
!retryReceipt.FrozenAt.Equal(firstReceipt.FrozenAt) || !reflect.DeepEqual(retryReceipt.Artifacts, firstReceipt.Artifacts) {
t.Fatalf("restart receipt changed: first=%#v retry=%#v err=%v", firstReceipt, retryReceipt, err)
}
for artifactID, wantHash := range want {
raw, err := reopened.ArtifactContentByID(artifactID)
if err != nil {
+3
View File
@@ -74,6 +74,9 @@ type Manager struct {
projectWorkObservationEnabled bool
projectWorkPreflight projectWorkPreflight
projectRiskRespond projectRiskRespond
// documentMigrationComparison is an internal host capability. It is never
// exposed through tool arguments, settings, prompts, or child environments.
documentMigrationComparison bool
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
commands CommandSource
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
+10 -29
View File
@@ -1,11 +1,11 @@
package runtime
import (
"encoding/json"
"fmt"
"strings"
"time"
"agent-system-backend/internal/docgen"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
"agent-system-backend/internal/workflow"
@@ -178,37 +178,18 @@ func (m *MockRunner) Execute(rc RunCtx) error {
// 工件,再保存 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"
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,
arguments, _ := json.Marshal(map[string]any{
"operationId": "mock-doc-" + string(run.ID), "name": "demo-会议纪要", "documentId": "mock-demo-minutes",
"documentType": "general", "language": "zh-CN", "markdown": md, "mode": "preview",
"controlled": false, "projectDocument": false,
})
if derr != nil {
addText("演示文档生成失败:" + derr.Error())
result := rc.ExecuteTool(core.ToolCallRequest{CallID: "mock-render-document", Name: "render_document", Arguments: arguments})
if result.Status != "ok" || result.Artifact == nil {
addText("演示文档生成失败:" + result.Output)
break
}
var written []core.Artifact
for _, item := range []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive} {
kind := "document"
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]
if result.Artifact != nil {
art := *result.Artifact
addText("Markdown、Word、渲染清单和完整归档已生成;清单列出可复核 SHA-256。点击下方交付卡片下载归档,或在工件面板查看单项。")
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
+2 -20
View File
@@ -5,27 +5,9 @@ description: 用统一 renderer 生成 Markdown、DOCX、manifest 和归档
# docx 生成配方
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。普通、非受控工作区 Word 调用 `render_document`;先用 `time_now` 冻结时间,传稳定 documentId、标题和 Markdown。项目或受控 Word、专用模板、审阅与批准输出必须交给 RDM,禁止默认回退。
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。普通、非受控工作区 Word 调用 `render_document`;传本次交付稳定且重试复用的 operationId、稳定 documentId、标题和 Markdown,时间由宿主持久冻结。项目或受控 Word、专用模板、审阅与批准输出必须交给 RDM,禁止默认回退。
成功结果包括 Markdown、DOCX、manifest 和 zip,逐项核对 SHA-256;当前组织默认候选仍是 `pending-human-visual-review`。下面的 Python 构件只供显式迁移对照,不得用于生产交付或 renderer 失败回退。
```python
from housestyle import docx_kit as dx
doc = dx.new_document(theme="modern", title="文档标题")
dx.add_title(doc, "文档标题", "日期或副标题")
dx.add_kv_table(doc, [("项目", "值"), ("日期", "2026年10月1日")])
dx.add_heading(doc, "一、结论", 1)
dx.add_paragraph(doc, "先写结论。")
dx.add_heading(doc, "依据", 2)
dx.add_bullets(doc, ["要点一", "要点二"])
dx.add_numbers(doc, ["步骤一", "步骤二"])
dx.add_table(doc, ["事项", "负责人", "期限"], [["完成需求说明", "未指明", "未指明"]], ["left", "left", "left"])
dx.add_callout(doc, "只看这一段也能知道要做什么。", "摘要")
dx.save(doc, "交付物.docx")
```
迁移对照必须显式设置 `AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1`。生产 Word 的 OPC、安全、锚点与回读结果以共享 renderer manifest 为准;修订以返回的 Markdown 为权威源。
成功结果包括 Markdown、DOCX、manifest 和 zip,逐项核对 SHA-256;当前组织默认候选仍是 `pending-human-visual-review`。Python、shell、`docx_kit` 和 `register_artifact` 都不是 Word 交付入口。迁移对照只能由 Backend 内部受信能力启动。生产 Word 的 OPC、安全、锚点与回读结果以共享 renderer manifest 为准;修订以返回的 Markdown 为权威源。
版面范式:
+7 -26
View File
@@ -7,7 +7,7 @@ description: 交付物设计系统。普通 Word 使用统一 renderer;housest
普通、非受控工作区 Word 的生产入口是 `render_document`:它保存 Markdown、确定性 DOCX、manifest 和 zip,并在 manifest 标注组织默认候选 `pending-human-visual-review`。项目或受控 Word、专用模板、审阅和批准输出必须交给 RDM,禁止 Backend 默认回退。
housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word 构件只保留给显式迁移对照;`housestyle.build` 默认禁用,仅在 `AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1` 时运行,而且永不作为共享 renderer 失败后的回退。
housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word 构件不是生产或迁移入口;`housestyle.build` 永久禁用,DOC/DOCX 也不能通过 `register_artifact` 登记。
## 主题
@@ -26,8 +26,8 @@ housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word
按这个顺序,只取第一个命中项:
1. 用户本轮说清楚的要求(“改成公文”“主色用品牌绿”)。
2. 本轮上传的模板(`.docx` `.dotx` `.pptx` `.potx` `.xlsx` `.xltx`),路径在 `inputs/`。Word 用 `new_document(template=路径)`,它会清空正文,保留纸张、页边距和页眉页脚。
3. 用户要求仿照的参考文档:`from housestyle.extract import extract`,再 `extract(路径)` 得到字典,传给 `new_document(抽取结果)`。PDF 在装了 pdfplumber 时抽取字体和页面尺寸,否则说明只能近似。图片不能精确抽取,按描述近似,并请用户确认主色、字体和页边距。
2. 本轮上传的 PowerPoint/Excel 模板(`.pptx` `.potx` `.xlsx` `.xltx`),路径在 `inputs/`。
3. 用户要求仿照的 PowerPoint/Excel/PDF/图片参考;Word 模板属于 RDM 受控路径。
4. 本会话前面已经定过的样式。
5. 环境段里的默认主题、强调色和 `inputs/_default-template*`。
@@ -35,28 +35,9 @@ housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word
```python
import housestyle
from housestyle import docx_kit as dx
from housestyle import pptx_kit as px
from housestyle import xlsx_kit as xs
doc = dx.new_document(theme="modern", title="文档标题") # accent="1B3A4B" 或 template="inputs/模板.docx"
dx.add_title(doc, "文档标题", "副标题或日期")
dx.add_kv_table(doc, [("字段", "值")])
dx.add_heading(doc, "一级标题", 1) # 只到 3 级
dx.add_paragraph(doc, "正文。")
dx.add_bullets(doc, ["要点"]) # 文本里不要再写 •
dx.add_numbers(doc, ["步骤"]) # 文本里不要再写 1.
dx.add_table(doc, ["列"], [["值"]], ["left"])
dx.add_callout(doc, "给决策者的一段话。", "摘要")
dx.add_quote(doc, "关键原话。", "出处")
from housestyle import visuals
visuals.status_row(doc, [{"title": "已修复", "badge": "历史", "body": "一句说明。", "tone": "ok"}])
visuals.card_grid(doc, [{"title": "判断", "points": ["要点"], "footnote": "一句话总结", "tone": "info"}])
visuals.column_row(doc, [{"title": "要求", "points": ["事项"], "footnote": "收束", "tone": "warn"}])
png = visuals.render_flow("/tmp/flow.png", [{"title": "先入库", "body": "再输出", "tone": "info"}])
visuals.add_picture(doc, png)
dx.save(doc, "交付物.docx")
prs = px.new_presentation(theme="modern", title="汇报")
px.add_title_slide(prs, "汇报标题", "副标题")
px.add_section_slide(prs, "第一部分")
@@ -76,16 +57,16 @@ xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"])
## 统一 Word 构建(生产)
```text
1. time_now 冻结 frozenAt。
2. 调用 render_document,传稳定 documentId、name、Markdown、language、working|preview,并显式传 controlled=false、projectDocument=false。
1. 为本次交付选择稳定且重试复用的 operationId;时间由宿主冻结,不传 frozenAt。
2. 调用 render_document,传 operationId、稳定 documentId、name、Markdown、language、working|preview,并显式传 controlled=false、projectDocument=false。
3. 核对返回的 Markdown、DOCX、manifest、zip 四项 SHA-256。
4. 修改时以返回的 Markdown 为权威源,再次调用同一工具;失败不走 legacy 回退。
```
Markdown 支持 `#`-`######` 标题、段落、`-`/`1.` 列表、GFM 表格、已声明 `asset:` 图片、
Markdown 支持 `#`-`######` 标题、段落、引用、围栏/行内代码、粗体、斜体、删除线、安全链接标签、`-`/`1.` 列表、GFM 表格、已声明 `asset:` 图片、
`:::callout warning`/`:::` 提示块和 `:::page-break`。脚本、HTML、外链图片、未知指令和未声明资产会 fail-closed。
迁移对照如需运行旧构建器,显式设置 `AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1` 后调用模块;它的输出只供对照,不得交付或替代统一 renderer 输出。Excel/PPT 仍用对应 kit 的 run_python 脚本。
迁移对照只能由 Backend 内部受信能力运行,不能由模型、Python 或环境变量开启。Excel/PPT 仍用对应 kit 的 run_python 脚本。
## 自检
@@ -1,177 +1,20 @@
"""Legacy Word migration comparator.
"""Disabled legacy Word builder.
普通 Word 生产交付必须调用 Backend 的 render_document 工具,共享 canonical
parser/profile/compiler/Word adapter。此模块仅在
AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1 时用于迁移对照,永不作为 renderer
失败后的回退。xlsx/pptx 等其他类型仍走 run_python + 对应 kit。
Markdown 约定:
# 标题 文档标题(Title,只取第一个)
> 副标题或日期 标题下副题(只取标题后的第一段 >)
## 一级 / ### 二级 / #### 三级 Heading 1/2/3(标题层级连续)
- 或 * 条目 项目符号
1. 条目 编号列表
| a | b | 表格(首个分隔行 |---|---| 定表头)
> 引文 引用块(副题之后)
!!! 标签 文本 强调块(label=标签,缺省「摘要」)
<!-- housestyle: theme=modern accent=1B3A4B --> 主题与强调色(可省)
Word delivery is a trusted host operation implemented by render_document. A
model-controlled Python process cannot authorize migration comparison or emit
a trusted DOCX receipt, so this module never writes Word output.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from housestyle import docx_kit as dx
from housestyle import lint
def build(source: str, output: str | None = None) -> int:
if os.environ.get("AGENT_SYSTEM_LEGACY_WORD_COMPARISON") != "1":
print(
"build: legacy Word path is disabled; use the render_document tool "
"(set AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1 only for migration comparison)",
file=sys.stderr,
)
return 2
src = Path(source)
if not src.is_file():
print(f"build: 源文件不存在: {source}", file=sys.stderr)
return 2
text = src.read_text(encoding="utf-8")
out = Path(output or str(src.with_suffix(".docx")))
if out.suffix.lower() not in (".docx",):
print(f"build: 输出只支持 .docx(其他类型用 run_python + 对应 kit)", file=sys.stderr)
return 2
theme, accent = "modern", None
lines = text.splitlines()
if lines and lines[0].strip().startswith("<!-- housestyle:"):
for kv in lines[0].replace("<!-- housestyle:", "").replace("-->", "").split():
if "=" in kv:
k, v = kv.split("=", 1)
if k == "theme":
theme = v
elif k == "accent":
accent = v
lines = lines[1:]
doc = dx.new_document(theme=theme, accent=accent, title=_first_title(lines) or out.stem)
_render(doc, lines)
dx.save(doc, str(out))
issues = lint.lint_path(str(out))
errors = [i for i in issues if i.get("severity") == "error"]
from housestyle.outline import summarize
print(f"built {out.name}")
print(summarize(str(out)))
if issues:
print(json.dumps(issues, ensure_ascii=False, indent=2))
if errors:
print(f"lint: {len(errors)} 个 error——修 .md 源里对应段落后重新 build", file=sys.stderr)
return 1
print("lint: 通过(0 error)")
return 0
def _first_title(lines: list[str]) -> str | None:
for line in lines:
if line.startswith("# "):
return line[2:].strip()
return None
def _split_row(line: str) -> list[str]:
body = line.strip().strip("|")
return [c.strip() for c in body.split("|")]
def _is_table_sep(line: str) -> bool:
s = line.strip()
if not s.startswith("|"):
return False
cells = [c.strip() for c in s.strip("|").split("|")]
return bool(cells) and all(set(c) <= set("-: ") and c for c in cells)
def _render(doc, lines: list[str]) -> None:
title_seen = False
subtitle_used = False
i = 0
pending_numbers: list[str] = []
pending_bullets: list[str] = []
def flush_lists() -> None:
nonlocal pending_numbers, pending_bullets
if pending_bullets:
dx.add_bullets(doc, pending_bullets)
pending_bullets = []
if pending_numbers:
dx.add_numbers(doc, pending_numbers)
pending_numbers = []
while i < len(lines):
raw = lines[i]
line = raw.rstrip()
stripped = line.strip()
if not stripped or stripped == "---":
flush_lists()
i += 1
continue
if stripped.startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]):
flush_lists()
headers = _split_row(stripped)
rows = []
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
rows.append(_split_row(lines[j]))
j += 1
dx.add_table(doc, headers, rows)
i = j
continue
flush_lists()
if stripped.startswith("# ") and not title_seen:
dx.add_title(doc, stripped[2:].strip())
title_seen = True
elif stripped.startswith("#### "):
dx.add_heading(doc, stripped[5:].strip(), 3)
elif stripped.startswith("### "):
dx.add_heading(doc, stripped[4:].strip(), 2)
elif stripped.startswith("## "):
dx.add_heading(doc, stripped[3:].strip(), 1)
elif stripped.startswith("!!!"):
rest = stripped[3:].strip()
label, _, body = rest.partition(" ")
dx.add_callout(doc, body.strip() or rest, label.strip() or "摘要")
elif stripped.startswith("> ") or stripped.startswith(">"):
quote = stripped.lstrip(">").strip()
dx.add_quote(doc, quote, "")
elif stripped.startswith(("- ", "* ")):
pending_bullets.append(stripped[2:].strip())
elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1:3] in (". ", ") ") or (
len(stripped) > 3 and stripped[:2].isdigit() and stripped[2:4] == ". "
):
pending_numbers.append(stripped.split(".", 1)[1].strip() if "." in stripped else stripped)
else:
dx.add_paragraph(doc, stripped)
i += 1
flush_lists()
del source, output
print("build: disabled; use the trusted render_document host operation", file=sys.stderr)
return 2
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: python3 -m housestyle.build <源.md> [-o 输出.docx]", file=sys.stderr)
raise SystemExit(2)
args = sys.argv[1:]
out = None
if "-o" in args:
k = args.index("-o")
if k + 1 >= len(args):
print("build: -o 缺少输出路径", file=sys.stderr)
raise SystemExit(2)
out = args[k + 1]
args = args[:k] + args[k + 2 :]
raise SystemExit(build(args[0], out))
raise SystemExit(build("", None))
@@ -184,53 +184,27 @@ class BuildTests(unittest.TestCase):
self.assertEqual(B.build(src, out), 2)
self.assertFalse(os.path.exists(out))
def test_build_renders_markdown_and_passes_lint(self):
import tempfile, os, subprocess, sys, json
def test_model_set_environment_cannot_enable_legacy_word_build(self):
import os
from housestyle import build as B
with tempfile.TemporaryDirectory() as d:
src = os.path.join(d, "plan.md")
out = os.path.join(d, "plan.docx")
Path(src).write_text(
"# 测试方案:先写需求再开发\n\n"
"## 一、结论\n结论:先写需求文档。\n\n"
"- 第一步:停代码\n- 第二步:写需求\n\n"
"## 二、行动项\n\n| 事项 | 负责人 |\n|---|---|\n| 需求文档 | 陈威 |\n\n"
"1. 确认设计输入\n",
encoding="utf-8",
)
Path(src).write_text("# 方案\n\n正文。\n", encoding="utf-8")
with mock.patch.dict(os.environ, {"AGENT_SYSTEM_LEGACY_WORD_COMPARISON": "1"}):
self.assertEqual(B.build(src, out), 0)
self.assertTrue(os.path.exists(out))
from housestyle.outline import summarize
o = summarize(out)
self.assertIn("测试方案", o)
self.assertIn("一、结论", o)
self.assertIn("tables 1", o)
def test_build_reports_lint_errors_with_nonzero_exit(self):
import tempfile, os
from housestyle import build as B
with tempfile.TemporaryDirectory() as d:
# 标题跳级(## 后直接 ####)触发 heading-skip error —— 诊断导向改源。
src = os.path.join(d, "bad.md")
out = os.path.join(d, "bad.docx")
Path(src).write_text("# 标题\n\n## 一、章节\n\n#### 跳级\n内容\n", encoding="utf-8")
with mock.patch.dict(os.environ, {"AGENT_SYSTEM_LEGACY_WORD_COMPARISON": "1"}):
self.assertEqual(B.build(src, out), 1)
self.assertEqual(B.build(src, out), 2)
self.assertFalse(os.path.exists(out))
def test_build_cli_entry(self):
import subprocess, sys, tempfile, os
with tempfile.TemporaryDirectory() as d:
src = os.path.join(d, "s.md")
Path(src).write_text("# T\n\n## 一、A\n内容\n", encoding="utf-8")
env = os.environ.copy()
env["AGENT_SYSTEM_LEGACY_WORD_COMPARISON"] = "1"
r = subprocess.run([sys.executable, "-m", "housestyle.build", src, "-o", os.path.join(d, "s.docx")],
capture_output=True, text=True, env=env,
capture_output=True, text=True,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。
if r.returncode not in (0, 1):
self.fail(r.stderr[:200])
self.assertEqual(r.returncode, 2, r.stderr[:200])
self.assertFalse(os.path.exists(os.path.join(d, "s.docx")))
class BrokenPptxTests(unittest.TestCase):
+5 -3
View File
@@ -183,13 +183,15 @@ func TestHouseStyleRoutesProductionWordThroughUnifiedRenderer(t *testing.T) {
t.Fatal(err)
}
body := string(raw)
for _, required := range []string{"render_document", "pending-human-visual-review", "RDM", "AGENT_SYSTEM_LEGACY_WORD_COMPARISON"} {
for _, required := range []string{"render_document", "pending-human-visual-review", "RDM"} {
if !strings.Contains(body, required) {
t.Errorf("house-style skill missing %q", required)
}
}
if strings.Contains(body, "python3 -m housestyle.build 方案.md") {
t.Fatal("house-style still advertises the legacy Word builder as a production command")
for _, forbidden := range []string{"AGENT_SYSTEM_LEGACY_WORD_COMPARISON", "docx_kit", "new_document(template=", "dx.save("} {
if strings.Contains(body, forbidden) {
t.Fatalf("house-style still advertises Word bypass %q", forbidden)
}
}
}
+475
View File
@@ -0,0 +1,475 @@
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"time"
core "agent-system-core"
)
type DocumentDeliveryState string
const (
DocumentDeliveryFrozen DocumentDeliveryState = "frozen"
DocumentDeliverySourceReady DocumentDeliveryState = "source-ready"
DocumentDeliveryFailed DocumentDeliveryState = "failed"
DocumentDeliveryComplete DocumentDeliveryState = "complete"
)
type DocumentDeliveryArtifactRole string
const (
DocumentArtifactMarkdown DocumentDeliveryArtifactRole = "markdown"
DocumentArtifactDOCX DocumentDeliveryArtifactRole = "docx"
DocumentArtifactManifest DocumentDeliveryArtifactRole = "manifest"
DocumentArtifactArchive DocumentDeliveryArtifactRole = "archive"
)
const (
DocumentDeliveryFaultAfterMarkdownBlob = "after-markdown-blob"
DocumentDeliveryFaultBeforeMarkdownCommit = "before-markdown-commit"
DocumentDeliveryFaultAfterDOCXBlob = "after-docx-blob"
DocumentDeliveryFaultAfterManifestBlob = "after-manifest-blob"
DocumentDeliveryFaultAfterArchiveBlob = "after-archive-blob"
DocumentDeliveryFaultBeforeDerivedCommit = "before-derived-commit"
DocumentDeliveryFaultAfterDerivedCommit = "after-derived-commit"
)
var (
ErrDocumentDeliveryConflict = errors.New("document delivery operation conflicts with its frozen request")
ErrDocumentDeliveryOutcomeUnknown = errors.New("document delivery commit outcome is unknown")
deliveryOperationPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
)
type DocumentDeliveryBeginInput struct {
OperationID string
RequestSHA256 string
ProjectID string
ConversationID string
SourceRun string
DocumentID string
Mode string
RendererIdentity string
}
type DocumentDeliveryArtifactInput struct {
Role DocumentDeliveryArtifactRole
Name string
Kind string
MIME string
SHA256 string
Content []byte
}
type DocumentDeliveryArtifact struct {
Role DocumentDeliveryArtifactRole `json:"role"`
Artifact core.Artifact `json:"artifact"`
SHA256 string `json:"sha256"`
}
type DocumentDeliveryRecord struct {
OperationID string `json:"operationId"`
Owner string `json:"owner"`
ProjectID string `json:"projectId"`
ConversationID string `json:"conversationId"`
SourceRun string `json:"sourceRun,omitempty"`
RequestSHA256 string `json:"requestSha256"`
State DocumentDeliveryState `json:"state"`
FrozenAt time.Time `json:"frozenAt"`
RendererIdentity string `json:"rendererIdentity"`
Mode string `json:"mode"`
DocumentID string `json:"documentId"`
CanonicalHash string `json:"canonicalHash,omitempty"`
ProfileHash string `json:"profileHash,omitempty"`
ReviewHash string `json:"reviewHash,omitempty"`
DeliveryKey string `json:"deliveryKey,omitempty"`
MarkdownArtifactID string `json:"markdownArtifactId,omitempty"`
Error string `json:"error,omitempty"`
Artifacts []DocumentDeliveryArtifact `json:"artifacts,omitempty"`
}
type storedDocumentDelivery struct {
DocumentDeliveryRecord
markdownSHA, docxID, docxSHA, manifestID, manifestSHA, archiveID, archiveSHA string
}
const documentDeliveryColumns = `operation_id,owner,project_id,conversation_id,source_run,request_sha256,state,frozen_at,renderer_identity,mode,document_id,canonical_hash,profile_hash,review_hash,delivery_key,markdown_artifact_id,markdown_sha256,docx_artifact_id,docx_sha256,manifest_artifact_id,manifest_sha256,archive_artifact_id,archive_sha256,error_text`
func (s *Store) BeginDocumentDeliveryAccountChecked(actor string, input DocumentDeliveryBeginInput, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
if err := validateDocumentDeliveryBegin(actor, input); err != nil {
return DocumentDeliveryRecord{}, err
}
var record DocumentDeliveryRecord
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.cardResourcesForWrite(tx, actor, input.ProjectID, input.ConversationID); err != nil {
return err
}
stored, found, err := s.documentDeliveryForWrite(tx, actor, input.OperationID)
if err != nil {
return err
}
if found {
if !sameDocumentDeliveryInput(stored.DocumentDeliveryRecord, input) {
return ErrDocumentDeliveryConflict
}
record = stored.DocumentDeliveryRecord
return nil
}
now := time.Now().UTC()
_, err = tx.Exec(s.q(`INSERT INTO document_render_deliveries(operation_id,owner,project_id,conversation_id,source_run,request_sha256,state,frozen_at,renderer_identity,mode,document_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`),
input.OperationID, actor, input.ProjectID, input.ConversationID, input.SourceRun, input.RequestSHA256,
DocumentDeliveryFrozen, now.Format(time.RFC3339Nano), input.RendererIdentity, input.Mode, input.DocumentID,
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err == nil {
record = DocumentDeliveryRecord{OperationID: input.OperationID, Owner: actor, ProjectID: input.ProjectID,
ConversationID: input.ConversationID, SourceRun: input.SourceRun, RequestSHA256: input.RequestSHA256,
State: DocumentDeliveryFrozen, FrozenAt: now, RendererIdentity: input.RendererIdentity, Mode: input.Mode, DocumentID: input.DocumentID}
}
return err
})
if err != nil {
return record, err
}
return s.GetDocumentDelivery(actor, input.OperationID)
}
func (s *Store) PersistDocumentDeliveryMarkdownAccountChecked(actor, operationID string, artifact DocumentDeliveryArtifactInput, canonicalHash string, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
if artifact.Role != DocumentArtifactMarkdown || canonicalHash == "" {
return DocumentDeliveryRecord{}, errors.New("document delivery Markdown role and canonical hash are required")
}
if err := validateDeliveryArtifactInput(artifact); err != nil {
return DocumentDeliveryRecord{}, err
}
current, err := s.GetDocumentDelivery(actor, operationID)
if err != nil {
return DocumentDeliveryRecord{}, err
}
if current.MarkdownArtifactID != "" {
return current, nil
}
artifactID := stableDeliveryArtifactID(actor, operationID, artifact.Role)
ref, err := s.blobs.Put(newID("blob"), artifact.Content)
if err != nil {
return current, err
}
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultAfterMarkdownBlob); fault != nil {
_ = s.blobs.Delete(ref)
return current, fault
}
used := false
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID)
if err != nil || !found {
if !found && err == nil {
err = ErrNotFound
}
return err
}
if stored.MarkdownArtifactID != "" {
return nil
}
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultBeforeMarkdownCommit); fault != nil {
return fault
}
now := time.Now().UTC()
if err := insertDeliveryArtifactTx(s, tx, actor, stored.ProjectID, stored.SourceRun, artifactID, ref, artifact, now); err != nil {
return err
}
_, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,canonical_hash=?,markdown_artifact_id=?,markdown_sha256=?,error_text='',updated_at=? WHERE owner=? AND operation_id=?`),
DocumentDeliverySourceReady, canonicalHash, artifactID, artifact.SHA256, now.Format(time.RFC3339Nano), actor, operationID)
if err == nil {
used = true
}
return err
})
if !used {
_ = s.blobs.Delete(ref)
}
record, readErr := s.GetDocumentDelivery(actor, operationID)
if err != nil {
if used && readErr == nil {
return record, ErrDocumentDeliveryOutcomeUnknown
}
return current, err
}
return record, readErr
}
func (s *Store) CommitDocumentDeliveryDerivedAccountChecked(actor, operationID, deliveryKey, profileHash, reviewHash string, artifacts []DocumentDeliveryArtifactInput, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
if deliveryKey == "" || profileHash == "" || reviewHash == "" {
return DocumentDeliveryRecord{}, errors.New("document delivery key, profile hash, and review hash are required")
}
ordered, err := validateDerivedArtifacts(artifacts)
if err != nil {
return DocumentDeliveryRecord{}, err
}
current, err := s.GetDocumentDelivery(actor, operationID)
if err != nil {
return DocumentDeliveryRecord{}, err
}
if current.State == DocumentDeliveryComplete {
if current.DeliveryKey != deliveryKey || current.ProfileHash != profileHash || current.ReviewHash != reviewHash {
return current, ErrDocumentDeliveryConflict
}
return current, nil
}
if current.MarkdownArtifactID == "" {
return current, errors.New("document delivery has no authoritative Markdown")
}
type stagedArtifact struct {
input DocumentDeliveryArtifactInput
id, ref string
}
staged := make([]stagedArtifact, 0, len(ordered))
cleanup := func() {
for _, item := range staged {
_ = s.blobs.Delete(item.ref)
}
}
for _, input := range ordered {
ref, err := s.blobs.Put(newID("blob"), input.Content)
if err != nil {
cleanup()
return current, err
}
staged = append(staged, stagedArtifact{input: input, id: stableDeliveryArtifactID(actor, operationID, input.Role), ref: ref})
stage := map[DocumentDeliveryArtifactRole]string{DocumentArtifactDOCX: DocumentDeliveryFaultAfterDOCXBlob, DocumentArtifactManifest: DocumentDeliveryFaultAfterManifestBlob, DocumentArtifactArchive: DocumentDeliveryFaultAfterArchiveBlob}[input.Role]
if fault := s.takeDocumentDeliveryFault(stage); fault != nil {
cleanup()
return current, fault
}
}
used := false
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID)
if err != nil || !found {
if !found && err == nil {
err = ErrNotFound
}
return err
}
if stored.State == DocumentDeliveryComplete {
if stored.DeliveryKey != deliveryKey || stored.ProfileHash != profileHash || stored.ReviewHash != reviewHash {
return ErrDocumentDeliveryConflict
}
return nil
}
if stored.MarkdownArtifactID == "" {
return errors.New("document delivery has no authoritative Markdown")
}
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultBeforeDerivedCommit); fault != nil {
return fault
}
now := time.Now().UTC()
for _, item := range staged {
if err := insertDeliveryArtifactTx(s, tx, actor, stored.ProjectID, stored.SourceRun, item.id, item.ref, item.input, now); err != nil {
return err
}
}
_, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,profile_hash=?,review_hash=?,delivery_key=?,docx_artifact_id=?,docx_sha256=?,manifest_artifact_id=?,manifest_sha256=?,archive_artifact_id=?,archive_sha256=?,error_text='',updated_at=? WHERE owner=? AND operation_id=?`),
DocumentDeliveryComplete, profileHash, reviewHash, deliveryKey,
staged[0].id, staged[0].input.SHA256, staged[1].id, staged[1].input.SHA256, staged[2].id, staged[2].input.SHA256,
now.Format(time.RFC3339Nano), actor, operationID)
if err == nil {
used = true
}
return err
})
if !used {
cleanup()
}
record, readErr := s.GetDocumentDelivery(actor, operationID)
if err != nil {
if used && readErr == nil {
return record, ErrDocumentDeliveryOutcomeUnknown
}
return current, err
}
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultAfterDerivedCommit); fault != nil {
return record, fmt.Errorf("%w: %v", ErrDocumentDeliveryOutcomeUnknown, fault)
}
return record, readErr
}
func (s *Store) MarkDocumentDeliveryFailedAccountChecked(actor, operationID, message string, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID)
if err != nil || !found {
if !found && err == nil {
return ErrNotFound
}
return err
}
if stored.State == DocumentDeliveryComplete {
return nil
}
_, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,error_text=?,updated_at=? WHERE owner=? AND operation_id=?`),
DocumentDeliveryFailed, message, nowISO(), actor, operationID)
return err
})
record, readErr := s.GetDocumentDelivery(actor, operationID)
if err != nil {
return record, err
}
return record, readErr
}
func (s *Store) GetDocumentDelivery(owner, operationID string) (DocumentDeliveryRecord, error) {
stored, err := scanDocumentDelivery(s.db.QueryRow(s.q(`SELECT `+documentDeliveryColumns+` FROM document_render_deliveries WHERE owner=? AND operation_id=?`), owner, operationID).Scan)
if errors.Is(err, sql.ErrNoRows) {
return DocumentDeliveryRecord{}, ErrNotFound
}
if err != nil {
return DocumentDeliveryRecord{}, err
}
return s.hydrateDocumentDelivery(stored)
}
func (s *Store) DocumentDeliveryMarkdown(owner, operationID string) ([]byte, error) {
record, err := s.GetDocumentDelivery(owner, operationID)
if err != nil {
return nil, err
}
if record.MarkdownArtifactID == "" {
return nil, ErrNotFound
}
return s.ArtifactContent(owner, record.MarkdownArtifactID)
}
func (s *Store) SetDocumentDeliveryFault(stage string, err error) {
s.documentDeliveryMu.Lock()
s.documentDeliveryFaultStage, s.documentDeliveryFault = stage, err
s.documentDeliveryMu.Unlock()
}
func (s *Store) takeDocumentDeliveryFault(stage string) error {
if stage == "" {
return nil
}
s.documentDeliveryMu.Lock()
defer s.documentDeliveryMu.Unlock()
if s.documentDeliveryFaultStage != stage {
return nil
}
err := s.documentDeliveryFault
s.documentDeliveryFaultStage, s.documentDeliveryFault = "", nil
return err
}
func (s *Store) documentDeliveryForWrite(tx *sql.Tx, owner, operationID string) (storedDocumentDelivery, bool, error) {
query := s.q(`SELECT ` + documentDeliveryColumns + ` FROM document_render_deliveries WHERE owner=? AND operation_id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
stored, err := scanDocumentDelivery(tx.QueryRow(query, owner, operationID).Scan)
if errors.Is(err, sql.ErrNoRows) {
return storedDocumentDelivery{}, false, nil
}
return stored, err == nil, err
}
func scanDocumentDelivery(scan func(...any) error) (storedDocumentDelivery, error) {
var stored storedDocumentDelivery
var frozen string
err := scan(&stored.OperationID, &stored.Owner, &stored.ProjectID, &stored.ConversationID, &stored.SourceRun,
&stored.RequestSHA256, &stored.State, &frozen, &stored.RendererIdentity, &stored.Mode, &stored.DocumentID,
&stored.CanonicalHash, &stored.ProfileHash, &stored.ReviewHash, &stored.DeliveryKey,
&stored.MarkdownArtifactID, &stored.markdownSHA, &stored.docxID, &stored.docxSHA,
&stored.manifestID, &stored.manifestSHA, &stored.archiveID, &stored.archiveSHA, &stored.Error)
if err == nil {
stored.FrozenAt, err = time.Parse(time.RFC3339Nano, frozen)
}
return stored, err
}
func (s *Store) hydrateDocumentDelivery(stored storedDocumentDelivery) (DocumentDeliveryRecord, error) {
record := stored.DocumentDeliveryRecord
for _, item := range []struct {
role DocumentDeliveryArtifactRole
id, hash string
}{
{DocumentArtifactMarkdown, stored.MarkdownArtifactID, stored.markdownSHA},
{DocumentArtifactDOCX, stored.docxID, stored.docxSHA},
{DocumentArtifactManifest, stored.manifestID, stored.manifestSHA},
{DocumentArtifactArchive, stored.archiveID, stored.archiveSHA},
} {
if item.id == "" {
continue
}
artifact, err := s.GetArtifact(record.Owner, item.id)
if err != nil {
return DocumentDeliveryRecord{}, err
}
record.Artifacts = append(record.Artifacts, DocumentDeliveryArtifact{Role: item.role, Artifact: artifact, SHA256: item.hash})
}
return record, nil
}
func validateDocumentDeliveryBegin(actor string, input DocumentDeliveryBeginInput) error {
if actor == "" || !deliveryOperationPattern.MatchString(input.OperationID) || !validDeliveryHash(input.RequestSHA256) ||
input.ProjectID == "" || input.ConversationID == "" || input.DocumentID == "" || input.RendererIdentity == "" ||
(input.Mode != "working" && input.Mode != "preview") {
return errors.New("invalid document delivery begin input")
}
return nil
}
func sameDocumentDeliveryInput(record DocumentDeliveryRecord, input DocumentDeliveryBeginInput) bool {
return record.ProjectID == input.ProjectID && record.ConversationID == input.ConversationID &&
record.RequestSHA256 == input.RequestSHA256 && record.RendererIdentity == input.RendererIdentity && record.Mode == input.Mode && record.DocumentID == input.DocumentID
}
func validateDeliveryArtifactInput(input DocumentDeliveryArtifactInput) error {
if input.Name == "" || input.Kind == "" || input.MIME == "" || !validDeliveryHash(input.SHA256) {
return errors.New("invalid document delivery artifact")
}
sum := sha256.Sum256(input.Content)
if hex.EncodeToString(sum[:]) != input.SHA256 {
return errors.New("document delivery artifact hash mismatch")
}
return nil
}
func validateDerivedArtifacts(inputs []DocumentDeliveryArtifactInput) ([]DocumentDeliveryArtifactInput, error) {
want := map[DocumentDeliveryArtifactRole]bool{DocumentArtifactDOCX: true, DocumentArtifactManifest: true, DocumentArtifactArchive: true}
byRole := make(map[DocumentDeliveryArtifactRole]DocumentDeliveryArtifactInput, len(inputs))
for _, input := range inputs {
if !want[input.Role] || byRole[input.Role].Role != "" {
return nil, errors.New("derived delivery requires unique DOCX, manifest, and archive artifacts")
}
if err := validateDeliveryArtifactInput(input); err != nil {
return nil, err
}
byRole[input.Role] = input
}
if len(byRole) != len(want) {
return nil, errors.New("derived delivery requires DOCX, manifest, and archive artifacts")
}
return []DocumentDeliveryArtifactInput{byRole[DocumentArtifactDOCX], byRole[DocumentArtifactManifest], byRole[DocumentArtifactArchive]}, nil
}
func insertDeliveryArtifactTx(s *Store, tx *sql.Tx, owner, projectID, sourceRun, id, ref string, input DocumentDeliveryArtifactInput, now time.Time) error {
_, err := tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
id, projectID, owner, input.Name, input.Kind, input.MIME, len(input.Content), sourceRun, ref,
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
}
func stableDeliveryArtifactID(owner, operationID string, role DocumentDeliveryArtifactRole) string {
sum := sha256.Sum256([]byte(owner + "\x00" + operationID + "\x00" + string(role)))
return "art_doc_" + hex.EncodeToString(sum[:12])
}
func validDeliveryHash(value string) bool {
if len(value) != 64 || strings.ToLower(value) != value {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
+229
View File
@@ -0,0 +1,229 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"errors"
"path/filepath"
"reflect"
"testing"
core "agent-system-core"
)
func TestDocumentDeliveryFreezesOnceAndRejectsOperationConflict(t *testing.T) {
st := openTestStore(t)
conversation := seedConversation(t, st)
input := deliveryBeginFixture(conversation, "operation-freeze")
first, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
if err != nil {
t.Fatal(err)
}
second, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
if err != nil {
t.Fatal(err)
}
if first.FrozenAt.IsZero() || !first.FrozenAt.Equal(second.FrozenAt) || first.State != DocumentDeliveryFrozen {
t.Fatalf("freeze records differ: first=%#v second=%#v", first, second)
}
conflict := input
conflict.RequestSHA256 = hashText("different request")
if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, conflict, true); !errors.Is(err, ErrDocumentDeliveryConflict) {
t.Fatalf("conflicting operation error = %v, want ErrDocumentDeliveryConflict", err)
}
}
func TestDocumentDeliveryPersistsMarkdownFirstAndCommitsDerivedSetAtomically(t *testing.T) {
st := openTestStore(t)
conversation := seedConversation(t, st)
input := deliveryBeginFixture(conversation, "operation-complete")
record, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
if err != nil {
t.Fatal(err)
}
markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "report.md", "text/markdown; charset=utf-8", []byte("# report\n"))
record, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, input.OperationID, markdown, hashText("canonical"), true)
if err != nil {
t.Fatal(err)
}
if record.State != DocumentDeliverySourceReady || len(record.Artifacts) != 1 || record.Artifacts[0].Role != DocumentArtifactMarkdown {
t.Fatalf("source receipt = %#v", record)
}
if raw, err := st.ArtifactContent(OwnerDefault, record.Artifacts[0].Artifact.ID); err != nil || string(raw) != "# report\n" {
t.Fatalf("persisted Markdown = %q err=%v", raw, err)
}
derived := []DocumentDeliveryArtifactInput{
deliveryArtifactFixture(DocumentArtifactDOCX, "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")),
deliveryArtifactFixture(DocumentArtifactManifest, "report.manifest.json", "application/json", []byte("manifest")),
deliveryArtifactFixture(DocumentArtifactArchive, "report.zip", "application/zip", []byte("archive")),
}
record, err = st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "delivery-key", "profile-hash", "review-hash", derived, true)
if err != nil {
t.Fatal(err)
}
if record.State != DocumentDeliveryComplete || len(record.Artifacts) != 4 || record.DeliveryKey != "delivery-key" {
t.Fatalf("complete receipt = %#v", record)
}
firstIDs := deliveryArtifactIDs(record.Artifacts)
retry, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "delivery-key", "profile-hash", "review-hash", derived, true)
if err != nil || !reflect.DeepEqual(firstIDs, deliveryArtifactIDs(retry.Artifacts)) {
t.Fatalf("idempotent derived retry = %#v err=%v", retry, err)
}
artifacts, err := st.ListArtifacts(OwnerDefault, string(conversation.ProjectID))
if err != nil || len(artifacts) != 4 {
t.Fatalf("visible artifact set = %d err=%v", len(artifacts), err)
}
}
func TestDocumentDeliveryFailureAfterEveryWriteIsDurableAndRetryable(t *testing.T) {
stages := []string{
DocumentDeliveryFaultAfterMarkdownBlob,
DocumentDeliveryFaultBeforeMarkdownCommit,
DocumentDeliveryFaultAfterDOCXBlob,
DocumentDeliveryFaultAfterManifestBlob,
DocumentDeliveryFaultAfterArchiveBlob,
DocumentDeliveryFaultBeforeDerivedCommit,
DocumentDeliveryFaultAfterDerivedCommit,
}
for _, stage := range stages {
t.Run(stage, func(t *testing.T) {
st := openTestStore(t)
conversation := seedConversation(t, st)
operationID := "fault-" + stage
input := deliveryBeginFixture(conversation, operationID)
if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true); err != nil {
t.Fatal(err)
}
markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "fault.md", "text/markdown", []byte("source"))
derived := []DocumentDeliveryArtifactInput{
deliveryArtifactFixture(DocumentArtifactDOCX, "fault.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")),
deliveryArtifactFixture(DocumentArtifactManifest, "fault.manifest.json", "application/json", []byte("manifest")),
deliveryArtifactFixture(DocumentArtifactArchive, "fault.zip", "application/zip", []byte("archive")),
}
st.SetDocumentDeliveryFault(stage, errors.New("injected "+stage))
var receipt DocumentDeliveryRecord
var err error
if stage == DocumentDeliveryFaultAfterMarkdownBlob || stage == DocumentDeliveryFaultBeforeMarkdownCommit {
receipt, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true)
} else {
if _, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true); err != nil {
t.Fatal(err)
}
receipt, err = st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, operationID, "delivery-key", "profile-hash", "review-hash", derived, true)
}
if err == nil || receipt.OperationID != operationID {
t.Fatalf("fault receipt=%#v err=%v", receipt, err)
}
if stage == DocumentDeliveryFaultAfterDerivedCommit {
if !errors.Is(err, ErrDocumentDeliveryOutcomeUnknown) || receipt.State != DocumentDeliveryComplete {
t.Fatalf("post-commit outcome=%#v err=%v", receipt, err)
}
} else if stage != DocumentDeliveryFaultAfterMarkdownBlob && stage != DocumentDeliveryFaultBeforeMarkdownCommit && receipt.MarkdownArtifactID == "" {
t.Fatalf("derived failure lost authoritative Markdown: %#v", receipt)
}
visible, listErr := st.ListArtifacts(OwnerDefault, string(conversation.ProjectID))
wantVisible := 1
if stage == DocumentDeliveryFaultAfterMarkdownBlob || stage == DocumentDeliveryFaultBeforeMarkdownCommit {
wantVisible = 0
}
if stage == DocumentDeliveryFaultAfterDerivedCommit {
wantVisible = 4
}
if listErr != nil || len(visible) != wantVisible {
t.Fatalf("failure exposed %d artifacts, want %d: %v", len(visible), wantVisible, listErr)
}
st.SetDocumentDeliveryFault("", nil)
if receipt.MarkdownArtifactID == "" {
if _, err := st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true); err != nil {
t.Fatal(err)
}
}
complete, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, operationID, "delivery-key", "profile-hash", "review-hash", derived, true)
if err != nil || complete.State != DocumentDeliveryComplete || len(complete.Artifacts) != 4 {
t.Fatalf("retry did not complete: %#v err=%v", complete, err)
}
})
}
}
func TestDocumentDeliveryRetryAfterRestartReturnsSameIDsAndHashes(t *testing.T) {
directory := t.TempDir()
database := filepath.Join(directory, "delivery.db")
st, err := OpenSQLite(database)
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject(OwnerDefault, "delivery")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation(OwnerDefault, project.ID, "delivery", "")
if err != nil {
t.Fatal(err)
}
input := deliveryBeginFixture(conversation, "restart-operation")
if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true); err != nil {
t.Fatal(err)
}
markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "restart.md", "text/markdown", []byte("source"))
if _, err := st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, input.OperationID, markdown, hashText("canonical"), true); err != nil {
t.Fatal(err)
}
derived := []DocumentDeliveryArtifactInput{
deliveryArtifactFixture(DocumentArtifactDOCX, "restart.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")),
deliveryArtifactFixture(DocumentArtifactManifest, "restart.manifest.json", "application/json", []byte("manifest")),
deliveryArtifactFixture(DocumentArtifactArchive, "restart.zip", "application/zip", []byte("archive")),
}
first, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "restart-key", "profile", "review", derived, true)
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
reopened, err := OpenSQLite(database)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
begin, err := reopened.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
if err != nil {
t.Fatal(err)
}
retry, err := reopened.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "restart-key", "profile", "review", derived, true)
if err != nil || !begin.FrozenAt.Equal(first.FrozenAt) || !reflect.DeepEqual(deliveryArtifactIDs(first.Artifacts), deliveryArtifactIDs(retry.Artifacts)) {
t.Fatalf("restart retry changed identity: first=%#v begin=%#v retry=%#v err=%v", first, begin, retry, err)
}
}
func deliveryBeginFixture(conversation core.Conversation, operationID string) DocumentDeliveryBeginInput {
return DocumentDeliveryBeginInput{
OperationID: operationID, RequestSHA256: hashText("request-" + operationID),
ProjectID: string(conversation.ProjectID), ConversationID: string(conversation.ID),
SourceRun: "run-1", DocumentID: "document-1", Mode: "preview", RendererIdentity: "renderer@revision",
}
}
func deliveryArtifactFixture(role DocumentDeliveryArtifactRole, name, mime string, content []byte) DocumentDeliveryArtifactInput {
return DocumentDeliveryArtifactInput{Role: role, Name: name, Kind: "document", MIME: mime, SHA256: hashBytesForTest(content), Content: content}
}
func deliveryArtifactIDs(artifacts []DocumentDeliveryArtifact) map[DocumentDeliveryArtifactRole]string {
ids := make(map[DocumentDeliveryArtifactRole]string, len(artifacts))
for _, artifact := range artifacts {
ids[artifact.Role] = string(artifact.Artifact.ID) + ":" + artifact.SHA256
}
return ids
}
func hashText(value string) string { return hashBytesForTest([]byte(value)) }
func hashBytesForTest(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
+23
View File
@@ -1145,6 +1145,29 @@ func init() {
"sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
"postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
}},
migration{version: 47, name: "document_render_deliveries",
up: map[string][]string{
"sqlite": {`CREATE TABLE document_render_deliveries (
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL,
canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '',
markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '',
manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '',
error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`,
`CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`},
"postgres": {`CREATE TABLE document_render_deliveries (
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL,
canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '',
markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '',
manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '',
error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`,
`CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
"postgres": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
}},
)
}
+1
View File
@@ -53,6 +53,7 @@ var migrationBreaksOldReaders = map[int]bool{
44: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容
45: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
46: false, // human_decision_proofs:独立真人确认表,旧读者不读取
47: false, // document_render_deliveries:新增普通工作区文档交付回执表
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+1
View File
@@ -39,6 +39,7 @@ func TestMigrationTailKeepsAdoptedExecEnvBeforeFeatureHistory(t *testing.T) {
{44, "project_audit_all_writers_guard"},
{45, "project_work_observation_outbox"},
{46, "human_decision_proofs"},
{47, "document_render_deliveries"},
}
for _, item := range want {
if got, ok := MigrationName(item.version); !ok || got != item.name {
+4
View File
@@ -79,6 +79,10 @@ type Store struct {
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
accountFault error // 非空时账户事务在提交前失败(半态测试)
documentDeliveryMu sync.Mutex
documentDeliveryFaultStage string
documentDeliveryFault error
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
vecPG bool
}
+27 -43
View File
@@ -2,12 +2,12 @@ package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"agent-document-renderer/render"
"agent-system-backend/internal/docgen"
@@ -18,27 +18,31 @@ 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"},` +
Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{` +
`"operationId":{"type":"string"},"name":{"type":"string"},"documentId":{"type":"string"},"documentType":{"type":"string"},` +
`"language":{"type":"string"},"markdown":{"type":"string"},` +
`"mode":{"type":"string","enum":["working","preview"]},"frozenAt":{"type":"string"},` +
`"mode":{"type":"string","enum":["working","preview"]},` +
`"controlled":{"type":"boolean"},"projectDocument":{"type":"boolean"}` +
`},"required":["name","documentId","markdown","frozenAt","controlled","projectDocument"]}`),
`},"required":["operationId","name","documentId","markdown","controlled","projectDocument"]}`),
}, executeDocumentRenderer)
}
type documentRendererArgs struct {
OperationID string `json:"operationId"`
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"`
}
type documentDeliveryHost interface {
ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error)
}
func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error) {
var args documentRendererArgs
decoder := json.NewDecoder(bytes.NewReader(raw))
@@ -50,19 +54,18 @@ func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error
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 strings.TrimSpace(args.OperationID) == "" || strings.TrimSpace(args.Name) == "" || strings.TrimSpace(args.DocumentID) == "" || strings.TrimSpace(args.Markdown) == "" {
return "", errors.New("render_document: operationId, 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 *args.Controlled || *args.ProjectDocument {
return "", docgen.ErrRDMRequired
}
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
@@ -78,40 +81,21 @@ func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error
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(),
host, ok := rc.(documentDeliveryHost)
if !ok {
return "", errors.New("render_document: trusted delivery host is unavailable")
}
receipt, err := host.ExecuteDocumentDelivery(execParentCtx(rc), docgen.OperationRequest{
OperationID: args.OperationID, DocumentID: args.DocumentID, Title: args.Name,
DocumentType: documentType, Language: language, Markdown: []byte(args.Markdown), Mode: mode,
Controlled: *args.Controlled, ProjectDocument: *args.ProjectDocument,
})
encoded, marshalErr := json.Marshal(receipt)
if marshalErr != nil {
return "", marshalErr
}
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 "", fmt.Errorf("render_document: %s", encoded)
}
return string(encoded), nil
}
+87 -26
View File
@@ -2,11 +2,10 @@ package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"time"
"agent-system-backend/internal/docgen"
core "agent-system-core"
@@ -20,6 +19,8 @@ type capturedArtifact struct {
type documentRendererCtx struct {
ctx context.Context
artifacts []capturedArtifact
receipt docgen.OperationReceipt
err error
}
func (c *documentRendererCtx) ProjectID() string { return "conversation-project" }
@@ -47,6 +48,9 @@ func (c *documentRendererCtx) WriteArtifact(name, kind, mime string, content []b
c.artifacts = append(c.artifacts, capturedArtifact{metadata: artifact, content: append([]byte(nil), content...)})
return artifact, nil
}
func (c *documentRendererCtx) ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error) {
return c.receipt, c.err
}
func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing.T) {
registry := NewRegistry()
@@ -54,34 +58,91 @@ func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing
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",
"operationId": "operation-1", "name": "研发纪要", "documentId": "workspace-minutes", "documentType": "general", "language": "zh-CN",
"markdown": "# 研发纪要\n\n正文。\n", "mode": "preview",
"controlled": false, "projectDocument": false,
})
ctx := &documentRendererCtx{}
ctx := &documentRendererCtx{receipt: docgen.OperationReceipt{
OperationID: "operation-1", Outcome: "complete", State: "complete", Renderer: docgen.RendererIdentity,
FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC),
Artifacts: []docgen.ReceiptArtifact{
{Role: "markdown", ID: "md", Name: "研发纪要.md", SHA256: strings.Repeat("a", 64)},
{Role: "docx", ID: "docx", Name: "研发纪要.docx", SHA256: strings.Repeat("b", 64)},
{Role: "manifest", ID: "manifest", Name: "研发纪要.manifest.json", SHA256: strings.Repeat("c", 64)},
{Role: "archive", ID: "archive", Name: "研发纪要.zip", SHA256: strings.Repeat("d", 64)},
},
}}
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 {
var receipt docgen.OperationReceipt
if err := json.Unmarshal([]byte(result.Output), &receipt); 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)
if receipt.OperationID != "operation-1" || receipt.Outcome != "complete" || len(receipt.Artifacts) != 4 || receipt.FrozenAt.IsZero() {
t.Fatalf("durable receipt = %#v", receipt)
}
for _, artifact := range receipt.Artifacts {
if !strings.Contains(result.Output, artifact.SHA256) {
t.Errorf("tool output omitted %s hash %s", artifact.Role, artifact.SHA256)
}
}
}
func TestRenderDocumentToolSchemaIsClosedAndUsesOnlyOperationIdentity(t *testing.T) {
registry := NewRegistry()
if err := registry.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
description, _, found := registry.Get("render_document")
if !found {
t.Fatal("render_document is not registered")
}
var schema struct {
AdditionalProperties *bool `json:"additionalProperties"`
Properties map[string]json.RawMessage `json:"properties"`
Required []string `json:"required"`
}
if err := json.Unmarshal(description.Parameters, &schema); err != nil {
t.Fatal(err)
}
if schema.AdditionalProperties == nil || *schema.AdditionalProperties {
t.Fatal("render_document schema is not closed")
}
if _, found := schema.Properties["operationId"]; !found {
t.Fatal("render_document schema omitted operationId")
}
if _, found := schema.Properties["frozenAt"]; found {
t.Fatal("render_document still accepts model-supplied frozenAt")
}
wantRequired := map[string]bool{"operationId": true, "name": true, "documentId": true, "markdown": true, "controlled": true, "projectDocument": true}
for _, name := range schema.Required {
delete(wantRequired, name)
}
if len(wantRequired) != 0 {
t.Fatalf("render_document required fields missing: %#v", wantRequired)
}
arguments := json.RawMessage(`{"operationId":"closed-schema","name":"Report","documentId":"report","markdown":"# Report\n","controlled":false,"projectDocument":false,"frozenAt":"2000-01-01T00:00:00Z"}`)
result := registry.Execute(&documentRendererCtx{}, core.ToolCallRequest{CallID: "closed-schema", Name: "render_document", Arguments: arguments})
if result.Status != "error" || !strings.Contains(result.Output, `unknown field "frozenAt"`) {
t.Fatalf("model-supplied frozenAt was accepted: %#v", result)
}
}
func TestPythonToolDescriptionDoesNotAdvertiseWordBypass(t *testing.T) {
registry := NewRegistry()
if err := RegisterInterpreterTools(registry); err != nil {
t.Fatal(err)
}
description, _, found := registry.Get("run_python")
if !found {
t.Fatal("run_python is not registered")
}
for _, forbidden := range []string{"python-docx", "生成文档", "docx_kit"} {
if strings.Contains(description.Description, forbidden) {
t.Fatalf("run_python description advertises Word bypass %q: %s", forbidden, description.Description)
}
}
}
@@ -91,8 +152,8 @@ func TestRenderDocumentToolRequiresExplicitRouteDeclaration(t *testing.T) {
t.Fatal(err)
}
arguments, _ := json.Marshal(map[string]any{
"name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n",
"mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
"operationId": "operation-ambiguous", "name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n",
"mode": "preview",
})
ctx := &documentRendererCtx{}
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-3", Name: "render_document", Arguments: arguments})
@@ -110,8 +171,8 @@ func TestRenderDocumentToolRejectsControlledRouteWithoutArtifacts(t *testing.T)
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",
"operationId": "operation-controlled", "name": "受控文档", "documentId": "controlled", "documentType": "dhf", "language": "zh-CN",
"markdown": "# 受控文档\n", "mode": "preview",
"controlled": true, "projectDocument": false,
})
ctx := &documentRendererCtx{}
+6 -13
View File
@@ -4,10 +4,11 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestLegacyComparisonHousestyleBuildPassesOOXMLGate(t *testing.T) {
func TestLegacyHousestyleBuildCannotBeEnabledByEnvironment(t *testing.T) {
py, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 not available")
@@ -20,10 +21,6 @@ func TestLegacyComparisonHousestyleBuildPassesOOXMLGate(t *testing.T) {
if _, err := os.Stat(filepath.Join(skill, "housestyle", "build.py")); err != nil {
t.Skip("house-style skill is not in this checkout")
}
probe := exec.Command(py, "-c", "import docx")
if out, err := probe.CombinedOutput(); err != nil {
t.Skipf("python-docx is not installed: %s", out)
}
dir := t.TempDir()
src := filepath.Join(dir, "summary.md")
body := "# 摘要\n\n## 结论\n\n- 沙箱里可以直接生成 docx\n"
@@ -33,15 +30,11 @@ func TestLegacyComparisonHousestyleBuildPassesOOXMLGate(t *testing.T) {
cmd := exec.Command(py, "-m", "housestyle.build", src)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PYTHONPATH="+skill, "AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("housestyle.build: %v\n%s", err, out)
if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "disabled") {
t.Fatalf("housestyle.build environment bypass: %v\n%s", err, out)
}
docx := filepath.Join(dir, "summary.docx")
content, err := os.ReadFile(docx)
if err != nil {
t.Fatal(err)
}
if err := ooxmlStructuralCheck("summary.docx", content); err != nil {
t.Fatal(err)
if _, err := os.Stat(docx); !os.IsNotExist(err) {
t.Fatalf("disabled builder wrote %s: %v", docx, err)
}
}
+4
View File
@@ -10,6 +10,7 @@ package tools
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -120,6 +121,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
if err != nil {
return "", fmt.Errorf("register_artifact: %w", err)
}
if isWordBinaryName(abs) {
return "", errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
}
st, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("register_artifact: %s: %v", a.Path, err)
+1 -1
View File
@@ -44,7 +44,7 @@ func RegisterInterpreterTools(r *Registry) error {
}
return r.Register(core.ToolDesc{
Name: "run_python", Source: core.SourceBuiltin, Risk: "medium",
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层已提供 python-docx、openpyxl、python-pptx 等,可直接 import。缺的包用 python_env 申请,不要 pip install。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
Description: "执行 Python 脚本(python3,工作目录为会话工作区):用于数据处理以及 Excel、PowerPoint、图片生成。普通 Word 必须使用 render_document;register_artifact 会拒绝 Python 或其他任意来源生成的 DOC/DOCX。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层提供 openpyxl、python-pptx 等;缺的包用 python_env 申请,不要 pip install。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
+10 -1
View File
@@ -270,7 +270,7 @@ func mimeOfExt(name string) string {
// 普通 Word 由 render_document 生成;其他带排版二进制由受控生成器产出后登记。
func rejectBinaryOfficeWrite(name string) error {
switch strings.ToLower(filepath.Ext(name)) {
case ".doc", ".docx":
case ".doc", ".docx", ".docm", ".dotx", ".dotm":
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))
@@ -278,3 +278,12 @@ func rejectBinaryOfficeWrite(name string) error {
return nil
}
}
func isWordBinaryName(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".doc", ".docx", ".docm", ".dotx", ".dotm":
return true
default:
return false
}
}