更新v2版本适配skill

This commit is contained in:
ljz committed 2026-09-18 01:40:58 +08:00
1 parent 7611f13208
commit 2f3f7d00ec
58 files changed
+180419 -526

No files matched your search

@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Build editor projects from explicit plans using this checkout's real contracts.
Natural-language interpretation belongs to the skill. This helper never infers
components, connections or physical inputs. No running HTTP service is required.
"""
from __future__ import annotations
import argparse
from collections import Counter
from copy import deepcopy
import math
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPOSITORY_ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from simulation_skill import ( # noqa: E402
InputError, SkillCliError, _canonical_json, _ensure_distinct_new_output,
_sha256, _write_new_file, configure_standard_streams, emit_json,
load_source, nonnegative_integer, page_limit, stable_error_payload,
)
def require(condition, message):
if not condition:
raise InputError("PROJECT_CONTRACT_INVALID", message)
def record(value, allowed, required, path):
require(isinstance(value, dict), f"{path} must be an object.")
require(not set(value) - set(allowed), f"{path} has unknown fields: {sorted(set(value) - set(allowed))}.")
require(set(required) <= set(value), f"{path} is missing fields: {sorted(set(required) - set(value))}.")
return value
def text_value(value):
return isinstance(value, str) and bool(value.strip())
def finite_number(value):
try:
return type(value) in (int, float) and math.isfinite(value)
except OverflowError:
return False
def parameter_value(value):
return isinstance(value, str) or finite_number(value)
def catalog_models():
from app.main import get_component_catalog
catalog = get_component_catalog()
require(catalog.get("schemaVersion") == 1, "Unsupported component catalog schema.")
# The actual editor excludes the experimental test library.
return [component for library in catalog["libraries"] if library["id"] != "experimental"
for component in library["components"]]
def build_project(plan):
plan = deepcopy(plan)
record(plan, {"name", "components", "connections", "simulation"},
{"name", "components", "connections", "simulation"}, "plan")
require(text_value(plan["name"]), "plan.name must be nonempty.")
require(isinstance(plan["components"], list) and plan["components"], "plan.components must be a nonempty array.")
require(isinstance(plan["connections"], list), "plan.connections must be an array.")
catalog = {model["modelType"]: model for model in catalog_models()}
nodes, defaults = [], {}
for index, item in enumerate(plan["components"]):
location = f"components[{index}]"
record(item, {"id", "modelType", "label", "parameters", "parameterUnits", "position", "rotation", "mirrored"},
{"id", "modelType"}, location)
require(text_value(item["id"]), f"{location}.id must be nonempty.")
require(isinstance(item["modelType"], str) and item["modelType"] in catalog,
f"{location}.modelType is not in the editor catalog: {item['modelType']}.")
model = catalog[item["modelType"]]
definitions = {p["name"]: p for p in model["parameters"]}
overrides = record(item.get("parameters", {}), definitions, (), f"{location}.parameters")
units = record(item.get("parameterUnits", {}), definitions, (), f"{location}.parameterUnits")
require(set(units) <= set(overrides), f"{location}: a selected unit requires an explicit parameter value.")
require(all(parameter_value(v) for v in overrides.values()), f"{location}: parameters must be finite numbers or strings.")
require(all(isinstance(v, str) for v in units.values()), f"{location}: units must be strings.")
ports = []
for port in model["ports"]:
snapshot = {key: port[key] for key in ("name", "kind", "domain", "nominalRole", "side")}
if port.get("positiveFlowDirection") is not None:
snapshot["positiveFlowDirection"] = port["positiveFlowDirection"]
ports.append(snapshot)
nodes.append({
"id": item["id"], "type": "simulationComponent",
"position": item.get("position", {"x": (index % 4) * 300, "y": (index // 4) * 220}),
"data": {
"label": item.get("label", item["id"]),
"componentType": model["type"], "modelType": model["modelType"],
"modelVersion": model["modelVersion"], "ports": ports,
"parameters": {**{key: p["default"] for key, p in definitions.items()}, **overrides},
"parameterUnits": {**{key: p["unit"] for key, p in definitions.items()}, **units},
"parameterScientificNotation": {}, "rotation": item.get("rotation", 0),
"mirrored": item.get("mirrored", False),
},
})
defaults[item["id"]] = sorted(set(definitions) - set(overrides))
edges = []
for index, item in enumerate(plan["connections"]):
location = f"connections[{index}]"
record(item, {"id", "source", "target"}, {"id", "source", "target"}, location)
for side in ("source", "target"):
record(item[side], {"component", "port"}, {"component", "port"}, f"{location}.{side}")
edges.append({"id": item["id"], "source": item["source"]["component"],
"sourceHandle": item["source"]["port"], "target": item["target"]["component"],
"targetHandle": item["target"]["port"], "data": {"isContactEdge": False}})
project = {"projectSchemaVersion": 2, "name": plan["name"], "nodes": nodes,
"edges": edges, "simulation": plan["simulation"]}
validate_editor_shape(project)
return project, defaults
def validate_editor_shape(project):
"""Preflight the stricter editor shape; actual browser acceptance is separate."""
record(project, {"projectSchemaVersion", "name", "nodes", "edges", "simulation"},
{"projectSchemaVersion", "name", "nodes", "edges", "simulation"}, "project")
require(type(project["projectSchemaVersion"]) is int and project["projectSchemaVersion"] in (1, 2),
"Expected projectSchemaVersion 1 or 2.")
require(isinstance(project["name"], str), "Project name must be a string.")
require(isinstance(project["nodes"], list) and project["nodes"], "Project nodes must be nonempty.")
require(isinstance(project["edges"], list), "Project edges must be an array.")
simulation = record(project["simulation"], {"t_start", "t_stop", "step", "max_step", "method"},
{"t_start", "t_stop", "step", "max_step", "method"}, "simulation")
require(isinstance(simulation["method"], str) and simulation["method"] in ("RK45", "BDF"),
"Only RK45 and BDF are executable.")
require(all(parameter_value(simulation[k]) for k in ("t_start", "t_stop", "step", "max_step")),
"Simulation inputs must be finite numbers or expressions.")
node_ids, edge_ids = set(), set()
for node in project["nodes"]:
require(isinstance(node, dict), "Each node must be an object.")
ident = node.get("id")
require(text_value(ident) and ident not in node_ids, "Node IDs must be nonempty and unique.")
node_ids.add(ident)
require(node.get("type") == "simulationComponent", f"{ident}: invalid editor node type.")
position = node.get("position")
require(isinstance(position, dict) and all(finite_number(position.get(k)) for k in ("x", "y")),
f"{ident}: position requires finite x/y.")
data = node.get("data")
require(isinstance(data, dict), f"{ident}: data must be an object.")
require(isinstance(data.get("label"), str) and all(text_value(data.get(k)) for k in ("componentType", "modelType")),
f"{ident}: label and component/model type are required.")
require("modelVersion" not in data or isinstance(data["modelVersion"], str), f"{ident}: invalid modelVersion.")
require(type(data.get("rotation")) in (int, float) and data["rotation"] in (0, 90, 180, 270)
and type(data.get("mirrored")) is bool, f"{ident}: invalid rotation/mirrored.")
parameters = data.get("parameters")
require(isinstance(parameters, dict) and all(parameter_value(v) for v in parameters.values()),
f"{ident}: parameters must be finite numbers or strings.")
units = data.get("parameterUnits", {})
require(isinstance(units, dict) and all(isinstance(v, str) for v in units.values()), f"{ident}: invalid parameterUnits.")
require(isinstance(data.get("parameterScientificNotation", {}), dict), f"{ident}: invalid scientific notation metadata.")
ports = data.get("ports")
require(isinstance(ports, list), f"{ident}: ports must be objects, not strings.")
names = set()
for port in ports:
require(isinstance(port, dict), f"{ident}: invalid port snapshot.")
name = port.get("name")
require(text_value(name) and name not in names, f"{ident}: port names must be nonempty and unique.")
names.add(name)
require(port.get("kind") in ("physical", "signal") and text_value(port.get("domain"))
and port.get("nominalRole") in ("inlet", "outlet", "bidirectional", "input", "output")
and port.get("side") in ("left", "right"), f"{ident}.{name}: invalid port snapshot.")
require("positiveFlowDirection" not in port or port["positiveFlowDirection"] == "intoComponent",
f"{ident}.{name}: omit null positiveFlowDirection for browser import.")
for edge in project["edges"]:
require(isinstance(edge, dict), "Each edge must be an object.")
ident = edge.get("id")
require(text_value(ident) and ident not in edge_ids, "Edge IDs must be nonempty and unique.")
edge_ids.add(ident)
require(all(text_value(edge.get(k)) for k in ("source", "target", "sourceHandle", "targetHandle")),
f"{ident}: explicit endpoints and handles are required.")
require(edge["source"] in node_ids and edge["target"] in node_ids, f"{ident}: missing endpoint component.")
data = edge.get("data")
require(isinstance(data, dict) and type(data.get("isContactEdge")) is bool,
f"{ident}: data.isContactEdge must be boolean.")
if "routePoints" in data:
require(isinstance(data["routePoints"], list) and all(
isinstance(p, dict) and all(finite_number(p.get(k)) for k in ("x", "y"))
for p in data["routePoints"]), f"{ident}: invalid routePoints.")
def validate_project(project):
from app.main import (ReactFlowProjectPayload, build_reactflow_system_xml,
compile_reactflow_network, validate_reactflow_component_contract)
from app.project_parameters import prepare_project, version_warning
from app.simulation.registry import get_component_model_spec
validate_editor_shape(project)
available = {model["modelType"] for model in catalog_models()}
require(all(node["data"]["modelType"] in available for node in project["nodes"]),
"Project contains a model that is not available in the editor catalog.")
try:
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(project))
xml = build_reactflow_system_xml(normalized)
connected = Counter(endpoint for edge in normalized.edges for endpoint in (
(edge.source, edge.sourceHandle), (edge.target, edge.targetHandle)))
# Store the full registered snapshot, but check only currently active ports.
for node in normalized.nodes:
spec = get_component_model_spec(node.data.modelType)
parameters = validate_reactflow_component_contract(node, spec)
for port in spec.active_ports(parameters):
count = connected[(node.id, port.name)]
require(count == 1, f"{node.id}.{port.name}: expected one connection, found {count}.")
compiled = compile_reactflow_network(normalized).as_interface_dict()
except (ValueError, KeyError) as exc:
raise InputError("PROJECT_EXECUTION_INVALID", str(exc)) from exc
warnings = [version_warning(notices)] if notices else []
report = {
"ok": True, "projectSchemaVersion": project["projectSchemaVersion"],
"componentCount": len(project["nodes"]), "connectionCount": len(project["edges"]),
"warnings": warnings,
"checks": {"editorShape": "passed", "activePorts": "passed", "xmlV3": "passed",
"networkCompile": "passed", "browserImport": "notRun", "simulation": "notRun"},
"compiledComponentCount": len(compiled.get("components", [])),
}
return report, xml
def semantic_signature(xml):
"""Compare SI execution inputs, ignoring layout and reversible edge direction."""
root = ET.fromstring(xml)
simulation = root.find("Simulation")
return {
"simulation": {k: v if k == "method" else float(v) for k, v in simulation.attrib.items()},
"components": {c.attrib["id"]: {
"type": c.attrib["type"], "modelVersion": c.attrib["modelVersion"],
"parameters": {p.attrib["name"]: float(p.attrib["value"]) for p in c.findall("Parameter")},
} for c in root.findall("Components/Component")},
"connections": {c.attrib["id"]: sorted((e.attrib["component"], e.attrib["port"]) for e in c)
for c in root.findall("Connections/Connection")},
}
def main(argv=None):
configure_standard_streams()
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
catalog = sub.add_parser("catalog", help="Read current local catalog; filter before requesting full model details.")
catalog.add_argument("--query", default="")
catalog.add_argument("--model", action="append", default=[])
catalog.add_argument("--offset", type=nonnegative_integer, default=0)
catalog.add_argument("--limit", type=page_limit, default=20)
build = sub.add_parser("build", help="Build and validate JSON v2 from an explicit plan.")
build.add_argument("input")
build.add_argument("--output", required=True)
check = sub.add_parser("check", help="Check editor shape, active connections, XML and network compilation.")
check.add_argument("input")
check.add_argument("--xml-output")
compare = sub.add_parser("compare", help="Check two projects and compare their normalized execution inputs.")
compare.add_argument("input")
compare.add_argument("roundtrip")
args = parser.parse_args(argv)
try:
if args.command == "catalog":
models = catalog_models()
available = {m["modelType"] for m in models}
require(set(args.model) <= available, f"Unknown models: {sorted(set(args.model) - available)}.")
models = [m for m in models if (not args.model or m["modelType"] in args.model)
and args.query.casefold() in (m["modelType"] + " " + m["label"]).casefold()]
page = models[args.offset:args.offset + args.limit]
if not args.model:
page = [{k: m[k] for k in ("modelType", "label", "modelVersion")} for m in page]
emit_json({"ok": True, "command": "catalog", "source": "localCheckout", "models": page,
"total": len(models), "nextOffset": args.offset + len(page),
"hasMore": args.offset + len(page) < len(models)})
return 0
source = load_source(args.input, "json")
if args.command == "build":
output = _ensure_distinct_new_output(source.path, args.output)
project, defaults = build_project(source.parsed)
report, _ = validate_project(project)
data = _canonical_json(project)
_write_new_file(output, data)
report.update(output=str(output), sha256=_sha256(data), defaultsUsed=defaults)
else:
report, xml = validate_project(source.parsed)
if args.command == "check" and args.xml_output:
output = _ensure_distinct_new_output(source.path, args.xml_output)
_write_new_file(output, xml)
report["xmlOutput"] = str(output)
if args.command == "compare":
roundtrip = load_source(args.roundtrip, "json")
other_report, other_xml = validate_project(roundtrip.parsed)
before, after = semantic_signature(xml), semantic_signature(other_xml)
changed = [key for key in before if before[key] != after[key]]
require(not changed, f"Round-trip changed execution inputs: {', '.join(changed)}.")
report.update(executionEquivalent=True, roundtripSha256=roundtrip.sha256,
roundtripWarnings=other_report["warnings"])
report.update(command=args.command, sourceSha256=source.sha256)
emit_json(report)
return 0
except SkillCliError as exc:
emit_json(stable_error_payload(exc), stream=sys.stderr)
return exc.exit_code
except (ValueError, KeyError) as exc:
emit_json(stable_error_payload(InputError("PROJECT_CONTRACT_INVALID", str(exc))), stream=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())