From e7f4c3e114be0ec02d76a49ee6b571c13b0c11c8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 28 Jul 2026 11:57:25 +0800 Subject: [PATCH] feat(t208): close candidate decision feedback loop --- android-buyer/app/build.gradle.kts | 4 +- .../java/com/roubao/autopilot/MainActivity.kt | 111 ++- .../procurement/ExecutionResultOutbox.kt | 201 +++++ .../procurement/ProcurementApiClient.kt | 2 + .../procurement/ProcurementModels.kt | 1 + .../procurement/ProcurementRepository.kt | 63 +- .../autopilot/ui/screens/SearchProbeScreen.kt | 299 ++++++- .../CandidateHumanReviewDraftPolicyTest.kt | 67 ++ .../ExecutionCandidateIdentityPolicyTest.kt | 17 + .../procurement/ProcurementApiClientTest.kt | 23 + backend-api/internal/domain/task.go | 109 ++- .../migration/claims_migration_test.go | 29 +- .../platform/migration/runner_test.go | 9 +- .../repository/sqlite/auth_repository_test.go | 9 +- .../sqlite/candidate_decision_repository.go | 838 ++++++++++++++++++ .../sqlite/execution_result_repository.go | 18 + .../transport/httpapi/admin_handlers.go | 84 ++ .../transport/httpapi/device_handlers.go | 79 ++ .../transport/httpapi/device_handlers_test.go | 76 +- .../webui/templates/task-detail.gohtml | 8 +- backend-api/internal/transport/webui/types.go | 20 +- .../transport/webui/usecase_adapter.go | 22 + .../usecase/candidate_human_review.go | 277 ++++++ .../usecase/candidate_human_review_test.go | 85 ++ .../usecase/execution_result_ports.go | 5 + .../usecase/execution_result_service.go | 47 +- .../usecase/execution_result_service_test.go | 30 +- .../migrations/00006_candidate_decisions.sql | 365 ++++++++ docs/00-ai-start-here.md | 5 +- docs/02-requirements.md | 10 +- docs/04-architecture.md | 5 +- docs/08-interaction-checklist.md | 6 +- docs/api.md | 58 +- docs/current-state.md | 40 +- docs/tasks/T-208.md | 35 +- 35 files changed, 2854 insertions(+), 203 deletions(-) create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/procurement/CandidateHumanReviewDraftPolicyTest.kt create mode 100644 backend-api/internal/repository/sqlite/candidate_decision_repository.go create mode 100644 backend-api/internal/usecase/candidate_human_review.go create mode 100644 backend-api/internal/usecase/candidate_human_review_test.go create mode 100644 backend-api/migrations/00006_candidate_decisions.sql diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index d9f7ee8..4ec5b7e 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.roubao.autopilot" minSdk = 26 targetSdk = 34 - versionCode = 15 - versionName = "1.4.10" + versionCode = 16 + versionName = "1.4.11" vectorDrawables { useSupportLibrary = true diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt index 0c5b6e0..cd07387 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt @@ -107,6 +107,7 @@ import com.roubao.autopilot.procurement.ExecutionMode import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot import com.roubao.autopilot.procurement.ProcurementRepository import com.roubao.autopilot.procurement.RankedExecutionCandidateDraft +import com.roubao.autopilot.procurement.CandidateHumanReviewDraft import com.roubao.autopilot.task.RequirementProbeFixture import com.roubao.autopilot.workflow.WorkflowReport import com.roubao.autopilot.workflow.WorkflowRunner @@ -431,6 +432,10 @@ class MainActivity : ComponentActivity() { candidateEvaluationState = evaluationState, candidateReviewBatch = reviewBatch, candidateEvaluationFailureCode = evaluationFailure, + candidateOrdinals = taskCandidateDrafts.value + .map { it.candidate.ordinal }, + hasBudget = + procurementState.task?.maxBudget != null, canStartCandidateEvaluation = extractionState == RequirementProbeState.READY && extraction != null && @@ -449,11 +454,11 @@ class MainActivity : ComponentActivity() { onStopCandidateEvaluation = { stopCandidateEvaluation() }, - onAcceptCandidate = { reason -> - acceptRecommendedCandidate(reason) + onAcceptCandidate = { review -> + acceptCandidateReview(review) }, - onRejectCandidates = { reason -> - rejectCandidateReview(reason) + onRejectCandidates = { review -> + rejectCandidateReview(review) }, onStart = { startSearchProbe() }, onStop = { stopSearchProbe() } @@ -1032,18 +1037,17 @@ class MainActivity : ComponentActivity() { val evidenceByOrdinal = validated.associateBy { it.ordinal } - val drafts = ranked.map { rankedCandidate -> - val assessment = rankedCandidate.assessment + val assessments = result.batch.assessments + .sortedBy { it.ordinal } + val drafts = assessments.map { assessment -> val candidateEvidence = requireNotNull( - evidenceByOrdinal[ - rankedCandidate.sourceOrdinal - ] + evidenceByOrdinal[assessment.ordinal] ) ExecutionCandidateDraft( - ordinal = rankedCandidate.rankedOrdinal, + ordinal = assessment.ordinal, title = "拼多多图片候选 " + - rankedCandidate.sourceOrdinal, + assessment.ordinal, skuText = candidateEvidence.specification .selectedSummary @@ -1078,26 +1082,14 @@ class MainActivity : ComponentActivity() { ) ) } - val selectedEvidence = ranked.flatMap { - rankedCandidate -> + val selectedEvidence = assessments.flatMap { + assessment -> val candidate = requireNotNull( - evidenceByOrdinal[ - rankedCandidate.sourceOrdinal - ] - ) - require( - candidate.detailSha256 == - rankedCandidate.evidenceSha256 - ) - require( - candidate.specificationSha256 == - rankedCandidate - .specificationEvidenceSha256 + evidenceByOrdinal[assessment.ordinal] ) listOf( ExecutionEvidenceDraft( - ordinal = - rankedCandidate.rankedOrdinal, + ordinal = assessment.ordinal, pngBytes = candidate.detailPngBytes, sha256 = @@ -1106,8 +1098,7 @@ class MainActivity : ComponentActivity() { ExecutionEvidenceKind.DETAIL ), ExecutionEvidenceDraft( - ordinal = - rankedCandidate.rankedOrdinal, + ordinal = assessment.ordinal, pngBytes = candidate .specificationPngBytes, @@ -1139,9 +1130,11 @@ class MainActivity : ComponentActivity() { .requirementReferenceImageSha256 ), candidates = drafts, - recommendation = drafts.firstOrNull()?.let { + recommendation = ranked.firstOrNull()?.let { + rankedCandidate -> ExecutionRecommendation( - candidateOrdinal = 1, + candidateOrdinal = + rankedCandidate.sourceOrdinal, policyVersion = "sku-hard-constraints-v1", reasons = listOf( @@ -1161,17 +1154,19 @@ class MainActivity : ComponentActivity() { } taskCandidateDrafts.value = runCatching { ExecutionCandidateIdentityPolicy.bind( - identities = ranked.map { rankedCandidate -> + identities = assessments.map { assessment -> + val candidate = requireNotNull( + evidenceByOrdinal[assessment.ordinal] + ) ExecutionCandidateSourceIdentity( sourceOrdinal = - rankedCandidate.sourceOrdinal, + assessment.ordinal, rankedOrdinal = - rankedCandidate.rankedOrdinal, + assessment.ordinal, evidenceSha256 = - rankedCandidate.evidenceSha256, + candidate.detailSha256, specificationEvidenceSha256 = - rankedCandidate - .specificationEvidenceSha256 + candidate.specificationSha256 ) }, candidates = queued @@ -1283,7 +1278,7 @@ class MainActivity : ComponentActivity() { .joinToString(" ") .take(160) - private fun acceptRecommendedCandidate(operatorReason: String) { + private fun acceptCandidateReview(review: CandidateHumanReviewDraft) { candidateEvaluationState.value = CandidateHumanReviewPolicy.accept( currentState = candidateEvaluationState.value, batch = candidateReviewBatch.value @@ -1299,8 +1294,11 @@ class MainActivity : ComponentActivity() { CandidateEvaluationState.HUMAN_ACCEPTED ) { val candidate = runCatching { - ExecutionCandidateIdentityPolicy.recommended( - taskCandidateDrafts.value + ExecutionCandidateIdentityPolicy.select( + candidates = taskCandidateDrafts.value, + ordinal = requireNotNull( + review.selectedCandidateOrdinal + ) ) }.getOrElse { setCandidateEvaluationFailure( @@ -1308,14 +1306,10 @@ class MainActivity : ComponentActivity() { ) return } - if ( - candidate != null && - procurementRepository.currentProbeTask() != null - ) { + if (procurementRepository.currentProbeTask() != null) { lifecycleScope.launch { procurementRepository.completeExecution( - outcome = "CANDIDATE_ACCEPTED", - operatorReason = operatorReason, + review = review, candidate = candidate ) } @@ -1323,24 +1317,19 @@ class MainActivity : ComponentActivity() { } } - private fun rejectCandidateReview(operatorReason: String) { + private fun rejectCandidateReview(review: CandidateHumanReviewDraft) { candidateEvaluationState.value = CandidateHumanReviewPolicy.reject( candidateEvaluationState.value ) - if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_REJECTED && - procurementRepository.currentProbeTask() != null - ) { - lifecycleScope.launch { - procurementRepository.completeExecution( - outcome = if (taskCandidateDrafts.value.isEmpty()) { - "NO_MATCH" - } else { - "CANDIDATE_REJECTED" - }, - operatorReason = operatorReason - ) - } - } + if ( + candidateEvaluationState.value == + CandidateEvaluationState.HUMAN_REJECTED && + procurementRepository.currentProbeTask() != null + ) { + lifecycleScope.launch { + procurementRepository.completeExecution(review = review) + } + } } private fun setCandidateEvaluationFailure( diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt index 472142f..0cf0b34 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt @@ -101,6 +101,16 @@ object ExecutionCandidateIdentityPolicy { it.candidate.ordinal == 1 }.candidate } + + fun select( + candidates: List, + ordinal: Int + ): ExecutionCandidateDraft = + candidates.single { + it.identity.sourceOrdinal == ordinal && + it.identity.rankedOrdinal == ordinal && + it.candidate.ordinal == ordinal + }.candidate } data class ExecutionCandidateEvaluation( @@ -134,6 +144,197 @@ data class ExecutionCandidateBatchDraft( val recommendation: ExecutionRecommendation? = null ) +data class CandidateHumanReviewItemDraft( + val candidateOrdinal: Int, + val label: String, + val primaryReasonCode: String, + val reasonCodes: List, + val note: String = "" +) + +data class CandidateHumanReviewDraft( + val outcome: String, + val selectedCandidateOrdinal: Int?, + val primaryReasonCode: String, + val note: String = "", + val items: List, + val supersedesReviewId: String? = null +) { + fun operatorSummary(): String = + listOf(primaryReasonCode, note.trim()) + .filter { it.isNotBlank() } + .joinToString(": ") +} + +object CandidateHumanReviewDraftPolicy { + const val REASON_SCHEMA_VERSION = 1 + + val acceptReasonCodes = listOf( + "SKU_MATCH", + "IMAGE_MATCH", + "PRICE_ACCEPTABLE", + "EVIDENCE_SUFFICIENT", + "OTHER" + ) + val rejectReasonCodes = listOf( + "SKU_MISMATCH", + "IMAGE_MISMATCH", + "PRICE_TOO_HIGH", + "OUT_OF_STOCK", + "EVIDENCE_INSUFFICIENT", + "NOT_BEST_MATCH", + "OTHER" + ) + + fun accepted( + candidateOrdinals: List, + selectedOrdinal: Int, + acceptReasonCode: String, + acceptNote: String, + rejectReasonCode: String, + rejectNote: String, + hasBudget: Boolean + ): CandidateHumanReviewDraft { + validateOrdinals(candidateOrdinals) + require(selectedOrdinal in candidateOrdinals) { "请选择有效候选" } + validateReason(acceptReasonCode, acceptNote, acceptReasonCodes, hasBudget) + if (candidateOrdinals.size > 1) { + validateReason( + rejectReasonCode, + rejectNote, + rejectReasonCodes, + hasBudget + ) + } + return CandidateHumanReviewDraft( + outcome = "CANDIDATE_ACCEPTED", + selectedCandidateOrdinal = selectedOrdinal, + primaryReasonCode = "SELECTED_BEST_MATCH", + items = candidateOrdinals.map { ordinal -> + if (ordinal == selectedOrdinal) { + item(ordinal, "ACCEPT", acceptReasonCode, acceptNote) + } else { + item(ordinal, "REJECT", rejectReasonCode, rejectNote) + } + } + ).also { validate(it, hasBudget) } + } + + fun rejected( + candidateOrdinals: List, + rejectReasonCode: String, + rejectNote: String, + hasBudget: Boolean + ): CandidateHumanReviewDraft { + validateOrdinals(candidateOrdinals) + if (candidateOrdinals.isNotEmpty()) { + validateReason( + rejectReasonCode, + rejectNote, + rejectReasonCodes, + hasBudget + ) + } + return CandidateHumanReviewDraft( + outcome = if (candidateOrdinals.isEmpty()) { + "NO_MATCH" + } else { + "CANDIDATE_REJECTED" + }, + selectedCandidateOrdinal = null, + primaryReasonCode = "NO_ACCEPTABLE_CANDIDATE", + items = candidateOrdinals.map { ordinal -> + item(ordinal, "REJECT", rejectReasonCode, rejectNote) + } + ).also { validate(it, hasBudget) } + } + + fun validate(review: CandidateHumanReviewDraft, hasBudget: Boolean) { + val ordinals = review.items.map { it.candidateOrdinal } + validateOrdinals(ordinals) + require(review.primaryReasonCode in setOf( + "SELECTED_BEST_MATCH", + "NO_ACCEPTABLE_CANDIDATE", + "INSUFFICIENT_EVIDENCE", + "OTHER" + )) { "人工评审主原因无效" } + review.items.forEach { item -> + val allowed = if (item.label == "ACCEPT") { + acceptReasonCodes + } else { + require(item.label == "REJECT") { "人工评审标签无效" } + rejectReasonCodes + } + require( + item.reasonCodes == listOf(item.primaryReasonCode) + ) { "人工评审原因必须明确且唯一" } + validateReason( + item.primaryReasonCode, + item.note, + allowed, + hasBudget + ) + } + val accepted = review.items.filter { it.label == "ACCEPT" } + if (review.outcome == "CANDIDATE_ACCEPTED") { + require( + accepted.size == 1 && + accepted.single().candidateOrdinal == + review.selectedCandidateOrdinal && + review.primaryReasonCode == "SELECTED_BEST_MATCH" + ) { "人工选择与候选标签不一致" } + } else { + require( + review.outcome in setOf( + "CANDIDATE_REJECTED", + "NO_MATCH", + "MANUAL_REQUIRED" + ) && + review.selectedCandidateOrdinal == null && + accepted.isEmpty() + ) { "人工拒绝结论无效" } + } + } + + private fun item( + ordinal: Int, + label: String, + reasonCode: String, + note: String + ) = CandidateHumanReviewItemDraft( + candidateOrdinal = ordinal, + label = label, + primaryReasonCode = reasonCode, + reasonCodes = listOf(reasonCode), + note = note.trim() + ) + + private fun validateOrdinals(candidateOrdinals: List) { + require(candidateOrdinals == (1..candidateOrdinals.size).toList()) { + "候选编号必须连续" + } + require(candidateOrdinals.size <= 5) { "候选数量不能超过 5 个" } + } + + private fun validateReason( + reasonCode: String, + note: String, + allowed: List, + hasBudget: Boolean + ) { + require(reasonCode in allowed) { "请选择有效原因" } + require( + hasBudget || + reasonCode !in setOf("PRICE_ACCEPTABLE", "PRICE_TOO_HIGH") + ) { "任务未提供预算,不能选择价格原因" } + val trimmed = note.trim() + require(trimmed.length <= 200) { "补充说明不能超过 200 字" } + if (reasonCode == "OTHER") { + require(trimmed.length >= 4) { "其他原因至少填写 4 个字" } + } + } +} + object ExecutionTaskHash { fun sha256(task: RemotePurchaseTask): String { val maxBudgetCents = task.maxBudget diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt index c3ca221..8157acb 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt @@ -275,6 +275,8 @@ class ProcurementApiClient( ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events" ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence" ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates" + ExecutionOutboxType.HUMAN_REVIEW -> + "/api/v1/tasks/${task.id}/human-reviews" ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete" ExecutionOutboxType.FAIL -> "/api/v1/tasks/${task.id}/fail" } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt index 81fbed0..15a3037 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt @@ -105,6 +105,7 @@ enum class ExecutionOutboxType { EVENTS, EVIDENCE, CANDIDATES, + HUMAN_REVIEW, COMPLETE, FAIL } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt index 9fc9f20..8ef516f 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt @@ -477,28 +477,77 @@ class ProcurementRepository( } suspend fun completeExecution( - outcome: String, - operatorReason: String, + review: CandidateHumanReviewDraft, candidate: ExecutionCandidateDraft? = null ): Boolean = operation { val task = requireCurrentTask() val execution = requireActiveExecution() - require(operatorReason.trim().isNotEmpty()) { "请填写人工确认理由" } - require(outcome in COMPLETE_OUTCOMES) { "采购结论无效" } - if (outcome == "CANDIDATE_ACCEPTED") { + CandidateHumanReviewDraftPolicy.validate( + review, + hasBudget = task.maxBudget != null + ) + require(persisted.outbox.none { + it.type == ExecutionOutboxType.COMPLETE || + it.type == ExecutionOutboxType.FAIL + }) { "当前执行已有待回传终态" } + require(review.operatorSummary().isNotBlank()) { "人工确认理由无效" } + require(review.outcome in COMPLETE_OUTCOMES) { "采购结论无效" } + if (review.outcome == "CANDIDATE_ACCEPTED") { require(candidate != null) { "接受候选时必须保留候选证据" } + require(candidate.ordinal == review.selectedCandidateOrdinal) { + "人工选择与候选不一致" + } } else { require(candidate == null) { "当前结论不能附带候选" } } + val reviewPayload = JSONObject() + .put("execution_id", execution.id) + .put("claim_generation", task.claimGeneration) + .put("task_content_sha256", ExecutionTaskHash.sha256(task)) + .put( + "reason_schema_version", + CandidateHumanReviewDraftPolicy.REASON_SCHEMA_VERSION + ) + .put("outcome", review.outcome) + .put( + "selected_candidate_ordinal", + review.selectedCandidateOrdinal ?: JSONObject.NULL + ) + .put("primary_reason_code", review.primaryReasonCode) + .put("note", review.note.trim()) + .put( + "supersedes_review_id", + review.supersedesReviewId ?: JSONObject.NULL + ) + .put("items", JSONArray().apply { + review.items.forEach { item -> + put( + JSONObject() + .put("candidate_ordinal", item.candidateOrdinal) + .put("label", item.label) + .put("primary_reason_code", item.primaryReasonCode) + .put("reason_codes", JSONArray(item.reasonCodes)) + .put("note", item.note.trim()) + ) + } + }) val payload = JSONObject() .put("execution_id", execution.id) .put("claim_generation", task.claimGeneration) .put("task_content_sha256", ExecutionTaskHash.sha256(task)) .put("execution_mode", requireNotNull(execution.provenance).mode.name) - .put("outcome", outcome) - .put("operator_reason", operatorReason.trim()) + .put("outcome", review.outcome) + .put("operator_reason", review.operatorSummary()) .put("order_submitted", false) candidate?.let { payload.put("candidate", candidateJSON(it)) } + appendOutboxLocked( + ExecutionOutboxItem( + id = UUID.randomUUID().toString(), + type = ExecutionOutboxType.HUMAN_REVIEW, + idempotencyKey = newOpaqueSecret(), + payload = reviewPayload.toString() + ) + ) appendTerminalOutboxLocked( ExecutionOutboxItem( id = UUID.randomUUID().toString(), diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt index de45177..c449131 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +60,8 @@ import com.roubao.autopilot.vlm.CandidateDecision import com.roubao.autopilot.vlm.HardConstraintMatchStatus import com.roubao.autopilot.vlm.SkuConstraintKind import com.roubao.autopilot.vlm.CandidateEvaluationWarningCode +import com.roubao.autopilot.procurement.CandidateHumanReviewDraft +import com.roubao.autopilot.procurement.CandidateHumanReviewDraftPolicy private data class ProbeStepUi( val id: String, @@ -106,13 +109,15 @@ fun SearchProbeScreen( candidateEvaluationState: CandidateEvaluationState, candidateReviewBatch: CandidateReviewBatch?, candidateEvaluationFailureCode: CandidateEvaluationFailureCode?, + candidateOrdinals: List, + hasBudget: Boolean, canStartCandidateEvaluation: Boolean, onStartRequirement: () -> Unit, onStopRequirement: () -> Unit, onStartCandidateEvaluation: () -> Unit, onStopCandidateEvaluation: () -> Unit, - onAcceptCandidate: (String) -> Unit, - onRejectCandidates: (String) -> Unit, + onAcceptCandidate: (CandidateHumanReviewDraft) -> Unit, + onRejectCandidates: (CandidateHumanReviewDraft) -> Unit, onStart: () -> Unit, onStop: () -> Unit ) { @@ -298,6 +303,8 @@ fun SearchProbeScreen( state = candidateEvaluationState, batch = candidateReviewBatch, failureCode = candidateEvaluationFailureCode, + candidateOrdinals = candidateOrdinals, + hasBudget = hasBudget, canStart = canStartCandidateEvaluation && !active, onStart = onStartCandidateEvaluation, onStop = onStopCandidateEvaluation, @@ -313,14 +320,22 @@ private fun CandidateEvaluationSection( state: CandidateEvaluationState, batch: CandidateReviewBatch?, failureCode: CandidateEvaluationFailureCode?, + candidateOrdinals: List, + hasBudget: Boolean, canStart: Boolean, onStart: () -> Unit, onStop: () -> Unit, - onAccept: (String) -> Unit, - onReject: (String) -> Unit + onAccept: (CandidateHumanReviewDraft) -> Unit, + onReject: (CandidateHumanReviewDraft) -> Unit ) { val colors = BaoziTheme.colors - var operatorReason by remember(state) { mutableStateOf("") } + var selectedOrdinal by remember(state, candidateOrdinals) { + mutableStateOf(null) + } + var acceptReason by remember(state) { mutableStateOf("") } + var rejectReason by remember(state) { mutableStateOf("") } + var acceptNote by remember(state) { mutableStateOf("") } + var rejectNote by remember(state) { mutableStateOf("") } val statusColor = when (state) { CandidateEvaluationState.AWAITING_CONFIRMATION, CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success @@ -369,13 +384,23 @@ private fun CandidateEvaluationSection( ) batch.assessments.forEach { assessment -> Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "候选 ${assessment.ordinal} · " + - candidateDecisionLabel(assessment.decision), - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = colors.textPrimary - ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (state in reviewableCandidateStates) { + RadioButton( + selected = selectedOrdinal == assessment.ordinal, + onClick = { selectedOrdinal = assessment.ordinal } + ) + } + Text( + text = "候选 ${assessment.ordinal} · " + + candidateDecisionLabel(assessment.decision), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = colors.textPrimary + ) + } RequirementDetailRow( "评分", "匹配 ${(assessment.score * 100).toInt()}% · " + @@ -409,23 +434,80 @@ private fun CandidateEvaluationSection( } ) RequirementDetailRow("订单状态", "未提交") + } else if (candidateOrdinals.isNotEmpty() && + state in reviewableCandidateStates + ) { + Text( + text = "选择人工确认的候选", + fontSize = 14.sp, + color = colors.textSecondary + ) + candidateOrdinals.forEach { ordinal -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedOrdinal == ordinal, + onClick = { selectedOrdinal = ordinal } + ) + Text( + text = "候选 $ordinal", + color = colors.textPrimary + ) + } + } } Spacer(modifier = Modifier.height(14.dp)) - if (state in setOf( - CandidateEvaluationState.AWAITING_CONFIRMATION, - CandidateEvaluationState.MANUAL_REVIEW, - CandidateEvaluationState.NO_MATCH - )) { - OutlinedTextField( - value = operatorReason, - onValueChange = { operatorReason = it.take(1000) }, - label = { Text("人工确认理由") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2 - ) - Spacer(modifier = Modifier.height(10.dp)) - } + if (state in reviewableCandidateStates) { + if (candidateOrdinals.isNotEmpty()) { + Text( + text = "所选候选的接受原因", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colors.textPrimary + ) + ReasonSelector( + selected = acceptReason, + options = CandidateHumanReviewDraftPolicy.acceptReasonCodes + .filter { hasBudget || it != "PRICE_ACCEPTABLE" }, + onSelected = { acceptReason = it } + ) + if (acceptReason == "OTHER") { + ReasonNoteField( + value = acceptNote, + onValueChange = { acceptNote = it }, + label = "接受原因补充说明" + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = if (candidateOrdinals.size > 1) { + "未选候选统一拒绝原因" + } else { + "拒绝全部候选时的原因" + }, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colors.textPrimary + ) + ReasonSelector( + selected = rejectReason, + options = CandidateHumanReviewDraftPolicy.rejectReasonCodes + .filter { hasBudget || it != "PRICE_TOO_HIGH" }, + onSelected = { rejectReason = it } + ) + if (rejectReason == "OTHER") { + ReasonNoteField( + value = rejectNote, + onValueChange = { rejectNote = it }, + label = "拒绝原因补充说明" + ) + } + } + Spacer(modifier = Modifier.height(10.dp)) + } when (state) { CandidateEvaluationState.RUNNING -> { OutlinedButton( @@ -439,8 +521,27 @@ private fun CandidateEvaluationSection( } CandidateEvaluationState.AWAITING_CONFIRMATION -> { Button( - onClick = { onAccept(operatorReason) }, - enabled = operatorReason.isNotBlank(), + onClick = { + onAccept( + CandidateHumanReviewDraftPolicy.accepted( + candidateOrdinals = candidateOrdinals, + selectedOrdinal = requireNotNull(selectedOrdinal), + acceptReasonCode = acceptReason, + acceptNote = acceptNote, + rejectReasonCode = rejectReason, + rejectNote = rejectNote, + hasBudget = hasBudget + ) + ) + }, + enabled = acceptReviewReady( + candidateOrdinals, + selectedOrdinal, + acceptReason, + acceptNote, + rejectReason, + rejectNote + ), modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = colors.primary @@ -452,8 +553,21 @@ private fun CandidateEvaluationSection( } Spacer(modifier = Modifier.height(8.dp)) OutlinedButton( - onClick = { onReject(operatorReason) }, - enabled = operatorReason.isNotBlank(), + onClick = { + onReject( + CandidateHumanReviewDraftPolicy.rejected( + candidateOrdinals, + rejectReason, + rejectNote, + hasBudget + ) + ) + }, + enabled = rejectReviewReady( + candidateOrdinals, + rejectReason, + rejectNote + ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Default.Close, contentDescription = null) @@ -463,10 +577,29 @@ private fun CandidateEvaluationSection( } CandidateEvaluationState.MANUAL_REVIEW, CandidateEvaluationState.NO_MATCH -> { - if (state == CandidateEvaluationState.MANUAL_REVIEW) { + if (candidateOrdinals.isNotEmpty()) { Button( - onClick = { onAccept(operatorReason) }, - enabled = operatorReason.isNotBlank(), + onClick = { + onAccept( + CandidateHumanReviewDraftPolicy.accepted( + candidateOrdinals, + requireNotNull(selectedOrdinal), + acceptReason, + acceptNote, + rejectReason, + rejectNote, + hasBudget + ) + ) + }, + enabled = acceptReviewReady( + candidateOrdinals, + selectedOrdinal, + acceptReason, + acceptNote, + rejectReason, + rejectNote + ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Default.CheckCircle, contentDescription = null) @@ -476,8 +609,21 @@ private fun CandidateEvaluationSection( Spacer(modifier = Modifier.height(8.dp)) } OutlinedButton( - onClick = { onReject(operatorReason) }, - enabled = operatorReason.isNotBlank(), + onClick = { + onReject( + CandidateHumanReviewDraftPolicy.rejected( + candidateOrdinals, + rejectReason, + rejectNote, + hasBudget + ) + ) + }, + enabled = rejectReviewReady( + candidateOrdinals, + rejectReason, + rejectNote + ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Default.Close, contentDescription = null) @@ -518,6 +664,89 @@ private fun CandidateEvaluationSection( } } +private val reviewableCandidateStates = setOf( + CandidateEvaluationState.AWAITING_CONFIRMATION, + CandidateEvaluationState.MANUAL_REVIEW, + CandidateEvaluationState.NO_MATCH +) + +@Composable +private fun ReasonSelector( + selected: String, + options: List, + onSelected: (String) -> Unit +) { + options.forEach { reason -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selected == reason, + onClick = { onSelected(reason) } + ) + Text( + text = reviewReasonLabel(reason), + fontSize = 14.sp, + color = BaoziTheme.colors.textPrimary + ) + } + } +} + +@Composable +private fun ReasonNoteField( + value: String, + onValueChange: (String) -> Unit, + label: String +) { + OutlinedTextField( + value = value, + onValueChange = { onValueChange(it.take(200)) }, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2 + ) +} + +private fun acceptReviewReady( + ordinals: List, + selectedOrdinal: Int?, + acceptReason: String, + acceptNote: String, + rejectReason: String, + rejectNote: String +): Boolean = + ordinals.isNotEmpty() && + selectedOrdinal in ordinals && + reasonReady(acceptReason, acceptNote) && + (ordinals.size == 1 || reasonReady(rejectReason, rejectNote)) + +private fun rejectReviewReady( + ordinals: List, + rejectReason: String, + rejectNote: String +): Boolean = + ordinals.isEmpty() || reasonReady(rejectReason, rejectNote) + +private fun reasonReady(reason: String, note: String): Boolean = + reason.isNotBlank() && (reason != "OTHER" || note.trim().length >= 4) + +private fun reviewReasonLabel(reason: String): String = when (reason) { + "SKU_MATCH" -> "SKU 匹配" + "IMAGE_MATCH" -> "图片匹配" + "PRICE_ACCEPTABLE" -> "价格可接受" + "EVIDENCE_SUFFICIENT" -> "证据充分" + "SKU_MISMATCH" -> "SKU 不匹配" + "IMAGE_MISMATCH" -> "图片不匹配" + "PRICE_TOO_HIGH" -> "价格过高" + "OUT_OF_STOCK" -> "缺货" + "EVIDENCE_INSUFFICIENT" -> "证据不足" + "NOT_BEST_MATCH" -> "不是最佳匹配" + "OTHER" -> "其他" + else -> reason +} + private fun candidateDecisionLabel(decision: CandidateDecision): String = when (decision) { CandidateDecision.REVIEW -> "建议复核" diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/CandidateHumanReviewDraftPolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/CandidateHumanReviewDraftPolicyTest.kt new file mode 100644 index 0000000..3af8283 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/CandidateHumanReviewDraftPolicyTest.kt @@ -0,0 +1,67 @@ +package com.roubao.autopilot.procurement + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class CandidateHumanReviewDraftPolicyTest { + @Test + fun `accepted review labels selected and rejected observations`() { + val review = CandidateHumanReviewDraftPolicy.accepted( + candidateOrdinals = listOf(1, 2, 3), + selectedOrdinal = 2, + acceptReasonCode = "SKU_MATCH", + acceptNote = "", + rejectReasonCode = "NOT_BEST_MATCH", + rejectNote = "", + hasBudget = false + ) + + assertEquals("CANDIDATE_ACCEPTED", review.outcome) + assertEquals(2, review.selectedCandidateOrdinal) + assertEquals( + listOf("REJECT", "ACCEPT", "REJECT"), + review.items.map { it.label } + ) + } + + @Test + fun `no observations produce an explicit no-match review`() { + val review = CandidateHumanReviewDraftPolicy.rejected( + candidateOrdinals = emptyList(), + rejectReasonCode = "", + rejectNote = "", + hasBudget = false + ) + + assertEquals("NO_MATCH", review.outcome) + assertEquals(emptyList(), review.items) + } + + @Test + fun `price reasons require a task budget`() { + assertThrows(IllegalArgumentException::class.java) { + CandidateHumanReviewDraftPolicy.rejected( + candidateOrdinals = listOf(1), + rejectReasonCode = "PRICE_TOO_HIGH", + rejectNote = "", + hasBudget = false + ) + } + } + + @Test + fun `other reasons require a meaningful note`() { + assertThrows(IllegalArgumentException::class.java) { + CandidateHumanReviewDraftPolicy.accepted( + candidateOrdinals = listOf(1), + selectedOrdinal = 1, + acceptReasonCode = "OTHER", + acceptNote = "短", + rejectReasonCode = "", + rejectNote = "", + hasBudget = true + ) + } + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionCandidateIdentityPolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionCandidateIdentityPolicyTest.kt index 59d1d42..cd60e72 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionCandidateIdentityPolicyTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionCandidateIdentityPolicyTest.kt @@ -56,6 +56,23 @@ class ExecutionCandidateIdentityPolicyTest { ) } + @Test + fun `explicit selection keeps the original observation ordinal`() { + val bindings = ExecutionCandidateIdentityPolicy.bind( + identities = listOf( + identity(sourceOrdinal = 1, rankedOrdinal = 1), + identity(sourceOrdinal = 2, rankedOrdinal = 2), + identity(sourceOrdinal = 3, rankedOrdinal = 3) + ), + candidates = listOf(candidate(1), candidate(2), candidate(3)) + ) + + val selected = ExecutionCandidateIdentityPolicy.select(bindings, 3) + + assertEquals(3, selected.ordinal) + assertEquals("evidence-3", selected.evidenceLocalIDs.single()) + } + @Test fun `missing ranked ordinal is rejected instead of falling back`() { assertThrows(IllegalArgumentException::class.java) { diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt index 39edf9d..b052fff 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt @@ -225,6 +225,29 @@ class ProcurementApiClientTest { assertEquals("/api/v1/tasks/task-id/events", eventRequest.path) assertEquals("event-idempotency-key", eventRequest.getHeader("Idempotency-Key")) assertTrue(eventRequest.body.readUtf8().contains("execution-id")) + + server.enqueue(jsonResponse("""{"review":{"id":"review-id"},"replayed":false}""")) + api.uploadExecutionOutboxItem( + session = session(), + task = task, + execution = execution, + claimToken = "claim-token-value", + item = ExecutionOutboxItem( + id = "review-id", + type = ExecutionOutboxType.HUMAN_REVIEW, + idempotencyKey = "review-idempotency-key", + payload = """{"execution_id":"execution-id","items":[]}""" + ) + ) + val reviewRequest = server.takeRequest() + assertEquals( + "/api/v1/tasks/task-id/human-reviews", + reviewRequest.path + ) + assertEquals( + "review-idempotency-key", + reviewRequest.getHeader("Idempotency-Key") + ) } private fun session() = ProcurementSession( diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 46bcb58..834fd73 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -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 { diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index 70150ad..ed873c4 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -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) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index 1281e27..87cbc84 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -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, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 2aa665b..6e8e6d9 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -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) } } diff --git a/backend-api/internal/repository/sqlite/candidate_decision_repository.go b/backend-api/internal/repository/sqlite/candidate_decision_repository.go new file mode 100644 index 0000000..e7e05b8 --- /dev/null +++ b/backend-api/internal/repository/sqlite/candidate_decision_repository.go @@ -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 +} diff --git a/backend-api/internal/repository/sqlite/execution_result_repository.go b/backend-api/internal/repository/sqlite/execution_result_repository.go index 22a7cb9..d7585c9 100644 --- a/backend-api/internal/repository/sqlite/execution_result_repository.go +++ b/backend-api/internal/repository/sqlite/execution_result_repository.go @@ -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 diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 500c99f..3401cc6 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -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 diff --git a/backend-api/internal/transport/httpapi/device_handlers.go b/backend-api/internal/transport/httpapi/device_handlers.go index d1497cc..a20875f 100644 --- a/backend-api/internal/transport/httpapi/device_handlers.go +++ b/backend-api/internal/transport/httpapi/device_handlers.go @@ -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"` diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 6abe567..9ae8eef 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -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) } } diff --git a/backend-api/internal/transport/webui/templates/task-detail.gohtml b/backend-api/internal/transport/webui/templates/task-detail.gohtml index 674fe73..ad77764 100644 --- a/backend-api/internal/transport/webui/templates/task-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/task-detail.gohtml @@ -86,8 +86,12 @@ {{end}} {{if .Mode}}

模式:{{.Mode}} · 搜索词:{{.SearchQuery}}

{{end}} {{if .Provenance}}

本地模型出处

{{.Provenance}}
{{end}} - {{if .Candidates}}

候选与评估

{{.Candidates}}
{{end}} - {{if .Recommendation}}

本地推荐

{{.Recommendation}}
{{end}} + {{if .Observations}}

原始候选观察

{{.Observations}}
{{end}} + {{if .ModelPredictions}}

模型逐项评估

{{.ModelPredictions}}
{{end}} + {{if .DeterministicRecommendation}}

确定性推荐

{{.DeterministicRecommendation}}
{{end}} + {{if .HumanReviews}}

人工选择与拒绝

{{.HumanReviews}}
{{end}} + {{if and (not .Observations) .Candidates}}

候选与评估(兼容记录)

{{.Candidates}}
{{end}} + {{if and (not .DeterministicRecommendation) .Recommendation}}

本地推荐(兼容记录)

{{.Recommendation}}
{{end}} {{if .Evidence}}

证据截图

diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index a61f42a..eeae3b6 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -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 { diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index a644030..dba9633 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -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 "" diff --git a/backend-api/internal/usecase/candidate_human_review.go b/backend-api/internal/usecase/candidate_human_review.go new file mode 100644 index 0000000..bf28181 --- /dev/null +++ b/backend-api/internal/usecase/candidate_human_review.go @@ -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": {}, +} diff --git a/backend-api/internal/usecase/candidate_human_review_test.go b/backend-api/internal/usecase/candidate_human_review_test.go new file mode 100644 index 0000000..ac6f21b --- /dev/null +++ b/backend-api/internal/usecase/candidate_human_review_test.go @@ -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"}, + }, + }, + } +} diff --git a/backend-api/internal/usecase/execution_result_ports.go b/backend-api/internal/usecase/execution_result_ports.go index 028cabf..b4dcee8 100644 --- a/backend-api/internal/usecase/execution_result_ports.go +++ b/backend-api/internal/usecase/execution_result_ports.go @@ -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, diff --git a/backend-api/internal/usecase/execution_result_service.go b/backend-api/internal/usecase/execution_result_service.go index 22f5a03..d2425d5 100644 --- a/backend-api/internal/usecase/execution_result_service.go +++ b/backend-api/internal/usecase/execution_result_service.go @@ -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) && diff --git a/backend-api/internal/usecase/execution_result_service_test.go b/backend-api/internal/usecase/execution_result_service_test.go index ae3be27..75847bf 100644 --- a/backend-api/internal/usecase/execution_result_service_test.go +++ b/backend-api/internal/usecase/execution_result_service_test.go @@ -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") } } diff --git a/backend-api/migrations/00006_candidate_decisions.sql b/backend-api/migrations/00006_candidate_decisions.sql new file mode 100644 index 0000000..edc2297 --- /dev/null +++ b/backend-api/migrations/00006_candidate_decisions.sql @@ -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; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 501f726..3817c4a 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -56,8 +56,9 @@ 验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权 T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/ 证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控 -规格组合/价格核验均已完成。当前正在实现 T-208 的逐候选结构化人工理由、修订历史 -和优化数据闭环;不得提前建设报表、训练管线或外部商品抓取。 +规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 +结构化人工理由和修订历史也已完成;下一步需先把商品持久身份和 Admin 下单授权落成 +独立任务,不得直接把候选链接当成下单授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 13f2970..bb51869 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -53,7 +53,7 @@ | 功能 | 说明 | 阶段 | | --- | --- | --- | -| F-008 候选决策数据闭环 | 保存每次搜索实际曝光的最多 5 个候选、模型评估、系统推荐和人工理由,为离线评估与优化提供可信样本。 | P1,T-208;不阻塞第一版流程 | +| F-008 候选决策数据闭环 | 保存每次搜索实际曝光的最多 5 个候选、模型评估、系统推荐和人工理由,为离线评估与优化提供可信样本。 | P1,T-208 已实现 | | 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 | | 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 | | 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 | @@ -61,11 +61,11 @@ | 多平台比价 | 淘宝、1688、京东等平台。 | V3 | | 支付自动化 | 不在当前规划内,除非另行完成资金和合规评审。 | 未规划 | -### F-008 候选决策数据闭环(后置) +### F-008 候选决策数据闭环 -T-206/T-207 先跑通领取、执行、候选/证据和最小结果回传;T-208 再实现可用于优化的 -完整数据闭环。不能为了建设未来训练数据阻塞第一版端到端流程,但 20 条真实任务试验 -开始前必须完成 T-208,避免试验结束后才发现缺少曝光和人工标签。 +T-206/T-207 已跑通领取、执行、候选/证据和最小结果回传;T-208 已实现可用于优化 +的完整数据闭环。20 条真实任务试验必须使用这条结构化链路,避免试验结束后才发现 +缺少曝光和人工标签。 1. 每次 search run 保存需求快照、精确搜索词、App/拼多多版本、采集时间以及最多 5 个实际曝光候选的原始 ordinal。该集合只是特定账号、地区、时间和平台排序下的 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 171f434..05e8a54 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -528,9 +528,10 @@ T-205 的最小 execution 表中提前伪造。 - 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再 统一规范化为匿名 JPEG;未来 Android 读取的资产不能依赖原始扩展名或声明 MIME。 -### 后置候选决策数据集(T-208) +### 候选决策数据集(T-208) -T-207 先完成候选、事件、截图和最小结果回传。T-208 在 `task_executions` 下增加 +T-208 已在 T-207 的候选、事件、截图和最小结果回传基础上,于 +`task_executions` 下增加 独立、不可变的数据层,不把整批候选塞进 `task_events`、`purchase_tasks` JSON 或 一段不可查询的自由文本: diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index e6ead4b..a184a11 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -15,7 +15,7 @@ | IX-006 | US-004 | App 执行页 | 确认开始/自动步骤 | 显示步骤并有界执行搜索与候选判断 | P0 | 已定 | | IX-007 | US-005 | App 候选确认 | 接受/拒绝/转人工 | 停止自动化并回传人员结论 | P0 | 已定 | | IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 | -| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | 已定,T-208 后置 | +| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 | | IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 | ## IX-001 管理 Web 登录 @@ -255,8 +255,8 @@ - 页面:Android“候选确认”、管理 Web“任务详情/候选决策”。 - 角色:采购执行员、采购管理员/优化人员。 -- 前置条件:T-207 第一版流程已完成;T-208 reason schema 已固定。 -- 服务依赖:T-208 幂等 human review API。 +- 前置条件:T-207 第一版流程和 T-208 reason schema 已完成。 +- 服务依赖:已实现的 T-208 幂等 human review API。 **正常路径** diff --git a/docs/api.md b/docs/api.md index b4fbc04..85fa190 100644 --- a/docs/api.md +++ b/docs/api.md @@ -438,7 +438,8 @@ candidate evidence SHA-256 和结构化模型判断。结果不得包含 Key、A ## 执行事件与结果 -App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺序提交。所有写接口 +App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 人工 review -> 终态” +顺序提交。所有写接口 重新校验 BUYER/device/task/execution/claim 和幂等键。原设备可以在 `execution_expires_at` 后补报授权内已经产生的结果;后端记录 `received_after_execution_expiry=true`,但这不允许 App 在过期后继续自动化。 @@ -474,7 +475,7 @@ App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺 ### `POST /api/v1/tasks/{task_id}/candidates` -批量保存当前 execution 实际检查后通过策略的 `0..5` 个候选,必须带 +批量保存当前 execution 实际检查的 `0..5` 个原始曝光候选,必须带 `Idempotency-Key`。正式参考图检索使用固定审计值 `PDD_IMAGE_SEARCH`,不把标题或 SKU 伪装成图片检索词: @@ -533,11 +534,54 @@ SKU 伪装成图片检索词: ``` `MANUAL_FIRST` 时 `provenance` 和 `evaluation` 为空,但候选观察、搜索审计值和人工 -结果仍可提交。`AI_ASSISTED` 的非空 `evaluation` 必须同时包含唯一的 `COLOR` 和 -`SIZE`,且全部为 `MATCH`;schema v2 还要求分数和置信度均不低于 `0.75`,非空批次的 -推荐必须指向排序后的第 1 项。没有候选满足条件时提交空 `candidates` 和空 -`recommendation`,不得用弱匹配或未知项凑数。后端校验 ordinal 连续唯一、最多 5 个和 -任务内容哈希;不请求 `product_url` 或 `image_url`,主要证据必须是已鉴权 asset。 +结果仍可提交。`AI_ASSISTED` 为每个原始 observation 保存 evaluation;颜色/尺码状态 +可以是 `MATCH/MISMATCH/UNKNOWN`,模型拒绝项也必须保留。schema v2 的 +`recommendation` 只能指向颜色和尺码均为 `MATCH`、分数和置信度均不低于 `0.75` +且没有拒绝原因的原始 ordinal;没有满足项时只省略 recommendation,不能删除原始 +候选。后端在同一事务内写入 search run、observation、model evaluation 和 +recommendation,并保留旧 JSON 审计副本;不请求 `product_url` 或 `image_url`, +主要证据必须是已鉴权 asset。 + +### `POST /api/v1/tasks/{task_id}/human-reviews` + +采购员确认候选后、提交终态前调用。请求使用设备 Bearer token、`X-Claim-Token` +和 `Idempotency-Key`: + +```json +{ + "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", + "claim_generation": 1, + "task_content_sha256": "64-char-lowercase-hex", + "reason_schema_version": 1, + "outcome": "CANDIDATE_ACCEPTED", + "selected_candidate_ordinal": 2, + "primary_reason_code": "SELECTED_BEST_MATCH", + "note": "", + "supersedes_review_id": null, + "items": [ + { + "candidate_ordinal": 1, + "label": "REJECT", + "primary_reason_code": "NOT_BEST_MATCH", + "reason_codes": ["NOT_BEST_MATCH"], + "note": "" + }, + { + "candidate_ordinal": 2, + "label": "ACCEPT", + "primary_reason_code": "SKU_MATCH", + "reason_codes": ["SKU_MATCH"], + "note": "" + } + ] +} +``` + +非空 review 必须恰好覆盖本次所有 observation;接受时只有所选 ordinal 为 +`ACCEPT`,其余全部为 `REJECT`。零候选只允许 `NO_MATCH` 或 +`MANUAL_REQUIRED` 且 items 为空。理由使用 T-208 版本 1 allowlist;任务没有预算时 +禁止价格类理由,`OTHER` 必须带 4-200 字备注。同一幂等键重放返回原 review; +修订必须通过 `supersedes_review_id` 引用当前最新版本,服务端追加版本并保留历史。 ### `POST /api/v1/tasks/{task_id}/complete` diff --git a/docs/current-state.md b/docs/current-state.md index 65da750..2f492a4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,9 +5,9 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-208 候选决策数据与人工理由闭环进行中 +- 阶段:T-208 候选决策数据与人工理由闭环已完成 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、 - T-210、T-211、T-212、T-213 均已纳入 Git 历史 + T-208、T-210、T-211、T-212、T-213 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 @@ -17,11 +17,10 @@ - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:T-213 Android Debug 25 个 suite、148 个测试、0 失败,Debug/Release 回归、 - `assembleDebug` 和根 `init.ps1` 通过;Debug APK `1.4.10 (15)` 已安装并冷启动于 - PKG110 -- 后端测试:T-213 运行 `go test ./...` 通过;T-207 的全包 race 与 migration - 验证继续有效 +- 测试:T-208 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过; + Debug APK `1.4.11 (16)` 已安装并启动于 PKG110 +- 后端测试:T-208 运行 `go test ./...`、`go test -race ./...`、migration + `up/down/up` 和带 review 数据的降级保护均通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -47,10 +46,15 @@ 失败记录均按 execution 存储;Android 使用加密 outbox 按事件、截图、候选和终态 顺序回传,授权到期补报单独审计,所有终态固定 `order_submitted=false`。管理任务 详情展示模型/候选/人工理由/事件/证据摘要,不保存 VLM Key、完整 endpoint 或原始响应。 +- T-208 决策数据:候选批次同事务规范化为 search run、原始 observation、逐项 + model evaluation 和确定性 recommendation;模型拒绝项不再被过滤或重排。App 在 + COMPLETE 前提交幂等 HUMAN_REVIEW,接受项和其余拒绝项均有结构化理由;修订按 + supersedes/version 追加。Admin/API 分区显示 observation、prediction、 + recommendation 和完整人工 review 历史。 - T-211 图片检索:后台任务固定使用经 SHA-256 校验的参考 JPEG,经一次性 MediaStore 图片进入拼多多拍照搜索;App 本地从 SKU 唯一提取颜色和尺码,schema v3 逐项返回 - `MATCH/MISMATCH/UNKNOWN`。只有两项均匹配且分数/置信度不低于 `0.75` 的候选按 - 分数、置信度和曝光顺序回传 `0..5` 项,弱匹配和未知项不凑数。 + `MATCH/MISMATCH/UNKNOWN`。实际曝光的 `0..5` 项全部按原 ordinal 回传;只有两项 + 均匹配且分数/置信度不低于 `0.75` 的候选能成为确定性 recommendation。 - T-213 规格核验:`DISCOVERY_INSPECT` 可从唯一“免拼购买”进入规格弹层,按任务 SKU 唯一选择颜色/尺码并复核 selected/已选摘要,最多滚动 2 次;严格保存单一 人民币组合价和详情/规格双证据,并允许明确的“首件¥金额”当前单件价。区间、 @@ -63,15 +67,13 @@ 单次调用边界;候选最多 5 个并按 ordinal 串行评估,本地产生建议并停在人工确认, SKU/数量由本地原值回填,颜色/尺码硬约束由本地生成,预算保持空,订单提交状态固定 为 false -- 后置数据闭环:已登记 T-208,在第一版 T-206/T-207 跑通后分离保存候选观测、 - 模型预测、确定性推荐和人工标签;人工接受/拒绝使用结构化理由,20 条试验依赖它 - VLM 部署决策:手机本地调用已配置的 OpenAI 兼容 provider;管理后端只负责身份、 任务控制和结果审计,不保存/代理模型。T-207 保留 Roubao 独立模式并把端上 Key 迁移到 Keystore-backed 加密存储 - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, RUNNING 不自动重新分配 -- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.10 (15)`;拼多多 +- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.11 (16)`;拼多多 `8.17.0 (81700)` - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权 @@ -86,7 +88,7 @@ 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:T-213 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 +- 当前 blocker:T-208 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+ ## 当前目录 @@ -115,7 +117,7 @@ | `docs/tasks/T-211.md` | DONE | 参考图召回、SKU 颜色尺码硬匹配与 0..5 候选回传 | | `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 | | `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 | -| `docs/tasks/T-208.md` | DOING | 归一化候选决策数据并增加结构化人工 review | +| `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -126,12 +128,10 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、T-210、T-211、 - T-212、T-213。 -- 正在进行:T-208 候选决策数据与人工理由闭环。 -- 下一个可领取任务:无;先完成 T-208。 -- 后置任务:T-208 完成后依次实现商品 - 持久身份、Admin 下单授权、设备命令、订单 dry-run、单次提交对账和付款提醒。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-213。 +- 正在进行:无。 +- 下一步:先落独立任务,依次实现商品持久身份、Admin 下单授权、设备命令、 + 订单 dry-run、单次提交对账和付款提醒。 ## 当前可运行内容 diff --git a/docs/tasks/T-208.md b/docs/tasks/T-208.md index 8ea0e46..fa04a8f 100644 --- a/docs/tasks/T-208.md +++ b/docs/tasks/T-208.md @@ -5,7 +5,7 @@ phase: 2 deps: - T-207 - T-213 -status: DOING +status: DONE created: 2026-07-28 context_ref: 4c3b962 work_branch: null @@ -131,15 +131,15 @@ T-207 已能回传候选批次、模型评估、本地推荐和一段 `operator_ ## 验收要点 -- [ ] migration `up/down/up` 可重复;有 review 数据时破坏性 down 明确失败。 -- [ ] 最多 5 个原始曝光 observation 按原 ordinal 可查询,模型拒绝项不会丢失。 -- [ ] observation、model evaluation、recommendation 和 human label 分表且引用一致。 -- [ ] human review API 校验理由 allowlist、标签兼容、预算适用性、全候选覆盖、唯一 +- [x] migration `up/down/up` 可重复;有 review 数据时破坏性 down 明确失败。 +- [x] 最多 5 个原始曝光 observation 按原 ordinal 可查询,模型拒绝项不会丢失。 +- [x] observation、model evaluation、recommendation 和 human label 分表且引用一致。 +- [x] human review API 校验理由 allowlist、标签兼容、预算适用性、全候选覆盖、唯一 接受项、幂等重放和追加式 supersedes。 -- [ ] App 支持选择推荐项、改选、全部拒绝和零候选;每种路径都产生结构化逐候选理由。 -- [ ] 模型理由不会预填人工理由,旧 `operator_reason` 只保留兼容审计含义。 -- [ ] Admin/API 能读取当前 review 和历史版本,外链不会触发任何服务端网络请求。 -- [ ] Android/Go 单元测试、race、migration、Debug/Release 构建和根验证通过。 +- [x] App 支持选择推荐项、改选、全部拒绝和零候选;每种路径都产生结构化逐候选理由。 +- [x] 模型理由不会预填人工理由,旧 `operator_reason` 只保留兼容审计含义。 +- [x] Admin/API 能读取当前 review 和历史版本,外链不会触发任何服务端网络请求。 +- [x] Android/Go 单元测试、race、migration、Debug/Release 构建和根验证通过。 ## 边界 @@ -155,3 +155,20 @@ T-207 已能回传候选批次、模型评估、本地推荐和一段 `operator_ `execution_candidate_batches`/`execution_outcomes` 只能提供 JSON 审计副本和自由 文本说明,AI 模式还会过滤拒绝候选;因此冻结以上归一化数据层和结构化 review 合约后再开始实现。 +- 2026-07-28:新增 v6 migration 和规范化仓储,在候选批次同一事务内保存 search + run、全部原始 observation、逐项模型评估和引用原 ordinal 的 recommendation; + 兼容 JSON 保留,但不再是唯一查询来源。空库 migration `up/down/up` 通过,存在 + review 时 down guard 经 HTTP 集成测试确认失败且不破坏数据。 +- 2026-07-28:新增设备鉴权、claim-scoped、幂等的 + `POST /api/v1/tasks/{id}/human-reviews`,验证完整覆盖、唯一接受项、reason allowlist、 + 无预算价格理由、OTHER 备注、重放和 supersedes;集成测试确认同一 execution 的 + version 1/2 均可从 Admin/API 读取。 +- 2026-07-28:Android 改为上传全部实际曝光候选并保持原 ordinal;推荐只引用合格 + observation。候选确认页使用候选和理由单选控件,未选项的批量拒绝理由显式展开为 + 逐候选 item;加密 outbox 严格按 CANDIDATES、HUMAN_REVIEW、COMPLETE 顺序重放。 + App 升级为 `1.4.11 (16)`。 +- 2026-07-28:`.\init.ps1`、Android Debug/Release 单元测试与构建、 + `go test -race ./...`、Go vet/format/build 全部通过。Android App Debug 和 Release + 各 26 个 suite/153 个测试、0 失败;共享模块另有 12 个测试。Debug APK 已通过 + SDK ADB 覆盖安装并启动于 PKG110,设备报告 `1.4.11 (16)`,肉包采购无障碍仍 + 已启用。安装后截图确认任务首屏无重叠或裁切。