programgarden-core 1.12.2__tar.gz → 1.12.3__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 (82) hide show
  1. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/PKG-INFO +1 -1
  2. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/__init__.py +15 -1
  3. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/__init__.py +29 -0
  4. programgarden_core-1.12.3/programgarden_core/models/validation.py +326 -0
  5. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/workflow.py +89 -22
  6. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/symbol.py +54 -1
  7. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/pyproject.toml +1 -1
  8. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/README.md +0 -0
  9. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/__init__.py +0 -0
  10. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/client.py +0 -0
  11. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/components.py +0 -0
  12. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/listener.py +0 -0
  13. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/mixins.py +0 -0
  14. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/products.py +0 -0
  15. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/sql.py +0 -0
  16. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/bases/storage.py +0 -0
  17. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/exceptions.py +0 -0
  18. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/expression/__init__.py +0 -0
  19. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/expression/evaluator.py +0 -0
  20. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/i18n/__init__.py +0 -0
  21. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/i18n/locales/en.json +0 -0
  22. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/i18n/locales/ko.json +0 -0
  23. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/i18n/translator.py +0 -0
  24. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/korea_alias.py +0 -0
  25. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/connection_rule.py +0 -0
  26. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/credential.py +0 -0
  27. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/edge.py +0 -0
  28. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/event.py +0 -0
  29. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/exchange.py +0 -0
  30. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/field_binding.py +0 -0
  31. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/job.py +0 -0
  32. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/plugin_resource.py +0 -0
  33. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/resilience.py +0 -0
  34. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/models/resource.py +0 -0
  35. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/__init__.py +0 -0
  36. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/account_futures.py +0 -0
  37. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/account_korea_stock.py +0 -0
  38. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/account_stock.py +0 -0
  39. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/ai.py +0 -0
  40. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/backtest.py +0 -0
  41. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/backtest_futures.py +0 -0
  42. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
  43. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/backtest_stock.py +0 -0
  44. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/base.py +0 -0
  45. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/broker.py +0 -0
  46. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/calculation.py +0 -0
  47. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/condition.py +0 -0
  48. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/data.py +0 -0
  49. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/data_futures.py +0 -0
  50. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/data_korea_stock.py +0 -0
  51. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/data_stock.py +0 -0
  52. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/display.py +0 -0
  53. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/event.py +0 -0
  54. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
  55. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/fundamental_stock.py +0 -0
  56. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/infra.py +0 -0
  57. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/market_external.py +0 -0
  58. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/market_status.py +0 -0
  59. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/open_orders_futures.py +0 -0
  60. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
  61. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/open_orders_stock.py +0 -0
  62. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/order.py +0 -0
  63. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/portfolio.py +0 -0
  64. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/realtime_futures.py +0 -0
  65. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
  66. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/realtime_stock.py +0 -0
  67. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/risk.py +0 -0
  68. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/symbol_futures.py +0 -0
  69. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
  70. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/symbol_stock.py +0 -0
  71. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/nodes/trigger.py +0 -0
  72. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/presets/__init__.py +0 -0
  73. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/presets/news_analyst.json +0 -0
  74. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/presets/risk_manager.json +0 -0
  75. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/presets/strategist.json +0 -0
  76. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/presets/technical_analyst.json +0 -0
  77. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/registry/__init__.py +0 -0
  78. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/registry/credential_registry.py +0 -0
  79. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/registry/dynamic_node_registry.py +0 -0
  80. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/registry/node_registry.py +0 -0
  81. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/registry/plugin_registry.py +0 -0
  82. {programgarden_core-1.12.2 → programgarden_core-1.12.3}/programgarden_core/retry_executor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: programgarden-core
3
- Version: 1.12.2
3
+ Version: 1.12.3
4
4
  Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
5
5
  Author: 프로그램동산
6
6
  Author-email: coding@programgarden.com
@@ -24,7 +24,7 @@ from programgarden_core.exceptions import *
24
24
  from programgarden_core import korea_alias
25
25
  from programgarden_core import bases
26
26
 
27
- __version__ = "2.0.0"
27
+ __version__ = "1.12.3"
28
28
  __all__ = [
29
29
  # Nodes - Base
30
30
  "BaseNode",
@@ -96,6 +96,20 @@ __all__ = [
96
96
  "JobState",
97
97
  "BrokerCredential",
98
98
  "Event",
99
+ # Validation (structured errors + recommendations)
100
+ "ErrorSeverity",
101
+ "ErrorCode",
102
+ "ErrorLocation",
103
+ "ErrorInfo",
104
+ "RecommendationCategory",
105
+ "Recommendation",
106
+ "ValidationLimits",
107
+ "ResultSummary",
108
+ "ValidationResult",
109
+ "CASCADE_SUPPRESSION_RULES",
110
+ "default_severity_for",
111
+ "suggest_close_match",
112
+ "build_error",
99
113
  # Registry
100
114
  "NodeTypeRegistry",
101
115
  "PluginRegistry",
@@ -78,6 +78,21 @@ from programgarden_core.models.connection_rule import (
78
78
  ConnectionRule,
79
79
  RateLimitConfig,
80
80
  )
81
+ from programgarden_core.models.validation import (
82
+ ErrorSeverity,
83
+ ErrorCode,
84
+ ErrorLocation,
85
+ ErrorInfo,
86
+ RecommendationCategory,
87
+ Recommendation,
88
+ ValidationLimits,
89
+ ResultSummary,
90
+ ValidationResult,
91
+ CASCADE_SUPPRESSION_RULES,
92
+ default_severity_for,
93
+ suggest_close_match,
94
+ build_error,
95
+ )
81
96
 
82
97
  __all__ = [
83
98
  # Edge
@@ -150,4 +165,18 @@ __all__ = [
150
165
  "ConnectionSeverity",
151
166
  "ConnectionRule",
152
167
  "RateLimitConfig",
168
+ # Validation
169
+ "ErrorSeverity",
170
+ "ErrorCode",
171
+ "ErrorLocation",
172
+ "ErrorInfo",
173
+ "RecommendationCategory",
174
+ "Recommendation",
175
+ "ValidationLimits",
176
+ "ResultSummary",
177
+ "ValidationResult",
178
+ "CASCADE_SUPPRESSION_RULES",
179
+ "default_severity_for",
180
+ "suggest_close_match",
181
+ "build_error",
153
182
  ]
@@ -0,0 +1,326 @@
1
+ """Structured validation errors for AI-chatbot-friendly workflow validation.
2
+
3
+ ErrorInfo objects let downstream consumers (AI chatbots, IDEs, dashboards)
4
+ make deterministic self-correction decisions based on error codes and
5
+ structured location metadata instead of parsing free-form strings.
6
+
7
+ All user-facing strings in this module (and all consumers) must be English.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from difflib import get_close_matches
12
+ from enum import Enum
13
+ from typing import Any, Dict, Iterable, List, Optional
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class ErrorSeverity(str, Enum):
19
+ ERROR = "error"
20
+ WARNING = "warning"
21
+
22
+
23
+ class ErrorCode(str, Enum):
24
+ # Definition / structure
25
+ DEFINITION_PARSE_ERROR = "DEFINITION_PARSE_ERROR"
26
+ DUPLICATE_NODE_ID = "DUPLICATE_NODE_ID"
27
+ RESERVED_NODE_ID = "RESERVED_NODE_ID"
28
+ MISSING_START_NODE = "MISSING_START_NODE"
29
+ MULTIPLE_START_NODES = "MULTIPLE_START_NODES"
30
+ CYCLE_DETECTED = "CYCLE_DETECTED"
31
+
32
+ # Node / plugin registry
33
+ UNKNOWN_NODE_TYPE = "UNKNOWN_NODE_TYPE"
34
+ UNKNOWN_DYNAMIC_NODE_SCHEMA = "UNKNOWN_DYNAMIC_NODE_SCHEMA"
35
+ DYNAMIC_NODE_CREDENTIAL_FORBIDDEN = "DYNAMIC_NODE_CREDENTIAL_FORBIDDEN"
36
+ MISSING_PLUGIN = "MISSING_PLUGIN"
37
+ UNKNOWN_PLUGIN = "UNKNOWN_PLUGIN"
38
+
39
+ # Edges
40
+ INVALID_EDGE_REF = "INVALID_EDGE_REF"
41
+ INVALID_EDGE_PORT = "INVALID_EDGE_PORT"
42
+
43
+ # Expressions / fields
44
+ INVALID_EXPRESSION_REF = "INVALID_EXPRESSION_REF"
45
+ INVALID_EXPRESSION_SYNTAX = "INVALID_EXPRESSION_SYNTAX"
46
+ MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD"
47
+ INVALID_FIELD_TYPE = "INVALID_FIELD_TYPE"
48
+ INVALID_FIELD_ENUM = "INVALID_FIELD_ENUM"
49
+
50
+ # Credentials
51
+ UNKNOWN_CREDENTIAL = "UNKNOWN_CREDENTIAL"
52
+
53
+ # Broker / connection rules
54
+ DUPLICATE_BROKER_NODE = "DUPLICATE_BROKER_NODE"
55
+ MISSING_REQUIRED_BROKER = "MISSING_REQUIRED_BROKER"
56
+ INCOMPATIBLE_BROKER_PROVIDER = "INCOMPATIBLE_BROKER_PROVIDER"
57
+ CONNECTION_RULE_VIOLATION = "CONNECTION_RULE_VIOLATION"
58
+
59
+ # Runtime (dry_run)
60
+ DRY_RUN_RUNTIME_ERROR = "DRY_RUN_RUNTIME_ERROR"
61
+ DRY_RUN_CREDENTIAL_MISSING = "DRY_RUN_CREDENTIAL_MISSING"
62
+ DRY_RUN_DEPENDENCY_FAILURE = "DRY_RUN_DEPENDENCY_FAILURE"
63
+
64
+
65
+ _DEFAULT_SEVERITY: Dict[ErrorCode, ErrorSeverity] = {
66
+ ErrorCode.UNKNOWN_PLUGIN: ErrorSeverity.WARNING,
67
+ }
68
+
69
+
70
+ def default_severity_for(code: ErrorCode) -> ErrorSeverity:
71
+ return _DEFAULT_SEVERITY.get(code, ErrorSeverity.ERROR)
72
+
73
+
74
+ class ErrorLocation(BaseModel):
75
+ """Where in the workflow definition the error occurred. All fields optional."""
76
+
77
+ node_id: Optional[str] = Field(default=None, description="Node id from definition.nodes[].id")
78
+ node_type: Optional[str] = Field(default=None, description="Node type (e.g. 'OverseasStockNewOrderNode')")
79
+ field_path: Optional[str] = Field(default=None, description="Dot/bracket notation: 'fields.period' or 'symbols[0].symbol'")
80
+ edge_index: Optional[int] = Field(default=None, description="Index into definition.edges[]")
81
+ edge_from: Optional[str] = Field(default=None, description="Edge.from raw value (may include dot port)")
82
+ edge_to: Optional[str] = Field(default=None, description="Edge.to raw value")
83
+ credential_id: Optional[str] = Field(default=None, description="Credential id from definition.credentials[]")
84
+ plugin_id: Optional[str] = Field(default=None, description="Plugin id referenced by ConditionNode etc.")
85
+ expression: Optional[str] = Field(default=None, description="Raw expression string when INVALID_EXPRESSION_*")
86
+ output_port: Optional[str] = Field(default=None, description="Output port name on source node")
87
+
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+
91
+ class RecommendationCategory(str, Enum):
92
+ DATA_FLOW = "data_flow"
93
+ RESILIENCE = "resilience"
94
+ PERFORMANCE = "performance"
95
+ SAFETY = "safety"
96
+ READABILITY = "readability"
97
+
98
+
99
+ class Recommendation(BaseModel):
100
+ """Non-blocking quality-improvement hint for AI chatbots and users.
101
+
102
+ Recommendations are deterministic, rule-based suggestions — never prescribe
103
+ a single fixed value. Always present options with rationale so the
104
+ consumer (chatbot or human) can pick the right one for their context.
105
+
106
+ `example_snippet` is a partial workflow fragment (one or two
107
+ nodes/edges) showing one applied option — not a full workflow JSON. The
108
+ chatbot is expected to merge it into the user's existing workflow.
109
+ """
110
+ title: str = Field(description="Short one-line hint (English). Use 'Consider X' tone.")
111
+ rationale: str = Field(description="Why this may improve the workflow — 1~2 sentences.")
112
+ category: RecommendationCategory
113
+ location: Optional[ErrorLocation] = Field(default=None, description="Optional anchor point in the workflow")
114
+ options: List[str] = Field(default_factory=list, description="2+ alternative approaches the user/AI may pick from")
115
+ example_snippet: Optional[Dict[str, Any]] = Field(
116
+ default=None,
117
+ description="Partial workflow JSON showing one applied option (node/edge fragment only — not a full workflow)",
118
+ )
119
+ rule_id: str = Field(description="Deterministic rule identifier for testing / suppression (e.g. 'REC_REALTIME_THROTTLE')")
120
+
121
+ model_config = ConfigDict(use_enum_values=True)
122
+
123
+ def short(self) -> str:
124
+ return f"[{self.rule_id}] {self.title} — {self.rationale}"
125
+
126
+
127
+ class ErrorInfo(BaseModel):
128
+ code: ErrorCode
129
+ severity: ErrorSeverity = Field(default=ErrorSeverity.ERROR)
130
+ location: ErrorLocation = Field(default_factory=ErrorLocation)
131
+ message: str = Field(description="Human-readable English message. One sentence preferred.")
132
+ suggestion: Optional[str] = Field(default=None, description="Single-line English remediation hint.")
133
+ available_values: Optional[List[str]] = Field(
134
+ default=None,
135
+ description="Closest valid candidates for enum/registry-style errors (suggest_close_match result).",
136
+ )
137
+ docs_url: Optional[str] = Field(default=None, description="Optional anchor link to documentation.")
138
+ details: Dict[str, Any] = Field(default_factory=dict, description="Free-form structured context.")
139
+ recommendations: List[Recommendation] = Field(
140
+ default_factory=list,
141
+ description="Optional improvement hints worth considering while fixing this error.",
142
+ )
143
+
144
+ model_config = ConfigDict(use_enum_values=True)
145
+
146
+ def short(self) -> str:
147
+ loc_parts: List[str] = []
148
+ if self.location.node_id:
149
+ loc_parts.append(f"node={self.location.node_id}")
150
+ if self.location.field_path:
151
+ loc_parts.append(f"field={self.location.field_path}")
152
+ if self.location.edge_index is not None:
153
+ loc_parts.append(f"edge[{self.location.edge_index}]")
154
+ suffix = f" ({', '.join(loc_parts)})" if loc_parts else ""
155
+ return f"[{self.code}] {self.message}{suffix}"
156
+
157
+
158
+ class ValidationLimits(BaseModel):
159
+ """Output volume limits to prevent overwhelming LLM context."""
160
+
161
+ max_errors: int = Field(default=20, ge=1)
162
+ max_warnings: int = Field(default=10, ge=1)
163
+ max_recommendations_per_channel: int = Field(default=10, ge=1)
164
+ max_per_node: int = Field(default=3, ge=1)
165
+
166
+ model_config = ConfigDict(extra="forbid")
167
+
168
+
169
+ class ResultSummary(BaseModel):
170
+ """Top-level summary for fast LLM triage of a ValidationResult."""
171
+
172
+ is_valid: bool
173
+ error_count: int = 0
174
+ warning_count: int = 0
175
+ static_recommendation_count: int = 0
176
+ runtime_recommendation_count: int = 0
177
+ critical_codes: List[ErrorCode] = Field(
178
+ default_factory=list,
179
+ description="Up to 3 most-impactful codes (cascade roots first, then frequency).",
180
+ )
181
+ root_cause_node_ids: List[str] = Field(
182
+ default_factory=list,
183
+ description="Node ids that triggered cascade suppression (fix these first).",
184
+ )
185
+ next_action_hint: Optional[str] = Field(
186
+ default=None,
187
+ description="Deterministic English single-line hint for the consumer's next step.",
188
+ )
189
+ truncated: bool = Field(
190
+ default=False,
191
+ description="True if any channel hit ValidationLimits and entries were dropped.",
192
+ )
193
+
194
+ model_config = ConfigDict(use_enum_values=True)
195
+
196
+
197
+ class ValidationResult(BaseModel):
198
+ """Structured validation outcome for a workflow definition.
199
+
200
+ Two recommendation channels exist:
201
+ - `static_recommendations`: filled by `executor.validate()` (topology
202
+ analysis only)
203
+ - `runtime_recommendations`: filled by `executor.execute(dry_run=True)`
204
+ based on actual node mock outputs (e.g. REC_EMPTY_SYMBOL_LIST)
205
+ """
206
+
207
+ errors: List[ErrorInfo] = Field(default_factory=list)
208
+ warnings: List[ErrorInfo] = Field(default_factory=list)
209
+ static_recommendations: List[Recommendation] = Field(default_factory=list)
210
+ runtime_recommendations: List[Recommendation] = Field(default_factory=list)
211
+ summary: Optional[ResultSummary] = Field(
212
+ default=None,
213
+ description="Populated after post-processing (cascade suppression + capping). None until finalize() runs.",
214
+ )
215
+ truncated: Dict[str, int] = Field(
216
+ default_factory=dict,
217
+ description="Per-channel count of entries dropped by ValidationLimits (e.g. {'errors': 5}).",
218
+ )
219
+
220
+ model_config = ConfigDict(use_enum_values=True)
221
+
222
+ @property
223
+ def is_valid(self) -> bool:
224
+ return not self.errors
225
+
226
+ def add(self, info: ErrorInfo) -> None:
227
+ """Append an ErrorInfo into errors/warnings by severity."""
228
+ if info.severity == ErrorSeverity.WARNING or info.severity == ErrorSeverity.WARNING.value:
229
+ self.warnings.append(info)
230
+ else:
231
+ self.errors.append(info)
232
+
233
+ def add_static_recommendation(self, rec: Recommendation) -> None:
234
+ self.static_recommendations.append(rec)
235
+
236
+ def add_runtime_recommendation(self, rec: Recommendation) -> None:
237
+ self.runtime_recommendations.append(rec)
238
+
239
+ def all_recommendations(self) -> List[Recommendation]:
240
+ """Merge static + runtime channels (read-only helper for consumers that don't distinguish stages)."""
241
+ return [*self.static_recommendations, *self.runtime_recommendations]
242
+
243
+ def as_strings(self) -> List[str]:
244
+ """Human-readable one-line per entry. Not a legacy compat shim — English-only."""
245
+ lines: List[str] = []
246
+ lines.extend(e.short() for e in self.errors)
247
+ lines.extend(w.short() for w in self.warnings)
248
+ lines.extend(r.short() for r in self.all_recommendations())
249
+ return lines
250
+
251
+
252
+ def suggest_close_match(
253
+ value: str,
254
+ candidates: Iterable[str],
255
+ cutoff: float = 0.6,
256
+ n: int = 3,
257
+ ) -> Optional[List[str]]:
258
+ """Return up to `n` closest matches above `cutoff`, or None if there are no matches.
259
+
260
+ Thin wrapper over `difflib.get_close_matches` with standardised defaults
261
+ for ErrorInfo.available_values.
262
+ """
263
+ matches = get_close_matches(value, list(candidates), n=n, cutoff=cutoff)
264
+ return matches or None
265
+
266
+
267
+ def build_error(
268
+ code: ErrorCode,
269
+ message: str,
270
+ *,
271
+ location: Optional[ErrorLocation] = None,
272
+ suggestion: Optional[str] = None,
273
+ available_values: Optional[List[str]] = None,
274
+ details: Optional[Dict[str, Any]] = None,
275
+ severity: Optional[ErrorSeverity] = None,
276
+ recommendations: Optional[List[Recommendation]] = None,
277
+ ) -> ErrorInfo:
278
+ """DRY helper for resolver.py and dry_run capture sites."""
279
+ return ErrorInfo(
280
+ code=code,
281
+ severity=severity or default_severity_for(code),
282
+ location=location or ErrorLocation(),
283
+ message=message,
284
+ suggestion=suggestion,
285
+ available_values=available_values,
286
+ details=details or {},
287
+ recommendations=recommendations or [],
288
+ )
289
+
290
+
291
+ # Cascade suppression rule data — actual application lives in Phase 4 post-processing.
292
+ # Each entry documents which subordinate error codes a given root cause hides.
293
+ CASCADE_SUPPRESSION_RULES: Dict[ErrorCode, str] = {
294
+ ErrorCode.UNKNOWN_NODE_TYPE: (
295
+ "Suppresses INVALID_EXPRESSION_REF / INVALID_EDGE_REF / INVALID_EDGE_PORT "
296
+ "where the source/target is the unknown node id."
297
+ ),
298
+ ErrorCode.MISSING_REQUIRED_BROKER: (
299
+ "Suppresses additional MISSING_REQUIRED_BROKER entries with the same "
300
+ "product_scope (keeps only the first)."
301
+ ),
302
+ ErrorCode.CYCLE_DETECTED: (
303
+ "Suppresses any follow-on validation errors on nodes inside the cycle."
304
+ ),
305
+ ErrorCode.DUPLICATE_NODE_ID: (
306
+ "Suppresses INVALID_EDGE_REF / INVALID_EXPRESSION_REF that reference the "
307
+ "duplicated id (since id resolution is ambiguous)."
308
+ ),
309
+ }
310
+
311
+
312
+ __all__ = [
313
+ "ErrorSeverity",
314
+ "ErrorCode",
315
+ "ErrorLocation",
316
+ "ErrorInfo",
317
+ "RecommendationCategory",
318
+ "Recommendation",
319
+ "ValidationLimits",
320
+ "ResultSummary",
321
+ "ValidationResult",
322
+ "CASCADE_SUPPRESSION_RULES",
323
+ "default_severity_for",
324
+ "suggest_close_match",
325
+ "build_error",
326
+ ]
@@ -206,42 +206,109 @@ class WorkflowDefinition(BaseModel):
206
206
  if node.get("id") not in nodes_with_input
207
207
  ]
208
208
 
209
- def validate_structure(self) -> List[str]:
210
- """
211
- 워크플로우 구조 검증
209
+ def validate_structure(self) -> List["ErrorInfo"]:
210
+ """Validate the workflow's structural invariants.
212
211
 
213
- Returns:
214
- 검증 오류 메시지 목록 (빈 리스트면 유효)
212
+ Returns a list of `ErrorInfo` objects. An empty list means no
213
+ structural problems were found. Callers should append the result
214
+ to a `ValidationResult` via `result.add(...)` for each entry.
215
215
  """
216
- errors = []
216
+ from programgarden_core.models.validation import (
217
+ ErrorCode,
218
+ ErrorInfo,
219
+ ErrorLocation,
220
+ build_error,
221
+ )
217
222
 
218
- # 1. Duplicate node ID check
223
+ errors: List[ErrorInfo] = []
224
+
225
+ # 1. Duplicate node IDs
219
226
  node_ids = self.get_node_ids()
220
227
  if len(node_ids) != len(set(node_ids)):
221
- seen = set()
222
- dupes = [nid for nid in node_ids if nid in seen or seen.add(nid)]
223
- errors.append(f"Duplicate node IDs found: {', '.join(dupes)}")
224
-
225
- # 2. Edge node reference check
228
+ seen: set = set()
229
+ dupes: List[str] = []
230
+ for nid in node_ids:
231
+ if nid in seen and nid not in dupes:
232
+ dupes.append(nid)
233
+ seen.add(nid)
234
+ for dup_id in dupes:
235
+ errors.append(
236
+ build_error(
237
+ ErrorCode.DUPLICATE_NODE_ID,
238
+ f"Duplicate node id '{dup_id}'",
239
+ location=ErrorLocation(node_id=dup_id),
240
+ suggestion="Rename one of the duplicated nodes so each id is unique.",
241
+ )
242
+ )
243
+
244
+ # 2. Edge node references
226
245
  node_id_set = set(node_ids)
227
- for edge in self.edges:
246
+ for idx, edge in enumerate(self.edges):
228
247
  if edge.from_node_id not in node_id_set:
229
- errors.append(f"Edge 'from' references non-existent node: {edge.from_node_id}")
248
+ errors.append(
249
+ build_error(
250
+ ErrorCode.INVALID_EDGE_REF,
251
+ f"Edge 'from' references non-existent node '{edge.from_node_id}'",
252
+ location=ErrorLocation(
253
+ edge_index=idx,
254
+ edge_from=edge.from_node_id,
255
+ edge_to=edge.to_node_id,
256
+ ),
257
+ available_values=sorted(node_id_set),
258
+ )
259
+ )
230
260
  if edge.to_node_id not in node_id_set:
231
- errors.append(f"Edge 'to' references non-existent node: {edge.to_node_id}")
232
-
233
- # 3. StartNode check
261
+ errors.append(
262
+ build_error(
263
+ ErrorCode.INVALID_EDGE_REF,
264
+ f"Edge 'to' references non-existent node '{edge.to_node_id}'",
265
+ location=ErrorLocation(
266
+ edge_index=idx,
267
+ edge_from=edge.from_node_id,
268
+ edge_to=edge.to_node_id,
269
+ ),
270
+ available_values=sorted(node_id_set),
271
+ )
272
+ )
273
+
274
+ # 3. StartNode invariants
234
275
  start_nodes = [n for n in self.nodes if n.get("type") == "StartNode"]
235
276
  if not start_nodes:
236
- errors.append("StartNode is required (exactly 1 per workflow)")
277
+ errors.append(
278
+ build_error(
279
+ ErrorCode.MISSING_START_NODE,
280
+ "Workflow must contain exactly one StartNode",
281
+ suggestion="Add a StartNode at the entry of the main flow.",
282
+ )
283
+ )
237
284
  elif len(start_nodes) > 1:
238
- errors.append("Multiple StartNodes found (only 1 allowed per workflow)")
239
-
240
- # 4. Cycle detection (DAG validation)
285
+ offending_ids = [n.get("id") for n in start_nodes if n.get("id")]
286
+ errors.append(
287
+ build_error(
288
+ ErrorCode.MULTIPLE_START_NODES,
289
+ f"Workflow has {len(start_nodes)} StartNodes; only one is allowed",
290
+ location=ErrorLocation(
291
+ node_id=offending_ids[0] if offending_ids else None,
292
+ node_type="StartNode",
293
+ ),
294
+ suggestion="Keep one StartNode and remove the others (use ScheduleNode for cron triggers).",
295
+ details={"start_node_ids": offending_ids},
296
+ )
297
+ )
298
+
299
+ # 4. Cycle detection
241
300
  cycle = self._detect_cycle()
242
301
  if cycle:
243
302
  cycle_path = " -> ".join(cycle)
244
- errors.append(f"Circular reference detected: {cycle_path}")
303
+ errors.append(
304
+ build_error(
305
+ ErrorCode.CYCLE_DETECTED,
306
+ f"Cycle detected in the workflow DAG: {cycle_path}",
307
+ location=ErrorLocation(node_id=cycle[0] if cycle else None),
308
+ suggestion="Remove one of the edges in the cycle so execution order can be resolved.",
309
+ details={"cycle_path": cycle},
310
+ )
311
+ )
245
312
 
246
313
  return errors
247
314
 
@@ -400,6 +400,14 @@ class ScreenerNode(BaseNode):
400
400
  default=None,
401
401
  description="최소 평균 거래량 (주). 예: 1000000 = 100만주",
402
402
  )
403
+ price_min: Optional[float] = Field(
404
+ default=None,
405
+ description="최소 주가 ($). 예: 1.0 = $1 이상. 동전주 발굴 등 가격대 필터링에 사용",
406
+ )
407
+ price_max: Optional[float] = Field(
408
+ default=None,
409
+ description="최대 주가 ($). 예: 5.0 = $5 이하. 동전주 발굴 등 가격대 필터링에 사용",
410
+ )
403
411
  sector: Optional[str] = Field(
404
412
  default=None,
405
413
  description="섹터 필터 (Technology, Healthcare, Finance 등)",
@@ -409,10 +417,15 @@ class ScreenerNode(BaseNode):
409
417
  description="거래소 필터 (NASDAQ, NYSE, AMEX)",
410
418
  )
411
419
  max_results: int = Field(
412
- default=100,
420
+ default=100,
413
421
  description="최대 결과 수"
414
422
  )
415
423
 
424
+ data_source: Literal["auto", "ls", "yfinance"] = Field(
425
+ default="auto",
426
+ description="자료 가져올 곳. auto=브로커 연결 시 LS증권, 아니면 Yahoo Finance. ls=LS증권 강제. yfinance=Yahoo Finance 강제.",
427
+ )
428
+
416
429
  _inputs: List[InputPort] = [
417
430
  InputPort(
418
431
  name="symbols",
@@ -582,6 +595,29 @@ class ScreenerNode(BaseNode):
582
595
  placeholder="예: 1000000 (100만주)",
583
596
  expected_type="int",
584
597
  ),
598
+ # === PARAMETERS: 가격 필터 ===
599
+ "price_min": FieldSchema(
600
+ name="price_min",
601
+ type=FieldType.NUMBER,
602
+ description="최소 주가 (달러). 동전주 발굴이나 저가주 필터링에 사용하세요. 예: 1.0 = $1 이상.",
603
+ required=False,
604
+ category=FieldCategory.PARAMETERS,
605
+ expression_mode=ExpressionMode.FIXED_ONLY,
606
+ example=1.0,
607
+ placeholder="예: 1.0 ($1 이상)",
608
+ expected_type="float",
609
+ ),
610
+ "price_max": FieldSchema(
611
+ name="price_max",
612
+ type=FieldType.NUMBER,
613
+ description="최대 주가 (달러). 고가주 제외나 동전주 상한선 설정에 사용하세요. 예: 5.0 = $5 이하.",
614
+ required=False,
615
+ category=FieldCategory.PARAMETERS,
616
+ expression_mode=ExpressionMode.FIXED_ONLY,
617
+ example=5.0,
618
+ placeholder="예: 5.0 ($5 이하)",
619
+ expected_type="float",
620
+ ),
585
621
  # === PARAMETERS: 섹터/거래소 필터 ===
586
622
  "sector": FieldSchema(
587
623
  name="sector",
@@ -625,6 +661,23 @@ class ScreenerNode(BaseNode):
625
661
  example="NASDAQ",
626
662
  expected_type="str",
627
663
  ),
664
+ # === PARAMETERS: 자료 가져올 곳 ===
665
+ "data_source": FieldSchema(
666
+ name="data_source",
667
+ type=FieldType.ENUM,
668
+ description="가격/시가총액/거래량 정보를 어디에서 받아올지 선택하세요. '자동'을 권장합니다.",
669
+ default="auto",
670
+ enum_values=["auto", "ls", "yfinance"],
671
+ enum_labels={
672
+ "auto": "자동 (브로커 연결 시 LS증권 사용, 없으면 Yahoo Finance)",
673
+ "ls": "LS증권 (빠름, 브로커 연결 필요)",
674
+ "yfinance": "Yahoo Finance (외부 API)",
675
+ },
676
+ category=FieldCategory.PARAMETERS,
677
+ expression_mode=ExpressionMode.FIXED_ONLY,
678
+ example="auto",
679
+ expected_type="str",
680
+ ),
628
681
  # === SETTINGS: 결과 제한 ===
629
682
  "max_results": FieldSchema(
630
683
  name="max_results",
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden-core"
8
- version = "1.12.2"
8
+ version = "1.12.3"
9
9
  description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
10
10
  readme = "README.md"
11
11