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,195 @@
1
+ --[[
2
+ REFERENCE SPEC - Tests Python implementation
3
+
4
+ The primary Tactus (.tac) implementation and spec is now at:
5
+ tactus/stdlib/tac/tactus/classify.spec.tac
6
+
7
+ This spec remains for:
8
+ - Testing Python fallback implementation
9
+ - Documentation reference
10
+ - Backwards compatibility verification
11
+ ]]
12
+
13
+ --[[doc
14
+ # Classification Classes
15
+
16
+ Proper Lua class hierarchy for text classification:
17
+
18
+ - **BaseClassifier**: Abstract base class
19
+ - **LLMClassifier**: LLM-based classification with retry logic
20
+ - **FuzzyMatchClassifier**: String similarity matching
21
+
22
+ ## Usage
23
+
24
+ ```lua
25
+ -- Import classification classes
26
+ local classify = require("tactus.stdlib.classify")
27
+ local LLMClassifier = classify.LLMClassifier
28
+ local FuzzyMatchClassifier = classify.FuzzyMatchClassifier
29
+
30
+ -- LLM Classification
31
+ local classifier = LLMClassifier:new {
32
+ classes = {"Yes", "No"},
33
+ prompt = "Is this a question?",
34
+ model = "openai/gpt-4o-mini"
35
+ }
36
+ local result = classifier:classify("How are you?")
37
+
38
+ -- Fuzzy Matching
39
+ local fuzzy = FuzzyMatchClassifier:new {
40
+ expected = "hello",
41
+ threshold = 0.8
42
+ }
43
+ local result = fuzzy:classify("helo")
44
+ ```
45
+
46
+ ## LLMClassifier Parameters
47
+
48
+ - `classes` (required): List of valid classification values
49
+ - `prompt` (required): Classification instruction
50
+ - `model`: Model identifier (e.g., "openai/gpt-4o-mini")
51
+ - `temperature`: LLM temperature (default: 0.3)
52
+ - `max_retries`: Maximum retry attempts (default: 3)
53
+ - `confidence_mode`: "heuristic" or "none" (default: "heuristic")
54
+
55
+ ## FuzzyMatchClassifier Parameters
56
+
57
+ - `expected` (required): Expected string to match against
58
+ - `threshold`: Similarity threshold 0.0-1.0 (default: 0.8)
59
+ - `classes`: Output values (default: ["Yes", "No"])
60
+
61
+ ## Confidence Warning
62
+
63
+ LLM self-assessed confidence is generally unreliable unless calibrated
64
+ for your specific use case. Consider using fuzzy matching or human
65
+ review for high-stakes decisions.
66
+ ]]
67
+
68
+ -- Local state for test context
69
+ local test_state = {}
70
+
71
+ -- Custom step definitions
72
+ Step("an LLM classifier with classes (.+)", function(ctx, classes_str)
73
+ local classes = {}
74
+ for class in string.gmatch(classes_str, '"([^"]+)"') do
75
+ table.insert(classes, class)
76
+ end
77
+ test_state.classifier_config = {
78
+ classes = classes,
79
+ model = "openai/gpt-4o-mini"
80
+ }
81
+ test_state.classifier_type = "llm"
82
+ end)
83
+
84
+ Step("prompt \"(.+)\"", function(ctx, prompt)
85
+ test_state.classifier_config.prompt = prompt
86
+ end)
87
+
88
+ Step("a fuzzy classifier expecting \"(.+)\"", function(ctx, expected)
89
+ test_state.classifier_config = {
90
+ expected = expected
91
+ }
92
+ test_state.classifier_type = "fuzzy"
93
+ end)
94
+
95
+ Step("I create the classifier", function(ctx)
96
+ if test_state.classifier_type == "llm" then
97
+ test_state.classifier = LLMClassifier:new(test_state.classifier_config)
98
+ elseif test_state.classifier_type == "fuzzy" then
99
+ test_state.classifier = FuzzyMatchClassifier:new(test_state.classifier_config)
100
+ else
101
+ error("Unknown classifier type: " .. tostring(test_state.classifier_type))
102
+ end
103
+ end)
104
+
105
+ Step("I classify \"(.+)\"", function(ctx, text)
106
+ if not test_state.classifier then
107
+ if test_state.classifier_type == "llm" then
108
+ test_state.classifier = LLMClassifier:new(test_state.classifier_config)
109
+ else
110
+ test_state.classifier = FuzzyMatchClassifier:new(test_state.classifier_config)
111
+ end
112
+ end
113
+ test_state.result = test_state.classifier:classify(text)
114
+ end)
115
+
116
+ Step("the result value should be \"(.+)\"", function(ctx, expected)
117
+ assert(test_state.result, "No classification result found")
118
+ assert(test_state.result.value == expected,
119
+ "Expected '" .. expected .. "' but got '" .. tostring(test_state.result.value) .. "'")
120
+ end)
121
+
122
+ Step("the result should have a confidence score", function(ctx)
123
+ assert(test_state.result, "No classification result found")
124
+ assert(test_state.result.confidence ~= nil,
125
+ "Expected confidence score but got nil")
126
+ assert(type(test_state.result.confidence) == "number",
127
+ "Confidence should be a number, got " .. type(test_state.result.confidence))
128
+ assert(test_state.result.confidence >= 0.0 and test_state.result.confidence <= 1.0,
129
+ "Confidence should be between 0 and 1, got " .. tostring(test_state.result.confidence))
130
+ end)
131
+
132
+ Step("the matched_text should be \"(.+)\"", function(ctx, expected)
133
+ assert(test_state.result, "No classification result found")
134
+ assert(test_state.result.matched_text == expected,
135
+ "Expected matched_text '" .. expected .. "' but got '" .. tostring(test_state.result.matched_text) .. "'")
136
+ end)
137
+
138
+ -- BDD Specifications
139
+ Specification([[
140
+ Feature: Classification Class Hierarchy
141
+ As a Tactus developer
142
+ I want to use proper OOP classifiers
143
+ So that I can extend and compose classification behavior
144
+
145
+ Scenario: LLM binary classification
146
+ Given an LLM classifier with classes "Yes" and "No"
147
+ And prompt "Is this a question?"
148
+ When I classify "How are you?"
149
+ Then the result value should be "Yes"
150
+ And the result should have a confidence score
151
+
152
+ Scenario: LLM multi-class classification
153
+ Given an LLM classifier with classes "positive", "negative", and "neutral"
154
+ And prompt "What is the sentiment?"
155
+ When I classify "I love this product!"
156
+ Then the result value should be "positive"
157
+
158
+ Scenario: LLM negative sentiment
159
+ Given an LLM classifier with classes "positive", "negative", and "neutral"
160
+ And prompt "What is the sentiment?"
161
+ When I classify "This is terrible"
162
+ Then the result value should be "negative"
163
+
164
+ Scenario: LLM neutral sentiment
165
+ Given an LLM classifier with classes "positive", "negative", and "neutral"
166
+ And prompt "What is the sentiment?"
167
+ When I classify "The sky is blue"
168
+ Then the result value should be "neutral"
169
+
170
+ Scenario: Fuzzy match with typo
171
+ Given a fuzzy classifier expecting "hello"
172
+ When I classify "helo"
173
+ Then the result value should be "Yes"
174
+ And the matched_text should be "hello"
175
+
176
+ Scenario: Fuzzy match exact
177
+ Given a fuzzy classifier expecting "hello"
178
+ When I classify "hello"
179
+ Then the result value should be "Yes"
180
+
181
+ Scenario: Fuzzy match failure
182
+ Given a fuzzy classifier expecting "hello"
183
+ When I classify "goodbye"
184
+ Then the result value should be "No"
185
+ ]])
186
+
187
+ -- Minimal procedure
188
+ Procedure {
189
+ output = {
190
+ result = field.string{required = true}
191
+ },
192
+ function(input)
193
+ return {result = "Classification class hierarchy specs executed"}
194
+ end
195
+ }
@@ -0,0 +1,257 @@
1
+ -- Classification Classes with Proper Inheritance
2
+ --
3
+ -- This implements a proper Lua class hierarchy for classifiers:
4
+ -- - BaseClassifier (abstract base)
5
+ -- - LLMClassifier (LLM-based classification)
6
+ -- - FuzzyMatchClassifier (string similarity)
7
+
8
+ -- Simple class system for Lua
9
+ local function class(base)
10
+ local c = {}
11
+ if base then
12
+ for k, v in pairs(base) do
13
+ c[k] = v
14
+ end
15
+ c._base = base
16
+ end
17
+ c.__index = c
18
+
19
+ function c:new(config)
20
+ local instance = setmetatable({}, self)
21
+ if instance.init then
22
+ instance:init(config)
23
+ end
24
+ return instance
25
+ end
26
+
27
+ return c
28
+ end
29
+
30
+ -- ============================================================================
31
+ -- BaseClassifier (Abstract Base Class)
32
+ -- ============================================================================
33
+
34
+ BaseClassifier = class()
35
+
36
+ function BaseClassifier:init(config)
37
+ self.config = config or {}
38
+ end
39
+
40
+ function BaseClassifier:classify(text)
41
+ error("BaseClassifier.classify() must be implemented by subclass")
42
+ end
43
+
44
+ function BaseClassifier:__call(text)
45
+ return self:classify(text)
46
+ end
47
+
48
+ -- ============================================================================
49
+ -- LLMClassifier
50
+ -- ============================================================================
51
+
52
+ LLMClassifier = class(BaseClassifier)
53
+
54
+ function LLMClassifier:init(config)
55
+ BaseClassifier.init(self, config)
56
+
57
+ -- Validate required fields
58
+ assert(config.classes, "LLMClassifier requires 'classes' field")
59
+ assert(config.prompt, "LLMClassifier requires 'prompt' field")
60
+
61
+ self.classes = config.classes
62
+ self.prompt = config.prompt
63
+ self.max_retries = config.max_retries or 3
64
+ self.temperature = config.temperature or 0.3
65
+ self.model = config.model
66
+ self.confidence_mode = config.confidence_mode or "heuristic"
67
+
68
+ -- Build classification prompt
69
+ local classes_str = table.concat(self.classes, ", ")
70
+ self.system_prompt = string.format([[%s
71
+
72
+ You MUST respond with ONLY one of these values: %s
73
+
74
+ Response format:
75
+ - Start your response with the classification value on its own line
76
+ - You may optionally explain your reasoning afterward
77
+
78
+ Valid values: %s]], self.prompt, classes_str, classes_str)
79
+
80
+ -- Create agent
81
+ local agent_config = {
82
+ system_prompt = self.system_prompt,
83
+ temperature = self.temperature,
84
+ }
85
+
86
+ if self.model then
87
+ local provider, model_id = self.model:match("([^/]+)/(.+)")
88
+ if provider and model_id then
89
+ agent_config.provider = provider
90
+ agent_config.model = model_id
91
+ end
92
+ end
93
+
94
+ self.agent = Agent(agent_config)
95
+ end
96
+
97
+ function LLMClassifier:parse_response(response)
98
+ if not response or response == "" then
99
+ return nil
100
+ end
101
+
102
+ -- Get first line
103
+ local first_line = response:match("^([^\n]+)")
104
+ if not first_line then
105
+ first_line = response
106
+ end
107
+
108
+ -- Clean up formatting
109
+ first_line = first_line:gsub("[%*\"'`:%.]", ""):gsub("^%s+", ""):gsub("%s+$", "")
110
+ local first_line_lower = first_line:lower()
111
+
112
+ -- Create case-insensitive lookup
113
+ local value_map = {}
114
+ for _, v in ipairs(self.classes) do
115
+ value_map[v:lower()] = v
116
+ end
117
+
118
+ -- Exact match (case-insensitive)
119
+ if value_map[first_line_lower] then
120
+ return value_map[first_line_lower]
121
+ end
122
+
123
+ -- Prefix match
124
+ for v_lower, v_original in pairs(value_map) do
125
+ if first_line_lower:find("^" .. v_lower) then
126
+ return v_original
127
+ end
128
+ end
129
+
130
+ return nil
131
+ end
132
+
133
+ function LLMClassifier:classify(input_text)
134
+ local retry_count = 0
135
+ local last_response = nil
136
+
137
+ for attempt = 1, self.max_retries + 1 do
138
+ -- Call agent
139
+ local agent_result = self.agent:turn({input = input_text})
140
+ last_response = agent_result.message or agent_result.content or ""
141
+
142
+ -- Parse classification
143
+ local value = self:parse_response(last_response)
144
+
145
+ if value then
146
+ local result = {
147
+ value = value,
148
+ retry_count = retry_count,
149
+ raw_response = last_response
150
+ }
151
+
152
+ if self.confidence_mode == "heuristic" then
153
+ result.confidence = 0.8
154
+ end
155
+
156
+ return result
157
+ end
158
+
159
+ -- Retry
160
+ retry_count = retry_count + 1
161
+
162
+ if attempt <= self.max_retries then
163
+ local feedback = string.format(
164
+ "Your response '%s' is not valid. Please respond with ONLY one of: %s",
165
+ last_response,
166
+ table.concat(self.classes, ", ")
167
+ )
168
+ self.agent:turn({input = feedback})
169
+ end
170
+ end
171
+
172
+ -- All retries exhausted
173
+ return {
174
+ value = "ERROR",
175
+ error = "Failed to get valid classification after " .. self.max_retries .. " retries",
176
+ retry_count = retry_count,
177
+ raw_response = last_response
178
+ }
179
+ end
180
+
181
+ -- ============================================================================
182
+ -- FuzzyMatchClassifier
183
+ -- ============================================================================
184
+
185
+ FuzzyMatchClassifier = class(BaseClassifier)
186
+
187
+ function FuzzyMatchClassifier:init(config)
188
+ BaseClassifier.init(self, config)
189
+
190
+ assert(config.expected, "FuzzyMatchClassifier requires 'expected' field")
191
+
192
+ self.expected = config.expected
193
+ self.threshold = config.threshold or 0.8
194
+ self.classes = config.classes or {"Yes", "No"}
195
+ end
196
+
197
+ function FuzzyMatchClassifier:calculate_similarity(s1, s2)
198
+ s1 = s1:lower()
199
+ s2 = s2:lower()
200
+
201
+ if s1 == s2 then
202
+ return 1.0
203
+ end
204
+
205
+ if s1:find(s2, 1, true) or s2:find(s1, 1, true) then
206
+ return 0.85
207
+ end
208
+
209
+ -- Character overlap similarity
210
+ local set1 = {}
211
+ for i = 1, #s1 do
212
+ set1[s1:sub(i,i)] = true
213
+ end
214
+
215
+ local intersection = 0
216
+ local set2 = {}
217
+ for i = 1, #s2 do
218
+ local char = s2:sub(i,i)
219
+ set2[char] = true
220
+ if set1[char] then
221
+ intersection = intersection + 1
222
+ end
223
+ end
224
+
225
+ local union = 0
226
+ for _ in pairs(set1) do union = union + 1 end
227
+ for char in pairs(set2) do
228
+ if not set1[char] then
229
+ union = union + 1
230
+ end
231
+ end
232
+
233
+ if union == 0 then
234
+ return 0.0
235
+ end
236
+
237
+ return intersection / union
238
+ end
239
+
240
+ function FuzzyMatchClassifier:classify(input_text)
241
+ local similarity = self:calculate_similarity(input_text, self.expected)
242
+ local value = similarity >= self.threshold and self.classes[1] or self.classes[2]
243
+
244
+ return {
245
+ value = value,
246
+ confidence = similarity,
247
+ matched_text = self.expected, -- What it matched against
248
+ retry_count = 0
249
+ }
250
+ end
251
+
252
+ -- Export classes
253
+ return {
254
+ BaseClassifier = BaseClassifier,
255
+ LLMClassifier = LLMClassifier,
256
+ FuzzyMatchClassifier = FuzzyMatchClassifier,
257
+ }