feat(t214): persist candidate observation identities

This commit is contained in:
QiuSW
2026-07-28 12:21:55 +08:00
parent acd3c85b5b
commit 56e7a2be33
29 changed files with 622 additions and 94 deletions
+13
View File
@@ -168,6 +168,19 @@ type CandidateObservation struct {
EvidenceAssetIDs []string
CollectionStatus string
ObservedAt time.Time
Identity *CandidateObservationIdentity
}
type CandidateObservationIdentity struct {
CandidateKey string
ExecutionID string
CandidateOrdinal int
CardSignature string
DetailSignature string
DetailEvidenceSHA256 string
SpecificationEvidenceSHA256 string
IdentityVersion int
CreatedAt time.Time
}
type CandidateModelRun struct {
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 6 {
t.Fatalf("initial Up() applied = %d, want 6", applied)
} else if applied != 7 {
t.Fatalf("initial Up() applied = %d, want 7", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v7) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v6) error = %v", err)
@@ -47,9 +50,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
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)
t.Fatalf("Up(v5-v7) over historical data error = %v", err)
} else if applied != 3 {
t.Fatalf("Up(v5-v7) applied = %d, want 3", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v7) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -69,9 +77,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v6) error = %v", err)
} else if applied != 3 {
t.Fatalf("final Up(v4-v6) applied = %d, want 3", applied)
t.Fatalf("final Up(v4-v7) error = %v", err)
} else if applied != 4 {
t.Fatalf("final Up(v4-v7) applied = %d, want 4", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -307,6 +315,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v7) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v6) error = %v", err)
}
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 6 {
t.Fatalf("Up() applied = %d, want 6", applied)
if applied != 7 {
t.Fatalf("Up() applied = %d, want 7", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -37,6 +37,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
4: true,
5: true,
6: true,
7: true,
})
applied, err = runner.Up(context.Background())
@@ -56,7 +57,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
3: true,
4: true,
5: true,
6: false,
6: true,
7: false,
})
applied, err = runner.Up(context.Background())
@@ -73,6 +75,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
4: true,
5: true,
6: true,
7: 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(v7) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v6) error = %v", err)
}
@@ -408,9 +411,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-v6) error = %v", err)
} else if applied != 4 {
t.Fatalf("Up(v3-v6) applied = %d, want 4", applied)
t.Fatalf("Up(v3-v7) error = %v", err)
} else if applied != 5 {
t.Fatalf("Up(v3-v7) applied = %d, want 5", applied)
}
}
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
@@ -93,6 +94,25 @@ func storeCandidateDecisionDataset(
if err != nil {
return repositoryFailure(err)
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO candidate_observation_identities (
candidate_key, execution_id, candidate_ordinal,
card_signature, detail_signature, detail_evidence_sha256,
specification_evidence_sha256, identity_version, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
candidateObservationKey(write.ExecutionID, candidate),
write.ExecutionID,
candidate.Ordinal,
candidate.CardSignature,
candidate.DetailSignature,
candidate.DetailEvidenceSHA256,
candidate.SpecificationEvidenceSHA256,
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
}
if batch.ProvenanceJSON != nil {
var provenance usecase.ExecutionProvenance
@@ -567,12 +587,21 @@ func getCandidateDecisionDataset(
}
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`,
`SELECT observation.task_id, observation.execution_id,
observation.ordinal, observation.title, observation.sku_text,
observation.price_text, observation.product_url,
observation.image_url, observation.evidence_asset_ids_json,
observation.collection_status, observation.observed_at,
identity.candidate_key, identity.card_signature,
identity.detail_signature, identity.detail_evidence_sha256,
identity.specification_evidence_sha256,
identity.identity_version, identity.created_at
FROM candidate_observations AS observation
LEFT JOIN candidate_observation_identities AS identity
ON identity.execution_id = observation.execution_id
AND identity.candidate_ordinal = observation.ordinal
WHERE observation.task_id = ? AND observation.execution_id = ?
ORDER BY observation.ordinal ASC`,
taskID,
executionID,
)
@@ -582,6 +611,9 @@ func getCandidateDecisionDataset(
for rows.Next() {
var observation domain.CandidateObservation
var evidenceJSON, observedAt string
var candidateKey, cardSignature, detailSignature sql.NullString
var detailHash, specificationHash, identityAt sql.NullString
var identityVersion sql.NullInt64
if err := rows.Scan(
&observation.TaskID,
&observation.ExecutionID,
@@ -594,6 +626,13 @@ func getCandidateDecisionDataset(
&evidenceJSON,
&observation.CollectionStatus,
&observedAt,
&candidateKey,
&cardSignature,
&detailSignature,
&detailHash,
&specificationHash,
&identityVersion,
&identityAt,
); err != nil {
_ = rows.Close()
return nil, repositoryFailure(err)
@@ -610,6 +649,24 @@ func getCandidateDecisionDataset(
_ = rows.Close()
return nil, repositoryFailure(err)
}
if candidateKey.Valid {
identityCreatedAt, parseErr := parseTimestamp(identityAt.String)
if parseErr != nil {
_ = rows.Close()
return nil, repositoryFailure(parseErr)
}
observation.Identity = &domain.CandidateObservationIdentity{
CandidateKey: candidateKey.String,
ExecutionID: observation.ExecutionID,
CandidateOrdinal: observation.Ordinal,
CardSignature: cardSignature.String,
DetailSignature: detailSignature.String,
DetailEvidenceSHA256: detailHash.String,
SpecificationEvidenceSHA256: specificationHash.String,
IdentityVersion: int(identityVersion.Int64),
CreatedAt: identityCreatedAt,
}
}
dataset.Observations = append(dataset.Observations, observation)
}
if err := rows.Err(); err != nil {
@@ -823,6 +880,23 @@ func hashCandidateResult(candidates string, recommendation *string) string {
return hex.EncodeToString(digest.Sum(nil))
}
func candidateObservationKey(
executionID string,
candidate usecase.ExecutionCandidate,
) string {
digest := sha256.New()
_, _ = digest.Write([]byte("cmroubao-candidate-v1"))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(executionID))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(strconv.Itoa(candidate.Ordinal)))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(candidate.DetailSignature))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(candidate.SpecificationEvidenceSHA256))
return hex.EncodeToString(digest.Sum(nil))
}
func nullableSQLString(value sql.NullString) any {
if !value.Valid {
return nil
@@ -0,0 +1,34 @@
package sqlite
import (
"strings"
"testing"
"cmroubao/backend-api/internal/usecase"
)
func TestCandidateObservationKeyIsStableAndObservationScoped(t *testing.T) {
candidate := usecase.ExecutionCandidate{
Ordinal: 1,
DetailSignature: strings.Repeat("c", 64),
SpecificationEvidenceSHA256: strings.Repeat("d", 64),
}
first := candidateObservationKey("execution-1", candidate)
replayed := candidateObservationKey("execution-1", candidate)
otherOrdinal := candidate
otherOrdinal.Ordinal = 2
if len(first) != 64 {
t.Fatalf("candidate key length = %d", len(first))
}
if replayed != first {
t.Fatalf("replayed key = %q, want %q", replayed, first)
}
if candidateObservationKey("execution-1", otherOrdinal) == first {
t.Fatal("different observation ordinal produced the same key")
}
if candidateObservationKey("execution-2", candidate) == first {
t.Fatal("different execution produced the same key")
}
}
@@ -557,9 +557,7 @@ func validateCandidateEvidence(
write usecase.ExecutionResultWrite,
candidatesJSON string,
) error {
var candidates []struct {
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
var candidates []usecase.ExecutionCandidate
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant
}
@@ -569,6 +567,30 @@ func validateCandidateEvidence(
); err != nil {
return err
}
if len(candidate.EvidenceAssetIDs) != 2 {
return usecase.ErrTaskStateConflict
}
expectedHashes := []string{
candidate.DetailEvidenceSHA256,
candidate.SpecificationEvidenceSHA256,
}
for index, evidenceID := range candidate.EvidenceAssetIDs {
var actualHash string
err := tx.QueryRowContext(
ctx,
`SELECT sha256 FROM execution_evidence_assets
WHERE id = ? AND task_id = ? AND execution_id = ?`,
evidenceID,
write.TaskID,
write.ExecutionID,
).Scan(&actualHash)
if err != nil {
return repositoryFailure(err)
}
if actualHash != expectedHashes[index] {
return usecase.ErrTaskStateConflict
}
}
}
return nil
}
@@ -449,7 +449,7 @@ func candidateDecisionDatasetResponse(
) gin.H {
observations := make([]gin.H, 0, len(dataset.Observations))
for _, observation := range dataset.Observations {
observations = append(observations, gin.H{
item := gin.H{
"ordinal": observation.Ordinal,
"title": observation.Title,
"sku_text": observation.SKUText,
@@ -459,7 +459,19 @@ func candidateDecisionDatasetResponse(
"evidence_asset_ids": observation.EvidenceAssetIDs,
"collection_status": observation.CollectionStatus,
"observed_at": formatTime(observation.ObservedAt),
})
}
if identity := observation.Identity; identity != nil {
item["identity"] = gin.H{
"candidate_key": identity.CandidateKey,
"identity_version": identity.IdentityVersion,
"card_signature": identity.CardSignature,
"detail_signature": identity.DetailSignature,
"detail_evidence_sha256": identity.DetailEvidenceSHA256,
"specification_evidence_sha256": identity.SpecificationEvidenceSHA256,
"created_at": formatTime(identity.CreatedAt),
}
}
observations = append(observations, item)
}
evaluations := make([]gin.H, 0, len(dataset.Evaluations))
for _, evaluation := range dataset.Evaluations {
@@ -17,6 +17,7 @@ import (
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
@@ -26,6 +27,37 @@ import (
"github.com/gin-gonic/gin"
)
func TestCandidateDecisionDatasetResponseIncludesPersistentIdentity(t *testing.T) {
createdAt := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
response := candidateDecisionDatasetResponse(&domain.CandidateDecisionDataset{
Observations: []domain.CandidateObservation{
{
Ordinal: 1,
Identity: &domain.CandidateObservationIdentity{
CandidateKey: strings.Repeat("a", 64),
CardSignature: strings.Repeat("b", 64),
DetailSignature: strings.Repeat("c", 64),
DetailEvidenceSHA256: strings.Repeat("d", 64),
SpecificationEvidenceSHA256: strings.Repeat("e", 64),
IdentityVersion: 1,
CreatedAt: createdAt,
},
},
},
})
observations, ok := response["observations"].([]gin.H)
if !ok || len(observations) != 1 {
t.Fatalf("observations = %#v", response["observations"])
}
identity, ok := observations[0]["identity"].(gin.H)
if !ok ||
identity["candidate_key"] != strings.Repeat("a", 64) ||
identity["identity_version"] != 1 {
t.Fatalf("identity = %#v", observations[0]["identity"])
}
}
func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "asset-key-1")
@@ -522,7 +522,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
})
requireDeviceStatus(t, events, http.StatusOK)
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
detailEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
@@ -533,15 +533,42 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, evidence, http.StatusCreated)
requireDeviceStatus(t, detailEvidence, http.StatusCreated)
var evidenceResponse struct {
Evidence struct {
ID string `json:"id"`
ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"`
}
decodeResponse(t, evidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" {
t.Fatalf("evidence response = %s", evidence.Body.String())
decodeResponse(t, detailEvidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" || evidenceResponse.Evidence.SHA256 == "" {
t.Fatalf("detail evidence response = %s", detailEvidence.Body.String())
}
specificationEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
body: deviceReferenceImage(t, 702),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-evidence-2",
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, specificationEvidence, http.StatusCreated)
var specificationEvidenceResponse struct {
Evidence struct {
ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"`
}
decodeResponse(t, specificationEvidence, &specificationEvidenceResponse)
if specificationEvidenceResponse.Evidence.ID == "" ||
specificationEvidenceResponse.Evidence.SHA256 == "" {
t.Fatalf(
"specification evidence response = %s",
specificationEvidence.Body.String(),
)
}
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
@@ -549,13 +576,36 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
t.Fatalf("get task for content hash: %v", err)
}
taskHash := usecase.TaskContentSHA256(detail.Task)
cardSignature := strings.Repeat("b", 64)
detailSignature := strings.Repeat("c", 64)
candidatePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"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}]}`,
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
)
badCandidatePayload := strings.Replace(
candidatePayload,
evidenceResponse.Evidence.SHA256,
strings.Repeat("0", 64),
1,
)
badCandidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(badCandidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-bad-hash",
})
requireDeviceStatus(t, badCandidates, http.StatusConflict)
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
@@ -566,6 +616,19 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidates, http.StatusOK)
candidateReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(candidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidateReplay, http.StatusOK)
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
}
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":""}]}`,
@@ -633,15 +696,20 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
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")
t.Fatal("candidate identity migration down succeeded with retained 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}`,
`{"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":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null},"order_submitted":false}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
)
complete := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
@@ -677,7 +745,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.Outcome == nil ||
detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 1 ||
len(detail.Report.EvidenceAssets) != 2 ||
detail.Report.CandidateBatch == nil ||
detail.Report.DecisionDataset == nil ||
len(detail.Report.DecisionDataset.Observations) != 1 ||
@@ -686,6 +754,16 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.DecisionDataset.HumanReviews[1].Version != 2 {
t.Fatalf("execution report = %+v", detail.Report)
}
identity := detail.Report.DecisionDataset.Observations[0].Identity
if identity == nil ||
len(identity.CandidateKey) != 64 ||
identity.CardSignature != cardSignature ||
identity.DetailSignature != detailSignature ||
identity.DetailEvidenceSHA256 != evidenceResponse.Evidence.SHA256 ||
identity.SpecificationEvidenceSHA256 !=
specificationEvidenceResponse.Evidence.SHA256 {
t.Fatalf("candidate identity = %+v", identity)
}
}
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
@@ -90,14 +90,18 @@ type CandidateHardConstraintEvaluation struct {
}
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"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
SKUText string `json:"sku_text"`
Price string `json:"price"`
ProductURL string `json:"product_url"`
ImageURL string `json:"image_url"`
CardSignature string `json:"card_signature"`
DetailSignature string `json:"detail_signature"`
DetailEvidenceSHA256 string `json:"detail_evidence_sha256"`
SpecificationEvidenceSHA256 string `json:"specification_evidence_sha256"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
Evaluation *CandidateEvaluation `json:"evaluation"`
}
type CandidateRecommendation struct {
@@ -603,7 +607,11 @@ func validCandidate(candidate ExecutionCandidate, mode string) bool {
!validOptionalAuditText(candidate.Price, 64) ||
!validObservationURL(candidate.ProductURL) ||
!validObservationURL(candidate.ImageURL) ||
len(candidate.EvidenceAssetIDs) > 5 {
!sha256Pattern.MatchString(candidate.CardSignature) ||
!sha256Pattern.MatchString(candidate.DetailSignature) ||
!sha256Pattern.MatchString(candidate.DetailEvidenceSHA256) ||
!sha256Pattern.MatchString(candidate.SpecificationEvidenceSHA256) ||
len(candidate.EvidenceAssetIDs) != 2 {
return false
}
for _, id := range candidate.EvidenceAssetIDs {
@@ -611,6 +619,9 @@ func validCandidate(candidate ExecutionCandidate, mode string) bool {
return false
}
}
if candidate.EvidenceAssetIDs[0] == candidate.EvidenceAssetIDs[1] {
return false
}
if mode == manualFirstMode {
return candidate.Evaluation == nil
}
@@ -98,8 +98,16 @@ func validAIExecutionCandidateCommand() StoreExecutionCandidatesCommand {
},
Candidates: []ExecutionCandidate{
{
Ordinal: 1,
Title: "拼多多图片候选 1",
Ordinal: 1,
Title: "拼多多图片候选 1",
CardSignature: strings.Repeat("b", 64),
DetailSignature: strings.Repeat("c", 64),
DetailEvidenceSHA256: strings.Repeat("d", 64),
SpecificationEvidenceSHA256: strings.Repeat("e", 64),
EvidenceAssetIDs: []string{
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
},
Evaluation: &CandidateEvaluation{
Decision: "REVIEW",
Score: 0.9,
@@ -129,3 +137,13 @@ func validAIExecutionCandidateCommand() StoreExecutionCandidatesCommand {
},
}
}
func TestStoreCandidatesRejectsDuplicateEvidenceReferences(t *testing.T) {
command := validAIExecutionCandidateCommand()
command.Candidates[0].EvidenceAssetIDs[1] =
command.Candidates[0].EvidenceAssetIDs[0]
if err := validateCandidateCommand(command); err == nil {
t.Fatal("expected duplicate evidence references to be rejected")
}
}
@@ -0,0 +1,53 @@
-- +goose Up
CREATE TABLE candidate_observation_identities (
candidate_key TEXT PRIMARY KEY NOT NULL
CHECK (
length(candidate_key) = 64
AND candidate_key NOT GLOB '*[^0-9a-f]*'
),
execution_id TEXT NOT NULL,
candidate_ordinal INTEGER NOT NULL,
card_signature TEXT NOT NULL
CHECK (
length(card_signature) = 64
AND card_signature NOT GLOB '*[^0-9a-f]*'
),
detail_signature TEXT NOT NULL
CHECK (
length(detail_signature) = 64
AND detail_signature NOT GLOB '*[^0-9a-f]*'
),
detail_evidence_sha256 TEXT NOT NULL
CHECK (
length(detail_evidence_sha256) = 64
AND detail_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
),
specification_evidence_sha256 TEXT NOT NULL
CHECK (
length(specification_evidence_sha256) = 64
AND specification_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
),
identity_version INTEGER NOT NULL
CHECK (identity_version = 1),
created_at TEXT NOT NULL,
UNIQUE (execution_id, candidate_ordinal),
FOREIGN KEY (execution_id, candidate_ordinal)
REFERENCES candidate_observations(execution_id, ordinal)
ON UPDATE RESTRICT ON DELETE CASCADE
);
-- +goose Down
CREATE TEMP TABLE candidate_identities_v7_down_guard (
allowed INTEGER NOT NULL
CHECK (allowed = 1)
);
INSERT INTO candidate_identities_v7_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM candidate_observation_identities)
THEN 0
ELSE 1
END;
DROP TABLE candidate_identities_v7_down_guard;
DROP TABLE candidate_observation_identities;