1047 lines
35 KiB
Go
1047 lines
35 KiB
Go
package coordinator_test
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
|
||
"chat-agent-system/internal/app/stageflow"
|
||
"chat-agent-system/internal/coordinator"
|
||
"chat-agent-system/internal/intent"
|
||
"chat-agent-system/internal/maturity"
|
||
"chat-agent-system/internal/squad"
|
||
)
|
||
|
||
// nilBriefStageRunner 模拟阶段任务全部 halt / 无已执行产物:AdvisorBrief 为 nil。
|
||
type nilBriefStageRunner struct{}
|
||
|
||
func (nilBriefStageRunner) ExecuteTask(ctx context.Context, req stageflow.TaskRunRequest) (*stageflow.TaskRunResult, error) {
|
||
return &stageflow.TaskRunResult{}, nil
|
||
}
|
||
|
||
func (nilBriefStageRunner) ExecuteStage(ctx context.Context, req stageflow.StageRunRequest) (*stageflow.StageRunResult, error) {
|
||
return &stageflow.StageRunResult{
|
||
Stage: req.Stage,
|
||
StageTitle: "可行性论证",
|
||
Success: true,
|
||
// PrimaryArtifact / AdvisorBrief 均为 nil —— 复现 2026-09-25 nil-deref panic
|
||
}, nil
|
||
}
|
||
|
||
// 回归:空 executedArtifacts 时 stageBrief=nil,旧代码在 NaturalReply 组装处
|
||
// 裸解引用 stageRes.AdvisorBrief 导致 panic(TestBizDev_CaseRecommendation_FullLink_E2E 亦触发)。
|
||
func TestCoordinatorEngine_AdvanceStageNilBriefNoPanic(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
engine.SetStageTaskRunner(nilBriefStageRunner{})
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "超声测试项目",
|
||
CurrentStage: maturity.StageV01_Triage,
|
||
},
|
||
User: coordinator.UserInfo{ProjectRole: "composer"},
|
||
BlackboardFacts: make(map[string]string),
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentAdvanceStage,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetStage: string(maturity.StageV05_Feasibility),
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Fatalf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "未生成阶段级参谋简报") {
|
||
t.Errorf("expected graceful nil-brief fallback in reply, got: %s", res.NaturalReply)
|
||
}
|
||
if userCtx.LatestAdvisorBrief != nil {
|
||
t.Errorf("expected LatestAdvisorBrief to stay nil, got %+v", userCtx.LatestAdvisorBrief)
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_QueryProgress(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "便携式掌上超声项目",
|
||
},
|
||
LatestAdvisorBrief: &squad.AdvisorBriefing{
|
||
MaturityScore: 70,
|
||
MaturityLevel: "V0.1",
|
||
MaturityHeadline: "已具备基本产品定义,亟待补充探头频率与目标市场",
|
||
PreemptiveRisks: []string{"欧盟CE MDR认证周期较长"},
|
||
NextBestActions: []string{"推进进入V0.5可行性论证"},
|
||
CommunicationKit: "【给客户的建议沟通要点】...",
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQueryProgress,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotQueryTopic: "risk",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if res.AdvisorBrief == nil {
|
||
t.Errorf("expected advisor brief in result")
|
||
}
|
||
}
|
||
|
||
// TestCoordinatorEngine_QueryProgress_NoBrief_SubstantiveReply 复现线上问题:
|
||
// 新会话无参谋简报(LatestAdvisorBrief == nil)时,用户询问风险/进展/话术
|
||
// 必须得到基于基准简报的实质研判,而不是 V0.1 推销模板。
|
||
func TestCoordinatorEngine_QueryProgress_NoBrief_SubstantiveReply(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
cases := []struct {
|
||
name string
|
||
topic string
|
||
mustContain []string
|
||
}{
|
||
{"risk", "risk", []string{"潜在风险", "⚠️", "摸底"}},
|
||
{"progress", "progress", []string{"进展汇报", "诊断结论"}},
|
||
{"talk_script", "talk_script", []string{"沟通话术", "微信沟通草稿"}},
|
||
{"all", "all", []string{"核心研判", "关键风险预警"}},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{Name: "张工", ProjectRole: "composer"},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "医疗器械研发项目",
|
||
CustomerName: "北京水木东方",
|
||
CurrentWorkflow: "intake",
|
||
CurrentStage: maturity.StageV01_Triage,
|
||
},
|
||
BlackboardFacts: map[string]string{},
|
||
}
|
||
recRes := &intent.IntentRecognitionResult{
|
||
RawInput: "简单分析一下这个项目有什么风险",
|
||
Branch: intent.BranchNewIntentPreempt,
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQueryProgress,
|
||
Branch: intent.BranchNewIntentPreempt,
|
||
Confidence: 0.9,
|
||
FilledSlots: map[string]string{intent.SlotQueryTopic: tc.topic},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("[%s] process intent failed: %v", tc.name, err)
|
||
}
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Fatalf("[%s] expected status ACCEPTED, got %s", tc.name, res.Status)
|
||
}
|
||
for _, want := range tc.mustContain {
|
||
if !strings.Contains(res.NaturalReply, want) {
|
||
t.Errorf("[%s] expected reply to contain %q, got: %s", tc.name, want, res.NaturalReply)
|
||
}
|
||
}
|
||
if strings.Contains(res.NaturalReply, "当前处于初始准备期") {
|
||
t.Errorf("[%s] reply regressed to canned V0.1 sales pitch: %s", tc.name, res.NaturalReply)
|
||
}
|
||
if res.AdvisorBrief == nil {
|
||
t.Errorf("[%s] expected synthesized baseline advisor brief in result", tc.name)
|
||
}
|
||
if res.ReplySource != coordinator.ReplySourceTemplate {
|
||
t.Errorf("[%s] expected reply source template, got %q", tc.name, res.ReplySource)
|
||
}
|
||
if res.NextExpectation == nil {
|
||
t.Errorf("[%s] expected next expectation preserved", tc.name)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_SupplementInfo(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "便携式掌上超声项目",
|
||
},
|
||
BlackboardFacts: make(map[string]string),
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentSupplementInfo,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotFactKey: "target_market",
|
||
intent.SlotFactValue: "国内及欧盟CE",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if userCtx.BlackboardFacts["target_market"] != "国内及欧盟CE" {
|
||
t.Errorf("expected blackboard target_market updated, got %s", userCtx.BlackboardFacts["target_market"])
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_AdvanceStage(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "超声测试项目",
|
||
CurrentStage: maturity.StageV01_Triage,
|
||
},
|
||
User: coordinator.UserInfo{
|
||
ProjectRole: "composer",
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentAdvanceStage,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetStage: string(maturity.StageV05_Feasibility),
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if userCtx.Project.CurrentStage != maturity.StageV05_Feasibility {
|
||
t.Errorf("expected stage V0.5_feasibility, got %s", userCtx.Project.CurrentStage)
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_DeclineExpectation(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "超声测试项目",
|
||
CurrentStage: maturity.StageV01_Triage,
|
||
},
|
||
PendingExpectation: coordinator.BuildNextExpectation(maturity.StageV01_Triage, "超声测试项目"),
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
Branch: intent.BranchExpectationHit,
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDeclineExpectation,
|
||
Branch: intent.BranchExpectationHit,
|
||
IsDecline: true,
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if !res.ClearExpectation {
|
||
t.Errorf("expected ClearExpectation to be true")
|
||
}
|
||
if userCtx.PendingExpectation != nil {
|
||
t.Errorf("expected PendingExpectation to be cleared in userCtx")
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_AmbiguousConsult(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "超声测试项目",
|
||
CurrentStage: maturity.StageV01_Triage,
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
Branch: intent.BranchAmbiguousFallback,
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentCasualChat,
|
||
Branch: intent.BranchAmbiguousFallback,
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Branch != intent.BranchAmbiguousFallback {
|
||
t.Errorf("expected branch AMBIGUOUS_FALLBACK, got %s", res.Branch)
|
||
}
|
||
if res.NextExpectation == nil {
|
||
t.Errorf("expected NextExpectation to be populated")
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_AdvanceStageWithPatch(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "超声测试项目",
|
||
CurrentStage: maturity.StageV01_Triage,
|
||
},
|
||
BlackboardFacts: make(map[string]string),
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
Branch: intent.BranchExpectationHit,
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentAdvanceStage,
|
||
Branch: intent.BranchExpectationHit,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetStage: string(maturity.StageV05_Feasibility),
|
||
"target_market": "欧洲CE",
|
||
},
|
||
PatchSlots: map[string]string{
|
||
"target_market": "欧洲CE",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if userCtx.BlackboardFacts["target_market"] != "欧洲CE" {
|
||
t.Errorf("expected target_market '欧洲CE' in blackboard facts, got '%s'", userCtx.BlackboardFacts["target_market"])
|
||
}
|
||
if userCtx.Project.TargetMarket != "欧洲CE" {
|
||
t.Errorf("expected project TargetMarket '欧洲CE', got '%s'", userCtx.Project.TargetMarket)
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_ReviewRejectWithCard(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_102",
|
||
Name: "李主管",
|
||
Role: "reviewer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "微创内窥镜项目",
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovReviewReject,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc-002",
|
||
intent.SlotRejectReason: "第3节缺少史赛克高频电刀的抗扰度指标",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.InteractiveCard == nil {
|
||
t.Fatalf("expected interactive card payload in coordination result")
|
||
}
|
||
if res.InteractiveCard.CardType != "CONFIRMATION" {
|
||
t.Errorf("expected card type CONFIRMATION, got %s", res.InteractiveCard.CardType)
|
||
}
|
||
if len(res.InteractiveCard.Actions) < 2 {
|
||
t.Errorf("expected at least 2 actions (confirm and cancel)")
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "驳回") {
|
||
t.Errorf("expected natural reply to mention 驳回, got %s", res.NaturalReply)
|
||
}
|
||
if res.NextExpectation == nil {
|
||
t.Fatalf("expected NextExpectation in reject result")
|
||
}
|
||
if res.NextExpectation.TargetAction != "confirm_reject" {
|
||
t.Errorf("expected target action confirm_reject, got %s", res.NextExpectation.TargetAction)
|
||
}
|
||
if res.NextExpectation.ExpectedIntent != intent.IntentDocGovProposalDecide {
|
||
t.Errorf("expected expected intent DOCGOV_PROPOSAL_DECIDE, got %s", res.NextExpectation.ExpectedIntent)
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_SecurityForbidden(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_101",
|
||
Name: "张工",
|
||
Role: "engineer",
|
||
ProjectRole: "composer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "医用超声设备项目",
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentSecurityForbidden,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotForbiddenTarget: "identity_or_settings",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusRejected {
|
||
t.Errorf("expected status REJECTED, got %s", res.Status)
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "合规权限拦截") || !strings.Contains(res.NaturalReply, "ISO 13485") {
|
||
t.Errorf("expected ISO 13485 compliance warning, got %s", res.NaturalReply)
|
||
}
|
||
// Verify user context was not modified
|
||
if userCtx.User.Name != "张工" || userCtx.User.Role != "engineer" {
|
||
t.Errorf("expected user context to remain unmodified")
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_QueryUserRights(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_102",
|
||
Name: "李主管",
|
||
Role: "reviewer",
|
||
ProjectRole: "reviewer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "微创内窥镜项目",
|
||
},
|
||
UserDocs: coordinator.UserDocsInfo{
|
||
Reviewed: []string{"技术与市场调研报告", "法规与注册论证报告"},
|
||
Claimed: []string{"技术与市场调研报告"},
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQueryUserRights,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotQueryTopic: "user_rights",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "李主管") || !strings.Contains(res.NaturalReply, "审核") {
|
||
t.Errorf("expected reply to mention user name and review rights, got %s", res.NaturalReply)
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_DocGovActions(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_101",
|
||
Name: "张工",
|
||
Role: "engineer",
|
||
ProjectRole: "composer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "智能内窥镜项目",
|
||
},
|
||
UserDocs: coordinator.UserDocsInfo{
|
||
Composed: []string{"doc-001"},
|
||
},
|
||
}
|
||
|
||
// 1. DocGovClaim
|
||
recClaim := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovClaim,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc-001",
|
||
},
|
||
},
|
||
}
|
||
resClaim, err := engine.ProcessIntent(context.Background(), recClaim, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process claim failed: %v", err)
|
||
}
|
||
if resClaim.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected claim accepted, got %s", resClaim.Status)
|
||
}
|
||
|
||
// 2. DocGovSubmitReview
|
||
recSubmit := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovSubmitReview,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc-001",
|
||
},
|
||
},
|
||
}
|
||
resSubmit, err := engine.ProcessIntent(context.Background(), recSubmit, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process submit failed: %v", err)
|
||
}
|
||
if resSubmit.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected submit accepted, got %s", resSubmit.Status)
|
||
}
|
||
|
||
// 3. DocGovApprove
|
||
reviewerCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_102",
|
||
Name: "李主管",
|
||
Role: "reviewer",
|
||
ProjectRole: "reviewer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "智能内窥镜项目",
|
||
},
|
||
}
|
||
recApprove := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovReviewApprove,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc-001",
|
||
},
|
||
},
|
||
}
|
||
resApprove, err := engine.ProcessIntent(context.Background(), recApprove, reviewerCtx)
|
||
if err != nil {
|
||
t.Fatalf("process approve failed: %v", err)
|
||
}
|
||
if resApprove.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected approve accepted, got %s", resApprove.Status)
|
||
}
|
||
|
||
// 4. DocGovProposalDecide
|
||
recProposal := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovProposalDecide,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotProposalAction: "accept",
|
||
},
|
||
},
|
||
}
|
||
resProposal, err := engine.ProcessIntent(context.Background(), recProposal, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process proposal decide failed: %v", err)
|
||
}
|
||
if resProposal.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected proposal decide accepted, got %s", resProposal.Status)
|
||
}
|
||
|
||
// 5. QueryRegulations
|
||
recReg := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQueryRegulations,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotQueryTopic: "regulations",
|
||
},
|
||
},
|
||
}
|
||
resReg, err := engine.ProcessIntent(context.Background(), recReg, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process query regulations failed: %v", err)
|
||
}
|
||
if resReg.Status != coordinator.StatusAccepted || !strings.Contains(resReg.NaturalReply, "GB 9706") {
|
||
t.Errorf("expected GB 9706 in regulations reply, got %s", resReg.NaturalReply)
|
||
}
|
||
|
||
// 6. QuerySimilarCases
|
||
recCase := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQuerySimilarCases,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotQueryTopic: "similar_cases",
|
||
},
|
||
},
|
||
}
|
||
resCase, err := engine.ProcessIntent(context.Background(), recCase, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process query similar cases failed: %v", err)
|
||
}
|
||
if resCase.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected similar cases accepted, got %s", resCase.Status)
|
||
}
|
||
}
|
||
|
||
// TestCoordinatorEngine_RejectExpectationAndAffirmativeChain 测试驳回二次确认链条 (挂载 NextExpectation 与多词确认闭环)
|
||
func TestCoordinatorEngine_RejectExpectationAndAffirmativeChain(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
rec := intent.NewRecognizer()
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_102",
|
||
Name: "李审核",
|
||
Role: "reviewer",
|
||
ProjectRole: "reviewer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "心血管介入导管项目",
|
||
},
|
||
}
|
||
|
||
// 1. 发起驳回,产生交互卡片与 NextExpectation
|
||
recReject := &intent.IntentRecognitionResult{
|
||
Branch: intent.BranchNewIntentPreempt,
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovReviewReject,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc_cardio_01",
|
||
intent.SlotRejectReason: "第3节缺少血液相容性溶血试验评价指标",
|
||
intent.SlotTargetSection: "第3节",
|
||
},
|
||
},
|
||
}
|
||
|
||
resReject, err := engine.ProcessIntent(context.Background(), recReject, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process reject failed: %v", err)
|
||
}
|
||
if resReject.Status != coordinator.StatusAccepted {
|
||
t.Fatalf("expected reject status ACCEPTED, got %s", resReject.Status)
|
||
}
|
||
if resReject.InteractiveCard == nil {
|
||
t.Fatalf("expected InteractiveCard to be generated")
|
||
}
|
||
if resReject.InteractiveCard.CardType != "CONFIRMATION" {
|
||
t.Errorf("expected card type CONFIRMATION, got %s", resReject.InteractiveCard.CardType)
|
||
}
|
||
if resReject.InteractiveCard.TTLTurns != 3 {
|
||
t.Errorf("expected TTLTurns 3, got %d", resReject.InteractiveCard.TTLTurns)
|
||
}
|
||
|
||
// 验证 NextExpectation 挂载正确
|
||
if resReject.NextExpectation == nil {
|
||
t.Fatalf("expected NextExpectation to be populated")
|
||
}
|
||
if resReject.NextExpectation.TargetAction != "confirm_reject" {
|
||
t.Errorf("expected TargetAction confirm_reject, got %s", resReject.NextExpectation.TargetAction)
|
||
}
|
||
if resReject.NextExpectation.ExpectedIntent != intent.IntentDocGovProposalDecide {
|
||
t.Errorf("expected ExpectedIntent DOCGOV_PROPOSAL_DECIDE, got %s", resReject.NextExpectation.ExpectedIntent)
|
||
}
|
||
if resReject.NextExpectation.AffirmativeWord != "确认" {
|
||
t.Errorf("expected AffirmativeWord 确认, got %s", resReject.NextExpectation.AffirmativeWord)
|
||
}
|
||
if resReject.NextExpectation.DeclineWord != "取消" {
|
||
t.Errorf("expected DeclineWord 取消, got %s", resReject.NextExpectation.DeclineWord)
|
||
}
|
||
|
||
// 2. 验证用户说 "确认", "好", "同意" 均能命中 Branch 1 并正确驱动协调层执行驳回
|
||
affirmativeWords := []string{"确认", "好", "同意"}
|
||
for _, word := range affirmativeWords {
|
||
// 每次使用相同初始化的上下文进行分支 1 验证
|
||
testCtx := &coordinator.UserProjectContext{
|
||
User: userCtx.User,
|
||
Project: userCtx.Project,
|
||
InteractiveCard: resReject.InteractiveCard,
|
||
PendingExpectation: resReject.NextExpectation,
|
||
}
|
||
|
||
snap := testCtx.ToSnapshot()
|
||
recRes, err := rec.Recognize(context.Background(), word, snap)
|
||
if err != nil {
|
||
t.Fatalf("recognize '%s' failed: %v", word, err)
|
||
}
|
||
if recRes.Branch != intent.BranchExpectationHit {
|
||
t.Errorf("word '%s': expected branch EXPECTATION_HIT, got %s", word, recRes.Branch)
|
||
}
|
||
if recRes.TopIntent.Intent != intent.IntentDocGovProposalDecide {
|
||
t.Errorf("word '%s': expected intent DOCGOV_PROPOSAL_DECIDE, got %s", word, recRes.TopIntent.Intent)
|
||
}
|
||
|
||
// 协调层处理确认
|
||
coordRes, err := engine.ProcessIntent(context.Background(), recRes, testCtx)
|
||
if err != nil {
|
||
t.Fatalf("coord process confirmation failed for word '%s': %v", word, err)
|
||
}
|
||
if coordRes.Status != coordinator.StatusAccepted {
|
||
t.Errorf("word '%s': expected status ACCEPTED, got %s", word, coordRes.Status)
|
||
}
|
||
if !coordRes.ClearExpectation {
|
||
t.Errorf("word '%s': expected ClearExpectation true", word)
|
||
}
|
||
if !strings.Contains(coordRes.NaturalReply, "驳回") {
|
||
t.Errorf("word '%s': expected natural reply to confirm reject execution, got: %s", word, coordRes.NaturalReply)
|
||
}
|
||
if testCtx.InteractiveCard != nil {
|
||
t.Errorf("word '%s': expected InteractiveCard to be cleared after resolution", word)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCoordinatorEngine_TTLTurnsDecayAndCardCleanup 测试卡片生命周期消退与过期清理
|
||
func TestCoordinatorEngine_TTLTurnsDecayAndCardCleanup(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_101",
|
||
Name: "张工",
|
||
Role: "engineer",
|
||
ProjectRole: "composer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "测试项目",
|
||
},
|
||
InteractiveCard: &intent.InteractiveCardPayload{
|
||
CardID: "card_test_ttl",
|
||
CardType: "CONFIRMATION",
|
||
TargetDocID: "doc_test_001",
|
||
CurrentStatus: "active",
|
||
TTLTurns: 2,
|
||
},
|
||
}
|
||
|
||
// 验证当前活动状态下 ToSnapshot() 正确输出 ActiveDocID
|
||
snap0 := userCtx.ToSnapshot()
|
||
if snap0.ActiveDocID != "doc_test_001" {
|
||
t.Fatalf("expected ActiveDocID doc_test_001, got %s", snap0.ActiveDocID)
|
||
}
|
||
|
||
// 轮次 1:执行一个无关对话意图,TTLTurns 衰减为 1
|
||
rec1 := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentCasualChat,
|
||
},
|
||
}
|
||
_, err := engine.ProcessIntent(context.Background(), rec1, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("turn 1 failed: %v", err)
|
||
}
|
||
if userCtx.InteractiveCard == nil {
|
||
t.Fatalf("turn 1: expected card to still exist with TTL 1")
|
||
}
|
||
if userCtx.InteractiveCard.TTLTurns != 1 {
|
||
t.Errorf("turn 1: expected TTLTurns 1, got %d", userCtx.InteractiveCard.TTLTurns)
|
||
}
|
||
snap1 := userCtx.ToSnapshot()
|
||
if snap1.ActiveDocID != "doc_test_001" {
|
||
t.Errorf("turn 1: expected ActiveDocID doc_test_001, got %s", snap1.ActiveDocID)
|
||
}
|
||
|
||
// 轮次 2:再执行一个无关意图,TTLTurns 衰减为 0 <= 0,卡片过期并置空清理
|
||
rec2 := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentCasualChat,
|
||
},
|
||
}
|
||
_, err = engine.ProcessIntent(context.Background(), rec2, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("turn 2 failed: %v", err)
|
||
}
|
||
if userCtx.InteractiveCard != nil {
|
||
t.Errorf("turn 2: expected card to be cleared after TTL expiration, got %+v", userCtx.InteractiveCard)
|
||
}
|
||
|
||
// 快照不再包含失效的 ActiveDocID
|
||
snap2 := userCtx.ToSnapshot()
|
||
if snap2.ActiveDocID != "" {
|
||
t.Errorf("turn 2: expected ActiveDocID to be empty after expiry, got %s", snap2.ActiveDocID)
|
||
}
|
||
|
||
// 验证非 active 状态卡片立即被清理
|
||
userCtx.InteractiveCard = &intent.InteractiveCardPayload{
|
||
CardID: "card_cancelled",
|
||
CardType: "CONFIRMATION",
|
||
TargetDocID: "doc_test_002",
|
||
CurrentStatus: "cancelled",
|
||
TTLTurns: 5,
|
||
}
|
||
// ToSnapshot() 对非 active 状态忽略 ActiveDocID
|
||
if userCtx.ToSnapshot().ActiveDocID != "" {
|
||
t.Errorf("expected non-active card to not set ActiveDocID in snapshot")
|
||
}
|
||
// 经过 ProcessIntent 自动清理置空
|
||
_, _ = engine.ProcessIntent(context.Background(), rec1, userCtx)
|
||
if userCtx.InteractiveCard != nil {
|
||
t.Errorf("expected non-active card to be cleared by ProcessIntent")
|
||
}
|
||
}
|
||
|
||
// TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard 测试批准动作角色权责核验与终审批准二次确认
|
||
func TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
rec := intent.NewRecognizer()
|
||
|
||
// 1. 审核通过 (ReviewApprove):未授权角色 (composer / engineer) 必须被拒绝
|
||
composerCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_composer",
|
||
Name: "张编制",
|
||
Role: "engineer",
|
||
ProjectRole: "composer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "血液透析机项目",
|
||
},
|
||
}
|
||
recReviewApprove := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovReviewApprove,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc_dialysis_01",
|
||
},
|
||
},
|
||
}
|
||
|
||
resUnauthorizedReview, err := engine.ProcessIntent(context.Background(), recReviewApprove, composerCtx)
|
||
if err != nil {
|
||
t.Fatalf("process review approve failed: %v", err)
|
||
}
|
||
if resUnauthorizedReview.Status != coordinator.StatusRejected {
|
||
t.Errorf("expected review approve by composer to be REJECTED, got %s", resUnauthorizedReview.Status)
|
||
}
|
||
if resUnauthorizedReview.RejectReason != "UNAUTHORIZED_REVIEW_ROLE" {
|
||
t.Errorf("expected reject reason UNAUTHORIZED_REVIEW_ROLE, got %s", resUnauthorizedReview.RejectReason)
|
||
}
|
||
if !strings.Contains(resUnauthorizedReview.NaturalReply, "权限不足") {
|
||
t.Errorf("expected natural reply to explain permission lack, got %s", resUnauthorizedReview.NaturalReply)
|
||
}
|
||
|
||
// 2. 审核通过 (ReviewApprove):合法角色 (lead_engineer 或 reviewer) 允许通过
|
||
reviewerCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_lead",
|
||
Name: "赵主管",
|
||
Role: "lead_engineer",
|
||
ProjectRole: "reviewer",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "血液透析机项目",
|
||
},
|
||
}
|
||
resAuthorizedReview, err := engine.ProcessIntent(context.Background(), recReviewApprove, reviewerCtx)
|
||
if err != nil {
|
||
t.Fatalf("process review approve with lead_engineer failed: %v", err)
|
||
}
|
||
if resAuthorizedReview.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected review approve by lead_engineer to be ACCEPTED, got %s", resAuthorizedReview.Status)
|
||
}
|
||
|
||
// 3. 终审批准 (FinalApprove):未授权角色 (reviewer) 必须被拒绝
|
||
recFinalApprove := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentDocGovFinalApprove,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotTargetDoc: "doc_dialysis_01",
|
||
},
|
||
},
|
||
}
|
||
resUnauthorizedFinal, err := engine.ProcessIntent(context.Background(), recFinalApprove, reviewerCtx)
|
||
if err != nil {
|
||
t.Fatalf("process final approve with reviewer failed: %v", err)
|
||
}
|
||
if resUnauthorizedFinal.Status != coordinator.StatusRejected {
|
||
t.Errorf("expected final approve by reviewer to be REJECTED, got %s", resUnauthorizedFinal.Status)
|
||
}
|
||
if resUnauthorizedFinal.RejectReason != "UNAUTHORIZED_APPROVAL_ROLE" {
|
||
t.Errorf("expected reject reason UNAUTHORIZED_APPROVAL_ROLE, got %s", resUnauthorizedFinal.RejectReason)
|
||
}
|
||
|
||
// 4. 终审批准 (FinalApprove):合法角色 (approver 或 project_manager) 生成 CONFIRMATION 卡片与 NextExpectation
|
||
approverCtx := &coordinator.UserProjectContext{
|
||
User: coordinator.UserInfo{
|
||
ID: "user_pm",
|
||
Name: "王总监",
|
||
Role: "project_manager",
|
||
ProjectRole: "approver",
|
||
},
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "血液透析机项目",
|
||
},
|
||
}
|
||
resAuthorizedFinal, err := engine.ProcessIntent(context.Background(), recFinalApprove, approverCtx)
|
||
if err != nil {
|
||
t.Fatalf("process final approve with approver failed: %v", err)
|
||
}
|
||
if resAuthorizedFinal.Status != coordinator.StatusAccepted {
|
||
t.Fatalf("expected final approve by approver to be ACCEPTED, got %s", resAuthorizedFinal.Status)
|
||
}
|
||
if resAuthorizedFinal.InteractiveCard == nil {
|
||
t.Fatalf("expected CONFIRMATION card for final approve")
|
||
}
|
||
if resAuthorizedFinal.InteractiveCard.CardType != "CONFIRMATION" {
|
||
t.Errorf("expected card type CONFIRMATION, got %s", resAuthorizedFinal.InteractiveCard.CardType)
|
||
}
|
||
if resAuthorizedFinal.InteractiveCard.TTLTurns != 3 {
|
||
t.Errorf("expected card TTLTurns 3, got %d", resAuthorizedFinal.InteractiveCard.TTLTurns)
|
||
}
|
||
if resAuthorizedFinal.NextExpectation == nil {
|
||
t.Fatalf("expected NextExpectation for final approve confirmation")
|
||
}
|
||
if resAuthorizedFinal.NextExpectation.TargetAction != "confirm_final_approve" {
|
||
t.Errorf("expected target action confirm_final_approve, got %s", resAuthorizedFinal.NextExpectation.TargetAction)
|
||
}
|
||
|
||
// 5. 模拟总监在下一轮回复“确认”,分支 1 命中并正式受控锁定归档
|
||
snap := approverCtx.ToSnapshot()
|
||
recConfirm, err := rec.Recognize(context.Background(), "确认", snap)
|
||
if err != nil {
|
||
t.Fatalf("recognize confirm failed: %v", err)
|
||
}
|
||
if recConfirm.Branch != intent.BranchExpectationHit {
|
||
t.Errorf("expected branch EXPECTATION_HIT, got %s", recConfirm.Branch)
|
||
}
|
||
|
||
resConfirm, err := engine.ProcessIntent(context.Background(), recConfirm, approverCtx)
|
||
if err != nil {
|
||
t.Fatalf("process confirm final approve failed: %v", err)
|
||
}
|
||
if resConfirm.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected confirm status ACCEPTED, got %s", resConfirm.Status)
|
||
}
|
||
if !(strings.Contains(resConfirm.NaturalReply, "终审") || strings.Contains(resConfirm.NaturalReply, "批准")) {
|
||
t.Errorf("expected natural reply to confirm final approval, got: %s", resConfirm.NaturalReply)
|
||
}
|
||
if approverCtx.InteractiveCard != nil {
|
||
t.Errorf("expected interactive card to be cleared after final approve confirmation")
|
||
}
|
||
if approverCtx.PendingExpectation != nil {
|
||
t.Errorf("expected PendingExpectation to be cleared after final approve confirmation")
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_QueryRegulations_BaselineFallback(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "4K超高清荧光内窥镜系统",
|
||
DeviceType: "内窥镜",
|
||
},
|
||
RewrittenQuery: "4K超高清荧光内窥镜系统适用的国家标准和法规有哪些?",
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQueryRegulations,
|
||
Branch: intent.BranchNewIntentPreempt,
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "GB 9706.1-2020") {
|
||
t.Errorf("expected reply to contain GB 9706.1-2020, got: %s", res.NaturalReply)
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "YY 9706.102-2021") {
|
||
t.Errorf("expected reply to contain YY 9706.102-2021, got: %s", res.NaturalReply)
|
||
}
|
||
}
|
||
|
||
func TestCoordinatorEngine_TestingAnalysis(t *testing.T) {
|
||
engine := coordinator.NewCoordinatorEngine(nil)
|
||
|
||
userCtx := &coordinator.UserProjectContext{
|
||
Project: coordinator.ProjectInfo{
|
||
Name: "肌骨超声AI辅助诊断系统",
|
||
DeviceType: "肌骨超声",
|
||
},
|
||
User: coordinator.UserInfo{
|
||
ID: "108",
|
||
Name: "张工",
|
||
Role: "test_manager",
|
||
ProjectRole: "test_manager",
|
||
},
|
||
}
|
||
|
||
recRes := &intent.IntentRecognitionResult{
|
||
RawInput: "分析一下测试方案中,有哪些比较难的项目,以及需要特别注意的地方",
|
||
TopIntent: &intent.CandidateIntent{
|
||
Intent: intent.IntentQueryProgress,
|
||
Branch: intent.BranchNewIntentPreempt,
|
||
FilledSlots: map[string]string{
|
||
intent.SlotQueryTopic: "testing_analysis",
|
||
},
|
||
},
|
||
}
|
||
|
||
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
|
||
if err != nil {
|
||
t.Fatalf("process intent failed: %v", err)
|
||
}
|
||
|
||
if res.Status != coordinator.StatusAccepted {
|
||
t.Errorf("expected status ACCEPTED, got %s", res.Status)
|
||
}
|
||
// 验证包含声输出安全与温升要求 GB 9706.237
|
||
if !strings.Contains(res.NaturalReply, "GB 9706.237-2020") {
|
||
t.Errorf("expected reply to contain GB 9706.237-2020, got: %s", res.NaturalReply)
|
||
}
|
||
// 验证包含体模标定与测距公差 0.3mm
|
||
if !strings.Contains(res.NaturalReply, "0.3mm") {
|
||
t.Errorf("expected reply to contain 0.3mm tolerance, got: %s", res.NaturalReply)
|
||
}
|
||
// 验证包含 AI 算法滑膜分割与 Dice 系数
|
||
if !strings.Contains(res.NaturalReply, "Dice") {
|
||
t.Errorf("expected reply to contain Dice score, got: %s", res.NaturalReply)
|
||
}
|
||
// 严格杜绝商业摸底机械套话
|
||
if strings.Contains(res.NaturalReply, "完全理解您想再掂量一下") {
|
||
t.Errorf("reply must not contain commercial triage boilerplate, got: %s", res.NaturalReply)
|
||
}
|
||
if !strings.Contains(res.NaturalReply, "张工,您好") {
|
||
t.Errorf("greeting must use the user name, got: %s", res.NaturalReply)
|
||
}
|
||
if strings.Contains(res.NaturalReply, "张工您好") {
|
||
t.Errorf("hardcoded greeting must be gone, got: %s", res.NaturalReply)
|
||
}
|
||
}
|