1
T-257
ila edited this page 2026-08-07 16:37:02 +08:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

同步来源:docs/tasks/T-257.md · commit afc651f75a3a


id: T-257 title: 诊断规格弹层价格解析失败并消除候选批次静默丢弃 phase: 2 deps:

  • T-255
  • T-256 status: DOING created: 2026-07-31 context_ref: 181efe6 work_branch: null write_paths:
  • docs/tasks/T-257.md
  • docs/current-state.md
  • android-buyer/app/src/main/**
  • android-buyer/app/src/test/**

问题 / 背景

T-255/T-256 上机后,真机三轮采集到两个身份已解析的合格候选,任务仍然 FAILED, 候选一个都没送到 Admin。设备取证的实际数据:

ordinal round matchType goodsId specGroups price priceAmbiguous
1 1 EXACT_MATCH 有 2 null false
2 3 FALLBACK 有 2 ¥21.98 false

ordinal 1 的逐选项标志与摘要都正确(黑色【假两件】 selected=true、 L 100-115斤 selected=true、selectedSummary='已选择:L 100-115斤 黑色【假两件】'), 唯独 price 为 null。

因果链

  1. PinduoduoSpecificationParser.merge: priceAmbiguous = observations.any { it.priceAmbiguous } || prices.size > 1, price = prices.singleOrNull().takeUnless { priceAmbiguous }。
  2. 实测 priceAmbiguous=false ⟹ prices.size <= 1;price=null ⟹ singleOrNull() 为空 ⟹ prices.size != 1。两者相交只有一个解: prices.size == 0,整个选规格过程一次价格都没解析出来。
  3. SkuHardConstraints.evaluate 首行短路: if (evidence.price == null || evidence.priceAmbiguous) → 两个硬约束全部返回 UNKNOWN,audit 原因 combination_price_unverified。
  4. MainActivity.kt:1753 守卫要求所有身份已解析的 EXACT_MATCH 候选硬约束必须恰好 2 条且全为 MATCH,否则 return false。
  5. queueProcurementCandidates 返回 false → candidateBatchQueued=false; collectionComplete 虽为 true(T-256 生效),仍落入 FAILED 分支。
  6. FAILED 分支 reason 取 report.failureCode?.name,把真实原因 CANDIDATE_BATCH_QUEUE_FAILED 盖成了 CANDIDATE_ALL_EXCLUDED。

已排除的解释

「选规格时价格变化导致多个价格、singleOrNull() 落空」——不成立。该路径会置 priceAmbiguous=true,而实测为 false。据此也排除「回落到最终一次读取的价格」这类 兜底修法:所有观测都没有价格,最后一次同样没有。

能力本身没问题

ordinal 2 成功解析出 ¥21.98;T-255 的拒绝分布中 rejPrice=0,说明候选卡片的价格 语义可以识别。能力具备,但 EXACT_MATCH 的规格弹层里一次都没成功。

待查的具体机制

价格正则是 matchEntire,要求文本完全等于价格:

Regex("""^(?:首件)?[¥¥](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/docs/tasks/%5Cd%7B1%2C7%7D)(?:\.(\d{1,2}))?$""")

只要拼多多在选规格后把价格渲染成 ¥21.98起、¥21.98-35.98、到手价¥21.98、 ¥21.98/件 之类,matchEntire 就全部失败。FALLBACK 单次读取可能正好命中纯价格 文本,因而通过。

但这仍是推测。 直接放宽正则很可能改完还是不匹配,白跑一轮真机。本任务先取得 失败文本的结构形状分布,再据此单独开任务修正则。

关联需求与交互

  • 功能:F-005、F-006、F-007
  • 用户故事:US-004、US-005、US-006
  • 交互:沿用 Admin 候选确认区,价格未确认时需明确标注
  • 架构/API:沿用既有候选回传契约与 T-254/T-255 诊断机制,后端零改动

行为契约

一、规格弹层价格文本形状诊断

在规格证据解析处,对每个含 ¥ 或 ¥ 的文本做短路归因统计,并入 CandidateSearchDiagnostics,按固定顺序追加在既有键之后:

priceTexts=<n> priceParsed=<n> priceRange=<n> pricePrefix=<n> priceSuffix=<n> priceOther=<n>
键 含义
priceTexts 含 ¥/¥ 的文本总数(归因基数)
priceParsed 现有 PRICE_PATTERN.matchEntire 成功的条数
priceRange 区间形式:出现两个及以上货币符号,或价格后接 -/~/到 再接数字
pricePrefix 货币符号前有额外字符(首件 前缀除外,那已被现有正则接受)
priceSuffix 价格数字后有额外字符
priceOther 含货币符号但不属于以上任何一类

要求:

  • 短路归因,一条文本只计一次,判定顺序为 priceParsed → priceRange → pricePrefix → priceSuffix → priceOther。
  • 恒等式 priceTexts = priceParsed + priceRange + pricePrefix + priceSuffix + priceOther 必须有单测保证。
  • 只记形状计数,绝不记录原始文本:不得写入商品标题、店铺名、goods_id、链接或 任何规格/价格原文。沿用 T-255 的脱敏口径。
  • 计数在 PinduoduoCandidateAutomation 单轮内累计,reset() 清零。

二、消除静默丢弃

queueProcurementCandidates 现有至少 6 处裸 return false, CandidateEvidenceSource.load 用 getOrNull() 吞掉全部异常,外部完全不可观测。 本次排障绕了三轮真机才定位到根因,直接原因就是这里。

每个提前返回点必须绑定稳定原因码,取值满足 ^[A-Za-z0-9_.-]{1,160}$(无空格):

原因码 触发点
QUEUE_NO_SELECTED_EVIDENCE selectedEvidence.isEmpty()
QUEUE_EVIDENCE_LOAD_FAILED candidateEvidenceSource.load 返回 null
QUEUE_VALIDATED_SIZE_INVALID validated.size !in 1..5 或 searchRound !in 1..3
QUEUE_EXECUTION_MODE_MISSING activeExecutionMode() 为 null
QUEUE_PROVENANCE_MISSING AI_ASSISTED 下 provenance 为 null
QUEUE_SKU_CONSTRAINTS_NOT_READY constraints.readyForAutomaticMatching 为 false
QUEUE_HARD_CONSTRAINT_UNMATCHED MainActivity.kt:1753 守卫命中
QUEUE_PRICE_REQUIRED_FOR_BUDGET 有预算任务但组合价格未确认(见第四节)
QUEUE_BATCH_REJECTED queueCandidateBatch 返回 false

CandidateEvidenceSource.load 失败时必须给出失败的 ordinal 与失败类别,不得只返回 null。类别粒度做到「哪一类 require 失败」即可,不要求逐条。

三、失败原因优先级反转

FAILED 分支的 reason 目前取 report.failureCode?.name,会盖住队列失败原因。改为 队列失败原因码优先于 report.failureCode。这是本次根因难以定位的直接原因。

四、价格未确认候选的归宿

默认决策:组合价格无法确认时,候选仍然提交给 Admin 进入人工确认,但必须明确 标注价格未确认,不得伪装成已核价。

依据:docs/02-requirements.md 业务规则 20 规定硬约束只有颜色和尺码,价格不是 候选匹配的硬约束;F-006 本就要求人工复核证据后选品。

例外:任务设置了最高商品总预算时,没有价格无法校验预算,必须明确失败并给出 QUEUE_PRICE_REQUIRED_FOR_BUDGET,不得放行。

因此 MainActivity.kt:1753 守卫需调整:UNKNOWN 且 audit 原因为 combination_price_unverified 时,无预算任务不再一票否决;颜色或尺码为 MISMATCH 仍然一票否决,不得放松。

这一条与第一节的诊断相互独立:即使价格解析问题尚未修复,本条也能让当前卡住的任务 立即走通,同时诊断数据为后续修正则提供依据。

五、保留候选卡片价格作为后续基线

候选卡片过滤器的第 7 个条件 hasPrice 已经要求卡片必须含可解析价格语义才能通过, T-255 实测 rejPrice=0,证明每个候选卡片都有可解析价格——但当前只把它当布尔量使用, 值被丢弃:

data class PinduoduoCandidateCard(
    val signature: String,
    val semanticTextCount: Int,
    val hasImage: Boolean          // 没有 price
)

要求:

  1. PinduoduoCandidateCard 增加 cardPriceText: String(默认空串),保存触发 hasPrice 判定的那条原始文本。多条命中时取第一条,判定顺序不变。
  2. 该值随候选证据一并持久化,供后续任务作为价格基线使用。
  3. 本任务只采集不使用。 严禁把它写入 ExecutionCandidateDraft.price、 candidate.priceText 或任何流向 authorizedPriceText 的字段。

第 3 条是硬边界。下单授权链路是 OrderDryRunCoordinator → commandCandidate.priceText → authorizedPriceText → PinduoduoAuthorizedPricePolicy.permits,一旦近似价进入该链路,Admin 会基于错误价格 选品,并在下单阶段才发现价格对不上。列表页价格属于引流价/区间下界,权威性最低, 其使用方式必须等价格来源枚举设计完成后另开任务。

  1. hasPrice 的判定逻辑、hasPriceSemantics 的实现和九个过滤条件的顺序一律不变, 只在既有判定处顺带保留命中文本。

六、不得降低既有校验

颜色、尺码 MISMATCH 的候选、身份未解析的候选、证据缺失的候选,处理方式一律不变。 本任务只解决「价格缺失把合格候选一并拖死」和「失败原因不可见」两件事。

方案

  1. 规格证据解析处增加六类价格文本形状短路归因计数,并入诊断快照与 message 尾部。
  2. queueProcurementCandidates 把裸 return false 改为带原因码的结果类型; CandidateEvidenceSource.load 失败返回可读类别与 ordinal。
  3. 调整 FAILED 分支 reason 优先级,队列原因码优先于 report.failureCode。
  4. 调整 MainActivity.kt:1753 守卫:区分 combination_price_unverified 型 UNKNOWN 与真实 MISMATCH;有预算任务保持严格并给出专用原因码。
  5. 候选草稿标注价格未确认,供 Admin 展示。
  6. PinduoduoCandidateCard 增加 cardPriceText 并随证据持久化,只采集不使用。
  7. 单测覆盖:六类价格形状各自归因、短路只计一次、恒等式成立、九个原因码各自触发、 reason 优先级反转、无预算放行与有预算拒绝、MISMATCH 仍一票否决、 cardPriceText 被保存且未流入任何下单授权字段。

验收要点

  • 六类价格文本形状各有单测,构造对应文本后只有目标计数递增。
  • 恒等式 priceTexts = priceParsed + priceRange + pricePrefix + priceSuffix + priceOther 有单测保证。
  • 诊断 message 不含标题、店铺名、goods_id、链接或任何价格/规格原文。
  • 九个队列原因码各有单测,触发对应路径时事件 reason 为该码。
  • 队列失败时 reason 不再被 report.failureCode 覆盖。
  • 无预算任务:价格未确认的 EXACT_MATCH 候选可提交并标注未确认。
  • 有预算任务:价格未确认时明确失败,原因码 QUEUE_PRICE_REQUIRED_FOR_BUDGET。
  • 颜色或尺码 MISMATCH 的候选仍被拒绝,有回归测试证明。
  • cardPriceText 保存了触发 hasPrice 的原始文本并随证据持久化。
  • 有回归测试证明 cardPriceText 未出现在 ExecutionCandidateDraft.price、 candidate.priceText 或任何流向 authorizedPriceText 的字段中。
  • hasPrice 判定结果与本任务前完全一致,卡片过滤行为不变,有回归测试证明。
  • T-254/T-255 既有诊断键的顺序、名称、取值不变。
  • Android Debug/Release 单测、lintDebug、assembleDebug、assembleRelease 全部通过。
  • 真机复跑:轮 1 的 EXACT_MATCH 候选出现在 Admin 候选确认区,双证据可查看, 任务进入 WAITING_CONFIRMATION;并从 Admin 读到六类价格形状分布记入执行记录。

边界

  • 不修改 PinduoduoSpecificationParser.merge 的价格合并语义—— PinduoduoOrderDryRunAutomation 共用,属下单路径。
  • 不修改 PRICE_PATTERN 正则——放宽正则必须等本任务的形状分布出来后另开任务, 盲改很可能无效。
  • 不修改 PinduoduoOrderDryRunAutomation 的价格判定、授权价格策略或预算校验。
  • 不修改 SkuHardConstraints.evaluate 对 MISMATCH 的判定,只在调用侧区分 combination_price_unverified 型 UNKNOWN。
  • 不改颜色/尺码硬约束语义、PinduoduoSpecificationTargetMatcher 的别名与正则。
  • 不改 T-254 的诊断字段语义、T-255 的拒绝归因、T-256 的候选提交门槛。
  • 不改卡片识别、签名算法、跨轮去重、三轮预算或成功判定式;cardPriceText 只是在 既有 hasPrice 判定处顺带保留命中文本,不改变任何过滤结果。
  • 不把 cardPriceText 用作候选价格——列表页价格权威性最低,其使用方式等价格来源 枚举(SELECTED_COMBINATION / DETAIL_PAGE_BASELINE / LIST_CARD 等)设计完成后 另开任务。本任务只采集。
  • 不改后端代码、数据库、API 契约或 Admin 模板。
  • 不启动真实下单,不进入订单提交或支付。
  • 本任务不解决卡片识别率偏低(distinct 偏小)问题,另开任务。

执行记录

开工后记录修改、命令、结果、环境、决策和 blocker。

2026-07-31 执行记录

  • 状态:已按规则领取为 DOING;未改为 DONE。
  • 修改范围:仅修改本任务 write_paths 内的 Android 代码、Android 单测、 docs/current-state.md 和本任务文件;未修改 backend-api,未运行 init.ps1, 未提交或推送。
  • 实现内容:
    • 在规格解析观察中增加 priceTexts/priceParsed/priceRange/pricePrefix/priceSuffix/priceOther 六类短路计数,保持 PRICE_PATTERN、merge 价格语义、既有诊断键顺序和候选卡九条件不变; 计数随单轮 automation 累计并由 reset() 清零,恢复路径也从持久化证据恢复计数。
    • 将候选队列提前返回绑定为九个稳定 QUEUE_* 原因码;证据加载失败返回失败 ordinal 和 require 类别;FAILED 原因优先采用队列原因。
    • 仅对 combination_price_unverified 型 UNKNOWN 在无最高预算任务放行;有预算返回 QUEUE_PRICE_REQUIRED_FOR_BUDGET,颜色/尺码 MISMATCH 和其他 UNKNOWN 仍拒绝。
    • 在既有 hasPrice 命中处保存首条 cardPriceText,并随候选证据/进度持久化;草稿价格统一 只取规格组合价,新增回归测试证明卡片价格不进入 ExecutionCandidateDraft.price 或授权价格来源。
    • 诊断事件只追加计数,不写商品标题、店铺名、goodsId、链接或价格/规格原文。
  • 验证命令与结果:
    • .\gradlew.bat :app:testDebugUnitTest --no-daemon:首次因预算字段访问错误失败;修正为从 ProcurementRepository.currentTaskHasBudget() 读取后,同一命令再次运行成功。
    • .\gradlew.bat testDebugUnitTest testReleaseUnitTest lintDebug assembleDebug assembleRelease --no-daemon:成功,五项任务全部通过;最终代码追加恢复诊断和原因优先级测试后再次运行,仍成功。
    • git diff --check:退出码 0,无 whitespace error;仓库既有 Git 行尾属性产生的提示不影响结果。
    • 修改文件 LF 检查:未发现 CRLF。
  • 环境:Windows 工作区 D:\chengma\cmroubao;Android Gradle Debug/Release 单测、lint 和 APK 构建均在 android-buyer 目录执行。命令输出有 Android SDK XML 版本兼容提示,但构建结果成功。
  • 未验证范围 / blocker:无法执行最终真机复跑,故未验证 Admin 候选确认区、WAITING_CONFIRMATION 和真实六类价格分布;需要采购人员在已授权测试设备和受控拼多多账号上完成。全程未进入订单提交或支付。