From d45a9b9a244793263f89897f93a665cf04e1a382 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 12 Oct 2026 14:53:16 +0800 Subject: [PATCH] feat(android): add sanitized spec-normalize trace (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规格规范化冲突或作废时输出一条脱敏 trace(每维每类最多一条,仅 Log.i 过程日志): spec-normalize dim outcome reason path pass sameFrame onlyTrailingPriceDiff rawVariants candidates badgeCaptionMerged。只含计数与枚举值,不输出节点原文。 ParsedPddScreen 新增 badgeCaptionMerged 计数(合并的价格加徽标说明卡片数)。 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../automation/PddProductDetailCollector.kt | 94 +++++++++++++++---- .../agent/PddProductDetailCollectorTest.kt | 72 ++++++++++++++ 2 files changed, 148 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 8918d66..c1ded47 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -172,6 +172,8 @@ data class ParsedPddScreen( val requiredEvidenceSameContainer: Boolean = false, /** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */ val finalSelectionSummaries: List = emptyList(), + /** #379: option cards whose truncated caption (price + badge) was merged into the outer card; count only. */ + val badgeCaptionMerged: Int = 0, ) { /** The three #331 required spec-panel items are all visible. */ val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput @@ -376,6 +378,7 @@ object PddScreenParser { visible.filter { it.path == region.path || it.path.startsWith("${region.path}/") } } ?: visible val headings = panelVisible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top } + val badgeMergedBlocks = mutableSetOf() val dimensions = buildList { headings.forEachIndexed { index, heading -> val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE @@ -399,7 +402,7 @@ object PddScreenParser { // node is still chosen by safeOptionRank among the members. val values = candidates .groupBy { candidate -> - optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path + optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion) { badgeMergedBlocks += it }?.path ?: candidate.path } .map { (blockPath, members) -> val block = visibleByPath[blockPath] @@ -598,6 +601,7 @@ object PddScreenParser { problem = problem, sourceNodes = visibleNodes, isPddPackage = snapshot.packageName == PDD_PACKAGE, + badgeCaptionMerged = badgeMergedBlocks.size, ) } @@ -617,6 +621,7 @@ object PddScreenParser { visibleByPath: Map, sourceByPath: Map, inValueRegion: (SnapshotNode) -> Boolean, + onBadgeMerge: (String) -> Unit, ): SnapshotNode? { fun compact(value: String) = value.replace(Regex("\\s+"), "") var block: SnapshotNode? = null @@ -631,7 +636,7 @@ object PddScreenParser { val outer = compact(resolved.label) val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") } val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) } - if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break + if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath) { onBadgeMerge(resolved.path) }) break block = resolved } parentPath = raw.parentPath @@ -661,6 +666,7 @@ object PddScreenParser { block: SnapshotNode, members: List, sourceByPath: Map, + onBadgeMerge: () -> Unit, ): Boolean { fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() } val full = name(block.label) @@ -691,7 +697,9 @@ object PddScreenParser { minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) } - return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 } + val merged = prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 } + if (merged && badgeName != null) onBadgeMerge() + return merged } /** @@ -1343,7 +1351,7 @@ class PddProductDetailCollector( val missing = linkedSetOf() val unsupported = linkedSetOf() val colorImages = linkedMapOf() - val colorNormalization = ColorNormalizationTracker() + val colorNormalization = ColorNormalizationTracker(trace) val specPanelContainer = openedScreen.specPanelContainer collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported, colorImages, colorNormalization)?.let { return it } @@ -1358,7 +1366,7 @@ class PddProductDetailCollector( } moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it } val sizes = linkedMapOf() - val sizeNormalization = SizeNormalizationTracker() + val sizeNormalization = SizeNormalizationTracker(trace) collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported, sizeNormalization)?.let { return it } if (sizeNormalization.invalid) { @@ -1485,7 +1493,7 @@ class PddProductDetailCollector( diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text)) } val values = rows.flatten() - values.forEach(normalization::observe) + normalization.observeFrame(values, "parsed", null, screen.badgeCaptionMerged) diagnosticParsedValues += values.map(VisibleSpecValue::text) diagnosticNonClickableCandidates = maxOf( diagnosticNonClickableCandidates, @@ -2070,7 +2078,12 @@ class PddProductDetailCollector( // #379: observe every candidate before continuation de-duplication, // otherwise a same-name conflict is swallowed by groupBy. val continuationCandidates = if (parsedSizes.isNotEmpty()) emptyList() else continuationCandidates(screen, continuation, config) - parsedSizes.ifEmpty { continuationCandidates }.forEach(normalization::observe) + normalization.observeFrame( + if (parsedSizes.isNotEmpty()) parsedSizes else continuationCandidates, + if (parsedSizes.isNotEmpty()) "parsed" else "continuation", + pass, + screen.badgeCaptionMerged, + ) val visible = if (parsedSizes.isNotEmpty()) parsedSizes else dedupeContinuation(continuationCandidates) visible.forEach { value -> if (SpecValueNormalizer.isSafeSize(value.text)) { @@ -2122,27 +2135,70 @@ class PddProductDetailCollector( * dimension ([invalid]); a normalized name backed by several raw labels only * marks a [conflict] and the dimension keeps a single value for that name. */ - private open class SpecNormalizationTracker(private val isSafe: (String) -> Boolean) { - private val sources = linkedMapOf>() + private open class SpecNormalizationTracker( + private val dim: String, + private val isSafe: (String) -> Boolean, + private val trace: (String) -> Unit, + ) { + // normalized text -> raw label -> frames in which that raw label was seen + private val sources = linkedMapOf>>() + private var frameCounter = 0 + private var invalidTraced = false + private var conflictTraced = false var invalid: Boolean = false private set var conflict: Boolean = false private set - fun observe(value: VisibleSpecValue) { - if (!isSafe(value.text)) { - invalid = true - return + /** [pass] identifies the frame; without it the tracker numbers its own observations. */ + fun observeFrame(values: List, path: String, pass: Int?, badgeMerged: Int) { + val frame = pass ?: ++frameCounter + values.forEach { value -> + if (!isSafe(value.text)) { + invalid = true + if (!invalidTraced) { + invalidTraced = true + val reason = if (value.text.isBlank()) "empty" else "unsafe_currency" + traceOutcome("invalid_rejected", reason, path, frame, false, false, 1, values.size, badgeMerged) + } + return@forEach + } + val raws = sources.getOrPut(value.text) { linkedMapOf() } + val isNewRaw = value.rawText !in raws + raws.getOrPut(value.rawText) { linkedSetOf() } += frame + if (raws.size > 1) { + conflict = true + if (isNewRaw && !conflictTraced) { + conflictTraced = true + traceOutcome( + "conflict_kept", "normalized_conflict", path, frame, + raws.values.count { frame in it } > 1, + raws.keys.map { it.replace(TRAILING_PRICE, "") }.toSet().size == 1, + raws.size, values.size, badgeMerged, + ) + } + } } - val rawValues = sources.getOrPut(value.text) { linkedSetOf() } - rawValues += value.rawText - if (rawValues.size > 1) conflict = true + } + + // Counts and enum values only: never the raw node text. + private fun traceOutcome( + outcome: String, reason: String, path: String, pass: Int, sameFrame: Boolean, + onlyTrailingPriceDiff: Boolean, rawVariants: Int, candidates: Int, badgeMerged: Int, + ) { + trace( + "spec-normalize dim=$dim outcome=$outcome reason=$reason path=$path pass=$pass sameFrame=$sameFrame " + + "onlyTrailingPriceDiff=$onlyTrailingPriceDiff rawVariants=$rawVariants candidates=$candidates " + + "badgeCaptionMerged=$badgeMerged", + ) } } - private class SizeNormalizationTracker : SpecNormalizationTracker(SpecValueNormalizer::isSafeSize) + private class SizeNormalizationTracker(trace: (String) -> Unit) : + SpecNormalizationTracker("size", SpecValueNormalizer::isSafeSize, trace) - private class ColorNormalizationTracker : SpecNormalizationTracker(SpecValueNormalizer::isSafeColor) + private class ColorNormalizationTracker(trace: (String) -> Unit) : + SpecNormalizationTracker("color", SpecValueNormalizer::isSafeColor, trace) private fun recordSizeDiscovery( reason: AgentDiagnosticReason, @@ -2400,6 +2456,8 @@ class PddProductDetailCollector( private fun traceLabel(value: String): String = value.replace(Regex("[\\r\\n\\t]+"), " ").take(40) private companion object { + /** Same terminal-price grammar as [SpecValueNormalizer]; used only to classify a trace, never to normalize. */ + val TRAILING_PRICE = Regex("""[\s\u00a0\u3000]*(?:¥|¥)[\s\u00a0\u3000]*\d+(?:\.\d{1,2})?[\s\u00a0\u3000]*$""") const val PRICE_SETTLE_MILLIS = 300L /** Fixed wait for the color grid to stop coasting after a horizontal swipe (no extra parse). */ const val HORIZONTAL_SETTLE_MILLIS = 1200L diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 5f0a0ae..a120134 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -1234,6 +1234,78 @@ class PddProductDetailCollectorTest { assertTrue(payload.missing.contains("spec_dimension_invalid:size")) } + @Test + fun `normalization conflict trace carries only counts and enum values`() { + val traces = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("黑色"), + sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")), + prices = mapOf("黑色" to 1000L), + ) + var clock = 0L + + PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule()) + + val lines = traces.filter { it.startsWith("spec-normalize ") } + assertEquals(1, lines.size) + assertEquals( + "spec-normalize dim=size outcome=conflict_kept reason=normalized_conflict path=parsed pass=1 sameFrame=false " + + "onlyTrailingPriceDiff=true rawVariants=2 candidates=2 badgeCaptionMerged=0", + lines.single(), + ) + assertTrue(lines.none { it.contains("XL") || it.contains("¥") }) + } + + @Test + fun `normalization invalid trace reports reason without node text`() { + val traces = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("黑色"), + sizePages = listOf(listOf("XL ¥10 热卖", "S")), + prices = mapOf("黑色" to 1000L), + ) + var clock = 0L + + PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule()) + + val line = traces.single { it.startsWith("spec-normalize ") } + assertTrue(line.contains("dim=size outcome=invalid_rejected reason=unsafe_currency path=parsed")) + assertTrue(line.contains("candidates=2")) + assertTrue(!line.contains("热卖") && !line.contains("XL")) + } + + @Test + fun `same-frame color conflict trace flags sameFrame and badge count`() { + val traces = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("红色 ¥10", "红色 ¥12"), + prices = mapOf("红色 ¥10" to 1000L), + imageColorCards = true, + ) + var clock = 0L + + PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule()) + + val line = traces.first { it.startsWith("spec-normalize dim=color") } + assertTrue(line.contains("outcome=conflict_kept")) + assertTrue(line.contains("sameFrame=true")) + assertTrue(line.contains("onlyTrailingPriceDiff=true")) + assertTrue(line.contains("rawVariants=2")) + assertTrue(line.endsWith("badgeCaptionMerged=0")) + } + + @Test + fun `parser counts merged badge captions without exposing text`() { + val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence()) + assertEquals(2, parsed.badgeCaptionMerged) + + val plain = PddScreenParser.parse( + badgeCardsSnapshot(listOf(badgeCard(3, 36, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"))), + config(), GOODS_ID, evidence(), + ) + assertEquals(0, plain.badgeCaptionMerged) + } + @Test fun `trailing color prices are normalized across dimension price and sku`() { val driver = FakeCollectorDriver(