feat(t208): close candidate decision feedback loop
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user