Table of Contents
同步来源:
docs/tasks/T-257.md· commitafc651f75a3a
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。
因果链
PinduoduoSpecificationParser.merge:priceAmbiguous = observations.any { it.priceAmbiguous } || prices.size > 1,price = prices.singleOrNull().takeUnless { priceAmbiguous }。- 实测
priceAmbiguous=false⟹prices.size <= 1;price=null⟹singleOrNull()为空 ⟹prices.size != 1。两者相交只有一个解:prices.size == 0,整个选规格过程一次价格都没解析出来。 SkuHardConstraints.evaluate首行短路:if (evidence.price == null || evidence.priceAmbiguous)→ 两个硬约束全部返回UNKNOWN,audit 原因combination_price_unverified。MainActivity.kt:1753守卫要求所有身份已解析的EXACT_MATCH候选硬约束必须恰好 2 条且全为MATCH,否则return false。queueProcurementCandidates返回 false →candidateBatchQueued=false;collectionComplete虽为 true(T-256 生效),仍落入 FAILED 分支。- 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
)
要求:
PinduoduoCandidateCard增加cardPriceText: String(默认空串),保存触发hasPrice判定的那条原始文本。多条命中时取第一条,判定顺序不变。- 该值随候选证据一并持久化,供后续任务作为价格基线使用。
- 本任务只采集不使用。 严禁把它写入
ExecutionCandidateDraft.price、candidate.priceText或任何流向authorizedPriceText的字段。
第 3 条是硬边界。下单授权链路是
OrderDryRunCoordinator → commandCandidate.priceText → authorizedPriceText → PinduoduoAuthorizedPricePolicy.permits,一旦近似价进入该链路,Admin 会基于错误价格
选品,并在下单阶段才发现价格对不上。列表页价格属于引流价/区间下界,权威性最低,
其使用方式必须等价格来源枚举设计完成后另开任务。
hasPrice的判定逻辑、hasPriceSemantics的实现和九个过滤条件的顺序一律不变, 只在既有判定处顺带保留命中文本。
六、不得降低既有校验
颜色、尺码 MISMATCH 的候选、身份未解析的候选、证据缺失的候选,处理方式一律不变。
本任务只解决「价格缺失把合格候选一并拖死」和「失败原因不可见」两件事。
方案
- 规格证据解析处增加六类价格文本形状短路归因计数,并入诊断快照与 message 尾部。
queueProcurementCandidates把裸return false改为带原因码的结果类型;CandidateEvidenceSource.load失败返回可读类别与 ordinal。- 调整 FAILED 分支 reason 优先级,队列原因码优先于
report.failureCode。 - 调整
MainActivity.kt:1753守卫:区分combination_price_unverified型UNKNOWN与真实MISMATCH;有预算任务保持严格并给出专用原因码。 - 候选草稿标注价格未确认,供 Admin 展示。
PinduoduoCandidateCard增加cardPriceText并随证据持久化,只采集不使用。- 单测覆盖:六类价格形状各自归因、短路只计一次、恒等式成立、九个原因码各自触发、
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和真实六类价格分布;需要采购人员在已授权测试设备和受控拼多多账号上完成。全程未进入订单提交或支付。