billcost

package
v0.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 4, 2026 License: None detected not legal advice Imports: 0 Imported by: 0

Documentation

Overview

Package billcost models a province-banded shipping cost table and provides a single source of truth for two questions: how much does a parcel cost given the table (CalculateShipCost), and is the table internally consistent (Reflect). Both routines operate on the same Band shape so a parser-LLM, a reflector loop, and a reconciliation pipeline all agree on what a "valid quote" means.

Why a dedicated package rather than carrying these helpers inside the bill-recon platform module: the cost-table shape is generic across logistics carriers (圆通 / 中通 / 韵达 / 京东 / 顺丰), and the same dry-run validator is the natural fit for any structured extraction that reduces to "weight bands per partition" -- think freight, parcel insurance tiers, courier returns, or even non- logistics tiered pricing. Keeping the algorithm + reflector here in core/pkg lets every consumer (bill-recon today, future ERP / CRM extractors) plug into the same contract without copy-paste.

Schema: a Band names a single (province, weight-segment) row in the WMS-style cost table. The Reflect function takes a slice of Bands plus optional strip fees and a return fee, groups by province, and dry-runs CalculateShipCost over weight points derived from the bands themselves (not a fixed grid) -- so any number of bands, any segment lengths, any carrier table layout works. Violations carry the province / band / weight that triggered the rule plus a human-readable detail so an LLM retry loop can correct itself.

Why dry-run rather than a rule list: a rule list would say "limit_top > limit_bottom" without verifying the cost function stays monotonic across segment boundaries. Running the actual calculation across band-derived sample points exercises the data structure as a live program would and catches inversions (e.g. continuation-kg cheaper than first-kg) that a static rule list would miss without an explicit pricing-model rule.

billcost 包对省份-重量段的运费表建模, 同一份 Band 数据结构服务两个 问题: 给定表算单包裹成本是多少 (CalculateShipCost), 以及这张表内部 一致么 (Reflect). 解析 LLM / 反射器循环 / 对账管道全部基于同一 Band 形状, 不会出现 "什么算合法报价" 的多重定义.

为何独立子包而不是放进 bill-recon 平台模块: 运费表形状跨承运商通用 (圆通/中通/韵达/京东/顺丰), 同一套 dry-run 校验亦适用任何 "按维度分 段" 的结构化抽取 -- 货运 / 保险等级 / 退回件 / 甚至非物流的阶梯计价. 算法 + 反射器留在 core/pkg 让每个消费者 (今天的 bill-recon, 未来的 ERP / CRM 抽取) 共享同一契约, 免拷贝.

schema: 一个 Band 命名运费表中一行 (省份 + 重量段). Reflect 接收 Bands + 可选单带费 + 退回费, 按省份 group 后用 bands 自身派生的重量 采样点 (非固定网格) dry-run CalculateShipCost -- 任意段数 / 任意段长 / 任意承运商布局都能跑. Violations 带触发规则的省份/段/重量 + 人类 可读细节, 供 LLM 重试循环自纠.

为何 dry-run 而非规则列表: 规则列表会说 "limit_top > limit_bottom" 但不验证成本函数跨段单调; 在 bands 派生点上实际跑算法把数据结构当 真程序执行, 能抓住静态规则列表必须额外加 "定价模型规则" 才能命中的 反逻辑 (如续重 kg 比首重 kg 更便宜).

Index

Constants

View Source
const (
	RuleCoverage           = "partition_missing"     // 期望分区未出现
	RuleSegmentOrder       = "segment_order"         // 同分区段未升序
	RuleSegmentGap         = "segment_gap"           // 段间缺口
	RuleSegmentOverlap     = "segment_overlap"       // 段间重叠
	RuleSegmentStartZero   = "segment_start_nonzero" // 首段下限非 0
	RuleSegmentEndShort    = "segment_end_short"     // 末段上限不到期望
	RuleMonotonic          = "monotonic"             // 重量增成本降
	RuleFirstLessIncrement = "first_lt_increment"    // 首重单位价 < 续重单位价 (反逻辑, BaseWeight==IncrementWeight 时校验)
	RuleBandIncomplete     = "band_incomplete"       // 段既无 base 也无 increment
	RuleDuplicateBand      = "duplicate_band"        // 同 (Partition, LimitBottom, LimitTop) 出现 ≥2 次
	RuleDuplicateStripFee  = "duplicate_strip_fee"   // 同 (City, LimitBottom, LimitTop) 单带费段出现 ≥2 次
)

Rule IDs. Stable machine keys for Violation grouping and prompt feedback. Keep in sync with the doc table in reflect.go.

规则 ID. Violation 分组与 prompt 反馈用稳定机读键. 与 reflect.go 文档表保持同步.

Variables

This section is empty.

Functions

func CalculateShipCost

func CalculateShipCost(bands []Band, weightG int) (float64, error)

CalculateShipCost computes the cost of shipping a parcel weighing weightG grams using the given province's bands. The bands must already be filtered to a single partition (the function does not inspect Band.Partition); callers that hold a multi-partition slice should group by Partition first.

Algorithm:

  1. Find the band b where b.LimitBottom < weightG <= b.LimitTop.
  2. cost := b.BaseAmount (first-weight flat charge).
  3. If weightG > b.BaseWeight and b.IncrementWeight > 0: extra := weightG - b.BaseWeight units := ceil(extra / b.IncrementWeight) cost += units * b.IncrementPrice

Returns an "uncovered" error when no band covers weightG, which the reflector treats as a coverage / gap rule violation.

CalculateShipCost 给出按 bands 算的运费. bands 必须已过滤到单一 分区 (函数不检查 Band.Partition); 持多分区切片的调用方先按 Partition group.

算法:

  1. 找命中段 b: b.LimitBottom < weightG <= b.LimitTop.
  2. cost := b.BaseAmount (首重一口价).
  3. weightG > b.BaseWeight 且 b.IncrementWeight > 0 时: extra := weightG - b.BaseWeight units := ceil(extra / b.IncrementWeight) cost += units * b.IncrementPrice

无段命中返 "uncovered" 错, 反射器记 coverage / gap 类违规.

Types

type Band

type Band struct {
	// Partition 分区维度的取值, 运费场景下填省份名 (如 "内蒙古" / "广东").
	// Partition is the partition dimension value, e.g. province name
	// for shipping costs ("内蒙古" / "广东").
	Partition string

	// City 城市名. 单带费类 band 必填且通常等于 Partition (单带费天然
	// 按城市绑); 普通运费段空.
	// City is the city name. Required for strip-fee bands and
	// typically equals Partition (strip fees are city-bound by
	// definition); empty for normal shipping bands.
	City string

	// LimitBottom 重量区间下限 (g, 开). LimitBottom < weight <= LimitTop.
	// LimitBottom is the inclusive-exclusive lower bound in grams.
	LimitBottom int

	// LimitTop 重量区间上限 (g, 闭).
	// LimitTop is the closed upper bound in grams.
	LimitTop int

	// BaseWeight 首重 (g). weight <= BaseWeight 时按 BaseAmount 一口价.
	// BaseWeight is the included-weight cap in grams; parcels at or
	// below it pay BaseAmount flat.
	BaseWeight int

	// BaseAmount 首重价 (元).
	// BaseAmount is the flat first-weight charge in yuan.
	BaseAmount float64

	// IncrementWeight 续重单位 (g). 0 表本段无续重 (一口价段).
	// IncrementWeight is the continuation unit in grams; 0 disables
	// the continuation tier (flat-fee band).
	IncrementWeight int

	// IncrementPrice 续重单位价 (元/IncrementWeight).
	// IncrementPrice is the cost per IncrementWeight unit in yuan.
	IncrementPrice float64

	// IsStripFee 单带费旗 (band 表示城市单带费而非省运费段).
	// IsStripFee marks the band as a strip-fee row (city-bound
	// surcharge rather than a province cost segment).
	IsStripFee bool

	// IsDeliveryFee 派送费旗.
	// IsDeliveryFee marks the band as a delivery surcharge.
	IsDeliveryFee bool

	// IsTargetSpecific 仅针对特定网点的调价旗.
	// IsTargetSpecific marks the band as applying only to the
	// listed sites in TargetSites.
	IsTargetSpecific bool

	// TargetSites IsTargetSpecific=true 时生效, 网点代码 (非名称).
	// TargetSites is the list of site codes (not names) the band
	// applies to when IsTargetSpecific is true.
	TargetSites []string
}

Band is a single row of the cost table: one weight segment within one partition (province for shipping, but the type does not bake that semantic). Weights are integer grams throughout the package, matching the WMS shipcostcfg layout where 1kg = 1000g (PM caught limit_top=0/limit_bottom=0 on a kg-encoded earlier draft, hence the explicit grams contract here).

Semantics of the four weight fields:

  • LimitBottom / LimitTop: the open-closed segment this row covers (LimitBottom < weight <= LimitTop). Adjacent bands within a partition must share the boundary value (bands[i].LimitTop == bands[i+1].LimitBottom).
  • BaseWeight + BaseAmount: the included-weight portion. A parcel of weight <= BaseWeight pays BaseAmount flat.
  • IncrementWeight + IncrementPrice: the continuation portion. For weight > BaseWeight, charge ceil((weight - BaseWeight) / IncrementWeight) * IncrementPrice on top of BaseAmount.

Flag fields mark non-WMS-native extensions (single-strip fee, delivery surcharge, target-site-specific adjustment) so a downstream persistence layer can route them to the right table without inspecting the partition string.

Band 是运费表的单行: 单分区 (运费场景下是省份, 但类型本身不固化此 语义) 内一段重量区间. 包内权重统一用整数 g, 对齐 WMS shipcostcfg 布局 (1kg = 1000g). 早期草稿用 kg 编码致 PM 抓到 limit_top=0/ limit_bottom=0 假违规, 此处 grams 契约显式不留歧义.

4 个重量字段语义:

  • LimitBottom / LimitTop: 本行覆盖的左开右闭重量区间 (LimitBottom < weight <= LimitTop). 同分区相邻 band 必须边界 接吻 (bands[i].LimitTop == bands[i+1].LimitBottom).
  • BaseWeight + BaseAmount: 首重段. weight <= BaseWeight 的包裹 按 BaseAmount 一口价计.
  • IncrementWeight + IncrementPrice: 续重段. weight > BaseWeight 时, 在 BaseAmount 之上加 ceil((weight - BaseWeight) / IncrementWeight) * IncrementPrice.

Flag 字段标识非 WMS 原生扩展 (单带费 / 派送费 / 特定网点调价), 让下游持久化层无需解析 Partition 字符串就能分发到正确表.

type Output

type Output struct {
	// Bands 主体: 按分区 (省份) 任意段数的重量段. LLM 自行决定段数
	// 与段位以匹配源表实际形状.
	// Bands is the primary list of (partition, weight-segment) rows.
	// The LLM picks the segment count and boundaries to match the
	// source table's actual shape.
	Bands []Band

	// StripFees 单带费 (城市单带费, 复用 Band 结构 + IsStripFee=true).
	// 与 Bands 同 schema 的好处是反射器规则可统一表达.
	// StripFees lists city-bound strip-fee surcharges as Bands with
	// IsStripFee=true. Sharing the Band schema lets the reflector
	// express rules uniformly.
	StripFees []Band

	// ReturnFee 退回件加收 (无则 nil).
	// ReturnFee is the per-parcel return surcharge, nil when absent.
	ReturnFee *ReturnFee
}

Output is the canonical structured form an LLM extractor must produce after parsing a quote table. Reflect operates directly on this shape so the extractor and the validator agree on schema without an intermediate translation layer (the prior QuoteRow -> QuoteToBundle hop was the layer PM identified as "削弱 LLM 的 意义" since it baked carrier-specific assumptions like 4 mainland bands + 1 continuation row).

Output 是 LLM 解析报价表后必须产出的标准化结构. Reflect 直接对此形 状校验, 抽取与校验共用一份 schema 不经中间翻译层 (此前的 QuoteRow -> QuoteToBundle 一跳就是 PM 指出的 "削弱 LLM 意义" 那层, 因为它 把承运商特化假设 -- 大陆 4 段 + 续重 1 段 -- 写死了).

type ReflectConfig

type ReflectConfig struct {
	// ExpectedPartitions 期望出现的分区集合 (运费场景: 31 省). 空表
	// 跳过覆盖检查.
	// ExpectedPartitions is the set of partitions that must appear.
	// Leave empty to skip the coverage rule.
	ExpectedPartitions []string

	// MaxWeightG 表期望覆盖的最大重量 (g). 末段 LimitTop 必须 >= 此值.
	// 0 跳过此规则.
	// MaxWeightG is the maximum weight (g) the table is expected to
	// cover; the last band's LimitTop must reach it. Zero skips.
	MaxWeightG int
}

ReflectConfig parameterizes the reflector's expectations. All fields are optional; zero values disable the corresponding rule (so a caller that does not require full 31-province coverage can leave ExpectedPartitions empty without generating false positives).

alpha.25 fix #11: business pricing fields (MinTotalCostYuan, MaxTotalCostYuan, MaxReturnFee, CrossBandJumpRatio, KnownCities) were removed. The reflector validates structure + simple sanity logic only and has no carrier context to set those thresholds correctly; pricing-policy validation belongs to the downstream platform layer that knows the carrier identity.

ReflectConfig 参数化反射器的期望. 全字段可选, 零值禁用对应规则 (不要求 31 省全覆盖的调用方留 ExpectedPartitions 空即可避免误报).

alpha.25 修 #11: 业务定价字段 (MinTotalCostYuan / MaxTotalCostYuan / MaxReturnFee / CrossBandJumpRatio / KnownCities) 已删除. 反射器只 做结构 + 简单逻辑校验, 没有承运商上下文设不出正确阈值; 定价策略校验 属于知道承运商身份的下游 platform 层.

type ReflectTool

type ReflectTool struct {
	// Cfg is the ReflectConfig the tool applies on every call. Pass
	// the same config the workflow's reflectQuoteOutput uses so
	// engine-driven and direct-call paths agree.
	//
	// Cfg 工具每次调用使用的 ReflectConfig. 与 workflow.reflectQuoteOutput
	// 的 cfg 同款, 让 engine-driven / 直调两条路径一致.
	Cfg ReflectConfig
}

ReflectTool wraps billcost.Reflect as a tools.Tool so an engine Session can register it and call it via the model's tool-use channel. PM design: the bill-recon main flow is the master agent; quote-table extraction is a sub-tool (LLM call); reflector is another sub-tool (this one). Wrapping Reflect as a Tool lets the future C6+ engine.Session-driven path call it the same way the current direct-call path does, with no schema drift.

ReflectTool 把 billcost.Reflect 包装成 tools.Tool, 让 engine.Session 可注册并经模型 tool-use 通道调用. PM 设计: bill-recon 主流程是主 agent; 报价表抽取是子工具 (LLM 调用); 反射器是另一子工具 (本工具). 包成 Tool 让未来 C6+ engine.Session 驱动路径与当前直调路径用同一 schema, 不漂移.

func (*ReflectTool) Description

func (r *ReflectTool) Description(ctx context.Context) string

Description returns the model-facing help text. The model uses it to decide when to call the tool, so it states the input shape + what a non-empty violations list means + when to call again after fixing.

Description 返回面向模型的帮助文本. 模型据此决定何时调用此工具, 所以描述输入形状 + 非空 violations 的含义 + 修正后何时再调.

func (*ReflectTool) Execute

func (r *ReflectTool) Execute(ctx context.Context, input json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error)

Execute runs Reflect against the parsed output. Returns a tools.Result with structured Data + a human-readable Output. The Output text is what the model sees in the next turn; Data is what any downstream programmatic consumer (engine orchestrator, audit sink) reads.

Execute 跑 Reflect. 返 tools.Result 含结构化 Data + 人类可读 Output. Output 文本是模型下一轮看到的内容; Data 给下游程序消费方 (引擎编排 / audit sink) 读.

func (*ReflectTool) InputSchema

func (r *ReflectTool) InputSchema() json.RawMessage

InputSchema returns the model-facing JSON schema.

InputSchema 返回面向模型的 JSON schema.

func (*ReflectTool) Metadata

func (r *ReflectTool) Metadata() tools.Metadata

Metadata declares the tool as concurrency-safe + read-only + non-destructive. Reflect has no side effects so it satisfies all three. PermissionClass="readonly" lets the engine permission chain admit it without prompting.

Metadata 声明工具并发安全 + 只读 + 非破坏性. Reflect 无副作用全部 满足. PermissionClass="readonly" 让引擎权限链不弹窗.

func (*ReflectTool) Name

func (r *ReflectTool) Name() string

Name returns the stable tool identifier "billcost_reflect".

CLEVER (Bug U, 2026-05-01): underscore separator, not dot. OpenAI function-calling protocol enforces regex ^[a-zA-Z0-9_-]+$ on function names; OpenRouter forwards to underlying providers (SiliconFlow / DeepSeek / etc.) that validate strictly and return HTTP 400 for any dot in the name. Anthropic tool_use is just as strict. Underscore is the canonical separator across the LLM tool-calling ecosystem.

Name 返回稳定工具标识 "billcost_reflect".

CLEVER (Bug U, 2026-05-01): 下划线分隔, 不用点. OpenAI function-calling 协议对 function name 强制 regex ^[a-zA-Z0-9_-]+$; OpenRouter 转给底层 provider (SiliconFlow / DeepSeek 等) 严格校验, dot 立即 HTTP 400. Anthropic tool_use 同款严格. 下划线是 LLM tool-calling 生态通用分隔符.

type ReturnFee

type ReturnFee struct {
	// FeePerItem 每票退回加收 (元).
	// FeePerItem is the per-parcel return surcharge in yuan.
	FeePerItem float64

	// Summary 人类可读摘要 (review / detail UI 显示).
	// Summary is a human-readable line for review and detail UIs.
	Summary string
}

ReturnFee captures the per-parcel return-shipment surcharge that some carriers price separately from the weight bands. WMS does not have a native column for this, so the platform layer stores it in a side table; this type is the canonical shape for both the LLM extractor output and the reflector input.

ReturnFee 描述部分承运商单独定价的退回件加收 (按票计, 不入重量段). WMS 无原生列, 平台层存于独立表; 此类型为 LLM 抽取输出与反射器输入 的共同正本.

type Violation

type Violation struct {
	// RuleID 稳定规则键. 见 rule_id.go 全集常量.
	// RuleID is a stable rule key; see rule_id.go for the full set.
	RuleID string

	// Partition 触发规则的分区 (省份), 跨分区规则可空.
	// Partition is the partition that triggered the rule; empty for
	// cross-partition rules.
	Partition string

	// WeightG 触发规则的具体采样重量 (覆盖/单调/跨段类规则), 0 表
	// 不适用 (静态规则).
	// WeightG is the sample weight that triggered the rule (for
	// coverage / monotonic / cross-band rules); 0 means N/A.
	WeightG int

	// GotCost / PrevCost 单调或跨段规则比较的两个值 (元), 静态规则
	// 留 0.
	// GotCost / PrevCost are the two compared values for monotonic /
	// cross-band rules in yuan; zero for static rules.
	GotCost  float64
	PrevCost float64

	// Detail 人类可读说明, 直接回喂 LLM.
	// Detail is the human-readable explanation fed back to the LLM.
	Detail string
}

Violation is one rule failure tagged with enough context for an LLM retry loop to fix the underlying value. RuleID is a stable machine identifier (e.g. "monotonic", "gap", "first_lt_increment") so the prompt-feedback layer can group / dedupe; Detail is the human-readable text fed back to the LLM.

Violation 是单条规则失败 + 足够上下文供 LLM 重试循环对症修. RuleID 是稳定机读标识 (如 "monotonic" / "gap" / "first_lt_increment") 供 prompt 反馈层分组去重; Detail 是回喂给 LLM 的人类可读说明.

func Reflect

func Reflect(out Output, cfg ReflectConfig) []Violation

Reflect dry-runs the cost table and emits one Violation per rule failure. The reflector groups bands by Partition and runs the per-partition rule set on each group, then runs the cross-cutting coverage rule.

Scope (alpha.25 fix #11): the reflector validates structure + simple sanity logic only. Carrier-specific business pricing rules (per-carrier cost ceilings, cross-band jump tolerance, city whitelists, return-fee caps) were removed because the reflector has no carrier context to set them correctly -- a single hard-coded threshold either false-positives (the alpha.25 cross_band_jump case, raising 30+ violations on real YTO tables) or fails to catch per-carrier outliers. PM ruled the reflector should stay schema-and-shape only; pricing-policy validation belongs to the downstream platform layer that knows the carrier.

Per-partition rules (in order):

segment_order            — adjacent bands sorted by LimitBottom
segment_gap              — bands[i].LimitTop == bands[i+1].LimitBottom
segment_overlap          — same key, but raised when LimitBottom < prev.LimitTop
segment_start_nonzero    — bands[0].LimitBottom == 0
segment_end_short        — bands[len-1].LimitTop >= cfg.MaxWeightG
monotonic                — cost(w) non-decreasing across band-derived sample weights
first_lt_increment       — for any band where BaseWeight == IncrementWeight: BaseAmount/BaseWeight >= IncrementPrice/IncrementWeight (per-gram)
band_incomplete          — each band has at least one of (BaseAmount > 0, IncrementPrice > 0)

Cross-cutting rules:

partition_missing        — every cfg.ExpectedPartitions value appears in Bands

Sample weights are derived from each band's own boundaries (LimitBottom+1, BaseWeight, BaseWeight+1, LimitTop) so any band count and any segment length is covered without a fixed grid.

Reflect dry-run 跑成本表, 每条规则失败 emit 一条 Violation. 按 Partition group bands 跑分区内规则集, 再跑跨规则 (分区覆盖).

范围 (alpha.25 修 #11): 反射器只做结构 + 简单逻辑校验, 不假定承运商 业务定价规则 (单价上限 / 跨段跳价容忍 / 城市白名单 / 退回费上限). 反射器无承运商上下文, 单一阈值要么误报 (alpha.25 cross_band_jump 在 圆通真实表 30+ 误报), 要么漏掉单承运商离群. PM 拍板反射器只看 schema 和 shape, 定价策略校验属于知道承运商身份的下游 platform 层.

分区内规则 (顺序见上方英文注释表). 跨规则同上.

采样重量从 band 自身边界派生 (LimitBottom+1 / BaseWeight / BaseWeight+1 / LimitTop), 任意段数 / 任意段长都覆盖, 不用固定网格.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL