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": {},
|
||||
}
|
||||
Reference in New Issue
Block a user