feat: store finding as structured title/fix/sources, not flat message

Migration 0002 replaces finding.message with title/fix/sources (a
Postgres text[]). A finding needs to render into the dossier the way it
looked at the moment it was raised — referencing the current rules/*.yaml
by rule_id+version isn't safe once that file is edited for a later
version, since old wording isn't kept around as a separate live file.
Added as a new migration rather than editing 0001, since that's already
applied on the test server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 14:39:14 +02:00
parent e0218337e7
commit 72005fc326
5 changed files with 42 additions and 16 deletions

View File

@@ -128,7 +128,7 @@ func TestFindingCRUDAndSupersedes(t *testing.T) {
t.Fatalf("CreateSubmission: %v", err)
}
old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung")
old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung", "alte Korrektur", []string{"§ 5a Abs. 4 UWG"})
if err != nil {
t.Fatalf("CreateFinding (old): %v", err)
}
@@ -140,11 +140,14 @@ func TestFindingCRUDAndSupersedes(t *testing.T) {
if len(current) != 1 || current[0].ID != old.ID {
t.Fatalf("expected exactly the old finding before any correction, got %+v", current)
}
if len(current[0].Sources) != 1 || current[0].Sources[0] != "§ 5a Abs. 4 UWG" {
t.Fatalf("Sources = %v, want [§ 5a Abs. 4 UWG]", current[0].Sources)
}
// Korrektur: neue Zeile, die die alte per supersedes ersetzt.
_, err = s.Pool.Exec(ctx, `
INSERT INTO finding (submission_id, rule_id, rule_version, severity, message, supersedes)
VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', $2)
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources, supersedes)
VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', 'neue Korrektur', '{}', $2)
`, sub.ID, old.ID)
if err != nil {
t.Fatalf("insert superseding finding: %v", err)
@@ -157,7 +160,7 @@ func TestFindingCRUDAndSupersedes(t *testing.T) {
if len(current) != 1 {
t.Fatalf("expected exactly one current finding after a correction, got %d: %+v", len(current), current)
}
if current[0].Message != "korrigierte Fassung" {
if current[0].Title != "korrigierte Fassung" {
t.Fatalf("expected the corrected finding to be current, got %+v", current[0])
}
}

View File

@@ -9,6 +9,11 @@ import (
// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only:
// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht
// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde).
// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert
// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein
// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr
// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des
// Findings ist der Beweis, kein Verweis darauf.
type Finding struct {
ID string
SubmissionID string
@@ -16,19 +21,21 @@ type Finding struct {
RuleID string
RuleVersion int
Severity string
Message string
Title string
Fix string
Sources []string
CreatedAt time.Time
}
// CreateFinding speichert ein Finding.
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, message string) (Finding, error) {
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
var f Finding
err := s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, message)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, message, created_at
`, submissionID, extractionID, ruleID, ruleVersion, severity, message).Scan(
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Message, &f.CreatedAt,
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at
`, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan(
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt,
)
if err != nil {
return Finding{}, fmt.Errorf("store: create finding: %w", err)
@@ -41,7 +48,7 @@ func (s *Store) CreateFinding(ctx context.Context, submissionID string, extracti
// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes).
func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) {
rows, err := s.Pool.Query(ctx, `
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.message, f.created_at
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at
FROM finding f
WHERE f.submission_id = $1
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
@@ -55,7 +62,7 @@ func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([
var findings []Finding
for rows.Next() {
var f Finding
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Message, &f.CreatedAt); err != nil {
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan finding: %w", err)
}
findings = append(findings, f)

View File

@@ -0,0 +1,5 @@
ALTER TABLE finding DROP COLUMN title;
ALTER TABLE finding DROP COLUMN fix;
ALTER TABLE finding DROP COLUMN sources;
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;

View File

@@ -0,0 +1,11 @@
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
ALTER TABLE finding DROP COLUMN message;
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;

View File

@@ -71,15 +71,15 @@ func TestFindingIsAppendOnly(t *testing.T) {
var findingID string
err = s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, rule_id, rule_version, severity, message)
VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung')
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources)
VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}')
RETURNING id
`, submissionID).Scan(&findingID)
if err != nil {
t.Fatalf("insert finding: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE finding SET message = 'geändert' WHERE id = $1`, findingID)
_, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID)
if err == nil {
t.Fatal("expected UPDATE on finding to be rejected, but it succeeded")
}