feat(t207): audit local procurement results

This commit is contained in:
QiuSW
2026-07-27 12:23:05 +08:00
parent a00c1e3e17
commit 4533df7abc
37 changed files with 4004 additions and 115 deletions
@@ -50,6 +50,10 @@ import com.roubao.autopilot.vlm.RequirementExtractionResult
import com.roubao.autopilot.vlm.RequirementExtractor
import com.roubao.autopilot.vlm.RequirementProbeState
import com.roubao.autopilot.vlm.RequirementProviderEndpointPolicy
import com.roubao.autopilot.vlm.REQUIREMENT_PROMPT_VERSION
import com.roubao.autopilot.vlm.REQUIREMENT_SCHEMA_VERSION
import com.roubao.autopilot.vlm.CANDIDATE_EVALUATION_PROMPT_VERSION
import com.roubao.autopilot.vlm.CANDIDATE_EVALUATION_SCHEMA_VERSION
import com.roubao.autopilot.vlm.AndroidCandidateEvaluationVlmGateway
import com.roubao.autopilot.vlm.CandidateBatchConclusion
import com.roubao.autopilot.vlm.CandidateEvaluationFailureCode
@@ -78,7 +82,14 @@ import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import com.roubao.autopilot.procurement.LoginInput
import com.roubao.autopilot.procurement.ExecutionCandidateBatchDraft
import com.roubao.autopilot.procurement.ExecutionCandidateDraft
import com.roubao.autopilot.procurement.ExecutionCandidateEvaluation
import com.roubao.autopilot.procurement.ExecutionEvidenceDraft
import com.roubao.autopilot.procurement.ExecutionMode
import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot
import com.roubao.autopilot.procurement.ProcurementRepository
import com.roubao.autopilot.task.RequirementProbeFixture
import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
@@ -123,6 +134,8 @@ class MainActivity : ComponentActivity() {
private val candidateReviewBatch = mutableStateOf<CandidateReviewBatch?>(null)
private val candidateEvaluationFailureCode =
mutableStateOf<CandidateEvaluationFailureCode?>(null)
private val taskCandidateDrafts =
mutableStateOf<List<ExecutionCandidateDraft>>(emptyList())
private var searchProbeRunner: WorkflowRunner? = null
private var searchProbeJob: Job? = null
private var requirementProbeJob: Job? = null
@@ -354,9 +367,9 @@ class MainActivity : ComponentActivity() {
procurementRepository.claimNext(readiness)
}
},
onStart = {
onStart = { mode ->
lifecycleScope.launch {
procurementRepository.start()
startProcurementExecution(mode)
}
},
onRelease = {
@@ -411,8 +424,12 @@ class MainActivity : ComponentActivity() {
onStopCandidateEvaluation = {
stopCandidateEvaluation()
},
onAcceptCandidate = { acceptRecommendedCandidate() },
onRejectCandidates = { rejectCandidateReview() },
onAcceptCandidate = { reason ->
acceptRecommendedCandidate(reason)
},
onRejectCandidates = { reason ->
rejectCandidateReview(reason)
},
onStart = { startSearchProbe() },
onStop = { stopSearchProbe() }
)
@@ -502,6 +519,43 @@ class MainActivity : ComponentActivity() {
startActivity(launchIntent)
}
private suspend fun startProcurementExecution(mode: ExecutionMode) {
if (mode == ExecutionMode.MANUAL_FIRST) {
procurementRepository.start(mode)
return
}
val settings = settingsManager.settings.value
val provider = settings.currentProvider
if (!provider.supportsRequirementExtraction ||
settings.apiKey.isBlank() ||
settings.baseUrl.isBlank() ||
settings.model.isBlank() ||
!settingsManager.isSecureCredentialStorageAvailable ||
!RequirementProviderEndpointPolicy.isAllowed(
settings.baseUrl,
settings.apiKey
)
) {
Toast.makeText(
this,
"AI 辅助模式需要本机安全配置可用的 VLM",
Toast.LENGTH_SHORT
).show()
return
}
procurementRepository.start(
mode = mode,
provenance = ExecutionProvenanceSnapshot(
mode = mode,
providerId = provider.id,
model = settings.model,
promptVersion = REQUIREMENT_PROMPT_VERSION,
schemaVersion = REQUIREMENT_SCHEMA_VERSION,
referenceImageSha256 = ""
)
)
}
private fun startSearchProbe() {
val readiness = readinessChecker.snapshot()
readinessSnapshot.value = readiness
@@ -517,11 +571,22 @@ class MainActivity : ComponentActivity() {
return
}
val procurementTask = procurementRepository.currentProbeTask()
val procurementMode = procurementRepository.activeExecutionMode()
val boundRequirement = requirementExtraction.value?.takeIf {
requirementProbeState.value == RequirementProbeState.READY &&
!it.manualReviewRequired
}
val searchKeyword = boundRequirement?.searchQuery ?: SEARCH_PROBE_KEYWORD
if (procurementTask != null &&
procurementMode == ExecutionMode.AI_ASSISTED &&
boundRequirement == null
) {
Toast.makeText(this, "请先完成本地需求提取", Toast.LENGTH_SHORT).show()
return
}
val searchKeyword = boundRequirement?.searchQuery ?: procurementTask
?.let { manualSearchQuery(it.title, it.sku) }
?: SEARCH_PROBE_KEYWORD
val candidateAutomation = PinduoduoCandidateAutomation(
AndroidPinduoduoCandidateDriver(this)
)
@@ -567,6 +632,16 @@ class MainActivity : ComponentActivity() {
val report = runner.run(PinduoduoCandidateWorkflow.steps())
searchProbeReport.value = report
searchProbeState.value = report.state
if (report.state == WorkflowState.SUCCEEDED &&
procurementTask != null &&
procurementMode == ExecutionMode.MANUAL_FIRST
) {
queueManualProcurementCandidates(
procurementTask.title,
searchKeyword,
candidateEvidence.value
)
}
} finally {
stateCollector.cancel()
stepCollector.cancel()
@@ -594,6 +669,16 @@ class MainActivity : ComponentActivity() {
Toast.makeText(this, "请先停止候选评估", Toast.LENGTH_SHORT).show()
return
}
if (procurementRepository.currentProbeTask() != null &&
procurementRepository.activeExecutionMode() != ExecutionMode.AI_ASSISTED
) {
Toast.makeText(
this,
"当前后台任务为人工优先模式,不调用 VLM",
Toast.LENGTH_SHORT
).show()
return
}
candidateEvidence.value = emptyList()
candidateSearchKeyword.value = null
candidateRequirementSnapshot.value = null
@@ -640,7 +725,11 @@ class MainActivity : ComponentActivity() {
requirementFailureCode.value = null
requirementProbeJob = lifecycleScope.launch {
try {
val fixture = requirementProbeSource.loadFirst().getOrElse {
val fixture = procurementRepository.currentProbeTask()?.let { task ->
procurementRepository.currentReferenceImageBytes()?.let { image ->
RequirementProbeFixture(task, image)
}
} ?: requirementProbeSource.loadFirst().getOrElse {
setRequirementFailure(RequirementExtractionFailureCode.SOURCE_UNAVAILABLE)
return@launch
} ?: run {
@@ -809,6 +898,48 @@ class MainActivity : ComponentActivity() {
)
when (result) {
is CandidateEvaluationResult.Completed -> {
if (procurementRepository.activeExecutionMode() ==
ExecutionMode.AI_ASSISTED
) {
val drafts = result.batch.assessments.map { assessment ->
ExecutionCandidateDraft(
ordinal = assessment.ordinal,
title = "拼多多候选 ${assessment.ordinal}",
evidenceLocalIDs = emptyList(),
evaluation = ExecutionCandidateEvaluation(
decision = assessment.decision.name,
score = assessment.score,
matched = assessment.matched,
missingOrUncertain = assessment.missingOrUncertain,
rejectionReasons = assessment.rejectionReasons,
confidence = assessment.confidence
)
)
}
taskCandidateDrafts.value = procurementRepository.queueCandidateBatch(
ExecutionCandidateBatchDraft(
mode = ExecutionMode.AI_ASSISTED,
searchQuery = requirement.searchQuery,
provenance = ExecutionProvenanceSnapshot(
mode = ExecutionMode.AI_ASSISTED,
providerId = result.batch.providerId,
model = result.batch.model,
promptVersion = CANDIDATE_EVALUATION_PROMPT_VERSION,
schemaVersion = CANDIDATE_EVALUATION_SCHEMA_VERSION,
referenceImageSha256 =
result.batch.requirementReferenceImageSha256
),
candidates = drafts
),
validated.map { candidate ->
ExecutionEvidenceDraft(
ordinal = candidate.ordinal,
pngBytes = candidate.pngBytes,
sha256 = candidate.sha256
)
}
).orEmpty()
}
candidateReviewBatch.value = result.batch
candidateEvaluationState.value = when (
result.batch.conclusion
@@ -838,17 +969,90 @@ class MainActivity : ComponentActivity() {
candidateEvaluationJob?.cancel()
}
private fun acceptRecommendedCandidate() {
private suspend fun queueManualProcurementCandidates(
taskTitle: String,
searchQuery: String,
evidence: List<PinduoduoCandidateEvidence>
) {
val validated = candidateEvidenceSource.load(evidence).getOrNull() ?: return
taskCandidateDrafts.value = procurementRepository.queueCandidateBatch(
ExecutionCandidateBatchDraft(
mode = ExecutionMode.MANUAL_FIRST,
searchQuery = searchQuery,
provenance = null,
candidates = validated.map { candidate ->
ExecutionCandidateDraft(
ordinal = candidate.ordinal,
title = "$taskTitle 候选 ${candidate.ordinal}",
evidenceLocalIDs = emptyList()
)
}
),
validated.map { candidate ->
ExecutionEvidenceDraft(
ordinal = candidate.ordinal,
pngBytes = candidate.pngBytes,
sha256 = candidate.sha256
)
}
).orEmpty()
if (taskCandidateDrafts.value.isNotEmpty()) {
candidateEvaluationState.value = CandidateEvaluationState.MANUAL_REVIEW
}
}
private fun manualSearchQuery(title: String, sku: String): String =
listOf(title.trim(), sku.trim())
.filter { it.isNotBlank() }
.joinToString(" ")
.take(160)
private fun acceptRecommendedCandidate(operatorReason: String) {
candidateEvaluationState.value = CandidateHumanReviewPolicy.accept(
currentState = candidateEvaluationState.value,
batch = candidateReviewBatch.value
)
if (candidateEvaluationState.value != CandidateEvaluationState.HUMAN_ACCEPTED &&
taskCandidateDrafts.value.isNotEmpty()
) {
candidateEvaluationState.value = CandidateEvaluationState.HUMAN_ACCEPTED
}
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_ACCEPTED) {
val recommendedOrdinal = candidateReviewBatch.value
?.recommendedCandidateOrdinal
val candidate = taskCandidateDrafts.value.firstOrNull {
it.ordinal == recommendedOrdinal
} ?: taskCandidateDrafts.value.firstOrNull()
if (candidate != null && procurementRepository.currentProbeTask() != null) {
lifecycleScope.launch {
procurementRepository.completeExecution(
outcome = "CANDIDATE_ACCEPTED",
operatorReason = operatorReason,
candidate = candidate
)
}
}
}
}
private fun rejectCandidateReview() {
private fun rejectCandidateReview(operatorReason: String) {
candidateEvaluationState.value = CandidateHumanReviewPolicy.reject(
candidateEvaluationState.value
)
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_REJECTED &&
procurementRepository.currentProbeTask() != null
) {
lifecycleScope.launch {
procurementRepository.completeExecution(
outcome = if (taskCandidateDrafts.value.isEmpty()) {
"NO_MATCH"
} else {
"CANDIDATE_REJECTED"
},
operatorReason = operatorReason
)
}
}
}
private fun setCandidateEvaluationFailure(
@@ -127,32 +127,31 @@ class SettingsManager(context: Context) {
context.getSharedPreferences("baozi_settings", Context.MODE_PRIVATE)
// 加密存储(用于敏感数据如 API Key)
private val securePrefs: SharedPreferences by lazy {
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs: SharedPreferences? = try {
val masterKey = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"baozi_secure_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
prefs
}
EncryptedSharedPreferences.create(
context.applicationContext,
"baozi_secure_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
null
}
private val _settings = MutableStateFlow(loadSettings())
val settings: StateFlow<AppSettings> = _settings
private val _settings: MutableStateFlow<AppSettings>
val settings: StateFlow<AppSettings>
init {
// 迁移旧的明文 API Key 到加密存储
migrateApiKeyToSecureStorage()
_settings = MutableStateFlow(loadSettings())
settings = _settings
}
val isSecureCredentialStorageAvailable: Boolean
@@ -163,13 +162,19 @@ class SettingsManager(context: Context) {
*/
private fun migrateApiKeyToSecureStorage() {
val oldApiKey = prefs.getString("api_key", null)
if (!oldApiKey.isNullOrEmpty()) {
// 保存到加密存储
securePrefs.edit().putString("api_key", oldApiKey).apply()
// 删除旧的明文存储
prefs.edit().remove("api_key").apply()
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
if (oldApiKey.isNullOrEmpty()) return
val encrypted = securePrefs
if (encrypted == null || !encrypted.edit().putString("api_key", oldApiKey).commit()) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "API Key migration failed closed")
return
}
if (!prefs.edit().remove("api_key").commit()) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Plaintext API Key cleanup failed closed")
return
}
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
}
private fun loadSettings(): AppSettings {
@@ -191,7 +196,7 @@ class SettingsManager(context: Context) {
}
// 迁移旧数据(如果有)
val oldApiKey = securePrefs.getString("api_key", null)
val oldApiKey = securePrefs?.getString("api_key", null)
val oldModel = prefs.getString("model", null)
val oldBaseUrl = prefs.getString("base_url", null)
val oldCachedModels = prefs.getStringSet("cached_models", null)
@@ -205,24 +210,29 @@ class SettingsManager(context: Context) {
else -> "custom"
}
// 迁移到新格式
val migratedConfig = ProviderConfig(
apiKey = oldApiKey ?: "",
model = oldModel ?: "",
cachedModels = oldCachedModels?.toList() ?: emptyList(),
customBaseUrl = if (oldProviderId == "custom") oldBaseUrl ?: "" else ""
)
providerConfigs[oldProviderId] = migratedConfig
saveProviderConfig(oldProviderId, migratedConfig)
if (saveProviderConfig(oldProviderId, migratedConfig)) {
providerConfigs[oldProviderId] = migratedConfig
}
// 清除旧数据
securePrefs.edit().remove("api_key").apply()
prefs.edit()
val secureMigrationComplete = securePrefs?.edit()
?.remove("api_key")
?.commit() == true
val preferencesMigrationComplete = prefs.edit()
.remove("model")
.remove("base_url")
.remove("cached_models")
.putString("current_provider_id", oldProviderId)
.apply()
.commit()
if (!secureMigrationComplete || !preferencesMigrationComplete) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Legacy provider migration failed closed")
}
android.util.Log.d("SettingsManager", "Migrated old settings to provider: $oldProviderId")
}
@@ -244,7 +254,7 @@ class SettingsManager(context: Context) {
private fun loadProviderConfig(providerId: String): ProviderConfig {
val prefix = "provider_${providerId}_"
return ProviderConfig(
apiKey = securePrefs.getString("${prefix}api_key", "") ?: "",
apiKey = securePrefs?.getString("${prefix}api_key", "") ?: "",
model = prefs.getString("${prefix}model", "") ?: "",
cachedModels = prefs.getStringSet("${prefix}cached_models", emptySet())?.toList() ?: emptyList(),
customBaseUrl = prefs.getString("${prefix}custom_base_url", "") ?: ""
@@ -254,14 +264,30 @@ class SettingsManager(context: Context) {
/**
* 保存指定服务商的配置
*/
private fun saveProviderConfig(providerId: String, config: ProviderConfig) {
private fun saveProviderConfig(providerId: String, config: ProviderConfig): Boolean {
val prefix = "provider_${providerId}_"
securePrefs.edit().putString("${prefix}api_key", config.apiKey).apply()
prefs.edit()
if (config.apiKey.isNotEmpty()) {
val encrypted = securePrefs
if (encrypted == null ||
!encrypted.edit().putString("${prefix}api_key", config.apiKey).commit()
) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Refused API Key write without encryption")
return false
}
} else {
if (securePrefs != null &&
!securePrefs.edit().remove("${prefix}api_key").commit()
) {
secureCredentialStorageAvailable = false
return false
}
}
return prefs.edit()
.putString("${prefix}model", config.model)
.putStringSet("${prefix}cached_models", config.cachedModels.toSet())
.putString("${prefix}custom_base_url", config.customBaseUrl)
.apply()
.commit()
}
/**
@@ -272,7 +298,9 @@ class SettingsManager(context: Context) {
val currentConfig = _settings.value.currentConfig
val newConfig = update(currentConfig)
saveProviderConfig(currentId, newConfig)
if (!saveProviderConfig(currentId, newConfig)) {
return
}
val newConfigs = _settings.value.providerConfigs.toMutableMap()
newConfigs[currentId] = newConfig
@@ -0,0 +1,79 @@
package com.roubao.autopilot.procurement
import java.security.MessageDigest
data class ExecutionEvidenceDraft(
val ordinal: Int,
val pngBytes: ByteArray,
val sha256: String
)
data class ExecutionCandidateDraft(
val ordinal: Int,
val title: String,
val skuText: String = "",
val price: String = "",
val productUrl: String = "",
val imageUrl: String = "",
val evidenceLocalIDs: List<String>,
val evaluation: ExecutionCandidateEvaluation? = null
)
data class ExecutionCandidateEvaluation(
val decision: String,
val score: Double,
val matched: List<String>,
val missingOrUncertain: List<String>,
val rejectionReasons: List<String>,
val confidence: Double
)
data class ExecutionRecommendation(
val candidateOrdinal: Int,
val policyVersion: String,
val reasons: List<String>
)
data class ExecutionCandidateBatchDraft(
val mode: ExecutionMode,
val searchQuery: String,
val provenance: ExecutionProvenanceSnapshot?,
val candidates: List<ExecutionCandidateDraft>,
val recommendation: ExecutionRecommendation? = null
)
object ExecutionTaskHash {
fun sha256(task: RemotePurchaseTask): String {
val maxBudgetCents = task.maxBudget
?.let(::parseCnyCents)
?.toString()
.orEmpty()
val payload = listOf(
task.title,
task.description,
task.sku,
task.imageAssetId,
task.quantity.toString(),
maxBudgetCents,
task.currency
).joinToString("\u0000")
return MessageDigest.getInstance("SHA-256")
.digest(payload.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
private fun parseCnyCents(value: String): Long {
val normalized = value.trim()
val pieces = normalized.split('.', limit = 2)
require(pieces.size in 1..2 && pieces[0].all(Char::isDigit))
val whole = pieces[0].toLong()
val fractional = when (pieces.size) {
1 -> 0L
else -> {
require(pieces[1].length in 1..2 && pieces[1].all(Char::isDigit))
pieces[1].padEnd(2, '0').toLong()
}
}
return whole * 100 + fractional
}
}
@@ -25,6 +25,10 @@ data class DownloadedReferenceImage(
val sha256: String
)
data class ExecutionOutboxUploadResult(
val evidenceAssetId: String? = null
)
interface ProcurementRemoteApi {
suspend fun login(
baseUrl: String,
@@ -79,6 +83,15 @@ interface ProcurementRemoteApi {
claimToken: String,
idempotencyKey: String
): RemotePurchaseTask
suspend fun uploadExecutionOutboxItem(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
item: ExecutionOutboxItem,
evidenceBytes: ByteArray? = null
): ExecutionOutboxUploadResult
}
class ProcurementApiClient(
@@ -250,6 +263,50 @@ class ProcurementApiClient(
}
}
override suspend fun uploadExecutionOutboxItem(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
item: ExecutionOutboxItem,
evidenceBytes: ByteArray?
): ExecutionOutboxUploadResult = withContext(Dispatchers.IO) {
val path = when (item.type) {
ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events"
ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence"
ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates"
ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete"
ExecutionOutboxType.FAIL -> "/api/v1/tasks/${task.id}/fail"
}
val request = authorizedRequest(session, path)
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, item.idempotencyKey)
if (item.type == ExecutionOutboxType.EVIDENCE) {
val bytes = requireNotNull(evidenceBytes) { "离线证据文件缺失" }
require(bytes.isNotEmpty() && bytes.size <= MAX_EVIDENCE_BYTES) {
"离线证据文件无效"
}
request
.header("X-Execution-ID", execution.id)
.header("X-Claim-Generation", task.claimGeneration.toString())
.post(bytes.toRequestBody(PNG_MEDIA_TYPE))
} else {
val payload = item.payload
require(payload.toByteArray(Charsets.UTF_8).size <= MAX_JSON_BYTES) {
"离线结果过大"
}
request.post(payload.toRequestBody(JSON_MEDIA_TYPE))
}
val json = executeJson(request.build())
ExecutionOutboxUploadResult(
evidenceAssetId = if (item.type == ExecutionOutboxType.EVIDENCE) {
json.getJSONObject("evidence").getString("id")
} else {
null
}
)
}
override suspend fun start(
session: ProcurementSession,
task: RemotePurchaseTask,
@@ -455,6 +512,7 @@ class ProcurementApiClient(
title = json.getString("title"),
description = json.optString("description"),
sku = json.getString("sku"),
imageAssetId = json.getString("image_asset_id"),
referenceImageUrl = json.getString("reference_image_url"),
quantity = json.getInt("quantity"),
maxBudget = json.optionalString("max_budget"),
@@ -501,10 +559,12 @@ class ProcurementApiClient(
companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val PNG_MEDIA_TYPE = "image/png".toMediaType()
private const val JPEG_MEDIA_TYPE = "image/jpeg"
private const val CLAIM_TOKEN_HEADER = "X-Claim-Token"
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
private const val MAX_JSON_BYTES = 1_048_576L
private const val MAX_EVIDENCE_BYTES = 8 * 1024 * 1024
private const val MAX_REFERENCE_IMAGE_BYTES = 20L * 1024L * 1024L
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
@@ -39,6 +39,7 @@ data class RemotePurchaseTask(
val title: String,
val description: String,
val sku: String,
val imageAssetId: String,
val referenceImageUrl: String,
val quantity: Int,
val maxBudget: String?,
@@ -67,7 +68,8 @@ data class RunningExecution(
val expiresAt: String,
val serverClockOffsetMillis: Long,
val safetyStopped: Boolean = false,
val cancelAcknowledgementKey: String? = null
val cancelAcknowledgementKey: String? = null,
val provenance: ExecutionProvenanceSnapshot? = null
) {
fun isExpired(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
ExecutionAuthorization.isExpired(
@@ -81,7 +83,39 @@ data class PersistedProcurementState(
val session: ProcurementSession? = null,
val deviceToken: String? = null,
val claim: ClaimContext? = null,
val execution: RunningExecution? = null
val execution: RunningExecution? = null,
val outbox: List<ExecutionOutboxItem> = emptyList()
)
enum class ExecutionMode {
MANUAL_FIRST,
AI_ASSISTED
}
data class ExecutionProvenanceSnapshot(
val mode: ExecutionMode,
val providerId: String? = null,
val model: String? = null,
val promptVersion: String? = null,
val schemaVersion: Int? = null,
val referenceImageSha256: String
)
enum class ExecutionOutboxType {
EVENTS,
EVIDENCE,
CANDIDATES,
COMPLETE,
FAIL
}
data class ExecutionOutboxItem(
val id: String,
val type: ExecutionOutboxType,
val idempotencyKey: String,
val payload: String,
val evidenceRelativePath: String? = null,
val remoteResourceID: String? = null
)
data class ProcurementUiState(
@@ -10,10 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.security.SecureRandom
import java.time.Instant
import java.util.Base64
import java.util.UUID
enum class ExecutionSyncDecision {
CONTINUE,
@@ -131,7 +135,10 @@ class ProcurementRepository(
true
} ?: false
suspend fun start(): Boolean = operation {
suspend fun start(
mode: ExecutionMode = ExecutionMode.MANUAL_FIRST,
provenance: ExecutionProvenanceSnapshot? = null
): Boolean = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim) { "没有可开始的任务" }
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
@@ -160,12 +167,20 @@ class ProcurementRepository(
serverClockOffsetMillis = ExecutionAuthorization.serverClockOffset(
result.serverTime,
requestStartedAt
)
),
provenance = normalizeProvenance(mode, provenance, claim.referenceImage)
)
persisted = persisted.copy(
claim = persisted.claim?.copy(task = result.task),
execution = execution
)
enqueueEventLocked(
task = result.task,
execution = execution,
step = "PREFLIGHT",
type = "EXECUTION_STARTED",
message = "本地受控采购流程已开始"
)
saveAndPublish(
backendOnline = true,
message = "受控采购流程已开始"
@@ -234,6 +249,21 @@ class ProcurementRepository(
}
}
return@withLock try {
if (flushExecutionOutbox(session)) {
saveAndPublish(
backendOnline = true,
message = "执行结果已回传,任务已结束"
)
return@withLock ExecutionSyncDecision.STOP
}
execution = persisted.execution ?: return@withLock ExecutionSyncDecision.STOP
if (execution.safetyStopped) {
publish(
backendOnline = true,
message = "授权到期后的审计结果已同步,自动化保持停止"
)
return@withLock ExecutionSyncDecision.STOP
}
val requestStartedAt = System.currentTimeMillis()
val result = api.heartbeat(session, task, execution, claim.token)
val updatedExecution = execution.copy(
@@ -294,6 +324,172 @@ class ProcurementRepository(
if (task != null && image != null) task.toProbeTask(image) else null
}
fun activeExecutionMode(): ExecutionMode? =
persisted.execution?.provenance?.mode
fun currentReferenceImageBytes(): ByteArray? =
persisted.claim?.referenceImage?.let { image ->
File(appContext.filesDir, image.relativePath)
.takeIf { it.isFile && it.length() == image.sizeBytes }
?.readBytes()
}
suspend fun queueCandidateBatch(
batch: ExecutionCandidateBatchDraft,
evidence: List<ExecutionEvidenceDraft>
): List<ExecutionCandidateDraft>? = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
require(!execution.safetyStopped && !execution.isExpired()) {
"执行授权已到期,不能继续采集"
}
require(evidence.size in 1..5) { "候选证据必须为 1 至 5 张" }
require(batch.candidates.size in 1..5) { "候选数量必须为 1 至 5 个" }
require(batch.candidates.map { it.ordinal } == (1..batch.candidates.size).toList()) {
"候选编号必须连续"
}
require(batch.mode == execution.provenance?.mode) { "执行模式不能在回传时变更" }
if (batch.mode == ExecutionMode.AI_ASSISTED) {
require(batch.provenance != null && batch.provenance.providerId != null) {
"AI 辅助模式缺少本地模型出处"
}
} else {
require(batch.provenance == null) { "手工优先模式不能附带模型出处" }
}
val evidenceIDs = evidence.sortedBy { it.ordinal }.associate { draft ->
require(draft.ordinal in 1..batch.candidates.size)
require(sha256(draft.pngBytes) == draft.sha256)
val localID = UUID.randomUUID().toString()
persistEvidenceLocked(localID, draft.pngBytes)
appendOutboxLocked(
ExecutionOutboxItem(
id = localID,
type = ExecutionOutboxType.EVIDENCE,
idempotencyKey = newOpaqueSecret(),
payload = "{}",
evidenceRelativePath = "$OUTBOX_DIRECTORY/$localID.png"
)
)
draft.ordinal to localID
}
val candidates = batch.candidates.map { candidate ->
candidate.copy(
evidenceLocalIDs = listOf(
requireNotNull(evidenceIDs[candidate.ordinal])
)
)
}
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("task_content_sha256", ExecutionTaskHash.sha256(task))
.put("execution_mode", batch.mode.name)
.put("search_query", batch.searchQuery.trim())
.put("candidates", JSONArray().apply {
candidates.forEach { put(candidateJSON(it)) }
})
batch.provenance?.let { payload.put("provenance", provenanceJSON(it)) }
batch.recommendation?.let { recommendation ->
payload.put(
"recommendation",
JSONObject()
.put("candidate_ordinal", recommendation.candidateOrdinal)
.put("policy_version", recommendation.policyVersion)
.put("reasons", JSONArray(recommendation.reasons))
)
}
appendOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.CANDIDATES,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "候选和截图已加入加密回传队列"
)
candidates
}
suspend fun completeExecution(
outcome: String,
operatorReason: String,
candidate: ExecutionCandidateDraft? = null
): Boolean = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
require(operatorReason.trim().isNotEmpty()) { "请填写人工确认理由" }
require(outcome in COMPLETE_OUTCOMES) { "采购结论无效" }
if (outcome == "CANDIDATE_ACCEPTED") {
require(candidate != null) { "接受候选时必须保留候选证据" }
} else {
require(candidate == null) { "当前结论不能附带候选" }
}
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("task_content_sha256", ExecutionTaskHash.sha256(task))
.put("execution_mode", requireNotNull(execution.provenance).mode.name)
.put("outcome", outcome)
.put("operator_reason", operatorReason.trim())
.put("order_submitted", false)
candidate?.let { payload.put("candidate", candidateJSON(it)) }
appendTerminalOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.COMPLETE,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "人工结论已加入加密回传队列"
)
true
} ?: false
suspend fun failExecution(
code: String,
message: String,
step: String,
retryable: Boolean,
evidenceLocalIDs: List<String> = emptyList()
): Boolean = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
require(code.isNotBlank() && message.isNotBlank() && step.isNotBlank()) {
"失败信息不完整"
}
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put(
"error",
JSONObject()
.put("code", code.trim())
.put("message", message.trim())
.put("step", step.trim())
.put("retryable", retryable)
)
.put("evidence_asset_ids", JSONArray(evidenceLocalIDs))
appendTerminalOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.FAIL,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "失败信息已加入加密回传队列"
)
true
} ?: false
private suspend fun acknowledgeCancellation(session: ProcurementSession) {
val claim = requireNotNull(persisted.claim)
val task = requireNotNull(claim.task)
@@ -363,9 +559,252 @@ class ProcurementRepository(
persisted.claim?.referenceImage?.let {
File(appContext.filesDir, it.relativePath).delete()
}
persisted = persisted.copy(claim = null, execution = null)
persisted.outbox.forEach { item ->
item.evidenceRelativePath?.let { relativePath ->
File(appContext.filesDir, relativePath).delete()
}
}
persisted = persisted.copy(
claim = null,
execution = null,
outbox = emptyList()
)
}
private fun requireCurrentTask(): RemotePurchaseTask =
requireNotNull(persisted.claim?.task) { "没有可回传的采购任务" }
private fun requireActiveExecution(): RunningExecution =
requireNotNull(persisted.execution) { "采购执行尚未开始" }
private fun appendOutboxLocked(item: ExecutionOutboxItem) {
var resolvedPayload = item.payload
persisted.outbox.forEach { knownEvidence ->
if (knownEvidence.type == ExecutionOutboxType.EVIDENCE &&
knownEvidence.remoteResourceID != null
) {
resolvedPayload = replaceEvidenceReference(
resolvedPayload,
knownEvidence.id,
knownEvidence.remoteResourceID
)
}
}
persisted = persisted.copy(
outbox = persisted.outbox + item.copy(payload = resolvedPayload)
)
}
private fun appendTerminalOutboxLocked(item: ExecutionOutboxItem) {
require(persisted.outbox.none {
it.type == ExecutionOutboxType.COMPLETE || it.type == ExecutionOutboxType.FAIL
}) { "当前执行已有待回传终态" }
appendOutboxLocked(item)
}
private fun enqueueEventLocked(
task: RemotePurchaseTask,
execution: RunningExecution,
step: String,
type: String,
message: String
) {
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put(
"events",
JSONArray().put(
JSONObject()
.put("event_id", UUID.randomUUID().toString())
.put("step", step)
.put("type", type)
.put("message", message)
.put("occurred_at", Instant.now().toString())
)
)
appendOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.EVENTS,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
}
private fun normalizeProvenance(
mode: ExecutionMode,
candidate: ExecutionProvenanceSnapshot?,
referenceImage: ReferenceImageRecord?
): ExecutionProvenanceSnapshot {
val referenceHash = requireNotNull(referenceImage?.sha256) {
"参考图哈希缺失"
}
if (mode == ExecutionMode.MANUAL_FIRST) {
require(candidate == null) { "手工优先模式不能配置模型出处" }
return ExecutionProvenanceSnapshot(
mode = mode,
referenceImageSha256 = referenceHash
)
}
val provenance = requireNotNull(candidate) { "AI 辅助模式缺少模型出处" }
require(
provenance.mode == mode &&
!provenance.providerId.isNullOrBlank() &&
!provenance.model.isNullOrBlank() &&
!provenance.promptVersion.isNullOrBlank() &&
provenance.schemaVersion != null
) { "AI 辅助模式模型出处无效" }
return provenance.copy(referenceImageSha256 = referenceHash)
}
private fun persistEvidenceLocked(localID: String, bytes: ByteArray) {
require(bytes.isNotEmpty() && bytes.size <= MAX_OUTBOX_EVIDENCE_BYTES) {
"候选截图无效"
}
val directory = File(appContext.filesDir, OUTBOX_DIRECTORY)
check(directory.exists() || directory.mkdirs()) { "无法创建离线证据目录" }
val target = File(directory, "$localID.png")
val temporary = File(directory, ".$localID.tmp")
temporary.outputStream().use { output ->
output.write(bytes)
output.fd.sync()
}
check(!target.exists() || target.delete()) { "无法替换离线证据" }
check(temporary.renameTo(target)) { "无法保存离线证据" }
}
private suspend fun flushExecutionOutbox(session: ProcurementSession): Boolean {
while (true) {
val task = requireCurrentTask()
val execution = requireActiveExecution()
val item = persisted.outbox.firstOrNull { pending ->
pending.type != ExecutionOutboxType.EVIDENCE ||
pending.remoteResourceID == null
} ?: return false
val evidenceBytes = item.evidenceRelativePath?.let { relativePath ->
val file = File(appContext.filesDir, relativePath)
require(file.isFile && file.length() in 1..MAX_OUTBOX_EVIDENCE_BYTES) {
"离线证据文件缺失"
}
file.readBytes()
}
val uploaded = api.uploadExecutionOutboxItem(
session = session,
task = task,
execution = execution,
claimToken = requireNotNull(persisted.claim).token,
item = item,
evidenceBytes = evidenceBytes
)
if (item.type == ExecutionOutboxType.EVIDENCE) {
val evidenceID = requireNotNull(uploaded.evidenceAssetId) {
"后台未返回证据编号"
}
persisted = persisted.copy(
outbox = persisted.outbox.map { pending ->
if (pending.id == item.id) {
pending.copy(remoteResourceID = evidenceID)
} else {
pending.copy(
payload = replaceEvidenceReference(
pending.payload,
item.id,
evidenceID
)
)
}
}
)
item.evidenceRelativePath?.let {
File(appContext.filesDir, it).delete()
}
} else {
persisted = persisted.copy(
outbox = persisted.outbox.filterNot { it.id == item.id }
)
}
if (item.type == ExecutionOutboxType.COMPLETE ||
item.type == ExecutionOutboxType.FAIL
) {
clearCurrentTask()
store.save(persisted)
return true
}
store.save(persisted)
}
}
private fun replaceEvidenceReference(
payload: String,
localID: String,
remoteID: String
): String {
val root = JSONObject(payload)
fun replace(value: Any?) {
when (value) {
is JSONObject -> {
val keys = value.keys().asSequence().toList()
keys.forEach { key -> replace(value.opt(key)) }
}
is JSONArray -> {
for (index in 0 until value.length()) {
if (value.optString(index) == localID) {
value.put(index, remoteID)
} else {
replace(value.opt(index))
}
}
}
}
}
replace(root)
return root.toString()
}
private fun candidateJSON(candidate: ExecutionCandidateDraft): JSONObject =
JSONObject()
.put("ordinal", candidate.ordinal)
.put("title", candidate.title)
.put("sku_text", candidate.skuText)
.put("price", candidate.price)
.put("product_url", candidate.productUrl)
.put("image_url", candidate.imageUrl)
.put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
.also { json ->
candidate.evaluation?.let { evaluation ->
json.put(
"evaluation",
JSONObject()
.put("decision", evaluation.decision)
.put("score", evaluation.score)
.put("matched", JSONArray(evaluation.matched))
.put(
"missing_or_uncertain",
JSONArray(evaluation.missingOrUncertain)
)
.put(
"rejection_reasons",
JSONArray(evaluation.rejectionReasons)
)
.put("confidence", evaluation.confidence)
)
}
}
private fun provenanceJSON(snapshot: ExecutionProvenanceSnapshot): JSONObject =
JSONObject()
.put("provider_id", snapshot.providerId)
.put("model", snapshot.model)
.put("prompt_version", snapshot.promptVersion)
.put("schema_version", snapshot.schemaVersion)
private fun sha256(bytes: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(bytes)
.joinToString("") { "%02x".format(it) }
private fun requireValidSession(): ProcurementSession {
val session = persisted.session
require(session != null && session.isValid()) { "采购员登录已过期,请重新登录" }
@@ -476,10 +915,18 @@ class ProcurementRepository(
companion object {
private const val REFERENCE_DIRECTORY = "procurement"
private const val OUTBOX_DIRECTORY = "procurement-outbox"
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
private const val MAX_REFERENCE_DIMENSION = 4_096
private const val MAX_REFERENCE_PIXELS = 20_000_000L
private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L
private val COMPLETE_OUTCOMES = setOf(
"CANDIDATE_ACCEPTED",
"CANDIDATE_REJECTED",
"NO_MATCH",
"MANUAL_REQUIRED"
)
private val SECURE_RANDOM = SecureRandom()
fun create(context: Context): ProcurementRepository =
@@ -3,6 +3,7 @@ package com.roubao.autopilot.procurement
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONArray
import org.json.JSONObject
interface ProcurementStateStore {
@@ -89,9 +90,50 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
"cancel_acknowledgement_key",
execution.cancelAcknowledgementKey
)
execution.provenance?.let { provenance ->
put(
"provenance",
JSONObject().apply {
put("mode", provenance.mode.name)
putNullable("provider_id", provenance.providerId)
putNullable("model", provenance.model)
putNullable("prompt_version", provenance.promptVersion)
provenance.schemaVersion?.let {
put("schema_version", it)
}
put(
"reference_image_sha256",
provenance.referenceImageSha256
)
}
)
}
}
)
}
put(
"outbox",
JSONArray().apply {
state.outbox.forEach { item ->
put(
JSONObject().apply {
put("id", item.id)
put("type", item.type.name)
put("idempotency_key", item.idempotencyKey)
put("payload", item.payload)
putNullable(
"evidence_relative_path",
item.evidenceRelativePath
)
putNullable(
"remote_resource_id",
item.remoteResourceID
)
}
)
}
}
)
}
private fun decodeState(json: JSONObject): PersistedProcurementState =
@@ -137,9 +179,43 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
serverClockOffsetMillis = it.getLong("server_clock_offset_ms"),
safetyStopped = it.optBoolean("safety_stopped", false),
cancelAcknowledgementKey =
it.optionalString("cancel_acknowledgement_key")
it.optionalString("cancel_acknowledgement_key"),
provenance = it.optionalObject("provenance")?.let { provenance ->
ExecutionProvenanceSnapshot(
mode = ExecutionMode.valueOf(provenance.getString("mode")),
providerId = provenance.optionalString("provider_id"),
model = provenance.optionalString("model"),
promptVersion = provenance.optionalString("prompt_version"),
schemaVersion = if (provenance.has("schema_version")) {
provenance.getInt("schema_version")
} else {
null
},
referenceImageSha256 =
provenance.getString("reference_image_sha256")
)
}
)
}
},
outbox = json.optionalArray("outbox")?.let { entries ->
buildList {
for (index in 0 until entries.length()) {
val item = entries.getJSONObject(index)
add(
ExecutionOutboxItem(
id = item.getString("id"),
type = ExecutionOutboxType.valueOf(item.getString("type")),
idempotencyKey = item.getString("idempotency_key"),
payload = item.getString("payload"),
evidenceRelativePath =
item.optionalString("evidence_relative_path"),
remoteResourceID =
item.optionalString("remote_resource_id")
)
)
}
}
} ?: emptyList()
)
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
@@ -152,6 +228,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
put("title", task.title)
put("description", task.description)
put("sku", task.sku)
put("image_asset_id", task.imageAssetId)
put("reference_image_url", task.referenceImageUrl)
put("quantity", task.quantity)
putNullable("max_budget", task.maxBudget)
@@ -168,6 +245,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
title = json.getString("title"),
description = json.optString("description"),
sku = json.getString("sku"),
imageAssetId = json.getString("image_asset_id"),
referenceImageUrl = json.getString("reference_image_url"),
quantity = json.getInt("quantity"),
maxBudget = json.optionalString("max_budget"),
@@ -184,6 +262,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
private fun JSONObject.optionalObject(name: String): JSONObject? =
if (has(name) && !isNull(name)) getJSONObject(name) else null
private fun JSONObject.optionalArray(name: String): JSONArray? =
if (has(name) && !isNull(name)) getJSONArray(name) else null
private companion object {
const val FILE_NAME = "procurement_secure_state"
const val STATE_KEY = "state_v1"
@@ -43,6 +43,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.procurement.LoginInput
import com.roubao.autopilot.procurement.ExecutionMode
import com.roubao.autopilot.procurement.ProcurementPhase
import com.roubao.autopilot.procurement.ProcurementUiState
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
@@ -56,12 +57,15 @@ fun ProcurementScreen(
readiness: DeviceReadinessSnapshot,
onLogin: (LoginInput) -> Unit,
onClaim: () -> Unit,
onStart: () -> Unit,
onStart: (ExecutionMode) -> Unit,
onRelease: () -> Unit,
onSync: () -> Unit
) {
val colors = BaoziTheme.colors
var confirmStart by remember(state.task?.id) { mutableStateOf(false) }
var executionMode by remember(state.task?.id) {
mutableStateOf(ExecutionMode.MANUAL_FIRST)
}
if (confirmStart) {
AlertDialog(
@@ -77,7 +81,7 @@ fun ProcurementScreen(
Button(
onClick = {
confirmStart = false
onStart()
onStart(executionMode)
}
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
@@ -154,6 +158,12 @@ fun ProcurementScreen(
item { LoginSection(state = state, onLogin = onLogin) }
}
item { TaskDetails(state) }
item {
ExecutionModeSelector(
selected = executionMode,
onSelect = { executionMode = it }
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -206,6 +216,47 @@ fun ProcurementScreen(
}
}
@Composable
private fun ExecutionModeSelector(
selected: ExecutionMode,
onSelect: (ExecutionMode) -> Unit
) {
val colors = BaoziTheme.colors
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"执行模式",
color = colors.textPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ExecutionMode.values().forEach { mode ->
OutlinedButton(
onClick = { onSelect(mode) },
modifier = Modifier.weight(1f),
enabled = mode != selected
) {
Text(
if (mode == ExecutionMode.MANUAL_FIRST) "人工优先" else "AI 辅助"
)
}
}
}
Text(
if (selected == ExecutionMode.MANUAL_FIRST) {
"直接采集候选并由人员确认"
} else {
"仅使用本机已配置的 VLM;后台不会读取模型配置"
},
color = colors.textSecondary,
fontSize = 13.sp
)
}
}
@Composable
private fun LoginSection(
state: ProcurementUiState,
@@ -25,7 +25,12 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -85,8 +90,8 @@ fun SearchProbeScreen(
onStopRequirement: () -> Unit,
onStartCandidateEvaluation: () -> Unit,
onStopCandidateEvaluation: () -> Unit,
onAcceptCandidate: () -> Unit,
onRejectCandidates: () -> Unit,
onAcceptCandidate: (String) -> Unit,
onRejectCandidates: (String) -> Unit,
onStart: () -> Unit,
onStop: () -> Unit
) {
@@ -279,10 +284,11 @@ private fun CandidateEvaluationSection(
canStart: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
onAccept: () -> Unit,
onReject: () -> Unit
onAccept: (String) -> Unit,
onReject: (String) -> Unit
) {
val colors = BaoziTheme.colors
var operatorReason by remember(state) { mutableStateOf("") }
val statusColor = when (state) {
CandidateEvaluationState.AWAITING_CONFIRMATION,
CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success
@@ -366,6 +372,20 @@ private fun CandidateEvaluationSection(
}
Spacer(modifier = Modifier.height(14.dp))
if (state in setOf(
CandidateEvaluationState.AWAITING_CONFIRMATION,
CandidateEvaluationState.MANUAL_REVIEW,
CandidateEvaluationState.NO_MATCH
)) {
OutlinedTextField(
value = operatorReason,
onValueChange = { operatorReason = it.take(1000) },
label = { Text("人工确认理由") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
}
when (state) {
CandidateEvaluationState.RUNNING -> {
OutlinedButton(
@@ -379,7 +399,8 @@ private fun CandidateEvaluationSection(
}
CandidateEvaluationState.AWAITING_CONFIRMATION -> {
Button(
onClick = onAccept,
onClick = { onAccept(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colors.primary
@@ -391,7 +412,8 @@ private fun CandidateEvaluationSection(
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onReject,
onClick = { onReject(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
@@ -401,8 +423,21 @@ private fun CandidateEvaluationSection(
}
CandidateEvaluationState.MANUAL_REVIEW,
CandidateEvaluationState.NO_MATCH -> {
if (state == CandidateEvaluationState.MANUAL_REVIEW) {
Button(
onClick = { onAccept(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("标记候选可用")
}
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedButton(
onClick = onReject,
onClick = { onReject(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
@@ -0,0 +1,34 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ExecutionTaskHashTest {
@Test
fun taskHashUsesOnlyBackendRequirementFields() {
val task = RemotePurchaseTask(
id = "task-id",
status = "RUNNING",
version = 3,
claimGeneration = 1,
claimExpiresAt = "2099-01-01T00:00:00Z",
title = "测试商品",
description = "测试描述",
sku = "SKU-01",
imageAssetId = "asset-id",
referenceImageUrl = "/api/v1/tasks/task-id/reference-image?claim_generation=1",
quantity = 2,
maxBudget = "20.00",
currency = "CNY"
)
assertEquals(
ExecutionTaskHash.sha256(task),
ExecutionTaskHash.sha256(task.copy(version = 99, status = "CLAIMED"))
)
assertNotEquals(
ExecutionTaskHash.sha256(task),
ExecutionTaskHash.sha256(task.copy(quantity = 3))
)
}
}
@@ -172,6 +172,61 @@ class ProcurementApiClientTest {
assertEquals(0, server.requestCount)
}
@Test
fun executionOutboxUsesDeviceResultEndpointsWithoutProviderConfiguration() = runBlocking {
val task = task("/api/v1/tasks/task-id/reference-image?claim_generation=1")
val execution = RunningExecution(
id = "execution-id",
currentStep = "SEARCH",
expiresAt = "2099-01-01T00:00:00Z",
serverClockOffsetMillis = 0
)
server.enqueue(
jsonResponse(
"""{"evidence":{"id":"evidence-id"},"replayed":false}"""
)
)
val evidenceResult = api.uploadExecutionOutboxItem(
session = session(),
task = task,
execution = execution,
claimToken = "claim-token-value",
item = ExecutionOutboxItem(
id = "local-evidence-id",
type = ExecutionOutboxType.EVIDENCE,
idempotencyKey = "evidence-idempotency-key",
payload = "{}",
evidenceRelativePath = "procurement-outbox/local-evidence-id.png"
),
evidenceBytes = byteArrayOf(1, 2, 3)
)
assertEquals("evidence-id", evidenceResult.evidenceAssetId)
val evidenceRequest = server.takeRequest()
assertEquals("/api/v1/tasks/task-id/evidence", evidenceRequest.path)
assertEquals("claim-token-value", evidenceRequest.getHeader("X-Claim-Token"))
assertEquals("execution-id", evidenceRequest.getHeader("X-Execution-ID"))
assertEquals("1", evidenceRequest.getHeader("X-Claim-Generation"))
assertTrue(evidenceRequest.getHeader("Content-Type")!!.startsWith("image/png"))
server.enqueue(jsonResponse("""{"replayed":false}"""))
api.uploadExecutionOutboxItem(
session = session(),
task = task,
execution = execution,
claimToken = "claim-token-value",
item = ExecutionOutboxItem(
id = "event-id",
type = ExecutionOutboxType.EVENTS,
idempotencyKey = "event-idempotency-key",
payload = """{"execution_id":"execution-id","claim_generation":1,"events":[]}"""
)
)
val eventRequest = server.takeRequest()
assertEquals("/api/v1/tasks/task-id/events", eventRequest.path)
assertEquals("event-idempotency-key", eventRequest.getHeader("Idempotency-Key"))
assertTrue(eventRequest.body.readUtf8().contains("execution-id"))
}
private fun session() = ProcurementSession(
backendUrl = server.url("/").toString().trimEnd('/'),
username = "buyer01",
@@ -191,6 +246,7 @@ class ProcurementApiClientTest {
"title":"测试商品",
"description":"测试描述",
"sku":"SKU-01",
"image_asset_id":"00000000-0000-4000-8000-000000000001",
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
"quantity":2,
"max_budget":"20.00",
@@ -207,6 +263,7 @@ class ProcurementApiClientTest {
title = "测试商品",
description = "测试描述",
sku = "SKU-01",
imageAssetId = "00000000-0000-4000-8000-000000000001",
referenceImageUrl = referenceImageUrl,
quantity = 2,
maxBudget = "20.00",