feat(t207): audit local procurement results
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type ExecutionResultAuthorization struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
}
|
||||
|
||||
type ExecutionResultWrite struct {
|
||||
ExecutionResultAuthorization
|
||||
Operation string
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type ExecutionResultRepository interface {
|
||||
AppendExecutionEvents(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
[]domain.ExecutionEvent,
|
||||
) (bool, error)
|
||||
CreateExecutionEvidence(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionEvidenceAsset,
|
||||
) (domain.ExecutionEvidenceAsset, bool, error)
|
||||
StoreExecutionCandidates(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionCandidateBatch,
|
||||
) (bool, error)
|
||||
CompleteExecution(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionOutcome,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
FailExecution(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionOutcome,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
GetExecutionEvidence(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.ExecutionEvidenceAsset, error)
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
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"
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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)
|
||||
}
|
||||
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,
|
||||
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.Recommendation != nil {
|
||||
recommendation := command.Recommendation
|
||||
if recommendation.CandidateOrdinal < 1 ||
|
||||
recommendation.CandidateOrdinal > len(command.Candidates) ||
|
||||
!validAuditText(recommendation.PolicyVersion, 128) ||
|
||||
!validStringList(recommendation.Reasons, 8, 160) {
|
||||
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) ||
|
||||
len(candidate.EvidenceAssetIDs) > 5 {
|
||||
return false
|
||||
}
|
||||
for _, id := range candidate.EvidenceAssetIDs {
|
||||
if !isUUID(strings.TrimSpace(id)) {
|
||||
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)
|
||||
}
|
||||
|
||||
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}$")
|
||||
Reference in New Issue
Block a user