feat(t214): persist candidate observation identities

This commit is contained in:
QiuSW
2026-07-28 12:21:55 +08:00
parent acd3c85b5b
commit 56e7a2be33
29 changed files with 622 additions and 94 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.roubao.autopilot"
minSdk = 26
targetSdk = 34
versionCode = 16
versionName = "1.4.11"
versionCode = 17
versionName = "1.4.12"
vectorDrawables {
useSupportLibrary = true
@@ -1045,9 +1045,8 @@ class MainActivity : ComponentActivity() {
)
ExecutionCandidateDraft(
ordinal = assessment.ordinal,
title =
"拼多多图片候选 " +
assessment.ordinal,
title = candidateEvidence.observedTitle
?: "拼多多图片候选 ${assessment.ordinal}",
skuText =
candidateEvidence.specification
.selectedSummary
@@ -1059,6 +1058,14 @@ class MainActivity : ComponentActivity() {
},
price = candidateEvidence.specification
.price?.rawText.orEmpty(),
cardSignature =
candidateEvidence.cardSignature,
detailSignature =
candidateEvidence.detailSignature,
detailEvidenceSha256 =
candidateEvidence.detailSha256,
specificationEvidenceSha256 =
candidateEvidence.specificationSha256,
evidenceLocalIDs = emptyList(),
evaluation = ExecutionCandidateEvaluation(
decision = assessment.decision.name,
@@ -1222,11 +1229,17 @@ class MainActivity : ComponentActivity() {
candidates = validated.map { candidate ->
ExecutionCandidateDraft(
ordinal = candidate.ordinal,
title = "$taskTitle 候选 ${candidate.ordinal}",
title = candidate.observedTitle
?: "$taskTitle 候选 ${candidate.ordinal}",
skuText = candidate.specification
.selectedSummary.orEmpty(),
price = candidate.specification
.price?.rawText.orEmpty(),
cardSignature = candidate.cardSignature,
detailSignature = candidate.detailSignature,
detailEvidenceSha256 = candidate.detailSha256,
specificationEvidenceSha256 =
candidate.specificationSha256,
evidenceLocalIDs = emptyList()
)
}
@@ -14,6 +14,7 @@ import androidx.annotation.RequiresApi
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy
import com.roubao.autopilot.readiness.DeviceObservationStore
import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
@@ -320,7 +321,8 @@ class BuyerAccessibilityService : AccessibilityService() {
signature = PinduoduoEvidenceHash.sha256(
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
),
semanticTextCount = semanticTexts.size
semanticTextCount = semanticTexts.size,
observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts)
)
}
@@ -134,7 +134,8 @@ private class CandidateEvidenceStore(context: Context) {
detailSemanticTextCount = detail.detail.semanticTextCount,
detailAsset = detailAsset,
specification = specifications.evidence,
specificationAsset = specificationAsset
specificationAsset = specificationAsset,
observedTitle = detail.detail.observedTitle
)
evidenceByOrdinal[ordinal] = evidence
writeManifest()
@@ -172,6 +173,7 @@ private class CandidateEvidenceStore(context: Context) {
evidence.cardSemanticTextCount
)
.put("detail_signature", evidence.detailSignature)
.put("observed_title", evidence.observedTitle)
.put(
"detail_semantic_text_count",
evidence.detailSemanticTextCount
@@ -208,7 +210,7 @@ private class CandidateEvidenceStore(context: Context) {
)
}
val manifest = JSONObject()
.put("schema_version", 3)
.put("schema_version", 4)
.put("candidate_count", evidenceByOrdinal.size)
.put("candidates", candidates)
.toString(2)
@@ -9,6 +9,9 @@ import kotlinx.coroutines.withContext
data class ValidatedCandidateEvidence(
val ordinal: Int,
val cardSignature: String,
val detailSignature: String,
val observedTitle: String?,
val detailPngBytes: ByteArray,
val detailSha256: String,
val specification: PinduoduoSpecificationEvidence,
@@ -46,6 +49,11 @@ class CandidateEvidenceSource(
?.length
?.let { it in 1..160 } != false
)
require(SHA256_PATTERN.matches(metadata.cardSignature))
require(SHA256_PATTERN.matches(metadata.detailSignature))
require(
metadata.observedTitle?.length?.let { it in 1..160 } != false
)
require(
metadata.specification.price?.let { price ->
price.rawText.length in 1..32 &&
@@ -69,6 +77,9 @@ class CandidateEvidenceSource(
require(totalBytes <= MAX_TOTAL_PNG_BYTES)
ValidatedCandidateEvidence(
ordinal = metadata.ordinal,
cardSignature = metadata.cardSignature,
detailSignature = metadata.detailSignature,
observedTitle = metadata.observedTitle,
detailPngBytes = detail,
detailSha256 = metadata.detailAsset.sha256,
specification = metadata.specification,
@@ -14,7 +14,8 @@ data class PinduoduoCandidateCard(
data class PinduoduoCandidateDetailEvidence(
val signature: String,
val semanticTextCount: Int
val semanticTextCount: Int,
val observedTitle: String? = null
)
data class PinduoduoScreenshotCapture(
@@ -39,9 +40,36 @@ data class PinduoduoCandidateEvidence(
val detailSemanticTextCount: Int,
val detailAsset: PinduoduoEvidenceAsset,
val specification: PinduoduoSpecificationEvidence,
val specificationAsset: PinduoduoEvidenceAsset
val specificationAsset: PinduoduoEvidenceAsset,
val observedTitle: String? = null
)
object PinduoduoObservedTitlePolicy {
private val excludedFragments = listOf(
"客服",
"收藏",
"店铺",
"免拼购买",
"单独购买",
"发起拼单",
"确认款式",
"已选择",
"退货",
"包邮"
)
fun select(semanticTexts: List<String>): String? =
semanticTexts.asSequence()
.map { it.trim().replace(Regex("\\s+"), " ") }
.filter { it.length in 6..160 }
.filterNot { text ->
text.any { it == '¥' || it == '¥' } ||
excludedFragments.any(text::contains) ||
text.all { it.isDigit() || it in ".,+-/% " }
}
.maxByOrNull(String::length)
}
object PinduoduoEvidenceHash {
fun sha256(value: String): String =
sha256(value.toByteArray(Charsets.UTF_8))
@@ -43,6 +43,10 @@ data class ExecutionCandidateDraft(
val price: String = "",
val productUrl: String = "",
val imageUrl: String = "",
val cardSignature: String = "",
val detailSignature: String = "",
val detailEvidenceSha256: String = "",
val specificationEvidenceSha256: String = "",
val evidenceLocalIDs: List<String>,
val evaluation: ExecutionCandidateEvaluation? = null
)
@@ -404,6 +404,14 @@ class ProcurementRepository(
require(batch.candidates.map { it.ordinal } == (1..batch.candidates.size).toList()) {
"候选编号必须连续"
}
require(batch.candidates.all { candidate ->
SHA256_PATTERN.matches(candidate.cardSignature) &&
SHA256_PATTERN.matches(candidate.detailSignature) &&
SHA256_PATTERN.matches(candidate.detailEvidenceSha256) &&
SHA256_PATTERN.matches(
candidate.specificationEvidenceSha256
)
}) { "候选持久身份指纹无效" }
require(batch.mode == execution.provenance?.mode) { "执行模式不能在回传时变更" }
if (batch.mode == ExecutionMode.AI_ASSISTED) {
require(batch.provenance != null && batch.provenance.providerId != null) {
@@ -883,6 +891,16 @@ class ProcurementRepository(
.put("price", candidate.price)
.put("product_url", candidate.productUrl)
.put("image_url", candidate.imageUrl)
.put("card_signature", candidate.cardSignature)
.put("detail_signature", candidate.detailSignature)
.put(
"detail_evidence_sha256",
candidate.detailEvidenceSha256
)
.put(
"specification_evidence_sha256",
candidate.specificationEvidenceSha256
)
.put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
.also { json ->
candidate.evaluation?.let { evaluation ->
@@ -1054,6 +1072,7 @@ class ProcurementRepository(
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 SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
private val COMPLETE_OUTCOMES = setOf(
"CANDIDATE_ACCEPTED",
"CANDIDATE_REJECTED",
@@ -150,13 +150,14 @@ class CandidateEvidenceSourceTest {
)
return PinduoduoCandidateEvidence(
ordinal = ordinal,
cardSignature = "card-$ordinal",
cardSignature = PinduoduoEvidenceHash.sha256("card-$ordinal"),
cardSemanticTextCount = 3,
detailSignature = "detail-$ordinal",
detailSignature = PinduoduoEvidenceHash.sha256("detail-$ordinal"),
detailSemanticTextCount = 4,
detailAsset = asset,
specification = specification(),
specificationAsset = asset.copy(fileName = specificationFileName)
specificationAsset = asset.copy(fileName = specificationFileName),
observedTitle = "候选商品标题 $ordinal"
)
}
@@ -0,0 +1,35 @@
package com.roubao.autopilot.pinduoduo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PinduoduoObservedTitlePolicyTest {
@Test
fun `selects longest product-like semantic text`() {
val title = PinduoduoObservedTitlePolicy.select(
listOf(
"¥12.90",
"店铺客服",
"2026夏季纯棉宽松短袖圆领上衣",
"纯棉短袖"
)
)
assertEquals("2026夏季纯棉宽松短袖圆领上衣", title)
}
@Test
fun `rejects actions prices and numeric-only text`() {
val title = PinduoduoObservedTitlePolicy.select(
listOf(
"免拼购买",
"已选择 黑色 L",
"¥12.90",
"123456"
)
)
assertNull(title)
}
}