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.
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.33.0.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,326 @@
1
+ """
2
+ Extract documentation from Tactus .tac files.
3
+
4
+ This module parses .tac files to extract:
5
+ - --[[doc]] comment blocks (Markdown documentation)
6
+ - --[[doc:parameter name]] blocks (Parameter documentation)
7
+ - Specification([[...]]) blocks (BDD specifications in Gherkin format)
8
+ """
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+ from tactus.docs.models import (
14
+ DocBlock,
15
+ ParameterDoc,
16
+ BDDStep,
17
+ BDDScenario,
18
+ BDDFeature,
19
+ CodeExample,
20
+ ModuleDoc,
21
+ DocumentationTree,
22
+ )
23
+
24
+
25
+ class TacFileExtractor:
26
+ """Extract documentation from a single .tac file."""
27
+
28
+ def __init__(self, file_path: Path):
29
+ self.file_path = file_path
30
+ self.content = file_path.read_text()
31
+ self.lines = self.content.split("\n")
32
+
33
+ def extract_doc_blocks(self) -> List[DocBlock]:
34
+ """
35
+ Extract --[[doc]] and --[[doc:parameter]] blocks.
36
+
37
+ Returns regular doc blocks (parameter blocks handled separately).
38
+ """
39
+ doc_blocks = []
40
+ pattern = r"--\[\[doc\s*\n(.*?)\]\]"
41
+
42
+ for match in re.finditer(pattern, self.content, re.DOTALL):
43
+ content = match.group(1)
44
+ # Skip parameter docs (handled separately)
45
+ if content.strip().startswith(":parameter"):
46
+ continue
47
+
48
+ # Find line number
49
+ line_num = self.content[: match.start()].count("\n") + 1
50
+
51
+ doc_blocks.append(DocBlock(content=content.strip(), line_number=line_num))
52
+
53
+ return doc_blocks
54
+
55
+ def extract_parameter_docs(self) -> List[ParameterDoc]:
56
+ """
57
+ Extract --[[doc:parameter name]] blocks.
58
+
59
+ Format:
60
+ --[[doc:parameter <name>
61
+ Description here.
62
+ Type: string (optional)
63
+ Required: true/false (optional)
64
+ Default: value (optional)
65
+ ]]
66
+ """
67
+ parameter_docs = []
68
+ pattern = r"--\[\[doc:parameter\s+(\w+)\s*\n(.*?)\]\]"
69
+
70
+ for match in re.finditer(pattern, self.content, re.DOTALL):
71
+ param_name = match.group(1)
72
+ content = match.group(2).strip()
73
+
74
+ # Parse optional metadata
75
+ type_hint = None
76
+ required = True
77
+ default = None
78
+
79
+ # Extract type if present
80
+ type_match = re.search(r"Type:\s*(\S+)", content)
81
+ if type_match:
82
+ type_hint = type_match.group(1)
83
+ content = re.sub(r"Type:\s*\S+\s*\n?", "", content)
84
+
85
+ # Extract required if present
86
+ required_match = re.search(r"Required:\s*(true|false)", content, re.IGNORECASE)
87
+ if required_match:
88
+ required = required_match.group(1).lower() == "true"
89
+ content = re.sub(
90
+ r"Required:\s*(?:true|false)\s*\n?", "", content, flags=re.IGNORECASE
91
+ )
92
+
93
+ # Extract default if present
94
+ default_match = re.search(r"Default:\s*(.+)", content)
95
+ if default_match:
96
+ default = default_match.group(1).strip()
97
+ content = re.sub(r"Default:\s*.+\s*\n?", "", content)
98
+
99
+ parameter_docs.append(
100
+ ParameterDoc(
101
+ name=param_name,
102
+ description=content.strip(),
103
+ type_hint=type_hint,
104
+ required=required,
105
+ default=default,
106
+ )
107
+ )
108
+
109
+ return parameter_docs
110
+
111
+ def extract_bdd_features(self) -> List[BDDFeature]:
112
+ """
113
+ Extract BDD features from Specification([[ ... ]]) blocks.
114
+
115
+ Parses Gherkin syntax:
116
+ Feature: Name
117
+ Scenario: Name
118
+ Given step
119
+ When step
120
+ Then step
121
+ """
122
+ features = []
123
+ pattern = r"Specification\(\[\[(.*?)\]\]\)"
124
+
125
+ for match in re.finditer(pattern, self.content, re.DOTALL):
126
+ gherkin_content = match.group(1).strip()
127
+ line_num = self.content[: match.start()].count("\n") + 1
128
+
129
+ feature = self._parse_gherkin(gherkin_content, line_num)
130
+ if feature:
131
+ features.append(feature)
132
+
133
+ return features
134
+
135
+ def _parse_gherkin(self, gherkin: str, start_line: int) -> Optional[BDDFeature]:
136
+ """Parse Gherkin syntax into BDDFeature."""
137
+ lines = gherkin.split("\n")
138
+
139
+ # Extract feature name and description
140
+ feature_name = None
141
+ feature_desc_lines = []
142
+ scenarios = []
143
+ current_scenario = None
144
+ current_steps = []
145
+
146
+ for line in lines:
147
+ line = line.strip()
148
+
149
+ if line.startswith("Feature:"):
150
+ feature_name = line[8:].strip()
151
+
152
+ elif line.startswith("Scenario:"):
153
+ # Save previous scenario if exists
154
+ if current_scenario:
155
+ scenarios.append(
156
+ BDDScenario(
157
+ name=current_scenario,
158
+ steps=current_steps,
159
+ line_number=start_line, # Approximate
160
+ )
161
+ )
162
+
163
+ current_scenario = line[9:].strip()
164
+ current_steps = []
165
+
166
+ elif any(line.startswith(kw) for kw in ["Given ", "When ", "Then ", "And ", "But "]):
167
+ # Extract keyword and text
168
+ for keyword in ["Given", "When", "Then", "And", "But"]:
169
+ if line.startswith(keyword + " "):
170
+ step_text = line[len(keyword) + 1 :].strip()
171
+ current_steps.append(BDDStep(keyword=keyword, text=step_text))
172
+ break
173
+
174
+ elif line and not feature_name:
175
+ # Description line before first scenario
176
+ feature_desc_lines.append(line)
177
+
178
+ # Save last scenario
179
+ if current_scenario:
180
+ scenarios.append(
181
+ BDDScenario(
182
+ name=current_scenario,
183
+ steps=current_steps,
184
+ line_number=start_line,
185
+ )
186
+ )
187
+
188
+ if not feature_name:
189
+ return None
190
+
191
+ feature_desc = "\n".join(feature_desc_lines).strip() if feature_desc_lines else None
192
+
193
+ return BDDFeature(
194
+ name=feature_name,
195
+ description=feature_desc,
196
+ scenarios=scenarios,
197
+ line_number=start_line,
198
+ )
199
+
200
+ def generate_code_examples(
201
+ self, doc_blocks: List[DocBlock], features: List[BDDFeature]
202
+ ) -> List[CodeExample]:
203
+ """
204
+ Generate code examples from doc blocks and BDD scenarios.
205
+
206
+ Extracts ```lua code blocks from doc blocks and generates
207
+ examples from BDD scenarios.
208
+ """
209
+ examples = []
210
+
211
+ # Extract code blocks from doc blocks
212
+ for doc_block in doc_blocks:
213
+ code_blocks = re.findall(r"```lua\n(.*?)\n```", doc_block.content, re.DOTALL)
214
+ for idx, code in enumerate(code_blocks):
215
+ # Try to extract title from heading before code block
216
+ title = f"Example {idx + 1}"
217
+ heading_match = re.search(r"##\s+(.+?)\n```lua", doc_block.content, re.DOTALL)
218
+ if heading_match:
219
+ title = heading_match.group(1).strip()
220
+
221
+ examples.append(
222
+ CodeExample(
223
+ title=title,
224
+ code=code.strip(),
225
+ description=None,
226
+ source="doc_block",
227
+ )
228
+ )
229
+
230
+ # Generate examples from BDD scenarios
231
+ for feature in features:
232
+ for scenario in feature.scenarios:
233
+ code_lines = [f"-- Scenario: {scenario.name}"]
234
+ for step in scenario.steps:
235
+ code_lines.append(f"-- {step.keyword} {step.text}")
236
+
237
+ examples.append(
238
+ CodeExample(
239
+ title=scenario.name,
240
+ code="\n".join(code_lines),
241
+ description=f"From feature: {feature.name}",
242
+ source="bdd_scenario",
243
+ )
244
+ )
245
+
246
+ return examples
247
+
248
+ def extract_module_doc(
249
+ self, module_name: str, full_module_name: str, module_dir: Optional[Path] = None
250
+ ) -> ModuleDoc:
251
+ """Extract complete documentation for this module."""
252
+ doc_blocks = self.extract_doc_blocks()
253
+ parameter_docs = self.extract_parameter_docs()
254
+ features = self.extract_bdd_features()
255
+ examples = self.generate_code_examples(doc_blocks, features)
256
+
257
+ # Use first doc block as overview
258
+ overview = doc_blocks[0].content if doc_blocks else None
259
+
260
+ # Look for index.md in module directory
261
+ index_content = None
262
+ if module_dir and module_dir.is_dir():
263
+ index_md = module_dir / "index.md"
264
+ if index_md.exists():
265
+ index_content = index_md.read_text()
266
+
267
+ return ModuleDoc(
268
+ name=module_name,
269
+ full_name=full_module_name,
270
+ file_path=str(self.file_path),
271
+ doc_blocks=doc_blocks,
272
+ overview=overview,
273
+ index_content=index_content,
274
+ parameters=parameter_docs,
275
+ features=features,
276
+ examples=examples,
277
+ line_count=len(self.lines),
278
+ has_specs=len(features) > 0,
279
+ )
280
+
281
+
282
+ class DirectoryExtractor:
283
+ """Extract documentation from all .tac files in a directory."""
284
+
285
+ def __init__(self, root_path: Path):
286
+ self.root_path = root_path
287
+
288
+ def extract_all(self) -> DocumentationTree:
289
+ """Extract documentation from all .tac files."""
290
+ modules = []
291
+
292
+ # Find all .spec.tac files
293
+ for spec_file in self.root_path.rglob("*.spec.tac"):
294
+ # Derive module name from file path
295
+ # e.g., tactus/stdlib/tac/tactus/classify.spec.tac -> tactus.classify
296
+ relative = spec_file.relative_to(self.root_path)
297
+ parts = relative.parts
298
+
299
+ # Remove .spec.tac extension
300
+ module_name = spec_file.stem.replace(".spec", "")
301
+
302
+ # Build full module name from path
303
+ # If path is tactus/stdlib/tac/tactus/classify.spec.tac
304
+ # -> tactus.classify
305
+ if "tactus" in parts:
306
+ # Find first "tactus" and build from there
307
+ tactus_idx = parts.index("tactus")
308
+ module_parts = list(parts[tactus_idx:-1]) + [module_name]
309
+ full_module_name = ".".join(module_parts)
310
+ else:
311
+ full_module_name = module_name
312
+
313
+ # Find module directory (parent of spec file or sibling directory)
314
+ # For tactus/stdlib/tac/tactus/classify.spec.tac
315
+ # -> look for tactus/stdlib/tac/tactus/classify/ directory
316
+ module_dir = spec_file.parent / module_name
317
+ if not module_dir.is_dir():
318
+ # Fallback: spec file's parent is the module directory
319
+ module_dir = spec_file.parent
320
+
321
+ # Extract documentation
322
+ extractor = TacFileExtractor(spec_file)
323
+ module_doc = extractor.extract_module_doc(module_name, full_module_name, module_dir)
324
+ modules.append(module_doc)
325
+
326
+ return DocumentationTree(root_path=str(self.root_path), modules=modules)
@@ -0,0 +1,72 @@
1
+ """
2
+ Render Tactus documentation as HTML.
3
+
4
+ Uses Jinja2 templates with clean, minimal styling.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
10
+ import markdown
11
+ from tactus.docs.models import DocumentationTree, ModuleDoc
12
+
13
+
14
+ class HTMLRenderer:
15
+ """Render documentation tree as HTML files."""
16
+
17
+ def __init__(self, template_dir: Optional[Path] = None):
18
+ """
19
+ Initialize renderer with templates.
20
+
21
+ Args:
22
+ template_dir: Path to Jinja2 templates. If None, uses built-in templates.
23
+ """
24
+ if template_dir is None:
25
+ template_dir = Path(__file__).parent / "templates"
26
+
27
+ self.env = Environment(
28
+ loader=FileSystemLoader(str(template_dir)),
29
+ autoescape=select_autoescape(["html", "xml"]),
30
+ )
31
+
32
+ # Add custom filters
33
+ self.env.filters["markdown"] = self._markdown_filter
34
+
35
+ def _markdown_filter(self, text: str) -> str:
36
+ """Convert markdown to HTML."""
37
+ return markdown.markdown(
38
+ text,
39
+ extensions=["extra", "codehilite", "fenced_code"],
40
+ )
41
+
42
+ def render_module(self, module: ModuleDoc) -> str:
43
+ """Render a single module's documentation."""
44
+ template = self.env.get_template("module.html")
45
+ return template.render(module=module)
46
+
47
+ def render_index(self, tree: DocumentationTree) -> str:
48
+ """Render the index page listing all modules."""
49
+ template = self.env.get_template("index.html")
50
+ return template.render(tree=tree)
51
+
52
+ def render_all(self, tree: DocumentationTree, output_dir: Path):
53
+ """
54
+ Render all documentation to HTML files.
55
+
56
+ Creates:
57
+ - index.html (list of all modules)
58
+ - <module-name>.html for each module
59
+ """
60
+ output_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ # Render index
63
+ index_html = self.render_index(tree)
64
+ (output_dir / "index.html").write_text(index_html)
65
+
66
+ # Render each module
67
+ for module in tree.modules:
68
+ module_html = self.render_module(module)
69
+ filename = f"{module.name}.html"
70
+ (output_dir / filename).write_text(module_html)
71
+
72
+ print(f"✓ Generated {len(tree.modules) + 1} HTML files in {output_dir}")
tactus/docs/models.py ADDED
@@ -0,0 +1,121 @@
1
+ """
2
+ Pydantic models for Tactus documentation structure.
3
+
4
+ These models represent the extracted documentation from .tac files,
5
+ including doc blocks, BDD specifications, and code examples.
6
+ """
7
+
8
+ from typing import List, Optional
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class DocBlock(BaseModel):
13
+ """
14
+ A --[[doc]] comment block extracted from a .tac file.
15
+
16
+ Contains markdown-formatted documentation text.
17
+ """
18
+
19
+ content: str = Field(..., description="Markdown content of the doc block")
20
+ line_number: int = Field(..., description="Starting line number in source file")
21
+
22
+
23
+ class ParameterDoc(BaseModel):
24
+ """
25
+ Documentation for a single parameter extracted from --[[doc:parameter name]] blocks.
26
+ """
27
+
28
+ name: str = Field(..., description="Parameter name")
29
+ description: str = Field(..., description="Parameter description")
30
+ type_hint: Optional[str] = Field(None, description="Type hint if specified")
31
+ required: bool = Field(True, description="Whether parameter is required")
32
+ default: Optional[str] = Field(None, description="Default value if specified")
33
+
34
+
35
+ class BDDStep(BaseModel):
36
+ """
37
+ A single Given/When/Then step in a BDD scenario.
38
+ """
39
+
40
+ keyword: str = Field(..., description="Given, When, Then, And, But")
41
+ text: str = Field(..., description="Step text")
42
+
43
+
44
+ class BDDScenario(BaseModel):
45
+ """
46
+ A BDD scenario from a Specification block.
47
+ """
48
+
49
+ name: str = Field(..., description="Scenario name")
50
+ steps: List[BDDStep] = Field(default_factory=list, description="Scenario steps")
51
+ line_number: int = Field(..., description="Starting line number")
52
+
53
+
54
+ class BDDFeature(BaseModel):
55
+ """
56
+ A BDD feature containing multiple scenarios.
57
+ """
58
+
59
+ name: str = Field(..., description="Feature name")
60
+ description: Optional[str] = Field(None, description="Feature description")
61
+ scenarios: List[BDDScenario] = Field(default_factory=list, description="Scenarios")
62
+ line_number: int = Field(..., description="Starting line number")
63
+
64
+
65
+ class CodeExample(BaseModel):
66
+ """
67
+ A code example extracted from doc blocks or generated from BDD scenarios.
68
+ """
69
+
70
+ title: str = Field(..., description="Example title")
71
+ code: str = Field(..., description="Lua code")
72
+ description: Optional[str] = Field(None, description="Example description")
73
+ source: str = Field(..., description="Source: 'doc_block' or 'bdd_scenario'")
74
+
75
+
76
+ class ModuleDoc(BaseModel):
77
+ """
78
+ Complete documentation for a Tactus module (e.g., tactus.classify).
79
+ """
80
+
81
+ name: str = Field(..., description="Module name (e.g., 'classify')")
82
+ full_name: str = Field(..., description="Full module path (e.g., 'tactus.classify')")
83
+ file_path: str = Field(..., description="Path to source .tac file")
84
+
85
+ # Main documentation
86
+ doc_blocks: List[DocBlock] = Field(default_factory=list, description="All doc blocks")
87
+ overview: Optional[str] = Field(None, description="Module overview from first doc block")
88
+ index_content: Optional[str] = Field(None, description="Content from index.md if present")
89
+
90
+ # Parameters/Configuration
91
+ parameters: List[ParameterDoc] = Field(default_factory=list, description="Parameter docs")
92
+
93
+ # BDD Specifications
94
+ features: List[BDDFeature] = Field(default_factory=list, description="BDD features")
95
+
96
+ # Code Examples
97
+ examples: List[CodeExample] = Field(default_factory=list, description="Code examples")
98
+
99
+ # Metadata
100
+ line_count: int = Field(0, description="Total lines in source file")
101
+ has_specs: bool = Field(False, description="Whether module has BDD specs")
102
+
103
+
104
+ class DocumentationTree(BaseModel):
105
+ """
106
+ Tree structure representing all documentation in a directory.
107
+ """
108
+
109
+ root_path: str = Field(..., description="Root directory path")
110
+ modules: List[ModuleDoc] = Field(default_factory=list, description="All documented modules")
111
+
112
+ def get_module(self, name: str) -> Optional[ModuleDoc]:
113
+ """Get module by name."""
114
+ for module in self.modules:
115
+ if module.name == name or module.full_name == name:
116
+ return module
117
+ return None
118
+
119
+ def get_modules_by_prefix(self, prefix: str) -> List[ModuleDoc]:
120
+ """Get all modules matching a prefix (e.g., 'tactus.classify')."""
121
+ return [m for m in self.modules if m.full_name.startswith(prefix)]