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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user