feat: add coverage decision persistence
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- 000007_coverage_decisions down migration
|
||||
-- Drop support_cases first (child table), then coverage_decisions (parent table).
|
||||
|
||||
DROP TABLE IF EXISTS support_cases;
|
||||
DROP TABLE IF EXISTS coverage_decisions;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 000007_coverage_decisions up migration
|
||||
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coverage_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
evaluator_version TEXT NOT NULL DEFAULT 'v1',
|
||||
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
|
||||
reason_code TEXT NOT NULL CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer'
|
||||
)),
|
||||
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
|
||||
ON coverage_decisions (tenant_key, channel, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
|
||||
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_pending
|
||||
ON support_cases (status, created_at DESC) WHERE status = 'pending';
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000007_coverage_decisions migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read007Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000007 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "TOP_SCORE",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// REFERENCES turns(id)
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("coverage_decisions.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// verdict CHECK
|
||||
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
|
||||
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
|
||||
}
|
||||
// reason_code CHECK
|
||||
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
|
||||
if !strings.Contains(up, code) {
|
||||
t.Errorf("reason_code CHECK missing %q", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesSupportCasesTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007SupportCasesConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// FK to coverage_decisions
|
||||
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
|
||||
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
|
||||
}
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("support_cases.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
if !strings.Contains(up, "PENDING") {
|
||||
t.Error("status CHECK must include 'pending'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesQueryIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// Pending query index
|
||||
if !strings.Contains(up, "WHERE STATUS = 'PENDING'") {
|
||||
t.Error("up migration must create partial index on pending support_cases")
|
||||
}
|
||||
// Coverage decisions listing index
|
||||
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
|
||||
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
|
||||
}
|
||||
// Uncovered verdict index
|
||||
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
|
||||
t.Error("up migration must create partial index on uncovered coverage_decisions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageNoReservedWords(t *testing.T) {
|
||||
up := read007Migration(t, "up")
|
||||
// Check for bare "references" as column name in CREATE TABLE statements
|
||||
lines := strings.Split(up, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upperLine := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
|
||||
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoForbiddenOperations(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{
|
||||
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
|
||||
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR",
|
||||
} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
|
||||
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
|
||||
if supportAt < 0 || coverageAt < 0 {
|
||||
t.Fatal("down migration must drop both tables")
|
||||
}
|
||||
if supportAt > coverageAt {
|
||||
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
|
||||
// returns when it cannot find relevant information. These are matched as
|
||||
// substrings after trimming whitespace.
|
||||
var noAnswerPhrases = []string{
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无法确定",
|
||||
"暂无相关",
|
||||
"知识库中没有",
|
||||
}
|
||||
|
||||
// Evaluator classifies a successful turn into a coverage verdict.
|
||||
type Evaluator struct{}
|
||||
|
||||
// NewEvaluator creates a v1 Evaluator.
|
||||
func NewEvaluator() *Evaluator {
|
||||
return &Evaluator{}
|
||||
}
|
||||
|
||||
// Version returns the evaluator version, always "v1".
|
||||
func (e *Evaluator) Version() string {
|
||||
return EvaluatorVersion
|
||||
}
|
||||
|
||||
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
|
||||
// rules (in priority order):
|
||||
// 1. Failed/processing turns → ErrTurnNotSucceeded
|
||||
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
|
||||
// 3. Zero references (nil or empty) → uncovered_no_references
|
||||
// 4. Has references → covered_has_references
|
||||
//
|
||||
// v1 never uses a score threshold. TopScore captures the highest reference score
|
||||
// (0 if no references).
|
||||
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
if turn.Status != "succeeded" {
|
||||
return Decision{}, ErrTurnNotSucceeded
|
||||
}
|
||||
|
||||
// Normalize answer for no-answer phrase detection.
|
||||
trimmed := strings.TrimSpace(turn.Answer)
|
||||
|
||||
// Check for explicit no-answer phrases first — this takes priority
|
||||
// over reference presence.
|
||||
if isNoAnswer(trimmed) {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredExplicitNoAnswer,
|
||||
TopScore: maxScore(turn.References),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No references → uncovered.
|
||||
if len(turn.References) == 0 {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredNoReferences,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Has references → covered (v1 never uses score threshold).
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredHasReferences,
|
||||
TopScore: maxScore(turn.References),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isNoAnswer(answer string) bool {
|
||||
lower := strings.ToLower(answer)
|
||||
for _, phrase := range noAnswerPhrases {
|
||||
if strings.Contains(lower, strings.ToLower(phrase)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maxScore(refs []core.Reference) float64 {
|
||||
var max float64
|
||||
for _, r := range refs {
|
||||
if r.Score > max {
|
||||
max = r.Score
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
func TestEvaluatorVersion(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
if e.Version() != "v1" {
|
||||
t.Errorf("Evaluator version = %q, want v1", e.Version())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_RejectsFailedTurn(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
// Failed turn should be rejected/return error
|
||||
_, err := e.Evaluate(core.Turn{Status: "failed"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed turn, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
_, err := e.Evaluate(core.Turn{Status: "processing"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for processing turn, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
noAnswerPhrases := []string{
|
||||
"未找到相关结果",
|
||||
"没有找到你需要的信息",
|
||||
"无法确定该问题的答案",
|
||||
"暂无相关信息",
|
||||
"知识库中没有找到相关内容",
|
||||
"这个问题知识库中没有可用的信息",
|
||||
}
|
||||
for _, answer := range noAnswerPhrases {
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: answer,
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for answer %q: %v", answer, err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("answer %q: verdict = %s, want uncovered", answer, d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("answer %q: reason_code = %s, want %s", answer, d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但没有任何引用",
|
||||
References: []core.Reference{},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但引用为nil",
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_CoveredHasReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库中的信息,这个问题的答案是...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.75},
|
||||
{ID: "r2", Score: 0.92},
|
||||
{ID: "r3", Score: 0.60},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TopScore(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库中的信息...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.75},
|
||||
{ID: "r2", Score: 0.92},
|
||||
{ID: "r3", Score: 0.60},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.TopScore != 0.92 {
|
||||
t.Errorf("top_score = %f, want 0.92", d.TopScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "没有引用",
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.TopScore != 0 {
|
||||
t.Errorf("top_score = %f, want 0", d.TopScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
|
||||
// When answer contains explicit "no answer" phrase, it should be uncovered
|
||||
// even if references exist. No-answer detection takes priority.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "未找到相关结果,请换个方式提问",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.99},
|
||||
{ID: "r2", Score: 0.88},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("no-answer with references: verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据现有资料,知识库中没有对应说明,请联系技术支持。",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_DoesNotExpandBeyondApprovedNoAnswerPhrases(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "没有相关限制,设备可以正常运行。",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoScoreThreshold(t *testing.T) {
|
||||
// v1 evaluator must never use score threshold to determine covered/uncovered.
|
||||
// Even with very low score references, if there are references and no explicit
|
||||
// no-answer, it should be covered.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个回答,引用分数很低",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.01},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered (v1 never uses score threshold)", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_EmptyAnswerCovered(t *testing.T) {
|
||||
// Empty answer is not a no-answer phrase. With references, it's covered.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_DecisionHasTurnIdentity(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "turn-abc",
|
||||
TenantKey: "tenant-1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{{ID: "r1", Score: 0.5}},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.TurnID != "turn-abc" {
|
||||
t.Errorf("turn_id = %q, want turn-abc", d.TurnID)
|
||||
}
|
||||
if d.TenantKey != "tenant-1" {
|
||||
t.Errorf("tenant_key = %q, want tenant-1", d.TenantKey)
|
||||
}
|
||||
if d.Channel != "dingtalk" {
|
||||
t.Errorf("channel = %q, want dingtalk", d.Channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// Service evaluates turns and persists coverage decisions.
|
||||
type Service struct {
|
||||
evaluator *Evaluator
|
||||
repo Repository
|
||||
}
|
||||
|
||||
// NewService creates a coverage Service. repo must not be nil.
|
||||
func NewService(repo Repository) (*Service, error) {
|
||||
if repo == nil {
|
||||
return nil, errors.New("coverage service: repository must not be nil")
|
||||
}
|
||||
return &Service{
|
||||
evaluator: NewEvaluator(),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EvaluateAndPersist evaluates a turn and persists the resulting decision.
|
||||
// For uncovered verdicts the Repository atomically creates a pending
|
||||
// support case. The operation is idempotent — replaying the same
|
||||
// (tenant_key, channel, turn_id) is safe.
|
||||
//
|
||||
// Failed and processing turns are rejected with ErrTurnNotSucceeded.
|
||||
func (s *Service) EvaluateAndPersist(ctx context.Context, turn core.Turn) (Decision, error) {
|
||||
d, err := s.evaluator.Evaluate(turn)
|
||||
if err != nil {
|
||||
return Decision{}, fmt.Errorf("evaluate: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repo.SaveCoverageDecision(ctx, d); err != nil {
|
||||
return Decision{}, fmt.Errorf("save coverage decision: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// mockRepo implements Repository for testing.
|
||||
type mockRepo struct {
|
||||
saved []Decision
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRepo) SaveCoverageDecision(_ context.Context, d Decision) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.saved = append(m.saved, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewService_RejectsNilRepo(t *testing.T) {
|
||||
_, err := NewService(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil repo, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService_SucceedsWithRepo(t *testing.T) {
|
||||
s, err := NewService(&mockRepo{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_Covered(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, err := NewService(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-1",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库,答案是...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
if len(repo.saved) != 1 {
|
||||
t.Fatalf("expected 1 saved decision, got %d", len(repo.saved))
|
||||
}
|
||||
if repo.saved[0].Verdict != VerdictCovered {
|
||||
t.Errorf("saved verdict = %s, want covered", repo.saved[0].Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-2",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答但没有引用",
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
if len(repo.saved) != 1 {
|
||||
t.Fatalf("expected 1 saved decision, got %d", len(repo.saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-3",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "未找到相关结果",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_RejectsFailedTurn(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-4",
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
_, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed turn, got nil")
|
||||
}
|
||||
if len(repo.saved) != 0 {
|
||||
t.Errorf("expected 0 saved decisions for failed turn, got %d", len(repo.saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_PropagatesRepoError(t *testing.T) {
|
||||
repo := &mockRepo{err: ErrRepository}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-5",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from repo, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_DecisionHasEvaluatorVersion(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-6",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.EvaluatorVersion != EvaluatorVersion {
|
||||
t.Errorf("evaluator_version = %s, want %s", d.EvaluatorVersion, EvaluatorVersion)
|
||||
}
|
||||
if repo.saved[0].EvaluatorVersion != EvaluatorVersion {
|
||||
t.Errorf("saved evaluator_version = %s, want %s", repo.saved[0].EvaluatorVersion, EvaluatorVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package coverage implements the domain logic for evaluating whether a QA
|
||||
// turn is covered by the knowledge base and persisting the resulting decision.
|
||||
//
|
||||
// The Evaluator classifies a successful turn into one of three outcomes:
|
||||
// - covered_has_references: the answer uses knowledge-base references
|
||||
// - uncovered_no_references: the answer has no references
|
||||
// - uncovered_explicit_no_answer: the answer is an explicit "no answer" phrase
|
||||
//
|
||||
// The Service delegates persistence to a Repository. For uncovered turns it
|
||||
// atomically writes the decision and creates a pending support case. For
|
||||
// covered turns it only persists the decision. All writes are idempotent via
|
||||
// ON CONFLICT (tenant_key, channel, turn_id).
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Verdict values.
|
||||
const (
|
||||
VerdictCovered = "covered"
|
||||
VerdictUncovered = "uncovered"
|
||||
)
|
||||
|
||||
// ReasonCode values.
|
||||
const (
|
||||
ReasonCoveredHasReferences = "covered_has_references"
|
||||
ReasonUncoveredNoReferences = "uncovered_no_references"
|
||||
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
|
||||
)
|
||||
|
||||
// EvaluatorVersion is the current version of the evaluator.
|
||||
const EvaluatorVersion = "v1"
|
||||
|
||||
var (
|
||||
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
|
||||
ErrRepository = errors.New("coverage repository error")
|
||||
)
|
||||
|
||||
// Decision is the result of evaluating a single turn.
|
||||
type Decision struct {
|
||||
TurnID string
|
||||
TenantKey string
|
||||
Channel string
|
||||
EvaluatorVersion string
|
||||
Verdict string
|
||||
ReasonCode string
|
||||
TopScore float64
|
||||
}
|
||||
|
||||
// Repository persists coverage decisions and, when applicable, support cases.
|
||||
type Repository interface {
|
||||
// SaveCoverageDecision persists a decision. For uncovered verdicts it
|
||||
// must also atomically create a pending support case. The call is
|
||||
// idempotent — replayed decisions for the same (tenant_key, channel,
|
||||
// turn_id) must not create duplicates.
|
||||
SaveCoverageDecision(ctx context.Context, d Decision) error
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
)
|
||||
|
||||
// SaveCoverageDecision implements coverage.Repository. It writes the decision
|
||||
// and, for uncovered verdicts, atomically creates a pending support case in a
|
||||
// single transaction. Both writes are idempotent via ON CONFLICT DO NOTHING.
|
||||
func (s *Store) SaveCoverageDecision(ctx context.Context, d coverage.Decision) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return coverage.ErrRepository
|
||||
}
|
||||
|
||||
decisionID, err := newUUID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage decision id: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage tx begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var persistedDecisionID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO coverage_decisions
|
||||
(id, tenant_key, channel, turn_id, evaluator_version, verdict, reason_code, top_score)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (tenant_key, channel, turn_id)
|
||||
DO UPDATE SET id=coverage_decisions.id
|
||||
RETURNING id`,
|
||||
decisionID, d.TenantKey, d.Channel, d.TurnID,
|
||||
d.EvaluatorVersion, d.Verdict, d.ReasonCode, d.TopScore).Scan(&persistedDecisionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage insert decision: %w", err)
|
||||
}
|
||||
|
||||
// Only create support case for uncovered verdicts.
|
||||
if d.Verdict == coverage.VerdictUncovered {
|
||||
caseID, err := newUUID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage case id: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO support_cases
|
||||
(id, tenant_key, channel, turn_id, coverage_decision_id, status)
|
||||
VALUES($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (tenant_key, channel, turn_id) DO NOTHING`,
|
||||
caseID, d.TenantKey, d.Channel, d.TurnID, persistedDecisionID, "pending")
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage insert support case: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("coverage tx commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newUUID produces a 32-char hex id.
|
||||
func newUUID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%08x%04x%04x%04x%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Source-level contract tests for the coverage repository SQL.
|
||||
// No real database connection is used.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readCoverageSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("coverage.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read coverage.go: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func extractSQLFromCoverage(t *testing.T) []string {
|
||||
t.Helper()
|
||||
src := readCoverageSource(t)
|
||||
var sqls []string
|
||||
re := regexp.MustCompile("`([^`]*)`")
|
||||
for _, m := range re.FindAllStringSubmatch(src, -1) {
|
||||
upper := strings.ToUpper(m[1])
|
||||
if strings.Contains(upper, "SELECT") || strings.Contains(upper, "UPDATE") ||
|
||||
strings.Contains(upper, "INSERT") || strings.Contains(upper, "DELETE") {
|
||||
sqls = append(sqls, m[1])
|
||||
}
|
||||
}
|
||||
return sqls
|
||||
}
|
||||
|
||||
func TestCoverageRepoInsertsIntoCoverageDecisions(t *testing.T) {
|
||||
// Find the INSERT INTO coverage_decisions SQL in coverage.go
|
||||
sqls := extractSQLFromCoverage(t)
|
||||
var found string
|
||||
for _, sql := range sqls {
|
||||
upper := strings.ToUpper(sql)
|
||||
if strings.Contains(upper, "INSERT INTO COVERAGE_DECISIONS") {
|
||||
found = sql
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == "" {
|
||||
t.Fatal("INSERT INTO coverage_decisions SQL not found in coverage.go")
|
||||
}
|
||||
|
||||
requiredCols := []string{
|
||||
"id", "tenant_key", "channel", "turn_id",
|
||||
"evaluator_version", "verdict", "reason_code", "top_score",
|
||||
}
|
||||
upper := strings.ToUpper(found)
|
||||
for _, col := range requiredCols {
|
||||
if !strings.Contains(upper, strings.ToUpper(col)) {
|
||||
t.Errorf("INSERT INTO coverage_decisions missing column %q", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoReplayReturnsPersistedDecisionIDForSupportCase(t *testing.T) {
|
||||
sqls := extractSQLFromCoverage(t)
|
||||
var decisionInsert string
|
||||
for _, sql := range sqls {
|
||||
if strings.Contains(strings.ToUpper(sql), "INSERT INTO COVERAGE_DECISIONS") {
|
||||
decisionInsert = strings.ToUpper(sql)
|
||||
break
|
||||
}
|
||||
}
|
||||
if decisionInsert == "" {
|
||||
t.Fatal("INSERT INTO coverage_decisions SQL not found")
|
||||
}
|
||||
if !strings.Contains(decisionInsert, "ON CONFLICT (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Fatal("decision upsert must use the scoped idempotency key")
|
||||
}
|
||||
if !strings.Contains(decisionInsert, "DO UPDATE") || !strings.Contains(decisionInsert, "RETURNING ID") {
|
||||
t.Fatal("decision upsert must return the persisted ID on both insert and replay")
|
||||
}
|
||||
|
||||
src := readCoverageSource(t)
|
||||
if !strings.Contains(src, "persistedDecisionID") {
|
||||
t.Fatal("support case must use the persisted decision ID")
|
||||
}
|
||||
if !strings.Contains(src, "d.TurnID, persistedDecisionID") {
|
||||
t.Fatal("support case INSERT must reference persistedDecisionID, not a new conflicting ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoInsertsSupportCaseOnlyForUncovered(t *testing.T) {
|
||||
sqls := extractSQLFromCoverage(t)
|
||||
var insertSQL string
|
||||
for _, sql := range sqls {
|
||||
upper := strings.ToUpper(sql)
|
||||
if strings.Contains(upper, "INSERT INTO SUPPORT_CASES") {
|
||||
insertSQL = sql
|
||||
break
|
||||
}
|
||||
}
|
||||
if insertSQL == "" {
|
||||
t.Fatal("INSERT INTO support_cases SQL not found in coverage.go")
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(insertSQL)
|
||||
requiredCols := []string{
|
||||
"id", "tenant_key", "channel", "turn_id",
|
||||
"coverage_decision_id", "status",
|
||||
}
|
||||
for _, col := range requiredCols {
|
||||
if !strings.Contains(upper, strings.ToUpper(col)) {
|
||||
t.Errorf("INSERT INTO support_cases missing column %q", col)
|
||||
}
|
||||
}
|
||||
// Must have ON CONFLICT for idempotency
|
||||
if !strings.Contains(upper, "ON CONFLICT") {
|
||||
t.Error("support_cases INSERT must use ON CONFLICT for idempotency")
|
||||
}
|
||||
if !strings.Contains(upper, "DO NOTHING") {
|
||||
t.Error("support_cases ON CONFLICT must use DO NOTHING")
|
||||
}
|
||||
|
||||
// Check that the support_cases INSERT is guarded by a verdict check
|
||||
src := readCoverageSource(t)
|
||||
if !strings.Contains(src, "VerdictUncovered") {
|
||||
t.Error("support_cases INSERT must be guarded by uncovered verdict check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoUsesSingleTransaction(t *testing.T) {
|
||||
src := readCoverageSource(t)
|
||||
// Must use BeginTx
|
||||
if !strings.Contains(src, "BeginTx") {
|
||||
t.Error("SaveCoverageDecision must use a transaction (BeginTx)")
|
||||
}
|
||||
// Must commit
|
||||
if !strings.Contains(src, "Commit(ctx)") {
|
||||
t.Error("SaveCoverageDecision must commit the transaction")
|
||||
}
|
||||
// Must rollback on error
|
||||
if !strings.Contains(src, "Rollback(ctx)") {
|
||||
t.Error("SaveCoverageDecision must rollback on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoNoReservedWords(t *testing.T) {
|
||||
sqls := extractSQLFromCoverage(t)
|
||||
for _, sql := range sqls {
|
||||
upper := strings.ToUpper(sql)
|
||||
// Check for bare "references" as column name (not references_json)
|
||||
// Remove references_json first
|
||||
scrubbed := regexp.MustCompile("(?i)\\breferences_json\\b").ReplaceAllString(upper, "")
|
||||
// Also remove REFERENCES as foreign key keyword
|
||||
scrubbed = regexp.MustCompile("(?i)\\bREFERENCES\\s+(COVERAGE_DECISIONS|TURNS)\\b").ReplaceAllString(scrubbed, "")
|
||||
if regexp.MustCompile("(?i)\\bREFERENCES\\b").MatchString(scrubbed) {
|
||||
t.Errorf("SQL uses reserved word 'references' as identifier: %s", sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoNoQuestionAnswerBodies(t *testing.T) {
|
||||
sqls := extractSQLFromCoverage(t)
|
||||
joined := strings.ToUpper(strings.Join(sqls, " "))
|
||||
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
|
||||
if strings.Contains(joined, forbidden) {
|
||||
t.Errorf("coverage repository SQL must not dupe question/answer/reference bodies: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoSavesTenantChannelTurnScope(t *testing.T) {
|
||||
src := readCoverageSource(t)
|
||||
// Must pass tenant, channel, turn values to the INSERT
|
||||
if !strings.Contains(src, "d.TenantKey") || !strings.Contains(src, "d.Channel") || !strings.Contains(src, "d.TurnID") {
|
||||
t.Error("SaveCoverageDecision must use d.TenantKey, d.Channel, d.TurnID for tenant/channel/turn scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageRepoNilSafe(t *testing.T) {
|
||||
src := readCoverageSource(t)
|
||||
// Must check for nil store/pool
|
||||
if !strings.Contains(src, "s == nil") || !strings.Contains(src, "s.pool == nil") {
|
||||
t.Error("SaveCoverageDecision must guard against nil store/pool")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user