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
@@ -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(