programgarden 1.23.0__tar.gz → 1.24.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {programgarden-1.23.0 → programgarden-1.24.0}/PKG-INFO +2 -2
  2. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/__init__.py +21 -0
  3. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/client.py +12 -1
  4. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/deep_fixtures.py +71 -0
  5. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/executor.py +194 -13
  6. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resolver.py +164 -27
  7. programgarden-1.24.0/programgarden/semantic_rules.py +404 -0
  8. {programgarden-1.23.0 → programgarden-1.24.0}/pyproject.toml +2 -2
  9. {programgarden-1.23.0 → programgarden-1.24.0}/README.md +0 -0
  10. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/binding_validator.py +0 -0
  11. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/context.py +0 -0
  12. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/__init__.py +0 -0
  13. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/checkpoint_manager.py +0 -0
  14. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/query_builder.py +0 -0
  15. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/workflow_position_tracker.py +0 -0
  16. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  17. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/plugin/__init__.py +0 -0
  19. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/plugin/sandbox.py +0 -0
  20. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/providers/__init__.py +0 -0
  21. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/providers/llm_errors.py +0 -0
  22. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/providers/llm_provider.py +0 -0
  23. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/reconnect_handler.py +0 -0
  24. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/__init__.py +0 -0
  30. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/credential_tools.py +0 -0
  31. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/definition_tools.py +0 -0
  32. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/event_tools.py +0 -0
  33. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/job_tools.py +0 -0
  34. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/registry_tools.py +0 -0
  35. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/sqlite_tools.py +0 -0
  36. {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.23.0
3
+ Version: 1.24.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  Author: 프로그램동산
6
6
  Author-email: coding@programgarden.com
@@ -15,7 +15,7 @@ Requires-Dist: croniter (>=6.0.0,<7.0.0)
15
15
  Requires-Dist: litellm (>=1.40.0)
16
16
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
17
17
  Requires-Dist: programgarden-community (>=1.13.8,<2.0.0)
18
- Requires-Dist: programgarden-core (>=1.14.4,<2.0.0)
18
+ Requires-Dist: programgarden-core (>=1.15.0,<2.0.0)
19
19
  Requires-Dist: programgarden-finance (>=1.6.10,<2.0.0)
20
20
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
21
21
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
@@ -14,6 +14,17 @@ from programgarden.executor import WorkflowExecutor
14
14
  from programgarden.context import ExecutionContext
15
15
  from programgarden.client import ProgramGarden
16
16
  from programgarden.node_runner import NodeRunner
17
+ from programgarden.semantic_rules import (
18
+ analyze_workflow_semantics,
19
+ normalize_severities,
20
+ DEFAULT_SEMANTIC_SEVERITIES,
21
+ STRICT_SEMANTIC_SEVERITIES,
22
+ ALL_RULES,
23
+ RULE_ORDER_QTY_FROM_AI,
24
+ RULE_STRUCTURED_OUTPUT_NO_SCHEMA,
25
+ RULE_HARDCODED_ORDER_QTY,
26
+ RULE_ORDER_IGNORED_FIELD,
27
+ )
17
28
  from programgarden_core.bases.listener import (
18
29
  NodeState,
19
30
  EdgeState,
@@ -140,4 +151,14 @@ __all__ = [
140
151
  "get_events",
141
152
  "get_job_summary",
142
153
  "analyze_performance",
154
+ # Semantic / safety layer (deep_validate R1~R4)
155
+ "analyze_workflow_semantics",
156
+ "normalize_severities",
157
+ "DEFAULT_SEMANTIC_SEVERITIES",
158
+ "STRICT_SEMANTIC_SEVERITIES",
159
+ "ALL_RULES",
160
+ "RULE_ORDER_QTY_FROM_AI",
161
+ "RULE_STRUCTURED_OUTPUT_NO_SCHEMA",
162
+ "RULE_HARDCODED_ORDER_QTY",
163
+ "RULE_ORDER_IGNORED_FIELD",
143
164
  ]
@@ -80,6 +80,7 @@ class ProgramGarden:
80
80
  *,
81
81
  fixtures: Optional[Dict[str, Any]] = None,
82
82
  timeout: float = 15.0,
83
+ semantic_rules: Optional[Dict[str, Any]] = None,
83
84
  ) -> ValidationResult:
84
85
  """Deep-validate a workflow via virtual full-execution (never raises).
85
86
 
@@ -96,6 +97,11 @@ class ProgramGarden:
96
97
  fixtures: Optional per-node fixture overrides, keyed by node id or
97
98
  node type (merged shallowly on top of the default fixture).
98
99
  timeout: Hard timeout (seconds) for the single validation pass.
100
+ semantic_rules: Optional per-rule severity config for the configurable
101
+ semantic/safety layer (R1~R4). ``None`` (default) skips the layer;
102
+ pass ``programgarden.semantic_rules.STRICT_SEMANTIC_SEVERITIES`` (or
103
+ a ``{rule_id: "error"|"warning"|"off"}`` dict) to opt in. See
104
+ ``WorkflowExecutor.deep_validate`` for details.
99
105
 
100
106
  Returns:
101
107
  ValidationResult — ``errors`` carry structured per-node ErrorInfo;
@@ -109,7 +115,12 @@ class ProgramGarden:
109
115
  ... print(err.short())
110
116
  """
111
117
  return _run_coro_sync(
112
- self.executor.deep_validate(definition, fixtures=fixtures, timeout=timeout)
118
+ self.executor.deep_validate(
119
+ definition,
120
+ fixtures=fixtures,
121
+ timeout=timeout,
122
+ semantic_rules=semantic_rules,
123
+ )
113
124
  )
114
125
 
115
126
  def run(
@@ -361,6 +361,77 @@ def broker_connection_fixture(
361
361
  }
362
362
 
363
363
 
364
+ def _schema_default_value(schema: Any) -> Any:
365
+ """Best-effort default value for one ``output_schema`` entry.
366
+
367
+ An entry is either a bare type string (``"number"``) or a JSON-schema-ish
368
+ dict (``{"type": "...", "enum": [...], "items": {...}, "properties": {...}}``)
369
+ — the two shapes ``AIAgentNodeExecutor._build_output_instruction`` /
370
+ ``_validate_structured`` already accept. Returns a type-correct placeholder
371
+ so a downstream ``{{ nodes.<agent>.response.<field> }}`` binding resolves to a
372
+ real value (an enum field resolves to its first allowed value, an object to
373
+ its declared properties, an array to a single shaped element).
374
+ """
375
+ if isinstance(schema, str):
376
+ type_name, spec = schema, {}
377
+ elif isinstance(schema, dict):
378
+ type_name, spec = str(schema.get("type", "string")), schema
379
+ else:
380
+ return "deep_validate"
381
+
382
+ enum_vals = spec.get("enum") if isinstance(spec, dict) else None
383
+ if enum_vals:
384
+ return enum_vals[0]
385
+
386
+ if type_name in ("string", "str"):
387
+ return "deep_validate"
388
+ if type_name in ("number", "float"):
389
+ return 1.0
390
+ if type_name in ("integer", "int"):
391
+ return 1
392
+ if type_name in ("boolean", "bool"):
393
+ return True
394
+ if type_name == "array":
395
+ items = spec.get("items") if isinstance(spec, dict) else None
396
+ if isinstance(items, (dict, str)):
397
+ return [_schema_default_value(items)]
398
+ return []
399
+ if type_name in ("object", "dict"):
400
+ props = spec.get("properties") if isinstance(spec, dict) else None
401
+ if isinstance(props, dict):
402
+ return {key: _schema_default_value(sub) for key, sub in props.items()}
403
+ return {}
404
+ return "deep_validate"
405
+
406
+
407
+ def ai_agent_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
408
+ """AIAgentNode deep fixture — schema-shaped ``{"response": ...}``, no LLM call.
409
+
410
+ A deep run must never hit a live LLM (cost, non-determinism, network) nor
411
+ silently swallow a failed model call. This builds the ``response`` output
412
+ port directly from the node's declared ``output_format`` / ``output_schema``
413
+ so downstream consumers see the same shape they would at runtime:
414
+
415
+ - ``"structured"`` + ``output_schema`` → a dict with every declared field
416
+ populated by a type-correct placeholder (enum → first value, nested
417
+ object/array shaped recursively). This is what makes
418
+ ``{{ nodes.<agent>.response.<field> }}`` bindings resolve in deep mode.
419
+ - ``"json"`` → an empty dict (no schema to shape it).
420
+ - ``"text"`` / anything else → a placeholder string.
421
+ """
422
+ output_format = config.get("output_format", "text")
423
+ output_schema = config.get("output_schema")
424
+ if output_format == "structured" and isinstance(output_schema, dict) and output_schema:
425
+ response: Any = {
426
+ key: _schema_default_value(spec) for key, spec in output_schema.items()
427
+ }
428
+ elif output_format == "json":
429
+ response = {}
430
+ else: # "text" or unknown → raw string
431
+ response = "deep_validate: AIAgentNode response (virtual run, no live LLM call)"
432
+ return {"response": response}
433
+
434
+
364
435
  def apply_override(default: Dict[str, Any], override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
365
436
  """Shallow-merge a caller override on top of a default fixture.
366
437
 
@@ -11,6 +11,8 @@ Workflow execution engine
11
11
  from typing import Optional, Dict, Any, List, Callable, Awaitable, Set, Tuple
12
12
  from datetime import datetime
13
13
  import asyncio
14
+ import ast
15
+ import re
14
16
  import uuid
15
17
  import logging
16
18
 
@@ -37,6 +39,50 @@ from programgarden_core.nodes.base import BaseMessagingNode
37
39
  logger = logging.getLogger("programgarden.executor")
38
40
 
39
41
 
42
+ # lib reserved iteration variable roots — valid ONLY mid-iteration.
43
+ # `{{ item.* }}` (auto-iterate per-item) and `{{ row.* }}` (ConditionNode
44
+ # items.extract) only resolve while the executor has an iteration context set;
45
+ # before auto-iterate they legitimately fail to evaluate. These are lib
46
+ # *identifiers* (single source of the engine's iteration binding), NOT
47
+ # language keywords — the deep-validate recorder uses them as a structural
48
+ # signal (AST free-variable roots) to avoid false-rejecting correct examples
49
+ # whose nested config holds `{{ item.symbol }}` etc.
50
+ _RESERVED_ITERATION_ROOTS: frozenset = frozenset({"item", "row"})
51
+
52
+ _INLINE_EXPR_PATTERN = re.compile(r"\{\{\s*(.+?)\s*\}\}")
53
+
54
+
55
+ def _free_root_names(text: str) -> Set[str]:
56
+ """Return the set of free-variable root identifiers referenced (ctx=Load) by
57
+ every ``{{ ... }}`` expression embedded in ``text``.
58
+
59
+ Used by the deep-validate binding recorder to decide whether an unresolved
60
+ expression is iteration-scoped (root in :data:`_RESERVED_ITERATION_ROOTS`)
61
+ and therefore expected to be deferred — not a real defect.
62
+
63
+ - ``{{ nodes.split.item }}`` → root ``nodes`` (``item`` is an attribute,
64
+ not a root) → NOT iteration-scoped.
65
+ - ``{{ x | length }}`` (unsupported pipe filter) → roots ``{x, length}``
66
+ → NOT iteration-scoped → recorded (desired).
67
+ - A genuinely malformed expression (SyntaxError) yields a sentinel
68
+ ``__syntax_error__`` root so the recorder treats it as a real defect.
69
+ """
70
+ roots: Set[str] = set()
71
+ for match in _INLINE_EXPR_PATTERN.findall(text):
72
+ try:
73
+ tree = ast.parse(match, mode="eval")
74
+ except (SyntaxError, ValueError):
75
+ # SyntaxError = genuine malformed expression.
76
+ # ValueError (incl. UnicodeEncodeError on surrogate chars) = the
77
+ # source cannot even be parsed/encoded — treat the same way so the
78
+ # expression is recorded (not dropped → no false-negative).
79
+ return roots | {"__syntax_error__"}
80
+ for node in ast.walk(tree):
81
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
82
+ roots.add(node.id)
83
+ return roots
84
+
85
+
40
86
  def _build_reconnect_hooks(
41
87
  tracker: Any,
42
88
  context: "ExecutionContext",
@@ -14524,6 +14570,31 @@ class AIAgentNodeExecutor(NodeExecutorBase):
14524
14570
  import json as json_module
14525
14571
  from programgarden.providers import LLMProvider
14526
14572
 
14573
+ # === 0. deep_validate: 실 LLM/ReAct 루프를 절대 돌리지 않는다 ===
14574
+ # 가짜(스키마-shaped) response 를 주입해 다운스트림 {{ nodes.X.response[.field] }}
14575
+ # 바인딩이 풀리고 flow 무결성이 검증되도록 한다(네트워크·모델비용 0). 실 LLM
14576
+ # 호출 실패를 {"error":...} 로 삼키던 silent-fail 경로 자체를 제거한다.
14577
+ # preset 을 먼저 적용해 output_format/output_schema 가 런타임과 동일하게 반영되도록 함.
14578
+ if getattr(context, "is_deep_validate", False):
14579
+ from programgarden import deep_fixtures as _df
14580
+ _deep_config = config
14581
+ _preset_id = config.get("preset")
14582
+ if _preset_id and _preset_id != "custom":
14583
+ try:
14584
+ from programgarden_core.presets import PresetLoader
14585
+ _deep_config = PresetLoader.apply_preset(_preset_id, config)
14586
+ except Exception:
14587
+ _deep_config = config
14588
+ fixture = _df.ai_agent_fixture(_deep_config)
14589
+ override = context.get_deep_fixture(node_id, node_type)
14590
+ fixture = _df.apply_override(fixture, override)
14591
+ context.log(
14592
+ "info",
14593
+ "[deep_validate] AIAgentNode response simulated (no live LLM call)",
14594
+ node_id,
14595
+ )
14596
+ return fixture
14597
+
14527
14598
  # === 1. ai_model 엣지에서 LLM connection 주입 ===
14528
14599
  workflow = kwargs.get("workflow")
14529
14600
  if not workflow:
@@ -15506,6 +15577,7 @@ class WorkflowExecutor:
15506
15577
  *,
15507
15578
  fixtures: Optional[Dict[str, Any]] = None,
15508
15579
  timeout: float = 15.0,
15580
+ semantic_rules: Optional[Dict[str, Any]] = None,
15509
15581
  ) -> ValidationResult:
15510
15582
  """Deep-validate a workflow via virtual full-execution (never raises).
15511
15583
 
@@ -15525,6 +15597,15 @@ class WorkflowExecutor:
15525
15597
  timeout: Hard timeout (seconds) for the single validation pass. On
15526
15598
  timeout the partial result so far is returned with a flow-broken
15527
15599
  error appended.
15600
+ semantic_rules: Optional per-rule severity config for the configurable
15601
+ semantic/safety layer (R1~R4 — order-quantity-from-AI, schema-less
15602
+ structured output, hardcoded quantity, ignored broker field). A
15603
+ ``{rule_id: "error"|"warning"|"off"}`` dict; only named rules are
15604
+ overridden, the rest stay off. ``None`` (default) skips the layer
15605
+ entirely, so the default deep_validate pass is unchanged. Pass
15606
+ ``programgarden.semantic_rules.STRICT_SEMANTIC_SEVERITIES`` to opt
15607
+ into the chatbot anti-pattern checks. Findings carry ``SEMANTIC_*``
15608
+ codes with the same ErrorInfo shape as every other error.
15528
15609
 
15529
15610
  Returns:
15530
15611
  ValidationResult — ``errors`` carry structured per-node ErrorInfo
@@ -15552,6 +15633,19 @@ class WorkflowExecutor:
15552
15633
  )
15553
15634
  )
15554
15635
  return result
15636
+
15637
+ # 1b) Configurable semantic/safety layer (R1~R4) — off unless the caller
15638
+ # opts in. Added before the static-validity gate so its findings ride
15639
+ # along with structure errors too (the chatbot cascade combined both).
15640
+ # Pure / never-raising, so a failure here never breaks deep_validate.
15641
+ if semantic_rules:
15642
+ try:
15643
+ from .semantic_rules import analyze_workflow_semantics
15644
+ for info in analyze_workflow_semantics(definition, semantic_rules):
15645
+ result.add(info)
15646
+ except Exception: # pragma: no cover - defensive
15647
+ pass
15648
+
15555
15649
  if not static.is_valid:
15556
15650
  # Hand back the structure errors verbatim (same ErrorInfo shape).
15557
15651
  for err in static.errors:
@@ -16749,7 +16843,7 @@ class WorkflowJob:
16749
16843
  break # 첫 번째 상위 노드만 사용
16750
16844
 
16751
16845
  # Resolve expressions in config ({{ input.xxx }}, {{ nodeId.port }})
16752
- config = self._resolve_config_expressions(config)
16846
+ config = self._resolve_config_expressions(config, node_id)
16753
16847
 
16754
16848
  # Auto-inject connection from matching BrokerNode (Phase 5)
16755
16849
  config = self._auto_inject_connection(node_id, node, config)
@@ -16877,6 +16971,43 @@ class WorkflowJob:
16877
16971
  if new_skips:
16878
16972
  print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
16879
16973
 
16974
+ # deep_validate: a node that *returns* a sole-`error` dict (rather
16975
+ # than raising) would otherwise be stored as a COMPLETED output and
16976
+ # silently swallowed — its downstream consumers then read a node
16977
+ # with no real output port. Promote it to a blocking structured
16978
+ # error so the chatbot learns why/where instead of looping on a
16979
+ # validation that wrongly passed (feedback_chatbot_error_clarity).
16980
+ # Scoped to a *sole* `error` key so nodes that legitimately return
16981
+ # `{"...": [], "error": ...}` partial payloads keep flowing as before.
16982
+ if (
16983
+ getattr(self.context, "is_deep_validate", False)
16984
+ and isinstance(outputs, dict)
16985
+ and set(outputs.keys()) == {"error"}
16986
+ and isinstance(outputs.get("error"), str)
16987
+ and node_id not in self._node_error_infos
16988
+ ):
16989
+ from programgarden_core import (
16990
+ ErrorCode,
16991
+ ErrorLocation,
16992
+ build_error,
16993
+ )
16994
+ _emsg = outputs["error"]
16995
+ self._node_error_infos[node_id] = build_error(
16996
+ ErrorCode.DEEP_VALIDATION_NODE_ERROR,
16997
+ f"Node '{node_id}' ({node.node_type}) returned an error "
16998
+ f"instead of producing output: {_emsg}",
16999
+ location=ErrorLocation(node_id=node_id, node_type=node.node_type),
17000
+ suggestion=(
17001
+ "이 노드가 정상 출력 대신 오류를 돌려줬습니다. 위 사유를 보고 "
17002
+ "노드 설정(연결된 입력·자격증명·필수 필드)을 점검하세요."
17003
+ ),
17004
+ details={
17005
+ "raw_message": _emsg,
17006
+ "deep_validate": True,
17007
+ "stage": "node_error_return",
17008
+ },
17009
+ )
17010
+
16880
17011
  # Store outputs
16881
17012
  for out_port_name, value in outputs.items():
16882
17013
  self.context.set_output(node_id, out_port_name, value)
@@ -17034,7 +17165,7 @@ class WorkflowJob:
17034
17165
  self.context.set_iteration_context(current_item, idx, total)
17035
17166
 
17036
17167
  # config 내 표현식 평가 ({{ item.xxx }}, {{ index }} 등)
17037
- item_config = self._resolve_config_expressions(config)
17168
+ item_config = self._resolve_config_expressions(config, node_id)
17038
17169
 
17039
17170
  # 진행 상황 로그
17040
17171
  item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
@@ -17428,7 +17559,7 @@ class WorkflowJob:
17428
17559
 
17429
17560
  # Prepare config
17430
17561
  config = dict(node.config)
17431
- config = self._resolve_config_expressions(config)
17562
+ config = self._resolve_config_expressions(config, node_id)
17432
17563
  config = self._auto_inject_connection(node_id, node, config)
17433
17564
 
17434
17565
  # Connect inputs from upstream
@@ -17481,7 +17612,7 @@ class WorkflowJob:
17481
17612
  )
17482
17613
 
17483
17614
  config = dict(node.config)
17484
- config = self._resolve_config_expressions(config)
17615
+ config = self._resolve_config_expressions(config, aggregate_id)
17485
17616
 
17486
17617
  try:
17487
17618
  outputs = await self.executor.execute_node(
@@ -17606,7 +17737,11 @@ class WorkflowJob:
17606
17737
  # 매칭 실패 시 원본 반환 (노드 executor에서 에러 처리)
17607
17738
  return config
17608
17739
 
17609
- def _resolve_config_expressions(self, config: Dict[str, Any]) -> Dict[str, Any]:
17740
+ def _resolve_config_expressions(
17741
+ self,
17742
+ config: Dict[str, Any],
17743
+ node_id: Optional[str] = None,
17744
+ ) -> Dict[str, Any]:
17610
17745
  """
17611
17746
  Config 내의 {{ }} 표현식을 resolve.
17612
17747
 
@@ -17619,6 +17754,17 @@ class WorkflowJob:
17619
17754
 
17620
17755
  Note: items 키는 제외 (ConditionNode의 _process_items_with_extract에서 별도 처리).
17621
17756
  items 내부에 {{ row.xxx }} 같은 지연 평가 표현식이 있어 여기서 평가하면 실패함.
17757
+
17758
+ Deep-validate 동작 (node_id 가 주어졌을 때만):
17759
+ 정상(runtime/dry_run) 모드는 한 필드라도 실패하면 전체 try/except 로
17760
+ 삼키고 원본을 유지한다(동작 불변). deep_validate 모드에서는 이 전체삼킴
17761
+ 경로가 미해결 ``{{ }}`` 표현식(잘못된 필드 경로·미정의 변수·미지원 pipe
17762
+ filter 등)을 검증에서 그대로 통과시킨다 — 일부 executor 가
17763
+ evaluate_all_bindings 를 부르지 않아 그 노드 표현식이 여기서만 평가되기
17764
+ 때문이다. 그래서 deep 모드 + node_id 가 있으면 leaf 단위 on_error
17765
+ 콜백으로 평가해 실패 leaf 를 record_deep_unresolved_binding 에 기록한다
17766
+ (C1 가드 통과 시에만). 비-deep 모드 또는 node_id 가 없으면 기존
17767
+ 전체삼킴 경로 그대로다.
17622
17768
  """
17623
17769
  from programgarden_core.expression import ExpressionEvaluator
17624
17770
 
@@ -17634,13 +17780,48 @@ class WorkflowJob:
17634
17780
  if isinstance(v, str) and "{{ item" in v:
17635
17781
  deferred[k] = config_copy.pop(k)
17636
17782
 
17637
- try:
17638
- expr_context = self.context.get_expression_context()
17639
- evaluator = ExpressionEvaluator(expr_context)
17640
- resolved = evaluator.evaluate_fields(config_copy)
17641
- except Exception as e:
17642
- self.context.log("warning", f"Expression resolve failed: {e}")
17643
- resolved = config_copy
17783
+ deep_mode = bool(getattr(self.context, "is_deep_validate", False))
17784
+ if deep_mode and node_id is not None:
17785
+ # deep_validate: leaf 단위로 평가하고 미해결 binding 을 기록.
17786
+ def _recorder(expr: str, exc: Exception) -> None:
17787
+ # C1 가드 — iteration 컨텍스트가 없을 때, 표현식이 lib 예약
17788
+ # iteration 변수(item/row)를 자유변수 루트로 참조하면 기록 제외.
17789
+ # `{{ item.* }}`(auto-iterate)·`{{ row.* }}`(items.extract)는
17790
+ # iteration 시점에만 유효하고, top-level `{{ item`은 위에서 이미
17791
+ # deferred 되지만 nested dict/list 안의 `{{ item.symbol }}`은
17792
+ # deferred 를 빠져나가 auto-iterate 전 여기서 실패한다. 기록하면
17793
+ # 정답 예제(30/31-liquidate-* 등)가 false-reject 된다.
17794
+ if self.context._iteration_item is None:
17795
+ roots = _free_root_names(expr)
17796
+ if roots & _RESERVED_ITERATION_ROOTS:
17797
+ return
17798
+ try:
17799
+ self.context.record_deep_unresolved_binding(
17800
+ node_id, expr, str(exc)
17801
+ )
17802
+ except Exception: # pragma: no cover - defensive
17803
+ pass
17804
+
17805
+ # NOTE: evaluator 구성(get_expression_context / ExpressionEvaluator
17806
+ # → to_dict)도 try 안에 둔다 — 컨텍스트 자체가 손상돼 raise 하면 leaf
17807
+ # on_error 가 아니라 여기서 터지므로, 기존 전체삼킴 경로와 동일하게
17808
+ # 원본 config 를 유지해야 한다(동작 불변).
17809
+ try:
17810
+ expr_context = self.context.get_expression_context()
17811
+ evaluator = ExpressionEvaluator(expr_context)
17812
+ resolved = evaluator.evaluate_fields(config_copy, on_error=_recorder)
17813
+ except Exception as e: # pragma: no cover - on_error 가 leaf 에서 흡수
17814
+ self.context.log("warning", f"Expression resolve failed: {e}")
17815
+ resolved = config_copy
17816
+ else:
17817
+ # 정상 모드(또는 node_id 없음): 기존 전체삼킴 경로 (동작 불변).
17818
+ try:
17819
+ expr_context = self.context.get_expression_context()
17820
+ evaluator = ExpressionEvaluator(expr_context)
17821
+ resolved = evaluator.evaluate_fields(config_copy)
17822
+ except Exception as e:
17823
+ self.context.log("warning", f"Expression resolve failed: {e}")
17824
+ resolved = config_copy
17644
17825
 
17645
17826
  # 지연 평가 필드 복원
17646
17827
  resolved.update(deferred)
@@ -17942,7 +18123,7 @@ class WorkflowJob:
17942
18123
  )
17943
18124
 
17944
18125
  # Resolve expressions in config
17945
- config_with_source = self._resolve_config_expressions(config_with_source)
18126
+ config_with_source = self._resolve_config_expressions(config_with_source, node_id)
17946
18127
 
17947
18128
  # Auto-inject connection from matching BrokerNode (Phase 5)
17948
18129
  config_with_source = self._auto_inject_connection(node_id, node, config_with_source)
@@ -783,6 +783,90 @@ class WorkflowResolver:
783
783
  return _extract_field_names(dyn_schema.outputs, port_name)
784
784
  return None
785
785
 
786
+ # ── Phase 2: cross-port TYPE compatibility ──────────────────────────
787
+ # Field *existence* is handled above. This adds a conservative type
788
+ # check: a binding that is the WHOLE value of a numeric/boolean-typed
789
+ # consuming field must not read an output sub-field of a *different*
790
+ # concrete scalar type (e.g. feeding a `string` symbol into a `number`
791
+ # balance field). Only strong scalar classes are compared; object /
792
+ # array / enum / json / `any` and string-consuming fields (freely
793
+ # coercible) are left open to keep false-rejects at zero.
794
+ _SCALAR_CLASS = {
795
+ "number": "num", "integer": "num", "int": "num",
796
+ "float": "num", "decimal": "num",
797
+ "string": "str", "str": "str",
798
+ "boolean": "bool", "bool": "bool",
799
+ }
800
+
801
+ def _scalar_class(type_str: Any) -> Optional[str]:
802
+ if not type_str:
803
+ return None
804
+ return _SCALAR_CLASS.get(str(type_str).strip().lower())
805
+
806
+ def _extract_field_type(outputs: Any, port_name: str, field_name: str) -> Optional[str]:
807
+ for out in (outputs or []):
808
+ if isinstance(out, dict):
809
+ nm = out.get("name")
810
+ fields = out.get("fields")
811
+ else:
812
+ nm = getattr(out, "name", None)
813
+ fields = getattr(out, "fields", None)
814
+ if nm != port_name:
815
+ continue
816
+ for f in (fields or []):
817
+ if isinstance(f, dict):
818
+ fn, ft = f.get("name"), f.get("type")
819
+ else:
820
+ fn, ft = getattr(f, "name", None), getattr(f, "type", None)
821
+ if fn == field_name:
822
+ return ft
823
+ return None
824
+
825
+ def _output_field_type(node_type: str, port_name: str, field_name: str) -> Optional[str]:
826
+ if not node_type:
827
+ return None
828
+ schema = registry.get_schema(node_type)
829
+ if schema:
830
+ return _extract_field_type(schema.outputs, port_name, field_name)
831
+ dyn_schema = dynamic_registry.get_schema(node_type)
832
+ if dyn_schema:
833
+ return _extract_field_type(dyn_schema.outputs, port_name, field_name)
834
+ return None
835
+
836
+ def _consuming_scalar_class(node_type: str, field_key: str) -> Optional[str]:
837
+ """Strong scalar class expected by a consuming node's top-level
838
+ config field, or None when the field is open/structured (skip).
839
+
840
+ `expected_type == 'any'` (e.g. IfNode.left/right) is explicitly open
841
+ and must never be type-checked; struct-shaped `expected_type`
842
+ (``{...}``) is object-like → skip; a concrete scalar `expected_type`
843
+ wins, otherwise fall back to the FieldType enum.
844
+ """
845
+ node_class = registry.get(node_type) if node_type else None
846
+ if node_class is None:
847
+ return None
848
+ try:
849
+ fs = node_class.get_field_schema()
850
+ except Exception:
851
+ return None
852
+ sch = fs.get(field_key) if isinstance(fs, dict) else None
853
+ if sch is None:
854
+ return None
855
+ expected = getattr(sch, "expected_type", None)
856
+ if expected is not None:
857
+ exp_s = str(expected).strip().lower()
858
+ if exp_s in ("any", ""):
859
+ return None
860
+ cls = _scalar_class(exp_s)
861
+ # Concrete scalar expected_type → use it; struct/other → skip.
862
+ return cls
863
+ ftype = getattr(sch, "type", None)
864
+ ftype = getattr(ftype, "value", ftype)
865
+ return _scalar_class(ftype)
866
+
867
+ # Whole-value single-expression matcher (no surrounding text / arithmetic).
868
+ _whole_expr = re.compile(r"^\s*\{\{\s*nodes\.[\w.]+\s*\}\}\s*$")
869
+
786
870
  # nodes.<id>(.<attr>)* — capture the full dotted path so nested
787
871
  # field typos (e.g. {{ nodes.account.balance.orderabl_amount }})
788
872
  # are also caught when OutputPort.fields declares the shape.
@@ -856,34 +940,87 @@ class WorkflowResolver:
856
940
  continue
857
941
  nested = attrs[1]
858
942
  field_names = _field_names_for(source_type, attr)
859
- if field_names is None or nested in field_names:
860
- continue
861
- # Underscore-prefixed keys are reserved for internal
862
- # metadata (e.g. _partial_failure on balance dicts).
863
- # Treat them as known so consumers can branch on them
864
- # without forcing every metadata addition to update
865
- # BALANCE_FIELDS in lockstep.
866
- if nested.startswith("_"):
867
- continue
868
- result.add(
869
- build_error(
870
- ErrorCode.INVALID_EXPRESSION_REF,
871
- f"Expression field '{nested}' is not a declared field of "
872
- f"port '{attr}' on node '{ref_id}' ({source_type})",
873
- location=ErrorLocation(
874
- node_id=node_id,
875
- node_type=node_type_by_id.get(node_id),
876
- field_path=field_path,
877
- expression=value,
878
- output_port=attr,
879
- ),
880
- available_values=suggest_close_match(nested, field_names) or sorted(field_names),
881
- suggestion=(
882
- f"Pick a field that exists on '{attr}' "
883
- "(see OutputPort.fields in the node schema)."
884
- ),
885
- )
943
+ field_exists = (
944
+ field_names is None
945
+ or nested in field_names
946
+ # Underscore-prefixed keys are reserved for internal
947
+ # metadata (e.g. _partial_failure on balance dicts).
948
+ # Treat them as known so consumers can branch on them
949
+ # without forcing every metadata addition to update
950
+ # BALANCE_FIELDS in lockstep.
951
+ or nested.startswith("_")
886
952
  )
953
+ if not field_exists:
954
+ result.add(
955
+ build_error(
956
+ ErrorCode.INVALID_EXPRESSION_REF,
957
+ f"Expression field '{nested}' is not a declared field of "
958
+ f"port '{attr}' on node '{ref_id}' ({source_type})",
959
+ location=ErrorLocation(
960
+ node_id=node_id,
961
+ node_type=node_type_by_id.get(node_id),
962
+ field_path=field_path,
963
+ expression=value,
964
+ output_port=attr,
965
+ ),
966
+ available_values=suggest_close_match(nested, field_names) or sorted(field_names),
967
+ suggestion=(
968
+ f"Pick a field that exists on '{attr}' "
969
+ "(see OutputPort.fields in the node schema)."
970
+ ),
971
+ )
972
+ )
973
+ continue
974
+
975
+ # ── Phase 2: type compatibility on the valid-field path ──
976
+ # Only when this binding is the WHOLE value of a TOP-LEVEL
977
+ # config field and the field shape is known. Nested consumer
978
+ # paths (field_path with '.'/'[' → object-typed parent) and
979
+ # interpolated strings are intentionally skipped (ceiling).
980
+ if (
981
+ field_names is not None
982
+ and not is_call
983
+ and len(attrs) == 2
984
+ and "." not in field_path
985
+ and "[" not in field_path
986
+ and _whole_expr.match(value)
987
+ ):
988
+ consumer_type = node_type_by_id.get(node_id)
989
+ cin = _consuming_scalar_class(consumer_type, field_path)
990
+ if cin in ("num", "bool"):
991
+ out_type = _output_field_type(source_type, attr, nested)
992
+ cout = _scalar_class(out_type)
993
+ if cout is not None and cout != cin:
994
+ result.add(
995
+ build_error(
996
+ ErrorCode.INVALID_FIELD_TYPE,
997
+ f"Field '{field_path}' on node '{node_id}' "
998
+ f"({consumer_type}) expects a {cin} value, but "
999
+ f"the expression reads '{attr}.{nested}' from "
1000
+ f"'{ref_id}' ({source_type}) which is declared "
1001
+ f"{out_type}.",
1002
+ location=ErrorLocation(
1003
+ node_id=node_id,
1004
+ node_type=consumer_type,
1005
+ field_path=field_path,
1006
+ expression=value,
1007
+ output_port=attr,
1008
+ ),
1009
+ suggestion=(
1010
+ f"'{field_path}' 필드에는 {cin} 타입 값이 필요합니다. "
1011
+ f"'{ref_id}' 노드의 '{attr}.{nested}' 출력은 타입이 달라 "
1012
+ f"맞지 않습니다 — 타입이 호환되는 출력 필드로 바꾸세요."
1013
+ ),
1014
+ details={
1015
+ "consumer_field": field_path,
1016
+ "consumer_class": cin,
1017
+ "ref_node_id": ref_id,
1018
+ "ref_port": attr,
1019
+ "ref_field": nested,
1020
+ "ref_field_type": out_type,
1021
+ },
1022
+ )
1023
+ )
887
1024
  elif isinstance(value, dict):
888
1025
  for k, v in value.items():
889
1026
  find_refs(v, node_id, f"{field_path}.{k}")
@@ -0,0 +1,404 @@
1
+ """Configurable semantic / safety rule layer for ``deep_validate`` (R1~R4).
2
+
3
+ Single source for the chatbot anti-pattern checks that used to live client-side
4
+ in ``programgarden_ai``'s ``workflow_semantic_lint``. This module exists because
5
+ structure-validation, ``dry_run`` and the Phase 2 port-type checks all pass for
6
+ workflows that are still *semantically* wrong or *unsafe* — they validate
7
+ *executability*, never *intent* or *safety*.
8
+
9
+ The four ground-truth anti-patterns (verified against the canonical example
10
+ workflows shipped in ``programgarden/examples/workflows`` and the live node
11
+ registry):
12
+
13
+ * **R1 — order quantity bound to an AIAgent response.** An AIAgent
14
+ sentiment/decision output wired straight into an order quantity risks a
15
+ 0 / negative / nonsense size. ``BaseOrderNode`` declares ``order: Any`` so
16
+ structure-validate accepts it. (Blocking when enabled.)
17
+ * **R2 — AIAgent ``output_format='structured'`` with no ``output_schema``.**
18
+ The ``response`` output port is declared ``type='any'`` with no sub-fields,
19
+ so port-type validation cannot constrain it; an unconstrained structured
20
+ output is one downstream consumers cannot rely on. (Blocking when enabled.)
21
+ NOTE: the *harmful* case (a downstream binding to a specific sub-field) is
22
+ already caught by ``deep_validate``'s runtime binding-resolution pass — the
23
+ schema-less fixture yields no such field, so the binding is reported as
24
+ ``DEEP_VALIDATION_BINDING_UNRESOLVED``. This rule adds *upfront* clarity
25
+ (say why/how before a field-miss happens), per ``feedback_chatbot_error_clarity``.
26
+ * **R3 — hardcoded literal order quantity with no PositionSizingNode.**
27
+ Always trades the same size regardless of balance/risk. (Advisory.)
28
+ * **R4 — order node carrying ``paper_trading``.** That field is a *broker*
29
+ field; on an order node it is silently ignored. (Advisory.)
30
+
31
+ Design contract
32
+ ---------------
33
+ * **Pure** — no network, no LLM, no DB; never raises (a malformed DSL is
34
+ defensively coerced, never crashes the validator).
35
+ * **Structural signals only.** Node identity (order / AIAgent) is resolved from
36
+ the node *registry class hierarchy* (``BaseOrderNode`` / ``BaseModifyOrderNode``
37
+ / ``AIAgentNode``), never from natural-language keyword arrays
38
+ (project rule ``feedback_no_keyword_hardcoding_in_ai``). The binding graph is
39
+ read from the DSL ``{{ nodes.<id>.<path> }}`` templates.
40
+ * **Off by default.** ``deep_validate`` only runs this layer when the caller
41
+ passes a ``semantic_rules`` config, so the sandbox / editor default path is
42
+ byte-for-byte unchanged (zero false-reject regression on the example corpus).
43
+ * **Per-rule configurable severity** — ``"error"`` (blocks), ``"warning"``
44
+ (surfaced, non-blocking), or ``"off"`` (skipped).
45
+
46
+ All Korean ``suggestion`` strings are clear beginner-investor guidance the
47
+ chatbot can relay verbatim (``feedback_chatbot_error_clarity``).
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import re
53
+ from typing import Any, Dict, List, Optional
54
+
55
+ from programgarden_core import (
56
+ ErrorCode,
57
+ ErrorInfo,
58
+ ErrorLocation,
59
+ ErrorSeverity,
60
+ build_error,
61
+ )
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Rule identifiers + severity presets
65
+ # ---------------------------------------------------------------------------
66
+
67
+ RULE_ORDER_QTY_FROM_AI = "order_qty_from_ai_response" # R1
68
+ RULE_STRUCTURED_OUTPUT_NO_SCHEMA = "structured_output_missing_schema" # R2
69
+ RULE_HARDCODED_ORDER_QTY = "hardcoded_order_quantity" # R3
70
+ RULE_ORDER_IGNORED_FIELD = "order_ignored_field" # R4
71
+
72
+ ALL_RULES: tuple[str, ...] = (
73
+ RULE_ORDER_QTY_FROM_AI,
74
+ RULE_STRUCTURED_OUTPUT_NO_SCHEMA,
75
+ RULE_HARDCODED_ORDER_QTY,
76
+ RULE_ORDER_IGNORED_FIELD,
77
+ )
78
+
79
+ _RULE_CODE: Dict[str, ErrorCode] = {
80
+ RULE_ORDER_QTY_FROM_AI: ErrorCode.SEMANTIC_ORDER_QTY_FROM_AI,
81
+ RULE_STRUCTURED_OUTPUT_NO_SCHEMA: ErrorCode.SEMANTIC_STRUCTURED_OUTPUT_NO_SCHEMA,
82
+ RULE_HARDCODED_ORDER_QTY: ErrorCode.SEMANTIC_HARDCODED_ORDER_QTY,
83
+ RULE_ORDER_IGNORED_FIELD: ErrorCode.SEMANTIC_ORDER_IGNORED_FIELD,
84
+ }
85
+
86
+ # Everything off — the default when no config is supplied.
87
+ DEFAULT_SEMANTIC_SEVERITIES: Dict[str, str] = {r: "off" for r in ALL_RULES}
88
+
89
+ # Chatbot strict preset — reproduces the legacy ``workflow_semantic_lint``
90
+ # behavior exactly (R1/R2 block, R3/R4 advise). Callers opt in with this.
91
+ STRICT_SEMANTIC_SEVERITIES: Dict[str, str] = {
92
+ RULE_ORDER_QTY_FROM_AI: "error",
93
+ RULE_STRUCTURED_OUTPUT_NO_SCHEMA: "error",
94
+ RULE_HARDCODED_ORDER_QTY: "warning",
95
+ RULE_ORDER_IGNORED_FIELD: "warning",
96
+ }
97
+
98
+ _SEVERITY_MAP: Dict[str, ErrorSeverity] = {
99
+ "error": ErrorSeverity.ERROR,
100
+ "warning": ErrorSeverity.WARNING,
101
+ }
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # DSL field shapes (verified against the canonical examples — flat node dict)
105
+ # ---------------------------------------------------------------------------
106
+
107
+ _ORDER_FIELD = "order"
108
+ _QUANTITY_KEY = "quantity"
109
+ # ``paper_trading`` is a legitimate BrokerNode field; on an order node it is
110
+ # silently ignored. Scoped denylist (not a broad unknown-field sweep) to avoid
111
+ # false positives on legitimately-varied order fields.
112
+ _ORDER_IGNORED_FIELDS: frozenset[str] = frozenset({"paper_trading"})
113
+
114
+ # Binding template: "{{ nodes.<node_id>.<rest...> }}" (spaces optional).
115
+ _NODE_REF_RE = re.compile(r"\{\{\s*nodes\.([A-Za-z0-9_\-]+)\.")
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Structural node identity (registry class hierarchy — no keyword arrays)
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def _order_base_classes() -> tuple:
123
+ """Order-family base classes from the registry (placing OR mutating quantity).
124
+
125
+ ``BaseOrderNode`` = New orders (carry an ``order.quantity``); ``BaseModifyOrderNode``
126
+ = Modify/Cancel. Imported lazily so importing this module never forces the
127
+ (heavy) node package at import time.
128
+ """
129
+ from programgarden_core.nodes.order import BaseOrderNode, BaseModifyOrderNode
130
+ return (BaseOrderNode, BaseModifyOrderNode)
131
+
132
+
133
+ def _aiagent_base_class():
134
+ from programgarden_core.nodes.ai import AIAgentNode
135
+ return AIAgentNode
136
+
137
+
138
+ def _node_class(node_type: Any):
139
+ """Resolve a node ``type`` string to its registered class, or None.
140
+
141
+ None for unknown / dynamic / community types and for non-str types (a
142
+ malformed DSL may carry a list/dict ``type``) — callers treat None as
143
+ "neither order nor AI", keeping the lint never-raising.
144
+ """
145
+ if not isinstance(node_type, str):
146
+ return None
147
+ try:
148
+ from programgarden_core.registry.node_registry import NodeTypeRegistry
149
+ return NodeTypeRegistry().get(node_type)
150
+ except Exception: # pragma: no cover - defensive
151
+ return None
152
+
153
+
154
+ def _is_order_node(node_type: Any) -> bool:
155
+ cls = _node_class(node_type)
156
+ if cls is None:
157
+ return False
158
+ try:
159
+ return issubclass(cls, _order_base_classes())
160
+ except Exception: # pragma: no cover - defensive
161
+ return False
162
+
163
+
164
+ def _is_aiagent_node(node_type: Any) -> bool:
165
+ cls = _node_class(node_type)
166
+ if cls is None:
167
+ return False
168
+ try:
169
+ return issubclass(cls, _aiagent_base_class())
170
+ except Exception: # pragma: no cover - defensive
171
+ return False
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Binding helpers
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def _is_binding(value: Any) -> bool:
179
+ return isinstance(value, str) and "{{" in value and "}}" in value
180
+
181
+
182
+ def _referenced_node_ids(value: Any) -> List[str]:
183
+ if not isinstance(value, str):
184
+ return []
185
+ return _NODE_REF_RE.findall(value)
186
+
187
+
188
+ def _order_quantity_value(node: Dict[str, Any]) -> tuple[bool, Any]:
189
+ """Return ``(present, value)`` for an order's quantity.
190
+
191
+ Order shape (canonical examples): ``order`` is either a literal dict
192
+ ``{"quantity": <int>, ...}`` or a whole-order binding ``"{{ nodes.X.order }}"``.
193
+ ``value`` is the quantity expression when ``order`` is a dict, else the whole
194
+ ``order`` value. ``present`` is False only when there is no quantity to check
195
+ (no ``order`` field, or a dict ``order`` without ``quantity`` — e.g. cancel).
196
+ """
197
+ if _ORDER_FIELD not in node:
198
+ return False, None
199
+ order = node.get(_ORDER_FIELD)
200
+ if isinstance(order, dict):
201
+ if _QUANTITY_KEY in order:
202
+ return True, order.get(_QUANTITY_KEY)
203
+ return False, None
204
+ return True, order
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Config normalization
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def normalize_severities(config: Any) -> Dict[str, str]:
212
+ """Merge a caller config over the all-off default → ``{rule: severity}``.
213
+
214
+ ``config`` may be a partial ``{rule_id: "error"|"warning"|"off"}`` dict (only
215
+ the named rules are overridden), one of the preset dicts, or None (→ all off).
216
+ Unknown rule keys and invalid severities are ignored defensively.
217
+ """
218
+ merged = dict(DEFAULT_SEMANTIC_SEVERITIES)
219
+ if isinstance(config, dict):
220
+ for rule, sev in config.items():
221
+ if rule in merged and isinstance(sev, str) and sev in ("error", "warning", "off"):
222
+ merged[rule] = sev
223
+ return merged
224
+
225
+
226
+ def _enabled(severities: Dict[str, str], rule: str) -> Optional[ErrorSeverity]:
227
+ """ErrorSeverity for an enabled rule, or None when the rule is 'off'."""
228
+ return _SEVERITY_MAP.get(severities.get(rule, "off"))
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Public entry point
233
+ # ---------------------------------------------------------------------------
234
+
235
+ def analyze_workflow_semantics(
236
+ definition: Dict[str, Any],
237
+ config: Any = None,
238
+ ) -> List[ErrorInfo]:
239
+ """Run the configurable semantic/safety layer over a workflow definition.
240
+
241
+ Pure, never-raising. Returns a list of ``ErrorInfo`` (the same structured
242
+ shape as every other validation error — ``code`` / ``severity`` /
243
+ ``location{node_id,node_type,field_path,expression}`` / ``message`` /
244
+ ``suggestion``), so consumers handle these uniformly with structure/deep
245
+ errors. Severity per finding follows ``config`` (see ``normalize_severities``).
246
+
247
+ Args:
248
+ definition: Workflow definition (JSON dict) with ``nodes`` / ``edges``.
249
+ config: Per-rule severity config; None → all rules off → ``[]``.
250
+ """
251
+ severities = normalize_severities(config)
252
+ # Fast exit: nothing enabled → no work, no node-class resolution.
253
+ if all(v == "off" for v in severities.values()):
254
+ return []
255
+
256
+ nodes = definition.get("nodes") if isinstance(definition, dict) else None
257
+ if not isinstance(nodes, list):
258
+ return []
259
+
260
+ sev_r1 = _enabled(severities, RULE_ORDER_QTY_FROM_AI)
261
+ sev_r2 = _enabled(severities, RULE_STRUCTURED_OUTPUT_NO_SCHEMA)
262
+ sev_r3 = _enabled(severities, RULE_HARDCODED_ORDER_QTY)
263
+ sev_r4 = _enabled(severities, RULE_ORDER_IGNORED_FIELD)
264
+
265
+ # Index node id → node, and whether any PositionSizingNode exists (R3).
266
+ nodes_by_id: Dict[str, Dict[str, Any]] = {}
267
+ has_position_sizing = False
268
+ for n in nodes:
269
+ if not isinstance(n, dict):
270
+ continue
271
+ nid = n.get("id")
272
+ if isinstance(nid, str):
273
+ nodes_by_id[nid] = n
274
+ if n.get("type") == "PositionSizingNode":
275
+ has_position_sizing = True
276
+
277
+ errors: List[ErrorInfo] = []
278
+
279
+ for node in nodes:
280
+ if not isinstance(node, dict):
281
+ continue
282
+ node_type = node.get("type")
283
+ node_id = node.get("id") if isinstance(node.get("id"), str) else "?"
284
+
285
+ # ── R2: AIAgent structured output with no schema (and no preset) ──
286
+ if sev_r2 is not None and _is_aiagent_node(node_type):
287
+ if node.get("output_format") == "structured":
288
+ schema = node.get("output_schema")
289
+ # missing / None / empty dict / empty string all = "no schema".
290
+ # FALSE-REJECT GUARD: shipped examples 33/34 use structured + no
291
+ # schema + a built-in ``preset`` (risk_manager / technical_analyst);
292
+ # the lib treats schema-less structured as a non-fatal fallback to
293
+ # raw JSON and a preset carries a behavior contract — so only the
294
+ # truly-unconstrained case (no schema AND no preset) is flagged.
295
+ if not schema and not node.get("preset"):
296
+ errors.append(build_error(
297
+ ErrorCode.SEMANTIC_STRUCTURED_OUTPUT_NO_SCHEMA,
298
+ "AIAgentNode output_format='structured' has no output_schema, "
299
+ "so the structured output is unconstrained and downstream "
300
+ "nodes cannot rely on its shape.",
301
+ severity=sev_r2,
302
+ location=ErrorLocation(
303
+ node_id=node_id, node_type=node_type, field_path="output_schema"
304
+ ),
305
+ suggestion=(
306
+ "output_schema 를 제공하거나(예: action/quantity 등 필드와 "
307
+ "타입을 명시), 구조가 필요 없으면 output_format=text 로 바꾸세요."
308
+ ),
309
+ details={"rule": RULE_STRUCTURED_OUTPUT_NO_SCHEMA},
310
+ ))
311
+
312
+ # ── Order-node rules (R1 / R3 / R4) ──────────────────────────────
313
+ if _is_order_node(node_type):
314
+ present, qty_value = _order_quantity_value(node)
315
+
316
+ # R1: order quantity bound to an AIAgent response → block.
317
+ r1_hit = False
318
+ if sev_r1 is not None and present and _is_binding(qty_value):
319
+ ref_ids = _referenced_node_ids(qty_value)
320
+ ai_ref = next(
321
+ (rid for rid in ref_ids
322
+ if _is_aiagent_node(nodes_by_id.get(rid, {}).get("type"))),
323
+ None,
324
+ )
325
+ if ai_ref is not None:
326
+ r1_hit = True
327
+ errors.append(build_error(
328
+ ErrorCode.SEMANTIC_ORDER_QTY_FROM_AI,
329
+ f"Order quantity is bound to AIAgent '{ai_ref}' output; an AI "
330
+ "sentiment/decision response wired straight into quantity risks "
331
+ "a 0 / negative / nonsense order size.",
332
+ severity=sev_r1,
333
+ location=ErrorLocation(
334
+ node_id=node_id, node_type=node_type,
335
+ field_path="order.quantity",
336
+ expression=qty_value if isinstance(qty_value, str) else None,
337
+ ),
338
+ suggestion=(
339
+ "수량은 PositionSizingNode 로 산출하고 order.quantity 는 그 "
340
+ "출력을 바인딩하세요. AI 감성/신호 응답을 주문 수량에 직결하면 "
341
+ "0/음수/엉뚱한 수량 위험이 있습니다 (주문 안티패턴 #1)."
342
+ ),
343
+ details={"rule": RULE_ORDER_QTY_FROM_AI, "ai_node_id": ai_ref},
344
+ ))
345
+
346
+ # R3: hardcoded literal quantity AND no PositionSizingNode → advise.
347
+ # Never on a binding (a binding is not a hardcoded literal). bool is
348
+ # an int subclass — exclude it.
349
+ if (
350
+ sev_r3 is not None and not r1_hit and present
351
+ and isinstance(qty_value, int) and not isinstance(qty_value, bool)
352
+ and not has_position_sizing
353
+ ):
354
+ errors.append(build_error(
355
+ ErrorCode.SEMANTIC_HARDCODED_ORDER_QTY,
356
+ f"Order quantity is hardcoded ({qty_value}) and the workflow has no "
357
+ "PositionSizingNode — it always trades the same size regardless of "
358
+ "account balance or risk.",
359
+ severity=sev_r3,
360
+ location=ErrorLocation(
361
+ node_id=node_id, node_type=node_type, field_path="order.quantity"
362
+ ),
363
+ suggestion=(
364
+ "계정/리스크 기반 수량은 PositionSizingNode 사용을 권장합니다 "
365
+ "(고정 수량이 의도라면 그대로 두어도 됩니다)."
366
+ ),
367
+ details={"rule": RULE_HARDCODED_ORDER_QTY, "quantity": qty_value},
368
+ ))
369
+
370
+ # R4: order node carries a silently-ignored broker field → advise.
371
+ if sev_r4 is not None:
372
+ for field in _ORDER_IGNORED_FIELDS:
373
+ if field in node:
374
+ errors.append(build_error(
375
+ ErrorCode.SEMANTIC_ORDER_IGNORED_FIELD,
376
+ f"Order node carries '{field}', which the order schema does "
377
+ "not define and silently ignores; real/paper is decided at "
378
+ "the broker/credential level, not on the order node.",
379
+ severity=sev_r4,
380
+ location=ErrorLocation(
381
+ node_id=node_id, node_type=node_type, field_path=field
382
+ ),
383
+ suggestion=(
384
+ f"'{field}' 는 주문 노드에서 무시됩니다. 실전/모의 구분은 "
385
+ "BrokerNode 의 paper_trading 또는 모의 credential 로 "
386
+ "결정하세요."
387
+ ),
388
+ details={"rule": RULE_ORDER_IGNORED_FIELD, "field": field},
389
+ ))
390
+
391
+ return errors
392
+
393
+
394
+ __all__ = [
395
+ "RULE_ORDER_QTY_FROM_AI",
396
+ "RULE_STRUCTURED_OUTPUT_NO_SCHEMA",
397
+ "RULE_HARDCODED_ORDER_QTY",
398
+ "RULE_ORDER_IGNORED_FIELD",
399
+ "ALL_RULES",
400
+ "DEFAULT_SEMANTIC_SEVERITIES",
401
+ "STRICT_SEMANTIC_SEVERITIES",
402
+ "normalize_severities",
403
+ "analyze_workflow_semantics",
404
+ ]
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.23.0"
8
+ version = "1.24.0"
9
9
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
10
10
  readme = "README.md"
11
11
 
@@ -28,7 +28,7 @@ lxml = "^6.0.2"
28
28
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
29
29
  aiosqlite = "^0.20.0"
30
30
  litellm = ">=1.40.0"
31
- programgarden-core = "^1.14.4"
31
+ programgarden-core = "^1.15.0"
32
32
  programgarden-finance = "^1.6.10"
33
33
  programgarden-community = "^1.13.8"
34
34
 
File without changes