Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter. Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally. Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only. Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
118 lines
4.7 KiB
Go
118 lines
4.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"agent-document-renderer/render"
|
|
"agent-system-backend/internal/docgen"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func registerDocumentRenderer(r *Registry) error {
|
|
return r.Register(core.ToolDesc{
|
|
Name: "render_document", Source: core.SourceBuiltin, Edits: true, Risk: "medium",
|
|
Description: "把普通工作区 Markdown 通过统一文档渲染器生成 Markdown、DOCX、manifest 和可下载归档。只允许非受控工作区交付;项目受控文档必须交给 RDM。公司默认规则仍是待视觉评审候选,状态会写入 manifest。",
|
|
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
|
`"name":{"type":"string"},"documentId":{"type":"string"},"documentType":{"type":"string"},` +
|
|
`"language":{"type":"string"},"markdown":{"type":"string"},` +
|
|
`"mode":{"type":"string","enum":["working","preview"]},"frozenAt":{"type":"string"},` +
|
|
`"controlled":{"type":"boolean"},"projectDocument":{"type":"boolean"}` +
|
|
`},"required":["name","documentId","markdown","frozenAt","controlled","projectDocument"]}`),
|
|
}, executeDocumentRenderer)
|
|
}
|
|
|
|
type documentRendererArgs struct {
|
|
Name string `json:"name"`
|
|
DocumentID string `json:"documentId"`
|
|
DocumentType string `json:"documentType"`
|
|
Language string `json:"language"`
|
|
Markdown string `json:"markdown"`
|
|
Mode string `json:"mode"`
|
|
FrozenAt string `json:"frozenAt"`
|
|
Controlled *bool `json:"controlled"`
|
|
ProjectDocument *bool `json:"projectDocument"`
|
|
}
|
|
|
|
func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error) {
|
|
var args documentRendererArgs
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&args); err != nil {
|
|
return "", fmt.Errorf("render_document: bad args: %w", err)
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
|
return "", errors.New("render_document: arguments must contain one JSON object")
|
|
}
|
|
if strings.TrimSpace(args.Name) == "" || strings.TrimSpace(args.DocumentID) == "" || strings.TrimSpace(args.Markdown) == "" {
|
|
return "", errors.New("render_document: name, documentId, and markdown are required")
|
|
}
|
|
if args.Controlled == nil || args.ProjectDocument == nil {
|
|
return "", errors.New("render_document: controlled and projectDocument must be explicit")
|
|
}
|
|
if strings.TrimSpace(rc.OwnerID()) == "" {
|
|
return "", errors.New("render_document: trusted requester identity is required")
|
|
}
|
|
frozenAt, err := time.Parse(time.RFC3339Nano, args.FrozenAt)
|
|
if err != nil {
|
|
return "", fmt.Errorf("render_document: frozenAt must be RFC3339: %w", err)
|
|
}
|
|
mode := render.Mode(args.Mode)
|
|
if mode == "" {
|
|
mode = render.ModePreview
|
|
}
|
|
if mode != render.ModeWorking && mode != render.ModePreview {
|
|
return "", fmt.Errorf("render_document: mode %q is unavailable for an ordinary workspace document", mode)
|
|
}
|
|
documentType := strings.TrimSpace(args.DocumentType)
|
|
if documentType == "" {
|
|
documentType = "general"
|
|
}
|
|
language := strings.TrimSpace(args.Language)
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
delivery, err := docgen.Generate(execParentCtx(rc), docgen.Request{
|
|
DocumentID: args.DocumentID, Title: args.Name, DocumentType: documentType, Language: language,
|
|
Markdown: []byte(args.Markdown), Mode: mode, FrozenAt: frozenAt.UTC(), RequestedBy: rc.OwnerID(),
|
|
Controlled: *args.Controlled, ProjectDocument: *args.ProjectDocument,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("render_document: %w", err)
|
|
}
|
|
|
|
artifacts := []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive}
|
|
for _, item := range artifacts {
|
|
if err := checkToolExternalEffect(rc); err != nil {
|
|
return "", fmt.Errorf("render_document: permission recheck before %s: %w", item.Name, err)
|
|
}
|
|
if _, err := rc.WriteArtifact(item.Name, artifactKindOf(item.Name, item.MIME), item.MIME, item.Bytes); err != nil {
|
|
return "", fmt.Errorf("render_document: persist %s: %w", item.Name, err)
|
|
}
|
|
}
|
|
type outputArtifact struct {
|
|
Name string `json:"name"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int `json:"size"`
|
|
}
|
|
output := struct {
|
|
ProfileStatus string `json:"profileStatus"`
|
|
PublicationAllowed bool `json:"publicationAllowed"`
|
|
Artifacts []outputArtifact `json:"artifacts"`
|
|
}{ProfileStatus: delivery.Profile.ReviewStatus, PublicationAllowed: delivery.Profile.PublicationAllowed}
|
|
for _, item := range artifacts {
|
|
output.Artifacts = append(output.Artifacts, outputArtifact{Name: item.Name, SHA256: item.SHA256, Size: len(item.Bytes)})
|
|
}
|
|
encoded, err := json.Marshal(output)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(encoded), nil
|
|
}
|