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
@@ -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)
}
}