krons 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +56 -74
- krons/core/base/__init__.py +121 -0
- krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
- krons/core/{element.py → base/element.py} +13 -5
- krons/core/{event.py → base/event.py} +39 -6
- krons/core/{eventbus.py → base/eventbus.py} +3 -1
- krons/core/{flow.py → base/flow.py} +11 -4
- krons/core/{graph.py → base/graph.py} +24 -8
- krons/core/{node.py → base/node.py} +44 -19
- krons/core/{pile.py → base/pile.py} +22 -8
- krons/core/{processor.py → base/processor.py} +21 -7
- krons/core/{progression.py → base/progression.py} +3 -1
- krons/{specs → core/specs}/__init__.py +0 -5
- krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
- krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
- krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
- krons/{specs → core/specs}/catalog/__init__.py +2 -2
- krons/{specs → core/specs}/catalog/_audit.py +2 -2
- krons/{specs → core/specs}/catalog/_common.py +2 -2
- krons/{specs → core/specs}/catalog/_content.py +4 -4
- krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
- krons/{specs → core/specs}/factory.py +5 -5
- krons/{specs → core/specs}/operable.py +8 -2
- krons/{specs → core/specs}/protocol.py +4 -2
- krons/{specs → core/specs}/spec.py +23 -11
- krons/{types → core/types}/base.py +4 -2
- krons/{types → core/types}/db_types.py +2 -2
- krons/errors.py +13 -13
- krons/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- krons/{services → resource}/backend.py +48 -22
- krons/{services → resource}/endpoint.py +28 -14
- krons/{services → resource}/hook.py +20 -7
- krons/{services → resource}/imodel.py +46 -28
- krons/{services → resource}/registry.py +26 -24
- krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
- krons/{services → resource}/utilities/rate_limiter.py +3 -1
- krons/{services → resource}/utilities/resilience.py +15 -5
- krons/resource/utilities/token_calculator.py +185 -0
- krons/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- krons/session/exchange.py +11 -3
- krons/session/message.py +3 -1
- krons/session/registry.py +35 -0
- krons/session/session.py +165 -174
- krons/utils/__init__.py +45 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- krons/utils/_to_list.py +9 -3
- krons/utils/_utils.py +6 -2
- krons/utils/concurrency/_async_call.py +4 -2
- krons/utils/concurrency/_errors.py +3 -1
- krons/utils/concurrency/_patterns.py +3 -1
- krons/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- krons/utils/fuzzy/__init__.py +6 -1
- krons/utils/fuzzy/_fuzzy_match.py +14 -8
- krons/utils/fuzzy/_string_similarity.py +3 -1
- krons/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- krons/{operations → work/operations}/__init__.py +7 -4
- krons/{operations → work/operations}/builder.py +1 -1
- krons/{enforcement → work/operations}/context.py +36 -5
- krons/{operations → work/operations}/flow.py +13 -5
- krons/{operations → work/operations}/node.py +45 -43
- krons/work/operations/registry.py +103 -0
- krons/{specs → work}/phrase.py +130 -13
- krons/{enforcement → work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- krons/{enforcement → work/rules}/common/boolean.py +3 -1
- krons/{enforcement → work/rules}/common/choice.py +9 -3
- krons/{enforcement → work/rules}/common/number.py +3 -1
- krons/{enforcement → work/rules}/common/string.py +9 -3
- krons/{enforcement → work/rules}/rule.py +1 -1
- krons/{enforcement → work/rules}/validator.py +20 -5
- krons/{enforcement → work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
- krons-0.2.0.dist-info/RECORD +154 -0
- krons/enforcement/__init__.py +0 -57
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- krons-0.1.1.dist-info/RECORD +0 -101
- /krons/{specs → core/specs}/adapters/__init__.py +0 -0
- /krons/{specs → core/specs}/adapters/_utils.py +0 -0
- /krons/{specs → core/specs}/adapters/factory.py +0 -0
- /krons/{types → core/types}/__init__.py +0 -0
- /krons/{types → core/types}/_sentinel.py +0 -0
- /krons/{types → core/types}/identity.py +0 -0
- /krons/{services → resource}/utilities/__init__.py +0 -0
- /krons/{services → resource}/utilities/header_factory.py +0 -0
- /krons/{enforcement → work/rules}/common/__init__.py +0 -0
- /krons/{enforcement → work/rules}/common/mapping.py +0 -0
- /krons/{enforcement → work/rules}/common/model.py +0 -0
- /krons/{enforcement → work/rules}/registry.py +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Per-session operation handler registry.
|
|
5
|
+
|
|
6
|
+
Maps operation names to async handlers. Instantiated per-Session
|
|
7
|
+
for isolation, testability, and per-session customization.
|
|
8
|
+
|
|
9
|
+
Handler signature: async handler(params, ctx: RequestContext) -> result
|
|
10
|
+
Operation._invoke() creates the RequestContext from bound session/branch.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
__all__ = ("OperationHandler", "OperationRegistry")
|
|
19
|
+
|
|
20
|
+
OperationHandler = Callable[..., Awaitable[Any]]
|
|
21
|
+
"""Handler signature: async (params, ctx: RequestContext) -> result"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OperationRegistry:
|
|
25
|
+
"""Map operation names to async handler functions.
|
|
26
|
+
|
|
27
|
+
Per-session registry (not global) for isolation and testability.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
from krons.agent.operations import generate, structure, operate
|
|
31
|
+
|
|
32
|
+
registry = OperationRegistry()
|
|
33
|
+
registry.register("generate", generate)
|
|
34
|
+
registry.register("structure", structure)
|
|
35
|
+
registry.register("operate", operate)
|
|
36
|
+
|
|
37
|
+
# Called by Operation._invoke() — users call session.conduct()
|
|
38
|
+
handler = registry.get("generate")
|
|
39
|
+
result = await handler(params, ctx)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
"""Initialize empty registry."""
|
|
44
|
+
self._handlers: dict[str, OperationHandler] = {}
|
|
45
|
+
|
|
46
|
+
def register(
|
|
47
|
+
self,
|
|
48
|
+
operation_name: str,
|
|
49
|
+
handler: OperationHandler,
|
|
50
|
+
*,
|
|
51
|
+
override: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Register handler for operation name.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
operation_name: Lookup key (e.g. "generate", "operate").
|
|
57
|
+
handler: Async (params, ctx) -> result.
|
|
58
|
+
override: Allow replacing existing. Default False.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If name exists and override=False.
|
|
62
|
+
"""
|
|
63
|
+
if operation_name in self._handlers and not override:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Operation '{operation_name}' already registered. "
|
|
66
|
+
"Use override=True to replace."
|
|
67
|
+
)
|
|
68
|
+
self._handlers[operation_name] = handler
|
|
69
|
+
|
|
70
|
+
def get(self, operation_name: str) -> OperationHandler:
|
|
71
|
+
"""Get handler by name. Raises KeyError with available names if not found."""
|
|
72
|
+
if operation_name not in self._handlers:
|
|
73
|
+
raise KeyError(
|
|
74
|
+
f"Operation '{operation_name}' not registered. "
|
|
75
|
+
f"Available: {self.list_names()}"
|
|
76
|
+
)
|
|
77
|
+
return self._handlers[operation_name]
|
|
78
|
+
|
|
79
|
+
def has(self, operation_name: str) -> bool:
|
|
80
|
+
"""Check if name is registered."""
|
|
81
|
+
return operation_name in self._handlers
|
|
82
|
+
|
|
83
|
+
def unregister(self, operation_name: str) -> bool:
|
|
84
|
+
"""Remove registration. Returns True if existed."""
|
|
85
|
+
if operation_name in self._handlers:
|
|
86
|
+
del self._handlers[operation_name]
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def list_names(self) -> list[str]:
|
|
91
|
+
"""Return all registered operation names."""
|
|
92
|
+
return list(self._handlers.keys())
|
|
93
|
+
|
|
94
|
+
def __contains__(self, operation_name: str) -> bool:
|
|
95
|
+
"""Support 'name in registry' syntax."""
|
|
96
|
+
return operation_name in self._handlers
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
"""Count of registered operations."""
|
|
100
|
+
return len(self._handlers)
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"OperationRegistry(operations={self.list_names()})"
|
krons/{specs → work}/phrase.py
RENAMED
|
@@ -6,7 +6,7 @@ A Phrase wraps an async handler with:
|
|
|
6
6
|
- Validation via Operable
|
|
7
7
|
|
|
8
8
|
Usage with decorator (custom handler):
|
|
9
|
-
from krons.specs import Operable, phrase
|
|
9
|
+
from krons.core.specs import Operable, phrase
|
|
10
10
|
|
|
11
11
|
consent_operable = Operable([
|
|
12
12
|
Spec("subject_id", UUID),
|
|
@@ -25,7 +25,7 @@ Usage with decorator (custom handler):
|
|
|
25
25
|
result = await verify_consent({"subject_id": id, "scope": "background"}, ctx)
|
|
26
26
|
|
|
27
27
|
Usage with CrudPattern (declarative):
|
|
28
|
-
from krons.specs import Operable, phrase, CrudPattern
|
|
28
|
+
from krons.core.specs import Operable, phrase, CrudPattern
|
|
29
29
|
|
|
30
30
|
def check_has_consent(row):
|
|
31
31
|
return {"has_consent": row["status"] in {"active"} if row else False}
|
|
@@ -48,12 +48,14 @@ from collections.abc import Awaitable, Callable, Mapping
|
|
|
48
48
|
from dataclasses import dataclass
|
|
49
49
|
from enum import Enum
|
|
50
50
|
from types import MappingProxyType
|
|
51
|
-
from typing import Any
|
|
51
|
+
from typing import TYPE_CHECKING, Any
|
|
52
52
|
|
|
53
|
-
from krons.
|
|
53
|
+
from krons.core.specs.operable import Operable
|
|
54
|
+
from krons.core.types import Unset, is_unset
|
|
54
55
|
from krons.utils.sql import validate_identifier
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from krons.work.operations.node import Operation
|
|
57
59
|
|
|
58
60
|
__all__ = ("CrudPattern", "CrudOperation", "Phrase", "phrase")
|
|
59
61
|
|
|
@@ -111,18 +113,31 @@ class CrudPattern:
|
|
|
111
113
|
object.__setattr__(self, "lookup", frozenset(self.lookup))
|
|
112
114
|
# Normalize None mappings to immutable empty maps; freeze mutable dicts
|
|
113
115
|
object.__setattr__(
|
|
114
|
-
self,
|
|
115
|
-
|
|
116
|
+
self,
|
|
117
|
+
"filters",
|
|
118
|
+
(
|
|
119
|
+
_EMPTY_MAP
|
|
120
|
+
if self.filters is None
|
|
121
|
+
else MappingProxyType(dict(self.filters))
|
|
122
|
+
),
|
|
116
123
|
)
|
|
117
124
|
object.__setattr__(
|
|
118
|
-
self,
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
self,
|
|
126
|
+
"set_fields",
|
|
127
|
+
(
|
|
128
|
+
_EMPTY_MAP
|
|
129
|
+
if self.set_fields is None
|
|
130
|
+
else MappingProxyType(dict(self.set_fields))
|
|
131
|
+
),
|
|
121
132
|
)
|
|
122
133
|
object.__setattr__(
|
|
123
|
-
self,
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
self,
|
|
135
|
+
"defaults",
|
|
136
|
+
(
|
|
137
|
+
_EMPTY_MAP
|
|
138
|
+
if self.defaults is None
|
|
139
|
+
else MappingProxyType(dict(self.defaults))
|
|
140
|
+
),
|
|
126
141
|
)
|
|
127
142
|
|
|
128
143
|
|
|
@@ -307,6 +322,108 @@ class Phrase:
|
|
|
307
322
|
)
|
|
308
323
|
return self._result_type
|
|
309
324
|
|
|
325
|
+
# --- Form-like interface ---
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def input_fields(self) -> list[str]:
|
|
329
|
+
"""Input field names (Form-compatible interface)."""
|
|
330
|
+
return list(self.inputs)
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def output_fields(self) -> list[str]:
|
|
334
|
+
"""Output field names (Form-compatible interface)."""
|
|
335
|
+
return list(self.outputs)
|
|
336
|
+
|
|
337
|
+
def is_workable(self, available_data: dict[str, Any]) -> bool:
|
|
338
|
+
"""Check if all inputs are available in data dict.
|
|
339
|
+
|
|
340
|
+
Enables Form/Report-style scheduling based on data availability.
|
|
341
|
+
"""
|
|
342
|
+
return all(field in available_data for field in self.inputs)
|
|
343
|
+
|
|
344
|
+
def extract_inputs(self, available_data: dict[str, Any]) -> dict[str, Any]:
|
|
345
|
+
"""Extract input values from available data.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Dict with only the fields declared as inputs.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
KeyError: If any required input is missing.
|
|
352
|
+
"""
|
|
353
|
+
return {field: available_data[field] for field in self.inputs}
|
|
354
|
+
|
|
355
|
+
# --- Operation bridge ---
|
|
356
|
+
|
|
357
|
+
def as_operation(
|
|
358
|
+
self,
|
|
359
|
+
options: dict[str, Any] | None = None,
|
|
360
|
+
*,
|
|
361
|
+
available_data: dict[str, Any] | None = None,
|
|
362
|
+
ctx: Any = None,
|
|
363
|
+
**metadata,
|
|
364
|
+
) -> "Operation":
|
|
365
|
+
"""Create an Operation that invokes this phrase.
|
|
366
|
+
|
|
367
|
+
The Operation can participate in DAG flow while preserving
|
|
368
|
+
the phrase's typed I/O semantics.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
options: Direct input values (takes precedence)
|
|
372
|
+
available_data: Data dict to extract inputs from (if options not given)
|
|
373
|
+
ctx: Execution context passed to phrase handler
|
|
374
|
+
**metadata: Additional metadata for Operation
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
PhraseOperation instance ready for DAG execution.
|
|
378
|
+
|
|
379
|
+
Example:
|
|
380
|
+
# Direct options
|
|
381
|
+
op = phrase.as_operation({"subject_id": id, "scope": "read"})
|
|
382
|
+
|
|
383
|
+
# From available data (Form/Report pattern)
|
|
384
|
+
op = phrase.as_operation(available_data=report.available_data)
|
|
385
|
+
|
|
386
|
+
# Build DAG
|
|
387
|
+
graph = Graph()
|
|
388
|
+
graph.add_node(phrase1.as_operation({"input": "x"}))
|
|
389
|
+
await flow(session, graph)
|
|
390
|
+
"""
|
|
391
|
+
from krons.work.operations.node import Operation
|
|
392
|
+
|
|
393
|
+
# Resolve options
|
|
394
|
+
if options is not None:
|
|
395
|
+
resolved_options = dict(options)
|
|
396
|
+
elif available_data is not None:
|
|
397
|
+
if not self.is_workable(available_data):
|
|
398
|
+
missing = [f for f in self.inputs if f not in available_data]
|
|
399
|
+
raise ValueError(f"Missing required inputs: {missing}")
|
|
400
|
+
resolved_options = self.extract_inputs(available_data)
|
|
401
|
+
else:
|
|
402
|
+
raise ValueError("Either options or available_data must be provided")
|
|
403
|
+
|
|
404
|
+
# Create closure-based operation that bypasses registry
|
|
405
|
+
phrase = self
|
|
406
|
+
bound_ctx = ctx
|
|
407
|
+
|
|
408
|
+
class PhraseOperation(Operation):
|
|
409
|
+
"""Operation wrapping a Phrase for direct invocation."""
|
|
410
|
+
|
|
411
|
+
async def _invoke(self) -> Any:
|
|
412
|
+
# Direct phrase invocation - no registry lookup
|
|
413
|
+
return await phrase(self.parameters, bound_ctx)
|
|
414
|
+
|
|
415
|
+
return PhraseOperation(
|
|
416
|
+
operation_type=self.name,
|
|
417
|
+
parameters=resolved_options,
|
|
418
|
+
metadata={
|
|
419
|
+
"name": self.name,
|
|
420
|
+
"phrase": True,
|
|
421
|
+
"input_fields": self.input_fields,
|
|
422
|
+
"output_fields": self.output_fields,
|
|
423
|
+
**metadata,
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
310
427
|
|
|
311
428
|
def _to_pascal(snake_name: str) -> str:
|
|
312
429
|
"""Convert snake_case name to PascalCase.
|
|
@@ -13,9 +13,9 @@ from collections.abc import Sequence
|
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from typing import Any, Protocol, runtime_checkable
|
|
15
15
|
|
|
16
|
-
from krons.
|
|
17
|
-
from krons.
|
|
18
|
-
from krons.
|
|
16
|
+
from krons.core.specs.catalog._enforcement import EnforcementLevel
|
|
17
|
+
from krons.core.types.base import DataClass
|
|
18
|
+
from krons.work.operations.context import RequestContext
|
|
19
19
|
|
|
20
20
|
__all__ = (
|
|
21
21
|
"EnforcementLevel",
|
krons/work/report.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Report - Multi-step workflow orchestration.
|
|
5
|
+
|
|
6
|
+
A Report orchestrates multiple Forms based on data availability:
|
|
7
|
+
- Schedules forms when their inputs become available
|
|
8
|
+
- Groups forms by branch for sequential execution within branch
|
|
9
|
+
- Propagates outputs between forms
|
|
10
|
+
- Tracks overall workflow completion
|
|
11
|
+
|
|
12
|
+
This is the scheduling layer that enables data-driven DAG execution.
|
|
13
|
+
|
|
14
|
+
The Report pattern supports declarative workflow definition:
|
|
15
|
+
- Fields as class attributes (typed outputs)
|
|
16
|
+
- form_assignments DSL with branch/resource hints
|
|
17
|
+
- Implicit dependencies from data flow
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pydantic import Field
|
|
26
|
+
|
|
27
|
+
from krons.core import Element, Pile
|
|
28
|
+
|
|
29
|
+
from .form import Form, parse_assignment
|
|
30
|
+
|
|
31
|
+
__all__ = ("Report",)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Report(Element):
|
|
35
|
+
"""Workflow orchestrator - schedules forms based on field availability.
|
|
36
|
+
|
|
37
|
+
A Report manages a collection of Forms, executing them as their
|
|
38
|
+
inputs become available. Forms are grouped by branch - forms on the
|
|
39
|
+
same branch execute sequentially, different branches can run in parallel.
|
|
40
|
+
|
|
41
|
+
Example (simple):
|
|
42
|
+
report = Report(
|
|
43
|
+
assignment="context -> final_score",
|
|
44
|
+
form_assignments=[
|
|
45
|
+
"context -> analysis",
|
|
46
|
+
"analysis -> score",
|
|
47
|
+
"score -> final_score",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
report.initialize(context="some input")
|
|
51
|
+
|
|
52
|
+
while not report.is_complete():
|
|
53
|
+
for form in report.next_forms():
|
|
54
|
+
await form.execute(ctx)
|
|
55
|
+
report.complete_form(form)
|
|
56
|
+
|
|
57
|
+
Example (with branches and resources):
|
|
58
|
+
class HiringBriefReport(Report):
|
|
59
|
+
role_classification: RoleClassification | None = None
|
|
60
|
+
strategic_context: StrategicContext | None = None
|
|
61
|
+
|
|
62
|
+
assignment: str = "job_input -> executive_summary"
|
|
63
|
+
|
|
64
|
+
form_assignments: list[str] = [
|
|
65
|
+
"classifier: job_input -> role_classification | api:fast",
|
|
66
|
+
"strategist: job_input, role_classification -> strategic_context | api:synthesis",
|
|
67
|
+
"writer: strategic_context -> executive_summary | api:reasoning",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
assignment: Overall workflow 'inputs -> final_outputs'
|
|
72
|
+
form_assignments: List of form assignments with optional branch/resource
|
|
73
|
+
input_fields: Workflow input fields
|
|
74
|
+
output_fields: Workflow output fields
|
|
75
|
+
forms: All forms in workflow
|
|
76
|
+
completed_forms: Forms that have finished
|
|
77
|
+
available_data: Current state of all field values
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
assignment: str = Field(
|
|
81
|
+
default="",
|
|
82
|
+
description="Overall workflow: 'inputs -> final_outputs'",
|
|
83
|
+
)
|
|
84
|
+
form_assignments: list[str] = Field(
|
|
85
|
+
default_factory=list,
|
|
86
|
+
description="List of form assignments: ['branch: a -> b | resource', ...]",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
input_fields: list[str] = Field(default_factory=list)
|
|
90
|
+
output_fields: list[str] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
forms: Pile[Form] = Field(
|
|
93
|
+
default_factory=lambda: Pile(item_type=Form),
|
|
94
|
+
description="All forms in the workflow",
|
|
95
|
+
)
|
|
96
|
+
completed_forms: Pile[Form] = Field(
|
|
97
|
+
default_factory=lambda: Pile(item_type=Form),
|
|
98
|
+
description="Completed forms",
|
|
99
|
+
)
|
|
100
|
+
available_data: dict[str, Any] = Field(
|
|
101
|
+
default_factory=dict,
|
|
102
|
+
description="Current state of all field values",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Branch tracking: branch_name -> list of form IDs in order
|
|
106
|
+
_branch_forms: dict[str, list[Form]] = {}
|
|
107
|
+
# Track last completed form per branch for sequential execution
|
|
108
|
+
_branch_progress: dict[str, int] = {}
|
|
109
|
+
|
|
110
|
+
def model_post_init(self, _: Any) -> None:
|
|
111
|
+
"""Parse assignment and create forms."""
|
|
112
|
+
self._branch_forms = defaultdict(list)
|
|
113
|
+
self._branch_progress = defaultdict(int)
|
|
114
|
+
|
|
115
|
+
if not self.assignment:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Parse overall assignment
|
|
119
|
+
self.input_fields, self.output_fields = parse_assignment(self.assignment)
|
|
120
|
+
|
|
121
|
+
# Create forms from form_assignments
|
|
122
|
+
for fa in self.form_assignments:
|
|
123
|
+
form = Form(assignment=fa)
|
|
124
|
+
self.forms.include(form)
|
|
125
|
+
|
|
126
|
+
# Track by branch
|
|
127
|
+
branch = form.branch or "_default"
|
|
128
|
+
self._branch_forms[branch].append(form)
|
|
129
|
+
|
|
130
|
+
def initialize(self, **inputs: Any) -> None:
|
|
131
|
+
"""Provide initial input data.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
**inputs: Initial field values
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If required input is missing
|
|
138
|
+
"""
|
|
139
|
+
for field in self.input_fields:
|
|
140
|
+
if field not in inputs:
|
|
141
|
+
raise ValueError(f"Missing required input: '{field}'")
|
|
142
|
+
self.available_data[field] = inputs[field]
|
|
143
|
+
|
|
144
|
+
def next_forms(self) -> list[Form]:
|
|
145
|
+
"""Get forms that are ready to execute.
|
|
146
|
+
|
|
147
|
+
Forms with explicit branches execute sequentially within their branch.
|
|
148
|
+
Forms without branches (None) execute in parallel based on data availability.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of forms with all inputs available and not yet filled
|
|
152
|
+
"""
|
|
153
|
+
ready = []
|
|
154
|
+
|
|
155
|
+
for branch, forms in self._branch_forms.items():
|
|
156
|
+
if branch == "_default":
|
|
157
|
+
# No explicit branch - parallel execution based on data
|
|
158
|
+
for form in forms:
|
|
159
|
+
if form.filled:
|
|
160
|
+
continue
|
|
161
|
+
form.available_data = self.available_data.copy()
|
|
162
|
+
if form.is_workable():
|
|
163
|
+
ready.append(form)
|
|
164
|
+
else:
|
|
165
|
+
# Explicit branch - sequential execution
|
|
166
|
+
progress = self._branch_progress[branch]
|
|
167
|
+
|
|
168
|
+
# Only consider the next form in this branch
|
|
169
|
+
if progress < len(forms):
|
|
170
|
+
form = forms[progress]
|
|
171
|
+
if form.filled:
|
|
172
|
+
# Already done, advance progress
|
|
173
|
+
self._branch_progress[branch] += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
form.available_data = self.available_data.copy()
|
|
177
|
+
if form.is_workable():
|
|
178
|
+
ready.append(form)
|
|
179
|
+
|
|
180
|
+
return ready
|
|
181
|
+
|
|
182
|
+
def complete_form(self, form: Form) -> None:
|
|
183
|
+
"""Mark a form as completed and update available data.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
form: The completed form
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If form is not filled
|
|
190
|
+
"""
|
|
191
|
+
if not form.filled:
|
|
192
|
+
raise ValueError("Form is not filled")
|
|
193
|
+
|
|
194
|
+
self.completed_forms.include(form)
|
|
195
|
+
|
|
196
|
+
# Advance branch progress
|
|
197
|
+
branch = form.branch or "_default"
|
|
198
|
+
if branch in self._branch_forms:
|
|
199
|
+
forms = self._branch_forms[branch]
|
|
200
|
+
progress = self._branch_progress[branch]
|
|
201
|
+
if progress < len(forms) and forms[progress].id == form.id:
|
|
202
|
+
self._branch_progress[branch] += 1
|
|
203
|
+
|
|
204
|
+
# Update available data with form outputs
|
|
205
|
+
output_data = form.get_output_data()
|
|
206
|
+
self.available_data.update(output_data)
|
|
207
|
+
|
|
208
|
+
def is_complete(self) -> bool:
|
|
209
|
+
"""Check if all output fields are available.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if workflow is complete
|
|
213
|
+
"""
|
|
214
|
+
return all(field in self.available_data for field in self.output_fields)
|
|
215
|
+
|
|
216
|
+
def get_deliverable(self) -> dict[str, Any]:
|
|
217
|
+
"""Get final output values.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Dict of output field values
|
|
221
|
+
"""
|
|
222
|
+
return {f: self.available_data.get(f) for f in self.output_fields}
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def progress(self) -> tuple[int, int]:
|
|
226
|
+
"""Get progress as (completed, total).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (completed forms, total forms)
|
|
230
|
+
"""
|
|
231
|
+
return len(self.completed_forms), len(self.forms)
|
|
232
|
+
|
|
233
|
+
def get_forms_by_branch(self, branch: str) -> list[Form]:
|
|
234
|
+
"""Get all forms for a specific branch.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
branch: Branch name
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of forms on that branch (in order)
|
|
241
|
+
"""
|
|
242
|
+
return list(self._branch_forms.get(branch, []))
|
|
243
|
+
|
|
244
|
+
def get_forms_by_resource(self, resource: str) -> list[Form]:
|
|
245
|
+
"""Get all forms requiring a specific resource.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
resource: Resource hint (e.g., 'api:fast')
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of forms with that resource hint
|
|
252
|
+
"""
|
|
253
|
+
return [f for f in self.forms if f.resource == resource]
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def branches(self) -> list[str]:
|
|
257
|
+
"""Get all branch names in this report."""
|
|
258
|
+
return list(self._branch_forms.keys())
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def resources(self) -> set[str]:
|
|
262
|
+
"""Get all resource hints used in this report."""
|
|
263
|
+
return {f.resource for f in self.forms if f.resource}
|
|
264
|
+
|
|
265
|
+
def __repr__(self) -> str:
|
|
266
|
+
completed, total = self.progress
|
|
267
|
+
branches = len(self._branch_forms)
|
|
268
|
+
return f"Report('{self.assignment}', {completed}/{total} forms, {branches} branches)"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Rules module: validation rules with auto-correction support.
|
|
5
|
+
|
|
6
|
+
Core exports:
|
|
7
|
+
- Rule, RuleParams, RuleQualifier: Base rule classes
|
|
8
|
+
- ValidationError: Validation failure exception
|
|
9
|
+
- Validator: Spec-aware validation orchestrator
|
|
10
|
+
- RuleRegistry: Type-to-rule mapping with inheritance
|
|
11
|
+
- Common rules: StringRule, NumberRule, BooleanRule, ChoiceRule, MappingRule, BaseModelRule
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from krons.errors import ValidationError
|
|
15
|
+
|
|
16
|
+
from .common import (
|
|
17
|
+
BaseModelRule,
|
|
18
|
+
BooleanRule,
|
|
19
|
+
ChoiceRule,
|
|
20
|
+
MappingRule,
|
|
21
|
+
NumberRule,
|
|
22
|
+
StringRule,
|
|
23
|
+
)
|
|
24
|
+
from .registry import RuleRegistry, get_default_registry, reset_default_registry
|
|
25
|
+
from .rule import Rule, RuleParams, RuleQualifier
|
|
26
|
+
from .validator import Validator
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
# Base classes
|
|
30
|
+
"Rule",
|
|
31
|
+
"RuleParams",
|
|
32
|
+
"RuleQualifier",
|
|
33
|
+
"ValidationError",
|
|
34
|
+
# Validator
|
|
35
|
+
"Validator",
|
|
36
|
+
# Registry
|
|
37
|
+
"RuleRegistry",
|
|
38
|
+
"get_default_registry",
|
|
39
|
+
"reset_default_registry",
|
|
40
|
+
# Common rules
|
|
41
|
+
"BaseModelRule",
|
|
42
|
+
"BooleanRule",
|
|
43
|
+
"ChoiceRule",
|
|
44
|
+
"MappingRule",
|
|
45
|
+
"NumberRule",
|
|
46
|
+
"StringRule",
|
|
47
|
+
)
|
|
@@ -50,7 +50,9 @@ class BooleanRule(Rule):
|
|
|
50
50
|
ValueError: If not a boolean
|
|
51
51
|
"""
|
|
52
52
|
if not isinstance(v, bool):
|
|
53
|
-
raise ValueError(
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Invalid boolean value: expected bool, got {type(v).__name__}"
|
|
55
|
+
)
|
|
54
56
|
|
|
55
57
|
async def perform_fix(self, v: Any, _t: type) -> Any:
|
|
56
58
|
"""Attempt to convert value to boolean.
|
|
@@ -58,7 +58,9 @@ class ChoiceRule(Rule):
|
|
|
58
58
|
self.case_sensitive = case_sensitive
|
|
59
59
|
|
|
60
60
|
if not case_sensitive:
|
|
61
|
-
self._lower_map = {
|
|
61
|
+
self._lower_map = {
|
|
62
|
+
str(c).lower(): c for c in self.choices if isinstance(c, str)
|
|
63
|
+
}
|
|
62
64
|
|
|
63
65
|
async def validate(self, v: Any, t: type, **kw) -> None:
|
|
64
66
|
"""Validate that value is in allowed choices (exact match only).
|
|
@@ -72,7 +74,9 @@ class ChoiceRule(Rule):
|
|
|
72
74
|
if v in self.choices:
|
|
73
75
|
return
|
|
74
76
|
|
|
75
|
-
raise ValueError(
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Invalid choice: {v} not in {sorted(str(c) for c in self.choices)}"
|
|
79
|
+
)
|
|
76
80
|
|
|
77
81
|
async def perform_fix(self, v: Any, _t: type) -> Any:
|
|
78
82
|
"""Attempt to fix value to closest choice.
|
|
@@ -94,4 +98,6 @@ class ChoiceRule(Rule):
|
|
|
94
98
|
if v_lower in self._lower_map:
|
|
95
99
|
return self._lower_map[v_lower]
|
|
96
100
|
|
|
97
|
-
raise ValueError(
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Cannot fix choice: {v} not in {sorted(str(c) for c in self.choices)}"
|
|
103
|
+
)
|
|
@@ -66,7 +66,9 @@ class NumberRule(Rule):
|
|
|
66
66
|
ValueError: If not a number or constraints violated
|
|
67
67
|
"""
|
|
68
68
|
if not isinstance(v, (int, float)):
|
|
69
|
-
raise ValueError(
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid number value: expected int or float, got {type(v).__name__}"
|
|
71
|
+
)
|
|
70
72
|
|
|
71
73
|
if self.ge is not None and v < self.ge:
|
|
72
74
|
raise ValueError(f"Number too small: {v} < {self.ge}")
|