feat(t208): close candidate decision feedback loop
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const candidateReasonSchemaVersion = 1
|
||||
|
||||
type CandidateHumanReviewItemInput struct {
|
||||
CandidateOrdinal int `json:"candidate_ordinal"`
|
||||
Label string `json:"label"`
|
||||
PrimaryReasonCode string `json:"primary_reason_code"`
|
||||
ReasonCodes []string `json:"reason_codes"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type StoreCandidateHumanReviewCommand struct {
|
||||
Identity ExecutionResultIdentity
|
||||
TaskContentSHA256 string
|
||||
ReasonSchemaVersion int
|
||||
Outcome string
|
||||
SelectedCandidateOrdinal *int
|
||||
PrimaryReasonCode string
|
||||
Note string
|
||||
SupersedesReviewID *string
|
||||
Items []CandidateHumanReviewItemInput
|
||||
}
|
||||
|
||||
type StoreCandidateHumanReviewResult struct {
|
||||
Review domain.CandidateHumanReview
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) StoreHumanReview(
|
||||
ctx context.Context,
|
||||
command StoreCandidateHumanReviewCommand,
|
||||
) (StoreCandidateHumanReviewResult, error) {
|
||||
identity, err := normalizeExecutionIdentity(command.Identity)
|
||||
if err != nil {
|
||||
return StoreCandidateHumanReviewResult{}, err
|
||||
}
|
||||
command.Identity = identity
|
||||
if err := validateCandidateHumanReview(command); err != nil {
|
||||
return StoreCandidateHumanReviewResult{}, err
|
||||
}
|
||||
reviewID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return StoreCandidateHumanReviewResult{}, internalExecutionResultFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := executionResultHash(command)
|
||||
if err != nil {
|
||||
return StoreCandidateHumanReviewResult{}, internalExecutionResultFailure(err)
|
||||
}
|
||||
deviceID := identity.DeviceID
|
||||
review := domain.CandidateHumanReview{
|
||||
ID: reviewID,
|
||||
TaskID: identity.TaskID,
|
||||
ExecutionID: identity.ExecutionID,
|
||||
TaskContentSHA256: command.TaskContentSHA256,
|
||||
ReasonSchemaVersion: command.ReasonSchemaVersion,
|
||||
Outcome: command.Outcome,
|
||||
SelectedCandidateOrdinal: command.SelectedCandidateOrdinal,
|
||||
PrimaryReasonCode: strings.TrimSpace(command.PrimaryReasonCode),
|
||||
Note: strings.TrimSpace(command.Note),
|
||||
SupersedesReviewID: trimmedOptional(command.SupersedesReviewID),
|
||||
ActorUserID: identity.UserID,
|
||||
ActorDeviceID: &deviceID,
|
||||
CreatedAt: now,
|
||||
Items: make([]domain.CandidateHumanReviewItem, 0, len(command.Items)),
|
||||
}
|
||||
for _, item := range command.Items {
|
||||
review.Items = append(review.Items, domain.CandidateHumanReviewItem{
|
||||
CandidateOrdinal: item.CandidateOrdinal,
|
||||
Label: item.Label,
|
||||
PrimaryReasonCode: item.PrimaryReasonCode,
|
||||
ReasonCodes: append([]string(nil), item.ReasonCodes...),
|
||||
Note: strings.TrimSpace(item.Note),
|
||||
})
|
||||
}
|
||||
stored, replayed, err := service.repository.StoreCandidateHumanReview(
|
||||
ctx,
|
||||
service.write(
|
||||
identity,
|
||||
executionResultHumanReviewOperation,
|
||||
requestHash,
|
||||
now,
|
||||
),
|
||||
review,
|
||||
)
|
||||
if err != nil {
|
||||
return StoreCandidateHumanReviewResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return StoreCandidateHumanReviewResult{
|
||||
Review: stored, Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateCandidateHumanReview(
|
||||
command StoreCandidateHumanReviewCommand,
|
||||
) error {
|
||||
if !sha256Pattern.MatchString(command.TaskContentSHA256) {
|
||||
return executionResultInvalid(
|
||||
"task_content_sha256",
|
||||
"must be lowercase SHA-256",
|
||||
)
|
||||
}
|
||||
if command.ReasonSchemaVersion != candidateReasonSchemaVersion {
|
||||
return executionResultInvalid(
|
||||
"reason_schema_version",
|
||||
"must be 1",
|
||||
)
|
||||
}
|
||||
if !validOutcome(command.Outcome) {
|
||||
return executionResultInvalid("outcome", "is invalid")
|
||||
}
|
||||
primary := strings.TrimSpace(command.PrimaryReasonCode)
|
||||
if !validReviewPrimaryReason(command.Outcome, primary) ||
|
||||
!validReviewNote(primary, command.Note) {
|
||||
return executionResultInvalid("primary_reason_code", "is invalid")
|
||||
}
|
||||
if command.SupersedesReviewID != nil &&
|
||||
!isUUID(strings.TrimSpace(*command.SupersedesReviewID)) {
|
||||
return executionResultInvalid("supersedes_review_id", "must be a UUID")
|
||||
}
|
||||
if len(command.Items) > 5 {
|
||||
return executionResultInvalid("items", "must contain at most 5 items")
|
||||
}
|
||||
seen := make(map[int]struct{}, len(command.Items))
|
||||
accepted := 0
|
||||
for _, item := range command.Items {
|
||||
if item.CandidateOrdinal < 1 || item.CandidateOrdinal > 5 {
|
||||
return executionResultInvalid(
|
||||
"items",
|
||||
"candidate_ordinal must be between 1 and 5",
|
||||
)
|
||||
}
|
||||
if _, found := seen[item.CandidateOrdinal]; found {
|
||||
return executionResultInvalid("items", "candidate_ordinal must be unique")
|
||||
}
|
||||
seen[item.CandidateOrdinal] = struct{}{}
|
||||
if item.Label != "ACCEPT" && item.Label != "REJECT" {
|
||||
return executionResultInvalid("items", "label must be ACCEPT or REJECT")
|
||||
}
|
||||
if item.Label == "ACCEPT" {
|
||||
accepted++
|
||||
}
|
||||
if !validHumanReviewItem(item) {
|
||||
return executionResultInvalid("items", "contains invalid reasons")
|
||||
}
|
||||
}
|
||||
if command.Outcome == "CANDIDATE_ACCEPTED" {
|
||||
if command.SelectedCandidateOrdinal == nil || accepted != 1 {
|
||||
return executionResultInvalid(
|
||||
"selected_candidate_ordinal",
|
||||
"must identify the single accepted item",
|
||||
)
|
||||
}
|
||||
_, found := seen[*command.SelectedCandidateOrdinal]
|
||||
if !found || !itemAccepted(command.Items, *command.SelectedCandidateOrdinal) {
|
||||
return executionResultInvalid(
|
||||
"selected_candidate_ordinal",
|
||||
"must identify the single accepted item",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if command.SelectedCandidateOrdinal != nil || accepted != 0 {
|
||||
return executionResultInvalid(
|
||||
"items",
|
||||
"non-accepted outcomes may contain only rejected items",
|
||||
)
|
||||
}
|
||||
if len(command.Items) == 0 &&
|
||||
command.Outcome != "NO_MATCH" &&
|
||||
command.Outcome != "MANUAL_REQUIRED" {
|
||||
return executionResultInvalid(
|
||||
"items",
|
||||
"empty reviews require NO_MATCH or MANUAL_REQUIRED",
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validHumanReviewItem(item CandidateHumanReviewItemInput) bool {
|
||||
primary := strings.TrimSpace(item.PrimaryReasonCode)
|
||||
if len(item.ReasonCodes) < 1 || len(item.ReasonCodes) > 8 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
containsPrimary := false
|
||||
for _, candidate := range item.ReasonCodes {
|
||||
code := strings.TrimSpace(candidate)
|
||||
if !validItemReason(item.Label, code) {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[code]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
containsPrimary = containsPrimary || code == primary
|
||||
}
|
||||
return containsPrimary && validReviewNote(primary, item.Note)
|
||||
}
|
||||
|
||||
func validReviewPrimaryReason(outcome string, code string) bool {
|
||||
switch outcome {
|
||||
case "CANDIDATE_ACCEPTED":
|
||||
return code == "SELECTED_BEST_MATCH" || code == "OTHER"
|
||||
case "CANDIDATE_REJECTED", "NO_MATCH":
|
||||
return code == "NO_ACCEPTABLE_CANDIDATE" || code == "OTHER"
|
||||
case "MANUAL_REQUIRED":
|
||||
return code == "INSUFFICIENT_EVIDENCE" || code == "OTHER"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validItemReason(label string, code string) bool {
|
||||
if label == "ACCEPT" {
|
||||
_, found := acceptReasonCodes[code]
|
||||
return found
|
||||
}
|
||||
_, found := rejectReasonCodes[code]
|
||||
return found
|
||||
}
|
||||
|
||||
func validReviewNote(primaryReason string, value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if !utf8.ValidString(value) || utf8.RuneCountInString(value) > 200 ||
|
||||
len([]byte(value)) > 800 {
|
||||
return false
|
||||
}
|
||||
if primaryReason == "OTHER" {
|
||||
return utf8.RuneCountInString(value) >= 4
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func itemAccepted(items []CandidateHumanReviewItemInput, ordinal int) bool {
|
||||
for _, item := range items {
|
||||
if item.CandidateOrdinal == ordinal {
|
||||
return item.Label == "ACCEPT"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimmedOptional(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
var acceptReasonCodes = map[string]struct{}{
|
||||
"SKU_MATCH": {},
|
||||
"IMAGE_MATCH": {},
|
||||
"PRICE_ACCEPTABLE": {},
|
||||
"EVIDENCE_SUFFICIENT": {},
|
||||
"OTHER": {},
|
||||
}
|
||||
|
||||
var rejectReasonCodes = map[string]struct{}{
|
||||
"SKU_MISMATCH": {},
|
||||
"IMAGE_MISMATCH": {},
|
||||
"PRICE_TOO_HIGH": {},
|
||||
"OUT_OF_STOCK": {},
|
||||
"EVIDENCE_INSUFFICIENT": {},
|
||||
"NOT_BEST_MATCH": {},
|
||||
"OTHER": {},
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCandidateHumanReviewAcceptsStructuredSelection(t *testing.T) {
|
||||
selected := 2
|
||||
command := validCandidateHumanReviewCommand()
|
||||
command.SelectedCandidateOrdinal = &selected
|
||||
command.Items = []CandidateHumanReviewItemInput{
|
||||
{
|
||||
CandidateOrdinal: 1,
|
||||
Label: "REJECT",
|
||||
PrimaryReasonCode: "NOT_BEST_MATCH",
|
||||
ReasonCodes: []string{"NOT_BEST_MATCH"},
|
||||
},
|
||||
{
|
||||
CandidateOrdinal: 2,
|
||||
Label: "ACCEPT",
|
||||
PrimaryReasonCode: "SKU_MATCH",
|
||||
ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := validateCandidateHumanReview(command); err != nil {
|
||||
t.Fatalf("validate structured selection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCandidateHumanReviewRejectsMissingSelectedItem(t *testing.T) {
|
||||
selected := 2
|
||||
command := validCandidateHumanReviewCommand()
|
||||
command.SelectedCandidateOrdinal = &selected
|
||||
|
||||
if err := validateCandidateHumanReview(command); err == nil {
|
||||
t.Fatal("expected missing selected item to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCandidateHumanReviewRequiresOtherNote(t *testing.T) {
|
||||
command := validCandidateHumanReviewCommand()
|
||||
command.PrimaryReasonCode = "OTHER"
|
||||
command.Note = "短"
|
||||
|
||||
if err := validateCandidateHumanReview(command); err == nil {
|
||||
t.Fatal("expected short OTHER note to be rejected")
|
||||
}
|
||||
command.Note = "人工判断更合适"
|
||||
if err := validateCandidateHumanReview(command); err != nil {
|
||||
t.Fatalf("validate OTHER note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCandidateHumanReviewAllowsEmptyNoMatch(t *testing.T) {
|
||||
command := validCandidateHumanReviewCommand()
|
||||
command.Outcome = "NO_MATCH"
|
||||
command.SelectedCandidateOrdinal = nil
|
||||
command.PrimaryReasonCode = "NO_ACCEPTABLE_CANDIDATE"
|
||||
command.Items = nil
|
||||
|
||||
if err := validateCandidateHumanReview(command); err != nil {
|
||||
t.Fatalf("validate empty no-match review: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validCandidateHumanReviewCommand() StoreCandidateHumanReviewCommand {
|
||||
selected := 1
|
||||
return StoreCandidateHumanReviewCommand{
|
||||
TaskContentSHA256: strings.Repeat("a", 64),
|
||||
ReasonSchemaVersion: 1,
|
||||
Outcome: "CANDIDATE_ACCEPTED",
|
||||
SelectedCandidateOrdinal: &selected,
|
||||
PrimaryReasonCode: "SELECTED_BEST_MATCH",
|
||||
Items: []CandidateHumanReviewItemInput{
|
||||
{
|
||||
CandidateOrdinal: 1,
|
||||
Label: "ACCEPT",
|
||||
PrimaryReasonCode: "SKU_MATCH",
|
||||
ReasonCodes: []string{"SKU_MATCH"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ type ExecutionResultRepository interface {
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionCandidateBatch,
|
||||
) (bool, error)
|
||||
StoreCandidateHumanReview(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.CandidateHumanReview,
|
||||
) (domain.CandidateHumanReview, bool, error)
|
||||
CompleteExecution(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
|
||||
@@ -18,11 +18,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
executionResultEventsOperation = "EVENTS"
|
||||
executionResultEvidenceOperation = "EVIDENCE"
|
||||
executionResultCandidatesOperation = "CANDIDATES"
|
||||
executionResultCompleteOperation = "COMPLETE"
|
||||
executionResultFailOperation = "FAIL"
|
||||
executionResultEventsOperation = "EVENTS"
|
||||
executionResultEvidenceOperation = "EVIDENCE"
|
||||
executionResultCandidatesOperation = "CANDIDATES"
|
||||
executionResultHumanReviewOperation = "HUMAN_REVIEW"
|
||||
executionResultCompleteOperation = "COMPLETE"
|
||||
executionResultFailOperation = "FAIL"
|
||||
|
||||
manualFirstMode = "MANUAL_FIRST"
|
||||
aiAssistedMode = "AI_ASSISTED"
|
||||
@@ -531,21 +532,16 @@ func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
|
||||
} else if command.Provenance != nil {
|
||||
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
|
||||
}
|
||||
strictSKUMatching := command.ExecutionMode == aiAssistedMode &&
|
||||
command.Provenance.SchemaVersion >= 2
|
||||
for index, candidate := range command.Candidates {
|
||||
if candidate.Ordinal != index+1 ||
|
||||
!validCandidate(candidate, command.ExecutionMode) ||
|
||||
(strictSKUMatching && !validSKUMatchedCandidate(candidate)) {
|
||||
!validCandidate(candidate, command.ExecutionMode) {
|
||||
return executionResultInvalid("candidates", "must be continuous, bounded observations")
|
||||
}
|
||||
}
|
||||
if strictSKUMatching && len(command.Candidates) > 0 &&
|
||||
(command.Recommendation == nil ||
|
||||
command.Recommendation.CandidateOrdinal != 1) {
|
||||
if command.ExecutionMode == manualFirstMode && command.Recommendation != nil {
|
||||
return executionResultInvalid(
|
||||
"recommendation",
|
||||
"must select the first sorted SKU-matched candidate",
|
||||
"must be omitted for MANUAL_FIRST",
|
||||
)
|
||||
}
|
||||
if command.Recommendation != nil {
|
||||
@@ -553,7 +549,12 @@ func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
|
||||
if recommendation.CandidateOrdinal < 1 ||
|
||||
recommendation.CandidateOrdinal > len(command.Candidates) ||
|
||||
!validAuditText(recommendation.PolicyVersion, 128) ||
|
||||
!validStringList(recommendation.Reasons, 8, 160) {
|
||||
!validStringList(recommendation.Reasons, 8, 160) ||
|
||||
(command.ExecutionMode == aiAssistedMode &&
|
||||
command.Provenance.SchemaVersion >= 2 &&
|
||||
!validSKUMatchedCandidate(
|
||||
command.Candidates[recommendation.CandidateOrdinal-1],
|
||||
)) {
|
||||
return executionResultInvalid("recommendation", "is invalid")
|
||||
}
|
||||
}
|
||||
@@ -638,7 +639,8 @@ func validSKUMatchedCandidate(candidate ExecutionCandidate) bool {
|
||||
value.Score >= 0.75 &&
|
||||
value.Confidence >= 0.75 &&
|
||||
len(value.RejectionReasons) == 0 &&
|
||||
len(value.HardConstraints) == 2
|
||||
len(value.HardConstraints) == 2 &&
|
||||
allHardConstraintsMatch(value.HardConstraints)
|
||||
}
|
||||
|
||||
func validCandidateHardConstraints(
|
||||
@@ -653,7 +655,9 @@ func validCandidateHardConstraints(
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
if (value.Kind != "COLOR" && value.Kind != "SIZE") ||
|
||||
value.Status != "MATCH" ||
|
||||
(value.Status != "MATCH" &&
|
||||
value.Status != "MISMATCH" &&
|
||||
value.Status != "UNKNOWN") ||
|
||||
!validAuditText(value.Expected, 128) ||
|
||||
!validAuditText(value.Evidence, 160) {
|
||||
return false
|
||||
@@ -666,6 +670,17 @@ func validCandidateHardConstraints(
|
||||
return len(seen) == 2
|
||||
}
|
||||
|
||||
func allHardConstraintsMatch(
|
||||
values []CandidateHardConstraintEvaluation,
|
||||
) bool {
|
||||
for _, value := range values {
|
||||
if value.Status != "MATCH" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validProvenance(value *ExecutionProvenance) bool {
|
||||
return value != nil &&
|
||||
validAuditText(value.ProviderID, 64) &&
|
||||
|
||||
@@ -26,9 +26,12 @@ func TestValidateCandidateCommandAcceptsMatchedColorAndSize(t *testing.T) {
|
||||
func TestValidateCandidateCommandRejectsUnknownHardConstraint(t *testing.T) {
|
||||
command := validAIExecutionCandidateCommand()
|
||||
command.Candidates[0].Evaluation.HardConstraints[1].Status = "UNKNOWN"
|
||||
command.Candidates[0].Evaluation.Decision = "REJECT"
|
||||
command.Candidates[0].Evaluation.RejectionReasons = []string{"尺码无法确认"}
|
||||
command.Recommendation = nil
|
||||
|
||||
if err := validateCandidateCommand(command); err == nil {
|
||||
t.Fatal("expected unknown hard constraint to be rejected")
|
||||
if err := validateCandidateCommand(command); err != nil {
|
||||
t.Fatalf("validate observed unknown hard constraint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +53,7 @@ func TestValidateCandidateCommandRejectsWeakV2Candidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCandidateCommandRejectsV2RecommendationAfterFirstCandidate(t *testing.T) {
|
||||
func TestValidateCandidateCommandAcceptsV2RecommendationUsingOriginalOrdinal(t *testing.T) {
|
||||
command := validAIExecutionCandidateCommand()
|
||||
second := command.Candidates[0]
|
||||
second.Ordinal = 2
|
||||
@@ -58,8 +61,27 @@ func TestValidateCandidateCommandRejectsV2RecommendationAfterFirstCandidate(t *t
|
||||
command.Candidates = append(command.Candidates, second)
|
||||
command.Recommendation.CandidateOrdinal = 2
|
||||
|
||||
if err := validateCandidateCommand(command); err != nil {
|
||||
t.Fatalf("validate recommendation using original ordinal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCandidateCommandRejectsV2RecommendationForRejectedCandidate(t *testing.T) {
|
||||
command := validAIExecutionCandidateCommand()
|
||||
second := command.Candidates[0]
|
||||
second.Ordinal = 2
|
||||
second.Title = "拼多多图片候选 2"
|
||||
second.Evaluation = &CandidateEvaluation{
|
||||
Decision: "REJECT",
|
||||
Score: 0.2,
|
||||
Confidence: 0.9,
|
||||
RejectionReasons: []string{"颜色不匹配"},
|
||||
}
|
||||
command.Candidates = append(command.Candidates, second)
|
||||
command.Recommendation.CandidateOrdinal = 2
|
||||
|
||||
if err := validateCandidateCommand(command); err == nil {
|
||||
t.Fatal("expected v2 recommendation after first candidate to be rejected")
|
||||
t.Fatal("expected recommendation for rejected candidate to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user