863 lines
26 KiB
Go
863 lines
26 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
)
|
|
|
|
const (
|
|
executionResultEventsOperation = "EVENTS"
|
|
executionResultEvidenceOperation = "EVIDENCE"
|
|
executionResultCandidatesOperation = "CANDIDATES"
|
|
executionResultHumanReviewOperation = "HUMAN_REVIEW"
|
|
executionResultCompleteOperation = "COMPLETE"
|
|
executionResultFailOperation = "FAIL"
|
|
|
|
manualFirstMode = "MANUAL_FIRST"
|
|
aiAssistedMode = "AI_ASSISTED"
|
|
)
|
|
|
|
type ExecutionResultService struct {
|
|
repository ExecutionResultRepository
|
|
store ReferenceImageStore
|
|
clock Clock
|
|
ids IDGenerator
|
|
}
|
|
|
|
type ExecutionResultIdentity struct {
|
|
UserID string
|
|
DeviceID string
|
|
TaskID string
|
|
ExecutionID string
|
|
ClaimGeneration int64
|
|
ClaimToken string
|
|
IdempotencyKey string
|
|
}
|
|
|
|
type ClientExecutionEvent struct {
|
|
ID string `json:"event_id"`
|
|
Step string `json:"step"`
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
OccurredAt string `json:"occurred_at"`
|
|
}
|
|
|
|
type AppendExecutionEventsCommand struct {
|
|
Identity ExecutionResultIdentity
|
|
Events []ClientExecutionEvent
|
|
}
|
|
|
|
type UploadExecutionEvidenceCommand struct {
|
|
Identity ExecutionResultIdentity
|
|
DeclaredMediaType string
|
|
Content io.Reader
|
|
}
|
|
|
|
type ExecutionProvenance struct {
|
|
ProviderID string `json:"provider_id"`
|
|
Model string `json:"model"`
|
|
PromptVersion string `json:"prompt_version"`
|
|
SchemaVersion int `json:"schema_version"`
|
|
}
|
|
|
|
type CandidateEvaluation struct {
|
|
Decision string `json:"decision"`
|
|
Score float64 `json:"score"`
|
|
Matched []string `json:"matched"`
|
|
MissingOrUncertain []string `json:"missing_or_uncertain"`
|
|
RejectionReasons []string `json:"rejection_reasons"`
|
|
Confidence float64 `json:"confidence"`
|
|
HardConstraints []CandidateHardConstraintEvaluation `json:"hard_constraints"`
|
|
}
|
|
|
|
type CandidateHardConstraintEvaluation struct {
|
|
Kind string `json:"kind"`
|
|
Expected string `json:"expected"`
|
|
Status string `json:"status"`
|
|
Evidence string `json:"evidence"`
|
|
}
|
|
|
|
type ExecutionCandidate struct {
|
|
Ordinal int `json:"ordinal"`
|
|
Title string `json:"title"`
|
|
SKUText string `json:"sku_text"`
|
|
Price string `json:"price"`
|
|
ProductURL string `json:"product_url"`
|
|
ImageURL string `json:"image_url"`
|
|
CardSignature string `json:"card_signature"`
|
|
DetailSignature string `json:"detail_signature"`
|
|
DetailEvidenceSHA256 string `json:"detail_evidence_sha256"`
|
|
SpecificationEvidenceSHA256 string `json:"specification_evidence_sha256"`
|
|
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
|
Evaluation *CandidateEvaluation `json:"evaluation"`
|
|
}
|
|
|
|
type CandidateRecommendation struct {
|
|
CandidateOrdinal int `json:"candidate_ordinal"`
|
|
PolicyVersion string `json:"policy_version"`
|
|
Reasons []string `json:"reasons"`
|
|
}
|
|
|
|
type StoreExecutionCandidatesCommand struct {
|
|
Identity ExecutionResultIdentity
|
|
TaskContentSHA256 string
|
|
ExecutionMode string
|
|
SearchQuery string
|
|
Provenance *ExecutionProvenance
|
|
Candidates []ExecutionCandidate
|
|
Recommendation *CandidateRecommendation
|
|
}
|
|
|
|
type CompleteExecutionCommand struct {
|
|
Identity ExecutionResultIdentity
|
|
TaskContentSHA256 string
|
|
ExecutionMode string
|
|
Outcome string
|
|
OperatorReason string
|
|
Candidate *ExecutionCandidate
|
|
OrderSubmitted bool
|
|
}
|
|
|
|
type FailExecutionCommand struct {
|
|
Identity ExecutionResultIdentity
|
|
ErrorCode string
|
|
ErrorMessage string
|
|
ErrorStep string
|
|
Retryable bool
|
|
EvidenceAssetIDs []string
|
|
}
|
|
|
|
type UploadExecutionEvidenceResult struct {
|
|
Evidence domain.ExecutionEvidenceAsset
|
|
Replayed bool
|
|
}
|
|
|
|
type ExecutionEvidenceContent struct {
|
|
Evidence domain.ExecutionEvidenceAsset
|
|
Content io.ReadCloser
|
|
}
|
|
|
|
func NewExecutionResultService(
|
|
repository ExecutionResultRepository,
|
|
store ReferenceImageStore,
|
|
clock Clock,
|
|
ids IDGenerator,
|
|
) (*ExecutionResultService, error) {
|
|
if repository == nil || store == nil || clock == nil || ids == nil {
|
|
return nil, errors.New("execution result service dependencies are required")
|
|
}
|
|
return &ExecutionResultService{
|
|
repository: repository,
|
|
store: store,
|
|
clock: clock,
|
|
ids: ids,
|
|
}, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) AppendEvents(
|
|
ctx context.Context,
|
|
command AppendExecutionEventsCommand,
|
|
) (bool, error) {
|
|
identity, err := normalizeExecutionIdentity(command.Identity)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(command.Events) < 1 || len(command.Events) > 32 {
|
|
return false, executionResultInvalid("events", "must contain 1 to 32 events")
|
|
}
|
|
now := service.clock.Now().UTC()
|
|
events := make([]domain.ExecutionEvent, 0, len(command.Events))
|
|
seen := make(map[string]struct{}, len(command.Events))
|
|
for _, event := range command.Events {
|
|
parsed, eventErr := normalizeClientEvent(event, identity.TaskID, identity.ExecutionID, now)
|
|
if eventErr != nil {
|
|
return false, eventErr
|
|
}
|
|
if _, found := seen[parsed.ID]; found {
|
|
return false, executionResultInvalid("events", "event_id must be unique")
|
|
}
|
|
seen[parsed.ID] = struct{}{}
|
|
events = append(events, parsed)
|
|
}
|
|
requestHash, err := executionResultHash(command.Events)
|
|
if err != nil {
|
|
return false, internalExecutionResultFailure(err)
|
|
}
|
|
replayed, err := service.repository.AppendExecutionEvents(
|
|
ctx,
|
|
service.write(identity, executionResultEventsOperation, requestHash, now),
|
|
events,
|
|
)
|
|
if err != nil {
|
|
return false, wrapLifecycleRepositoryError(err)
|
|
}
|
|
return replayed, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) UploadEvidence(
|
|
ctx context.Context,
|
|
command UploadExecutionEvidenceCommand,
|
|
) (UploadExecutionEvidenceResult, error) {
|
|
identity, err := normalizeExecutionIdentity(command.Identity)
|
|
if err != nil {
|
|
return UploadExecutionEvidenceResult{}, err
|
|
}
|
|
if command.Content == nil {
|
|
return UploadExecutionEvidenceResult{}, executionResultInvalid("file", "required")
|
|
}
|
|
evidenceID, err := service.ids.NewID()
|
|
if err != nil {
|
|
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
|
|
}
|
|
normalized, err := service.store.Put(
|
|
ctx,
|
|
evidenceID,
|
|
command.DeclaredMediaType,
|
|
command.Content,
|
|
)
|
|
if err != nil {
|
|
return UploadExecutionEvidenceResult{}, mapImageStoreError(err)
|
|
}
|
|
cleanup := func() {
|
|
_ = service.store.Delete(context.Background(), normalized.StorageKey)
|
|
}
|
|
now := service.clock.Now().UTC()
|
|
requestHash, err := executionResultHash(struct {
|
|
InputSHA256 string `json:"input_sha256"`
|
|
}{InputSHA256: normalized.InputSHA256})
|
|
if err != nil {
|
|
cleanup()
|
|
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
|
|
}
|
|
evidence, replayed, err := service.repository.CreateExecutionEvidence(
|
|
ctx,
|
|
service.write(identity, executionResultEvidenceOperation, requestHash, now),
|
|
domain.ExecutionEvidenceAsset{
|
|
ID: evidenceID,
|
|
TaskID: identity.TaskID,
|
|
ExecutionID: identity.ExecutionID,
|
|
MediaType: normalized.MediaType,
|
|
SizeBytes: normalized.SizeBytes,
|
|
SHA256: normalized.SHA256,
|
|
StorageKey: normalized.StorageKey,
|
|
CreatedAt: now,
|
|
},
|
|
)
|
|
if err != nil {
|
|
cleanup()
|
|
return UploadExecutionEvidenceResult{}, wrapLifecycleRepositoryError(err)
|
|
}
|
|
if replayed {
|
|
cleanup()
|
|
}
|
|
return UploadExecutionEvidenceResult{Evidence: evidence, Replayed: replayed}, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) StoreCandidates(
|
|
ctx context.Context,
|
|
command StoreExecutionCandidatesCommand,
|
|
) (bool, error) {
|
|
identity, err := normalizeExecutionIdentity(command.Identity)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := validateCandidateCommand(command); err != nil {
|
|
return false, err
|
|
}
|
|
provenanceJSON, candidatesJSON, recommendationJSON, err := candidateJSON(command)
|
|
if err != nil {
|
|
return false, internalExecutionResultFailure(err)
|
|
}
|
|
now := service.clock.Now().UTC()
|
|
requestHash, err := executionResultHash(command)
|
|
if err != nil {
|
|
return false, internalExecutionResultFailure(err)
|
|
}
|
|
var readyEvent *domain.TaskEvent
|
|
if len(command.Candidates) > 0 {
|
|
eventID, err := service.ids.NewID()
|
|
if err != nil {
|
|
return false, internalExecutionResultFailure(err)
|
|
}
|
|
actorUserID := identity.UserID
|
|
actorDeviceID := identity.DeviceID
|
|
readyEvent = &domain.TaskEvent{
|
|
ID: eventID,
|
|
TaskID: identity.TaskID,
|
|
ActorUserID: &actorUserID,
|
|
ActorDeviceID: &actorDeviceID,
|
|
Type: "CANDIDATES_READY",
|
|
Message: "candidates ready for admin confirmation",
|
|
OccurredAt: now,
|
|
}
|
|
}
|
|
replayed, err := service.repository.StoreExecutionCandidates(
|
|
ctx,
|
|
service.write(identity, executionResultCandidatesOperation, requestHash, now),
|
|
domain.ExecutionCandidateBatch{
|
|
TaskID: identity.TaskID,
|
|
ExecutionID: identity.ExecutionID,
|
|
TaskContentSHA256: command.TaskContentSHA256,
|
|
ExecutionMode: command.ExecutionMode,
|
|
SearchQuery: strings.TrimSpace(command.SearchQuery),
|
|
ProvenanceJSON: provenanceJSON,
|
|
CandidatesJSON: candidatesJSON,
|
|
RecommendationJSON: recommendationJSON,
|
|
ReadyEvent: readyEvent,
|
|
ReceivedAt: now,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return false, wrapLifecycleRepositoryError(err)
|
|
}
|
|
return replayed, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) Complete(
|
|
ctx context.Context,
|
|
command CompleteExecutionCommand,
|
|
) (domain.PurchaseTask, bool, error) {
|
|
identity, err := normalizeExecutionIdentity(command.Identity)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, err
|
|
}
|
|
if err := validateCompleteCommand(command); err != nil {
|
|
return domain.PurchaseTask{}, false, err
|
|
}
|
|
selectedJSON, err := optionalJSON(command.Candidate)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
|
}
|
|
now := service.clock.Now().UTC()
|
|
requestHash, err := executionResultHash(command)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
|
}
|
|
mode := command.ExecutionMode
|
|
taskHash := command.TaskContentSHA256
|
|
outcome := command.Outcome
|
|
reason := strings.TrimSpace(command.OperatorReason)
|
|
task, replayed, err := service.repository.CompleteExecution(
|
|
ctx,
|
|
service.write(identity, executionResultCompleteOperation, requestHash, now),
|
|
domain.ExecutionOutcome{
|
|
TaskID: identity.TaskID,
|
|
ExecutionID: identity.ExecutionID,
|
|
ResultType: "COMPLETE",
|
|
ExecutionMode: &mode,
|
|
TaskContentSHA256: &taskHash,
|
|
Outcome: &outcome,
|
|
OperatorReason: &reason,
|
|
SelectedCandidateJSON: selectedJSON,
|
|
OrderSubmitted: false,
|
|
ReceivedAt: now,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
|
|
}
|
|
return task, replayed, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) Fail(
|
|
ctx context.Context,
|
|
command FailExecutionCommand,
|
|
) (domain.PurchaseTask, bool, error) {
|
|
identity, err := normalizeExecutionIdentity(command.Identity)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, err
|
|
}
|
|
if err := validateFailCommand(command); err != nil {
|
|
return domain.PurchaseTask{}, false, err
|
|
}
|
|
evidenceJSON, err := optionalJSON(command.EvidenceAssetIDs)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
|
}
|
|
now := service.clock.Now().UTC()
|
|
requestHash, err := executionResultHash(command)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
|
}
|
|
code := strings.TrimSpace(command.ErrorCode)
|
|
message := strings.TrimSpace(command.ErrorMessage)
|
|
step := strings.TrimSpace(command.ErrorStep)
|
|
retryable := command.Retryable
|
|
task, replayed, err := service.repository.FailExecution(
|
|
ctx,
|
|
service.write(identity, executionResultFailOperation, requestHash, now),
|
|
domain.ExecutionOutcome{
|
|
TaskID: identity.TaskID,
|
|
ExecutionID: identity.ExecutionID,
|
|
ResultType: "FAIL",
|
|
EvidenceAssetIDsJSON: evidenceJSON,
|
|
ErrorCode: &code,
|
|
ErrorMessage: &message,
|
|
ErrorStep: &step,
|
|
Retryable: &retryable,
|
|
OrderSubmitted: false,
|
|
ReceivedAt: now,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
|
|
}
|
|
return task, replayed, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) OpenEvidence(
|
|
ctx context.Context,
|
|
taskID string,
|
|
evidenceID string,
|
|
) (ExecutionEvidenceContent, error) {
|
|
evidence, err := service.repository.GetExecutionEvidence(ctx, taskID, evidenceID)
|
|
if err != nil {
|
|
return ExecutionEvidenceContent{}, wrapLifecycleRepositoryError(err)
|
|
}
|
|
content, err := service.store.Open(ctx, evidence.StorageKey)
|
|
if err != nil {
|
|
return ExecutionEvidenceContent{}, mapImageStoreError(err)
|
|
}
|
|
return ExecutionEvidenceContent{Evidence: evidence, Content: content}, nil
|
|
}
|
|
|
|
func (service *ExecutionResultService) write(
|
|
identity ExecutionResultIdentity,
|
|
operation string,
|
|
requestHash string,
|
|
now time.Time,
|
|
) ExecutionResultWrite {
|
|
return ExecutionResultWrite{
|
|
ExecutionResultAuthorization: ExecutionResultAuthorization{
|
|
UserID: identity.UserID,
|
|
DeviceID: identity.DeviceID,
|
|
TaskID: identity.TaskID,
|
|
ExecutionID: identity.ExecutionID,
|
|
ClaimGeneration: identity.ClaimGeneration,
|
|
ClaimTokenHash: hashSecret(identity.ClaimToken),
|
|
},
|
|
Operation: operation,
|
|
IdempotencyKey: identity.IdempotencyKey,
|
|
RequestHash: requestHash,
|
|
Now: now,
|
|
}
|
|
}
|
|
|
|
func normalizeExecutionIdentity(
|
|
identity ExecutionResultIdentity,
|
|
) (ExecutionResultIdentity, error) {
|
|
identity.UserID = strings.TrimSpace(identity.UserID)
|
|
identity.DeviceID = strings.TrimSpace(identity.DeviceID)
|
|
identity.TaskID = strings.TrimSpace(identity.TaskID)
|
|
identity.ExecutionID = strings.TrimSpace(identity.ExecutionID)
|
|
identity.IdempotencyKey = strings.TrimSpace(identity.IdempotencyKey)
|
|
fields := map[string]string{}
|
|
if !isUUID(identity.UserID) {
|
|
fields["user_id"] = "must be a UUID"
|
|
}
|
|
if !isUUID(identity.DeviceID) {
|
|
fields["device_id"] = "must be a UUID"
|
|
}
|
|
if !isUUID(identity.TaskID) {
|
|
fields["task_id"] = "must be a UUID"
|
|
}
|
|
if !isUUID(identity.ExecutionID) {
|
|
fields["execution_id"] = "must be a UUID"
|
|
}
|
|
if identity.ClaimGeneration < 1 {
|
|
fields["claim_generation"] = "must be a positive integer"
|
|
}
|
|
if strings.TrimSpace(identity.ClaimToken) == "" {
|
|
fields["claim_token"] = "required"
|
|
}
|
|
if len([]byte(identity.IdempotencyKey)) == 0 ||
|
|
len([]byte(identity.IdempotencyKey)) > maxIdempotencyKeyBytes ||
|
|
!isPrintableASCII(identity.IdempotencyKey) {
|
|
fields["idempotency_key"] = "must be printable ASCII up to 128 bytes"
|
|
}
|
|
if len(fields) > 0 {
|
|
return ExecutionResultIdentity{}, invalidError(
|
|
"EXECUTION_RESULT_INVALID",
|
|
"execution result request is invalid",
|
|
fields,
|
|
)
|
|
}
|
|
return identity, nil
|
|
}
|
|
|
|
func normalizeClientEvent(
|
|
event ClientExecutionEvent,
|
|
taskID string,
|
|
executionID string,
|
|
now time.Time,
|
|
) (domain.ExecutionEvent, error) {
|
|
if !isUUID(strings.TrimSpace(event.ID)) {
|
|
return domain.ExecutionEvent{}, executionResultInvalid("event_id", "must be a UUID")
|
|
}
|
|
step := strings.TrimSpace(event.Step)
|
|
typeValue := strings.TrimSpace(event.Type)
|
|
message := strings.TrimSpace(event.Message)
|
|
if !validLifecycleStep(step) {
|
|
return domain.ExecutionEvent{}, executionResultInvalid("step", "must be uppercase ASCII")
|
|
}
|
|
if !validLifecycleStep(typeValue) {
|
|
return domain.ExecutionEvent{}, executionResultInvalid("type", "must be uppercase ASCII")
|
|
}
|
|
if !validAuditText(message, 1000) {
|
|
return domain.ExecutionEvent{}, executionResultInvalid("message", "is invalid or contains sensitive material")
|
|
}
|
|
occurredAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(event.OccurredAt))
|
|
if err != nil || occurredAt.After(now.Add(5*time.Minute)) ||
|
|
occurredAt.Before(now.Add(-7*24*time.Hour)) {
|
|
return domain.ExecutionEvent{}, executionResultInvalid("occurred_at", "must be a recent RFC3339 timestamp")
|
|
}
|
|
return domain.ExecutionEvent{
|
|
ID: strings.TrimSpace(event.ID),
|
|
TaskID: taskID,
|
|
ExecutionID: executionID,
|
|
Step: step,
|
|
Type: typeValue,
|
|
Message: message,
|
|
OccurredAt: occurredAt.UTC(),
|
|
ReceivedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
|
|
if !sha256Pattern.MatchString(command.TaskContentSHA256) {
|
|
return executionResultInvalid("task_content_sha256", "must be lowercase SHA-256")
|
|
}
|
|
if !validExecutionMode(command.ExecutionMode) {
|
|
return executionResultInvalid("execution_mode", "must be MANUAL_FIRST or AI_ASSISTED")
|
|
}
|
|
if !validAuditText(command.SearchQuery, 512) {
|
|
return executionResultInvalid("search_query", "is invalid")
|
|
}
|
|
if len(command.Candidates) > 5 {
|
|
return executionResultInvalid("candidates", "must contain at most 5 candidates")
|
|
}
|
|
if command.ExecutionMode == aiAssistedMode {
|
|
if !validProvenance(command.Provenance) {
|
|
return executionResultInvalid("provenance", "is required for AI_ASSISTED")
|
|
}
|
|
} else if command.Provenance != nil {
|
|
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
|
|
}
|
|
for index, candidate := range command.Candidates {
|
|
if candidate.Ordinal != index+1 ||
|
|
!validCandidate(candidate, command.ExecutionMode) {
|
|
return executionResultInvalid("candidates", "must be continuous, bounded observations")
|
|
}
|
|
}
|
|
if command.ExecutionMode == manualFirstMode && command.Recommendation != nil {
|
|
return executionResultInvalid(
|
|
"recommendation",
|
|
"must be omitted for MANUAL_FIRST",
|
|
)
|
|
}
|
|
if command.Recommendation != nil {
|
|
recommendation := command.Recommendation
|
|
if recommendation.CandidateOrdinal < 1 ||
|
|
recommendation.CandidateOrdinal > len(command.Candidates) ||
|
|
!validAuditText(recommendation.PolicyVersion, 128) ||
|
|
!validStringList(recommendation.Reasons, 8, 160) ||
|
|
(command.ExecutionMode == aiAssistedMode &&
|
|
command.Provenance.SchemaVersion >= 2 &&
|
|
!validSKUMatchedCandidate(
|
|
command.Candidates[recommendation.CandidateOrdinal-1],
|
|
)) {
|
|
return executionResultInvalid("recommendation", "is invalid")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCompleteCommand(command CompleteExecutionCommand) error {
|
|
if !sha256Pattern.MatchString(command.TaskContentSHA256) ||
|
|
!validExecutionMode(command.ExecutionMode) ||
|
|
!validOutcome(command.Outcome) ||
|
|
!validAuditText(command.OperatorReason, 1000) ||
|
|
command.OrderSubmitted {
|
|
return executionResultInvalid("complete", "contains an invalid outcome or order state")
|
|
}
|
|
if command.Outcome == "CANDIDATE_ACCEPTED" {
|
|
if command.Candidate == nil || !validCandidate(*command.Candidate, command.ExecutionMode) {
|
|
return executionResultInvalid("candidate", "is required for CANDIDATE_ACCEPTED")
|
|
}
|
|
} else if command.Candidate != nil {
|
|
return executionResultInvalid("candidate", "must be omitted for this outcome")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFailCommand(command FailExecutionCommand) error {
|
|
if !validLifecycleStep(strings.TrimSpace(command.ErrorCode)) ||
|
|
!validLifecycleStep(strings.TrimSpace(command.ErrorStep)) ||
|
|
!validAuditText(command.ErrorMessage, 1000) {
|
|
return executionResultInvalid("error", "is invalid")
|
|
}
|
|
if len(command.EvidenceAssetIDs) > 5 {
|
|
return executionResultInvalid("evidence_asset_ids", "must contain at most 5 items")
|
|
}
|
|
for _, id := range command.EvidenceAssetIDs {
|
|
if !isUUID(strings.TrimSpace(id)) {
|
|
return executionResultInvalid("evidence_asset_ids", "must contain UUIDs")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validCandidate(candidate ExecutionCandidate, mode string) bool {
|
|
if candidate.Ordinal < 1 ||
|
|
!validAuditText(candidate.Title, 512) ||
|
|
!validOptionalAuditText(candidate.SKUText, 512) ||
|
|
!validOptionalAuditText(candidate.Price, 64) ||
|
|
!validObservationURL(candidate.ProductURL) ||
|
|
!validObservationURL(candidate.ImageURL) ||
|
|
!sha256Pattern.MatchString(candidate.CardSignature) ||
|
|
!sha256Pattern.MatchString(candidate.DetailSignature) ||
|
|
!sha256Pattern.MatchString(candidate.DetailEvidenceSHA256) ||
|
|
!sha256Pattern.MatchString(candidate.SpecificationEvidenceSHA256) ||
|
|
len(candidate.EvidenceAssetIDs) != 2 {
|
|
return false
|
|
}
|
|
for _, id := range candidate.EvidenceAssetIDs {
|
|
if !isUUID(strings.TrimSpace(id)) {
|
|
return false
|
|
}
|
|
}
|
|
if candidate.EvidenceAssetIDs[0] == candidate.EvidenceAssetIDs[1] {
|
|
return false
|
|
}
|
|
if mode == manualFirstMode {
|
|
return candidate.Evaluation == nil
|
|
}
|
|
return validEvaluation(candidate.Evaluation)
|
|
}
|
|
|
|
func validEvaluation(value *CandidateEvaluation) bool {
|
|
if value == nil || value.Score < 0 || value.Score > 1 ||
|
|
value.Confidence < 0 || value.Confidence > 1 {
|
|
return false
|
|
}
|
|
if value.Decision != "REVIEW" && value.Decision != "REJECT" &&
|
|
value.Decision != "MANUAL_REQUIRED" {
|
|
return false
|
|
}
|
|
return validStringList(value.Matched, 12, 160) &&
|
|
validStringList(value.MissingOrUncertain, 12, 160) &&
|
|
validStringList(value.RejectionReasons, 12, 160) &&
|
|
validCandidateHardConstraints(value.HardConstraints)
|
|
}
|
|
|
|
func validSKUMatchedCandidate(candidate ExecutionCandidate) bool {
|
|
value := candidate.Evaluation
|
|
return value != nil &&
|
|
value.Decision == "REVIEW" &&
|
|
value.Score >= 0.75 &&
|
|
value.Confidence >= 0.75 &&
|
|
len(value.RejectionReasons) == 0 &&
|
|
len(value.HardConstraints) == 2 &&
|
|
allHardConstraintsMatch(value.HardConstraints)
|
|
}
|
|
|
|
func validCandidateHardConstraints(
|
|
values []CandidateHardConstraintEvaluation,
|
|
) bool {
|
|
if len(values) == 0 {
|
|
return true
|
|
}
|
|
if len(values) != 2 {
|
|
return false
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, value := range values {
|
|
if (value.Kind != "COLOR" && value.Kind != "SIZE") ||
|
|
(value.Status != "MATCH" &&
|
|
value.Status != "MISMATCH" &&
|
|
value.Status != "UNKNOWN") ||
|
|
!validAuditText(value.Expected, 128) ||
|
|
!validAuditText(value.Evidence, 160) {
|
|
return false
|
|
}
|
|
if _, duplicate := seen[value.Kind]; duplicate {
|
|
return false
|
|
}
|
|
seen[value.Kind] = struct{}{}
|
|
}
|
|
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) &&
|
|
validAuditText(value.Model, 256) &&
|
|
validAuditText(value.PromptVersion, 128) &&
|
|
value.SchemaVersion >= 1 && value.SchemaVersion <= 32
|
|
}
|
|
|
|
func validExecutionMode(value string) bool {
|
|
return value == manualFirstMode || value == aiAssistedMode
|
|
}
|
|
|
|
func validOutcome(value string) bool {
|
|
switch value {
|
|
case "CANDIDATE_ACCEPTED", "CANDIDATE_REJECTED", "NO_MATCH", "MANUAL_REQUIRED":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validObservationURL(value string) bool {
|
|
if strings.TrimSpace(value) == "" {
|
|
return true
|
|
}
|
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
|
return err == nil &&
|
|
(parsed.Scheme == "https" || parsed.Scheme == "http") &&
|
|
parsed.Host != "" &&
|
|
parsed.User == nil &&
|
|
len(value) <= 2048
|
|
}
|
|
|
|
func validAuditText(value string, maximum int) bool {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || !utf8.ValidString(value) || len([]byte(value)) > maximum {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(value)
|
|
return !strings.Contains(lower, "authorization:") &&
|
|
!strings.Contains(lower, "api_key") &&
|
|
!strings.Contains(lower, "bearer ")
|
|
}
|
|
|
|
func validOptionalAuditText(value string, maximum int) bool {
|
|
return strings.TrimSpace(value) == "" || validAuditText(value, maximum)
|
|
}
|
|
|
|
func validStringList(values []string, maximumItems int, maximumText int) bool {
|
|
if len(values) > maximumItems {
|
|
return false
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, value := range values {
|
|
if !validAuditText(value, maximumText) {
|
|
return false
|
|
}
|
|
key := strings.ToLower(strings.TrimSpace(value))
|
|
if _, duplicate := seen[key]; duplicate {
|
|
return false
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func candidateJSON(command StoreExecutionCandidatesCommand) (*string, string, *string, error) {
|
|
var provenance *string
|
|
if command.Provenance != nil {
|
|
encoded, err := json.Marshal(command.Provenance)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
value := string(encoded)
|
|
provenance = &value
|
|
}
|
|
candidates, err := json.Marshal(command.Candidates)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
var recommendation *string
|
|
if command.Recommendation != nil {
|
|
encoded, err := json.Marshal(command.Recommendation)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
value := string(encoded)
|
|
recommendation = &value
|
|
}
|
|
return provenance, string(candidates), recommendation, nil
|
|
}
|
|
|
|
func optionalJSON(value any) (*string, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := string(encoded)
|
|
return &result, nil
|
|
}
|
|
|
|
func executionResultHash(value any) (string, error) {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func TaskContentSHA256(task domain.PurchaseTask) string {
|
|
budget := ""
|
|
if task.MaxBudgetCents != nil {
|
|
budget = strconv.FormatInt(*task.MaxBudgetCents, 10)
|
|
}
|
|
payload := strings.Join([]string{
|
|
task.Title,
|
|
task.Description,
|
|
task.SKU,
|
|
task.ImageAssetID,
|
|
strconv.Itoa(task.Quantity),
|
|
budget,
|
|
task.Currency,
|
|
}, "\x00")
|
|
sum := sha256.Sum256([]byte(payload))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func executionResultInvalid(field string, message string) error {
|
|
return invalidError(
|
|
"EXECUTION_RESULT_INVALID",
|
|
"execution result request is invalid",
|
|
map[string]string{field: message},
|
|
)
|
|
}
|
|
|
|
func internalExecutionResultFailure(err error) error {
|
|
return newError(
|
|
ErrorKindInternal,
|
|
"INTERNAL_ERROR",
|
|
"internal server error",
|
|
err,
|
|
)
|
|
}
|
|
|
|
var sha256Pattern = regexp.MustCompile("^[0-9a-f]{64}$")
|