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
@@ -20,7 +20,11 @@ from pathlib import Path
20
20
  from typing import Any, Callable, Dict, List, Optional
21
21
 
22
22
  from .config import SandboxConfig
23
- from .docker_manager import DockerManager, calculate_source_hash
23
+ from .docker_manager import (
24
+ DockerManager,
25
+ calculate_source_hash,
26
+ resolve_dockerfile_path,
27
+ )
24
28
  from .protocol import (
25
29
  ExecutionRequest,
26
30
  ExecutionResult,
@@ -146,14 +150,16 @@ class ContainerRunner:
146
150
  # container_runner.py is in tactus/sandbox/, so root is 2 levels up
147
151
  tactus_root = Path(__file__).parent.parent.parent
148
152
 
149
- current_hash = calculate_source_hash(tactus_root)
153
+ dockerfile_path, build_mode = resolve_dockerfile_path(tactus_root)
154
+ current_hash = None
155
+ if build_mode == "source":
156
+ current_hash = calculate_source_hash(tactus_root)
157
+ else:
158
+ logger.info("[SANDBOX] No source tree detected, using PyPI-based sandbox image build")
150
159
 
151
160
  # Check if rebuild is needed
152
161
  if self.docker_manager.needs_rebuild(__version__, current_hash):
153
- logger.info("Code changes detected, rebuilding sandbox...")
154
-
155
- # Get paths
156
- dockerfile_path = tactus_root / "tactus" / "docker" / "Dockerfile"
162
+ logger.info("Sandbox image missing or outdated, rebuilding...")
157
163
 
158
164
  # Build with source hash
159
165
  success, msg = self.docker_manager.build_image(
@@ -253,8 +259,8 @@ class ContainerRunner:
253
259
  if self.config.limits.cpus:
254
260
  cmd.extend(["--cpus", self.config.limits.cpus])
255
261
 
256
- # Mount working directory
257
- cmd.extend(["-v", f"{working_dir}:/workspace:rw"])
262
+ # Working directory mount is handled by SandboxConfig.add_default_volumes()
263
+ # which adds ".:/workspace:rw" to config.volumes (unless mount_current_dir=False)
258
264
 
259
265
  # Mount MCP servers if available
260
266
  if mcp_servers_path and mcp_servers_path.exists():
@@ -321,7 +327,7 @@ class ContainerRunner:
321
327
  container = parts[1]
322
328
  mode = parts[2] if len(parts) > 2 else None
323
329
 
324
- host_is_path = host.startswith(("/", "./", "../", "~"))
330
+ host_is_path = host.startswith(("/", "./", "../", "~")) or host == "." or host == ".."
325
331
  if not host_is_path:
326
332
  # Named volume (or other special form) - leave unchanged
327
333
  return volume
@@ -344,6 +350,9 @@ class ContainerRunner:
344
350
  format: str = "lua",
345
351
  event_handler: Optional[Callable[[Dict[str, Any]], None]] = None,
346
352
  callback_url: Optional[str] = None,
353
+ run_id: Optional[str] = None,
354
+ control_handler: Optional[Callable[[dict], Any]] = None,
355
+ llm_backend_config: Optional[Dict[str, Any]] = None,
347
356
  ) -> ExecutionResult:
348
357
  """
349
358
  Execute a procedure in a sandboxed container.
@@ -355,6 +364,10 @@ class ContainerRunner:
355
364
  working_dir: Working directory to use (default: temp directory)
356
365
  format: Source format ("lua" for .tac files, "yaml" for legacy)
357
366
  event_handler: Optional host callback for streaming events from the container
367
+ callback_url: Optional HTTP callback URL for streaming events
368
+ run_id: Optional run ID for checkpoint isolation across executions
369
+ control_handler: Optional callback for handling container HITL requests
370
+ llm_backend_config: Optional config for broker's LLM backend (provider-agnostic)
358
371
 
359
372
  Returns:
360
373
  ExecutionResult with status, result/error, and metadata.
@@ -427,12 +440,18 @@ class ContainerRunner:
427
440
  keyfile=self.config.broker_tls_key_file,
428
441
  )
429
442
 
443
+ # Extract OpenAI-specific config if provided
444
+ openai_key = None
445
+ if llm_backend_config:
446
+ openai_key = llm_backend_config.get("openai_api_key")
447
+
430
448
  broker_server = TcpBrokerServer(
431
449
  host=self.config.broker_bind_host,
432
450
  port=self.config.broker_port,
433
451
  ssl_context=ssl_context,
434
- openai_backend=OpenAIChatBackend(),
452
+ openai_backend=OpenAIChatBackend(api_key=openai_key),
435
453
  event_handler=event_handler,
454
+ control_handler=control_handler,
436
455
  )
437
456
  await broker_server.start()
438
457
  if broker_server.bound_port is None:
@@ -463,6 +482,7 @@ class ContainerRunner:
463
482
  working_dir="/workspace",
464
483
  params=params or {},
465
484
  execution_id=execution_id,
485
+ run_id=run_id,
466
486
  source_file_path=source_file_path,
467
487
  format=format,
468
488
  )
@@ -487,6 +507,8 @@ class ContainerRunner:
487
507
  request,
488
508
  timeout=self.config.timeout,
489
509
  event_handler=event_handler,
510
+ control_handler=control_handler,
511
+ llm_backend_config=llm_backend_config,
490
512
  )
491
513
  finally:
492
514
  # Cancel broker task when container finishes
@@ -501,6 +523,8 @@ class ContainerRunner:
501
523
  request,
502
524
  timeout=self.config.timeout,
503
525
  event_handler=event_handler,
526
+ control_handler=control_handler,
527
+ llm_backend_config=llm_backend_config,
504
528
  )
505
529
 
506
530
  result.duration_seconds = time.time() - start_time
@@ -537,6 +561,8 @@ class ContainerRunner:
537
561
  request: ExecutionRequest,
538
562
  timeout: int,
539
563
  event_handler: Optional[Callable[[Dict[str, Any]], None]] = None,
564
+ control_handler: Optional[Callable[[dict], Any]] = None,
565
+ llm_backend_config: Optional[Dict[str, Any]] = None,
540
566
  ) -> ExecutionResult:
541
567
  """
542
568
  Run the container and communicate via stdio.
@@ -558,7 +584,13 @@ class ContainerRunner:
558
584
  from tactus.broker.stdio import STDIO_REQUEST_PREFIX
559
585
 
560
586
  stdio_request_prefix = STDIO_REQUEST_PREFIX
561
- openai_backend = OpenAIChatBackend()
587
+
588
+ # Extract OpenAI-specific config if provided
589
+ openai_key = None
590
+ if llm_backend_config:
591
+ openai_key = llm_backend_config.get("openai_api_key")
592
+
593
+ openai_backend = OpenAIChatBackend(api_key=openai_key)
562
594
  tool_registry = HostToolRegistry.default()
563
595
 
564
596
  async def send_event(writer: asyncio.StreamWriter, event: dict[str, Any]) -> None:
@@ -602,6 +634,42 @@ class ContainerRunner:
602
634
  await send_event(writer, {"id": req_id, "event": "done", "data": {"ok": True}})
603
635
  return
604
636
 
637
+ if method == "control.request":
638
+ request_data = params.get("request") if isinstance(params, dict) else None
639
+ if control_handler is not None:
640
+ try:
641
+ # Send delivered event
642
+ await send_event(writer, {"id": req_id, "event": "delivered"})
643
+
644
+ # Call control handler and await response
645
+ response_data = await control_handler(request_data)
646
+
647
+ # Send response event
648
+ await send_event(
649
+ writer, {"id": req_id, "event": "response", "data": response_data}
650
+ )
651
+ except asyncio.TimeoutError:
652
+ await send_event(
653
+ writer,
654
+ {"id": req_id, "event": "timeout", "data": {"timed_out": True}},
655
+ )
656
+ except Exception as e:
657
+ logger.debug("[BROKER] control.request handler raised", exc_info=True)
658
+ await send_event(
659
+ writer,
660
+ {"id": req_id, "event": "error", "error": {"message": str(e)}},
661
+ )
662
+ else:
663
+ await send_event(
664
+ writer,
665
+ {
666
+ "id": req_id,
667
+ "event": "error",
668
+ "error": {"message": "No control handler configured"},
669
+ },
670
+ )
671
+ return
672
+
605
673
  if method == "tool.call":
606
674
  name = params.get("name") if isinstance(params, dict) else None
607
675
  args = params.get("args") if isinstance(params, dict) else None
@@ -18,6 +18,26 @@ DEFAULT_IMAGE_NAME = "tactus-sandbox"
18
18
  DEFAULT_IMAGE_TAG = "local"
19
19
 
20
20
 
21
+ def resolve_dockerfile_path(tactus_root: Path) -> Tuple[Path, str]:
22
+ """
23
+ Choose the appropriate Dockerfile for the sandbox build.
24
+
25
+ Returns:
26
+ Tuple of (dockerfile_path, build_mode) where build_mode is "source" or "pypi".
27
+ """
28
+ docker_dir = tactus_root / "tactus" / "docker"
29
+ source_dockerfile = docker_dir / "Dockerfile"
30
+ pypi_dockerfile = docker_dir / "Dockerfile.pypi"
31
+ has_source_tree = (tactus_root / "pyproject.toml").exists() and (
32
+ tactus_root / "README.md"
33
+ ).exists()
34
+
35
+ if has_source_tree or not pypi_dockerfile.exists():
36
+ return source_dockerfile, "source"
37
+
38
+ return pypi_dockerfile, "pypi"
39
+
40
+
21
41
  def calculate_source_hash(tactus_root: Path) -> str:
22
42
  """
23
43
  Calculate hash of Tactus source files for change detection.
@@ -35,6 +55,7 @@ def calculate_source_hash(tactus_root: Path) -> str:
35
55
  paths_to_hash = [
36
56
  tactus_root / "tactus" / "dspy",
37
57
  tactus_root / "tactus" / "adapters",
58
+ tactus_root / "tactus" / "broker", # Broker client used by sandbox
38
59
  tactus_root / "tactus" / "core",
39
60
  tactus_root / "tactus" / "primitives",
40
61
  tactus_root / "tactus" / "sandbox",
@@ -283,6 +304,8 @@ class DockerManager:
283
304
  self.full_image_name,
284
305
  "-f",
285
306
  str(dockerfile_path),
307
+ "--build-arg",
308
+ f"TACTUS_VERSION={version}",
286
309
  "--label",
287
310
  f"tactus.version={version}",
288
311
  ]
@@ -85,6 +85,7 @@ async def execute_procedure(
85
85
  params: Dict[str, Any],
86
86
  source_file_path: Optional[str] = None,
87
87
  format: str = "lua",
88
+ run_id: Optional[str] = None,
88
89
  ) -> Any:
89
90
  """
90
91
  Execute a procedure using TactusRuntime.
@@ -96,6 +97,7 @@ async def execute_procedure(
96
97
  mcp_servers: MCP server configurations
97
98
  source_file_path: Original source file path
98
99
  format: Source format ("lua" or "yaml")
100
+ run_id: Run ID for checkpoint isolation
99
101
 
100
102
  Returns:
101
103
  Procedure execution result
@@ -105,6 +107,7 @@ async def execute_procedure(
105
107
  from tactus.adapters.broker_log import BrokerLogHandler
106
108
  from tactus.adapters.http_callback_log import HTTPCallbackLogHandler
107
109
  from tactus.adapters.cost_collector_log import CostCollectorLogHandler
110
+ from tactus.adapters.channels.broker import BrokerControlChannel
108
111
 
109
112
  # Create a unique procedure ID
110
113
  import uuid
@@ -129,6 +132,18 @@ async def execute_procedure(
129
132
  log_handler = CostCollectorLogHandler()
130
133
  logger.info("[SANDBOX] No callback configured; using CostCollectorLogHandler")
131
134
 
135
+ # Set up HITL control channel (broker-based in container mode)
136
+ broker_channel = BrokerControlChannel.from_environment()
137
+ hitl_handler = None
138
+ if broker_channel:
139
+ from tactus.adapters.control_loop import ControlLoopHandler, ControlLoopHITLAdapter
140
+
141
+ control_handler = ControlLoopHandler(channels=[broker_channel])
142
+ hitl_handler = ControlLoopHITLAdapter(control_handler)
143
+ logger.info("[SANDBOX] Using broker control channel for HITL")
144
+ else:
145
+ logger.debug("[SANDBOX] No broker control channel available, HITL disabled")
146
+
132
147
  # Create runtime with log handler for event streaming
133
148
  runtime = TactusRuntime(
134
149
  procedure_id=procedure_id,
@@ -137,6 +152,8 @@ async def execute_procedure(
137
152
  external_config={},
138
153
  source_file_path=source_file_path,
139
154
  log_handler=log_handler, # Enable event streaming to IDE
155
+ hitl_handler=hitl_handler, # Enable HITL control channel
156
+ run_id=run_id, # Pass run_id for checkpoint isolation
140
157
  )
141
158
 
142
159
  # Execute procedure
@@ -146,6 +163,14 @@ async def execute_procedure(
146
163
  format=format,
147
164
  )
148
165
 
166
+ # CRITICAL: Flush pending log events before returning
167
+ # This ensures all streaming events reach the broker before container exits.
168
+ # Without this, fire-and-forget async tasks may be discarded.
169
+ if hasattr(log_handler, "flush"):
170
+ logger.info("[SANDBOX] Flushing pending log events...")
171
+ await log_handler.flush()
172
+ logger.info("[SANDBOX] Log events flushed")
173
+
149
174
  return result
150
175
 
151
176
 
@@ -179,6 +204,7 @@ async def main_async() -> int:
179
204
  params=request.params,
180
205
  source_file_path=request.source_file_path,
181
206
  format=request.format,
207
+ run_id=request.run_id,
182
208
  )
183
209
 
184
210
  # Create success result
@@ -51,6 +51,9 @@ class ExecutionRequest:
51
51
  # Unique execution ID for tracking
52
52
  execution_id: Optional[str] = None
53
53
 
54
+ # Run ID for checkpoint isolation across multiple executions
55
+ run_id: Optional[str] = None
56
+
54
57
  # Source file path (for error messages)
55
58
  source_file_path: Optional[str] = None
56
59
 
@@ -0,0 +1,77 @@
1
+ # Tactus Standard Library
2
+
3
+ The Tactus standard library provides reusable primitives for building AI agents and classification workflows.
4
+
5
+ ## Architecture
6
+
7
+ The stdlib follows the **Dogfooding with BDD Specs as Contract** principle:
8
+
9
+ 1. **BDD specs define behavior** - Each primitive has comprehensive `.spec.tac` files
10
+ 2. **Implementation is secondary** - Python, Tactus, or mix - doesn't matter if specs pass
11
+ 3. **Specs serve triple duty** - Tests, documentation, and contract
12
+
13
+ ## Structure
14
+
15
+ ```
16
+ tactus/stdlib/
17
+ ├── classify/
18
+ │ ├── classify.tac # Tactus implementation (reference)
19
+ │ ├── classify.spec.tac # BDD specifications (THE CONTRACT)
20
+ │ ├── primitive.py # Current Python implementation
21
+ │ ├── llm.py # LLM-based classifier
22
+ │ └── fuzzy.py # Fuzzy string matching
23
+ ```
24
+
25
+ ## Testing
26
+
27
+ Run all stdlib specs:
28
+ ```bash
29
+ tactus stdlib test
30
+ ```
31
+
32
+ Run specific primitive specs:
33
+ ```bash
34
+ tactus test tactus/stdlib/classify/classify.spec.tac
35
+ ```
36
+
37
+ ## Documentation
38
+
39
+ Each `.spec.tac` file contains:
40
+ - `--[[doc]]` blocks with usage documentation
41
+ - `--[[doc:parameter name]]` blocks with parameter documentation
42
+ - BDD scenarios showing expected behavior
43
+ - Custom step definitions for the tests
44
+
45
+ ## Example: Classify
46
+
47
+ The Classify primitive demonstrates the stdlib pattern:
48
+
49
+ **Specifications** ([classify.spec.tac](classify/classify.spec.tac)):
50
+ - 7 BDD scenarios covering LLM and fuzzy matching
51
+ - Documentation blocks explaining usage and parameters
52
+ - Custom steps for testing classification behavior
53
+
54
+ **Current Status**:
55
+ - ✅ Specs pass with Python implementation
56
+ - ✅ Tactus reference implementation exists
57
+ - 🔜 Module loading system needed to use Tactus impl
58
+
59
+ ## Adding New Primitives
60
+
61
+ 1. Create `primitive-name/` directory
62
+ 2. Write `primitive-name.spec.tac` with:
63
+ - `--[[doc]]` documentation blocks
64
+ - Custom step definitions
65
+ - Comprehensive BDD scenarios
66
+ 3. Implement in Python (for now) or Tactus (when module loading ready)
67
+ 4. Ensure `tactus test` passes
68
+
69
+ ## CI Integration
70
+
71
+ ```yaml
72
+ # .github/workflows/stdlib.yml
73
+ - name: Test Standard Library
74
+ run: tactus stdlib test --verbose
75
+ ```
76
+
77
+ All stdlib specs must pass before merge.
tactus/stdlib/__init__.py CHANGED
@@ -1,6 +1,27 @@
1
1
  """Tactus Standard Library.
2
2
 
3
- The standard library consists of .tac files in the tac/ subdirectory.
3
+ The standard library provides high-level primitives for common AI tasks:
4
+
5
+ ## Primitives (injected into Lua)
6
+
7
+ ### Classify - Smart classification with retry logic
8
+ result = Classify {
9
+ classes = {"Yes", "No"},
10
+ prompt = "Did the agent greet the customer?",
11
+ input = transcript
12
+ }
13
+ -- result.value = "Yes"
14
+ -- result.confidence = 0.92
15
+ -- result.explanation = "The agent said 'Hello'..."
16
+
17
+ ### Coming Soon
18
+ - Extract: Schema-based information extraction with validation
19
+ - Match: Fuzzy matching verification
20
+ - Generate: Constrained generation with validation
21
+
22
+ ## Utility Modules (via require)
23
+
24
+ The standard library also includes .tac files in the tac/ subdirectory.
4
25
  These are loaded via Lua's require() function:
5
26
 
6
27
  local done = require("tactus.tools.done")
@@ -8,3 +29,8 @@ These are loaded via Lua's require() function:
8
29
 
9
30
  See tactus/stdlib/tac/ for available modules.
10
31
  """
32
+
33
+ from .classify import ClassifyPrimitive
34
+ from .extract import ExtractPrimitive
35
+
36
+ __all__ = ["ClassifyPrimitive", "ExtractPrimitive"]
@@ -0,0 +1,165 @@
1
+ """
2
+ Tactus Standard Library - Classify Primitive
3
+
4
+ Provides smart classification with built-in retry logic, validation,
5
+ and confidence extraction.
6
+
7
+ ## Quick Start
8
+
9
+ -- Simple binary classification
10
+ result = Classify {
11
+ classes = {"Yes", "No"},
12
+ prompt = "Did the agent greet the customer?",
13
+ input = transcript
14
+ }
15
+ -- result.value = "Yes"
16
+ -- result.confidence = 0.92
17
+ -- result.explanation = "The agent said 'Hello'..."
18
+
19
+ ## Reusable Classifier
20
+
21
+ Create a classifier once and use it multiple times:
22
+
23
+ sentiment = Classify {
24
+ classes = {"positive", "negative", "neutral"},
25
+ prompt = "What is the sentiment of this text?"
26
+ }
27
+ result1 = sentiment(text1)
28
+ result2 = sentiment(text2)
29
+
30
+ ## Configuration Options
31
+
32
+ | Option | Type | Default | Description |
33
+ |-----------------|----------|------------|--------------------------------------|
34
+ | classes | table | (required) | Valid classification values |
35
+ | prompt | string | (required) | Classification instruction |
36
+ | input | string | nil | Input for one-shot classification |
37
+ | max_retries | number | 3 | Max retry attempts on invalid output |
38
+ | temperature | number | 0.3 | LLM temperature for classification |
39
+ | model | string | nil | Override default model |
40
+ | confidence_mode | string | "heuristic"| "heuristic" or "none" |
41
+
42
+ ## Return Value
43
+
44
+ The Classify primitive returns a result with:
45
+
46
+ | Field | Type | Description |
47
+ |-------------|---------|------------------------------------------|
48
+ | value | string | The classification (e.g., "Yes", "No") |
49
+ | confidence | number | Confidence score (0.0 - 1.0) or nil |
50
+ | explanation | string | LLM's reasoning for the classification |
51
+ | retry_count | number | Number of retries needed |
52
+ | error | string | Error message if classification failed |
53
+
54
+ ## Retry Logic
55
+
56
+ When the LLM returns an invalid classification (not in the `classes` list),
57
+ Classify automatically retries with conversational feedback:
58
+
59
+ 1. First attempt: Send classification request
60
+ 2. If invalid: Send feedback message with valid options
61
+ 3. Repeat until valid classification or max_retries exceeded
62
+
63
+ This mimics the LangGraphScore retry pattern where the LLM sees its
64
+ previous mistake and can self-correct.
65
+
66
+ ## Confidence Extraction
67
+
68
+ In "heuristic" mode (default), confidence is extracted from response text:
69
+
70
+ - High (0.95): "definitely", "certainly", "clearly", "absolutely"
71
+ - Medium-high (0.80): "likely", "probably", "appears to be"
72
+ - Low (0.50): "possibly", "might be", "uncertain"
73
+ - Default (0.75): When no indicators found
74
+
75
+ Set `confidence_mode = "none"` to disable confidence extraction.
76
+
77
+ ## Examples
78
+
79
+ ### Binary Classification with NA
80
+
81
+ result = Classify {
82
+ classes = {"Yes", "No", "NA"},
83
+ prompt = "Did the agent provide the required information?",
84
+ input = transcript
85
+ }
86
+
87
+ ### Multi-class with Custom Temperature
88
+
89
+ urgency = Classify {
90
+ classes = {"critical", "high", "medium", "low"},
91
+ prompt = "What is the urgency level of this support ticket?",
92
+ temperature = 0.1, -- More deterministic
93
+ max_retries = 5 -- More attempts for complex classification
94
+ }
95
+ result = urgency(ticket_text)
96
+
97
+ ### Sentiment Analysis
98
+
99
+ sentiment = Classify {
100
+ classes = {"positive", "negative", "neutral", "mixed"},
101
+ prompt = [[
102
+ Analyze the overall sentiment of the customer feedback.
103
+ Consider both explicit statements and implicit tone.
104
+ ]],
105
+ }
106
+
107
+ for _, review in ipairs(reviews) do
108
+ local result = sentiment(review.text)
109
+ Log.info("Review sentiment: " .. result.value)
110
+ end
111
+
112
+ ### Fuzzy String Matching
113
+
114
+ For string similarity matching (finding the best match from a list):
115
+
116
+ -- Match school names with variations
117
+ local FuzzyClassifier = require("tactus.stdlib.classify.fuzzy")
118
+
119
+ local school_matcher = FuzzyClassifier {
120
+ classes = {
121
+ "United Education Institute",
122
+ "Abilene Christian University",
123
+ "Arizona School of Integrative Studies"
124
+ },
125
+ threshold = 0.75,
126
+ algorithm = "token_set_ratio" -- Handles reordered tokens
127
+ }
128
+
129
+ local result = school_matcher("Institute Education United Dallas")
130
+ -- result.value = "United Education Institute"
131
+ -- result.matched_text = "United Education Institute"
132
+ -- result.confidence = 0.82
133
+
134
+ Available algorithms:
135
+ - `ratio`: Character-level similarity (default, best for exact matches)
136
+ - `token_set_ratio`: Tokenizes and compares unique words (handles reordering)
137
+ - `token_sort_ratio`: Sorts tokens before comparing (handles reordering)
138
+ - `partial_ratio`: Best substring match (good for partial text)
139
+
140
+ Binary mode (Yes/No matching):
141
+
142
+ local matcher = FuzzyClassifier {
143
+ expected = "Customer Service",
144
+ threshold = 0.8,
145
+ algorithm = "token_set_ratio"
146
+ }
147
+
148
+ local result = matcher("Service for Customers")
149
+ -- result.value = "Yes" (matched)
150
+ -- result.matched_text = "Customer Service" (what it matched against)
151
+ """
152
+
153
+ from .primitive import ClassifyPrimitive, ClassifyHandle
154
+ from .llm import LLMClassifier
155
+ from .fuzzy import FuzzyMatchClassifier, FuzzyClassifier
156
+ from ..core.models import ClassifierResult
157
+
158
+ __all__ = [
159
+ "ClassifyPrimitive",
160
+ "ClassifyHandle",
161
+ "ClassifierResult",
162
+ "LLMClassifier",
163
+ "FuzzyMatchClassifier",
164
+ "FuzzyClassifier",
165
+ ]