Files
weichen b5a782bb38 feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards
Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
2026-09-25 20:14:10 +00:00

226 lines
7.7 KiB
Go

package intent_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/maturity"
)
type goldenFile struct {
Version string `json:"version"`
Cases []goldenCase `json:"cases"`
}
type goldenCase struct {
ID string `json:"id"`
Input string `json:"input"`
Snapshot *goldenSnapshot `json:"snapshot"`
Pending *goldenPending `json:"pending"`
ExpectP0 *goldenExpect `json:"expect_phase0"`
ExpectP1 *goldenExpect `json:"expect_phase1"`
}
type goldenSnapshot struct {
UserDocRole string `json:"user_doc_role"`
DocLifecycleState string `json:"doc_lifecycle_state"`
ActiveDocID string `json:"active_doc_id"`
CurrentStage string `json:"current_stage"`
}
type goldenPending struct {
ExpectedIntent string `json:"expected_intent"`
AllowAffirmative bool `json:"allow_affirmative"`
DefaultOption *goldenExpectOption `json:"default_option"`
}
type goldenExpectOption struct {
OptionID string `json:"option_id"`
Title string `json:"title"`
Intent string `json:"intent"`
Slots map[string]string `json:"slots"`
}
type goldenExpect struct {
LegacyIntent string `json:"legacy_intent"`
Branch string `json:"branch"`
RequiresClarifying *bool `json:"requires_clarifying"`
MustNotWrite bool `json:"must_not_write"`
DialogueAct string `json:"dialogue_act"`
ForbiddenIntents []string `json:"forbidden_intents"`
MustNotIntent string `json:"must_not_intent"`
MustNotBranch string `json:"must_not_branch"`
PreferredIntent string `json:"preferred_intent"`
ClarificationContains string `json:"clarification_contains"`
ReasoningContains string `json:"reasoning_contains"`
}
func loadGoldenV1(t *testing.T) goldenFile {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
path := filepath.Join(filepath.Dir(file), "testdata", "intent-golden-v1.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden file: %v", err)
}
var gf goldenFile
if err := json.Unmarshal(raw, &gf); err != nil {
t.Fatalf("parse golden file: %v", err)
}
if gf.Version != "intent-golden-v1" {
t.Fatalf("unexpected golden version: %s", gf.Version)
}
return gf
}
func buildContextFromGolden(t *testing.T, snap *goldenSnapshot, pending *goldenPending) *intent.ContextSnapshot {
t.Helper()
ctx := &intent.ContextSnapshot{}
if snap != nil {
ctx.UserDocRole = snap.UserDocRole
ctx.DocLifecycleState = snap.DocLifecycleState
ctx.ActiveDocID = snap.ActiveDocID
ctx.CurrentStage = snap.CurrentStage
}
if pending != nil {
pe := &intent.PendingExpectation{
ExpectedIntent: intent.IntentType(pending.ExpectedIntent),
AllowAffirmative: pending.AllowAffirmative,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour),
}
if pending.DefaultOption != nil {
pe.DefaultOption = &intent.ExpectationOption{
OptionID: pending.DefaultOption.OptionID,
Title: pending.DefaultOption.Title,
Intent: intent.IntentType(pending.DefaultOption.Intent),
Slots: pending.DefaultOption.Slots,
}
}
ctx.PendingExpectation = pe
}
return ctx
}
func assertGoldenExpect(t *testing.T, caseID string, exp *goldenExpect, res *intent.IntentRecognitionResult) {
t.Helper()
if exp == nil {
return
}
if exp.LegacyIntent != "" && string(res.TopIntent.Intent) != exp.LegacyIntent {
t.Errorf("[%s] intent: want %s got %s", caseID, exp.LegacyIntent, res.TopIntent.Intent)
}
if exp.Branch != "" && string(res.Branch) != exp.Branch {
t.Errorf("[%s] branch: want %s got %s", caseID, exp.Branch, res.Branch)
}
if exp.RequiresClarifying != nil && res.RequiresClarifying != *exp.RequiresClarifying {
t.Errorf("[%s] requires_clarifying: want %v got %v", caseID, *exp.RequiresClarifying, res.RequiresClarifying)
}
if exp.MustNotIntent != "" && string(res.TopIntent.Intent) == exp.MustNotIntent {
t.Errorf("[%s] must not intent %s", caseID, exp.MustNotIntent)
}
if exp.MustNotBranch != "" && string(res.Branch) == exp.MustNotBranch {
t.Errorf("[%s] must not branch %s", caseID, exp.MustNotBranch)
}
if exp.PreferredIntent != "" && string(res.TopIntent.Intent) != exp.PreferredIntent {
t.Errorf("[%s] preferred intent: want %s got %s", caseID, exp.PreferredIntent, res.TopIntent.Intent)
}
if exp.ClarificationContains != "" && !strings.Contains(res.TopIntent.ClarificationQuestion, exp.ClarificationContains) {
t.Errorf("[%s] clarification should contain %q, got %q", caseID, exp.ClarificationContains, res.TopIntent.ClarificationQuestion)
}
if exp.ReasoningContains != "" && !strings.Contains(res.TopIntent.Reasoning, exp.ReasoningContains) {
t.Errorf("[%s] reasoning should contain %q, got %q", caseID, exp.ReasoningContains, res.TopIntent.Reasoning)
}
for _, forbidden := range exp.ForbiddenIntents {
if string(res.TopIntent.Intent) == forbidden {
t.Errorf("[%s] forbidden intent %s", caseID, forbidden)
}
}
if exp.MustNotWrite {
writeIntents := map[intent.IntentType]bool{
intent.IntentDocGovClaim: true,
intent.IntentDocGovSubmitReview: true,
intent.IntentDocGovReviewApprove: true,
intent.IntentDocGovReviewReject: true,
intent.IntentDocGovFinalApprove: true,
intent.IntentDocGovFinalReject: true,
intent.IntentDocGovCommentAdd: true,
intent.IntentDocGovCommentResolve: true,
intent.IntentDocGovReopenRequest: true,
intent.IntentDocGovReopenApprove: true,
}
if writeIntents[res.TopIntent.Intent] {
t.Errorf("[%s] must not route to write intent %s", caseID, res.TopIntent.Intent)
}
}
if exp.DialogueAct != "" && res.ShadowDialogueAct != exp.DialogueAct {
t.Errorf("[%s] dialogue_act: want %s got %s", caseID, exp.DialogueAct, res.ShadowDialogueAct)
}
}
// TestIntentGoldenV1_Phase1 运行 golden-v1 的 Phase 1 安全门禁期望
func TestIntentGoldenV1_Phase1(t *testing.T) {
gf := loadGoldenV1(t)
rec := intent.NewRecognizer()
for _, c := range gf.Cases {
t.Run(c.ID, func(t *testing.T) {
ctx := buildContextFromGolden(t, c.Snapshot, c.Pending)
res, err := rec.Recognize(context.Background(), c.Input, ctx)
if err != nil {
t.Fatalf("recognize failed: %v", err)
}
assertGoldenExpect(t, c.ID, c.ExpectP1, res)
})
}
}
// TestIntentGoldenV1_V2DualWrite 每条识别结果应双写 intent_frame_v2
func TestIntentGoldenV1_V2DualWrite(t *testing.T) {
gf := loadGoldenV1(t)
rec := intent.NewRecognizer()
for _, c := range gf.Cases {
t.Run(c.ID, func(t *testing.T) {
ctx := buildContextFromGolden(t, c.Snapshot, c.Pending)
res, err := rec.Recognize(context.Background(), c.Input, ctx)
if err != nil {
t.Fatalf("recognize failed: %v", err)
}
if res.IntentFrameV2 == nil {
t.Fatal("expected intent_frame_v2")
}
if res.IntentFrameV2.LegacyIntent != res.TopIntent.Intent {
t.Errorf("v2 legacy mismatch: %s vs %s", res.IntentFrameV2.LegacyIntent, res.TopIntent.Intent)
}
})
}
}
// TestIntentGoldenV1_ShadowFields shadow 观测字段应被填充
func TestIntentGoldenV1_ShadowFields(t *testing.T) {
rec := intent.NewRecognizer()
ctx := &intent.ContextSnapshot{CurrentStage: string(maturity.StageV01_Triage)}
res, err := rec.Recognize(context.Background(), "现在这个便携超声立项评估到哪一步了?", ctx)
if err != nil {
t.Fatalf("recognize failed: %v", err)
}
if res.ShadowScore <= 0 {
t.Errorf("expected shadow_score > 0, got %v", res.ShadowScore)
}
if res.ShadowDialogueAct == "" {
t.Errorf("expected shadow_dialogue_act to be set")
}
if res.ShadowDecisionHint == "" {
t.Errorf("expected shadow_decision_hint to be set")
}
}