fix: артикул — категории из component universe + видимость нераспознанных токенов
Генерация артикула резолвила lot_category из одного прайслиста конфигурации (GetLocalLotCategoriesByServerPricelistID), поэтому world-only LOT (напр. GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE, есть только в world) молча выпадал из артикула. Теперь категории берутся через GetLocalComponentCategoriesByLotNames (тот же world ∪ estimate, что и весь конфигуратор). BuildOptions.ServerPricelist убран; preview-article принимает pricelist_id, но игнорирует. Нераспознанные токены больше не пишутся как UNK: в артикул идёт lot_category как плейсхолдер (4xGPU, 2xCPU), сегмент помечается Recognized=false, добавляется warning с именем LOT. Конфигуратор подсвечивает такие сегменты (amber) и выводит список предупреждений; сохранение/обновление/откат логируют WARN. Без каталога вендоров в репо детектируется только структурный сбой формы имени. parseGPUModel: принимает суффикс-букву в номере модели (6000D → RTX6000D), раньше терял её и схлопывал до RTX_84GB. ADL bible-local/decisions/2026-09-01-article-category-from-component-universe.md, 2026-09-01-article-degraded-token-visibility.md; раздел «Article generation» в 02-architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAhfF4P1ySRZ67yyUUeVw2
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ecef030699
commit
d7d4ea74b6
@@ -157,6 +157,34 @@ Rules:
|
||||
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
|
||||
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
|
||||
|
||||
## Article generation
|
||||
|
||||
`internal/article` builds the `article` string (`{MODEL}-{CPU}-{MEM}-{GPU}-{DISK}-{NET}-{PSU}-{SUPPORT}`)
|
||||
from the configuration's `items` + `server_model`.
|
||||
|
||||
- which segment a cart LOT belongs to is decided by its `lot_category`, resolved through
|
||||
`ResolveLotCategories` → `GetLocalComponentCategoriesByLotNames`, i.e. the **component
|
||||
universe** (latest active `world` ∪ `estimate`), the same source the configurator/BOM/pricing
|
||||
tab use. It must **not** be scoped to the configuration's pinned pricelist: a world-only LOT
|
||||
(e.g. `GPU_NV_RTX_PRO_6000D_...`, priced by the world fallback, never added to the estimate
|
||||
pricelist) is a legitimate cart member and still carries a real `lot_category` in `world`.
|
||||
Scoping to one pricelist silently dropped such LOTs from the article. See
|
||||
[decisions/2026-09-01-article-category-from-component-universe.md](decisions/2026-09-01-article-category-from-component-universe.md);
|
||||
- `BuildOptions` no longer takes a pricelist; `POST /api/configs/preview-article` still accepts
|
||||
`pricelist_id` but ignores it;
|
||||
- category comes only from real synced pricelist columns — never inferred from the `lot_name`
|
||||
prefix (the `SVC_` SUPPORT segment is the sole lot_name-pattern exception, see below);
|
||||
- within a segment the model/capacity/speed **token** is still parsed from the `lot_name` —
|
||||
that is the article text itself, not categorization;
|
||||
- when a token can't be parsed (the `lot_name` doesn't fit `{GROUP}_{VENDOR}_{MODEL}[_{SPEC}…]`)
|
||||
the segment carries the LOT's `lot_category` as the token — **never** a bare `UNK`. Such a
|
||||
segment comes back with `Recognized = false` in `BuildResult.Segments`, plus a `Warnings`
|
||||
entry naming the `lot_name`. The configurator highlights the segment (amber) and lists the
|
||||
warnings; create/update/rollback log `WARN "article generation degraded"`. No catalog of real
|
||||
lot_names / model names lives in the repo (`no-hardcoded-vendors`), so only *structural*
|
||||
parse failure is detected, not a wrong-but-well-formed token. See
|
||||
[decisions/2026-09-01-article-degraded-token-visibility.md](decisions/2026-09-01-article-degraded-token-visibility.md).
|
||||
|
||||
## Support as a BOM LOT
|
||||
|
||||
The Base tab's support-level picker adds/replaces a synthetic LOT in `cart` (e.g.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Decision: article generation reads lot_category from the component universe, not one pricelist
|
||||
|
||||
**Date:** 2026-09-01
|
||||
**Status:** active
|
||||
|
||||
## Context
|
||||
|
||||
`internal/article/generator.go` classifies each cart LOT into an article segment
|
||||
(CPU / MEM / GPU / DISK / NET / PSU) by its `lot_category`. That category was
|
||||
resolved by `ResolveLotCategories` → `GetLocalLotCategoriesByServerPricelistID`,
|
||||
which queries exactly **one** pricelist — the configuration's pinned
|
||||
`pricelist_id` (an `estimate` pricelist). A segment builder skips any LOT whose
|
||||
category is unknown, so a LOT absent from that one pricelist vanished from the
|
||||
article with no warning.
|
||||
|
||||
`GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE` exposed this: it exists only in `world`
|
||||
pricelists (where it correctly carries `lot_category = GPU`) and in no `estimate`
|
||||
pricelist. It was priced (world fallback), exported, and shown in the cart, but
|
||||
dropped from the article.
|
||||
|
||||
This is the same class of bug as
|
||||
[2026-07-24-component-universe-world-union.md](2026-07-24-component-universe-world-union.md):
|
||||
the cart's LOT set is `world` ∪ `estimate`, and no read path may silently drop a
|
||||
LOT that only lives in `world`. Article generation was the one component-reading
|
||||
path still scoped to a single pricelist.
|
||||
|
||||
Deriving the category from the `lot_name` prefix was rejected — it is forbidden by
|
||||
the pricelist-contract rules (`02-architecture.md`, `bible/rules/patterns/`):
|
||||
category comes only from real synced pricelist columns.
|
||||
|
||||
## Decision
|
||||
|
||||
`ResolveLotCategories(local, lotNames)` now calls
|
||||
`LocalDB.GetLocalComponentCategoriesByLotNames`, which reads the component universe
|
||||
(`componentUniverse()` — latest active `world` ∪ `estimate`, estimate wins a
|
||||
collision). The server-pricelist parameter is gone.
|
||||
|
||||
- `article.BuildOptions` no longer has `ServerPricelist`.
|
||||
- `POST /api/configs/preview-article` and the create/update paths still accept
|
||||
`pricelist_id` but ignore it for article purposes.
|
||||
- The `lot_name`-token parsing inside a segment (CPU model, memory size, GPU model,
|
||||
disk capacity, port speed, wattage) still parses the token from the `lot_name` —
|
||||
that produces the article text, it does not categorize. One parser bug was fixed
|
||||
alongside: `parseGPUModel` required the model-number token to be all digits
|
||||
(`isNumeric`), so `RTX_PRO_6000D` lost its `D` and collapsed to `RTX_84GB` —
|
||||
indistinguishable from a real `RTX` card and different from its sibling
|
||||
`RTX_PRO_6000` → `RTX6000_96GB`. It now accepts a digit-first alphanumeric token
|
||||
(`isModelNumber`), giving `RTX6000D_84GB`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A world-only LOT lands in the article with its real category. Article generation
|
||||
no longer depends on which pricelist a LOT was added to.
|
||||
- The article can shift if the latest active `world`/`estimate` pricelist
|
||||
recategorizes a LOT — acceptable, and consistent with how the configurator's
|
||||
category tabs already behave.
|
||||
- `GetLocalLotCategoriesByServerPricelistID` still exists and is still used by
|
||||
`services/rental.go` and `services/export.go`. Those paths have the same
|
||||
single-pricelist blind spot for world-only LOTs; migrating them is out of scope
|
||||
here but should follow the same direction.
|
||||
- Covered by `TestResolveLotCategories_WorldOnlyLot` in
|
||||
`internal/article/categories_test.go`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Decision: unparsed article tokens degrade to a visible category placeholder, never "UNK"
|
||||
|
||||
**Date:** 2026-09-01
|
||||
**Status:** active
|
||||
|
||||
## Context
|
||||
|
||||
`internal/article` builds each segment by parsing a spec token out of the `lot_name`
|
||||
(CPU model, memory size, GPU model, disk capacity, port speed, wattage). When a
|
||||
`lot_name` did not match the expected shape the generator failed silently or
|
||||
misleadingly:
|
||||
|
||||
- `parseCPUModel` / `parseGPUModel` fell back to `normalizeModelToken` or a literal
|
||||
`"UNK"` with **no signal** — and a partial parse (`RTX_PRO_6000D` → `RTX_84GB`)
|
||||
produced a plausible but wrong token indistinguishable from a real result;
|
||||
- `NET` / `PSU` emitted `UNKNET` / `UNKPSU`;
|
||||
- `MEM` / `DISK` dropped the segment or the capacity and emitted only a terse
|
||||
`mem_unknown` / `disk_unknown` warning;
|
||||
- `BuildResult.Warnings` existed but **nothing consumed it**: the `preview-article`
|
||||
frontend ignored the field and the create/update paths discarded it.
|
||||
|
||||
So a new naming shape (a vendor renames a card family, moves memory ahead of the
|
||||
model, glues an architecture onto the token) would silently produce a wrong or
|
||||
truncated article and no one would notice.
|
||||
|
||||
A vendor/model catalog in the repo to validate tokens against was rejected — it
|
||||
violates `bible/rules/patterns/no-hardcoded-vendors` and needs constant upkeep.
|
||||
Detection is therefore limited to **structural** failure (the name doesn't fit
|
||||
`{GROUP}_{VENDOR}_{MODEL}[_{SPEC}…]`); semantic drift where a wrong token still
|
||||
parses cleanly cannot be caught automatically and is out of scope.
|
||||
|
||||
## Decision
|
||||
|
||||
1. `"UNK"` / `"UNKNET"` / `"UNKPSU"` are gone. When a spec token can't be parsed the
|
||||
article carries the LOT's `lot_category` as the token (`4xGPU`, `2xCPU`,
|
||||
`768G+MEM`, …) via `placeholderToken`.
|
||||
2. Each `build*Segment` returns a `segmentResult{value, degraded, warnings}`. Every
|
||||
degraded segment produces a warning that **names the offending `lot_name`** and
|
||||
states which category was written instead.
|
||||
3. `BuildResult` gains `Segments []ResultSegment{Group, Text, Recognized}`.
|
||||
`Recognized == false` marks a segment whose token is a placeholder.
|
||||
4. `parseCPUModel` / `parseGPUModel` return `(token, ok)`; `ok == false` on the
|
||||
last-ditch fallback path.
|
||||
5. Consumers surface it:
|
||||
- `POST /api/configs/preview-article` returns `segments` and `warnings`; the
|
||||
configurator's article line renders each not-`recognized` segment highlighted
|
||||
(amber) and lists the warnings beneath it (`renderArticleDisplay` in
|
||||
`web/templates/index.html`);
|
||||
- create / update / rollback log `WARN "article generation degraded"` with the
|
||||
`server_model`, article and warnings.
|
||||
6. No list of real `lot_name`s or model names is committed to the repo — not as a
|
||||
fixture, not as a golden table. Tests use synthetic names only.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unrecognised LOT is always visible in the UI (highlighted token + warning) and
|
||||
in server logs — never a silent drop or a bare `UNK`.
|
||||
- The stored `article` string still contains only the placeholder token; the
|
||||
`recognized` flags are not persisted (regenerated on next save / preview).
|
||||
- `compressArticle` now returns `[]namedSeg` (the caller re-joins) so the degraded
|
||||
flags survive compression.
|
||||
- Structural detection only: a wrong-but-well-formed token (e.g. a future `GTX`
|
||||
where `RTX` was expected) still passes. Closing that needs a validation source,
|
||||
deferred pending a server-side structured-attribute contract.
|
||||
- Covered by `TestBuild_UnparseableModel_CategoryPlaceholder` and
|
||||
`TestParseGPUModel_VendorLetterSuffix`.
|
||||
Reference in New Issue
Block a user