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:
co-authored by
Claude Sonnet 5.5
parent
b86f858cb7
commit
d45a9b9a24
+76
-18
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user