docs: import wiki at afc651f75a3a

ila
2026-08-07 16:37:02 +08:00
parent b0f09e6bc1
commit 8ee36371df
+283
@@ -0,0 +1,283 @@
<!-- docs-wiki-sync:docs/tasks/T-257.md@afc651f75a3abc2676bb13aa8a80f6aa6a25a72e -->
> 同步来源:[`docs/tasks/T-257.md`](/chengma/mroubao/src/commit/afc651f75a3abc2676bb13aa8a80f6aa6a25a72e/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`,要求文本**完全等于**价格:
```kotlin
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`,证明每个候选卡片都有可解析价格——但当前只把它当布尔量使用,
值被丢弃:
```kotlin
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 会基于错误价格
选品,并在下单阶段才发现价格对不上。列表页价格属于引流价/区间下界,权威性最低,
其使用方式必须等价格来源枚举设计完成后另开任务。
4. `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`
和真实六类价格分布;需要采购人员在已授权测试设备和受控拼多多账号上完成。全程未进入订单提交或支付。