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