feat(android): add sanitized spec-normalize trace (#379)

规格规范化冲突或作废时输出一条脱敏 trace(每维每类最多一条,仅 Log.i 过程日志):
spec-normalize dim outcome reason path pass sameFrame onlyTrailingPriceDiff
rawVariants candidates badgeCaptionMerged。只含计数与枚举值,不输出节点原文。
ParsedPddScreen 新增 badgeCaptionMerged 计数(合并的价格加徽标说明卡片数)。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-12 14:53:16 +08:00
co-authored by Claude Sonnet 5.5
parent b86f858cb7
commit d45a9b9a24
2 changed files with 148 additions and 18 deletions
@@ -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<String> = 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<String>()
val dimensions = buildList<VisibleDimension> {
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<String, SnapshotNode>,
sourceByPath: Map<String, SnapshotNode>,
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<SnapshotNode>,
sourceByPath: Map<String, SnapshotNode>,
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<String>()
val unsupported = linkedSetOf<String>()
val colorImages = linkedMapOf<String, ByteArray>()
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<String, Boolean>()
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<String, MutableSet<String>>()
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<String, LinkedHashMap<String, MutableSet<Int>>>()
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<VisibleSpecValue>, 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
@@ -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<String>()
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<String>()
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<String>()
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(