Verify span deletion through complete CLI workflows

This commit is contained in:
2026-09-25 02:56:44 +00:00
parent 2a58db04c2
commit 24b899281c
8 changed files with 271 additions and 11 deletions

View File

@@ -11,6 +11,7 @@ import (
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"reflect"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -20,6 +21,7 @@ import (
"gitea.maximumdirect.net/eric/audita/internal/cli" "gitea.maximumdirect.net/eric/audita/internal/cli"
"gitea.maximumdirect.net/eric/audita/internal/core/reporting" "gitea.maximumdirect.net/eric/audita/internal/core/reporting"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -106,7 +108,30 @@ func (p *fixtureProvider) serve(w http.ResponseWriter, r *http.Request) {
content = `{"corrections":[{"id":1,"original_text":"absent text","corrected_text":"X","confidence":0.99},{"id":1,"original_text":"hello world","corrected_text":"Hello world","confidence":0.99}]}` content = `{"corrections":[{"id":1,"original_text":"absent text","corrected_text":"X","confidence":0.99},{"id":1,"original_text":"hello world","corrected_text":"Hello world","confidence":0.99}]}`
} }
} }
if strings.HasPrefix(p.mode, "span-") {
switch p.mode {
case "span-clean", "span-filter":
content = `{"corrections":[{"id":1,"original_text":"uh ","corrected_text":"","confidence":0.99},{"id":2,"original_text":"I I","corrected_text":"I","confidence":0.99}]}`
if p.mode == "span-filter" {
content = `{"corrections":[{"id":1,"original_text":"uh ","corrected_text":"","confidence":0.99},{"id":2,"original_text":"I I","corrected_text":"I","confidence":0.99},{"id":3,"original_text":"uh","corrected_text":"","confidence":0.99}]}`
}
case "span-spacing":
content = `{"corrections":[{"id":1,"original_text":" ","corrected_text":" ","confidence":0.99}]}`
case "span-negative":
content = `{"corrections":[{"id":1,"original_text":"not ","corrected_text":"","confidence":0.99}]}`
}
if validation { if validation {
if p.mode == "span-clean" || p.mode == "span-filter" {
content = `{"validations":[{"correction_index":0,"approved":true,"confidence":0.99,"reason":"ok"},{"correction_index":1,"approved":true,"confidence":0.99,"reason":"ok"}]}`
} else {
content = `{"validations":[{"correction_index":0,"approved":true,"confidence":0.99,"reason":"ok"}]}`
}
if p.mode == "span-negative" && validationCall == 2 {
content = `{"validations":[{"correction_index":0,"approved":false,"confidence":0.99,"reason":"would reverse meaning"}]}`
}
}
}
if validation && !strings.HasPrefix(p.mode, "span-") {
content = `{"validations":[{"correction_index":0,"approved":true,"confidence":0.99,"reason":"ok"}]}` content = `{"validations":[{"correction_index":0,"approved":true,"confidence":0.99,"reason":"ok"}]}`
if p.mode == "repair-filter-later-failure" { if p.mode == "repair-filter-later-failure" {
content = `{"validations":[{"correction_index":1,"approved":true,"confidence":0.99,"reason":"ok"}]}` content = `{"validations":[{"correction_index":1,"approved":true,"confidence":0.99,"reason":"ok"}]}`
@@ -155,7 +180,13 @@ func newProcessFixture(t *testing.T, mode, modules string) processFixture {
if mode == "repair-filter-later-failure" || mode == "invalid-decision-after-repair" { if mode == "repair-filter-later-failure" || mode == "invalid-decision-after-repair" {
repairs = 1 repairs = 1
} }
if strings.HasPrefix(mode, "span-") {
repairs = 1
}
configText := fmt.Sprintf("version: 2\nbackends:\n local:\n endpoint: %s/v1\n credential: AUDITA_TEST_PROVIDER_KEY\nllm:\n proposal_profile: proposal\n validation_profile: validation\n transient_retries: 0\n repair_attempts: %d\npipeline:\n modules: %s\ndiagnostics:\n work_dir: %s\n", server.URL, repairs, modules, filepath.Join(dir, "runs")) configText := fmt.Sprintf("version: 2\nbackends:\n local:\n endpoint: %s/v1\n credential: AUDITA_TEST_PROVIDER_KEY\nllm:\n proposal_profile: proposal\n validation_profile: validation\n transient_retries: 0\n repair_attempts: %d\npipeline:\n modules: %s\ndiagnostics:\n work_dir: %s\n", server.URL, repairs, modules, filepath.Join(dir, "runs"))
if strings.HasPrefix(mode, "span-") {
configText += "validation:\n max_prompt_tokens: 4096\nchunking:\n target_sections: 1\n max_section_tokens: 8192\n min_section_tokens: 1\nnormalization:\n max_segment_gap: 0\n"
}
if err := os.WriteFile(configPath, []byte(configText), 0600); err != nil { if err := os.WriteFile(configPath, []byte(configText), 0600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -208,6 +239,219 @@ func (f processFixture) errors(t *testing.T) reporting.ErrorsDocument {
return document return document
} }
func (f processFixture) recoveries(t *testing.T) reporting.RecoveriesDocument {
t.Helper()
raw, err := os.ReadFile(filepath.Join(f.dir, "recoveries.yml"))
if err != nil {
t.Fatal(err)
}
var document reporting.RecoveriesDocument
if err := yaml.Unmarshal(raw, &document); err != nil {
t.Fatal(err)
}
return document
}
func (f processFixture) setSegments(t *testing.T, texts ...string) {
t.Helper()
segments := make([]schema.Segment, len(texts))
for i, value := range texts {
segments[i] = schema.Segment{ID: i + 1, Speaker: "A", Start: float64(i * 10), End: float64(i*10 + 1), Text: value, Categories: []string{"dialogue"}}
}
raw, err := json.Marshal(segments)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f.transcript, raw, 0600); err != nil {
t.Fatal(err)
}
}
func (f processFixture) readTranscript(t *testing.T, path string) *schema.Transcript {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
transcript, err := schema.ParseTranscriptJSON(raw)
if err != nil {
t.Fatal(err)
}
return transcript
}
func (f processFixture) debugArtifacts(t *testing.T) (*schema.Transcript, []map[string]json.RawMessage) {
t.Helper()
dirs, err := filepath.Glob(filepath.Join(f.dir, "runs", "run-*"))
if err != nil || len(dirs) != 1 {
t.Fatalf("expected one debug run: %v %v", dirs, err)
}
normalized := f.readTranscript(t, filepath.Join(dirs[0], "normalized-transcript.json"))
raw, err := os.ReadFile(filepath.Join(dirs[0], "correction-ledger.json"))
if err != nil {
t.Fatal(err)
}
var ledger []map[string]json.RawMessage
if err := json.Unmarshal(raw, &ledger); err != nil {
t.Fatal(err)
}
return normalized, ledger
}
func assertSpanMetadata(t *testing.T, normalized, output *schema.Transcript) {
t.Helper()
if len(normalized.Segments) != len(output.Segments) {
t.Fatalf("segment membership changed: normalized=%+v output=%+v", normalized, output)
}
for i := range normalized.Segments {
normalized.Segments[i].Text = output.Segments[i].Text
}
if !reflect.DeepEqual(normalized, output) {
t.Fatalf("segment metadata/order changed: normalized=%+v output=%+v", normalized, output)
}
}
func ledgerDisposition(entry map[string]json.RawMessage) string {
var value string
_ = json.Unmarshal(entry["disposition"], &value)
return value
}
func ledgerHasDecision(t *testing.T, entry map[string]json.RawMessage, field, key string, approved bool) bool {
t.Helper()
var decisions []struct {
ValidatorKey string `json:"validator_key"`
Approved bool `json:"approved"`
}
if err := json.Unmarshal(entry[field], &decisions); err != nil {
t.Fatal(err)
}
for _, decision := range decisions {
if decision.ValidatorKey == key && decision.Approved == approved {
return true
}
}
return false
}
func (f processFixture) assertNoStructuralRepairs(t *testing.T, wantCalls int) {
t.Helper()
dirs, err := filepath.Glob(filepath.Join(f.dir, "runs", "run-*"))
if err != nil || len(dirs) != 1 {
t.Fatalf("debug run: %v %v", dirs, err)
}
raw, err := os.ReadFile(filepath.Join(dirs[0], "llm-execution.json"))
if err != nil {
t.Fatal(err)
}
var execution struct {
Calls []struct {
Summary struct {
ProviderAttempts int `json:"provider_attempts"`
StructuralRepairs int `json:"structural_repairs"`
} `json:"summary"`
} `json:"calls"`
}
if err := json.Unmarshal(raw, &execution); err != nil {
t.Fatal(err)
}
if len(execution.Calls) != wantCalls || f.provider.callCount() != wantCalls {
t.Fatalf("unexpected generation counts: calls=%d provider=%d", len(execution.Calls), f.provider.callCount())
}
for _, call := range execution.Calls {
if call.Summary.ProviderAttempts != 1 || call.Summary.StructuralRepairs != 0 {
t.Fatalf("unexpected structural repair: %+v", call)
}
}
}
func TestSubprocessAppliesSpanDeletionAndRepetitionCleanup(t *testing.T) {
f := newProcessFixture(t, "span-clean", "[spoken_word]")
f.setSegments(t, "uh hello", "I I think")
code, stdout, stderr := f.run(t, true, "--debug")
if code != 0 || stdout != "" || !strings.Contains(stderr, "2 corrections applied; 0 work units with errors; 0 recoveries") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr)
}
output := f.readTranscript(t, f.output)
normalized, ledger := f.debugArtifacts(t)
assertSpanMetadata(t, normalized, output)
if len(output.Segments) != 2 || output.Segments[0].Text != "hello" || output.Segments[1].Text != "I think" {
t.Fatalf("unexpected corrected transcript: %+v", output)
}
if len(ledger) != 2 || ledgerDisposition(ledger[0]) != "applied" || ledgerDisposition(ledger[1]) != "applied" || string(ledger[0]["proposed_corrected_text"]) != `""` || string(ledger[0]["applied_corrected_text"]) != `""` {
t.Fatalf("deletion missing from ledger: %+v", ledger)
}
errors, recoveries := f.errors(t), f.recoveries(t)
if len(errors.Errors) != 0 || len(recoveries.Recoveries) != 0 || *errors.Summary.CorrectionsApplied != 2 || *errors.Summary.WorkUnitsCompleted != 1 || *recoveries.Summary.CorrectionsApplied != 2 {
t.Fatalf("report totals disagree: errors=%+v recoveries=%+v", errors, recoveries)
}
f.assertNoStructuralRepairs(t, 3)
}
func TestSubprocessAppliesWhitespaceOnlySpan(t *testing.T) {
f := newProcessFixture(t, "span-spacing", "[grammar]")
f.setSegments(t, "hello world")
code, _, stderr := f.run(t, true, "--debug")
if code != 0 || !strings.Contains(stderr, "1 corrections applied; 0 work units with errors; 0 recoveries") {
t.Fatalf("code=%d stderr=%q", code, stderr)
}
output := f.readTranscript(t, f.output)
normalized, _ := f.debugArtifacts(t)
assertSpanMetadata(t, normalized, output)
if len(output.Segments) != 1 || output.Segments[0].Text != "hello world" || len(f.errors(t).Errors) != 0 || len(f.recoveries(t).Recoveries) != 0 {
t.Fatalf("spacing correction failed: %+v", output)
}
f.assertNoStructuralRepairs(t, 3)
}
func TestSubprocessFiltersBlankResultAlongsideApprovedCorrections(t *testing.T) {
f := newProcessFixture(t, "span-filter", "[spoken_word]")
f.setSegments(t, "uh hello", "I I think", "uh")
code, _, stderr := f.run(t, true, "--debug")
if code != 0 || !strings.Contains(stderr, "2 corrections applied; 0 work units with errors; 1 recoveries") {
t.Fatalf("code=%d stderr=%q", code, stderr)
}
output := f.readTranscript(t, f.output)
normalized, ledger := f.debugArtifacts(t)
assertSpanMetadata(t, normalized, output)
if len(output.Segments) != 3 || output.Segments[0].Text != "hello" || output.Segments[1].Text != "I think" || output.Segments[2].Text != "uh" {
t.Fatalf("filtered segment or survivors wrong: %+v", output)
}
recoveries := f.recoveries(t)
if len(f.errors(t).Errors) != 0 || len(recoveries.Recoveries) != 1 || recoveries.Recoveries[0].Operation != "proposal" || len(recoveries.Recoveries[0].Reasons) != 1 || recoveries.Recoveries[0].Reasons[0] != "proposals_filtered" || len(recoveries.Recoveries[0].FilteredProposals) != 1 || recoveries.Recoveries[0].FilteredProposals[0].Reason != "empty_resulting_segment" || *recoveries.Summary.ProposalResponsesWithFiltering != 1 {
t.Fatalf("filtering recovery wrong: %+v", recoveries)
}
if len(ledger) != 3 || ledgerDisposition(ledger[2]) != "rejected" || string(ledger[2]["proposed_corrected_text"]) != `""` {
t.Fatalf("filtered deletion evidence missing: %+v", ledger)
}
if !ledgerHasDecision(t, ledger[2], "deterministic_validator_decisions", "non_empty_resulting_segment", false) {
t.Fatalf("renamed validator missing from evidence: %+v", ledger[2])
}
f.assertNoStructuralRepairs(t, 3)
}
func TestSubprocessRespectsNegativeMeaningReviewOnDeletion(t *testing.T) {
f := newProcessFixture(t, "span-negative", "[grammar]")
f.setSegments(t, "not good")
code, _, stderr := f.run(t, true, "--debug")
if code != 0 || !strings.Contains(stderr, "0 corrections applied; 0 work units with errors; 0 recoveries") {
t.Fatalf("code=%d stderr=%q", code, stderr)
}
output := f.readTranscript(t, f.output)
normalized, ledger := f.debugArtifacts(t)
assertSpanMetadata(t, normalized, output)
if len(output.Segments) != 1 || output.Segments[0].Text != "not good" || len(f.errors(t).Errors) != 0 || len(f.recoveries(t).Recoveries) != 0 {
t.Fatalf("negative review changed outcome: %+v", output)
}
if len(ledger) != 1 || ledgerDisposition(ledger[0]) != "rejected" || string(ledger[0]["proposed_corrected_text"]) != `""` {
t.Fatalf("rejection evidence missing: %+v", ledger)
}
if _, present := ledger[0]["applied_corrected_text"]; present || !ledgerHasDecision(t, ledger[0], "llm_validator_decisions", "editorial_review", true) || !ledgerHasDecision(t, ledger[0], "llm_validator_decisions", "meaning_reversal_review", false) {
t.Fatalf("negative decision not recorded: %+v", ledger[0])
}
f.assertNoStructuralRepairs(t, 3)
}
func TestSubprocessDefaultFileOutputAndReports(t *testing.T) { func TestSubprocessDefaultFileOutputAndReports(t *testing.T) {
f := newProcessFixture(t, "normal", "[grammar]") f := newProcessFixture(t, "normal", "[grammar]")
code, stdout, stderr := f.run(t, true) code, stdout, stderr := f.run(t, true)

View File

@@ -38,6 +38,6 @@ path. Prepared runtime metadata belongs in diagnostics rather than the registry.
The structured-call contract carries prompt ID/version, string variables, The structured-call contract carries prompt ID/version, string variables,
response-schema key, and logical-call attribution. Module and validator callers response-schema key, and logical-call attribution. Module and validator callers
are being migrated in their respective work units. See the pass these values through the role-bound adapter. See the
[LLM runtime](llm-runtime.md) for runtime assembly and [LLM runtime](llm-runtime.md) for runtime assembly and
[configuration](../config.md) for operator-selected profiles. [configuration](../config.md) for operator-selected profiles.

View File

@@ -125,3 +125,11 @@ capacity and usage fields retain known/complete flags. Debug runs write
material. See [operations](../operations.md#diagnostics-directory) for placement material. See [operations](../operations.md#diagnostics-directory) for placement
and paths, [configuration](../config.md) for all settings, and the and paths, [configuration](../config.md) for all settings, and the
[CLI reference](../cli.md) for current flags. [CLI reference](../cli.md) for current flags.
Debug ledger consumers should recognize the deterministic validator key
`non_empty_resulting_segment` in place of `non_empty_corrected_text`. The
`empty_resulting_segment` reason is unchanged. Candidate-backed ledger entries
now include `proposed_corrected_text: ""` for an empty replacement, and applied
deletions also include `applied_corrected_text: ""`; omitted fields still mean
there was no candidate or no applied edit, respectively. See the
[debug artifact contract](../integrations/output-files.md#debug-only-files).

View File

@@ -2,7 +2,7 @@
**Last updated:** 2026-09-25 **Last updated:** 2026-09-25
**Status:** In progress; Stages 1–3 complete. **Status:** Implemented; all stages complete.
**Source baseline:** `a83a518` (feature roadmap); application baseline `0b6cd98`. **Source baseline:** `a83a518` (feature roadmap); application baseline `0b6cd98`.
**Audience:** gpt-5.6-terra coding agent. **Audience:** gpt-5.6-terra coding agent.
@@ -12,7 +12,8 @@ Implement [Consistent Span Deletion](span-deletion.md). That document owns the
product intent, target semantics, and acceptance criteria. This document owns the product intent, target semantics, and acceptance criteria. This document owns the
concrete decisions, ordered work, and validation. Execute Stage 1 through Stage 4 concrete decisions, ordered work, and validation. Execute Stage 1 through Stage 4
in order. Record actual completion and checks beneath each stage when implemented. in order. Record actual completion and checks beneath each stage when implemented.
Do not interpret this ready status as implemented behavior. The completion evidence below records the implemented behavior; the source
baseline remains historical.
Read [development guidance](../development.md) and all [agent](../policy/agents.md), Read [development guidance](../development.md) and all [agent](../policy/agents.md),
[architecture](../policy/architecture.md), [documentation](../policy/documentation.md), [architecture](../policy/architecture.md), [documentation](../policy/documentation.md),
@@ -351,6 +352,13 @@ schema/adapter tests, domain/application tests, serialized debug tests, and
representative complete CLI workflows. The workspace is committed and the docs representative complete CLI workflows. The workspace is committed and the docs
describe implemented behavior without claiming whole-segment removal. describe implemented behavior without claiming whole-segment removal.
**Completed 2026-09-25.** Four offline CLI workflows verified clean deletion,
literal spacing, partial success with blank-result filtering, and an ordinary
negative meaning-review decision. Metadata was checked against normalized
input; the existing runner test covers fixed section membership and order.
`go test ./...` passed. Documentation references, affected links, and whitespace
were checked. Whole-segment removal remains deferred.
## Open Questions ## Open Questions
None requiring a product decision remain. Validator names, schema/prompt identity, None requiring a product decision remain. Validator names, schema/prompt identity,

View File

@@ -4,8 +4,8 @@
**Status:** Production implementation complete. ConfigKit v0.4.0 and PromptKit **Status:** Production implementation complete. ConfigKit v0.4.0 and PromptKit
v0.11.0 are adopted; [historical implementation evidence](https://gitea.maximumdirect.net/eric/audita/src/commit/cb405f6/docs/roadmap/implementation.md) records the v0.11.0 are adopted; [historical implementation evidence](https://gitea.maximumdirect.net/eric/audita/src/commit/cb405f6/docs/roadmap/implementation.md) records the
completed checks. The current `implementation.md` is a separate resilience completed checks. The current `implementation.md` covers span deletion instead.
roadmap. Release compliance and publication remain separate work. Release compliance and publication remain separate work.
## Goal and Ownership ## Goal and Ownership

View File

@@ -3,7 +3,7 @@
**Reviewed:** 2026-09-25, source commit `ec78b5a`. **Reviewed:** 2026-09-25, source commit `ec78b5a`.
**Scope:** Review of the resilience implementation for unintentionally strengthened **Scope:** Review of the resilience implementation for unintentionally strengthened
invariants, contracts, guarantees, and test requirements, compared with invariants, contracts, guarantees, and test requirements, compared with
[resilience.md](resilience.md), [implementation.md](implementation.md), and the [resilience.md](resilience.md), [historical implementation plan](https://gitea.maximumdirect.net/eric/audita/src/commit/a83a518/docs/roadmap/implementation.md), and the
[agent policy](../policy/agents.md). [agent policy](../policy/agents.md).
**Status:** R11–R14 addressed. The findings below describe the reviewed source; **Status:** R11–R14 addressed. The findings below describe the reviewed source;
see the resolution record for the implemented changes and verification. see the resolution record for the implemented changes and verification.

View File

@@ -4,7 +4,7 @@
**Status:** Implemented. This document records the selected behavior and user **Status:** Implemented. This document records the selected behavior and user
intent. Concrete design decisions and completion evidence are in intent. Concrete design decisions and completion evidence are in
[implementation.md](implementation.md). [historical implementation plan](https://gitea.maximumdirect.net/eric/audita/src/commit/a83a518/docs/roadmap/implementation.md).
## Purpose and User Intent ## Purpose and User Intent
@@ -283,7 +283,7 @@ volume, and a short reason breakdown. Detailed inspection belongs in YAML or deb
## Implementation Reference ## Implementation Reference
The product decisions are settled. [implementation.md](implementation.md) defines The product decisions are settled. The [historical implementation plan](https://gitea.maximumdirect.net/eric/audita/src/commit/a83a518/docs/roadmap/implementation.md) defines
the selected settings and precedence, YAML schemas, counts and ordering, interrupted the selected settings and precedence, YAML schemas, counts and ordering, interrupted
work presentation, destination checks, and output finalization. It also owns the work presentation, destination checks, and output finalization. It also owns the
ordered implementation stages and validation work. This document remains the ordered implementation stages and validation work. This document remains the

View File

@@ -2,7 +2,7 @@
**Last updated:** 2026-09-25 **Last updated:** 2026-09-25
**Status:** Accepted target; not implemented. This document owns the desired **Status:** Implemented. This document owns the desired
behavior and user intent. [implementation.md](implementation.md) owns the concrete behavior and user intent. [implementation.md](implementation.md) owns the concrete
steps, selected implementation details, and completion evidence. steps, selected implementation details, and completion evidence.
@@ -19,7 +19,7 @@ authorize whole-segment removal, broaden module responsibilities, or introduce
new recovery machinery. The accepted [resilience behavior](resilience.md) remains new recovery machinery. The accepted [resilience behavior](resilience.md) remains
in effect, including partial success, fixed chunks, and validation before mutation. in effect, including partial success, fixed chunks, and validation before mutation.
## Current Baseline ## Historical Baseline (`0b6cd98`)
The source reviewed at `0b6cd98` contains a mismatch: The source reviewed at `0b6cd98` contains a mismatch: