278 lines
8.1 KiB
Go
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": {},
|
|
}
|