From 570bcba8b9d520e3f41d2d1faadfd7876cd16770 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Mon, 13 Jul 2026 18:39:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ext-data):=20List=E2=86=92string=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9B=B4=E6=8E=A5=20cast=20=E6=8A=A5?= =?UTF-8?q?=E9=94=99=20=E2=80=94=20=E5=85=88=20join=20=E5=86=8D=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手动创建的扩展配置拉取 THS 概念接口时, concepts 列为 List(String), 声明为 string 后 cast_df_to_schema 直接 cast(pl.Utf8) 抛 「cannot cast List type (inner: String, to: String)」。 cast_df_to_schema 检测到源列是 List 且目标为 Utf8 时, 先把元素 转字符串再以分号拼接 (与 _flatten_concept_rows 一致), 然后再 cast。 保护任意返回数组字段的配置, 不再依赖预设 id 走 flatten 分支。 --- backend/app/services/ext_data.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py index 86af142..729558c 100644 --- a/backend/app/services/ext_data.py +++ b/backend/app/services/ext_data.py @@ -451,10 +451,23 @@ def parse_upload_file(file_path: Path, symbol_col: str = "symbol", data_dir: Pat def cast_df_to_schema(df: pl.DataFrame, fields: list[ExtField]) -> pl.DataFrame: - """按配置的字段类型转换 DataFrame 列类型。""" + """按配置的字段类型转换 DataFrame 列类型。 + + List → string 的处理: 上游接口常返回数组字段 (如 concepts: ["AI", "芯片"]), + 若声明为 string, 直接 cast 会抛 `cannot cast List type`。 + 这里把列表元素先转字符串再以分号拼接, 与 _flatten_concept_rows 保持一致。 + """ + schema = df.schema for f in fields: - if f.name in df.columns: - target = _POLARS_DTYPE_MAP.get(f.dtype, pl.Utf8) + if f.name not in df.columns: + continue + target = _POLARS_DTYPE_MAP.get(f.dtype, pl.Utf8) + src = schema[f.name] + if isinstance(src, pl.List) and target == pl.Utf8: + df = df.with_columns( + pl.col(f.name).cast(pl.List(pl.Utf8)).list.join(";").cast(target) + ) + else: df = df.with_columns(pl.col(f.name).cast(target)) return df From 7062dcd94d759e97f8c1b607e7bbeb087af21677 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Mon, 13 Jul 2026 18:39:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(strategy):=20AI=20=E7=94=9F=E6=88=90=20?= =?UTF-8?q?META:=20dict=20=3D=20{...}=20=E6=97=B6=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=E6=8A=A5=E9=94=99=E3=80=8C=E6=89=BE=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=20META=20=E5=AD=97=E5=85=B8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM 常给 META 加类型注解 (ast.AnnAssign 节点), 旧版 _find_meta_dict 只遍历 ast.Assign 漏掉注解形式 → 抛「找不到 META 字典」→ 上层包成 「规范化 META 失败」。校验 (_extract_meta) 与规范化 (_find_meta_dict) 用两套不一致逻辑, 导致「校验通过、规范化失败」。 两个函数统一增加 ast.AnnAssign 分支, 消除不一致。补充 4 个回归测试 覆盖注解形式 (含端到端 _normalize_build_result 路径)。 修复后无论哪台机器、哪个模型生成都不再触发该错误。 --- backend/app/api/strategy.py | 16 ++++- backend/app/strategy/ai_generator.py | 21 +++++-- .../tests/test_ai_strategy_meta_normalize.py | 62 +++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 3886c0f..67a2ce7 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -313,14 +313,24 @@ def _py_string(value: str) -> str: def _find_meta_dict(code: str) -> ast.Dict: + # 兼容两种 LLM 常见写法: + # META = {...} → ast.Assign + # META: dict = {...} → ast.AnnAssign (类型注解, 合法但旧逻辑漏匹配) tree = ast.parse(code) for node in ast.walk(tree): + value = None if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "META": - if not isinstance(node.value, ast.Dict): - raise ValueError("META 必须是字面量字典") - return node.value + value = node.value + break + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) \ + and node.target.id == "META": + value = node.value + if value is not None: + if not isinstance(value, ast.Dict): + raise ValueError("META 必须是字面量字典") + return value raise ValueError("找不到 META 字典") diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py index dd2bd63..6f9429f 100644 --- a/backend/app/strategy/ai_generator.py +++ b/backend/app/strategy/ai_generator.py @@ -144,14 +144,25 @@ class AIStrategyGenerator: @staticmethod def _extract_meta(code: str) -> dict: - """从代码字符串中提取 META 字典(不执行代码, 仅接受字面量)""" + """从代码字符串中提取 META 字典(不执行代码, 仅接受字面量) + + 兼容两种声明: META = {...} (Assign) 和 META: dict = {...} (AnnAssign)。 + 与 api.strategy._find_meta_dict 保持同一套匹配逻辑。 + """ tree = ast.parse(code) for node in ast.walk(tree): + value = None if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "META": - try: - return ast.literal_eval(node.value) - except (ValueError, SyntaxError) as e: - raise ValueError(f"META 必须是纯字面量字典: {e}") from e + value = node.value + break + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) \ + and node.target.id == "META": + value = node.value + if value is not None: + try: + return ast.literal_eval(value) + except (ValueError, SyntaxError) as e: + raise ValueError(f"META 必须是纯字面量字典: {e}") from e return {} diff --git a/backend/tests/test_ai_strategy_meta_normalize.py b/backend/tests/test_ai_strategy_meta_normalize.py index d6ad8a7..16993e5 100644 --- a/backend/tests/test_ai_strategy_meta_normalize.py +++ b/backend/tests/test_ai_strategy_meta_normalize.py @@ -76,3 +76,65 @@ def filter(df: pl.DataFrame, params: dict) -> pl.Expr: assert '"id": "ai_inserted"' in code assert '"name": "中文名"' in code assert '"description": "描述"' in code + + +# --- 回归: LLM 偏移写法 ------------------------------------------------- +# 模型常给 META 加类型注解 (META: dict = {...}, ast.AnnAssign 节点)。 +# 旧版匹配器只遍历 ast.Assign, 漏掉注解形式 → 报「找不到 META 字典」。 + +ANNOTATED_CODE = '''"""模型返回的策略 (带类型注解的 META — LLM 常见偏移)""" +import polars as pl + +META: dict = { + "id": "annotated_wrong_id", + "name": "Placeholder", + "description": "model desc", + "tags": ["AI"], + "params": [], + "scoring": {}, +} + +def filter(df: pl.DataFrame, params: dict) -> pl.Expr: + return pl.lit(True) +''' + + +def test_find_meta_dict_accepts_type_annotated_form(): + """META: dict = {...} 必须能被识别 (旧版会抛「找不到 META 字典」)。""" + from app.api.strategy import _find_meta_dict + + node = _find_meta_dict(ANNOTATED_CODE) + assert node is not None # 能找到就说明没抛异常 + + +def test_extract_meta_accepts_type_annotated_form(): + from app.strategy.ai_generator import AIStrategyGenerator + + meta = AIStrategyGenerator._extract_meta(ANNOTATED_CODE) + assert meta["id"] == "annotated_wrong_id" + assert meta["name"] == "Placeholder" + + +def test_normalize_strategy_meta_works_on_annotated_form(): + """端到端: AI 生成注解形式 META 时, 规范化不再报「规范化 META 失败」。""" + code = _normalize_strategy_meta( + ANNOTATED_CODE, + "ai_annotated_ok", + name="断板反包", + description="中文描述", + ) + assert '"id": "ai_annotated_ok"' in code + assert '"name": "断板反包"' in code + assert '"description": "中文描述"' in code + assert "annotated_wrong_id" not in code + + +def test_normalize_build_result_succeeds_on_annotated_form(): + """模拟前端 /build/stream 的完整结果路径 (之前报错的入口)。""" + result = {"code": ANNOTATED_CODE, "meta": {}, "valid": True, "error": None} + + normalized = _normalize_build_result(result, "ai_build_ok") + + assert normalized["valid"] is True + assert normalized["error"] is None + assert normalized["meta"]["id"] == "ai_build_ok"