feat(t208): close candidate decision feedback loop

This commit is contained in:
QiuSW
2026-07-28 11:57:25 +08:00
parent 2a5ded42b5
commit e7f4c3e114
35 changed files with 2854 additions and 203 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.roubao.autopilot" applicationId = "com.roubao.autopilot"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 15 versionCode = 16
versionName = "1.4.10" versionName = "1.4.11"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -107,6 +107,7 @@ import com.roubao.autopilot.procurement.ExecutionMode
import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot
import com.roubao.autopilot.procurement.ProcurementRepository import com.roubao.autopilot.procurement.ProcurementRepository
import com.roubao.autopilot.procurement.RankedExecutionCandidateDraft import com.roubao.autopilot.procurement.RankedExecutionCandidateDraft
import com.roubao.autopilot.procurement.CandidateHumanReviewDraft
import com.roubao.autopilot.task.RequirementProbeFixture import com.roubao.autopilot.task.RequirementProbeFixture
import com.roubao.autopilot.workflow.WorkflowReport import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowRunner import com.roubao.autopilot.workflow.WorkflowRunner
@@ -431,6 +432,10 @@ class MainActivity : ComponentActivity() {
candidateEvaluationState = evaluationState, candidateEvaluationState = evaluationState,
candidateReviewBatch = reviewBatch, candidateReviewBatch = reviewBatch,
candidateEvaluationFailureCode = evaluationFailure, candidateEvaluationFailureCode = evaluationFailure,
candidateOrdinals = taskCandidateDrafts.value
.map { it.candidate.ordinal },
hasBudget =
procurementState.task?.maxBudget != null,
canStartCandidateEvaluation = canStartCandidateEvaluation =
extractionState == RequirementProbeState.READY && extractionState == RequirementProbeState.READY &&
extraction != null && extraction != null &&
@@ -449,11 +454,11 @@ class MainActivity : ComponentActivity() {
onStopCandidateEvaluation = { onStopCandidateEvaluation = {
stopCandidateEvaluation() stopCandidateEvaluation()
}, },
onAcceptCandidate = { reason -> onAcceptCandidate = { review ->
acceptRecommendedCandidate(reason) acceptCandidateReview(review)
}, },
onRejectCandidates = { reason -> onRejectCandidates = { review ->
rejectCandidateReview(reason) rejectCandidateReview(review)
}, },
onStart = { startSearchProbe() }, onStart = { startSearchProbe() },
onStop = { stopSearchProbe() } onStop = { stopSearchProbe() }
@@ -1032,18 +1037,17 @@ class MainActivity : ComponentActivity() {
val evidenceByOrdinal = validated.associateBy { val evidenceByOrdinal = validated.associateBy {
it.ordinal it.ordinal
} }
val drafts = ranked.map { rankedCandidate -> val assessments = result.batch.assessments
val assessment = rankedCandidate.assessment .sortedBy { it.ordinal }
val drafts = assessments.map { assessment ->
val candidateEvidence = requireNotNull( val candidateEvidence = requireNotNull(
evidenceByOrdinal[ evidenceByOrdinal[assessment.ordinal]
rankedCandidate.sourceOrdinal
]
) )
ExecutionCandidateDraft( ExecutionCandidateDraft(
ordinal = rankedCandidate.rankedOrdinal, ordinal = assessment.ordinal,
title = title =
"拼多多图片候选 " + "拼多多图片候选 " +
rankedCandidate.sourceOrdinal, assessment.ordinal,
skuText = skuText =
candidateEvidence.specification candidateEvidence.specification
.selectedSummary .selectedSummary
@@ -1078,26 +1082,14 @@ class MainActivity : ComponentActivity() {
) )
) )
} }
val selectedEvidence = ranked.flatMap { val selectedEvidence = assessments.flatMap {
rankedCandidate -> assessment ->
val candidate = requireNotNull( val candidate = requireNotNull(
evidenceByOrdinal[ evidenceByOrdinal[assessment.ordinal]
rankedCandidate.sourceOrdinal
]
)
require(
candidate.detailSha256 ==
rankedCandidate.evidenceSha256
)
require(
candidate.specificationSha256 ==
rankedCandidate
.specificationEvidenceSha256
) )
listOf( listOf(
ExecutionEvidenceDraft( ExecutionEvidenceDraft(
ordinal = ordinal = assessment.ordinal,
rankedCandidate.rankedOrdinal,
pngBytes = pngBytes =
candidate.detailPngBytes, candidate.detailPngBytes,
sha256 = sha256 =
@@ -1106,8 +1098,7 @@ class MainActivity : ComponentActivity() {
ExecutionEvidenceKind.DETAIL ExecutionEvidenceKind.DETAIL
), ),
ExecutionEvidenceDraft( ExecutionEvidenceDraft(
ordinal = ordinal = assessment.ordinal,
rankedCandidate.rankedOrdinal,
pngBytes = pngBytes =
candidate candidate
.specificationPngBytes, .specificationPngBytes,
@@ -1139,9 +1130,11 @@ class MainActivity : ComponentActivity() {
.requirementReferenceImageSha256 .requirementReferenceImageSha256
), ),
candidates = drafts, candidates = drafts,
recommendation = drafts.firstOrNull()?.let { recommendation = ranked.firstOrNull()?.let {
rankedCandidate ->
ExecutionRecommendation( ExecutionRecommendation(
candidateOrdinal = 1, candidateOrdinal =
rankedCandidate.sourceOrdinal,
policyVersion = policyVersion =
"sku-hard-constraints-v1", "sku-hard-constraints-v1",
reasons = listOf( reasons = listOf(
@@ -1161,17 +1154,19 @@ class MainActivity : ComponentActivity() {
} }
taskCandidateDrafts.value = runCatching { taskCandidateDrafts.value = runCatching {
ExecutionCandidateIdentityPolicy.bind( ExecutionCandidateIdentityPolicy.bind(
identities = ranked.map { rankedCandidate -> identities = assessments.map { assessment ->
val candidate = requireNotNull(
evidenceByOrdinal[assessment.ordinal]
)
ExecutionCandidateSourceIdentity( ExecutionCandidateSourceIdentity(
sourceOrdinal = sourceOrdinal =
rankedCandidate.sourceOrdinal, assessment.ordinal,
rankedOrdinal = rankedOrdinal =
rankedCandidate.rankedOrdinal, assessment.ordinal,
evidenceSha256 = evidenceSha256 =
rankedCandidate.evidenceSha256, candidate.detailSha256,
specificationEvidenceSha256 = specificationEvidenceSha256 =
rankedCandidate candidate.specificationSha256
.specificationEvidenceSha256
) )
}, },
candidates = queued candidates = queued
@@ -1283,7 +1278,7 @@ class MainActivity : ComponentActivity() {
.joinToString(" ") .joinToString(" ")
.take(160) .take(160)
private fun acceptRecommendedCandidate(operatorReason: String) { private fun acceptCandidateReview(review: CandidateHumanReviewDraft) {
candidateEvaluationState.value = CandidateHumanReviewPolicy.accept( candidateEvaluationState.value = CandidateHumanReviewPolicy.accept(
currentState = candidateEvaluationState.value, currentState = candidateEvaluationState.value,
batch = candidateReviewBatch.value batch = candidateReviewBatch.value
@@ -1299,8 +1294,11 @@ class MainActivity : ComponentActivity() {
CandidateEvaluationState.HUMAN_ACCEPTED CandidateEvaluationState.HUMAN_ACCEPTED
) { ) {
val candidate = runCatching { val candidate = runCatching {
ExecutionCandidateIdentityPolicy.recommended( ExecutionCandidateIdentityPolicy.select(
taskCandidateDrafts.value candidates = taskCandidateDrafts.value,
ordinal = requireNotNull(
review.selectedCandidateOrdinal
)
) )
}.getOrElse { }.getOrElse {
setCandidateEvaluationFailure( setCandidateEvaluationFailure(
@@ -1308,14 +1306,10 @@ class MainActivity : ComponentActivity() {
) )
return return
} }
if ( if (procurementRepository.currentProbeTask() != null) {
candidate != null &&
procurementRepository.currentProbeTask() != null
) {
lifecycleScope.launch { lifecycleScope.launch {
procurementRepository.completeExecution( procurementRepository.completeExecution(
outcome = "CANDIDATE_ACCEPTED", review = review,
operatorReason = operatorReason,
candidate = candidate 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 = CandidateHumanReviewPolicy.reject(
candidateEvaluationState.value candidateEvaluationState.value
) )
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_REJECTED && if (
procurementRepository.currentProbeTask() != null candidateEvaluationState.value ==
) { CandidateEvaluationState.HUMAN_REJECTED &&
lifecycleScope.launch { procurementRepository.currentProbeTask() != null
procurementRepository.completeExecution( ) {
outcome = if (taskCandidateDrafts.value.isEmpty()) { lifecycleScope.launch {
"NO_MATCH" procurementRepository.completeExecution(review = review)
} else { }
"CANDIDATE_REJECTED" }
},
operatorReason = operatorReason
)
}
}
} }
private fun setCandidateEvaluationFailure( private fun setCandidateEvaluationFailure(
@@ -101,6 +101,16 @@ object ExecutionCandidateIdentityPolicy {
it.candidate.ordinal == 1 it.candidate.ordinal == 1
}.candidate }.candidate
} }
fun select(
candidates: List<RankedExecutionCandidateDraft>,
ordinal: Int
): ExecutionCandidateDraft =
candidates.single {
it.identity.sourceOrdinal == ordinal &&
it.identity.rankedOrdinal == ordinal &&
it.candidate.ordinal == ordinal
}.candidate
} }
data class ExecutionCandidateEvaluation( data class ExecutionCandidateEvaluation(
@@ -134,6 +144,197 @@ data class ExecutionCandidateBatchDraft(
val recommendation: ExecutionRecommendation? = null val recommendation: ExecutionRecommendation? = null
) )
data class CandidateHumanReviewItemDraft(
val candidateOrdinal: Int,
val label: String,
val primaryReasonCode: String,
val reasonCodes: List<String>,
val note: String = ""
)
data class CandidateHumanReviewDraft(
val outcome: String,
val selectedCandidateOrdinal: Int?,
val primaryReasonCode: String,
val note: String = "",
val items: List<CandidateHumanReviewItemDraft>,
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<Int>,
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<Int>,
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<Int>) {
require(candidateOrdinals == (1..candidateOrdinals.size).toList()) {
"候选编号必须连续"
}
require(candidateOrdinals.size <= 5) { "候选数量不能超过 5 个" }
}
private fun validateReason(
reasonCode: String,
note: String,
allowed: List<String>,
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 { object ExecutionTaskHash {
fun sha256(task: RemotePurchaseTask): String { fun sha256(task: RemotePurchaseTask): String {
val maxBudgetCents = task.maxBudget val maxBudgetCents = task.maxBudget
@@ -275,6 +275,8 @@ class ProcurementApiClient(
ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events" ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events"
ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence" ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence"
ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates" 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.COMPLETE -> "/api/v1/tasks/${task.id}/complete"
ExecutionOutboxType.FAIL -> "/api/v1/tasks/${task.id}/fail" ExecutionOutboxType.FAIL -> "/api/v1/tasks/${task.id}/fail"
} }
@@ -105,6 +105,7 @@ enum class ExecutionOutboxType {
EVENTS, EVENTS,
EVIDENCE, EVIDENCE,
CANDIDATES, CANDIDATES,
HUMAN_REVIEW,
COMPLETE, COMPLETE,
FAIL FAIL
} }
@@ -477,28 +477,77 @@ class ProcurementRepository(
} }
suspend fun completeExecution( suspend fun completeExecution(
outcome: String, review: CandidateHumanReviewDraft,
operatorReason: String,
candidate: ExecutionCandidateDraft? = null candidate: ExecutionCandidateDraft? = null
): Boolean = operation { ): Boolean = operation {
val task = requireCurrentTask() val task = requireCurrentTask()
val execution = requireActiveExecution() val execution = requireActiveExecution()
require(operatorReason.trim().isNotEmpty()) { "请填写人工确认理由" } CandidateHumanReviewDraftPolicy.validate(
require(outcome in COMPLETE_OUTCOMES) { "采购结论无效" } review,
if (outcome == "CANDIDATE_ACCEPTED") { 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 != null) { "接受候选时必须保留候选证据" }
require(candidate.ordinal == review.selectedCandidateOrdinal) {
"人工选择与候选不一致"
}
} else { } else {
require(candidate == null) { "当前结论不能附带候选" } 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() val payload = JSONObject()
.put("execution_id", execution.id) .put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration) .put("claim_generation", task.claimGeneration)
.put("task_content_sha256", ExecutionTaskHash.sha256(task)) .put("task_content_sha256", ExecutionTaskHash.sha256(task))
.put("execution_mode", requireNotNull(execution.provenance).mode.name) .put("execution_mode", requireNotNull(execution.provenance).mode.name)
.put("outcome", outcome) .put("outcome", review.outcome)
.put("operator_reason", operatorReason.trim()) .put("operator_reason", review.operatorSummary())
.put("order_submitted", false) .put("order_submitted", false)
candidate?.let { payload.put("candidate", candidateJSON(it)) } candidate?.let { payload.put("candidate", candidateJSON(it)) }
appendOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.HUMAN_REVIEW,
idempotencyKey = newOpaqueSecret(),
payload = reviewPayload.toString()
)
)
appendTerminalOutboxLocked( appendTerminalOutboxLocked(
ExecutionOutboxItem( ExecutionOutboxItem(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@@ -26,6 +26,7 @@ import androidx.compose.material3.Divider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.HardConstraintMatchStatus
import com.roubao.autopilot.vlm.SkuConstraintKind import com.roubao.autopilot.vlm.SkuConstraintKind
import com.roubao.autopilot.vlm.CandidateEvaluationWarningCode import com.roubao.autopilot.vlm.CandidateEvaluationWarningCode
import com.roubao.autopilot.procurement.CandidateHumanReviewDraft
import com.roubao.autopilot.procurement.CandidateHumanReviewDraftPolicy
private data class ProbeStepUi( private data class ProbeStepUi(
val id: String, val id: String,
@@ -106,13 +109,15 @@ fun SearchProbeScreen(
candidateEvaluationState: CandidateEvaluationState, candidateEvaluationState: CandidateEvaluationState,
candidateReviewBatch: CandidateReviewBatch?, candidateReviewBatch: CandidateReviewBatch?,
candidateEvaluationFailureCode: CandidateEvaluationFailureCode?, candidateEvaluationFailureCode: CandidateEvaluationFailureCode?,
candidateOrdinals: List<Int>,
hasBudget: Boolean,
canStartCandidateEvaluation: Boolean, canStartCandidateEvaluation: Boolean,
onStartRequirement: () -> Unit, onStartRequirement: () -> Unit,
onStopRequirement: () -> Unit, onStopRequirement: () -> Unit,
onStartCandidateEvaluation: () -> Unit, onStartCandidateEvaluation: () -> Unit,
onStopCandidateEvaluation: () -> Unit, onStopCandidateEvaluation: () -> Unit,
onAcceptCandidate: (String) -> Unit, onAcceptCandidate: (CandidateHumanReviewDraft) -> Unit,
onRejectCandidates: (String) -> Unit, onRejectCandidates: (CandidateHumanReviewDraft) -> Unit,
onStart: () -> Unit, onStart: () -> Unit,
onStop: () -> Unit onStop: () -> Unit
) { ) {
@@ -298,6 +303,8 @@ fun SearchProbeScreen(
state = candidateEvaluationState, state = candidateEvaluationState,
batch = candidateReviewBatch, batch = candidateReviewBatch,
failureCode = candidateEvaluationFailureCode, failureCode = candidateEvaluationFailureCode,
candidateOrdinals = candidateOrdinals,
hasBudget = hasBudget,
canStart = canStartCandidateEvaluation && !active, canStart = canStartCandidateEvaluation && !active,
onStart = onStartCandidateEvaluation, onStart = onStartCandidateEvaluation,
onStop = onStopCandidateEvaluation, onStop = onStopCandidateEvaluation,
@@ -313,14 +320,22 @@ private fun CandidateEvaluationSection(
state: CandidateEvaluationState, state: CandidateEvaluationState,
batch: CandidateReviewBatch?, batch: CandidateReviewBatch?,
failureCode: CandidateEvaluationFailureCode?, failureCode: CandidateEvaluationFailureCode?,
candidateOrdinals: List<Int>,
hasBudget: Boolean,
canStart: Boolean, canStart: Boolean,
onStart: () -> Unit, onStart: () -> Unit,
onStop: () -> Unit, onStop: () -> Unit,
onAccept: (String) -> Unit, onAccept: (CandidateHumanReviewDraft) -> Unit,
onReject: (String) -> Unit onReject: (CandidateHumanReviewDraft) -> Unit
) { ) {
val colors = BaoziTheme.colors val colors = BaoziTheme.colors
var operatorReason by remember(state) { mutableStateOf("") } var selectedOrdinal by remember(state, candidateOrdinals) {
mutableStateOf<Int?>(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) { val statusColor = when (state) {
CandidateEvaluationState.AWAITING_CONFIRMATION, CandidateEvaluationState.AWAITING_CONFIRMATION,
CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success
@@ -369,13 +384,23 @@ private fun CandidateEvaluationSection(
) )
batch.assessments.forEach { assessment -> batch.assessments.forEach { assessment ->
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Row(
text = "候选 ${assessment.ordinal} · " + verticalAlignment = Alignment.CenterVertically
candidateDecisionLabel(assessment.decision), ) {
fontSize = 15.sp, if (state in reviewableCandidateStates) {
fontWeight = FontWeight.SemiBold, RadioButton(
color = colors.textPrimary 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( RequirementDetailRow(
"评分", "评分",
"匹配 ${(assessment.score * 100).toInt()}% · " + "匹配 ${(assessment.score * 100).toInt()}% · " +
@@ -409,23 +434,80 @@ private fun CandidateEvaluationSection(
} }
) )
RequirementDetailRow("订单状态", "未提交") 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)) Spacer(modifier = Modifier.height(14.dp))
if (state in setOf( if (state in reviewableCandidateStates) {
CandidateEvaluationState.AWAITING_CONFIRMATION, if (candidateOrdinals.isNotEmpty()) {
CandidateEvaluationState.MANUAL_REVIEW, Text(
CandidateEvaluationState.NO_MATCH text = "所选候选的接受原因",
)) { fontSize = 14.sp,
OutlinedTextField( fontWeight = FontWeight.Medium,
value = operatorReason, color = colors.textPrimary
onValueChange = { operatorReason = it.take(1000) }, )
label = { Text("人工确认理由") }, ReasonSelector(
modifier = Modifier.fillMaxWidth(), selected = acceptReason,
minLines = 2 options = CandidateHumanReviewDraftPolicy.acceptReasonCodes
) .filter { hasBudget || it != "PRICE_ACCEPTABLE" },
Spacer(modifier = Modifier.height(10.dp)) 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) { when (state) {
CandidateEvaluationState.RUNNING -> { CandidateEvaluationState.RUNNING -> {
OutlinedButton( OutlinedButton(
@@ -439,8 +521,27 @@ private fun CandidateEvaluationSection(
} }
CandidateEvaluationState.AWAITING_CONFIRMATION -> { CandidateEvaluationState.AWAITING_CONFIRMATION -> {
Button( Button(
onClick = { onAccept(operatorReason) }, onClick = {
enabled = operatorReason.isNotBlank(), 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(), modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = colors.primary containerColor = colors.primary
@@ -452,8 +553,21 @@ private fun CandidateEvaluationSection(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedButton( OutlinedButton(
onClick = { onReject(operatorReason) }, onClick = {
enabled = operatorReason.isNotBlank(), onReject(
CandidateHumanReviewDraftPolicy.rejected(
candidateOrdinals,
rejectReason,
rejectNote,
hasBudget
)
)
},
enabled = rejectReviewReady(
candidateOrdinals,
rejectReason,
rejectNote
),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon(Icons.Default.Close, contentDescription = null) Icon(Icons.Default.Close, contentDescription = null)
@@ -463,10 +577,29 @@ private fun CandidateEvaluationSection(
} }
CandidateEvaluationState.MANUAL_REVIEW, CandidateEvaluationState.MANUAL_REVIEW,
CandidateEvaluationState.NO_MATCH -> { CandidateEvaluationState.NO_MATCH -> {
if (state == CandidateEvaluationState.MANUAL_REVIEW) { if (candidateOrdinals.isNotEmpty()) {
Button( Button(
onClick = { onAccept(operatorReason) }, onClick = {
enabled = operatorReason.isNotBlank(), onAccept(
CandidateHumanReviewDraftPolicy.accepted(
candidateOrdinals,
requireNotNull(selectedOrdinal),
acceptReason,
acceptNote,
rejectReason,
rejectNote,
hasBudget
)
)
},
enabled = acceptReviewReady(
candidateOrdinals,
selectedOrdinal,
acceptReason,
acceptNote,
rejectReason,
rejectNote
),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon(Icons.Default.CheckCircle, contentDescription = null) Icon(Icons.Default.CheckCircle, contentDescription = null)
@@ -476,8 +609,21 @@ private fun CandidateEvaluationSection(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
OutlinedButton( OutlinedButton(
onClick = { onReject(operatorReason) }, onClick = {
enabled = operatorReason.isNotBlank(), onReject(
CandidateHumanReviewDraftPolicy.rejected(
candidateOrdinals,
rejectReason,
rejectNote,
hasBudget
)
)
},
enabled = rejectReviewReady(
candidateOrdinals,
rejectReason,
rejectNote
),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon(Icons.Default.Close, contentDescription = null) 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<String>,
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<Int>,
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<Int>,
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 = private fun candidateDecisionLabel(decision: CandidateDecision): String =
when (decision) { when (decision) {
CandidateDecision.REVIEW -> "建议复核" CandidateDecision.REVIEW -> "建议复核"
@@ -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<CandidateHumanReviewItemDraft>(), 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
)
}
}
}
@@ -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 @Test
fun `missing ranked ordinal is rejected instead of falling back`() { fun `missing ranked ordinal is rejected instead of falling back`() {
assertThrows(IllegalArgumentException::class.java) { assertThrows(IllegalArgumentException::class.java) {
@@ -225,6 +225,29 @@ class ProcurementApiClientTest {
assertEquals("/api/v1/tasks/task-id/events", eventRequest.path) assertEquals("/api/v1/tasks/task-id/events", eventRequest.path)
assertEquals("event-idempotency-key", eventRequest.getHeader("Idempotency-Key")) assertEquals("event-idempotency-key", eventRequest.getHeader("Idempotency-Key"))
assertTrue(eventRequest.body.readUtf8().contains("execution-id")) 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( private fun session() = ProcurementSession(
+105 -4
View File
@@ -140,11 +140,112 @@ type ExecutionOutcome struct {
ReceivedAfterExecutionExpiry bool 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 { type ExecutionReport struct {
Events []ExecutionEvent Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset EvidenceAssets []ExecutionEvidenceAsset
CandidateBatch *ExecutionCandidateBatch CandidateBatch *ExecutionCandidateBatch
Outcome *ExecutionOutcome DecisionDataset *CandidateDecisionDataset
Outcome *ExecutionOutcome
} }
type TaskDetail struct { type TaskDetail struct {
@@ -34,19 +34,27 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err) t.Fatalf("initial Up() error = %v", err)
} else if applied != 5 { } else if applied != 6 {
t.Fatalf("initial Up() applied = %d, want 5", applied) t.Fatalf("initial Up() applied = %d, want 6", applied)
} }
if err := runner.Down(ctx); err != nil { 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) seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v4) over historical data error = %v", err) t.Fatalf("Up(v5-v6) over historical data error = %v", err)
} else if applied != 1 { } else if applied != 2 {
t.Fatalf("Up(v4) applied = %d, want 1", applied) 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) assertClaimsHistory(t, db, true)
@@ -61,9 +69,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false) assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4) error = %v", err) t.Fatalf("final Up(v4-v6) error = %v", err)
} else if applied != 2 { } else if applied != 3 {
t.Fatalf("final Up(v4-v5) applied = %d, want 2", applied) t.Fatalf("final Up(v4-v6) applied = %d, want 3", applied)
} }
assertClaimsHistory(t, db, true) assertClaimsHistory(t, db, true)
} }
@@ -299,6 +307,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err) 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 { if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v5) error = %v", err) t.Fatalf("Down(v5) error = %v", err)
} }
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Up() error = %v", err) t.Fatalf("Up() error = %v", err)
} }
if applied != 5 { if applied != 6 {
t.Fatalf("Up() applied = %d, want 5", applied) t.Fatalf("Up() applied = %d, want 6", applied)
} }
assertStatuses(t, runner, map[int64]bool{ assertStatuses(t, runner, map[int64]bool{
1: true, 1: true,
@@ -36,6 +36,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
3: true, 3: true,
4: true, 4: true,
5: true, 5: true,
6: true,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -54,7 +55,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
2: true, 2: true,
3: true, 3: true,
4: true, 4: true,
5: false, 5: true,
6: false,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -70,6 +72,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
3: true, 3: true,
4: true, 4: true,
5: true, 5: true,
6: true,
}) })
} }
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil { if err != nil {
t.Fatalf("migration.New() error = %v", err) 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 { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v5) error = %v", err) t.Fatalf("Down(v5) error = %v", err)
} }
@@ -405,9 +408,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback") t.Fatal("purchase_tasks was lost during auth migration rollback")
} }
if applied, err := runner.Up(context.Background()); err != nil { if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v4) error = %v", err) t.Fatalf("Up(v3-v6) error = %v", err)
} else if applied != 3 { } else if applied != 4 {
t.Fatalf("Up(v3-v5) applied = %d, want 3", applied) t.Fatalf("Up(v3-v6) applied = %d, want 4", applied)
} }
} }
@@ -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
}
@@ -183,6 +183,15 @@ func (s *Store) StoreExecutionCandidates(
if err != nil { if err != nil {
return false, repositoryFailure(err) 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 { if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return false, err return false, err
} }
@@ -825,6 +834,15 @@ func getExecutionReport(
} else if !errors.Is(err, sql.ErrNoRows) { } else if !errors.Is(err, sql.ErrNoRows) {
return nil, repositoryFailure(err) return nil, repositoryFailure(err)
} }
report.DecisionDataset, err = getCandidateDecisionDataset(
ctx,
queryer,
taskID,
executionID,
)
if err != nil {
return nil, err
}
var outcome domain.ExecutionOutcome var outcome domain.ExecutionOutcome
var mode, hash, result, reason, selected, evidenceIDs, code, message, step sql.NullString var mode, hash, result, reason, selected, evidenceIDs, code, message, step sql.NullString
var retryable sql.NullBool var retryable sql.NullBool
@@ -418,6 +418,11 @@ func executionReportResponse(report *domain.ExecutionReport) gin.H {
"received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry, "received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry,
} }
} }
if dataset := report.DecisionDataset; dataset != nil {
response["candidate_decision_dataset"] = candidateDecisionDatasetResponse(
dataset,
)
}
if outcome := report.Outcome; outcome != nil { if outcome := report.Outcome; outcome != nil {
response["outcome"] = gin.H{ response["outcome"] = gin.H{
"result_type": outcome.ResultType, "result_type": outcome.ResultType,
@@ -439,6 +444,85 @@ func executionReportResponse(report *domain.ExecutionReport) gin.H {
return response 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 { func decodedAuditJSON(value *string) any {
if value == nil { if value == nil {
return nil return nil
@@ -72,6 +72,7 @@ func NewDeviceRouteRegistrar(
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents) routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence) routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates) 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/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask) routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil return nil
@@ -500,6 +501,53 @@ func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed}) 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) { func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx) principal, ok := devicePrincipal(ctx)
if !ok { 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 { type lifecycleTransitionRequest struct {
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
ClaimGeneration int64 `json:"claim_generation"` ClaimGeneration int64 `json:"claim_generation"`
@@ -567,6 +567,75 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
}) })
requireDeviceStatus(t, candidates, http.StatusOK) 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( completePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`, `{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"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, started.Execution.ID,
@@ -609,7 +678,12 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.Outcome.OrderSubmitted || detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 || len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 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) t.Fatalf("execution report = %+v", detail.Report)
} }
} }
@@ -86,8 +86,12 @@
{{end}} {{end}}
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}} {{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}} {{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}} {{if .Observations}}<h3>原始候选观察</h3><pre class="audit-json">{{.Observations}}</pre>{{end}}
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}} {{if .ModelPredictions}}<h3>模型逐项评估</h3><pre class="audit-json">{{.ModelPredictions}}</pre>{{end}}
{{if .DeterministicRecommendation}}<h3>确定性推荐</h3><pre class="audit-json">{{.DeterministicRecommendation}}</pre>{{end}}
{{if .HumanReviews}}<h3>人工选择与拒绝</h3><pre class="audit-json">{{.HumanReviews}}</pre>{{end}}
{{if and (not .Observations) .Candidates}}<h3>候选与评估(兼容记录)</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
{{if and (not .DeterministicRecommendation) .Recommendation}}<h3>本地推荐(兼容记录)</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
{{if .Evidence}} {{if .Evidence}}
<h3>证据截图</h3> <h3>证据截图</h3>
<div class="audit-evidence-grid"> <div class="audit-evidence-grid">
+12 -8
View File
@@ -62,14 +62,18 @@ type Task struct {
} }
type ExecutionReport struct { type ExecutionReport struct {
Events []ExecutionReportEvent Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence Evidence []ExecutionReportEvidence
Mode string Mode string
SearchQuery string SearchQuery string
Provenance string Provenance string
Candidates string Candidates string
Recommendation string Recommendation string
Outcome *ExecutionReportOutcome Observations string
ModelPredictions string
DeterministicRecommendation string
HumanReviews string
Outcome *ExecutionReportOutcome
} }
type ExecutionReportEvent struct { type ExecutionReportEvent struct {
@@ -216,6 +216,17 @@ func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON) result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON) 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 { if outcome := report.Outcome; outcome != nil {
result.Outcome = &ExecutionReportOutcome{ result.Outcome = &ExecutionReportOutcome{
ResultType: outcome.ResultType, ResultType: outcome.ResultType,
@@ -247,6 +258,17 @@ func prettyAuditJSON(value *string) string {
return string(formatted) 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 { func stringValue(value *string) string {
if value == nil { if value == nil {
return "" return ""
@@ -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": {},
}
@@ -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"},
},
},
}
}
@@ -40,6 +40,11 @@ type ExecutionResultRepository interface {
ExecutionResultWrite, ExecutionResultWrite,
domain.ExecutionCandidateBatch, domain.ExecutionCandidateBatch,
) (bool, error) ) (bool, error)
StoreCandidateHumanReview(
context.Context,
ExecutionResultWrite,
domain.CandidateHumanReview,
) (domain.CandidateHumanReview, bool, error)
CompleteExecution( CompleteExecution(
context.Context, context.Context,
ExecutionResultWrite, ExecutionResultWrite,
@@ -18,11 +18,12 @@ import (
) )
const ( const (
executionResultEventsOperation = "EVENTS" executionResultEventsOperation = "EVENTS"
executionResultEvidenceOperation = "EVIDENCE" executionResultEvidenceOperation = "EVIDENCE"
executionResultCandidatesOperation = "CANDIDATES" executionResultCandidatesOperation = "CANDIDATES"
executionResultCompleteOperation = "COMPLETE" executionResultHumanReviewOperation = "HUMAN_REVIEW"
executionResultFailOperation = "FAIL" executionResultCompleteOperation = "COMPLETE"
executionResultFailOperation = "FAIL"
manualFirstMode = "MANUAL_FIRST" manualFirstMode = "MANUAL_FIRST"
aiAssistedMode = "AI_ASSISTED" aiAssistedMode = "AI_ASSISTED"
@@ -531,21 +532,16 @@ func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
} else if command.Provenance != nil { } else if command.Provenance != nil {
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST") return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
} }
strictSKUMatching := command.ExecutionMode == aiAssistedMode &&
command.Provenance.SchemaVersion >= 2
for index, candidate := range command.Candidates { for index, candidate := range command.Candidates {
if candidate.Ordinal != index+1 || if candidate.Ordinal != index+1 ||
!validCandidate(candidate, command.ExecutionMode) || !validCandidate(candidate, command.ExecutionMode) {
(strictSKUMatching && !validSKUMatchedCandidate(candidate)) {
return executionResultInvalid("candidates", "must be continuous, bounded observations") return executionResultInvalid("candidates", "must be continuous, bounded observations")
} }
} }
if strictSKUMatching && len(command.Candidates) > 0 && if command.ExecutionMode == manualFirstMode && command.Recommendation != nil {
(command.Recommendation == nil ||
command.Recommendation.CandidateOrdinal != 1) {
return executionResultInvalid( return executionResultInvalid(
"recommendation", "recommendation",
"must select the first sorted SKU-matched candidate", "must be omitted for MANUAL_FIRST",
) )
} }
if command.Recommendation != nil { if command.Recommendation != nil {
@@ -553,7 +549,12 @@ func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
if recommendation.CandidateOrdinal < 1 || if recommendation.CandidateOrdinal < 1 ||
recommendation.CandidateOrdinal > len(command.Candidates) || recommendation.CandidateOrdinal > len(command.Candidates) ||
!validAuditText(recommendation.PolicyVersion, 128) || !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") return executionResultInvalid("recommendation", "is invalid")
} }
} }
@@ -638,7 +639,8 @@ func validSKUMatchedCandidate(candidate ExecutionCandidate) bool {
value.Score >= 0.75 && value.Score >= 0.75 &&
value.Confidence >= 0.75 && value.Confidence >= 0.75 &&
len(value.RejectionReasons) == 0 && len(value.RejectionReasons) == 0 &&
len(value.HardConstraints) == 2 len(value.HardConstraints) == 2 &&
allHardConstraintsMatch(value.HardConstraints)
} }
func validCandidateHardConstraints( func validCandidateHardConstraints(
@@ -653,7 +655,9 @@ func validCandidateHardConstraints(
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, value := range values { for _, value := range values {
if (value.Kind != "COLOR" && value.Kind != "SIZE") || 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.Expected, 128) ||
!validAuditText(value.Evidence, 160) { !validAuditText(value.Evidence, 160) {
return false return false
@@ -666,6 +670,17 @@ func validCandidateHardConstraints(
return len(seen) == 2 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 { func validProvenance(value *ExecutionProvenance) bool {
return value != nil && return value != nil &&
validAuditText(value.ProviderID, 64) && validAuditText(value.ProviderID, 64) &&
@@ -26,9 +26,12 @@ func TestValidateCandidateCommandAcceptsMatchedColorAndSize(t *testing.T) {
func TestValidateCandidateCommandRejectsUnknownHardConstraint(t *testing.T) { func TestValidateCandidateCommandRejectsUnknownHardConstraint(t *testing.T) {
command := validAIExecutionCandidateCommand() command := validAIExecutionCandidateCommand()
command.Candidates[0].Evaluation.HardConstraints[1].Status = "UNKNOWN" 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 { if err := validateCandidateCommand(command); err != nil {
t.Fatal("expected unknown hard constraint to be rejected") 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() command := validAIExecutionCandidateCommand()
second := command.Candidates[0] second := command.Candidates[0]
second.Ordinal = 2 second.Ordinal = 2
@@ -58,8 +61,27 @@ func TestValidateCandidateCommandRejectsV2RecommendationAfterFirstCandidate(t *t
command.Candidates = append(command.Candidates, second) command.Candidates = append(command.Candidates, second)
command.Recommendation.CandidateOrdinal = 2 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 { 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")
} }
} }
@@ -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;
+3 -2
View File
@@ -56,8 +56,9 @@
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权 验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/
证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控 证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控
规格组合/价格核验均已完成。当前正在实现 T-208 的逐候选结构化人工理由、修订历史 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
和优化数据闭环;不得提前建设报表、训练管线或外部商品抓取。 结构化人工理由和修订历史也已完成;下一步需先把商品持久身份和 Admin 下单授权落成
独立任务,不得直接把候选链接当成下单授权。
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。 管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。
+5 -5
View File
@@ -53,7 +53,7 @@
| 功能 | 说明 | 阶段 | | 功能 | 说明 | 阶段 |
| --- | --- | --- | | --- | --- | --- |
| F-008 候选决策数据闭环 | 保存每次搜索实际曝光的最多 5 个候选、模型评估、系统推荐和人工理由,为离线评估与优化提供可信样本。 | P1,T-208;不阻塞第一版流程 | | F-008 候选决策数据闭环 | 保存每次搜索实际曝光的最多 5 个候选、模型评估、系统推荐和人工理由,为离线评估与优化提供可信样本。 | P1,T-208 已实现 |
| 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 | | 完整 RBAC | 采购管理员、执行员、审核员和系统管理员的细粒度权限。 | V2 |
| 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 | | 多设备容量调度 | 在当前原子领取/租约基础上增加优先级、容量、运营监控和跨实例调度。 | V2 |
| 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 | | 后台通知 | WebSocket/厂商推送只通知有任务,App 仍通过 claim 领取。 | V2 |
@@ -61,11 +61,11 @@
| 多平台比价 | 淘宝、1688、京东等平台。 | V3 | | 多平台比价 | 淘宝、1688、京东等平台。 | V3 |
| 支付自动化 | 不在当前规划内,除非另行完成资金和合规评审。 | 未规划 | | 支付自动化 | 不在当前规划内,除非另行完成资金和合规评审。 | 未规划 |
### F-008 候选决策数据闭环(后置) ### F-008 候选决策数据闭环
T-206/T-207 先跑通领取、执行、候选/证据和最小结果回传;T-208 再实现可用于优化的 T-206/T-207 已跑通领取、执行、候选/证据和最小结果回传;T-208 已实现可用于优化
完整数据闭环。不能为了建设未来训练数据阻塞第一版端到端流程,但 20 条真实任务试验 的完整数据闭环。20 条真实任务试验必须使用这条结构化链路,避免试验结束后才发现
开始前必须完成 T-208,避免试验结束后才发现缺少曝光和人工标签。 缺少曝光和人工标签。
1. 每次 search run 保存需求快照、精确搜索词、App/拼多多版本、采集时间以及最多 1. 每次 search run 保存需求快照、精确搜索词、App/拼多多版本、采集时间以及最多
5 个实际曝光候选的原始 ordinal。该集合只是特定账号、地区、时间和平台排序下的 5 个实际曝光候选的原始 ordinal。该集合只是特定账号、地区、时间和平台排序下的
+3 -2
View File
@@ -528,9 +528,10 @@ T-205 的最小 execution 表中提前伪造。
- 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再 - 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再
统一规范化为匿名 JPEG;未来 Android 读取的资产不能依赖原始扩展名或声明 MIME。 统一规范化为匿名 JPEG;未来 Android 读取的资产不能依赖原始扩展名或声明 MIME。
### 后置候选决策数据集(T-208) ### 候选决策数据集(T-208)
T-207 先完成候选、事件、截图和最小结果回传。T-208 在 `task_executions` 下增加 T-208 已在 T-207 的候选、事件、截图和最小结果回传基础上,于
`task_executions` 下增加
独立、不可变的数据层,不把整批候选塞进 `task_events`、`purchase_tasks` JSON 或 独立、不可变的数据层,不把整批候选塞进 `task_events`、`purchase_tasks` JSON 或
一段不可查询的自由文本: 一段不可查询的自由文本:
+3 -3
View File
@@ -15,7 +15,7 @@
| IX-006 | US-004 | App 执行页 | 确认开始/自动步骤 | 显示步骤并有界执行搜索与候选判断 | P0 | 已定 | | IX-006 | US-004 | App 执行页 | 确认开始/自动步骤 | 显示步骤并有界执行搜索与候选判断 | P0 | 已定 |
| IX-007 | US-005 | App 候选确认 | 接受/拒绝/转人工 | 停止自动化并回传人员结论 | P0 | 已定 | | IX-007 | US-005 | App 候选确认 | 接受/拒绝/转人工 | 停止自动化并回传人员结论 | P0 | 已定 |
| IX-008 | US-006 | 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-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 |
## IX-001 管理 Web 登录 ## IX-001 管理 Web 登录
@@ -255,8 +255,8 @@
- 页面:Android“候选确认”、管理 Web“任务详情/候选决策”。 - 页面:Android“候选确认”、管理 Web“任务详情/候选决策”。
- 角色:采购执行员、采购管理员/优化人员。 - 角色:采购执行员、采购管理员/优化人员。
- 前置条件:T-207 第一版流程已完成;T-208 reason schema 已固定。 - 前置条件:T-207 第一版流程和 T-208 reason schema 已完成。
- 服务依赖:T-208 幂等 human review API。 - 服务依赖:已实现的 T-208 幂等 human review API。
**正常路径** **正常路径**
+51 -7
View File
@@ -438,7 +438,8 @@ candidate evidence SHA-256 和结构化模型判断。结果不得包含 Key、A
## 执行事件与结果 ## 执行事件与结果
App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺序提交。所有写接口 App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 人工 review -> 终态”
顺序提交。所有写接口
重新校验 BUYER/device/task/execution/claim 和幂等键。原设备可以在 重新校验 BUYER/device/task/execution/claim 和幂等键。原设备可以在
`execution_expires_at` 后补报授权内已经产生的结果;后端记录 `execution_expires_at` 后补报授权内已经产生的结果;后端记录
`received_after_execution_expiry=true`,但这不允许 App 在过期后继续自动化。 `received_after_execution_expiry=true`,但这不允许 App 在过期后继续自动化。
@@ -474,7 +475,7 @@ App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺
### `POST /api/v1/tasks/{task_id}/candidates` ### `POST /api/v1/tasks/{task_id}/candidates`
批量保存当前 execution 实际检查后通过策略的 `0..5` 个候选,必须带 批量保存当前 execution 实际检查的 `0..5` 个原始曝光候选,必须带
`Idempotency-Key`。正式参考图检索使用固定审计值 `PDD_IMAGE_SEARCH`,不把标题或 `Idempotency-Key`。正式参考图检索使用固定审计值 `PDD_IMAGE_SEARCH`,不把标题或
SKU 伪装成图片检索词: SKU 伪装成图片检索词:
@@ -533,11 +534,54 @@ SKU 伪装成图片检索词:
``` ```
`MANUAL_FIRST` 时 `provenance` 和 `evaluation` 为空,但候选观察、搜索审计值和人工 `MANUAL_FIRST` 时 `provenance` 和 `evaluation` 为空,但候选观察、搜索审计值和人工
结果仍可提交。`AI_ASSISTED` 的非空 `evaluation` 必须同时包含唯一的 `COLOR` 和 结果仍可提交。`AI_ASSISTED` 为每个原始 observation 保存 evaluation;颜色/尺码状态
`SIZE`,且全部为 `MATCH`;schema v2 还要求分数和置信度均不低于 `0.75`,非空批次的 可以是 `MATCH/MISMATCH/UNKNOWN`,模型拒绝项也必须保留。schema v2 的
推荐必须指向排序后的第 1 项。没有候选满足条件时提交空 `candidates` 和空 `recommendation` 只能指向颜色和尺码均为 `MATCH`、分数和置信度均不低于 `0.75`
`recommendation`,不得用弱匹配或未知项凑数。后端校验 ordinal 连续唯一、最多 5 个和 且没有拒绝原因的原始 ordinal;没有满足项时只省略 recommendation,不能删除原始
任务内容哈希;不请求 `product_url` 或 `image_url`,主要证据必须是已鉴权 asset。 候选。后端在同一事务内写入 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` ### `POST /api/v1/tasks/{task_id}/complete`
+20 -20
View File
@@ -5,9 +5,9 @@
## 当前快照 ## 当前快照
- 日期:2026-07-28 - 日期:2026-07-28
- 阶段:T-208 候选决策数据与人工理由闭环进行中 - 阶段:T-208 候选决策数据与人工理由闭环已完成
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、 - 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-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
@@ -17,11 +17,10 @@
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-213 Android Debug 25 个 suite、148 个测试、0 失败,Debug/Release 回归、 - 测试:T-208 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过;
`assembleDebug` 和根 `init.ps1` 通过;Debug APK `1.4.10 (15)` 已安装并冷启动于 Debug APK `1.4.11 (16)` 已安装并启动于 PKG110
PKG110 - 后端测试:T-208 运行 `go test ./...`、`go test -race ./...`、migration
- 后端测试:T-213 运行 `go test ./...` 通过;T-207 的全包 race 与 migration `up/down/up` 和带 review 数据的降级保护均通过
验证继续有效
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px 脚本错误或外部请求,Android 可见交互控件均不小于 44px
@@ -47,10 +46,15 @@
失败记录均按 execution 存储;Android 使用加密 outbox 按事件、截图、候选和终态 失败记录均按 execution 存储;Android 使用加密 outbox 按事件、截图、候选和终态
顺序回传,授权到期补报单独审计,所有终态固定 `order_submitted=false`。管理任务 顺序回传,授权到期补报单独审计,所有终态固定 `order_submitted=false`。管理任务
详情展示模型/候选/人工理由/事件/证据摘要,不保存 VLM Key、完整 endpoint 或原始响应。 详情展示模型/候选/人工理由/事件/证据摘要,不保存 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 - T-211 图片检索:后台任务固定使用经 SHA-256 校验的参考 JPEG,经一次性 MediaStore
图片进入拼多多拍照搜索;App 本地从 SKU 唯一提取颜色和尺码,schema v3 逐项返回 图片进入拼多多拍照搜索;App 本地从 SKU 唯一提取颜色和尺码,schema v3 逐项返回
`MATCH/MISMATCH/UNKNOWN`。只有两项均匹配且分数/置信度不低于 `0.75` 的候选按 `MATCH/MISMATCH/UNKNOWN`。实际曝光的 `0..5` 项全部按原 ordinal 回传;只有两项
分数、置信度和曝光顺序回传 `0..5` 项,弱匹配和未知项不凑数。 均匹配且分数/置信度不低于 `0.75` 的候选能成为确定性 recommendation。
- T-213 规格核验:`DISCOVERY_INSPECT` 可从唯一“免拼购买”进入规格弹层,按任务 - T-213 规格核验:`DISCOVERY_INSPECT` 可从唯一“免拼购买”进入规格弹层,按任务
SKU 唯一选择颜色/尺码并复核 selected/已选摘要,最多滚动 2 次;严格保存单一 SKU 唯一选择颜色/尺码并复核 selected/已选摘要,最多滚动 2 次;严格保存单一
人民币组合价和详情/规格双证据,并允许明确的“首件¥金额”当前单件价。区间、 人民币组合价和详情/规格双证据,并允许明确的“首件¥金额”当前单件价。区间、
@@ -63,15 +67,13 @@
单次调用边界;候选最多 5 个并按 ordinal 串行评估,本地产生建议并停在人工确认, 单次调用边界;候选最多 5 个并按 ordinal 串行评估,本地产生建议并停在人工确认,
SKU/数量由本地原值回填,颜色/尺码硬约束由本地生成,预算保持空,订单提交状态固定 SKU/数量由本地原值回填,颜色/尺码硬约束由本地生成,预算保持空,订单提交状态固定
为 false 为 false
- 后置数据闭环:已登记 T-208,在第一版 T-206/T-207 跑通后分离保存候选观测、
模型预测、确定性推荐和人工标签;人工接受/拒绝使用结构化理由,20 条试验依赖它
- VLM 部署决策:手机本地调用已配置的 OpenAI 兼容 provider;管理后端只负责身份、 - VLM 部署决策:手机本地调用已配置的 OpenAI 兼容 provider;管理后端只负责身份、
任务控制和结果审计,不保存/代理模型。T-207 保留 Roubao 独立模式并把端上 Key 任务控制和结果审计,不保存/代理模型。T-207 保留 Roubao 独立模式并把端上 Key
迁移到 Keystore-backed 加密存储 迁移到 Keystore-backed 加密存储
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
RUNNING 不自动重新分配 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 (81700)`
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权 真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权
@@ -86,7 +88,7 @@
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1` - 标准验证路径:`.\init.ps1`
- 当前 blocker:T-213 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 - 当前 blocker:T-208 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+ 和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+
## 当前目录 ## 当前目录
@@ -115,7 +117,7 @@
| `docs/tasks/T-211.md` | DONE | 参考图召回、SKU 颜色尺码硬匹配与 0..5 候选回传 | | `docs/tasks/T-211.md` | DONE | 参考图召回、SKU 颜色尺码硬匹配与 0..5 候选回传 |
| `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 | | `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 |
| `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 | | `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 页面 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `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-001 至 T-004、T-101 至 T-104、T-201 至 T-213。
T-212、T-213。 - 正在进行:无。
- 正在进行:T-208 候选决策数据与人工理由闭环。 - 下一步:先落独立任务,依次实现商品持久身份、Admin 下单授权、设备命令、
- 下一个可领取任务:无;先完成 T-208。 订单 dry-run、单次提交对账和付款提醒。
- 后置任务:T-208 完成后依次实现商品
持久身份、Admin 下单授权、设备命令、订单 dry-run、单次提交对账和付款提醒。
## 当前可运行内容 ## 当前可运行内容
+26 -9
View File
@@ -5,7 +5,7 @@ phase: 2
deps: deps:
- T-207 - T-207
- T-213 - T-213
status: DOING status: DONE
created: 2026-07-28 created: 2026-07-28
context_ref: 4c3b962 context_ref: 4c3b962
work_branch: null work_branch: null
@@ -131,15 +131,15 @@ T-207 已能回传候选批次、模型评估、本地推荐和一段 `operator_
## 验收要点 ## 验收要点
- [ ] migration `up/down/up` 可重复;有 review 数据时破坏性 down 明确失败。 - [x] migration `up/down/up` 可重复;有 review 数据时破坏性 down 明确失败。
- [ ] 最多 5 个原始曝光 observation 按原 ordinal 可查询,模型拒绝项不会丢失。 - [x] 最多 5 个原始曝光 observation 按原 ordinal 可查询,模型拒绝项不会丢失。
- [ ] observation、model evaluation、recommendation 和 human label 分表且引用一致。 - [x] observation、model evaluation、recommendation 和 human label 分表且引用一致。
- [ ] human review API 校验理由 allowlist、标签兼容、预算适用性、全候选覆盖、唯一 - [x] human review API 校验理由 allowlist、标签兼容、预算适用性、全候选覆盖、唯一
接受项、幂等重放和追加式 supersedes。 接受项、幂等重放和追加式 supersedes。
- [ ] App 支持选择推荐项、改选、全部拒绝和零候选;每种路径都产生结构化逐候选理由。 - [x] App 支持选择推荐项、改选、全部拒绝和零候选;每种路径都产生结构化逐候选理由。
- [ ] 模型理由不会预填人工理由,旧 `operator_reason` 只保留兼容审计含义。 - [x] 模型理由不会预填人工理由,旧 `operator_reason` 只保留兼容审计含义。
- [ ] Admin/API 能读取当前 review 和历史版本,外链不会触发任何服务端网络请求。 - [x] Admin/API 能读取当前 review 和历史版本,外链不会触发任何服务端网络请求。
- [ ] Android/Go 单元测试、race、migration、Debug/Release 构建和根验证通过。 - [x] Android/Go 单元测试、race、migration、Debug/Release 构建和根验证通过。
## 边界 ## 边界
@@ -155,3 +155,20 @@ T-207 已能回传候选批次、模型评估、本地推荐和一段 `operator_
`execution_candidate_batches`/`execution_outcomes` 只能提供 JSON 审计副本和自由 `execution_candidate_batches`/`execution_outcomes` 只能提供 JSON 审计副本和自由
文本说明,AI 模式还会过滤拒绝候选;因此冻结以上归一化数据层和结构化 review 文本说明,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)`,肉包采购无障碍仍
已启用。安装后截图确认任务首屏无重叠或裁切。