feat(t208): close candidate decision feedback loop

This commit is contained in:
QiuSW
2026-07-28 11:57:25 +08:00
parent 2a5ded42b5
commit e7f4c3e114
35 changed files with 2854 additions and 203 deletions
@@ -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")
}
}