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/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 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"