Files
cmroubao/backend-api/internal/usecase/candidate_human_review.go
T

278 lines
8.1 KiB
Go

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": {},
}