feat(t208): close candidate decision feedback loop

This commit is contained in:
QiuSW
2026-07-28 11:57:25 +08:00
parent 2a5ded42b5
commit e7f4c3e114
35 changed files with 2854 additions and 203 deletions
+105 -4
View File
@@ -140,11 +140,112 @@ type ExecutionOutcome struct {
ReceivedAfterExecutionExpiry bool
}
type CandidateSearchRun struct {
TaskID string
ExecutionID string
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
AppVersion *string
AndroidVersion *string
PDDVersion *string
StartedAt time.Time
ReceivedAt time.Time
ObservationCount int
CollectionComplete bool
ReceivedAfterExecutionExpiry bool
}
type CandidateObservation struct {
TaskID string
ExecutionID string
Ordinal int
Title string
SKUText string
PriceText string
ProductURL string
ImageURL string
EvidenceAssetIDs []string
CollectionStatus string
ObservedAt time.Time
}
type CandidateModelRun struct {
ExecutionID string
ProviderID string
Model string
PromptVersion string
SchemaVersion int
RecommendationThreshold float64
RequestSHA256 string
ResultSHA256 string
CreatedAt time.Time
}
type CandidateEvaluationRecord struct {
ExecutionID string
CandidateOrdinal int
Decision string
Score float64
Confidence float64
MatchedJSON string
MissingOrUncertainJSON string
RejectionReasonsJSON string
HardConstraintsJSON string
CreatedAt time.Time
}
type CandidateRecommendationRecord struct {
ExecutionID string
CandidateOrdinal int
Conclusion string
PolicyVersion string
ReasonsJSON string
CreatedAt time.Time
}
type CandidateHumanReview struct {
ID string
TaskID string
ExecutionID string
TaskContentSHA256 string
Version int
ReasonSchemaVersion int
Outcome string
SelectedCandidateOrdinal *int
PrimaryReasonCode string
Note string
SupersedesReviewID *string
ActorUserID string
ActorDeviceID *string
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
Items []CandidateHumanReviewItem
}
type CandidateHumanReviewItem struct {
CandidateOrdinal int
Label string
PrimaryReasonCode string
ReasonCodes []string
Note string
}
type CandidateDecisionDataset struct {
SearchRun *CandidateSearchRun
Observations []CandidateObservation
ModelRun *CandidateModelRun
Evaluations []CandidateEvaluationRecord
Recommendation *CandidateRecommendationRecord
HumanReviews []CandidateHumanReview
}
type ExecutionReport struct {
Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset
CandidateBatch *ExecutionCandidateBatch
Outcome *ExecutionOutcome
Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset
CandidateBatch *ExecutionCandidateBatch
DecisionDataset *CandidateDecisionDataset
Outcome *ExecutionOutcome
}
type TaskDetail struct {
@@ -34,19 +34,27 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 5 {
t.Fatalf("initial Up() applied = %d, want 5", applied)
} else if applied != 6 {
t.Fatalf("initial Up() applied = %d, want 6", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v4) error = %v", err)
t.Fatalf("initial Down(v6) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v5) error = %v", err)
}
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v4) over historical data error = %v", err)
} else if applied != 1 {
t.Fatalf("Up(v4) applied = %d, want 1", applied)
t.Fatalf("Up(v5-v6) over historical data error = %v", err)
} else if applied != 2 {
t.Fatalf("Up(v5-v6) applied = %d, want 2", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v6) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -61,9 +69,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4) error = %v", err)
} else if applied != 2 {
t.Fatalf("final Up(v4-v5) applied = %d, want 2", applied)
t.Fatalf("final Up(v4-v6) error = %v", err)
} else if applied != 3 {
t.Fatalf("final Up(v4-v6) applied = %d, want 3", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -299,6 +307,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v6) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v5) error = %v", err)
}
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 5 {
t.Fatalf("Up() applied = %d, want 5", applied)
if applied != 6 {
t.Fatalf("Up() applied = %d, want 6", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -36,6 +36,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
3: true,
4: true,
5: true,
6: true,
})
applied, err = runner.Up(context.Background())
@@ -54,7 +55,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
2: true,
3: true,
4: true,
5: false,
5: true,
6: false,
})
applied, err = runner.Up(context.Background())
@@ -70,6 +72,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
3: true,
4: true,
5: true,
6: true,
})
}
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v6) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v5) error = %v", err)
}
@@ -405,9 +408,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback")
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v4) error = %v", err)
} else if applied != 3 {
t.Fatalf("Up(v3-v5) applied = %d, want 3", applied)
t.Fatalf("Up(v3-v6) error = %v", err)
} else if applied != 4 {
t.Fatalf("Up(v3-v6) applied = %d, want 4", applied)
}
}
@@ -0,0 +1,838 @@
package sqlite
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
func storeCandidateDecisionDataset(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
batch domain.ExecutionCandidateBatch,
expired bool,
) error {
var candidates []usecase.ExecutionCandidate
if err := json.Unmarshal([]byte(batch.CandidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant
}
var startedAt string
var appVersion, androidVersion, pddVersion sql.NullString
err := tx.QueryRowContext(
ctx,
`SELECT execution.started_at, device.app_version,
device.android_version, device.pdd_version
FROM task_executions AS execution
JOIN devices AS device ON device.id = execution.device_id
WHERE execution.id = ? AND execution.task_id = ?`,
write.ExecutionID,
write.TaskID,
).Scan(&startedAt, &appVersion, &androidVersion, &pddVersion)
if err != nil {
return repositoryFailure(err)
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_search_runs (
execution_id, task_id, task_content_sha256, execution_mode,
search_query, app_version, android_version, pdd_version,
started_at, received_at, observation_count, collection_complete,
received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
write.ExecutionID,
write.TaskID,
batch.TaskContentSHA256,
batch.ExecutionMode,
batch.SearchQuery,
nullableSQLString(appVersion),
nullableSQLString(androidVersion),
nullableSQLString(pddVersion),
startedAt,
formatTimestamp(write.Now),
len(candidates),
expired,
)
if err != nil {
return repositoryFailure(err)
}
for _, candidate := range candidates {
evidenceJSON, marshalErr := json.Marshal(candidate.EvidenceAssetIDs)
if marshalErr != nil {
return usecase.ErrRepositoryInvariant
}
collectionStatus := "PARTIAL"
if len(candidate.EvidenceAssetIDs) > 0 {
collectionStatus = "COMPLETE"
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_observations (
execution_id, task_id, ordinal, title, sku_text, price_text,
product_url, image_url, evidence_asset_ids_json,
collection_status, observed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.ExecutionID,
write.TaskID,
candidate.Ordinal,
candidate.Title,
candidate.SKUText,
candidate.Price,
candidate.ProductURL,
candidate.ImageURL,
string(evidenceJSON),
collectionStatus,
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
}
if batch.ProvenanceJSON != nil {
var provenance usecase.ExecutionProvenance
if err := json.Unmarshal(
[]byte(*batch.ProvenanceJSON),
&provenance,
); err != nil {
return usecase.ErrRepositoryInvariant
}
resultHash := hashCandidateResult(
batch.CandidatesJSON,
batch.RecommendationJSON,
)
_, err = tx.ExecContext(
ctx,
`INSERT INTO model_runs (
execution_id, provider_id, model, prompt_version, schema_version,
recommendation_threshold, request_sha256, result_sha256,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.ExecutionID,
provenance.ProviderID,
provenance.Model,
provenance.PromptVersion,
provenance.SchemaVersion,
0.75,
write.RequestHash,
resultHash,
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
for _, candidate := range candidates {
if candidate.Evaluation == nil {
return usecase.ErrRepositoryInvariant
}
evaluation := candidate.Evaluation
matchedJSON, marshalErr := json.Marshal(evaluation.Matched)
if marshalErr != nil {
return usecase.ErrRepositoryInvariant
}
missingJSON, marshalErr := json.Marshal(
evaluation.MissingOrUncertain,
)
if marshalErr != nil {
return usecase.ErrRepositoryInvariant
}
rejectionsJSON, marshalErr := json.Marshal(
evaluation.RejectionReasons,
)
if marshalErr != nil {
return usecase.ErrRepositoryInvariant
}
constraintsJSON, marshalErr := json.Marshal(
evaluation.HardConstraints,
)
if marshalErr != nil {
return usecase.ErrRepositoryInvariant
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_evaluations (
execution_id, candidate_ordinal, decision, score,
confidence, matched_json, missing_or_uncertain_json,
rejection_reasons_json, hard_constraints_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.ExecutionID,
candidate.Ordinal,
evaluation.Decision,
evaluation.Score,
evaluation.Confidence,
string(matchedJSON),
string(missingJSON),
string(rejectionsJSON),
string(constraintsJSON),
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
}
}
if batch.RecommendationJSON != nil {
var recommendation usecase.CandidateRecommendation
if err := json.Unmarshal(
[]byte(*batch.RecommendationJSON),
&recommendation,
); err != nil {
return usecase.ErrRepositoryInvariant
}
reasonsJSON, marshalErr := json.Marshal(recommendation.Reasons)
if marshalErr != nil {
return usecase.ErrRepositoryInvariant
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_recommendations (
execution_id, candidate_ordinal, conclusion, policy_version,
reasons_json, created_at
) VALUES (?, ?, 'SUGGESTED', ?, ?, ?)`,
write.ExecutionID,
recommendation.CandidateOrdinal,
recommendation.PolicyVersion,
string(reasonsJSON),
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
}
return nil
}
func (s *Store) StoreCandidateHumanReview(
ctx context.Context,
write usecase.ExecutionResultWrite,
candidate domain.CandidateHumanReview,
) (domain.CandidateHumanReview, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.CandidateHumanReview{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil {
return domain.CandidateHumanReview{}, false, err
}
if found {
if err := validateExecutionResultReplay(record, write); err != nil {
return domain.CandidateHumanReview{}, false, err
}
if record.ResourceID == nil {
return domain.CandidateHumanReview{}, false, usecase.ErrRepositoryInvariant
}
review, err := getCandidateHumanReview(ctx, tx, *record.ResourceID)
if err != nil {
return domain.CandidateHumanReview{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.CandidateHumanReview{}, false, repositoryFailure(err)
}
return review, true, nil
}
task, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return domain.CandidateHumanReview{}, false, err
}
if usecase.TaskContentSHA256(task) != candidate.TaskContentSHA256 {
return domain.CandidateHumanReview{}, false, usecase.ErrTaskVersionConflict
}
if err := validateHumanReviewAgainstDataset(
ctx,
tx,
task,
candidate,
); err != nil {
return domain.CandidateHumanReview{}, false, err
}
latestID, latestVersion, err := latestCandidateHumanReview(
ctx,
tx,
write.ExecutionID,
)
if err != nil {
return domain.CandidateHumanReview{}, false, err
}
switch {
case latestID == nil && candidate.SupersedesReviewID != nil:
return domain.CandidateHumanReview{}, false, usecase.ErrTaskStateConflict
case latestID != nil && (candidate.SupersedesReviewID == nil ||
*candidate.SupersedesReviewID != *latestID):
return domain.CandidateHumanReview{}, false, usecase.ErrTaskStateConflict
}
candidate.Version = latestVersion + 1
candidate.ReceivedAfterExecutionExpiry = expired
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_human_reviews (
id, execution_id, task_id, version, reason_schema_version, outcome,
selected_candidate_ordinal, primary_reason_code, note,
supersedes_review_id, actor_user_id, actor_device_id, created_at,
received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
candidate.ID,
candidate.ExecutionID,
candidate.TaskID,
candidate.Version,
candidate.ReasonSchemaVersion,
candidate.Outcome,
nullableInt(candidate.SelectedCandidateOrdinal),
candidate.PrimaryReasonCode,
candidate.Note,
nullableString(candidate.SupersedesReviewID),
candidate.ActorUserID,
nullableString(candidate.ActorDeviceID),
formatTimestamp(candidate.CreatedAt),
expired,
)
if err != nil {
return domain.CandidateHumanReview{}, false, repositoryFailure(err)
}
for _, item := range candidate.Items {
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_human_review_items (
review_id, candidate_ordinal, label, primary_reason_code, note
) VALUES (?, ?, ?, ?, ?)`,
candidate.ID,
item.CandidateOrdinal,
item.Label,
item.PrimaryReasonCode,
item.Note,
)
if err != nil {
return domain.CandidateHumanReview{}, false, repositoryFailure(err)
}
for _, reason := range item.ReasonCodes {
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_human_review_reasons (
review_id, candidate_ordinal, reason_code
) VALUES (?, ?, ?)`,
candidate.ID,
item.CandidateOrdinal,
reason,
)
if err != nil {
return domain.CandidateHumanReview{}, false, repositoryFailure(err)
}
}
}
if err := insertExecutionResultRequest(
ctx,
tx,
write,
&candidate.ID,
); err != nil {
return domain.CandidateHumanReview{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.CandidateHumanReview{}, false, repositoryFailure(err)
}
return candidate, false, nil
}
func validateHumanReviewAgainstDataset(
ctx context.Context,
tx *sql.Tx,
task domain.PurchaseTask,
review domain.CandidateHumanReview,
) error {
rows, err := tx.QueryContext(
ctx,
`SELECT ordinal FROM candidate_observations
WHERE execution_id = ? AND task_id = ?
ORDER BY ordinal ASC`,
review.ExecutionID,
review.TaskID,
)
if err != nil {
return repositoryFailure(err)
}
defer rows.Close()
ordinals := make([]int, 0, 5)
for rows.Next() {
var ordinal int
if err := rows.Scan(&ordinal); err != nil {
return repositoryFailure(err)
}
ordinals = append(ordinals, ordinal)
}
if err := rows.Err(); err != nil {
return repositoryFailure(err)
}
if len(review.Items) != len(ordinals) {
return usecase.ErrTaskStateConflict
}
expected := make(map[int]struct{}, len(ordinals))
for _, ordinal := range ordinals {
expected[ordinal] = struct{}{}
}
for _, item := range review.Items {
if _, found := expected[item.CandidateOrdinal]; !found {
return usecase.ErrTaskStateConflict
}
for _, reason := range item.ReasonCodes {
if task.MaxBudgetCents == nil &&
(reason == "PRICE_ACCEPTABLE" || reason == "PRICE_TOO_HIGH") {
return usecase.ErrTaskStateConflict
}
}
}
return nil
}
func latestCandidateHumanReview(
ctx context.Context,
queryer queryRower,
executionID string,
) (*string, int, error) {
var id string
var version int
err := queryer.QueryRowContext(
ctx,
`SELECT id, version FROM candidate_human_reviews
WHERE execution_id = ?
ORDER BY version DESC
LIMIT 1`,
executionID,
).Scan(&id, &version)
if errors.Is(err, sql.ErrNoRows) {
return nil, 0, nil
}
if err != nil {
return nil, 0, repositoryFailure(err)
}
return &id, version, nil
}
func getCandidateHumanReview(
ctx context.Context,
queryer queryer,
reviewID string,
) (domain.CandidateHumanReview, error) {
var review domain.CandidateHumanReview
var selected sql.NullInt64
var supersedes, device sql.NullString
var createdAt string
err := queryer.QueryRowContext(
ctx,
`SELECT review.id, review.task_id, review.execution_id,
run.task_content_sha256, review.version,
review.reason_schema_version, review.outcome,
review.selected_candidate_ordinal, review.primary_reason_code,
review.note, review.supersedes_review_id, review.actor_user_id,
review.actor_device_id, review.created_at,
review.received_after_execution_expiry
FROM candidate_human_reviews AS review
JOIN candidate_search_runs AS run
ON run.execution_id = review.execution_id
WHERE review.id = ?`,
reviewID,
).Scan(
&review.ID,
&review.TaskID,
&review.ExecutionID,
&review.TaskContentSHA256,
&review.Version,
&review.ReasonSchemaVersion,
&review.Outcome,
&selected,
&review.PrimaryReasonCode,
&review.Note,
&supersedes,
&review.ActorUserID,
&device,
&createdAt,
&review.ReceivedAfterExecutionExpiry,
)
if errors.Is(err, sql.ErrNoRows) {
return domain.CandidateHumanReview{}, usecase.ErrRepositoryInvariant
}
if err != nil {
return domain.CandidateHumanReview{}, repositoryFailure(err)
}
if selected.Valid {
value := int(selected.Int64)
review.SelectedCandidateOrdinal = &value
}
review.SupersedesReviewID = nullableStringFromSQL(supersedes)
review.ActorDeviceID = nullableStringFromSQL(device)
review.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.CandidateHumanReview{}, repositoryFailure(err)
}
rows, err := queryer.QueryContext(
ctx,
`SELECT candidate_ordinal, label, primary_reason_code, note
FROM candidate_human_review_items
WHERE review_id = ?
ORDER BY candidate_ordinal ASC`,
reviewID,
)
if err != nil {
return domain.CandidateHumanReview{}, repositoryFailure(err)
}
defer rows.Close()
for rows.Next() {
var item domain.CandidateHumanReviewItem
if err := rows.Scan(
&item.CandidateOrdinal,
&item.Label,
&item.PrimaryReasonCode,
&item.Note,
); err != nil {
return domain.CandidateHumanReview{}, repositoryFailure(err)
}
reasons, err := candidateHumanReviewReasons(
ctx,
queryer,
reviewID,
item.CandidateOrdinal,
)
if err != nil {
return domain.CandidateHumanReview{}, err
}
item.ReasonCodes = reasons
review.Items = append(review.Items, item)
}
if err := rows.Err(); err != nil {
return domain.CandidateHumanReview{}, repositoryFailure(err)
}
return review, nil
}
func getCandidateDecisionDataset(
ctx context.Context,
queryer queryer,
taskID string,
executionID string,
) (*domain.CandidateDecisionDataset, error) {
var run domain.CandidateSearchRun
var appVersion, androidVersion, pddVersion sql.NullString
var startedAt, receivedAt string
err := queryer.QueryRowContext(
ctx,
`SELECT task_id, execution_id, task_content_sha256, execution_mode,
search_query, app_version, android_version, pdd_version,
started_at, received_at, observation_count, collection_complete,
received_after_execution_expiry
FROM candidate_search_runs
WHERE task_id = ? AND execution_id = ?`,
taskID,
executionID,
).Scan(
&run.TaskID,
&run.ExecutionID,
&run.TaskContentSHA256,
&run.ExecutionMode,
&run.SearchQuery,
&appVersion,
&androidVersion,
&pddVersion,
&startedAt,
&receivedAt,
&run.ObservationCount,
&run.CollectionComplete,
&run.ReceivedAfterExecutionExpiry,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, repositoryFailure(err)
}
run.AppVersion = nullableStringFromSQL(appVersion)
run.AndroidVersion = nullableStringFromSQL(androidVersion)
run.PDDVersion = nullableStringFromSQL(pddVersion)
run.StartedAt, err = parseTimestamp(startedAt)
if err == nil {
run.ReceivedAt, err = parseTimestamp(receivedAt)
}
if err != nil {
return nil, repositoryFailure(err)
}
dataset := &domain.CandidateDecisionDataset{
SearchRun: &run,
Observations: make([]domain.CandidateObservation, 0, run.ObservationCount),
Evaluations: make([]domain.CandidateEvaluationRecord, 0, run.ObservationCount),
HumanReviews: make([]domain.CandidateHumanReview, 0),
}
rows, err := queryer.QueryContext(
ctx,
`SELECT task_id, execution_id, ordinal, title, sku_text, price_text,
product_url, image_url, evidence_asset_ids_json,
collection_status, observed_at
FROM candidate_observations
WHERE task_id = ? AND execution_id = ?
ORDER BY ordinal ASC`,
taskID,
executionID,
)
if err != nil {
return nil, repositoryFailure(err)
}
for rows.Next() {
var observation domain.CandidateObservation
var evidenceJSON, observedAt string
if err := rows.Scan(
&observation.TaskID,
&observation.ExecutionID,
&observation.Ordinal,
&observation.Title,
&observation.SKUText,
&observation.PriceText,
&observation.ProductURL,
&observation.ImageURL,
&evidenceJSON,
&observation.CollectionStatus,
&observedAt,
); err != nil {
_ = rows.Close()
return nil, repositoryFailure(err)
}
if err := json.Unmarshal(
[]byte(evidenceJSON),
&observation.EvidenceAssetIDs,
); err != nil {
_ = rows.Close()
return nil, usecase.ErrRepositoryInvariant
}
observation.ObservedAt, err = parseTimestamp(observedAt)
if err != nil {
_ = rows.Close()
return nil, repositoryFailure(err)
}
dataset.Observations = append(dataset.Observations, observation)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, repositoryFailure(err)
}
if err := rows.Close(); err != nil {
return nil, repositoryFailure(err)
}
if err := loadCandidateModelData(ctx, queryer, executionID, dataset); err != nil {
return nil, err
}
if err := loadCandidateHumanReviews(ctx, queryer, executionID, dataset); err != nil {
return nil, err
}
return dataset, nil
}
func loadCandidateModelData(
ctx context.Context,
queryer queryer,
executionID string,
dataset *domain.CandidateDecisionDataset,
) error {
var model domain.CandidateModelRun
var createdAt string
err := queryer.QueryRowContext(
ctx,
`SELECT execution_id, provider_id, model, prompt_version,
schema_version, recommendation_threshold, request_sha256,
result_sha256, created_at
FROM model_runs
WHERE execution_id = ?`,
executionID,
).Scan(
&model.ExecutionID,
&model.ProviderID,
&model.Model,
&model.PromptVersion,
&model.SchemaVersion,
&model.RecommendationThreshold,
&model.RequestSHA256,
&model.ResultSHA256,
&createdAt,
)
if err == nil {
model.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return repositoryFailure(err)
}
dataset.ModelRun = &model
} else if !errors.Is(err, sql.ErrNoRows) {
return repositoryFailure(err)
}
rows, err := queryer.QueryContext(
ctx,
`SELECT execution_id, candidate_ordinal, decision, score,
confidence, matched_json, missing_or_uncertain_json,
rejection_reasons_json, hard_constraints_json, created_at
FROM candidate_evaluations
WHERE execution_id = ?
ORDER BY candidate_ordinal ASC`,
executionID,
)
if err != nil {
return repositoryFailure(err)
}
for rows.Next() {
var evaluation domain.CandidateEvaluationRecord
var evaluationAt string
if err := rows.Scan(
&evaluation.ExecutionID,
&evaluation.CandidateOrdinal,
&evaluation.Decision,
&evaluation.Score,
&evaluation.Confidence,
&evaluation.MatchedJSON,
&evaluation.MissingOrUncertainJSON,
&evaluation.RejectionReasonsJSON,
&evaluation.HardConstraintsJSON,
&evaluationAt,
); err != nil {
_ = rows.Close()
return repositoryFailure(err)
}
evaluation.CreatedAt, err = parseTimestamp(evaluationAt)
if err != nil {
_ = rows.Close()
return repositoryFailure(err)
}
dataset.Evaluations = append(dataset.Evaluations, evaluation)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return repositoryFailure(err)
}
if err := rows.Close(); err != nil {
return repositoryFailure(err)
}
var recommendation domain.CandidateRecommendationRecord
var reasonsJSON, recommendationAt string
err = queryer.QueryRowContext(
ctx,
`SELECT execution_id, candidate_ordinal, conclusion, policy_version,
reasons_json, created_at
FROM candidate_recommendations
WHERE execution_id = ?`,
executionID,
).Scan(
&recommendation.ExecutionID,
&recommendation.CandidateOrdinal,
&recommendation.Conclusion,
&recommendation.PolicyVersion,
&reasonsJSON,
&recommendationAt,
)
if err == nil {
recommendation.ReasonsJSON = reasonsJSON
recommendation.CreatedAt, err = parseTimestamp(recommendationAt)
if err != nil {
return repositoryFailure(err)
}
dataset.Recommendation = &recommendation
} else if !errors.Is(err, sql.ErrNoRows) {
return repositoryFailure(err)
}
return nil
}
func loadCandidateHumanReviews(
ctx context.Context,
queryer queryer,
executionID string,
dataset *domain.CandidateDecisionDataset,
) error {
rows, err := queryer.QueryContext(
ctx,
`SELECT id FROM candidate_human_reviews
WHERE execution_id = ?
ORDER BY version ASC`,
executionID,
)
if err != nil {
return repositoryFailure(err)
}
reviewIDs := make([]string, 0)
for rows.Next() {
var reviewID string
if err := rows.Scan(&reviewID); err != nil {
_ = rows.Close()
return repositoryFailure(err)
}
reviewIDs = append(reviewIDs, reviewID)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return repositoryFailure(err)
}
if err := rows.Close(); err != nil {
return repositoryFailure(err)
}
for _, reviewID := range reviewIDs {
review, err := getCandidateHumanReview(ctx, queryer, reviewID)
if err != nil {
return err
}
dataset.HumanReviews = append(dataset.HumanReviews, review)
}
return nil
}
func candidateHumanReviewReasons(
ctx context.Context,
queryer queryer,
reviewID string,
ordinal int,
) ([]string, error) {
rows, err := queryer.QueryContext(
ctx,
`SELECT reason_code FROM candidate_human_review_reasons
WHERE review_id = ? AND candidate_ordinal = ?
ORDER BY reason_code ASC`,
reviewID,
ordinal,
)
if err != nil {
return nil, repositoryFailure(err)
}
defer rows.Close()
reasons := make([]string, 0)
for rows.Next() {
var reason string
if err := rows.Scan(&reason); err != nil {
return nil, repositoryFailure(err)
}
reasons = append(reasons, reason)
}
if err := rows.Err(); err != nil {
return nil, repositoryFailure(err)
}
return reasons, nil
}
func hashCandidateResult(candidates string, recommendation *string) string {
digest := sha256.New()
_, _ = digest.Write([]byte(candidates))
_, _ = digest.Write([]byte{0})
if recommendation != nil {
_, _ = digest.Write([]byte(*recommendation))
}
return hex.EncodeToString(digest.Sum(nil))
}
func nullableSQLString(value sql.NullString) any {
if !value.Valid {
return nil
}
return value.String
}
func nullableInt(value *int) any {
if value == nil {
return nil
}
return *value
}
@@ -183,6 +183,15 @@ func (s *Store) StoreExecutionCandidates(
if err != nil {
return false, repositoryFailure(err)
}
if err := storeCandidateDecisionDataset(
ctx,
tx,
write,
candidate,
expired,
); err != nil {
return false, err
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return false, err
}
@@ -825,6 +834,15 @@ func getExecutionReport(
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, repositoryFailure(err)
}
report.DecisionDataset, err = getCandidateDecisionDataset(
ctx,
queryer,
taskID,
executionID,
)
if err != nil {
return nil, err
}
var outcome domain.ExecutionOutcome
var mode, hash, result, reason, selected, evidenceIDs, code, message, step sql.NullString
var retryable sql.NullBool
@@ -418,6 +418,11 @@ func executionReportResponse(report *domain.ExecutionReport) gin.H {
"received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry,
}
}
if dataset := report.DecisionDataset; dataset != nil {
response["candidate_decision_dataset"] = candidateDecisionDatasetResponse(
dataset,
)
}
if outcome := report.Outcome; outcome != nil {
response["outcome"] = gin.H{
"result_type": outcome.ResultType,
@@ -439,6 +444,85 @@ func executionReportResponse(report *domain.ExecutionReport) gin.H {
return response
}
func candidateDecisionDatasetResponse(
dataset *domain.CandidateDecisionDataset,
) gin.H {
observations := make([]gin.H, 0, len(dataset.Observations))
for _, observation := range dataset.Observations {
observations = append(observations, gin.H{
"ordinal": observation.Ordinal,
"title": observation.Title,
"sku_text": observation.SKUText,
"price_text": observation.PriceText,
"product_url": observation.ProductURL,
"image_url": observation.ImageURL,
"evidence_asset_ids": observation.EvidenceAssetIDs,
"collection_status": observation.CollectionStatus,
"observed_at": formatTime(observation.ObservedAt),
})
}
evaluations := make([]gin.H, 0, len(dataset.Evaluations))
for _, evaluation := range dataset.Evaluations {
evaluations = append(evaluations, gin.H{
"candidate_ordinal": evaluation.CandidateOrdinal,
"decision": evaluation.Decision,
"score": evaluation.Score,
"confidence": evaluation.Confidence,
"matched": decodedAuditJSON(&evaluation.MatchedJSON),
"missing_or_uncertain": decodedAuditJSON(&evaluation.MissingOrUncertainJSON),
"rejection_reasons": decodedAuditJSON(&evaluation.RejectionReasonsJSON),
"hard_constraints": decodedAuditJSON(&evaluation.HardConstraintsJSON),
"created_at": formatTime(evaluation.CreatedAt),
})
}
reviews := make([]gin.H, 0, len(dataset.HumanReviews))
for _, review := range dataset.HumanReviews {
reviews = append(reviews, candidateHumanReviewResponse(review))
}
response := gin.H{
"observations": observations,
"evaluations": evaluations,
"human_reviews": reviews,
}
if run := dataset.SearchRun; run != nil {
response["search_run"] = gin.H{
"task_content_sha256": run.TaskContentSHA256,
"execution_mode": run.ExecutionMode,
"search_query": run.SearchQuery,
"app_version": run.AppVersion,
"android_version": run.AndroidVersion,
"pdd_version": run.PDDVersion,
"started_at": formatTime(run.StartedAt),
"received_at": formatTime(run.ReceivedAt),
"observation_count": run.ObservationCount,
"collection_complete": run.CollectionComplete,
"received_after_execution_expiry": run.ReceivedAfterExecutionExpiry,
}
}
if model := dataset.ModelRun; model != nil {
response["model_run"] = gin.H{
"provider_id": model.ProviderID,
"model": model.Model,
"prompt_version": model.PromptVersion,
"schema_version": model.SchemaVersion,
"recommendation_threshold": model.RecommendationThreshold,
"request_sha256": model.RequestSHA256,
"result_sha256": model.ResultSHA256,
"created_at": formatTime(model.CreatedAt),
}
}
if recommendation := dataset.Recommendation; recommendation != nil {
response["recommendation"] = gin.H{
"candidate_ordinal": recommendation.CandidateOrdinal,
"conclusion": recommendation.Conclusion,
"policy_version": recommendation.PolicyVersion,
"reasons": decodedAuditJSON(&recommendation.ReasonsJSON),
"created_at": formatTime(recommendation.CreatedAt),
}
}
return response
}
func decodedAuditJSON(value *string) any {
if value == nil {
return nil
@@ -72,6 +72,7 @@ func NewDeviceRouteRegistrar(
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
routes.POST("/api/v1/tasks/:id/human-reviews", handler.storeHumanReview)
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil
@@ -500,6 +501,53 @@ func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) storeHumanReview(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ReasonSchemaVersion int `json:"reason_schema_version"`
Outcome string `json:"outcome"`
SelectedCandidateOrdinal *int `json:"selected_candidate_ordinal"`
PrimaryReasonCode string `json:"primary_reason_code"`
Note string `json:"note"`
SupersedesReviewID *string `json:"supersedes_review_id"`
Items []usecase.CandidateHumanReviewItemInput `json:"items"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, err := handler.services.Results.StoreHumanReview(
ctx.Request.Context(),
usecase.StoreCandidateHumanReviewCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ReasonSchemaVersion: request.ReasonSchemaVersion,
Outcome: request.Outcome,
SelectedCandidateOrdinal: request.SelectedCandidateOrdinal,
PrimaryReasonCode: request.PrimaryReasonCode,
Note: request.Note,
SupersedesReviewID: request.SupersedesReviewID,
Items: request.Items,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"review": candidateHumanReviewResponse(result.Review),
"replayed": result.Replayed,
})
}
func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
@@ -624,6 +672,37 @@ func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H {
}
}
func candidateHumanReviewResponse(review domain.CandidateHumanReview) gin.H {
items := make([]gin.H, 0, len(review.Items))
for _, item := range review.Items {
items = append(items, gin.H{
"candidate_ordinal": item.CandidateOrdinal,
"label": item.Label,
"primary_reason_code": item.PrimaryReasonCode,
"reason_codes": item.ReasonCodes,
"note": item.Note,
})
}
return gin.H{
"id": review.ID,
"task_id": review.TaskID,
"execution_id": review.ExecutionID,
"task_content_sha256": review.TaskContentSHA256,
"version": review.Version,
"reason_schema_version": review.ReasonSchemaVersion,
"outcome": review.Outcome,
"selected_candidate_ordinal": review.SelectedCandidateOrdinal,
"primary_reason_code": review.PrimaryReasonCode,
"note": review.Note,
"supersedes_review_id": review.SupersedesReviewID,
"actor_user_id": review.ActorUserID,
"actor_device_id": review.ActorDeviceID,
"created_at": formatTime(review.CreatedAt),
"received_after_execution_expiry": review.ReceivedAfterExecutionExpiry,
"items": items,
}
}
type lifecycleTransitionRequest struct {
DeviceID string `json:"device_id"`
ClaimGeneration int64 `json:"claim_generation"`
@@ -567,6 +567,75 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
})
requireDeviceStatus(t, candidates, http.StatusOK)
humanReviewPayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
)
humanReview := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/human-reviews",
contentType: "application/json",
body: strings.NewReader(humanReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-human-review-1",
})
requireDeviceStatus(t, humanReview, http.StatusOK)
if !strings.Contains(humanReview.Body.String(), `"version":1`) {
t.Fatalf("human review response = %s", humanReview.Body.String())
}
var storedReview struct {
Review struct {
ID string `json:"id"`
} `json:"review"`
}
decodeResponse(t, humanReview, &storedReview)
if storedReview.Review.ID == "" {
t.Fatalf("human review ID missing: %+v", storedReview)
}
humanReviewReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/human-reviews",
contentType: "application/json",
body: strings.NewReader(humanReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-human-review-1",
})
requireDeviceStatus(t, humanReviewReplay, http.StatusOK)
if !strings.Contains(humanReviewReplay.Body.String(), `"replayed":true`) {
t.Fatalf("human review replay response = %s", humanReviewReplay.Body.String())
}
revisedReviewPayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_review_id":%q,"items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"IMAGE_MATCH","reason_codes":["IMAGE_MATCH"],"note":""}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
storedReview.Review.ID,
)
revisedReview := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/human-reviews",
contentType: "application/json",
body: strings.NewReader(revisedReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-human-review-2",
})
requireDeviceStatus(t, revisedReview, http.StatusOK)
if !strings.Contains(revisedReview.Body.String(), `"version":2`) {
t.Fatalf("revised human review response = %s", revisedReview.Body.String())
}
runner, err := migration.New(fixture.db)
if err != nil {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("candidate migration down succeeded with retained review data")
}
completePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`,
started.Execution.ID,
@@ -609,7 +678,12 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 1 ||
detail.Report.CandidateBatch == nil {
detail.Report.CandidateBatch == nil ||
detail.Report.DecisionDataset == nil ||
len(detail.Report.DecisionDataset.Observations) != 1 ||
len(detail.Report.DecisionDataset.HumanReviews) != 2 ||
detail.Report.DecisionDataset.HumanReviews[0].Version != 1 ||
detail.Report.DecisionDataset.HumanReviews[1].Version != 2 {
t.Fatalf("execution report = %+v", detail.Report)
}
}
@@ -86,8 +86,12 @@
{{end}}
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
{{if .Observations}}<h3>原始候选观察</h3><pre class="audit-json">{{.Observations}}</pre>{{end}}
{{if .ModelPredictions}}<h3>模型逐项评估</h3><pre class="audit-json">{{.ModelPredictions}}</pre>{{end}}
{{if .DeterministicRecommendation}}<h3>确定性推荐</h3><pre class="audit-json">{{.DeterministicRecommendation}}</pre>{{end}}
{{if .HumanReviews}}<h3>人工选择与拒绝</h3><pre class="audit-json">{{.HumanReviews}}</pre>{{end}}
{{if and (not .Observations) .Candidates}}<h3>候选与评估(兼容记录)</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
{{if and (not .DeterministicRecommendation) .Recommendation}}<h3>本地推荐(兼容记录)</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
{{if .Evidence}}
<h3>证据截图</h3>
<div class="audit-evidence-grid">
+12 -8
View File
@@ -62,14 +62,18 @@ type Task struct {
}
type ExecutionReport struct {
Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence
Mode string
SearchQuery string
Provenance string
Candidates string
Recommendation string
Outcome *ExecutionReportOutcome
Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence
Mode string
SearchQuery string
Provenance string
Candidates string
Recommendation string
Observations string
ModelPredictions string
DeterministicRecommendation string
HumanReviews string
Outcome *ExecutionReportOutcome
}
type ExecutionReportEvent struct {
@@ -216,6 +216,17 @@ func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON)
}
if dataset := report.DecisionDataset; dataset != nil {
result.Observations = prettyValueJSON(dataset.Observations)
result.ModelPredictions = prettyValueJSON(map[string]any{
"model_run": dataset.ModelRun,
"evaluations": dataset.Evaluations,
})
result.DeterministicRecommendation = prettyValueJSON(
dataset.Recommendation,
)
result.HumanReviews = prettyValueJSON(dataset.HumanReviews)
}
if outcome := report.Outcome; outcome != nil {
result.Outcome = &ExecutionReportOutcome{
ResultType: outcome.ResultType,
@@ -247,6 +258,17 @@ func prettyAuditJSON(value *string) string {
return string(formatted)
}
func prettyValueJSON(value any) string {
if value == nil {
return ""
}
formatted, err := json.MarshalIndent(value, "", " ")
if err != nil || string(formatted) == "null" || string(formatted) == "[]" {
return ""
}
return string(formatted)
}
func stringValue(value *string) string {
if value == nil {
return ""
@@ -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": {},
}
@@ -0,0 +1,85 @@
package usecase
import (
"strings"
"testing"
)
func TestValidateCandidateHumanReviewAcceptsStructuredSelection(t *testing.T) {
selected := 2
command := validCandidateHumanReviewCommand()
command.SelectedCandidateOrdinal = &selected
command.Items = []CandidateHumanReviewItemInput{
{
CandidateOrdinal: 1,
Label: "REJECT",
PrimaryReasonCode: "NOT_BEST_MATCH",
ReasonCodes: []string{"NOT_BEST_MATCH"},
},
{
CandidateOrdinal: 2,
Label: "ACCEPT",
PrimaryReasonCode: "SKU_MATCH",
ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"},
},
}
if err := validateCandidateHumanReview(command); err != nil {
t.Fatalf("validate structured selection: %v", err)
}
}
func TestValidateCandidateHumanReviewRejectsMissingSelectedItem(t *testing.T) {
selected := 2
command := validCandidateHumanReviewCommand()
command.SelectedCandidateOrdinal = &selected
if err := validateCandidateHumanReview(command); err == nil {
t.Fatal("expected missing selected item to be rejected")
}
}
func TestValidateCandidateHumanReviewRequiresOtherNote(t *testing.T) {
command := validCandidateHumanReviewCommand()
command.PrimaryReasonCode = "OTHER"
command.Note = "短"
if err := validateCandidateHumanReview(command); err == nil {
t.Fatal("expected short OTHER note to be rejected")
}
command.Note = "人工判断更合适"
if err := validateCandidateHumanReview(command); err != nil {
t.Fatalf("validate OTHER note: %v", err)
}
}
func TestValidateCandidateHumanReviewAllowsEmptyNoMatch(t *testing.T) {
command := validCandidateHumanReviewCommand()
command.Outcome = "NO_MATCH"
command.SelectedCandidateOrdinal = nil
command.PrimaryReasonCode = "NO_ACCEPTABLE_CANDIDATE"
command.Items = nil
if err := validateCandidateHumanReview(command); err != nil {
t.Fatalf("validate empty no-match review: %v", err)
}
}
func validCandidateHumanReviewCommand() StoreCandidateHumanReviewCommand {
selected := 1
return StoreCandidateHumanReviewCommand{
TaskContentSHA256: strings.Repeat("a", 64),
ReasonSchemaVersion: 1,
Outcome: "CANDIDATE_ACCEPTED",
SelectedCandidateOrdinal: &selected,
PrimaryReasonCode: "SELECTED_BEST_MATCH",
Items: []CandidateHumanReviewItemInput{
{
CandidateOrdinal: 1,
Label: "ACCEPT",
PrimaryReasonCode: "SKU_MATCH",
ReasonCodes: []string{"SKU_MATCH"},
},
},
}
}
@@ -40,6 +40,11 @@ type ExecutionResultRepository interface {
ExecutionResultWrite,
domain.ExecutionCandidateBatch,
) (bool, error)
StoreCandidateHumanReview(
context.Context,
ExecutionResultWrite,
domain.CandidateHumanReview,
) (domain.CandidateHumanReview, bool, error)
CompleteExecution(
context.Context,
ExecutionResultWrite,
@@ -18,11 +18,12 @@ import (
)
const (
executionResultEventsOperation = "EVENTS"
executionResultEvidenceOperation = "EVIDENCE"
executionResultCandidatesOperation = "CANDIDATES"
executionResultCompleteOperation = "COMPLETE"
executionResultFailOperation = "FAIL"
executionResultEventsOperation = "EVENTS"
executionResultEvidenceOperation = "EVIDENCE"
executionResultCandidatesOperation = "CANDIDATES"
executionResultHumanReviewOperation = "HUMAN_REVIEW"
executionResultCompleteOperation = "COMPLETE"
executionResultFailOperation = "FAIL"
manualFirstMode = "MANUAL_FIRST"
aiAssistedMode = "AI_ASSISTED"
@@ -531,21 +532,16 @@ func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
} else if command.Provenance != nil {
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
}
strictSKUMatching := command.ExecutionMode == aiAssistedMode &&
command.Provenance.SchemaVersion >= 2
for index, candidate := range command.Candidates {
if candidate.Ordinal != index+1 ||
!validCandidate(candidate, command.ExecutionMode) ||
(strictSKUMatching && !validSKUMatchedCandidate(candidate)) {
!validCandidate(candidate, command.ExecutionMode) {
return executionResultInvalid("candidates", "must be continuous, bounded observations")
}
}
if strictSKUMatching && len(command.Candidates) > 0 &&
(command.Recommendation == nil ||
command.Recommendation.CandidateOrdinal != 1) {
if command.ExecutionMode == manualFirstMode && command.Recommendation != nil {
return executionResultInvalid(
"recommendation",
"must select the first sorted SKU-matched candidate",
"must be omitted for MANUAL_FIRST",
)
}
if command.Recommendation != nil {
@@ -553,7 +549,12 @@ func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
if recommendation.CandidateOrdinal < 1 ||
recommendation.CandidateOrdinal > len(command.Candidates) ||
!validAuditText(recommendation.PolicyVersion, 128) ||
!validStringList(recommendation.Reasons, 8, 160) {
!validStringList(recommendation.Reasons, 8, 160) ||
(command.ExecutionMode == aiAssistedMode &&
command.Provenance.SchemaVersion >= 2 &&
!validSKUMatchedCandidate(
command.Candidates[recommendation.CandidateOrdinal-1],
)) {
return executionResultInvalid("recommendation", "is invalid")
}
}
@@ -638,7 +639,8 @@ func validSKUMatchedCandidate(candidate ExecutionCandidate) bool {
value.Score >= 0.75 &&
value.Confidence >= 0.75 &&
len(value.RejectionReasons) == 0 &&
len(value.HardConstraints) == 2
len(value.HardConstraints) == 2 &&
allHardConstraintsMatch(value.HardConstraints)
}
func validCandidateHardConstraints(
@@ -653,7 +655,9 @@ func validCandidateHardConstraints(
seen := map[string]struct{}{}
for _, value := range values {
if (value.Kind != "COLOR" && value.Kind != "SIZE") ||
value.Status != "MATCH" ||
(value.Status != "MATCH" &&
value.Status != "MISMATCH" &&
value.Status != "UNKNOWN") ||
!validAuditText(value.Expected, 128) ||
!validAuditText(value.Evidence, 160) {
return false
@@ -666,6 +670,17 @@ func validCandidateHardConstraints(
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) &&
@@ -26,9 +26,12 @@ func TestValidateCandidateCommandAcceptsMatchedColorAndSize(t *testing.T) {
func TestValidateCandidateCommandRejectsUnknownHardConstraint(t *testing.T) {
command := validAIExecutionCandidateCommand()
command.Candidates[0].Evaluation.HardConstraints[1].Status = "UNKNOWN"
command.Candidates[0].Evaluation.Decision = "REJECT"
command.Candidates[0].Evaluation.RejectionReasons = []string{"尺码无法确认"}
command.Recommendation = nil
if err := validateCandidateCommand(command); err == nil {
t.Fatal("expected unknown hard constraint to be rejected")
if err := validateCandidateCommand(command); err != nil {
t.Fatalf("validate observed unknown hard constraint: %v", err)
}
}
@@ -50,7 +53,7 @@ func TestValidateCandidateCommandRejectsWeakV2Candidate(t *testing.T) {
}
}
func TestValidateCandidateCommandRejectsV2RecommendationAfterFirstCandidate(t *testing.T) {
func TestValidateCandidateCommandAcceptsV2RecommendationUsingOriginalOrdinal(t *testing.T) {
command := validAIExecutionCandidateCommand()
second := command.Candidates[0]
second.Ordinal = 2
@@ -58,8 +61,27 @@ func TestValidateCandidateCommandRejectsV2RecommendationAfterFirstCandidate(t *t
command.Candidates = append(command.Candidates, second)
command.Recommendation.CandidateOrdinal = 2
if err := validateCandidateCommand(command); err != nil {
t.Fatalf("validate recommendation using original ordinal: %v", err)
}
}
func TestValidateCandidateCommandRejectsV2RecommendationForRejectedCandidate(t *testing.T) {
command := validAIExecutionCandidateCommand()
second := command.Candidates[0]
second.Ordinal = 2
second.Title = "拼多多图片候选 2"
second.Evaluation = &CandidateEvaluation{
Decision: "REJECT",
Score: 0.2,
Confidence: 0.9,
RejectionReasons: []string{"颜色不匹配"},
}
command.Candidates = append(command.Candidates, second)
command.Recommendation.CandidateOrdinal = 2
if err := validateCandidateCommand(command); err == nil {
t.Fatal("expected v2 recommendation after first candidate to be rejected")
t.Fatal("expected recommendation for rejected candidate to be rejected")
}
}
@@ -0,0 +1,365 @@
-- +goose Up
ALTER TABLE execution_result_requests RENAME TO execution_result_requests_v5;
CREATE TABLE execution_result_requests (
user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
operation TEXT NOT NULL
CHECK (
operation IN (
'EVENTS',
'EVIDENCE',
'CANDIDATES',
'HUMAN_REVIEW',
'COMPLETE',
'FAIL'
)
),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
claim_token_sha256 TEXT NOT NULL
CHECK (
length(claim_token_sha256) = 64
AND claim_token_sha256 NOT GLOB '*[^0-9a-f]*'
),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
resource_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_id, operation, idempotency_key)
);
INSERT INTO execution_result_requests (
user_id,
device_id,
operation,
idempotency_key,
request_sha256,
claim_token_sha256,
task_id,
execution_id,
resource_id,
created_at
)
SELECT
user_id,
device_id,
operation,
idempotency_key,
request_sha256,
claim_token_sha256,
task_id,
execution_id,
resource_id,
created_at
FROM execution_result_requests_v5;
DROP TABLE execution_result_requests_v5;
CREATE TABLE candidate_search_runs (
execution_id TEXT PRIMARY KEY NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_content_sha256 TEXT NOT NULL
CHECK (
length(task_content_sha256) = 64
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
),
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
search_query TEXT NOT NULL
CHECK (
length(trim(search_query)) > 0
AND length(CAST(search_query AS BLOB)) <= 512
),
app_version TEXT,
android_version TEXT,
pdd_version TEXT,
started_at TEXT NOT NULL,
received_at TEXT NOT NULL,
observation_count INTEGER NOT NULL
CHECK (observation_count BETWEEN 0 AND 5),
collection_complete INTEGER NOT NULL
CHECK (collection_complete = 1),
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE TABLE candidate_observations (
execution_id TEXT NOT NULL
REFERENCES candidate_search_runs(execution_id)
ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
ordinal INTEGER NOT NULL
CHECK (ordinal BETWEEN 1 AND 5),
title TEXT NOT NULL
CHECK (
length(trim(title)) > 0
AND length(CAST(title AS BLOB)) <= 512
),
sku_text TEXT NOT NULL
CHECK (length(CAST(sku_text AS BLOB)) <= 512),
price_text TEXT NOT NULL
CHECK (length(CAST(price_text AS BLOB)) <= 64),
product_url TEXT NOT NULL
CHECK (length(CAST(product_url AS BLOB)) <= 2048),
image_url TEXT NOT NULL
CHECK (length(CAST(image_url AS BLOB)) <= 2048),
evidence_asset_ids_json TEXT NOT NULL
CHECK (length(CAST(evidence_asset_ids_json AS BLOB)) <= 4096),
collection_status TEXT NOT NULL
CHECK (collection_status IN ('COMPLETE', 'PARTIAL')),
observed_at TEXT NOT NULL,
PRIMARY KEY (execution_id, ordinal)
);
CREATE TABLE model_runs (
execution_id TEXT PRIMARY KEY NOT NULL
REFERENCES candidate_search_runs(execution_id)
ON UPDATE RESTRICT ON DELETE CASCADE,
provider_id TEXT NOT NULL
CHECK (
length(trim(provider_id)) > 0
AND length(CAST(provider_id AS BLOB)) <= 128
),
model TEXT NOT NULL
CHECK (
length(trim(model)) > 0
AND length(CAST(model AS BLOB)) <= 256
),
prompt_version TEXT NOT NULL
CHECK (
length(trim(prompt_version)) > 0
AND length(CAST(prompt_version AS BLOB)) <= 128
),
schema_version INTEGER NOT NULL
CHECK (schema_version BETWEEN 1 AND 1000),
recommendation_threshold REAL NOT NULL
CHECK (recommendation_threshold BETWEEN 0 AND 1),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
result_sha256 TEXT NOT NULL
CHECK (
length(result_sha256) = 64
AND result_sha256 NOT GLOB '*[^0-9a-f]*'
),
duration_millis INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cost_microunits INTEGER,
created_at TEXT NOT NULL
);
CREATE TABLE candidate_evaluations (
execution_id TEXT NOT NULL,
candidate_ordinal INTEGER NOT NULL,
decision TEXT NOT NULL
CHECK (decision IN ('REVIEW', 'REJECT', 'MANUAL_REQUIRED')),
score REAL NOT NULL
CHECK (score BETWEEN 0 AND 1),
confidence REAL NOT NULL
CHECK (confidence BETWEEN 0 AND 1),
matched_json TEXT NOT NULL,
missing_or_uncertain_json TEXT NOT NULL,
rejection_reasons_json TEXT NOT NULL,
hard_constraints_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (execution_id, candidate_ordinal),
FOREIGN KEY (execution_id)
REFERENCES model_runs(execution_id)
ON UPDATE RESTRICT ON DELETE CASCADE,
FOREIGN KEY (execution_id, candidate_ordinal)
REFERENCES candidate_observations(execution_id, ordinal)
ON UPDATE RESTRICT ON DELETE CASCADE
);
CREATE TABLE candidate_recommendations (
execution_id TEXT PRIMARY KEY NOT NULL,
candidate_ordinal INTEGER NOT NULL,
conclusion TEXT NOT NULL
CHECK (conclusion = 'SUGGESTED'),
policy_version TEXT NOT NULL
CHECK (
length(trim(policy_version)) > 0
AND length(CAST(policy_version AS BLOB)) <= 128
),
reasons_json TEXT NOT NULL
CHECK (length(CAST(reasons_json AS BLOB)) <= 4096),
created_at TEXT NOT NULL,
FOREIGN KEY (execution_id, candidate_ordinal)
REFERENCES candidate_observations(execution_id, ordinal)
ON UPDATE RESTRICT ON DELETE CASCADE
);
CREATE TABLE candidate_human_reviews (
id TEXT PRIMARY KEY NOT NULL
CHECK (length(id) = 36),
execution_id TEXT NOT NULL
REFERENCES candidate_search_runs(execution_id)
ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
version INTEGER NOT NULL
CHECK (version > 0),
reason_schema_version INTEGER NOT NULL
CHECK (reason_schema_version = 1),
outcome TEXT NOT NULL
CHECK (
outcome IN (
'CANDIDATE_ACCEPTED',
'CANDIDATE_REJECTED',
'NO_MATCH',
'MANUAL_REQUIRED'
)
),
selected_candidate_ordinal INTEGER,
primary_reason_code TEXT NOT NULL,
note TEXT NOT NULL
CHECK (length(CAST(note AS BLOB)) <= 800),
supersedes_review_id TEXT UNIQUE
REFERENCES candidate_human_reviews(id)
ON UPDATE RESTRICT ON DELETE RESTRICT,
actor_user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
actor_device_id TEXT
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
created_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1)),
UNIQUE (execution_id, version),
FOREIGN KEY (execution_id, selected_candidate_ordinal)
REFERENCES candidate_observations(execution_id, ordinal)
ON UPDATE RESTRICT ON DELETE RESTRICT
);
CREATE INDEX candidate_human_reviews_execution_version_idx
ON candidate_human_reviews (execution_id, version DESC);
CREATE TABLE candidate_human_review_items (
review_id TEXT NOT NULL
REFERENCES candidate_human_reviews(id)
ON UPDATE RESTRICT ON DELETE CASCADE,
candidate_ordinal INTEGER NOT NULL,
label TEXT NOT NULL
CHECK (label IN ('ACCEPT', 'REJECT')),
primary_reason_code TEXT NOT NULL,
note TEXT NOT NULL
CHECK (length(CAST(note AS BLOB)) <= 800),
PRIMARY KEY (review_id, candidate_ordinal)
);
CREATE TABLE candidate_human_review_reasons (
review_id TEXT NOT NULL,
candidate_ordinal INTEGER NOT NULL,
reason_code TEXT NOT NULL,
PRIMARY KEY (review_id, candidate_ordinal, reason_code),
FOREIGN KEY (review_id, candidate_ordinal)
REFERENCES candidate_human_review_items(review_id, candidate_ordinal)
ON UPDATE RESTRICT ON DELETE CASCADE
);
-- +goose Down
CREATE TEMP TABLE candidate_decisions_v6_down_guard (
allowed INTEGER NOT NULL
CHECK (allowed = 1)
);
INSERT INTO candidate_decisions_v6_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM candidate_search_runs)
OR EXISTS (SELECT 1 FROM candidate_human_reviews)
OR EXISTS (
SELECT 1 FROM execution_result_requests
WHERE operation = 'HUMAN_REVIEW'
)
THEN 0
ELSE 1
END;
DROP TABLE candidate_decisions_v6_down_guard;
DROP TABLE candidate_human_review_reasons;
DROP TABLE candidate_human_review_items;
DROP INDEX candidate_human_reviews_execution_version_idx;
DROP TABLE candidate_human_reviews;
DROP TABLE candidate_recommendations;
DROP TABLE candidate_evaluations;
DROP TABLE model_runs;
DROP TABLE candidate_observations;
DROP TABLE candidate_search_runs;
ALTER TABLE execution_result_requests RENAME TO execution_result_requests_v6;
CREATE TABLE execution_result_requests (
user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
operation TEXT NOT NULL
CHECK (operation IN ('EVENTS', 'EVIDENCE', 'CANDIDATES', 'COMPLETE', 'FAIL')),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
claim_token_sha256 TEXT NOT NULL
CHECK (
length(claim_token_sha256) = 64
AND claim_token_sha256 NOT GLOB '*[^0-9a-f]*'
),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
resource_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_id, operation, idempotency_key)
);
INSERT INTO execution_result_requests (
user_id,
device_id,
operation,
idempotency_key,
request_sha256,
claim_token_sha256,
task_id,
execution_id,
resource_id,
created_at
)
SELECT
user_id,
device_id,
operation,
idempotency_key,
request_sha256,
claim_token_sha256,
task_id,
execution_id,
resource_id,
created_at
FROM execution_result_requests_v6;
DROP TABLE execution_result_requests_v6;