feat(t208): close candidate decision feedback loop
This commit is contained in:
@@ -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(
|
||||
|
||||
+201
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+56
-7
@@ -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(),
|
||||
|
||||
+264
-35
@@ -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 -> "建议复核"
|
||||
|
||||
+67
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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) {
|
||||
|
||||
+23
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user