# -*- coding: utf-8 -*- """Ti-2 配方动态优化 · 优化问题建模(约束/目标定义)(Issue #78 / PRD 5.3 ②)。 承接 PRD 5.3「② 配方动态优化」与超参包示例(``objective`` / ``features`` / ``target``):把"给定质量目标 + 工艺约束,求最优配方/参数"这条链路**模板化、 可配置、可测试**,且与 #79 求解器、#80 跨工序寻优、#81 可解释建议解耦。 PRD 设计口径 ------------ - 架构表(PRD §5.3):``工艺优化/配方推荐 | 优化/推荐 | 入:质量目标+约束; 出:参数/配方建议 | ② 配方动态优化 | 高(需闭环反馈)``。 - 模板化技术路径:超参包驱动——``objective``、输入特征清单、``target`` 等可变量 外置为 JSON 超参包,切换模板仅改此包;跨行业差异落资产,不落代码。 - 风险表:二期交付(一期数据门槛不足),故本期**先把问题建模沉淀为可校验的声明 式规格**,为 #79 求解器、#80 跨工序寻优、#81 可解释建议提供**统一的问题描述 契约**;先有"能跑通、可测试"的模型,数据就绪后接求解器(#79/#80)。 本模块交付 ---------- 1. **决策变量 ``DecisionVariable``**:配方/工艺可调参数的声明式规格——变量名、 单位、取值域(``Bounds`` 连续区间 / ``Choices`` 离散枚举)、初值、是否整型、 工艺含义(``meaning``,供 #81 可解释建议引用)。 2. **目标函数规格 ``ObjectiveSpec``**:``Sense``(minimize/maximize)+ 目标项 (``ObjectiveTerm``:系数 × 变量,线性目标)+ 目标 ``target``(PRD 超参包字段)。 3. **约束规格 ``ConstraintSpec``**:``ConstraintKind``(box / linear / ratio / forbidden)统一描述工艺约束(温度上下限、配方配比、禁止组合等)。 4. **问题模型 ``OptimizationProblem``**:聚合变量 + 目标 + 约束,提供校验 (``validate``,聚合并列出全部错误,便于配置台一次性反馈)、声明式加载 (零第三方依赖 YAML 子集解析,与 data-bus/rag-kb/impurity-forecast 同款)。 设计要点 -------- - **零运行时依赖**(纯标准库):与内核既有模块一致,便于离线/隔离网部署。 - **求解器无关**:本模块只描述"问题",``solve`` 留给 #79 注入;便于换行业复用、 单测无需真实求解器。 - **可解释前置**:变量 ``meaning`` + 约束 ``reason`` 字段,为 #81 优化建议"可溯源" 预留引用依据(对齐 PRD"要求结果可解释、可溯源,要引用依据")。 """ from __future__ import annotations import math import os from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional, Sequence, Tuple, Union # 缺失值统一用 float('nan'),与 impurity-forecast 一致,便于上层判空屏蔽。 NAN = float("nan") class ProblemError(ValueError): """配方优化问题建模错误(未知变量 / 越界 / 约束矛盾 / 重复定义等)。""" # --------------------------------------------------------------------------- # 决策变量 # --------------------------------------------------------------------------- class DomainKind(str, Enum): """决策变量取值域类型。""" BOUNDS = "bounds" # 连续区间 [low, high](如温度 800~900℃) CHOICES = "choices" # 离散枚举(如催化剂型号 A/B/C) @dataclass class DecisionVariable: """一个可调配方/工艺参数的声明式规格。 ``bounds`` 与 ``choices`` 二选一(由 ``kind`` 决定): - ``bounds``:``[low, high]``,``integer=True`` 时取整; - ``choices``:离散可选值列表(任意可比较的标量,多为 float/str)。 """ name: str kind: DomainKind meaning: str = "" # 工艺含义,供 #81 可解释建议引用 unit: str = "" # 单位(℃、m³/h、kg、…) bounds: Optional[Tuple[float, float]] = None choices: Optional[List[Any]] = None initial: Optional[float] = None # 当前工况/配方初值 integer: bool = False # 仅 bounds 连续域生效 def __post_init__(self) -> None: if not self.name or not str(self.name).strip(): raise ProblemError("DecisionVariable.name 不能为空") if self.kind == DomainKind.BOUNDS: if self.bounds is None: raise ProblemError(f"变量 {self.name!r} kind=bounds 但未提供 bounds") low, high = self.bounds if _is_num(low) and _is_num(high) and low > high: raise ProblemError( f"变量 {self.name!r} bounds 下界 {low} 大于上界 {high}") if self.integer and self.bounds is not None: low, high = self.bounds if _is_num(low) and float(low).is_integer() is False: raise ProblemError( f"变量 {self.name!r} integer=True 但下界 {low} 非整") if _is_num(high) and float(high).is_integer() is False: raise ProblemError( f"变量 {self.name!r} integer=True 但上界 {high} 非整") elif self.kind == DomainKind.CHOICES: if not self.choices: raise ProblemError(f"变量 {self.name!r} kind=choices 但 choices 为空") else: # pragma: no cover - 枚举穷尽 raise ProblemError(f"变量 {self.name!r} 未知 kind={self.kind!r}") def contains(self, value: Any) -> bool: """取值是否落在该变量合法域内。""" if self.kind == DomainKind.BOUNDS and self.bounds is not None: if not _is_num(value): return False low, high = self.bounds if self.integer and float(value).is_integer() is False: return False return low <= value <= high # choices return value in (self.choices or []) def clamp(self, value: Any) -> Any: """把越界的连续域取值夹回合法区间(离散域不夹,原值返回)。""" if self.kind == DomainKind.BOUNDS and self.bounds is not None and _is_num(value): low, high = self.bounds value = max(low, min(high, value)) if self.integer: value = float(round(value)) return value return value def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = { "name": self.name, "kind": self.kind.value, "meaning": self.meaning, "unit": self.unit, "integer": self.integer, } if self.kind == DomainKind.BOUNDS: d["bounds"] = list(self.bounds) if self.bounds else None else: d["choices"] = list(self.choices) if self.choices else None if self.initial is not None: d["initial"] = self.initial return d @classmethod def from_dict(cls, d: Dict[str, Any]) -> "DecisionVariable": name = d.get("name") if not isinstance(name, str): raise ProblemError("DecisionVariable 缺少 name 字段") kind_raw = d.get("kind", "bounds") try: kind = DomainKind(str(kind_raw)) except ValueError as e: raise ProblemError(f"变量 {name!r} 未知 kind={kind_raw!r}") from e bounds = d.get("bounds") choices = d.get("choices") if kind == DomainKind.BOUNDS and bounds is not None: if (not isinstance(bounds, (list, tuple))) or len(bounds) != 2: raise ProblemError(f"变量 {name!r} bounds 必须是 [low, high]") bounds = (float(bounds[0]), float(bounds[1])) if kind == DomainKind.CHOICES and choices is not None: choices = list(choices) return cls( name=name, kind=kind, meaning=str(d.get("meaning", "")), unit=str(d.get("unit", "")), bounds=bounds, choices=choices, initial=d.get("initial"), integer=bool(d.get("integer", False)), ) # --------------------------------------------------------------------------- # 目标函数 # --------------------------------------------------------------------------- class Sense(str, Enum): """优化方向。""" MINIMIZE = "minimize" MAXIMIZE = "maximize" @property def label(self) -> str: return {Sense.MINIMIZE: "最小化", Sense.MAXIMIZE: "最大化"}[self] @dataclass class ObjectiveTerm: """线性目标项:``coefficient * variable``(变量名引用 ``DecisionVariable.name``)。""" variable: str coefficient: float = 1.0 def to_dict(self) -> Dict[str, Any]: return {"variable": self.variable, "coefficient": self.coefficient} @classmethod def from_dict(cls, d: Dict[str, Any]) -> "ObjectiveTerm": if "variable" not in d: raise ProblemError("ObjectiveTerm 缺少 variable 字段") return cls(variable=str(d["variable"]), coefficient=float(d.get("coefficient", 1.0))) @dataclass class ObjectiveSpec: """目标函数声明式规格(线性加权,对齐 PRD 超参包 ``objective`` 字段)。 形如 ``sense(coef1*var1 + coef2*var2 + ...)``,目标质量 ``target`` 为达标量 (如 ``Ti_purity ≥ 99.5%`` 中的 99.5),仅记录、不参与求解,供 #81 可解释。 """ sense: Sense = Sense.MAXIMIZE terms: List[ObjectiveTerm] = field(default_factory=list) target: Optional[str] = None # PRD 超参包 ``target``:如 "Ti_purity" target_value: Optional[float] = None # 达标量(可选) description: str = "" def evaluate(self, assignment: Dict[str, float]) -> float: """给定一组变量取值,计算目标函数值(未知变量按 0 计)。""" total = 0.0 for t in self.terms: v = assignment.get(t.variable) if _is_num(v): total += t.coefficient * v return total def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = { "sense": self.sense.value, "terms": [t.to_dict() for t in self.terms], } if self.target is not None: d["target"] = self.target if self.target_value is not None: d["target_value"] = self.target_value if self.description: d["description"] = self.description return d @classmethod def from_dict(cls, d: Dict[str, Any]) -> "ObjectiveSpec": sense_raw = d.get("sense", "maximize") try: sense = Sense(str(sense_raw)) except ValueError as e: raise ProblemError(f"未知 sense={sense_raw!r}") from e terms = [ObjectiveTerm.from_dict(t) for t in d.get("terms", [])] tv = d.get("target_value") return cls( sense=sense, terms=terms, target=d.get("target"), target_value=float(tv) if _is_num(tv) else None, description=str(d.get("description", "")), ) # --------------------------------------------------------------------------- # 约束 # --------------------------------------------------------------------------- class ConstraintKind(str, Enum): """约束类型(统一描述常见工艺约束)。""" BOX = "box" # 变量上下界(冗余于 DecisionVariable.bounds,供"运行期收紧") LINEAR = "linear" # 线性不等式 Σ a_i*x_i (/>=) b RATIO = "ratio" # 配比约束:x_a / x_b (op) value FORBIDDEN = "forbidden" # 禁止组合:若干变量取值组合不允许 _LINEAR_OPS = {"<", "<=", ">", ">=", "==", "!="} @dataclass class ConstraintSpec: """约束声明式规格。 每条约束带 ``reason``(工艺依据,供 #81 可解释建议"可溯源、引用依据")。 """ kind: ConstraintKind reason: str = "" # box variable: Optional[str] = None bounds: Optional[Tuple[float, float]] = None # linear coefficients: Optional[Dict[str, float]] = None op: str = "<=" rhs: float = 0.0 # ratio numerator: Optional[str] = None denominator: Optional[str] = None value: float = 0.0 # forbidden combination: Optional[Dict[str, Any]] = None def __post_init__(self) -> None: if self.kind == ConstraintKind.LINEAR and self.op not in _LINEAR_OPS: raise ProblemError(f"线性约束非法 op={self.op!r}") if self.kind == ConstraintKind.LINEAR and not self.coefficients: raise ProblemError("线性约束 coefficients 不能为空") # ---- 校验(返回错误信息列表,不抛异常,便于聚合) ------------------ def errors_against(self, variables: Dict[str, DecisionVariable]) -> List[str]: """对该约束引用的变量是否存在做静态校验,返回错误信息列表。""" errs: List[str] = [] if self.kind == ConstraintKind.BOX: if not self.variable: errs.append("box 约束缺少 variable") elif self.variable not in variables: errs.append(f"box 约束引用未知变量 {self.variable!r}") elif self.kind == ConstraintKind.LINEAR: for vname in (self.coefficients or {}): if vname not in variables: errs.append(f"线性约束引用未知变量 {vname!r}") elif self.kind == ConstraintKind.RATIO: for fld, vname in (("numerator", self.numerator), ("denominator", self.denominator)): if not vname: errs.append(f"ratio 约束缺少 {fld}") elif vname not in variables: errs.append(f"ratio 约束引用未知变量 {vname!r}") elif self.kind == ConstraintKind.FORBIDDEN: for vname in (self.combination or {}): if vname not in variables: errs.append(f"forbidden 约束引用未知变量 {vname!r}") return errs # ---- 可行性判定(给定取值,判断该约束是否满足) -------------------- def satisfied_by(self, assignment: Dict[str, Any]) -> bool: """给定一组变量取值,判断该约束是否被满足(未知变量视为未约束)。""" if self.kind == ConstraintKind.BOX and self.bounds is not None and self.variable: v = assignment.get(self.variable) if not _is_num(v): return True # 未知取值不判 low, high = self.bounds return low <= v <= high if self.kind == ConstraintKind.LINEAR and self.coefficients: total = 0.0 unknown = False for vname, coef in self.coefficients.items(): v = assignment.get(vname) if not _is_num(v): unknown = True break total += coef * v if unknown: return True return _apply_op(total, self.op, self.rhs) if self.kind == ConstraintKind.RATIO and self.numerator and self.denominator: a = assignment.get(self.numerator) b = assignment.get(self.denominator) if not _is_num(a) or not _is_num(b) or b == 0: return True return _apply_op(a / b, self.op, self.value) if self.kind == ConstraintKind.FORBIDDEN and self.combination: # 组合中每个键值都命中才算"禁止组合"被触发 for vname, want in self.combination.items(): if assignment.get(vname) != want: return True return False return True def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = {"kind": self.kind.value} if self.reason: d["reason"] = self.reason if self.kind == ConstraintKind.BOX: d["variable"] = self.variable d["bounds"] = list(self.bounds) if self.bounds else None elif self.kind == ConstraintKind.LINEAR: d["coefficients"] = dict(self.coefficients or {}) d["op"] = self.op d["rhs"] = self.rhs elif self.kind == ConstraintKind.RATIO: d["numerator"] = self.numerator d["denominator"] = self.denominator d["op"] = self.op d["value"] = self.value elif self.kind == ConstraintKind.FORBIDDEN: d["combination"] = dict(self.combination or {}) return d @classmethod def from_dict(cls, d: Dict[str, Any]) -> "ConstraintSpec": kind_raw = d.get("kind") try: kind = ConstraintKind(str(kind_raw)) except ValueError as e: raise ProblemError(f"未知约束 kind={kind_raw!r}") from e bounds = d.get("bounds") if kind == ConstraintKind.BOX and bounds is not None: bounds = (float(bounds[0]), float(bounds[1])) coefs = d.get("coefficients") if coefs is not None: coefs = {k: float(v) for k, v in coefs.items()} return cls( kind=kind, reason=str(d.get("reason", "")), variable=d.get("variable"), bounds=bounds, coefficients=coefs, op=str(d.get("op", "<=")), rhs=float(d.get("rhs", 0.0)), numerator=d.get("numerator"), denominator=d.get("denominator"), value=float(d.get("value", 0.0)), combination=d.get("combination"), ) # --------------------------------------------------------------------------- # 优化问题 # --------------------------------------------------------------------------- def objective_factory() -> ObjectiveSpec: """dataclass 默认值工厂:空目标(最大化、无项)。""" return ObjectiveSpec(sense=Sense.MAXIMIZE) @dataclass class OptimizationProblem: """配方优化问题模型(变量 + 目标 + 约束),求解器无关。 设计为「先建模、后求解」:``validate`` 做静态一致性校验(变量引用、域完整性), ``is_feasible`` 做取值可行性判定(运行期收紧约束 / 禁止组合),``solve`` 留给 #79 注入求解器,本模块不绑任何优化库。 """ variables: List[DecisionVariable] = field(default_factory=list) objective: ObjectiveSpec = field(default_factory=objective_factory) constraints: List[ConstraintSpec] = field(default_factory=list) problem_id: str = "" template: str = "" # 如 "iAOP-Template-Ti" description: str = "" # ---- 变量索引 ---------------------------------------------------- @property def variable_map(self) -> Dict[str, DecisionVariable]: return {v.name: v for v in self.variables} # ---- 校验 -------------------------------------------------------- def validate(self) -> List[str]: """聚合所有静态错误,返回错误信息列表(空列表表示通过)。""" errs: List[str] = [] seen: set = set() for v in self.variables: if v.name in seen: errs.append(f"重复定义变量 {v.name!r}") seen.add(v.name) vmap = self.variable_map for t in self.objective.terms: if t.variable not in vmap: errs.append(f"目标项引用未知变量 {t.variable!r}") for i, c in enumerate(self.constraints): for e in c.errors_against(vmap): errs.append(f"约束 #{i} ({c.kind.value}): {e}") if self.objective.terms and not any( t.variable in vmap for t in self.objective.terms ): errs.append("目标函数所有项均引用未知变量") return errs # ---- 可行性判定 -------------------------------------------------- def is_feasible(self, assignment: Dict[str, Any]) -> bool: """给定一组变量取值,判断是否满足全部约束与变量域。""" vmap = self.variable_map for name, val in assignment.items(): v = vmap.get(name) if v is not None and not v.contains(val): return False return all(c.satisfied_by(assignment) for c in self.constraints) def violated_constraints(self, assignment: Dict[str, Any]) -> List[ConstraintSpec]: """返回被该取值违反的约束列表(供 #81 可解释建议引用依据)。""" return [c for c in self.constraints if not c.satisfied_by(assignment)] # ---- 序列化 ------------------------------------------------------ def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = { "variables": [v.to_dict() for v in self.variables], "objective": self.objective.to_dict(), "constraints": [c.to_dict() for c in self.constraints], } if self.problem_id: d["problem_id"] = self.problem_id if self.template: d["template"] = self.template if self.description: d["description"] = self.description return d @classmethod def from_dict(cls, d: Dict[str, Any]) -> "OptimizationProblem": return cls( variables=[DecisionVariable.from_dict(v) for v in d.get("variables", [])], objective=ObjectiveSpec.from_dict(d.get("objective", {})), constraints=[ConstraintSpec.from_dict(c) for c in d.get("constraints", [])], problem_id=str(d.get("problem_id", "")), template=str(d.get("template", "")), description=str(d.get("description", "")), ) # --------------------------------------------------------------------------- # 辅助函数 # --------------------------------------------------------------------------- def _is_num(x: object) -> bool: return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x)) def _apply_op(left: float, op: str, right: float) -> bool: """应用比较算子(线性/配比约束共用)。""" if op == "<": return left < right if op == "<=": return left <= right if op == ">": return left > right if op == ">=": return left >= right if op == "==": return abs(left - right) < 1e-12 if op == "!=": return abs(left - right) >= 1e-12 return False # pragma: no cover # --------------------------------------------------------------------------- # 零依赖 YAML 子集加载(与 data-bus / rag-kb / impurity-forecast 同款) # --------------------------------------------------------------------------- def load_problem(path: str) -> OptimizationProblem: """从声明式模板资产(YAML 子集)加载优化问题。 解析支持:缩进块、``key: value``、``- item``、行内 ``# 注释``、字符串/数字/ 布尔、内联 ``[a, b]`` 列表与 ``{a: 1}`` 映射。足以覆盖本模板资产格式; 不引入第三方依赖,与内核既有模块一致。 """ with open(path, "r", encoding="utf-8") as fh: text = fh.read() data = _parse_yaml_subset(text) if not isinstance(data, dict): raise ProblemError(f"模板 {path} 顶层应为映射") return OptimizationProblem.from_dict(data) def _parse_yaml_subset(text: str) -> Any: """极简 YAML 子集解析器(仅供模板资产,非通用 YAML)。""" # 去注释 + 去尾部空白,保留缩进 lines: List[str] = [] for raw in text.splitlines(): # 行内注释:仅在 "# " 前不是值的一部分时剥离;这里取保守策略——行首/值后 # 的 " #" 视为注释。冒号/方括号内的 # 不处理。 stripped = raw.rstrip() if not stripped.strip(): continue # 简单注释行 if stripped.lstrip().startswith("#"): continue # 去行尾注释(" #" 形式) hash_idx = _find_inline_comment(stripped) if hash_idx is not None: stripped = stripped[:hash_idx].rstrip() if stripped: lines.append(stripped) parser = _YamlParser(lines) return parser.parse_block(0)[0] if lines else {} def _find_inline_comment(line: str) -> Optional[int]: """返回行内注释 ``#`` 的索引(无则 None),跳过 ``[...]``/``{...}`` 内的 #。""" depth = 0 in_str = False for i, ch in enumerate(line): if ch == '"': in_str = not in_str elif not in_str: if ch in "[{": depth += 1 elif ch in "]}": depth = max(0, depth - 1) elif ch == "#" and depth == 0 and i > 0 and line[i - 1] in (" ", "\t"): return i elif ch == "#" and depth == 0 and i == 0: return i return None class _YamlParser: """递归下降的 YAML 子集解析器(按缩进分层)。""" def __init__(self, lines: List[str]) -> None: self.lines = lines self.i = 0 def _indent(self, line: str) -> int: return len(line) - len(line.lstrip(" ")) def parse_block(self, indent: int) -> Tuple[Any, bool]: """解析当前缩进层级的一个块,返回 (value, is_list_marker)。""" if self.i >= len(self.lines): return {}, False line = self.lines[self.i] cur_indent = self._indent(line) if cur_indent < indent: return {}, False stripped = line.strip() if stripped.startswith("- ") or stripped == "-": return self._parse_list(cur_indent), True return self._parse_mapping(cur_indent), False def _parse_mapping(self, indent: int) -> Dict[str, Any]: result: Dict[str, Any] = {} # 实际子键缩进可能 > indent(如 "- key: v" 后 4 空格键、项缩进 2)。 # 用首行真实缩进对齐,避免误把合法子键当"孤立缩进"跳过。 effective = indent if self.i < len(self.lines): first = self._indent(self.lines[self.i]) if first > indent: effective = first while self.i < len(self.lines): line = self.lines[self.i] cur = self._indent(line) if cur < effective: break if cur > effective: # 跳过孤立缩进(不应出现,保守跳过) self.i += 1 continue stripped = line.strip() if stripped.startswith("- "): break key, sep, rest = stripped.partition(":") if not sep: self.i += 1 continue key = key.strip() rest = rest.strip() self.i += 1 if rest: result[key] = _parse_scalar(rest) else: # 子块:用首行真实缩进解析(列表或映射),兼容 4 空格子键等 if self.i < len(self.lines) and self._indent(self.lines[self.i]) > effective: child_indent = self._indent(self.lines[self.i]) val, _ = self.parse_block(child_indent) result[key] = val else: result[key] = None return result def _parse_list(self, indent: int) -> List[Any]: result: List[Any] = [] while self.i < len(self.lines): line = self.lines[self.i] cur = self._indent(line) if cur < indent: break if cur > indent: self.i += 1 continue stripped = line.strip() if not stripped.startswith("-"): break item_text = stripped[1:].strip() self.i += 1 # 后续更深缩进的行是否归属本项 if self.i < len(self.lines): child_indent = self._indent(self.lines[self.i]) else: child_indent = cur has_deeper = child_indent > cur if item_text: # 可能是 "- key: value"(映射项)或 "- 标量" if ":" in item_text and not item_text.startswith("["): # 单行映射项的首键 key, sep, rest = item_text.partition(":") kval = _parse_scalar(rest.strip()) if rest.strip() else None if has_deeper: # 把首键与后续子块合并:先解析子块,再把首键塞入 sub, _ = self.parse_block(child_indent) item: Dict[str, Any] = sub if isinstance(sub, dict) else {} item[key.strip()] = kval else: item = {key.strip(): kval} result.append(item) else: if has_deeper: # 标量头 + 子块(本模板未使用,保守取子块) sub, _ = self.parse_block(child_indent) result.append(sub) else: result.append(_parse_scalar(item_text)) else: # "- " 后跟子块 if has_deeper: sub, _ = self.parse_block(child_indent) result.append(sub) return result def _parse_scalar(text: str) -> Any: """解析标量:数字/布尔/字符串/内联列表/内联映射。""" text = text.strip() if not text: return "" # 内联列表 if text.startswith("[") and text.endswith("]"): inner = text[1:-1].strip() if not inner: return [] return [_parse_scalar(part.strip()) for part in _split_top(inner, ",")] # 内联映射 if text.startswith("{") and text.endswith("}"): inner = text[1:-1].strip() if not inner: return {} out: Dict[str, Any] = {} for part in _split_top(inner, ","): k, sep, v = part.partition(":") if sep: out[k.strip()] = _parse_scalar(v.strip()) return out low = text.lower() if low == "true": return True if low == "false": return False if low in ("null", "none", "~"): return None # 数字 try: if "." in text or "e" in low: return float(text) return int(text) except ValueError: # 去引号 if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]: return text[1:-1] return text def _split_top(text: str, sep: str) -> List[str]: """按分隔符切分顶层(跳过 []/{} 内的)。""" parts: List[str] = [] depth = 0 cur: List[str] = [] in_str = False for ch in text: if ch == '"': in_str = not in_str cur.append(ch) elif not in_str and ch in "[{": depth += 1 cur.append(ch) elif not in_str and ch in "]}": depth = max(0, depth - 1) cur.append(ch) elif ch == sep and depth == 0: parts.append("".join(cur)) cur = [] else: cur.append(ch) if cur: parts.append("".join(cur)) return parts