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
@@ -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"`