tactus 0.32.2__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.
Files changed (100) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +382 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.32.2.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
+ ]