Files
QuoteForge/internal/article/generator.go
T
Mikhail ChusavitinandClaude Sonnet 5 d7d4ea74b6 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
2026-09-01 09:01:56 +03:00

662 lines
17 KiB
Go

package article
import (
"fmt"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
type BuildOptions struct {
ServerModel string
}
type BuildResult struct {
Article string
Segments []ResultSegment
Warnings []string
}
// ResultSegment is one dash-separated piece of the article, tagged so the UI can
// highlight pieces the generator could not fully parse. Recognized is false when
// the segment contains a category placeholder (e.g. "GPU" instead of a real model
// token) because a lot_name did not match the expected naming shape.
type ResultSegment struct {
Group string `json:"group"`
Text string `json:"text"`
Recognized bool `json:"recognized"`
}
var (
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
)
type namedSeg struct {
group string // "MODEL","CPU","MEM","GPU","DISK","NET","PSU","SUPPORT"
value string
// degraded marks a segment whose value contains a category placeholder instead
// of a parsed spec token, because some lot_name did not match the expected shape.
degraded bool
}
// segmentResult is what each build*Segment helper returns: the rendered value, a
// degraded flag, and human-readable warnings that name the offending lot_names.
type segmentResult struct {
value string
degraded bool
warnings []string
}
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
segs := make([]namedSeg, 0, 8)
warnings := make([]string, 0)
model := NormalizeServerModel(opts.ServerModel)
if model == "" {
return BuildResult{}, fmt.Errorf("server_model required")
}
segs = append(segs, namedSeg{group: "MODEL", value: model})
lotNames := make([]string, 0, len(items))
for _, it := range items {
lotNames = append(lotNames, it.LotName)
}
cats, err := ResolveLotCategories(local, lotNames)
if err != nil {
return BuildResult{}, err
}
for _, sb := range []struct {
group string
build func([]models.ConfigItem, map[string]string) segmentResult
}{
{"CPU", buildCPUSegment},
{"MEM", buildMemSegment},
{"GPU", buildGPUSegment},
{"DISK", buildDiskSegment},
{"NET", buildNetSegment},
{"PSU", buildPSUSegment},
} {
res := sb.build(items, cats)
warnings = append(warnings, res.warnings...)
if res.value != "" {
segs = append(segs, namedSeg{group: sb.group, value: res.value, degraded: res.degraded})
}
}
if supportSeg := buildSupportSegment(items); supportSeg != "" {
segs = append(segs, namedSeg{group: "SUPPORT", value: supportSeg})
}
article := strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) > 80 {
segs = compressArticle(segs)
article = strings.Join(namedSegsValues(segs), "-")
warnings = append(warnings, "compressed")
}
if len([]rune(article)) > 80 {
return BuildResult{}, fmt.Errorf("article_overflow")
}
result := BuildResult{Article: article, Warnings: warnings}
for _, s := range segs {
result.Segments = append(result.Segments, ResultSegment{
Group: s.group,
Text: s.value,
Recognized: !s.degraded,
})
}
return result, nil
}
func namedSegsValues(segs []namedSeg) []string {
out := make([]string, len(segs))
for i, s := range segs {
out[i] = s.value
}
return out
}
func findSegGroup(segs []namedSeg, group string) int {
for i, s := range segs {
if s.group == group {
return i
}
}
return -1
}
// buildSupportSegment finds a support LOT in items (added to the spec via the
// Base tab's support picker, e.g. "SVC_3yB_HGX-H200") and returns its
// duration+level token ("3yB") for the article. Support is a regular BOM
// LOT, not pricelist-backed, so it's detected by lot_name prefix like the
// other lot_name-pattern parsers in this file, rather than by lot_category.
func buildSupportSegment(items []models.ConfigItem) string {
for _, it := range items {
if !strings.HasPrefix(strings.ToUpper(it.LotName), "SVC_") {
continue
}
parts := strings.SplitN(it.LotName, "_", 3)
if len(parts) >= 2 && parts[1] != "" {
return parts[1]
}
}
return ""
}
// placeholderToken returns the token used in the article when a lot's spec can't be
// parsed: the lot_category itself (never a bare "UNK"), uppercased.
func placeholderToken(cat string) string {
t := strings.ToUpper(strings.TrimSpace(cat))
if t == "" {
return "X"
}
return t
}
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
models := map[string]int{}
res := segmentResult{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupCPU {
continue
}
model, parsed := parseCPUModel(it.LotName)
if !parsed {
model = placeholderToken(cats[it.LotName])
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("CPU: не распознана модель LOT %q — в артикул записана категория %q", it.LotName, model))
}
models[model] += it.Quantity
}
if len(models) == 0 {
return res
}
res.value = joinQtyTokens(models)
return res
}
func buildMemSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
totalGiB := 0
res := segmentResult{}
unparsed := 0
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupMEM {
continue
}
per := parseMemGiB(it.LotName)
if per <= 0 {
unparsed++
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("MEM: не распознан объём LOT %q", it.LotName))
continue
}
totalGiB += per * it.Quantity
}
parts := make([]string, 0, 2)
if totalGiB > 0 {
if totalGiB%1024 == 0 {
parts = append(parts, fmt.Sprintf("%dT", totalGiB/1024))
} else {
parts = append(parts, fmt.Sprintf("%dG", totalGiB))
}
}
if unparsed > 0 {
parts = append(parts, placeholderToken("MEM"))
}
res.value = strings.Join(parts, "+")
return res
}
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
models := map[string]int{}
res := segmentResult{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupGPU {
continue
}
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
continue
}
model, parsed := parseGPUModel(it.LotName)
if !parsed {
model = placeholderToken(cats[it.LotName])
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("GPU: не распознана модель LOT %q — в артикул записана категория %q", it.LotName, model))
}
models[model] += it.Quantity
}
if len(models) == 0 {
return res
}
res.value = joinQtyTokens(models)
return res
}
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
type key struct {
t string
c string
}
groupQty := map[key]int{}
res := segmentResult{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupDISK {
continue
}
capToken := parseCapacity(it.LotName)
if capToken == "" {
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("DISK: не распознан объём LOT %q", it.LotName))
}
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
groupQty[key{t: typeCode, c: capToken}] += it.Quantity
}
if len(groupQty) == 0 {
return res
}
parts := make([]string, 0, len(groupQty))
for k, qty := range groupQty {
if k.c == "" {
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
} else {
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
}
}
sort.Strings(parts)
res.value = strings.Join(parts, "+")
return res
}
func buildNetSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
return buildProfileSegment(items, cats, GroupNET, parsePortSpeed, "NET")
}
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
return buildProfileSegment(items, cats, GroupPSU, parseWatts, "PSU")
}
// buildProfileSegment groups items of the given category group by a profile token
// parsed from their lot name (e.g. port speed, wattage rating). When a lot's profile
// can't be determined it falls back to the category placeholder and flags the segment
// as degraded, naming the lot in a warning.
func buildProfileSegment(items []models.ConfigItem, cats map[string]string, group Group, parseProfile func(string) string, groupLabel string) segmentResult {
groupQty := map[string]int{}
res := segmentResult{}
for _, it := range items {
g, ok := GroupForLotCategory(cats[it.LotName])
if !ok || g != group {
continue
}
profile := parseProfile(it.LotName)
if profile == "" {
profile = placeholderToken(groupLabel)
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("%s: не распознан профиль LOT %q — в артикул записана категория %q", groupLabel, it.LotName, profile))
}
groupQty[profile] += it.Quantity
}
if len(groupQty) == 0 {
return res
}
res.value = joinQtyTokens(groupQty)
return res
}
// joinQtyTokens renders {token: qty} as a sorted "NxTOKEN+MxTOKEN" string.
func joinQtyTokens(qty map[string]int) string {
parts := make([]string, 0, len(qty))
for token, n := range qty {
parts = append(parts, fmt.Sprintf("%dx%s", n, token))
}
sort.Strings(parts)
return strings.Join(parts, "+")
}
func normalizeModelToken(lotName string) string {
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
lotName = lotName[idx+1:]
}
parts := strings.Split(lotName, "_")
token := parts[len(parts)-1]
return strings.ToUpper(strings.TrimSpace(token))
}
// parseCPUModel extracts the model token from a CPU lot_name (shape
// CPU_{VENDOR}_{MODEL}, e.g. "CPU_INTEL_8592+"). The bool is false when the name
// has no parseable {VENDOR}_{MODEL} tail — a structural failure the caller surfaces.
// It cannot validate that the token is a real CPU model (no vendor catalog is kept
// in the repo by design), only that the name matched the expected shape.
func parseCPUModel(lotName string) (string, bool) {
parts := strings.Split(lotName, "_")
if len(parts) >= 2 {
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
if last != "" {
return last, true
}
}
return normalizeModelToken(lotName), false
}
// parseGPUModel extracts a "MODEL[_MEM]" token from a GPU lot_name. The bool is
// false when no model token could be located and the result is only the last-ditch
// last-underscore-segment fallback — a structural failure the caller surfaces.
// Like parseCPUModel it does not validate the token against a catalog.
func parseGPUModel(lotName string) (string, bool) {
upper := strings.ToUpper(lotName)
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
upper = upper[idx+4:]
}
parts := strings.Split(upper, "_")
model := ""
numSuffix := ""
mem := ""
for i, p := range parts {
if p == "" {
continue
}
switch p {
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE":
continue
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
if model != "" {
archAbbr := map[string]string{
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
}
numSuffix += archAbbr[p]
}
continue
default:
if strings.Contains(p, "GB") {
mem = p
continue
}
if model == "" && i > 0 {
model = p
} else if model != "" && numSuffix == "" && isModelNumber(p) {
numSuffix = p
}
}
}
full := model
if numSuffix != "" {
full = model + numSuffix
}
if full != "" && mem != "" {
return full + "_" + mem, true
}
if full != "" {
return full, true
}
return normalizeModelToken(lotName), false
}
// isModelNumber reports whether s looks like a GPU/accelerator model number token:
// it starts with a digit and contains only digits and uppercase letters. This keeps
// vendor-suffixed names like "6000D" (RTX PRO 6000D) intact instead of dropping the
// letter and collapsing two distinct models to the same token.
func isModelNumber(s string) bool {
if s == "" || s[0] < '0' || s[0] > '9' {
return false
}
for _, r := range s {
if (r < '0' || r > '9') && (r < 'A' || r > 'Z') {
return false
}
}
return true
}
func parseMemGiB(lotName string) int {
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1]) * 1024
}
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1])
}
return 0
}
func parseCapacity(lotName string) string {
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
}
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
}
return ""
}
func diskTypeCode(cat string, lotName string) string {
c := strings.ToUpper(strings.TrimSpace(cat))
if c == "M2" {
return "M2"
}
upper := strings.ToUpper(lotName)
if strings.Contains(upper, "NVME") {
return "NV"
}
if strings.Contains(upper, "SAS") {
return "SAS"
}
if strings.Contains(upper, "SATA") {
return "SAT"
}
return c
}
func parsePortSpeed(lotName string) string {
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
return fmt.Sprintf("%sp%sG", m[1], m[2])
}
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
return fmt.Sprintf("%spFC%s", m[1], m[2])
}
return ""
}
func parseWatts(lotName string) string {
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
w := atoi(m[1])
if w >= 1000 {
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
kw = strings.TrimSuffix(kw, ".0")
return fmt.Sprintf("%skW", kw)
}
return fmt.Sprintf("%dW", w)
}
return ""
}
func normalizeNumberToken(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimLeft(raw, "0")
if raw == "" || raw[0] == '.' {
raw = "0" + raw
}
return raw
}
func normalizeTToken(raw string) string {
raw = normalizeNumberToken(raw)
parts := strings.SplitN(raw, ".", 2)
intPart := parts[0]
frac := ""
if len(parts) == 2 {
frac = parts[1]
}
if frac == "" {
frac = "0"
}
if len(intPart) >= 2 {
return intPart + "." + frac
}
if len(frac) > 1 {
frac = frac[:1]
}
return intPart + "." + frac
}
func atoi(v string) int {
n := 0
for _, r := range v {
if r < '0' || r > '9' {
continue
}
n = n*10 + int(r-'0')
}
return n
}
// compressArticle shortens an over-long article by progressively dropping/abbreviating
// segments. It returns the (possibly shortened) segment list; the caller re-joins it.
func compressArticle(segs []namedSeg) []namedSeg {
if len(segs) == 0 {
return segs
}
fits := func() bool {
return len([]rune(strings.Join(namedSegsValues(segs), "-"))) <= 80
}
for i, s := range segs {
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G")
}
if fits() {
return segs
}
// 1) remove PSU
if i := findSegGroup(segs, "PSU"); i >= 0 {
segs = append(segs[:i], segs[i+1:]...)
if fits() {
return segs
}
}
// 2) compress NET/HBA/HCA
if i := findSegGroup(segs, "NET"); i >= 0 {
segs[i].value = compressNetSegment(segs[i].value)
if fits() {
return segs
}
}
// 3) compress DISK
if i := findSegGroup(segs, "DISK"); i >= 0 {
segs[i].value = compressDiskSegment(segs[i].value)
if fits() {
return segs
}
}
// 4) compress GPU to vendor only (GPU_NV)
if i := findSegGroup(segs, "GPU"); i >= 0 {
segs[i].value = compressGPUSegment(segs[i].value)
}
return segs
}
func compressNetSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
profile := p
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
profile = x[1]
}
upper := strings.ToUpper(profile)
label := "NIC"
if strings.Contains(upper, "FC") {
label = "HBA"
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
label = "HCA"
}
out = append(out, fmt.Sprintf("%sx%s", qty, label))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}
func compressDiskSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
spec := p
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
spec = x[1]
}
upper := strings.ToUpper(spec)
label := "DSK"
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
if strings.Contains(upper, t) {
label = t
break
}
}
out = append(out, fmt.Sprintf("%sx%s", qty, label))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}
func compressGPUSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
}
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}