更新v2版本适配skill
This commit is contained in:
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())
|
||||
@@ -862,11 +862,19 @@ def inspect_source(
|
||||
)
|
||||
backend_validation = compiled.get("validation")
|
||||
if isinstance(backend_validation, dict):
|
||||
if backend_validation.get("valid") is False:
|
||||
raise InputError("MODEL_VALIDATION_FAILED", "Backend model validation failed.", backend_validation)
|
||||
backend_issues = backend_validation.get("issues")
|
||||
if isinstance(backend_issues, list):
|
||||
source_issues.extend(
|
||||
dict(issue) for issue in backend_issues if isinstance(issue, dict)
|
||||
)
|
||||
backend_warnings = compiled.get("warnings")
|
||||
if isinstance(backend_warnings, list):
|
||||
source_issues.extend(
|
||||
{**warning, "severity": "warning"}
|
||||
for warning in backend_warnings if isinstance(warning, dict)
|
||||
)
|
||||
all_variables = _result_variables(compiled)
|
||||
result_variables, variable_page = _variable_page(
|
||||
all_variables,
|
||||
|
||||
Reference in new issue
Block a user