"""Persist caller-authored templates with frozen local research evidence.""" import math from sqlalchemy import select from ..models import BacktestItem, BacktestResult, ResearchAsset from ..research.assets import Assets from ..research.workspace_contracts import AssetWrite from .queries import item_summary async def create_template(db, args, principal, *, asset_id=None): """Save a new asset or revision inside the caller's account-locked, idempotent transaction. Args contain the external model's TemplateSpec and local source item IDs. Return the versioned asset and theoretical combination count. Raise ResearchError for name conflicts or missing/incomplete research evidence. Stored evidence proves provenance, not profitability or platform eligibility; schema validation does not validate every expanded FASTEXPR combination. Optional asset_id selects a version update guarded by args.expected_version; a stale version raises HTTP 409 and cannot overwrite a historical revision. """ from .service import ResearchError existing = await db.scalar(select(ResearchAsset).where( ResearchAsset.kind == "template", ResearchAsset.name == args.template.name, ResearchAsset.id != asset_id if asset_id else True, ).order_by(ResearchAsset.id).limit(1)) if existing: raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板", affected_items=[{"template_id": existing.id, "version": existing.version}]) previous = await Assets(db).get(asset_id, expected_kind="template") if asset_id else None rows = (await db.execute(select(BacktestItem, BacktestResult).outerjoin( BacktestResult, BacktestResult.item_id == BacktestItem.id, ).where(BacktestItem.id.in_(args.source_item_ids)))).all() found = {item.id: (item, result) for item, result in rows} missing = [item_id for item_id in args.source_item_ids if item_id not in found] if missing: raise ResearchError("NOT_FOUND", "部分来源回测候选不存在", affected_items=missing) incomplete = [item.id for item, result in rows if ( item.platform_status != "completed" or item.collection_status != "complete" or item.persistence_status != "saved" or not result or not result.complete )] if incomplete: raise ResearchError("SOURCE_NOT_READY", "来源候选须完成回测、结果采集和持久化,请先读取 get_backtest_results", affected_items=sorted(incomplete)) provenance = { "source": {"kind": "mcp", "reference": args.reference}, "hypothesis": args.hypothesis, "mcp_token_id": principal.token_id, "admin_id": principal.admin_id, "source_items": [item_summary(*found[item_id]) for item_id in args.source_item_ids], } if previous: provenance["parent_template"] = {"id": asset_id, "version": args.expected_version} asset = await Assets(db).save(AssetWrite( kind="template", content=args.template.model_dump(mode="json"), version=args.expected_version if asset_id else None, ), asset_id=asset_id, provenance=provenance) return { **asset, "template_id": asset["id"], "combination_count": (str(math.prod(len(v.values) for v in args.template.variables.values())) if all(v.values for v in args.template.variables.values()) else None), "validation": {"structure": "valid", "source_evidence": "recorded" if args.source_item_ids else "not_provided", "expanded_candidates": "not_validated", "platform_semantics": "unknown"}, "next_step": "选择数据准备和回测参数,用 expand_research_template 生成候选集合;核对候选后按已获授权范围调用 start_template_backtest。也可在模板工坊完成。", }