feat: add coverage decision persistence

This commit is contained in:
姚章浩
2026-07-22 12:00:32 +08:00
parent 4366c018f9
commit 5b5865dc07
10 changed files with 1167 additions and 0 deletions
@@ -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)
}
}
}
+107
View File
@@ -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
}
+298
View File
@@ -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)
}
}
+45
View File
@@ -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
}
+190
View File
@@ -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)
}
}
+59
View File
@@ -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
}
+75
View File
@@ -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")
}
}