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,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
|
+
}
|