diff --git a/cmd/document-revision-backfill/main.go b/cmd/document-revision-backfill/main.go new file mode 100644 index 00000000..3698deb3 --- /dev/null +++ b/cmd/document-revision-backfill/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "chat-agent-system/internal/docrevision" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// document-revision-backfill dry-runs or applies controlled document output.md into the ledger. +func main() { + dsn := flag.String("dsn", os.Getenv("TEST_DATABASE_URL"), "postgres DSN") + repoRoot := flag.String("repo-root", ".", "repository root for relative work_dir") + apply := flag.Bool("apply", false, "apply changes (default dry-run)") + outPath := flag.String("report", "", "write JSON report path") + flag.Parse() + if *dsn == "" { + fmt.Fprintln(os.Stderr, "dsn or TEST_DATABASE_URL required") + os.Exit(2) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + pool, err := pgxpool.New(ctx, *dsn) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer pool.Close() + + rows, err := pool.Query(ctx, ` + SELECT doc_id, COALESCE(work_dir,''), COALESCE(content_sha256,''), COALESCE(current_state,'drafting'), project_id + FROM document_records + ORDER BY doc_id`) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer rows.Close() + + type item struct { + DocID string `json:"doc_id"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + SHA string `json:"sha,omitempty"` + FileSHA string `json:"file_sha,omitempty"` + } + report := struct { + DryRun bool `json:"dry_run"` + Items []item `json:"items"` + Success int `json:"success"` + Missing int `json:"missing"` + Mismatch int `json:"hash_mismatch"` + }{DryRun: !*apply} + + svc := docrevision.NewService(nil) + for rows.Next() { + var docID, workDir, dbSHA, state string + var projectID int64 + if err := rows.Scan(&docID, &workDir, &dbSHA, &state, &projectID); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + it := item{DocID: docID, SHA: dbSHA} + if workDir == "" { + it.Status = "missing" + it.Detail = "empty work_dir" + report.Missing++ + report.Items = append(report.Items, it) + continue + } + path := workDir + if !filepath.IsAbs(path) { + path = filepath.Join(*repoRoot, workDir) + } + body, err := os.ReadFile(filepath.Join(path, "output.md")) + if err != nil { + it.Status = "missing" + it.Detail = err.Error() + report.Missing++ + report.Items = append(report.Items, it) + continue + } + canon := docrevision.CanonicalizeMarkdown(string(body)) + fileSHA := docrevision.SHA256Hex(canon) + it.FileSHA = fileSHA + if dbSHA != "" && !strings.EqualFold(dbSHA, fileSHA) { + it.Status = "hash_mismatch" + it.Detail = "document_records.content_sha256 != file" + report.Mismatch++ + report.Items = append(report.Items, it) + continue + } + if *apply { + if _, err := svc.OpenTemplateVersion(ctx, docID, canon, docrevision.ActorSnapshot{DisplayName: "backfill"}, projectID, workDir); err != nil { + it.Status = "error" + it.Detail = err.Error() + report.Items = append(report.Items, it) + continue + } + } + it.Status = "success" + report.Success++ + report.Items = append(report.Items, it) + } + enc, _ := json.MarshalIndent(report, "", " ") + if *outPath != "" { + _ = os.WriteFile(*outPath, enc, 0o644) + } + fmt.Println(string(enc)) + if report.Mismatch > 0 { + os.Exit(3) + } +} diff --git a/configs/schemas/change-report-v1.json b/configs/schemas/change-report-v1.json new file mode 100644 index 00000000..74b4165c --- /dev/null +++ b/configs/schemas/change-report-v1.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-system.local/schemas/change-report-v1.json", + "title": "change-report-v1", + "type": "object", + "required": ["schema_version", "summary", "risk"], + "additionalProperties": true, + "properties": { + "schema_version": { "const": 1 }, + "summary": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "items": { "type": "string", "minLength": 1 } + }, + "changes": { + "type": "array", + "items": { + "type": "object", + "required": ["section_locator", "change_type", "rationale"], + "properties": { + "section_locator": { "type": "string" }, + "change_type": { "type": "string" }, + "rationale": { "type": "string" }, + "evidence_refs": { "type": "array", "items": { "type": "string" } } + } + } + }, + "risk": { + "type": "object", + "required": ["level"], + "properties": { + "level": { "enum": ["low", "medium", "high", "critical"] }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["category", "description"], + "properties": { + "category": { "type": "string" }, + "description": { "type": "string" }, + "mitigation": { "type": "string" } + } + } + } + } + }, + "project_impact": { "type": "object" }, + "document_impacts": { + "type": "array", + "items": { + "type": "object", + "required": ["doc_id", "impact_type", "reason"], + "properties": { + "doc_id": { "type": "string" }, + "impact_type": { "type": "string" }, + "reason": { "type": "string" }, + "recommended_action": { "type": "string" } + } + } + }, + "open_questions": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/go.mod b/go.mod index 11624df4..e64429ef 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7c45d2d1..f06b3aee 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,12 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/docrevision/blockmap.go b/internal/docrevision/blockmap.go new file mode 100644 index 00000000..f18bccd5 --- /dev/null +++ b/internal/docrevision/blockmap.go @@ -0,0 +1,185 @@ +package docrevision + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" +) + +var ( + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) + reqIDRe = regexp.MustCompile(`\b(REQ-[A-Z0-9-]+)\b`) + listItemRe = regexp.MustCompile(`^(\s*[-*+]|\s*\d+\.)\s+`) + tableRowRe = regexp.MustCompile(`^\|`) + fenceRe = regexp.MustCompile("^```") + mermaidRe = regexp.MustCompile(`(?i)^mermaid\b`) +) + +// BuildBlockMap parses markdown into stable block nodes. +// When previous is provided, nodes are aligned by business key → heading path → fingerprint → order. +func BuildBlockMap(markdown string, previous []BlockNode) []BlockNode { + lines := strings.Split(strings.TrimSuffix(markdown, "\n"), "\n") + var nodes []BlockNode + offset := 0 + headingPath := []string{} + inFence := false + fenceLang := "" + var fenceBuf []string + fenceStart := 0 + order := 0 + + flushFence := func() { + if !inFence { + return + } + body := strings.Join(fenceBuf, "\n") + typ := "code" + if mermaidRe.MatchString(fenceLang) { + typ = "mermaid" + } + nodes = append(nodes, makeNode(typ, fenceStart, offset, strings.Join(headingPath, " / "), "", body, order, previous)) + order++ + inFence = false + fenceBuf = nil + fenceLang = "" + } + + for i, line := range lines { + lineLen := len(line) + if i < len(lines)-1 || strings.HasSuffix(markdown, "\n") { + lineLen++ // account for newline except possibly last without NL; we trimSuffix above so always +1 except last + } + // recompute offset carefully + _ = lineLen + } + + offset = 0 + for i, line := range lines { + start := offset + end := start + len(line) + if i < len(lines)-1 { + end++ // newline + } else if strings.HasSuffix(markdown, "\n") { + end++ + } + + if fenceRe.MatchString(line) { + if !inFence { + inFence = true + fenceLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) + fenceStart = start + fenceBuf = nil + } else { + flushFence() + } + offset = end + continue + } + if inFence { + fenceBuf = append(fenceBuf, line) + offset = end + continue + } + + if m := headingRe.FindStringSubmatch(line); m != nil { + level := len(m[1]) + title := strings.TrimSpace(m[2]) + if level <= len(headingPath) { + headingPath = headingPath[:level-1] + } + headingPath = append(headingPath, title) + path := strings.Join(headingPath, " / ") + bk := "" + if id := reqIDRe.FindString(title); id != "" { + bk = id + } + nodes = append(nodes, makeNode("heading", start, end, path, bk, line, order, previous)) + order++ + offset = end + continue + } + + path := strings.Join(headingPath, " / ") + switch { + case tableRowRe.MatchString(line): + bk := "" + if id := reqIDRe.FindString(line); id != "" { + bk = id + } + nodes = append(nodes, makeNode("table_row", start, end, path, bk, line, order, previous)) + case listItemRe.MatchString(line): + bk := "" + if id := reqIDRe.FindString(line); id != "" { + bk = id + } + nodes = append(nodes, makeNode("list_item", start, end, path, bk, line, order, previous)) + case strings.TrimSpace(line) == "": + // skip empty + default: + bk := "" + if id := reqIDRe.FindString(line); id != "" { + bk = id + } + nodes = append(nodes, makeNode("paragraph", start, end, path, bk, line, order, previous)) + } + order++ + offset = end + } + flushFence() + return nodes +} + +func makeNode(typ string, start, end int, path, businessKey, text string, order int, previous []BlockNode) BlockNode { + fp := ContentFingerprint(text) + id := matchPreviousID(typ, path, businessKey, fp, order, previous) + if id == "" { + id = newBlockID() + } + return BlockNode{ + ID: id, + Type: typ, + StartOffset: start, + EndOffset: end, + HeadingPath: path, + BusinessKey: businessKey, + Fingerprint: fp, + Order: order, + } +} + +func matchPreviousID(typ, path, businessKey, fp string, order int, previous []BlockNode) string { + if len(previous) == 0 { + return "" + } + if businessKey != "" { + for _, p := range previous { + if p.BusinessKey == businessKey && p.Type == typ { + return p.ID + } + } + } + for _, p := range previous { + if p.Type == typ && p.HeadingPath == path && p.Fingerprint == fp { + return p.ID + } + } + for _, p := range previous { + if p.Type == typ && p.Fingerprint == fp { + return p.ID + } + } + for _, p := range previous { + if p.Order == order && p.Type == typ { + return p.ID + } + } + return "" +} + +func newBlockID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return fmt.Sprintf("blk_%s", hex.EncodeToString(b[:])) +} diff --git a/internal/docrevision/canonicalize.go b/internal/docrevision/canonicalize.go new file mode 100644 index 00000000..5156002e --- /dev/null +++ b/internal/docrevision/canonicalize.go @@ -0,0 +1,49 @@ +package docrevision + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" +) + +// CanonicalizeMarkdown applies LF, UTF-8 NFC, trailing newline, and trims trailing spaces per line. +func CanonicalizeMarkdown(raw string) string { + s := strings.ReplaceAll(raw, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = norm.NFC.String(s) + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRightFunc(line, func(r rune) bool { + return r == ' ' || r == '\t' + }) + } + s = strings.Join(lines, "\n") + if s != "" && !strings.HasSuffix(s, "\n") { + s += "\n" + } + return s +} + +func SHA256Hex(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) +} + +func ContentFingerprint(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if unicode.IsSpace(r) { + continue + } + b.WriteRune(r) + } + return SHA256Hex(b.String())[:16] +} + +func RuneCount(s string) int { + return utf8.RuneCountInString(s) +} diff --git a/internal/docrevision/concurrency_test.go b/internal/docrevision/concurrency_test.go new file mode 100644 index 00000000..0468351f --- /dev/null +++ b/internal/docrevision/concurrency_test.go @@ -0,0 +1,100 @@ +package docrevision + +import ( + "context" + "fmt" + "sync" + "testing" +) + +func TestConcurrentHumanRevisions(t *testing.T) { + svc := NewService(nil) + ctx := context.Background() + st, err := svc.OpenTemplateVersion(ctx, "doc_conc", "# base\n", ActorSnapshot{}, 1, "") + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + errs := make(chan error, 50) + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + md := fmt.Sprintf("# base\n\nedit %d\n", i) + // Only first expected head will succeed; others conflict — that's OK. + _, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{ + DocID: "doc_conc", Markdown: md, + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: fmt.Sprintf("conc-%d", i), ActorUserID: int64(i + 1), LifecycleState: "drafting", + }) + if err != nil && err != ErrHeadConflict { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + // Sequential chain should produce continuous revision numbers + svc2 := NewService(nil) + st2, _ := svc2.OpenTemplateVersion(ctx, "doc_seq", "# s\n", ActorSnapshot{}, 1, "") + head := st2.HeadSnapshotID + rev := st2.HeadRevisionNo + for i := 0; i < 20; i++ { + r, err := svc2.CommitHumanRevision(ctx, HumanRevisionRequest{ + DocID: "doc_seq", Markdown: fmt.Sprintf("# s\n\n%d\n", i), + ExpectedHeadSnapshot: &head, ExpectedRevisionNo: &rev, + IdempotencyKey: fmt.Sprintf("seq-%d", i), ActorUserID: 1, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r.State.HeadRevisionNo != i+1 { + t.Fatalf("revision_no want %d got %d", i+1, r.State.HeadRevisionNo) + } + head = r.State.HeadSnapshotID + rev = r.State.HeadRevisionNo + } + vers, _ := svc2.ListVersions(ctx, "doc_seq") + open := 0 + for _, v := range vers { + if v.Status == VersionOpen { + open++ + } + } + if open != 1 { + t.Fatalf("want exactly one open version, got %d", open) + } +} + +func TestFreeformLegacyImport(t *testing.T) { + svc := NewService(nil) + ctx := context.Background() + st, err := svc.ImportFreeformLegacy(ctx, "ff1", 9, 1, []struct { + VersionNo int + Markdown string + SHA string + }{ + {VersionNo: 1, Markdown: "# v1\n"}, + {VersionNo: 2, Markdown: "# v2\n"}, + }, "# v3 head\n") + if err != nil { + t.Fatal(err) + } + if st.CurrentVersionNo != 3 { + t.Fatalf("want version 3 open, got %d", st.CurrentVersionNo) + } + r, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{ + DocID: "ff1", Markdown: "# v3 head\n\nedit\n", + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: "ff-e1", ActorUserID: 9, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r.State.CurrentVersionNo != 3 || r.State.HeadRevisionNo != 1 { + t.Fatalf("human save should only bump revision: %+v", r.State) + } +} diff --git a/internal/docrevision/contract_test.go b/internal/docrevision/contract_test.go new file mode 100644 index 00000000..d706af45 --- /dev/null +++ b/internal/docrevision/contract_test.go @@ -0,0 +1,225 @@ +package docrevision_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/docrevision" +) + +func TestCanonicalize_NFC_LF_TrailingNewline(t *testing.T) { + in := "a\r\n b \t\n" + out := docrevision.CanonicalizeMarkdown(in) + if out != "a\n b\n" { + t.Fatalf("got %q", out) + } +} + +func TestGoldenDiffFixtures(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "golden_diff_cases.json")) + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + ID string `json:"id"` + Before string `json:"before"` + After string `json:"after"` + ExpectKinds []string `json:"expect_kinds"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + for _, c := range fixture.Cases { + t.Run(c.ID, func(t *testing.T) { + before := docrevision.CanonicalizeMarkdown(c.Before) + after := docrevision.CanonicalizeMarkdown(c.After) + fb := docrevision.BuildBlockMap(before, nil) + ab := docrevision.BuildBlockMap(after, fb) + diff := docrevision.ComputeDiff(before, after, fb, ab) + if len(c.ExpectKinds) == 0 { + if len(diff.Hunks) != 0 { + t.Fatalf("noop expected empty hunks, got %+v", diff.Hunks) + } + return + } + found := map[string]bool{} + for _, h := range diff.Hunks { + found[h.Kind] = true + } + for _, k := range c.ExpectKinds { + if !found[k] { + t.Fatalf("expected kind %s in %+v", k, diff.Hunks) + } + } + }) + } +} + +func TestContractTimeline_VersionRevisionSemantics(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + actor := docrevision.ActorSnapshot{DisplayName: "张工", Role: "author", Duty: "author"} + + st, err := svc.OpenTemplateVersion(ctx, "doc_demo", "# V1\n\n模板正文\n", actor, 1, "workspaces/demo") + if err != nil { + t.Fatal(err) + } + if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 { + t.Fatalf("T0 state: %+v", st) + } + + r1, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n", + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: "k1", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r1.State.CurrentVersionNo != 1 || r1.State.HeadRevisionNo != 1 || r1.State.ManualChangeCount != 1 { + t.Fatalf("T1: %+v", r1.State) + } + + r2, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n李工再改\n", + ExpectedHeadSnapshot: &r1.State.HeadSnapshotID, ExpectedRevisionNo: &r1.State.HeadRevisionNo, + IdempotencyKey: "k2", ActorUserID: 11, ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "李工"}, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r2.State.CurrentVersionNo != 1 || r2.State.ManualChangeCount != 2 { + t.Fatalf("T2: %+v", r2.State) + } + + reportRaw, _ := os.ReadFile(filepath.Join("testdata", "change_report_v1_valid.json")) + cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + DocID: "doc_demo", RunID: "run-1", InputSnapshotID: r2.State.HeadSnapshotID, + OutputMarkdown: "# V2\n\n智能体新基线\n", ReportJSON: string(reportRaw), + ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "squad-a"}, + }) + if err != nil && cand == nil { + t.Fatal(err) + } + if cand.Status != docrevision.CandidateNeedsConfirmation { + t.Fatalf("high risk should need confirmation, got %s risk=%s", cand.Status, rep.RiskLevel) + } + _, err = svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: false, ActorUserID: 10, + }) + if err != docrevision.ErrNeedsConfirmation { + t.Fatalf("expected needs confirmation, got %v", err) + } + st3, err := svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: true, ActorUserID: 10, + ExpectedInputSHA: r2.State.HeadSHA256, + }) + if err != nil { + t.Fatal(err) + } + if st3.CurrentVersionNo != 2 || st3.HeadRevisionNo != 0 || st3.ManualChangeCount != 0 { + t.Fatalf("T3 after agent: %+v", st3) + } + + overlay, err := svc.ManualOverlay(ctx, "doc_demo") + if err != nil { + t.Fatal(err) + } + if len(overlay.Hunks) != 0 { + t.Fatalf("manual overlay should reset after agent baseline, got %+v", overlay) + } + + r4, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_demo", Markdown: "# V2\n\n智能体新基线\n张工再改\n", + ExpectedHeadSnapshot: &st3.HeadSnapshotID, ExpectedRevisionNo: &st3.HeadRevisionNo, + IdempotencyKey: "k4", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r4.State.CurrentVersionNo != 2 || r4.State.ManualChangeCount != 1 { + t.Fatalf("T4: %+v", r4.State) + } + + diff, err := svc.DiffVersions(ctx, "doc_demo", 1, 2, "final", "final") + if err != nil { + t.Fatal(err) + } + if len(diff.Hunks) == 0 { + t.Fatal("V1 vs V2 should differ") + } +} + +func TestBlockMapStableAcrossRebuild(t *testing.T) { + md := "## 需求\n\nREQ-PERF-007 温度精度 ±1℃\n" + a := docrevision.BuildBlockMap(md, nil) + b := docrevision.BuildBlockMap(md+"\n补充一句。\n", a) + found := false + for _, n := range b { + if n.BusinessKey == "REQ-PERF-007" { + for _, o := range a { + if o.BusinessKey == "REQ-PERF-007" && o.ID == n.ID { + found = true + } + } + } + } + if !found { + t.Fatalf("business key id not stable: a=%+v b=%+v", a, b) + } +} + +func TestIdempotentHumanRevision(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + st, _ := svc.OpenTemplateVersion(ctx, "doc_idem", "# A\n", docrevision.ActorSnapshot{}, 1, "") + req := docrevision.HumanRevisionRequest{ + DocID: "doc_idem", Markdown: "# A\n\nB\n", + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: "same", ActorUserID: 1, LifecycleState: "drafting", + } + r1, err := svc.CommitHumanRevision(ctx, req) + if err != nil { + t.Fatal(err) + } + r2, err := svc.CommitHumanRevision(ctx, req) + if err != nil { + t.Fatal(err) + } + if r1.Event.ID != r2.Event.ID || r1.State.HeadSnapshotID != r2.State.HeadSnapshotID { + t.Fatalf("idempotency broken") + } +} + +func TestHeadConflict(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + st, _ := svc.OpenTemplateVersion(ctx, "doc_c", "# A\n", docrevision.ActorSnapshot{}, 1, "") + wrong := st.HeadSnapshotID + 99 + _, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_c", Markdown: "# B\n", ExpectedHeadSnapshot: &wrong, + IdempotencyKey: "x", ActorUserID: 1, LifecycleState: "drafting", + }) + if err != docrevision.ErrHeadConflict { + t.Fatalf("got %v", err) + } +} + +func TestLifecycleLock(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + st, _ := svc.OpenTemplateVersion(ctx, "doc_l", "# A\n", docrevision.ActorSnapshot{}, 1, "") + svc.SetLifecycle("doc_l", "reviewing") + _, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_l", Markdown: "# B\n", ExpectedHeadSnapshot: &st.HeadSnapshotID, + IdempotencyKey: "y", ActorUserID: 1, LifecycleState: "reviewing", + }) + if err != docrevision.ErrLifecycleLocked { + t.Fatalf("got %v", err) + } +} diff --git a/internal/docrevision/diff.go b/internal/docrevision/diff.go new file mode 100644 index 00000000..ee008ce1 --- /dev/null +++ b/internal/docrevision/diff.go @@ -0,0 +1,242 @@ +package docrevision + +import ( + "fmt" + "strings" +) + +// ComputeDiff produces a line-oriented structured diff between two markdown bodies. +func ComputeDiff(fromMarkdown, toMarkdown string, fromBlocks, toBlocks []BlockNode) DiffResult { + fromLines := splitLines(fromMarkdown) + toLines := splitLines(toMarkdown) + hunks := myersLineDiff(fromLines, toLines, fromBlocks, toBlocks) + // Detect moves on raw delete/insert pairs before collapsing modifies. + hunks = annotateMoves(hunks, fromBlocks, toBlocks) + hunks = collapseAdjacentModify(hunks) + sum := DiffSummary{} + for _, h := range hunks { + switch h.Kind { + case "insert": + sum.Inserts++ + case "delete": + sum.Deletes++ + case "modify": + sum.Modifies++ + case "move": + sum.Moves++ + } + } + return DiffResult{Summary: sum, Hunks: hunks} +} + +func splitLines(s string) []string { + s = strings.TrimSuffix(s, "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func myersLineDiff(a, b []string, fromBlocks, toBlocks []BlockNode) []DiffHunk { + n, m := len(a), len(b) + // Simple LCS DP for correctness on moderate docs. + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + dp[i][j] = dp[i+1][j+1] + 1 + } else if dp[i+1][j] >= dp[i][j+1] { + dp[i][j] = dp[i+1][j] + } else { + dp[i][j] = dp[i][j+1] + } + } + } + var hunks []DiffHunk + i, j := 0, 0 + idx := 0 + for i < n && j < m { + if a[i] == b[j] { + i++ + j++ + continue + } + if dp[i+1][j] >= dp[i][j+1] { + h := DiffHunk{ + ID: fmt.Sprintf("h%d", idx), + Kind: "delete", + BeforeText: a[i], + LineStart: i + 1, + LineEnd: i + 1, + Section: sectionForLine(i, fromBlocks), + } + if blk := blockForLine(i, a, fromBlocks); blk != nil { + h.BlockIDBefore = blk.ID + } + hunks = append(hunks, h) + idx++ + i++ + } else { + h := DiffHunk{ + ID: fmt.Sprintf("h%d", idx), + Kind: "insert", + AfterText: b[j], + LineStart: j + 1, + LineEnd: j + 1, + Section: sectionForLine(j, toBlocks), + } + if blk := blockForLine(j, b, toBlocks); blk != nil { + h.BlockIDAfter = blk.ID + } + hunks = append(hunks, h) + idx++ + j++ + } + } + for i < n { + h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "delete", BeforeText: a[i], LineStart: i + 1, LineEnd: i + 1, Section: sectionForLine(i, fromBlocks)} + if blk := blockForLine(i, a, fromBlocks); blk != nil { + h.BlockIDBefore = blk.ID + } + hunks = append(hunks, h) + idx++ + i++ + } + for j < m { + h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "insert", AfterText: b[j], LineStart: j + 1, LineEnd: j + 1, Section: sectionForLine(j, toBlocks)} + if blk := blockForLine(j, b, toBlocks); blk != nil { + h.BlockIDAfter = blk.ID + } + hunks = append(hunks, h) + idx++ + j++ + } + return hunks +} + +func collapseAdjacentModify(hunks []DiffHunk) []DiffHunk { + if len(hunks) == 0 { + return hunks + } + out := make([]DiffHunk, 0, len(hunks)) + for i := 0; i < len(hunks); i++ { + if i+1 < len(hunks) && hunks[i].Kind == "delete" && hunks[i+1].Kind == "insert" { + out = append(out, DiffHunk{ + ID: hunks[i].ID, + Kind: "modify", + BeforeText: hunks[i].BeforeText, + AfterText: hunks[i+1].AfterText, + BlockIDBefore: hunks[i].BlockIDBefore, + BlockIDAfter: hunks[i+1].BlockIDAfter, + Section: firstNonEmpty(hunks[i].Section, hunks[i+1].Section), + LineStart: hunks[i].LineStart, + LineEnd: hunks[i+1].LineEnd, + }) + i++ + continue + } + out = append(out, hunks[i]) + } + return out +} + +func annotateMoves(hunks []DiffHunk, fromBlocks, toBlocks []BlockNode) []DiffHunk { + fromByFP := map[string]string{} + for _, b := range fromBlocks { + fromByFP[b.Fingerprint] = b.ID + } + toByFP := map[string]string{} + for _, b := range toBlocks { + toByFP[b.Fingerprint] = b.ID + } + deletedFP := map[string]int{} + insertedFP := map[string]int{} + for i, h := range hunks { + if h.Kind == "delete" { + deletedFP[ContentFingerprint(h.BeforeText)] = i + } + if h.Kind == "insert" { + insertedFP[ContentFingerprint(h.AfterText)] = i + } + } + used := map[int]bool{} + out := make([]DiffHunk, 0, len(hunks)) + for i, h := range hunks { + if used[i] { + continue + } + if h.Kind == "delete" { + fp := ContentFingerprint(h.BeforeText) + if j, ok := insertedFP[fp]; ok && !used[j] { + used[i] = true + used[j] = true + mh := DiffHunk{ + ID: h.ID, + Kind: "move", + BeforeText: h.BeforeText, + AfterText: hunks[j].AfterText, + BlockIDBefore: h.BlockIDBefore, + BlockIDAfter: hunks[j].BlockIDAfter, + Section: fmt.Sprintf("%s → %s", h.Section, hunks[j].Section), + LineStart: h.LineStart, + LineEnd: hunks[j].LineEnd, + } + out = append(out, mh) + continue + } + } + if h.Kind == "insert" { + fp := ContentFingerprint(h.AfterText) + if _, ok := deletedFP[fp]; ok { + // paired as move above + if used[i] { + continue + } + } + } + out = append(out, h) + } + return out +} + +func sectionForLine(lineIdx int, blocks []BlockNode) string { + // Approximate: find block whose order matches line; fallback heading path of nearest prior. + if lineIdx < 0 || len(blocks) == 0 { + return "" + } + best := "" + for _, b := range blocks { + if b.Order <= lineIdx { + if b.HeadingPath != "" { + best = b.HeadingPath + } + } + } + return best +} + +func blockForLine(lineIdx int, lines []string, blocks []BlockNode) *BlockNode { + if lineIdx < 0 || lineIdx >= len(lines) { + return nil + } + fp := ContentFingerprint(lines[lineIdx]) + for i := range blocks { + if blocks[i].Fingerprint == fp { + return &blocks[i] + } + } + if lineIdx < len(blocks) { + return &blocks[lineIdx] + } + return nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/internal/docrevision/impact.go b/internal/docrevision/impact.go new file mode 100644 index 00000000..f5b8bb2b --- /dev/null +++ b/internal/docrevision/impact.go @@ -0,0 +1,25 @@ +package docrevision + +// ImpactFromLineage builds deterministic document impacts from known downstream doc IDs. +func ImpactFromLineage(downstream []struct { + DocID string + Reason string +}) []DocImpact { + out := make([]DocImpact, 0, len(downstream)) + for _, d := range downstream { + if d.DocID == "" { + continue + } + reason := d.Reason + if reason == "" { + reason = "上游文档内容变更,下游需复核" + } + out = append(out, DocImpact{ + DocID: d.DocID, + ImpactType: "stale_candidate", + Reason: reason, + RecommendedAction: "由编制负责人复核并重新基线", + }) + } + return out +} diff --git a/internal/docrevision/risk.go b/internal/docrevision/risk.go new file mode 100644 index 00000000..e2055822 --- /dev/null +++ b/internal/docrevision/risk.go @@ -0,0 +1,160 @@ +package docrevision + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +var ( + sensitiveDeleteRe = regexp.MustCompile(`(?i)(法规|风险控制|验收标准|技术参数|REQ-|GB\d|YY\d)`) + numericChangeRe = regexp.MustCompile(`\d+(\.\d+)?`) +) + +// MaxRisk returns the higher of two risk levels. +func MaxRisk(a, b string) string { + rank := map[string]int{RiskLow: 1, RiskMedium: 2, RiskHigh: 3, RiskCritical: 4, "": 0} + if rank[a] >= rank[b] { + if a == "" { + return RiskLow + } + return a + } + if b == "" { + return RiskLow + } + return b +} + +// RuleRiskFloor computes deterministic risk from diff hunks. +func RuleRiskFloor(hunks []DiffHunk, impacts []DocImpact) string { + level := RiskLow + for _, h := range hunks { + text := h.BeforeText + "\n" + h.AfterText + if h.Kind == "delete" && sensitiveDeleteRe.MatchString(h.BeforeText) { + level = MaxRisk(level, RiskHigh) + } + if h.Kind == "modify" && sensitiveDeleteRe.MatchString(text) { + level = MaxRisk(level, RiskHigh) + } + if h.Kind == "modify" && numericChangeRe.MatchString(h.BeforeText) && numericChangeRe.MatchString(h.AfterText) && h.BeforeText != h.AfterText { + if sensitiveDeleteRe.MatchString(text) { + level = MaxRisk(level, RiskHigh) + } else { + level = MaxRisk(level, RiskMedium) + } + } + } + for _, imp := range impacts { + if imp.ImpactType == "stale_candidate" || strings.Contains(imp.Reason, "已批准") { + level = MaxRisk(level, RiskCritical) + } else if imp.DocID != "" { + level = MaxRisk(level, RiskHigh) + } + } + return level +} + +// ValidateChangeReportJSON parses and validates change_report.json against deterministic facts. +func ValidateChangeReportJSON(raw string, fromSHA, toSHA string, ruleRisk string, impacts []DocImpact, hunks []DiffHunk) (*ChangeReport, error) { + if strings.TrimSpace(raw) == "" { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "empty report"), ErrReportInvalid + } + var payload struct { + SchemaVersion int `json:"schema_version"` + Summary []string `json:"summary"` + Changes []map[string]any `json:"changes"` + Risk struct { + Level string `json:"level"` + Items []RiskItem `json:"items"` + } `json:"risk"` + ProjectImpact map[string]any `json:"project_impact"` + DocumentImpacts []DocImpact `json:"document_impacts"` + OpenQuestions []string `json:"open_questions"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "json parse failed"), fmt.Errorf("%w: %v", ErrReportInvalid, err) + } + if payload.SchemaVersion != 1 { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "bad schema"), fmt.Errorf("%w: schema_version", ErrReportInvalid) + } + if fromSHA != toSHA && len(payload.Summary) == 0 && len(payload.Changes) == 0 { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "claims no change"), fmt.Errorf("%w: missing summary for changed content", ErrReportInvalid) + } + if fromSHA != toSHA && strings.EqualFold(strings.Join(payload.Summary, " "), "未修改") { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "claims unmodified"), fmt.Errorf("%w: unmodified claim", ErrReportInvalid) + } + finalRisk := MaxRisk(ruleRisk, payload.Risk.Level) + if finalRisk == "" { + finalRisk = RiskUnassessedAsHigh() + } + rep := &ChangeReport{ + SchemaVersion: 1, + Summary: payload.Summary, + RiskLevel: finalRisk, + RiskItems: payload.Risk.Items, + ProjectImpacts: payload.ProjectImpact, + DocumentImpacts: mergeImpacts(payload.DocumentImpacts, impacts), + OpenQuestions: payload.OpenQuestions, + AnalysisStatus: AnalysisComplete, + RawJSON: raw, + DeterministicFacts: map[string]any{ + "from_sha": fromSHA, + "to_sha": toSHA, + "rule_risk": ruleRisk, + "hunk_count": len(hunks), + "impact_count": len(impacts), + }, + } + return rep, nil +} + +func RiskUnassessedAsHigh() string { return RiskHigh } + +func fallbackReport(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, hunks []DiffHunk, reason string) *ChangeReport { + risk := ruleRisk + if risk == "" { + risk = RiskHigh + } + return &ChangeReport{ + SchemaVersion: 1, + Summary: []string{"变更报告未完整评估,已按保守策略处理"}, + RiskLevel: risk, + DocumentImpacts: impacts, + OpenQuestions: []string{reason}, + AnalysisStatus: AnalysisUnassessed, + DeterministicFacts: map[string]any{ + "from_sha": fromSHA, + "to_sha": toSHA, + "rule_risk": ruleRisk, + "hunk_count": len(hunks), + "fallback": reason, + }, + } +} + +func mergeImpacts(model, det []DocImpact) []DocImpact { + seen := map[string]bool{} + out := make([]DocImpact, 0, len(model)+len(det)) + for _, d := range det { + if d.DocID == "" || seen[d.DocID] { + continue + } + seen[d.DocID] = true + out = append(out, d) + } + for _, m := range model { + if m.DocID == "" || seen[m.DocID] { + continue + } + seen[m.DocID] = true + out = append(out, m) + } + return out +} + +// NeedsConfirmation reports whether frozen policy requires author confirmation. +func NeedsConfirmation(risk string) bool { + return risk == RiskHigh || risk == RiskCritical +} diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go new file mode 100644 index 00000000..b837b4c5 --- /dev/null +++ b/internal/docrevision/service.go @@ -0,0 +1,712 @@ +package docrevision + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Service is the document revision ledger. +type Service struct { + pool *pgxpool.Pool + mu sync.Mutex + + blobs map[string]*ContentBlob + snapshots map[int64]*Snapshot + versions map[int64]*Version + events map[int64]*ChangeEvent + candidates map[int64]*AgentCandidate + reports map[int64]*ChangeReport + roots map[string]*DocRootPointers + idemp map[string]int64 // idempotency_key -> event id + + nextSnapID int64 + nextVerID int64 + nextEvtID int64 + nextCandID int64 + nextRepID int64 + + // Outbox hooks (Wave 5); optional. + OnOutbox func(ctx context.Context, eventType string, payload map[string]any) error + + // Feature flags + LedgerWrite bool + LedgerRead bool + AgentCommit bool + DiffUI bool + Notify bool +} + +func NewService(pool *pgxpool.Pool) *Service { + return &Service{ + pool: pool, + blobs: map[string]*ContentBlob{}, + snapshots: map[int64]*Snapshot{}, + versions: map[int64]*Version{}, + events: map[int64]*ChangeEvent{}, + candidates: map[int64]*AgentCandidate{}, + reports: map[int64]*ChangeReport{}, + roots: map[string]*DocRootPointers{}, + idemp: map[string]int64{}, + LedgerWrite: true, + LedgerRead: true, + AgentCommit: true, + DiffUI: true, + Notify: true, + } +} + +func (s *Service) EnsureRoot(docID, kind string, projectID int64, lifecycle string) *DocRootPointers { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.roots[docID]; ok { + return r + } + r := &DocRootPointers{ + DocID: docID, + Kind: kind, + LifecycleState: lifecycle, + ProjectID: projectID, + RevisionSchemaVer: SchemaVersion, + } + s.roots[docID] = r + return r +} + +func (s *Service) GetRevisionState(ctx context.Context, docID string) (*RevisionState, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + root, ok := s.roots[docID] + if !ok || root.LedgerVersionID == nil { + return nil, ErrNotFound + } + ver := s.versions[*root.LedgerVersionID] + head := s.snapshots[*root.HeadSnapshotID] + caps := []string{"read"} + if root.LifecycleState == "drafting" { + caps = append(caps, "edit") + } + if s.DiffUI { + caps = append(caps, "diff", "overlay", "history") + } + return &RevisionState{ + DocID: docID, + CurrentVersionNo: ver.VersionNo, + VersionStatus: ver.Status, + BaselineSnapshotID: ver.BaselineSnapshotID, + HeadSnapshotID: ver.HeadSnapshotID, + HeadRevisionNo: root.HeadRevisionNo, + HeadSHA256: head.ContentSHA256, + ManualChangeCount: ver.HumanRevisionCount, + Capabilities: caps, + ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo), + }, nil +} + +func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequest) (*HumanRevisionResult, error) { + _ = ctx + if req.IdempotencyKey == "" { + return nil, fmt.Errorf("docrevision: idempotency key required") + } + if req.LifecycleState != "" && req.LifecycleState != "drafting" { + return nil, ErrLifecycleLocked + } + canon := CanonicalizeMarkdown(req.Markdown) + if len(canon) > MaxMarkdownBytes { + return nil, ErrTooLarge + } + sha := SHA256Hex(canon) + + s.mu.Lock() + defer s.mu.Unlock() + + if eid, ok := s.idemp[req.IdempotencyKey]; ok { + ev := s.events[eid] + st, _ := s.revisionStateLocked(req.DocID) + overlay := s.manualOverlayLocked(req.DocID) + return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil + } + + root := s.roots[req.DocID] + if root == nil { + root = &DocRootPointers{DocID: req.DocID, Kind: "controlled", LifecycleState: req.LifecycleState, ProjectID: req.ProjectID, RevisionSchemaVer: SchemaVersion, WorkDir: req.WorkDir} + s.roots[req.DocID] = root + } + + if root.LedgerVersionID == nil { + if !req.AllowCreateBaseline { + return nil, ErrNoOpenVersion + } + snap := s.putSnapshotLocked(req.DocID, canon, sha, nil, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap) + ver := s.openVersionLocked(req.DocID, OriginTemplate, snap.ID, snap.ID, "") + root.LedgerVersionID = &ver.ID + root.HeadSnapshotID = &snap.ID + root.HeadRevisionNo = 0 + root.ContentSHA256 = sha + root.RowRevision++ + ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey) + st := s.mustStateLocked(req.DocID) + return &HumanRevisionResult{State: *st, Event: *ev}, nil + } + + ver := s.versions[*root.LedgerVersionID] + if ver.Status != VersionOpen { + return nil, ErrNoOpenVersion + } + if req.ExpectedHeadSnapshot != nil && *req.ExpectedHeadSnapshot != ver.HeadSnapshotID { + cur := s.snapshots[ver.HeadSnapshotID] + return &HumanRevisionResult{Conflict: &ConflictInfo{ + Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID, + CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256, + }}, ErrHeadConflict + } + if req.ExpectedRevisionNo != nil && *req.ExpectedRevisionNo != root.HeadRevisionNo { + cur := s.snapshots[ver.HeadSnapshotID] + return &HumanRevisionResult{Conflict: &ConflictInfo{ + Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID, + CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256, + }}, ErrHeadConflict + } + + parentID := ver.HeadSnapshotID + parent := s.snapshots[parentID] + if parent.ContentSHA256 == sha { + // no-op: return current without new snapshot/version + st := s.mustStateLocked(req.DocID) + overlay := s.manualOverlayLocked(req.DocID) + return &HumanRevisionResult{State: *st, Overlay: overlay}, nil + } + + prevBlocks := parent.BlockMap + snap := s.putSnapshotLocked(req.DocID, canon, sha, &parentID, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap) + if len(req.ClientBlockMap) == 0 { + snap.BlockMap = BuildBlockMap(canon, prevBlocks) + } else { + snap.BlockMap = BuildBlockMap(canon, req.ClientBlockMap) + } + diff := ComputeDiff(s.blobs[parent.ContentSHA256].ContentMarkdown, canon, prevBlocks, snap.BlockMap) + ver.HeadSnapshotID = snap.ID + ver.HumanRevisionCount++ + root.HeadSnapshotID = &snap.ID + root.HeadRevisionNo++ + root.ContentSHA256 = sha + root.RowRevision++ + eventNo := s.nextEventNoLocked(req.DocID) + ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey) + st := s.mustStateLocked(req.DocID) + overlay := DiffResult{FromSnapshotID: ver.BaselineSnapshotID, ToSnapshotID: snap.ID, Summary: diff.Summary, Hunks: diff.Hunks} + baseMD := s.blobs[s.snapshots[ver.BaselineSnapshotID].ContentSHA256].ContentMarkdown + overlay = ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap) + overlay.FromSnapshotID = ver.BaselineSnapshotID + overlay.ToSnapshotID = snap.ID + return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil +} + +func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) { + _ = ctx + canon := CanonicalizeMarkdown(markdown) + sha := SHA256Hex(canon) + s.mu.Lock() + defer s.mu.Unlock() + if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil { + return s.mustStateLocked(docID), nil + } + snap := s.putSnapshotLocked(docID, canon, sha, nil, SourceTemplate, nil, "template", actor, "", nil) + ver := s.openVersionLocked(docID, OriginTemplate, snap.ID, snap.ID, "") + root := &DocRootPointers{ + DocID: docID, Kind: "controlled", ProjectID: projectID, WorkDir: workDir, + LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0, + ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion, + } + s.roots[docID] = root + _ = s.putEventLocked(docID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorTemplate, nil, "template", actor, nil, "", "", fmt.Sprintf("template:%s:%s", docID, sha)) + return s.mustStateLocked(docID), nil +} + +func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRequest) (*AgentCandidate, *ChangeReport, error) { + _ = ctx + if !s.AgentCommit { + return nil, nil, fmt.Errorf("docrevision: agent commit disabled") + } + canon := CanonicalizeMarkdown(req.OutputMarkdown) + if len(canon) > MaxMarkdownBytes { + return nil, nil, ErrTooLarge + } + outSHA := SHA256Hex(canon) + + s.mu.Lock() + defer s.mu.Unlock() + + root := s.roots[req.DocID] + if root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil { + return nil, nil, ErrNoOpenVersion + } + if *root.HeadSnapshotID != req.InputSnapshotID { + // still create stale candidate + } + inSnap := s.snapshots[req.InputSnapshotID] + if inSnap == nil { + return nil, nil, ErrNotFound + } + inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown + outSnap := s.putSnapshotLocked(req.DocID, canon, outSHA, &req.InputSnapshotID, req.ActorKind, req.OnBehalfOf, req.RunID, req.ActorSnapshot, req.RunID, nil) + diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap) + impacts := s.impactLocked(req.DocID) + ruleRisk := RuleRiskFloor(diff.Hunks, impacts) + rep, err := ValidateChangeReportJSON(req.ReportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks) + status := CandidateReady + if err != nil { + status = CandidateReady + rep.AnalysisStatus = AnalysisUnassessed + } + if NeedsConfirmation(rep.RiskLevel) { + status = CandidateNeedsConfirmation + } + if *root.HeadSnapshotID != req.InputSnapshotID { + status = CandidateStale + } + if inSnap.ContentSHA256 == outSHA { + // noop path handled at commit + } + s.nextRepID++ + rep.ID = s.nextRepID + rep.DocID = req.DocID + rep.FromSnapshotID = req.InputSnapshotID + rep.ToSnapshotID = outSnap.ID + rep.CreatedAt = time.Now().UTC() + s.reports[rep.ID] = rep + + s.nextCandID++ + c := &AgentCandidate{ + ID: s.nextCandID, DocID: req.DocID, InputSnapshotID: req.InputSnapshotID, + OutputSnapshotID: &outSnap.ID, RunID: req.RunID, ProposalID: req.ProposalID, + Status: status, AuthorizedScope: req.AuthorizedScope, ChangeReportID: &rep.ID, + ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(), + } + s.candidates[c.ID] = c + if s.Notify && s.OnOutbox != nil && status == CandidateNeedsConfirmation { + _ = s.OnOutbox(ctx, "document.agent_candidate_ready", map[string]any{ + "doc_id": req.DocID, "candidate_id": c.ID, "risk": rep.RiskLevel, + }) + } + if err != nil && status != CandidateStale { + return c, rep, err + } + return c, rep, nil +} + +func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.candidates[candidateID] + if !ok { + return nil, ErrNotFound + } + if c.Status == CandidateCommitted { + return s.mustStateLocked(c.DocID), nil + } + if c.Status == CandidateStale { + return nil, ErrCandidateStale + } + if c.Status == CandidateRejected { + return nil, fmt.Errorf("docrevision: candidate rejected") + } + root := s.roots[c.DocID] + if root == nil || root.HeadSnapshotID == nil { + return nil, ErrNoOpenVersion + } + if *root.HeadSnapshotID != c.InputSnapshotID { + c.Status = CandidateStale + return nil, ErrCandidateStale + } + if root.LifecycleState != "" && root.LifecycleState != "drafting" { + return nil, ErrLifecycleLocked + } + rep := s.reports[*c.ChangeReportID] + outSnap := s.snapshots[*c.OutputSnapshotID] + inSnap := s.snapshots[c.InputSnapshotID] + if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 { + return nil, ErrCandidateStale + } + if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 { + return nil, ErrCandidateStale + } + if dec.Decision == "reject" { + now := time.Now().UTC() + c.Status = CandidateRejected + c.DecidedAt = &now + return s.mustStateLocked(c.DocID), nil + } + if NeedsConfirmation(rep.RiskLevel) && !dec.Confirmed { + return nil, ErrNeedsConfirmation + } + if inSnap.ContentSHA256 == outSnap.ContentSHA256 { + // agent noop: event only + ver := s.versions[*root.LedgerVersionID] + eventNo := s.nextEventNoLocked(c.DocID) + _ = s.putEventLocked(c.DocID, ver.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentNoop, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, nil, "", "", fmt.Sprintf("agent_noop:%s:%s", c.RunID, outSnap.ContentSHA256)) + now := time.Now().UTC() + c.Status = CandidateCommitted + c.DecidedAt = &now + return s.mustStateLocked(c.DocID), nil + } + + oldVer := s.versions[*root.LedgerVersionID] + now := time.Now().UTC() + oldVer.Status = VersionSealed + oldVer.SealedAt = &now + oldVer.HeadSnapshotID = c.InputSnapshotID // seal at input (= previous head) + + origin := OriginAgent + if c.ActorKind == ActorAgentSquad { + origin = OriginAgentSquad + } + newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID) + newVer.ChangeReportID = c.ChangeReportID + root.LedgerVersionID = &newVer.ID + root.HeadSnapshotID = &outSnap.ID + root.HeadRevisionNo = 0 + root.ContentSHA256 = outSnap.ContentSHA256 + root.RowRevision++ + + inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown + outMD := s.blobs[outSnap.ContentSHA256].ContentMarkdown + diff := ComputeDiff(inMD, outMD, inSnap.BlockMap, outSnap.BlockMap) + eventNo := s.nextEventNoLocked(c.DocID) + _ = s.putEventLocked(c.DocID, newVer.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentCommit, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, diff.Hunks, "", "", fmt.Sprintf("agent_commit:%s:%s", c.RunID, outSnap.ContentSHA256)) + c.Status = CandidateCommitted + c.DecidedAt = &now + + if s.Notify && s.OnOutbox != nil { + _ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{ + "doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID, + }) + for _, imp := range rep.DocumentImpacts { + _ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{ + "doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo, + }) + } + if rep.AnalysisStatus == AnalysisUnassessed || rep.AnalysisStatus == AnalysisFallback { + _ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{ + "doc_id": c.DocID, "report_id": rep.ID, + }) + } + } + return s.mustStateLocked(c.DocID), nil +} + +func (d CommitAgentDecision) ActorUserIDPtr() *int64 { + if d.ActorUserID == 0 { + return nil + } + id := d.ActorUserID + return &id +} + +func (s *Service) ListVersions(ctx context.Context, docID string) ([]Version, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + var out []Version + for _, v := range s.versions { + if v.DocID == docID { + out = append(out, *v) + } + } + // sort by version_no + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].VersionNo < out[i].VersionNo { + out[i], out[j] = out[j], out[i] + } + } + } + return out, nil +} + +func (s *Service) GetVersionContent(ctx context.Context, docID string, versionNo int, view string) (string, string, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + var ver *Version + for _, v := range s.versions { + if v.DocID == docID && v.VersionNo == versionNo { + ver = v + break + } + } + if ver == nil { + return "", "", ErrNotFound + } + sid := ver.HeadSnapshotID + if view == "baseline" { + sid = ver.BaselineSnapshotID + } + snap := s.snapshots[sid] + return s.blobs[snap.ContentSHA256].ContentMarkdown, snap.ContentSHA256, nil +} + +func (s *Service) DiffVersions(ctx context.Context, docID string, fromNo, toNo int, fromView, toView string) (*DiffResult, error) { + fromMD, _, err := s.GetVersionContent(ctx, docID, fromNo, fromView) + if err != nil { + return nil, err + } + toMD, _, err := s.GetVersionContent(ctx, docID, toNo, toView) + if err != nil { + return nil, err + } + fromBlocks := BuildBlockMap(fromMD, nil) + toBlocks := BuildBlockMap(toMD, fromBlocks) + d := ComputeDiff(fromMD, toMD, fromBlocks, toBlocks) + return &d, nil +} + +func (s *Service) ManualOverlay(ctx context.Context, docID string) (*DiffResult, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + d := s.manualOverlayLocked(docID) + return &d, nil +} + +func (s *Service) GetCandidate(ctx context.Context, docID string, candidateID int64) (*AgentCandidate, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.candidates[candidateID] + if !ok || c.DocID != docID { + return nil, ErrNotFound + } + cp := *c + return &cp, nil +} + +func (s *Service) GetChangeReport(ctx context.Context, docID string, reportID int64) (*ChangeReport, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.reports[reportID] + if !ok || r.DocID != docID { + return nil, ErrNotFound + } + cp := *r + return &cp, nil +} + +// ImportFreeformLegacy imports legacy freeform checkpoint as sealed migration versions + open head. +func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerID int64, projectID int64, checkpoints []struct { + VersionNo int + Markdown string + SHA string +}, currentMarkdown string) (*RevisionState, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil { + return s.mustStateLocked(docID), nil + } + actor := ActorSnapshot{DisplayName: "migration", Role: "system"} + var lastSnap *Snapshot + startNo := 1 + for _, cp := range checkpoints { + canon := CanonicalizeMarkdown(cp.Markdown) + sha := cp.SHA + if sha == "" { + sha = SHA256Hex(canon) + } + var parent *int64 + if lastSnap != nil { + id := lastSnap.ID + parent = &id + } + snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy", actor, "", nil) + ver := s.openVersionLocked(docID, OriginMigration, snap.ID, snap.ID, "") + // force version number and seal + ver.VersionNo = cp.VersionNo + now := time.Now().UTC() + ver.Status = VersionSealed + ver.SealedAt = &now + lastSnap = snap + if cp.VersionNo >= startNo { + startNo = cp.VersionNo + 1 + } + } + canon := CanonicalizeMarkdown(currentMarkdown) + sha := SHA256Hex(canon) + var parent *int64 + if lastSnap != nil { + id := lastSnap.ID + parent = &id + } + snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy_head", actor, "", nil) + ver := &Version{ + ID: 0, DocID: docID, VersionNo: startNo, Status: VersionOpen, OriginKind: OriginMigration, + BaselineSnapshotID: snap.ID, HeadSnapshotID: snap.ID, OpenedAt: time.Now().UTC(), + } + s.nextVerID++ + ver.ID = s.nextVerID + s.versions[ver.ID] = ver + root := &DocRootPointers{ + DocID: docID, Kind: "freeform", ProjectID: projectID, + LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0, + ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion, + LegacyVersionID: startNo - 1, + } + s.roots[docID] = root + return s.mustStateLocked(docID), nil +} + +// ProjectOutputMarkdown writes DB head to workDir/output.md (compatibility projection). +func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error { + s.mu.Lock() + root := s.roots[docID] + if root == nil || root.HeadSnapshotID == nil || root.WorkDir == "" { + s.mu.Unlock() + return nil + } + snap := s.snapshots[*root.HeadSnapshotID] + md := s.blobs[snap.ContentSHA256].ContentMarkdown + workDir := root.WorkDir + s.mu.Unlock() + path := workDir + if !filepath.IsAbs(path) { + path = filepath.Join(repoRoot, workDir) + } + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(path, "output.md"), []byte(md), 0o644) +} + +// --- internal helpers --- + +func (s *Service) putSnapshotLocked(docID, canon, sha string, parent *int64, source string, userID *int64, actorRef string, actor ActorSnapshot, runID string, clientBlocks []BlockNode) *Snapshot { + if _, ok := s.blobs[sha]; !ok { + s.blobs[sha] = &ContentBlob{ContentSHA256: sha, ContentMarkdown: canon, ByteSize: int64(len(canon)), CreatedAt: time.Now().UTC()} + } + var prev []BlockNode + if parent != nil { + if p := s.snapshots[*parent]; p != nil { + prev = p.BlockMap + } + } + blocks := clientBlocks + if len(blocks) == 0 { + blocks = BuildBlockMap(canon, prev) + } else { + blocks = BuildBlockMap(canon, clientBlocks) + } + s.nextSnapID++ + snap := &Snapshot{ + ID: s.nextSnapID, DocID: docID, ContentSHA256: sha, ParentSnapshotID: parent, + BlockMap: blocks, SourceKind: source, ActorUserID: userID, ActorRef: actorRef, + ActorSnapshot: actor, SourceRunID: runID, CreatedAt: time.Now().UTC(), + } + s.snapshots[snap.ID] = snap + return snap +} + +func (s *Service) openVersionLocked(docID, origin string, baselineID, headID int64, runID string) *Version { + maxNo := 0 + for _, v := range s.versions { + if v.DocID == docID && v.VersionNo > maxNo { + maxNo = v.VersionNo + } + if v.DocID == docID && v.Status == VersionOpen { + now := time.Now().UTC() + v.Status = VersionSealed + v.SealedAt = &now + } + } + s.nextVerID++ + ver := &Version{ + ID: s.nextVerID, DocID: docID, VersionNo: maxNo + 1, Status: VersionOpen, + OriginKind: origin, BaselineSnapshotID: baselineID, HeadSnapshotID: headID, + SourceRunID: runID, OpenedAt: time.Now().UTC(), + } + s.versions[ver.ID] = ver + return ver +} + +func (s *Service) putEventLocked(docID string, versionID int64, eventNo int, from *int64, to int64, kind, actorKind string, userID *int64, actorRef string, actor ActorSnapshot, hunks []DiffHunk, editSession, requestID, idemp string) *ChangeEvent { + s.nextEvtID++ + ev := &ChangeEvent{ + ID: s.nextEvtID, DocID: docID, VersionID: versionID, EventNo: eventNo, + FromSnapshotID: from, ToSnapshotID: to, ChangeKind: kind, ActorKind: actorKind, + ActorUserID: userID, ActorRef: actorRef, ActorSnapshot: actor, ChangeSet: hunks, + EditSessionID: editSession, RequestID: requestID, IdempotencyKey: idemp, CreatedAt: time.Now().UTC(), + } + s.events[ev.ID] = ev + if idemp != "" { + s.idemp[idemp] = ev.ID + } + return ev +} + +func (s *Service) nextEventNoLocked(docID string) int { + max := 0 + for _, e := range s.events { + if e.DocID == docID && e.EventNo > max { + max = e.EventNo + } + } + return max + 1 +} + +func (s *Service) revisionStateLocked(docID string) (*RevisionState, error) { + root, ok := s.roots[docID] + if !ok || root.LedgerVersionID == nil { + return nil, ErrNotFound + } + return s.mustStateLocked(docID), nil +} + +func (s *Service) mustStateLocked(docID string) *RevisionState { + root := s.roots[docID] + ver := s.versions[*root.LedgerVersionID] + head := s.snapshots[*root.HeadSnapshotID] + return &RevisionState{ + DocID: docID, CurrentVersionNo: ver.VersionNo, VersionStatus: ver.Status, + BaselineSnapshotID: ver.BaselineSnapshotID, HeadSnapshotID: ver.HeadSnapshotID, + HeadRevisionNo: root.HeadRevisionNo, HeadSHA256: head.ContentSHA256, + ManualChangeCount: ver.HumanRevisionCount, + Capabilities: []string{"read", "edit", "diff", "overlay", "history"}, + ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo), + } +} + +func (s *Service) manualOverlayLocked(docID string) DiffResult { + root := s.roots[docID] + if root == nil || root.LedgerVersionID == nil { + return DiffResult{} + } + ver := s.versions[*root.LedgerVersionID] + base := s.snapshots[ver.BaselineSnapshotID] + head := s.snapshots[ver.HeadSnapshotID] + d := ComputeDiff(s.blobs[base.ContentSHA256].ContentMarkdown, s.blobs[head.ContentSHA256].ContentMarkdown, base.BlockMap, head.BlockMap) + d.FromSnapshotID = base.ID + d.ToSnapshotID = head.ID + return d +} + +func (s *Service) impactLocked(docID string) []DocImpact { + // Deterministic placeholder: no lineage graph in memory mode. + _ = docID + return nil +} + +// SetLifecycle updates lifecycle gate used by write paths. +func (s *Service) SetLifecycle(docID, state string) { + s.mu.Lock() + defer s.mu.Unlock() + if r := s.roots[docID]; r != nil { + r.LifecycleState = state + } +} diff --git a/internal/docrevision/testdata/change_report_v1_valid.json b/internal/docrevision/testdata/change_report_v1_valid.json new file mode 100644 index 00000000..7a99056b --- /dev/null +++ b/internal/docrevision/testdata/change_report_v1_valid.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "summary": ["补充系统边界条件", "调整三项验收阈值"], + "changes": [ + { + "section_locator": "3.2 性能要求 / REQ-PERF-007", + "change_type": "modified", + "rationale": "依据新增用户材料与法规检索证据", + "evidence_refs": ["material:123#sha256", "kb:GB9706#8.4"] + } + ], + "risk": { + "level": "high", + "items": [ + { + "category": "verification", + "description": "阈值变化会使既有用例失效", + "mitigation": "重跑 TC-007" + } + ] + }, + "project_impact": { + "schedule": "可能增加验证工作量", + "cost": "待评估", + "scope": "影响性能验证范围", + "compliance": "需复核风险管理文件" + }, + "document_impacts": [ + { + "doc_id": "doc_proj1_test_manager", + "impact_type": "stale_candidate", + "reason": "引用的 REQ-PERF-007 已改变", + "recommended_action": "由测试方案编制人复核并重新基线" + } + ], + "open_questions": ["阈值是否已获得硬件负责人确认"] +} diff --git a/internal/docrevision/testdata/golden_diff_cases.json b/internal/docrevision/testdata/golden_diff_cases.json new file mode 100644 index 00000000..2ed6f928 --- /dev/null +++ b/internal/docrevision/testdata/golden_diff_cases.json @@ -0,0 +1,41 @@ +{ + "name": "chinese_paragraph_table_list_mermaid_move_noop", + "cases": [ + { + "id": "paragraph", + "before": "# 概述\n\n原系统用于监护。\n", + "after": "# 概述\n\n原系统用于重症监护与转运。\n", + "expect_kinds": ["modify"] + }, + { + "id": "table", + "before": "| 项目 | 值 |\n| --- | --- |\n| REQ-PERF-007 | ±1℃ |\n", + "after": "| 项目 | 值 |\n| --- | --- |\n| REQ-PERF-007 | ±0.5℃ |\n", + "expect_kinds": ["modify"] + }, + { + "id": "list", + "before": "## 清单\n\n- 项 A\n- 项 B\n", + "after": "## 清单\n\n- 项 A\n- 项 C\n- 项 B\n", + "expect_kinds": ["insert"] + }, + { + "id": "mermaid", + "before": "```mermaid\nflowchart LR\n A-->B\n```\n", + "after": "```mermaid\nflowchart LR\n A-->C\n```\n", + "expect_kinds": ["modify"] + }, + { + "id": "move", + "before": "## 一\n\n移动段落内容不变\n\n## 二\n\n其他\n", + "after": "## 一\n\n其他\n\n## 二\n\n移动段落内容不变\n", + "expect_kinds": ["move"] + }, + { + "id": "noop", + "before": "# 相同\n\n内容完全一致。\n", + "after": "# 相同\n\n内容完全一致。\n", + "expect_kinds": [] + } + ] +} diff --git a/internal/docrevision/types.go b/internal/docrevision/types.go new file mode 100644 index 00000000..deb15b56 --- /dev/null +++ b/internal/docrevision/types.go @@ -0,0 +1,323 @@ +// Package docrevision implements controlled/freeform document Version + Revision ledger. +package docrevision + +import ( + "errors" + "time" +) + +const ( + SchemaVersion = 1 + + VersionOpen = "open" + VersionSealed = "sealed" + + OriginTemplate = "template" + OriginAgent = "agent" + OriginAgentSquad = "agent_squad" + OriginMigration = "migration" + + SourceTemplate = "template" + SourceHuman = "human" + SourceAgent = "agent" + SourceAgentSquad = "agent_squad" + SourceMigration = "migration" + SourceSystem = "system" + + ChangeHumanRevision = "human_revision" + ChangeAgentCommit = "agent_commit" + ChangeAgentNoop = "agent_noop" + ChangeImport = "import" + ChangeRestoreAsNew = "restore_as_new" + + ActorHuman = "human" + ActorAgent = "agent" + ActorAgentSquad = "agent_squad" + ActorTemplate = "template" + ActorMigration = "migration" + ActorSystem = "system" + + CandidateReady = "ready" + CandidateNeedsConfirmation = "needs_confirmation" + CandidateCommitted = "committed" + CandidateRejected = "rejected" + CandidateStale = "stale" + CandidateQualityFailed = "quality_failed" + + RiskLow = "low" + RiskMedium = "medium" + RiskHigh = "high" + RiskCritical = "critical" + + AnalysisComplete = "complete" + AnalysisFallback = "fallback" + AnalysisUnassessed = "unassessed" + AnalysisCorrected = "corrected" + + ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT" + ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN" + ErrCodeBusy = "DOCUMENT_BUSY" + ErrCodeCandidateStale = "AGENT_CANDIDATE_STALE" + ErrCodeReportInvalid = "CHANGE_REPORT_INVALID" + ErrCodeLifecycleLocked = "DOCUMENT_LIFECYCLE_LOCKED" + ErrCodeTooLarge = "DOCUMENT_TOO_LARGE" + ErrCodeNeedsConfirmation = "AGENT_NEEDS_CONFIRMATION" + ErrCodeNotFound = "DOCUMENT_REVISION_NOT_FOUND" +) + +var ( + ErrNotFound = errors.New("docrevision: not found") + ErrHeadConflict = errors.New("docrevision: head conflict") + ErrLifecycleLocked = errors.New("docrevision: lifecycle locked") + ErrCandidateStale = errors.New("docrevision: candidate stale") + ErrReportInvalid = errors.New("docrevision: change report invalid") + ErrNeedsConfirmation = errors.New("docrevision: high/critical needs confirmation") + ErrIdempotencyConflict = errors.New("docrevision: idempotency conflict") + ErrForbidden = errors.New("docrevision: forbidden") + ErrTooLarge = errors.New("docrevision: document too large") + ErrNoOpenVersion = errors.New("docrevision: no open version") +) + +const MaxMarkdownBytes = 8 << 20 // 8 MiB + +// ActorSnapshot is an immutable attribution payload frozen at write time. +type ActorSnapshot struct { + DisplayName string `json:"display_name,omitempty"` + Role string `json:"role,omitempty"` + Duty string `json:"duty,omitempty"` + AgentKey string `json:"agent_key,omitempty"` + SquadName string `json:"squad_name,omitempty"` + Model string `json:"model,omitempty"` + ConfigVer string `json:"config_version,omitempty"` + Extra map[string]string `json:"extra,omitempty"` +} + +type ContentBlob struct { + ContentSHA256 string `json:"content_sha256"` + ContentMarkdown string `json:"content_markdown"` + ByteSize int64 `json:"byte_size"` + CreatedAt time.Time `json:"created_at"` +} + +type BlockNode struct { + ID string `json:"id"` + Type string `json:"type"` + StartOffset int `json:"start_offset"` + EndOffset int `json:"end_offset"` + HeadingPath string `json:"heading_path,omitempty"` + BusinessKey string `json:"business_key,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Order int `json:"order"` +} + +type Snapshot struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + ContentSHA256 string `json:"content_sha256"` + ParentSnapshotID *int64 `json:"parent_snapshot_id,omitempty"` + BlockMap []BlockNode `json:"block_map"` + SourceKind string `json:"source_kind"` + ActorUserID *int64 `json:"actor_user_id,omitempty"` + ActorRef string `json:"actor_ref,omitempty"` + ActorSnapshot ActorSnapshot `json:"actor_snapshot"` + SourceRunID string `json:"source_run_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Version struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + VersionNo int `json:"version_no"` + Status string `json:"status"` + OriginKind string `json:"origin_kind"` + BaselineSnapshotID int64 `json:"baseline_snapshot_id"` + HeadSnapshotID int64 `json:"head_snapshot_id"` + HumanRevisionCount int `json:"human_revision_count"` + SourceRunID string `json:"source_run_id,omitempty"` + ChangeReportID *int64 `json:"change_report_id,omitempty"` + OpenedAt time.Time `json:"opened_at"` + SealedAt *time.Time `json:"sealed_at,omitempty"` +} + +type ChangeEvent struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + VersionID int64 `json:"version_id"` + EventNo int `json:"event_no"` + FromSnapshotID *int64 `json:"from_snapshot_id,omitempty"` + ToSnapshotID int64 `json:"to_snapshot_id"` + ChangeKind string `json:"change_kind"` + ActorKind string `json:"actor_kind"` + ActorUserID *int64 `json:"actor_user_id,omitempty"` + ActorRef string `json:"actor_ref,omitempty"` + ActorSnapshot ActorSnapshot `json:"actor_snapshot"` + ChangeSet []DiffHunk `json:"change_set,omitempty"` + EditSessionID string `json:"edit_session_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + IdempotencyKey string `json:"idempotency_key"` + CreatedAt time.Time `json:"created_at"` +} + +type AgentCandidate struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + InputSnapshotID int64 `json:"input_snapshot_id"` + OutputSnapshotID *int64 `json:"output_snapshot_id,omitempty"` + RunID string `json:"run_id"` + ProposalID string `json:"proposal_id,omitempty"` + Status string `json:"status"` + AuthorizedScope string `json:"authorized_scope,omitempty"` + QualityResult string `json:"quality_result,omitempty"` + ChangeReportID *int64 `json:"change_report_id,omitempty"` + ActorKind string `json:"actor_kind"` + ActorSnapshot ActorSnapshot `json:"actor_snapshot"` + CreatedAt time.Time `json:"created_at"` + DecidedAt *time.Time `json:"decided_at,omitempty"` +} + +type ChangeReport struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + FromSnapshotID int64 `json:"from_snapshot_id"` + ToSnapshotID int64 `json:"to_snapshot_id"` + SchemaVersion int `json:"schema_version"` + Summary []string `json:"summary"` + RiskLevel string `json:"risk_level"` + RiskItems []RiskItem `json:"risk_items"` + ProjectImpacts map[string]any `json:"project_impacts"` + DocumentImpacts []DocImpact `json:"document_impacts"` + OpenQuestions []string `json:"open_questions"` + AnalysisStatus string `json:"analysis_status"` + ModelSnapshot string `json:"model_snapshot,omitempty"` + PromptVersion string `json:"prompt_version,omitempty"` + DeterministicFacts map[string]any `json:"deterministic_facts"` + CorrectsReportID *int64 `json:"corrects_report_id,omitempty"` + RawJSON string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type RiskItem struct { + Category string `json:"category"` + Description string `json:"description"` + Mitigation string `json:"mitigation,omitempty"` +} + +type DocImpact struct { + DocID string `json:"doc_id"` + ImpactType string `json:"impact_type"` + Reason string `json:"reason"` + RecommendedAction string `json:"recommended_action,omitempty"` +} + +type DiffHunk struct { + ID string `json:"id"` + Kind string `json:"kind"` // insert|delete|modify|move + Section string `json:"section,omitempty"` + BeforeText string `json:"before_text,omitempty"` + AfterText string `json:"after_text,omitempty"` + BlockIDBefore string `json:"block_id_before,omitempty"` + BlockIDAfter string `json:"block_id_after,omitempty"` + ActorKind string `json:"actor_kind,omitempty"` + ActorLabel string `json:"actor_label,omitempty"` + LineStart int `json:"line_start,omitempty"` + LineEnd int `json:"line_end,omitempty"` +} + +type DiffResult struct { + FromSnapshotID int64 `json:"from_snapshot_id"` + ToSnapshotID int64 `json:"to_snapshot_id"` + Summary DiffSummary `json:"summary"` + Hunks []DiffHunk `json:"hunks"` +} + +type DiffSummary struct { + Inserts int `json:"inserts"` + Deletes int `json:"deletes"` + Modifies int `json:"modifies"` + Moves int `json:"moves"` +} + +type DocRootPointers struct { + DocID string + Kind string // controlled|freeform + LedgerVersionID *int64 + HeadSnapshotID *int64 + HeadRevisionNo int + ReviewSnapshotID *int64 + RevisionSchemaVer int + RowRevision int64 + LifecycleState string + ContentSHA256 string + LegacyVersionID int // freeform only + WorkDir string + ProjectID int64 + AuthorID *int64 +} + +type RevisionState struct { + DocID string `json:"doc_id"` + CurrentVersionNo int `json:"current_version_no"` + VersionStatus string `json:"version_status"` + BaselineSnapshotID int64 `json:"baseline_snapshot_id"` + HeadSnapshotID int64 `json:"head_snapshot_id"` + HeadRevisionNo int `json:"head_revision_no"` + HeadSHA256 string `json:"head_sha256"` + ManualChangeCount int `json:"manual_change_count"` + Capabilities []string `json:"capabilities"` + ETag string `json:"etag"` +} + +type HumanRevisionRequest struct { + DocID string + Markdown string + ExpectedHeadSnapshot *int64 + ExpectedRevisionNo *int + IdempotencyKey string + EditSessionID string + RequestID string + ActorUserID int64 + ActorSnapshot ActorSnapshot + LifecycleState string + WorkDir string + ProjectID int64 + AllowCreateBaseline bool + ClientBlockMap []BlockNode +} + +type HumanRevisionResult struct { + State RevisionState `json:"state"` + Event ChangeEvent `json:"event"` + Overlay DiffResult `json:"manual_overlay"` + Conflict *ConflictInfo `json:"conflict,omitempty"` +} + +type ConflictInfo struct { + Code string `json:"code"` + BaseSnapshotID int64 `json:"base_snapshot_id"` + CurrentSnapshotID int64 `json:"current_snapshot_id"` + CurrentRevisionNo int `json:"current_revision_no"` + CurrentSHA256 string `json:"current_sha256"` +} + +type AgentCandidateRequest struct { + DocID string + RunID string + ProposalID string + InputSnapshotID int64 + OutputMarkdown string + ReportJSON string + ActorKind string + ActorSnapshot ActorSnapshot + AuthorizedScope string + OnBehalfOf *int64 +} + +type CommitAgentDecision struct { + Decision string // commit|reject + Confirmed bool // required when risk high/critical + ExpectedInputSHA string + ExpectedOutputSHA string + Reason string + ActorUserID int64 + ActorSnapshot ActorSnapshot +}