From 8ee36371df8056b0f2afae90467e66fb508cee26 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 7 Aug 2026 16:37:02 +0800 Subject: [PATCH] docs: import wiki at afc651f75a3a --- T-257.-.md | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 T-257.-.md diff --git a/T-257.-.md b/T-257.-.md new file mode 100644 index 0000000..44435c6 --- /dev/null +++ b/T-257.-.md @@ -0,0 +1,283 @@ + +> 同步来源:[`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= priceParsed= priceRange= pricePrefix= priceSuffix= priceOther= +``` + +| 键 | 含义 | +| --- | --- | +| `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` + 和真实六类价格分布;需要采购人员在已授权测试设备和受控拼多多账号上完成。全程未进入订单提交或支付。