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