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.
- {programgarden-1.23.0 → programgarden-1.24.0}/PKG-INFO +2 -2
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/__init__.py +21 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/client.py +12 -1
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/deep_fixtures.py +71 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/executor.py +194 -13
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resolver.py +164 -27
- programgarden-1.24.0/programgarden/semantic_rules.py +404 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/pyproject.toml +2 -2
- {programgarden-1.23.0 → programgarden-1.24.0}/README.md +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/context.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.23.0 → programgarden-1.24.0}/programgarden/tools/sqlite_tools.py +0 -0
- {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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
17638
|
-
|
|
17639
|
-
|
|
17640
|
-
|
|
17641
|
-
|
|
17642
|
-
|
|
17643
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.23.0 → programgarden-1.24.0}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|