tactus 0.33.0__py3-none-any.whl → 0.34.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.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +382 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/server.py +300 -9
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output Validation
|
|
3
|
+
|
|
4
|
+
Utilities for validating LLM outputs against expected schemas and values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_output(
|
|
12
|
+
output: str,
|
|
13
|
+
valid_values: Optional[List[str]] = None,
|
|
14
|
+
schema: Optional[Dict[str, Any]] = None,
|
|
15
|
+
strict: bool = True,
|
|
16
|
+
) -> Dict[str, Any]:
|
|
17
|
+
"""
|
|
18
|
+
Validate an LLM output against constraints.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
output: The output to validate
|
|
22
|
+
valid_values: List of valid output values (for classification)
|
|
23
|
+
schema: Optional JSON schema to validate against
|
|
24
|
+
strict: If True, exact match required; if False, fuzzy matching allowed
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Dict with:
|
|
28
|
+
- valid: True if validation passed
|
|
29
|
+
- value: The validated/normalized value
|
|
30
|
+
- error: Error message if validation failed
|
|
31
|
+
- suggestions: Suggestions for fixing invalid output
|
|
32
|
+
"""
|
|
33
|
+
if not output:
|
|
34
|
+
return {
|
|
35
|
+
"valid": False,
|
|
36
|
+
"value": None,
|
|
37
|
+
"error": "Empty output",
|
|
38
|
+
"suggestions": ["Provide a response"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Validate against valid values
|
|
42
|
+
if valid_values:
|
|
43
|
+
return _validate_classification(output, valid_values, strict)
|
|
44
|
+
|
|
45
|
+
# Validate against schema
|
|
46
|
+
if schema:
|
|
47
|
+
return _validate_schema(output, schema)
|
|
48
|
+
|
|
49
|
+
# No validation rules - pass through
|
|
50
|
+
return {
|
|
51
|
+
"valid": True,
|
|
52
|
+
"value": output,
|
|
53
|
+
"error": None,
|
|
54
|
+
"suggestions": None,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_classification(
|
|
59
|
+
output: str,
|
|
60
|
+
valid_values: List[str],
|
|
61
|
+
strict: bool = True,
|
|
62
|
+
) -> Dict[str, Any]:
|
|
63
|
+
"""
|
|
64
|
+
Validate output is one of the valid classification values.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
output: The output to validate
|
|
68
|
+
valid_values: List of valid values
|
|
69
|
+
strict: If True, exact match required
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Validation result dict
|
|
73
|
+
"""
|
|
74
|
+
# Get first line (classification should be first)
|
|
75
|
+
first_line = output.strip().split("\n")[0].strip()
|
|
76
|
+
|
|
77
|
+
# Clean up common formatting
|
|
78
|
+
cleaned = re.sub(r"[\*\"\'\`\:\.]", "", first_line).strip()
|
|
79
|
+
cleaned_lower = cleaned.lower()
|
|
80
|
+
|
|
81
|
+
# Create lookup for case-insensitive matching
|
|
82
|
+
value_map = {v.lower(): v for v in valid_values}
|
|
83
|
+
|
|
84
|
+
# Exact match (case-insensitive)
|
|
85
|
+
if cleaned_lower in value_map:
|
|
86
|
+
return {
|
|
87
|
+
"valid": True,
|
|
88
|
+
"value": value_map[cleaned_lower],
|
|
89
|
+
"error": None,
|
|
90
|
+
"suggestions": None,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Prefix match (e.g., "Yes - because..." matches "Yes")
|
|
94
|
+
for v_lower, v_original in value_map.items():
|
|
95
|
+
if cleaned_lower.startswith(v_lower):
|
|
96
|
+
return {
|
|
97
|
+
"valid": True,
|
|
98
|
+
"value": v_original,
|
|
99
|
+
"error": None,
|
|
100
|
+
"suggestions": None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Fuzzy matching (if not strict)
|
|
104
|
+
if not strict:
|
|
105
|
+
best_match = _find_best_fuzzy_match(cleaned, valid_values)
|
|
106
|
+
if best_match and best_match["similarity"] >= 0.8:
|
|
107
|
+
return {
|
|
108
|
+
"valid": True,
|
|
109
|
+
"value": best_match["value"],
|
|
110
|
+
"error": None,
|
|
111
|
+
"suggestions": None,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Validation failed - provide helpful suggestions
|
|
115
|
+
suggestions = _generate_suggestions(cleaned, valid_values)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"valid": False,
|
|
119
|
+
"value": first_line,
|
|
120
|
+
"error": f"'{first_line}' is not a valid classification. Valid options: {', '.join(valid_values)}",
|
|
121
|
+
"suggestions": suggestions,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _find_best_fuzzy_match(
|
|
126
|
+
text: str,
|
|
127
|
+
candidates: List[str],
|
|
128
|
+
) -> Optional[Dict[str, Any]]:
|
|
129
|
+
"""
|
|
130
|
+
Find the best fuzzy match for text among candidates.
|
|
131
|
+
|
|
132
|
+
Uses simple similarity based on common characters.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
text: Text to match
|
|
136
|
+
candidates: List of candidate values
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Dict with value and similarity, or None if no good match
|
|
140
|
+
"""
|
|
141
|
+
text_lower = text.lower()
|
|
142
|
+
best_match = None
|
|
143
|
+
best_similarity = 0.0
|
|
144
|
+
|
|
145
|
+
for candidate in candidates:
|
|
146
|
+
candidate_lower = candidate.lower()
|
|
147
|
+
similarity = _calculate_similarity(text_lower, candidate_lower)
|
|
148
|
+
|
|
149
|
+
if similarity > best_similarity:
|
|
150
|
+
best_similarity = similarity
|
|
151
|
+
best_match = candidate
|
|
152
|
+
|
|
153
|
+
if best_match:
|
|
154
|
+
return {"value": best_match, "similarity": best_similarity}
|
|
155
|
+
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _calculate_similarity(s1: str, s2: str) -> float:
|
|
160
|
+
"""
|
|
161
|
+
Calculate similarity between two strings.
|
|
162
|
+
|
|
163
|
+
Uses a simple character-based approach (not full Levenshtein).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
s1: First string
|
|
167
|
+
s2: Second string
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Similarity score between 0.0 and 1.0
|
|
171
|
+
"""
|
|
172
|
+
if not s1 or not s2:
|
|
173
|
+
return 0.0
|
|
174
|
+
|
|
175
|
+
if s1 == s2:
|
|
176
|
+
return 1.0
|
|
177
|
+
|
|
178
|
+
# Check if one contains the other
|
|
179
|
+
if s1 in s2 or s2 in s1:
|
|
180
|
+
return 0.85
|
|
181
|
+
|
|
182
|
+
# Character overlap
|
|
183
|
+
set1 = set(s1)
|
|
184
|
+
set2 = set(s2)
|
|
185
|
+
intersection = len(set1 & set2)
|
|
186
|
+
union = len(set1 | set2)
|
|
187
|
+
|
|
188
|
+
if union == 0:
|
|
189
|
+
return 0.0
|
|
190
|
+
|
|
191
|
+
return intersection / union
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _generate_suggestions(
|
|
195
|
+
invalid_value: str,
|
|
196
|
+
valid_values: List[str],
|
|
197
|
+
) -> List[str]:
|
|
198
|
+
"""
|
|
199
|
+
Generate helpful suggestions for fixing invalid output.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
invalid_value: The invalid value that was provided
|
|
203
|
+
valid_values: List of valid values
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
List of suggestion strings
|
|
207
|
+
"""
|
|
208
|
+
suggestions = []
|
|
209
|
+
|
|
210
|
+
# Find closest match
|
|
211
|
+
best_match = _find_best_fuzzy_match(invalid_value, valid_values)
|
|
212
|
+
if best_match and best_match["similarity"] > 0.3:
|
|
213
|
+
suggestions.append(f"Did you mean '{best_match['value']}'?")
|
|
214
|
+
|
|
215
|
+
# Format the valid options
|
|
216
|
+
valid_str = ", ".join(f"'{v}'" for v in valid_values)
|
|
217
|
+
suggestions.append(f"Valid options are: {valid_str}")
|
|
218
|
+
|
|
219
|
+
# Add formatting advice
|
|
220
|
+
suggestions.append("Start your response with the classification on its own line")
|
|
221
|
+
|
|
222
|
+
return suggestions
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validate_schema(output: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
226
|
+
"""
|
|
227
|
+
Validate output against a JSON schema.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
output: The output to validate (should be JSON)
|
|
231
|
+
schema: JSON schema dict
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Validation result dict
|
|
235
|
+
"""
|
|
236
|
+
import json
|
|
237
|
+
|
|
238
|
+
# Try to parse as JSON
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(output)
|
|
241
|
+
except json.JSONDecodeError as e:
|
|
242
|
+
return {
|
|
243
|
+
"valid": False,
|
|
244
|
+
"value": output,
|
|
245
|
+
"error": f"Invalid JSON: {e}",
|
|
246
|
+
"suggestions": ["Ensure output is valid JSON format"],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Validate against schema
|
|
250
|
+
try:
|
|
251
|
+
import jsonschema
|
|
252
|
+
|
|
253
|
+
jsonschema.validate(instance=data, schema=schema)
|
|
254
|
+
return {
|
|
255
|
+
"valid": True,
|
|
256
|
+
"value": data,
|
|
257
|
+
"error": None,
|
|
258
|
+
"suggestions": None,
|
|
259
|
+
}
|
|
260
|
+
except ImportError:
|
|
261
|
+
# jsonschema not installed - skip validation
|
|
262
|
+
return {
|
|
263
|
+
"valid": True,
|
|
264
|
+
"value": data,
|
|
265
|
+
"error": None,
|
|
266
|
+
"suggestions": None,
|
|
267
|
+
}
|
|
268
|
+
except jsonschema.ValidationError as e:
|
|
269
|
+
return {
|
|
270
|
+
"valid": False,
|
|
271
|
+
"value": data,
|
|
272
|
+
"error": f"Schema validation failed: {e.message}",
|
|
273
|
+
"suggestions": [f"Fix field '{e.path}'" if e.path else "Fix schema errors"],
|
|
274
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tactus Standard Library - Extract Primitive
|
|
3
|
+
|
|
4
|
+
Provides smart information extraction with built-in retry logic, validation,
|
|
5
|
+
and type coercion for extracted fields.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
-- Simple extraction
|
|
10
|
+
data = Extract {
|
|
11
|
+
fields = {name = "string", age = "number", email = "string"},
|
|
12
|
+
prompt = "Extract customer information from this conversation",
|
|
13
|
+
input = transcript
|
|
14
|
+
}
|
|
15
|
+
-- data.name = "John Smith"
|
|
16
|
+
-- data.age = 34
|
|
17
|
+
-- data.email = "john@example.com"
|
|
18
|
+
|
|
19
|
+
## Reusable Extractor
|
|
20
|
+
|
|
21
|
+
Create an extractor once and use it multiple times:
|
|
22
|
+
|
|
23
|
+
customer_info = Extract {
|
|
24
|
+
fields = {name = "string", phone = "string", issue = "string"},
|
|
25
|
+
prompt = "Extract customer details and their reported issue"
|
|
26
|
+
}
|
|
27
|
+
data1 = customer_info(transcript1)
|
|
28
|
+
data2 = customer_info(transcript2)
|
|
29
|
+
|
|
30
|
+
## Configuration Options
|
|
31
|
+
|
|
32
|
+
| Option | Type | Default | Description |
|
|
33
|
+
|--------------|----------|---------|--------------------------------------|
|
|
34
|
+
| fields | table | (req) | Field names and their types |
|
|
35
|
+
| prompt | string | (req) | Extraction instruction |
|
|
36
|
+
| input | string | nil | Input for one-shot extraction |
|
|
37
|
+
| max_retries | number | 3 | Max retry attempts on invalid output |
|
|
38
|
+
| temperature | number | 0.3 | LLM temperature for extraction |
|
|
39
|
+
| model | string | nil | Override default model |
|
|
40
|
+
| strict | boolean | true | Whether all fields are required |
|
|
41
|
+
|
|
42
|
+
## Supported Field Types
|
|
43
|
+
|
|
44
|
+
| Type | Description | Example Value |
|
|
45
|
+
|---------|------------------------------------|--------------------|
|
|
46
|
+
| string | Text value | "John Smith" |
|
|
47
|
+
| number | Numeric value (int or float) | 34, 3.14 |
|
|
48
|
+
| integer | Integer only | 34 |
|
|
49
|
+
| boolean | True/false | true, false |
|
|
50
|
+
| list | Array of values | {"a", "b", "c"} |
|
|
51
|
+
| object | Nested object | {key = "value"} |
|
|
52
|
+
|
|
53
|
+
## Return Value
|
|
54
|
+
|
|
55
|
+
The Extract primitive returns extracted fields directly for convenience:
|
|
56
|
+
|
|
57
|
+
| Access | Type | Description |
|
|
58
|
+
|------------|---------|------------------------------------------|
|
|
59
|
+
| data.name | any | Extracted value for 'name' field |
|
|
60
|
+
| data.age | any | Extracted value for 'age' field |
|
|
61
|
+
| data._error| string | Error message if extraction failed |
|
|
62
|
+
|
|
63
|
+
## Retry Logic
|
|
64
|
+
|
|
65
|
+
When the LLM returns invalid JSON or missing required fields,
|
|
66
|
+
Extract automatically retries with conversational feedback:
|
|
67
|
+
|
|
68
|
+
1. First attempt: Send extraction request
|
|
69
|
+
2. If invalid: Send feedback with validation errors
|
|
70
|
+
3. Repeat until valid extraction or max_retries exceeded
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
### Customer Information Extraction
|
|
75
|
+
|
|
76
|
+
data = Extract {
|
|
77
|
+
fields = {
|
|
78
|
+
name = "string",
|
|
79
|
+
phone = "string",
|
|
80
|
+
email = "string",
|
|
81
|
+
issue = "string"
|
|
82
|
+
},
|
|
83
|
+
prompt = [[
|
|
84
|
+
Extract the customer's contact information and their issue
|
|
85
|
+
from this support call transcript.
|
|
86
|
+
]],
|
|
87
|
+
input = transcript
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
### Order Details with Lists
|
|
91
|
+
|
|
92
|
+
order = Extract {
|
|
93
|
+
fields = {
|
|
94
|
+
order_id = "string",
|
|
95
|
+
items = "list",
|
|
96
|
+
total = "number",
|
|
97
|
+
priority = "boolean"
|
|
98
|
+
},
|
|
99
|
+
prompt = "Extract order details from the conversation",
|
|
100
|
+
strict = false -- Allow missing fields
|
|
101
|
+
}
|
|
102
|
+
result = order(conversation)
|
|
103
|
+
|
|
104
|
+
### Sentiment with Metadata
|
|
105
|
+
|
|
106
|
+
analysis = Extract {
|
|
107
|
+
fields = {
|
|
108
|
+
sentiment = "string",
|
|
109
|
+
confidence = "number",
|
|
110
|
+
key_phrases = "list"
|
|
111
|
+
},
|
|
112
|
+
prompt = "Analyze the sentiment and extract key phrases"
|
|
113
|
+
}
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
from .primitive import ExtractPrimitive, ExtractHandle
|
|
117
|
+
from .llm import LLMExtractor
|
|
118
|
+
from ..core.models import ExtractorResult
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"ExtractPrimitive",
|
|
122
|
+
"ExtractHandle",
|
|
123
|
+
"ExtractorResult",
|
|
124
|
+
"LLMExtractor",
|
|
125
|
+
]
|