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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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 配置。
|
||||
|
||||
@@ -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。该集合只是特定账号、地区、时间和平台排序下的
|
||||
|
||||
@@ -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 或
|
||||
一段不可查询的自由文本:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)`,肉包采购无障碍仍
|
||||
已启用。安装后截图确认任务首屏无重叠或裁切。
|
||||
|
||||
Reference in New Issue
Block a user