diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore new file mode 100644 index 0000000..3d632f9 --- /dev/null +++ b/experiments/param-opti/.gitignore @@ -0,0 +1,2 @@ +output/ +repos/ \ No newline at end of file diff --git a/experiments/param-opti/Agent.md b/experiments/param-opti/Agent.md new file mode 100644 index 0000000..06c4e09 --- /dev/null +++ b/experiments/param-opti/Agent.md @@ -0,0 +1,31 @@ +# Tool Parameter Extraction + +An experiment to extract configuration (hyper)parameters from tools that perform data integration tasks and cluster them to show common options. + +The extraction and clustering code is under src/kgpipe_parameters +The experiment code using this is under experimets/param-opti + +Trace the implementation state under spec/ +Reuse its state and extend it for each new feature. +Tack issues under spec/fix_needed.md + +# Implementation Requirements +- Implement parameter extractors from + - docker doc + - python lib + - cli help + - http api doc + - repo Readme.md/Doc +- The extractors can use LLMs or rules/regex patterns +- Find similar parameters between the single tools implementing a cluster strategy + - using sentence transformer embeddings + - prompting llms with preselected terms +- Visualize the clusters + +# Success Criteria +- simple tests for each extractors +- a working experiment in experiments/param-opti +- a table with configuration parameters +- A vizualization output + +I want you to check missing features and just implement the next feature required now. \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md new file mode 100644 index 0000000..8c62e8c --- /dev/null +++ b/experiments/param-opti/README.md @@ -0,0 +1,100 @@ +# Parameter Optimization Experiment + +This experiment extracts and analyzes configuration parameters from open-source data integration tools using the `kgpipe_parameters` extraction module. + +## Directory Structure + +``` +param-opti/ +├── input/ # Tool definitions +│ ├── paris/ +│ │ ├── repo.url # Git repository URL +│ │ └── cli.txt # CLI help output (optional) +│ └── corenlp_openie/ +│ └── repo.url +├── repos/ # Cloned repositories (auto-populated) +├── output/ # Extraction results (JSON) +├── src/ +│ └── param_opti/ # Experiment code +└── run_experiment.py # Main entry point +``` + +## Usage + +### Run full experiment + +```bash +# From kgpipe root (with venv activated) +cd experiments/param-opti +python run_experiment.py +``` + +### Run for specific tool + +```bash +python run_experiment.py --tool paris +python run_experiment.py --tool paris corenlp_openie +``` + +### Skip repository cloning + +```bash +python run_experiment.py --no-clone +``` + +### Use LLM-based extraction (requires kgpipe_llm) + +```bash +python run_experiment.py --use-llm +``` + +## Adding New Tools + +1. Create a folder in `input/` with the tool name +2. Add `repo.url` with the Git repository URL +3. Optionally add `cli.txt` with CLI help output +4. Optionally add `config.json` for additional settings: + +```json +{ + "language": "python", + "main_files": ["src/main.py", "cli.py"] +} +``` + +## Output Format + +Results are saved as JSON files in `output/`: + +```json +{ + "tool_name": "paris", + "timestamp": "2024-...", + "sources": [ + { + "source_type": "cli", + "file_path": "input/paris/cli.txt", + "parameters_count": 5 + } + ], + "parameters": [ + { + "name": "threshold", + "native_keys": ["--threshold"], + "description": "Matching threshold", + "type_hint": "float", + "default_value": 0.5, + "_source": "cli" + } + ], + "summary": { + "total_parameters": 5, + "total_sources": 1, + "total_errors": 0 + } +} +``` + +A `_summary.json` file is also generated with aggregate statistics. + + diff --git a/experiments/param-opti/input/corenlp_openie/repo.url b/experiments/param-opti/input/corenlp_openie/repo.url new file mode 100644 index 0000000..9ccaf57 --- /dev/null +++ b/experiments/param-opti/input/corenlp_openie/repo.url @@ -0,0 +1 @@ +https://github.com/stanfordnlp/CoreNLP.git \ No newline at end of file diff --git a/experiments/param-opti/input/paris/cli.txt b/experiments/param-opti/input/paris/cli.txt new file mode 100644 index 0000000..edeca1b --- /dev/null +++ b/experiments/param-opti/input/paris/cli.txt @@ -0,0 +1,10 @@ +Paris + +You can specify a file that has no content. +PARIS will ask for the necessary data and store it in . + +Paris + +Shorthand for the previous form. + +Paris diff --git a/experiments/param-opti/input/paris/repo.url b/experiments/param-opti/input/paris/repo.url new file mode 100644 index 0000000..d3f2731 --- /dev/null +++ b/experiments/param-opti/input/paris/repo.url @@ -0,0 +1 @@ +https://github.com/dig-team/PARIS.git \ No newline at end of file diff --git a/experiments/param-opti/input/valentine/repo.url b/experiments/param-opti/input/valentine/repo.url new file mode 100644 index 0000000..9a4dda0 --- /dev/null +++ b/experiments/param-opti/input/valentine/repo.url @@ -0,0 +1 @@ +https://github.com/delftdata/valentine.git \ No newline at end of file diff --git a/experiments/param-opti/run_experiment.py b/experiments/param-opti/run_experiment.py new file mode 100644 index 0000000..e9c5d9f --- /dev/null +++ b/experiments/param-opti/run_experiment.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +Quick script to run the parameter extraction experiment. + +This script can be run directly from the param-opti directory: + python run_experiment.py + python run_experiment.py --tool paris + python run_experiment.py --no-clone +""" + +import sys +from pathlib import Path + +# Add src to path +src_path = Path(__file__).parent / "src" +sys.path.insert(0, str(src_path)) + +# Also ensure kgpipe is importable +kgpipe_src = Path(__file__).parent.parent.parent / "src" +sys.path.insert(0, str(kgpipe_src)) + +from param_opti.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) + + diff --git a/experiments/param-opti/spec/implementation_state.md b/experiments/param-opti/spec/implementation_state.md new file mode 100644 index 0000000..d837e66 --- /dev/null +++ b/experiments/param-opti/spec/implementation_state.md @@ -0,0 +1,123 @@ +# Implementation State + +Last updated: 2026-02-20 + +## Parameter Extractors + +| Source Type | Regex | LLM | Tests | Module | +|-----------------|-------|-----|-------|---------------------------------| +| CLI help | ✅ | ✅ | ✅ | `extractors/cli.py` | +| Python lib | ✅ | ✅ | ✅ | `extractors/python_lib.py` | +| HTTP API doc | ✅ | ✅ | ✅ | `extractors/http_api.py` | +| Docker doc | ✅ | ✅ | ✅ | `extractors/docker.py` | +| Repo README/Doc | ✅ | ✅ | ✅ | `extractors/readme_doc.py` | + +All extractors live under `src/kgpipe_parameters/extraction/extractors/`. + +## Core Infrastructure + +| Component | Status | Location | +|----------------------------|--------|-----------------------------------------------| +| Models | ✅ | `extraction/models.py` | +| Base classes | ✅ | `extraction/base.py` | +| Regex patterns | ✅ | `extraction/patterns.py` | +| Utilities | ✅ | `extraction/utils.py` | +| ParameterMiner | ✅ | `extraction/param_miner.py` | +| Auto source detect | ✅ | `param_miner._detect_source_type()` | +| Keyword chunk filter | ✅ | `extraction/chunk_filter.py` | + +## Keyword Chunk Filter + +Keyword-based pre-filter that scores chunks before they reach any extractor. +Counts parameter-signal keywords per language/file-type and skips files below +a configurable threshold. No embeddings, zero extra dependencies. + +| Language / Type | Keywords cover | Threshold | +|-----------------|---------------------------------------------------------|-----------| +| Python | argparse, click, dataclass, Field, os.environ, … | 2 | +| Java | @Option, @Parameter, getProperty, Properties, @Value,… | 1 | +| .properties | `=`, `:` | 1 | +| XML | ` ExtractionResult +``` + +## How LLMExtractor Works + +### 1. Initialization (`base.py`) + +```python +class LLMExtractor(BaseExtractor): + def __init__(self, source_type: SourceType, llm_client=None): + self.llm_client = llm_client + if llm_client is None: + from kgpipe_llm.common.core import get_client_from_env + self.llm_client = get_client_from_env() +``` + +If no `llm_client` is passed, the constructor tries to auto-create one via `get_client_from_env()`, which reads these environment variables: + +| Variable | Purpose | +|------------------------|-----------------------------------------| +| `LLM_ENDPOINT_URL` | API endpoint (Ollama or OpenAI-compat) | +| `DEFAULT_LLM_MODEL_NAME` | Model name (`gemma3:27B`, `gpt-4o`, …)| +| `OLLAMA_TOKEN` | Token for Ollama API | +| `OPENAI_TOKEN` | Token for OpenAI API | +| `LLM_SEED` | Optional reproducibility seed | +| `CONTEXT_WINDOW` | Max context window (default 16384) | + +The client auto-detects whether to use the **OpenAI** or **Ollama** backend based on the model name. + +### 2. Prompt Construction (`_create_prompt`) + +Each LLM extractor overrides `_create_prompt()` with a source-type-specific prompt template. For example, `LLMCLIExtractor`: + +``` +Extract all configuration parameters from the following CLI help output. +For each parameter, identify: +- Parameter name (normalized, without -- or -) +- Native keys/flags (--flag, -f, etc.) +- Description +- Type (if mentioned) +- Default value (if mentioned) +- Whether it's required or optional + +CLI Help Output: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: +name, native_keys, description, type_hint, default_value, required. +``` + +Each source type adapts the prompt to mention the kind of content it expects (code blocks for README, ENV/ARG for Docker, function signatures for Python, etc.). + +### 3. Structured Output via Pydantic Schema + +The `extract()` method defines a Pydantic schema inline and passes it to `send_prompt()`: + +```python +class ParameterSchema(BaseModel): + name: str + native_keys: List[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + +class ExtractionSchema(BaseModel): + parameters: List[ParameterSchema] + +response = self.llm_client.send_prompt(prompt, ExtractionSchema) +``` + +`LLMClient.send_prompt()` uses the Pydantic model's JSON schema to enforce structured output: +- **OpenAI backend**: uses tool/function calling (`openai_call_with_tool`) to get schema-conformant JSON. +- **Ollama backend**: passes the schema in the `format` field so the model outputs valid JSON. + +The response is always a `dict` with a `"parameters"` key containing a list of parameter objects. + +### 4. Response Parsing + +The returned dict is iterated and each entry is converted to a `RawParameter`: + +```python +for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"}, + ) +``` + +The result is wrapped in an `ExtractionResult` with `extraction_method=ExtractionMethod.LLM`. + +### 5. Error Handling + +All LLM extractors catch exceptions and return an empty `ExtractionResult` with the error message in the `errors` list. This ensures a failed LLM call never crashes the extraction pipeline. + +## How ParameterMiner Uses LLM Extractors + +In `ParameterMiner.extract_parameters()`, the `method` argument controls dispatch: + +| Method | Behavior | +|-------------------------|-------------------------------------------------------| +| `ExtractionMethod.REGEX`| Always use the regex extractor. | +| `ExtractionMethod.LLM` | Always use the LLM extractor (raises if no client). | +| `ExtractionMethod.AUTO` | Try regex first. If it returns 0 parameters **and** an `llm_client` is available, fall back to the LLM extractor. | + +The experiment runner (`run_experiment.py --use-llm`) sets `use_llm=True`, which provides an `llm_client` to the `ParameterMiner`, enabling the AUTO fallback. + +## Existing LLM Extractors + +| Class | Source Type | Prompt Focus | +|--------------------------|-------------|-----------------------------------------------| +| `LLMCLIExtractor` | CLI | Flags, options, defaults from help output | +| `LLMPythonExtractor` | Python | Function params, class attrs, env vars | +| `LLMHTTPExtractor` | HTTP API | Query/path/body/header params from specs | +| `LLMDockerExtractor` | Docker | ENV, ARG, volumes, ports | +| `LLMReadmeDocExtractor` | README | Flags, env vars, config keys, tunable values | + +## Testing + +LLM extractors are tested with a mock client (`conftest.py::mock_llm_client`) that returns a canned response, so tests run without a live LLM endpoint. + diff --git a/experiments/param-opti/src/param_opti/__init__.py b/experiments/param-opti/src/param_opti/__init__.py new file mode 100644 index 0000000..0feb54e --- /dev/null +++ b/experiments/param-opti/src/param_opti/__init__.py @@ -0,0 +1,13 @@ +""" +Parameter Optimization Experiment Package. + +This package provides tools for extracting and analyzing configuration parameters +from open-source data integration tools. +""" + +from .experiment import ParameterExtractionExperiment +from .tool import ToolDefinition + +__all__ = ["ParameterExtractionExperiment", "ToolDefinition"] + + diff --git a/experiments/param-opti/src/param_opti/__main__.py b/experiments/param-opti/src/param_opti/__main__.py new file mode 100644 index 0000000..893f0fe --- /dev/null +++ b/experiments/param-opti/src/param_opti/__main__.py @@ -0,0 +1,161 @@ +""" +Command-line entry point for parameter extraction experiment. + +Usage: + python -m param_opti [--tool TOOL_NAME] [--no-clone] [--use-llm] +""" + +import argparse +import sys +from pathlib import Path + +from .experiment import ParameterExtractionExperiment + + +def get_project_root() -> Path: + """Get the param-opti project root directory.""" + return Path(__file__).parent.parent.parent + + +def main(): + parser = argparse.ArgumentParser( + description="Extract configuration parameters from data integration tools" + ) + parser.add_argument( + "--tool", "-t", + type=str, + nargs="*", + help="Specific tool(s) to process (default: all)" + ) + parser.add_argument( + "--no-clone", + action="store_true", + help="Skip cloning repositories" + ) + parser.add_argument( + "--use-llm", + action="store_true", + help="Use LLM-based extraction as fallback" + ) + parser.add_argument( + "--input-dir", + type=Path, + default=None, + help="Input directory with tool definitions" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Output directory for results" + ) + parser.add_argument( + "--repos-dir", + type=Path, + default=None, + help="Directory for cloned repositories" + ) + parser.add_argument( + "--cluster", + action="store_true", + help="Cluster parameters across tools after extraction" + ) + parser.add_argument( + "--cluster-only", + action="store_true", + help="Skip extraction, only cluster from existing output" + ) + parser.add_argument( + "--distance-threshold", + type=float, + default=0.55, + help="Cosine distance threshold for clustering (default: 0.55, lower = tighter)" + ) + parser.add_argument( + "--visualize", + action="store_true", + help="Generate visualization plots from clustering results" + ) + + args = parser.parse_args() + + # Determine directories + project_root = get_project_root() + input_dir = args.input_dir or project_root / "input" + output_dir = args.output_dir or project_root / "output" + repos_dir = args.repos_dir or project_root / "repos" + + # Initialize LLM client if requested + llm_client = None + if args.use_llm: + try: + from kgpipe_llm.common.core import get_client_from_env + llm_client = get_client_from_env() + print("LLM client initialized") + except ImportError: + print("Warning: kgpipe_llm not available, proceeding without LLM") + + # Create and run experiment + experiment = ParameterExtractionExperiment( + input_dir=input_dir, + output_dir=output_dir, + repos_dir=repos_dir, + clone_repos=not args.no_clone, + use_llm=args.use_llm, + llm_client=llm_client, + ) + + if not args.cluster_only: + results = experiment.run(tool_names=args.tool) + + # Print extraction summary + print("\n" + "=" * 60) + print("Extraction Summary") + print("=" * 60) + for name, result in results.items(): + status = "✓" if not result.errors else "⚠" + print(f"{status} {name}: {len(result.parameters)} parameters from {len(result.sources)} sources") + if result.errors: + for err in result.errors[:3]: + print(f" Error: {err}") + + # Clustering (after extraction, or standalone with --cluster-only) + if args.cluster or args.cluster_only: + print("\n" + "=" * 60) + print("Clustering Parameters") + print("=" * 60) + cluster_result = experiment.cluster_parameters( + distance_threshold=args.distance_threshold, + ) + if cluster_result: + cross_tool = cluster_result.cross_tool_clusters() + print(f" Total parameters: {cluster_result.n_parameters}") + print(f" Clusters: {cluster_result.n_clusters}") + print(f" Cross-tool clusters: {len(cross_tool)}") + if cross_tool: + print("\n Cross-tool clusters:") + for c in cross_tool[:15]: + tools_str = ", ".join(c.tools) + print(f" [{c.cluster_id}] {c.label!r} ({c.size()} params) — tools: {tools_str}") + print(f"\n Results saved to: {output_dir / '_clusters.json'}") + print(f" Table saved to: {output_dir / '_parameter_table.csv'}") + + # Visualization + if args.visualize: + print("\n" + "=" * 60) + print("Generating Visualizations") + print("=" * 60) + viz_paths = experiment.visualize_clusters() + if viz_paths: + for p in viz_paths: + print(f" Saved: {p}") + else: + print(" No visualizations generated (run with --cluster first?)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + diff --git a/experiments/param-opti/src/param_opti/experiment.py b/experiments/param-opti/src/param_opti/experiment.py new file mode 100644 index 0000000..b3c18ea --- /dev/null +++ b/experiments/param-opti/src/param_opti/experiment.py @@ -0,0 +1,767 @@ +""" +Main experiment runner for parameter extraction. +""" + +import json +import subprocess +import logging +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Dict, Any +from dataclasses import dataclass, field, asdict + +from .tool import ToolDefinition + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +@dataclass +class ExtractionSource: + """Represents a source that was used for extraction.""" + source_type: str # cli, python, docker, readme, etc. + file_path: Optional[str] = None + content_preview: Optional[str] = None + parameters_count: int = 0 + + +@dataclass +class ToolExtractionResult: + """Result of parameter extraction for a single tool.""" + tool_name: str + timestamp: str + sources: List[ExtractionSource] = field(default_factory=list) + parameters: List[Dict[str, Any]] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "tool_name": self.tool_name, + "timestamp": self.timestamp, + "sources": [asdict(s) for s in self.sources], + "parameters": self.parameters, + "errors": self.errors, + "metadata": self.metadata, + "summary": { + "total_parameters": len(self.parameters), + "total_sources": len(self.sources), + "total_errors": len(self.errors), + } + } + + +class ParameterExtractionExperiment: + """ + Main experiment class for extracting parameters from tools. + + This class orchestrates the parameter extraction process: + 1. Discovers tools from input folder + 2. Clones repositories if needed + 3. Applies extractors to various sources (CLI, Python, Docker, etc.) + 4. Aggregates and saves results + """ + + def __init__( + self, + input_dir: Path, + output_dir: Path, + repos_dir: Path, + clone_repos: bool = True, + use_llm: bool = False, + llm_client: Optional[Any] = None, + ): + """ + Initialize the experiment. + + Args: + input_dir: Directory containing tool definitions + output_dir: Directory for output results + repos_dir: Directory for cloned repositories + clone_repos: Whether to clone repositories + use_llm: Whether to use LLM-based extraction as fallback + llm_client: Optional LLM client instance + """ + self.input_dir = Path(input_dir) + self.output_dir = Path(output_dir) + self.repos_dir = Path(repos_dir) + self.clone_repos = clone_repos + self.use_llm = use_llm + self.llm_client = llm_client + + # Create directories + self.output_dir.mkdir(parents=True, exist_ok=True) + self.repos_dir.mkdir(parents=True, exist_ok=True) + + # Initialize miner lazily + self._miner = None + + @property + def miner(self): + """Lazy initialization of ParameterMiner.""" + if self._miner is None: + from kgpipe_parameters.extraction import ParameterMiner + self._miner = ParameterMiner(llm_client=self.llm_client) + return self._miner + + def discover_tools(self) -> List[ToolDefinition]: + """ + Discover all tool definitions in the input directory. + + Returns: + List of ToolDefinition instances + """ + tools = [] + for folder in sorted(self.input_dir.iterdir()): + if folder.is_dir() and not folder.name.startswith("."): + try: + tool = ToolDefinition.from_folder(folder) + tools.append(tool) + logger.info(f"Discovered tool: {tool}") + except Exception as e: + logger.warning(f"Failed to load tool from {folder}: {e}") + + logger.info(f"Discovered {len(tools)} tools") + return tools + + def clone_repository(self, tool: ToolDefinition) -> Optional[Path]: + """ + Clone a tool's repository if not already present. + + Args: + tool: Tool definition with repo URL + + Returns: + Path to the cloned repository, or None if failed + """ + if not tool.has_repo(): + logger.warning(f"No repository URL for {tool.name}") + return None + + repo_path = self.repos_dir / tool.name + + if repo_path.exists(): + logger.info(f"Repository already exists: {repo_path}") + return repo_path + + logger.info(f"Cloning {tool.repo_url} to {repo_path}") + try: + result = subprocess.run( + ["git", "clone", "--depth", "1", tool.repo_url, str(repo_path)], + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + if result.returncode == 0: + logger.info(f"Successfully cloned {tool.name}") + return repo_path + else: + logger.error(f"Git clone failed: {result.stderr}") + return None + except subprocess.TimeoutExpired: + logger.error(f"Git clone timed out for {tool.name}") + return None + except Exception as e: + logger.error(f"Failed to clone {tool.name}: {e}") + return None + + def extract_from_cli(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: + """ + Extract parameters from CLI help output. + + Args: + tool: Tool definition with CLI help + + Returns: + Extraction result dictionary, or None if no CLI help + """ + if not tool.has_cli_help(): + return None + + logger.info(f"Extracting from CLI help for {tool.name}") + from kgpipe_parameters.extraction import SourceType + + result = self.miner.extract_parameters( + source=tool.cli_help, + source_type=SourceType.CLI, + tool_name=tool.name, + ) + + return { + "source_type": "cli", + "source_file": str(tool.input_path / "cli.txt"), + "result": json.loads(result.model_dump_json()), + } + + def extract_from_readme(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: + """ + Extract parameters from a README bundled with the tool input definition. + + Args: + tool: Tool definition with readme_content + + Returns: + Extraction result dictionary, or None if no README content + """ + if not tool.readme_content: + return None + + logger.info(f"Extracting from bundled README for {tool.name}") + from kgpipe_parameters.extraction import SourceType + + result = self.miner.extract_parameters( + source=tool.readme_content, + source_type=SourceType.README, + tool_name=tool.name, + ) + + return { + "source_type": "readme", + "source_file": str(tool.input_path / "readme.md"), + "result": json.loads(result.model_dump_json()), + } + + def extract_from_repo(self, tool: ToolDefinition, repo_path: Path) -> List[Dict[str, Any]]: + """ + Extract parameters from repository files. + + Scans Python, Java, .properties, .xml, Docker, and README/doc files. + A keyword-based chunk filter is applied first so that only files + containing parameter-signal keywords are sent to the extractors, + preventing noise from irrelevant source files. + + Args: + tool: Tool definition + repo_path: Path to cloned repository + + Returns: + List of extraction result dictionaries + """ + from kgpipe_parameters.extraction import SourceType + from kgpipe_parameters.extraction.chunk_filter import has_parameter_signals, score_chunk + + results = [] + + # ------------------------------------------------------------------ + # Helper: prioritize files whose names suggest config / CLI / params + # ------------------------------------------------------------------ + priority_patterns = [ + "main", "cli", "config", "settings", "args", "params", "options", + "__main__", "run", "train", "evaluate", "application", "setup", + ] + + def priority_score(path: Path) -> int: + name = path.stem.lower() + for i, pattern in enumerate(priority_patterns): + if pattern in name: + return i + return len(priority_patterns) + + def _is_test_file(path: Path) -> bool: + """Return True for test / example files we want to skip.""" + low = str(path).lower() + return any(s in low for s in ["test", "/example", "/demo", "/sample"]) + + # ================================================================== + # 1. Python files + # ================================================================== + python_files = sorted(repo_path.rglob("*.py"), key=priority_score) + logger.info(f"Found {len(python_files)} Python files in {tool.name}") + + accepted_py = 0 + for py_file in python_files: + if accepted_py >= 20: + break + try: + content = py_file.read_text(errors="ignore") + if len(content) < 100 or _is_test_file(py_file): + continue + + # ── Keyword chunk filter ── + if not has_parameter_signals(content, file_path=str(py_file)): + logger.debug(f" Skipped (no param signals): {py_file.name}") + continue + + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.PYTHON_LIB, + tool_name=f"{tool.name}/{py_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "python", + "source_file": str(py_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {py_file.name}") + accepted_py += 1 + except Exception as e: + logger.warning(f" Failed to process {py_file}: {e}") + + # ================================================================== + # 2. Java files + # ================================================================== + java_files = sorted(repo_path.rglob("*.java"), key=priority_score) + logger.info(f"Found {len(java_files)} Java files in {tool.name}") + + accepted_java = 0 + for java_file in java_files: + if accepted_java >= 20: + break + try: + content = java_file.read_text(errors="ignore") + if len(content) < 100 or _is_test_file(java_file): + continue + + # ── Keyword chunk filter ── + if not has_parameter_signals(content, file_path=str(java_file)): + logger.debug(f" Skipped (no param signals): {java_file.name}") + continue + + # Java config files are best handled by the README extractor + # (it picks up flag patterns, key-value pairs, etc.) + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, + tool_name=f"{tool.name}/{java_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "java", + "source_file": str(java_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {java_file.name}") + accepted_java += 1 + except Exception as e: + logger.warning(f" Failed to process {java_file}: {e}") + + # ================================================================== + # 3. .properties files (Java native config format) + # ================================================================== + properties_files = list(repo_path.rglob("*.properties")) + logger.info(f"Found {len(properties_files)} .properties files in {tool.name}") + + for prop_file in properties_files[:15]: + try: + content = prop_file.read_text(errors="ignore") + if len(content) < 10 or _is_test_file(prop_file): + continue + + # .properties files are inherently config — always relevant + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, # kv-pair patterns work well + tool_name=f"{tool.name}/{prop_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "properties", + "source_file": str(prop_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {prop_file.name}") + except Exception as e: + logger.warning(f" Failed to process {prop_file}: {e}") + + # ================================================================== + # 4. XML config files + # ================================================================== + xml_files = list(repo_path.rglob("*.xml")) + # Only keep files whose names suggest config, not build scripts + _xml_config_hints = [ + "config", "setting", "param", "property", "application", + "persistence", "context", "bean", + ] + xml_files = [ + f for f in xml_files + if any(h in f.stem.lower() for h in _xml_config_hints) + or has_parameter_signals( + f.read_text(errors="ignore")[:2000], + file_path=str(f), + ) + ] + logger.info(f"Found {len(xml_files)} XML config files in {tool.name}") + + for xml_file in xml_files[:10]: + try: + content = xml_file.read_text(errors="ignore") + if len(content) < 30 or _is_test_file(xml_file): + continue + + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, + tool_name=f"{tool.name}/{xml_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "xml", + "source_file": str(xml_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {xml_file.name}") + except Exception as e: + logger.warning(f" Failed to process {xml_file}: {e}") + + # ================================================================== + # 5. Dockerfiles + # ================================================================== + for dockerfile in repo_path.rglob("Dockerfile*"): + try: + content = dockerfile.read_text(errors="ignore") + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.DOCKER, + tool_name=f"{tool.name}/Dockerfile", + ) + + if result.parameters: + results.append({ + "source_type": "docker", + "source_file": str(dockerfile.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {dockerfile.name}") + except Exception as e: + logger.warning(f" Failed to process {dockerfile}: {e}") + + # ================================================================== + # 6. docker-compose files + # ================================================================== + for compose_file in repo_path.rglob("docker-compose*.y*ml"): + try: + content = compose_file.read_text(errors="ignore") + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.DOCKER, + tool_name=f"{tool.name}/docker-compose", + ) + + if result.parameters: + results.append({ + "source_type": "docker", + "source_file": str(compose_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {compose_file.name}") + except Exception as e: + logger.warning(f" Failed to process {compose_file}: {e}") + + # ================================================================== + # 7. README and documentation files + # ================================================================== + readme_patterns = ["README*", "readme*", "INSTALL*", "USAGE*", "CONFIGURATION*"] + doc_dirs = ["doc", "docs", "documentation"] + + readme_files: List[Path] = [] + for pattern in readme_patterns: + readme_files.extend(repo_path.glob(pattern)) + # Also pick up .md files scattered in the repo root (e.g. RunPARIS.md) + readme_files.extend(repo_path.glob("*.md")) + for doc_dir_name in doc_dirs: + doc_dir = repo_path / doc_dir_name + if doc_dir.is_dir(): + readme_files.extend(doc_dir.rglob("*.md")) + readme_files.extend(doc_dir.rglob("*.txt")) + readme_files.extend(doc_dir.rglob("*.rst")) + + # Deduplicate while preserving order + seen_readme: set = set() + unique_readmes: List[Path] = [] + for f in readme_files: + if f.resolve() not in seen_readme and f.is_file(): + seen_readme.add(f.resolve()) + unique_readmes.append(f) + + logger.info(f"Found {len(unique_readmes)} README/doc files in {tool.name}") + + for readme_file in unique_readmes[:15]: + try: + content = readme_file.read_text(errors="ignore") + if len(content) < 50: + continue + + # ── Keyword chunk filter for docs ── + if not has_parameter_signals(content, file_path=str(readme_file), threshold=1): + logger.debug(f" Skipped (no param signals): {readme_file.name}") + continue + + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, + tool_name=f"{tool.name}/{readme_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "readme", + "source_file": str(readme_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {readme_file.name}") + except Exception as e: + logger.warning(f" Failed to process {readme_file}: {e}") + + return results + + def process_tool(self, tool: ToolDefinition) -> ToolExtractionResult: + """ + Process a single tool and extract all parameters. + + Args: + tool: Tool definition to process + + Returns: + ToolExtractionResult with all extracted parameters + """ + logger.info(f"Processing tool: {tool.name}") + + result = ToolExtractionResult( + tool_name=tool.name, + timestamp=datetime.now().isoformat(), + metadata={ + "repo_url": tool.repo_url, + "has_cli_help": tool.has_cli_help(), + "config": tool.config, + } + ) + + # Extract from CLI help + if tool.has_cli_help(): + try: + cli_result = self.extract_from_cli(tool) + if cli_result: + params = cli_result["result"].get("parameters", []) + result.sources.append(ExtractionSource( + source_type="cli", + file_path=cli_result["source_file"], + content_preview=tool.cli_help[:200] if tool.cli_help else None, + parameters_count=len(params), + )) + for p in params: + p["_source"] = "cli" + result.parameters.append(p) + except Exception as e: + result.errors.append(f"CLI extraction failed: {str(e)}") + logger.error(f"CLI extraction failed for {tool.name}: {e}") + + # Extract from bundled README + if tool.readme_content: + try: + readme_result = self.extract_from_readme(tool) + if readme_result: + params = readme_result["result"].get("parameters", []) + result.sources.append(ExtractionSource( + source_type="readme", + file_path=readme_result["source_file"], + content_preview=tool.readme_content[:200] if tool.readme_content else None, + parameters_count=len(params), + )) + for p in params: + p["_source"] = "readme" + result.parameters.append(p) + except Exception as e: + result.errors.append(f"README extraction failed: {str(e)}") + logger.error(f"README extraction failed for {tool.name}: {e}") + + # Clone (if requested) and extract from repository + if tool.has_repo(): + repo_path = self.repos_dir / tool.name + if self.clone_repos: + repo_path = self.clone_repository(tool) + if repo_path and repo_path.exists(): + try: + repo_results = self.extract_from_repo(tool, repo_path) + for r in repo_results: + params = r["result"].get("parameters", []) + result.sources.append(ExtractionSource( + source_type=r["source_type"], + file_path=r["source_file"], + parameters_count=len(params), + )) + for p in params: + p["_source"] = f"{r['source_type']}:{r['source_file']}" + result.parameters.append(p) + except Exception as e: + result.errors.append(f"Repository extraction failed: {str(e)}") + logger.error(f"Repository extraction failed for {tool.name}: {e}") + + logger.info(f"Completed {tool.name}: {len(result.parameters)} parameters from {len(result.sources)} sources") + return result + + def save_result(self, result: ToolExtractionResult) -> Path: + """ + Save extraction result to output directory. + + Args: + result: Extraction result to save + + Returns: + Path to saved file + """ + output_file = self.output_dir / f"{result.tool_name}.json" + + with open(output_file, "w") as f: + json.dump(result.to_dict(), f, indent=2, default=str) + + logger.info(f"Saved result to {output_file}") + return output_file + + def run(self, tool_names: Optional[List[str]] = None) -> Dict[str, ToolExtractionResult]: + """ + Run the experiment for all or selected tools. + + Args: + tool_names: Optional list of tool names to process (all if None) + + Returns: + Dictionary mapping tool names to their extraction results + """ + logger.info("=" * 60) + logger.info("Starting Parameter Extraction Experiment") + logger.info("=" * 60) + + # Discover tools + tools = self.discover_tools() + + # Filter if specific tools requested + if tool_names: + tools = [t for t in tools if t.name in tool_names] + logger.info(f"Filtered to {len(tools)} tools: {[t.name for t in tools]}") + + # Process each tool + results = {} + for tool in tools: + try: + result = self.process_tool(tool) + self.save_result(result) + results[tool.name] = result + except Exception as e: + logger.error(f"Failed to process {tool.name}: {e}") + results[tool.name] = ToolExtractionResult( + tool_name=tool.name, + timestamp=datetime.now().isoformat(), + errors=[str(e)], + ) + + # Generate summary + self._generate_summary(results) + + logger.info("=" * 60) + logger.info("Experiment Complete") + logger.info("=" * 60) + + return results + + def cluster_parameters( + self, + model_name: str = "all-MiniLM-L6-v2", + distance_threshold: float = 0.55, + ) -> Optional[Any]: + """ + Cluster extracted parameters across all tools using sentence-transformer + embeddings and agglomerative clustering. + + This reads the per-tool JSON files already written to ``output_dir``, + embeds every parameter, and groups similar ones together. + + Args: + model_name: Sentence-transformer model identifier. + distance_threshold: Max cosine distance for merging (lower = tighter). + + Returns: + A ClusteringResult, or None if no parameters were found. + """ + from kgpipe_parameters.clustering import ParameterClusterer + + clusterer = ParameterClusterer( + model_name=model_name, + distance_threshold=distance_threshold, + ) + + result = clusterer.cluster_from_output_dir(self.output_dir) + + if result.n_clusters == 0: + logger.warning("Clustering produced 0 clusters") + return result + + # Persist results + clusterer.save_result(result, self.output_dir / "_clusters.json") + clusterer.save_table(result, self.output_dir / "_parameter_table.csv") + + # Log summary + cross_tool = result.cross_tool_clusters() + logger.info( + "Clustering: %d parameters → %d clusters (%d cross-tool)", + result.n_parameters, + result.n_clusters, + len(cross_tool), + ) + return result + + def visualize_clusters(self) -> list[Path]: + """ + Generate visualization plots from existing clustering output. + + Reads ``_clusters.json`` from the output directory and produces + PNG plots in the same directory. Returns the list of generated + file paths. + """ + clusters_json = self.output_dir / "_clusters.json" + if not clusters_json.exists(): + logger.warning( + "No _clusters.json found in %s — run clustering first", + self.output_dir, + ) + return [] + + from kgpipe_parameters.visualization import ParameterVisualizer + + viz = ParameterVisualizer.from_clusters_json(clusters_json, self.output_dir) + return viz.generate_all() + + def _generate_summary(self, results: Dict[str, ToolExtractionResult]) -> None: + """Generate and save experiment summary.""" + summary = { + "timestamp": datetime.now().isoformat(), + "total_tools": len(results), + "tools": {} + } + + total_params = 0 + total_sources = 0 + total_errors = 0 + + for name, result in results.items(): + summary["tools"][name] = { + "parameters": len(result.parameters), + "sources": len(result.sources), + "errors": len(result.errors), + } + total_params += len(result.parameters) + total_sources += len(result.sources) + total_errors += len(result.errors) + + summary["totals"] = { + "parameters": total_params, + "sources": total_sources, + "errors": total_errors, + } + + summary_file = self.output_dir / "_summary.json" + with open(summary_file, "w") as f: + json.dump(summary, f, indent=2) + + logger.info(f"Summary: {total_params} parameters from {total_sources} sources ({total_errors} errors)") + + diff --git a/experiments/param-opti/src/param_opti/tool.py b/experiments/param-opti/src/param_opti/tool.py new file mode 100644 index 0000000..eb16aab --- /dev/null +++ b/experiments/param-opti/src/param_opti/tool.py @@ -0,0 +1,92 @@ +""" +Tool definition model for parameter extraction experiments. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, List, Dict, Any +import json + + +@dataclass +class ToolDefinition: + """ + Represents a tool to be analyzed for parameter extraction. + + A tool is defined by a folder containing: + - repo.url: Git repository URL + - cli.txt: (optional) CLI help output + - readme.md: (optional) README content + - config.json: (optional) Additional configuration + """ + name: str + input_path: Path + repo_url: Optional[str] = None + cli_help: Optional[str] = None + readme_content: Optional[str] = None + config: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_folder(cls, folder_path: Path) -> "ToolDefinition": + """ + Load a tool definition from a folder. + + Args: + folder_path: Path to the tool definition folder + + Returns: + ToolDefinition instance + """ + name = folder_path.name + + # Load repo URL + repo_url = None + repo_url_file = folder_path / "repo.url" + if repo_url_file.exists(): + repo_url = repo_url_file.read_text().strip() + + # Load CLI help + cli_help = None + cli_file = folder_path / "cli.txt" + if cli_file.exists(): + cli_help = cli_file.read_text() + + # Load README + readme_content = None + for readme_name in ["readme.md", "README.md", "readme.txt", "README.txt"]: + readme_file = folder_path / readme_name + if readme_file.exists(): + readme_content = readme_file.read_text() + break + + # Load config + config = {} + config_file = folder_path / "config.json" + if config_file.exists(): + config = json.loads(config_file.read_text()) + + return cls( + name=name, + input_path=folder_path, + repo_url=repo_url, + cli_help=cli_help, + readme_content=readme_content, + config=config, + ) + + def has_repo(self) -> bool: + """Check if this tool has a repository URL.""" + return self.repo_url is not None and len(self.repo_url) > 0 + + def has_cli_help(self) -> bool: + """Check if this tool has CLI help output.""" + return self.cli_help is not None and len(self.cli_help) > 0 + + def get_language(self) -> Optional[str]: + """Get the primary language of the tool (from config or auto-detect).""" + return self.config.get("language") + + def __repr__(self) -> str: + return f"ToolDefinition(name={self.name!r}, repo={self.has_repo()}, cli={self.has_cli_help()})" + + diff --git a/pyproject.toml b/pyproject.toml index 09e3183..ca838be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "jsonpath-ng>=1.7.0", "SPARQLWrapper>=2.0.0", "redis>=7.0.0", + "kgcore @ git+https://github.com/Vehnem/kgcore.git", ] [project.optional-dependencies] @@ -38,7 +39,7 @@ dev = ["pytest", "pytest-mock", "pytest-cov", "ruff", "black"] [tool.setuptools.packages.find] where = ["src"] -include = ["kgpipe*", "kgcore*", "kgback*"] +include = ["kgpipe*"] [tool.setuptools.package-dir] "" = "src" diff --git a/src/kgpipe_parameters/README.md b/src/kgpipe_parameters/README.md new file mode 100644 index 0000000..692f8ff --- /dev/null +++ b/src/kgpipe_parameters/README.md @@ -0,0 +1,25 @@ +# KGpipe Parameters + +1. Extract/Find configuration Parameters for a Task T and its implementations I +2. Match and cluster configuration parameters +3. Find best configuration parameters + + +## TODOs + +- [ ] Allow adding parameters to KgTask +- [ ] Describe in SysKg + + +## Parameter Mining + +Methods to find parameter or settings for codeing libraries, CLI, or remote APIs(Http) + +Inputs +- api documentation +- code files + +Methods +- regex +- llm + diff --git a/src/kgpipe_parameters/__init__.py b/src/kgpipe_parameters/__init__.py new file mode 100644 index 0000000..1698efa --- /dev/null +++ b/src/kgpipe_parameters/__init__.py @@ -0,0 +1,46 @@ +""" +KGpipe Parameters subpackage for analyzing and optimizing parameters for data integration tasks. + +This package provides functionality to: +1. Extract/Find configuration Parameters for a Task T and its implementations I +2. Match and cluster configuration parameters +3. Find best configuration parameters +""" + +from .extraction import ( + ParameterMiner, + RawParameter, + ExtractionResult, + SourceType, + ExtractionMethod, + ReadmeDocExtractor, + LLMReadmeDocExtractor, +) + +from .clustering import ( + ParameterClusterer, + ParameterVector, + ParameterCluster, + ClusteringResult, +) + +from .visualization import ParameterVisualizer + +__all__ = [ + # Extraction + "ParameterMiner", + "RawParameter", + "ExtractionResult", + "SourceType", + "ExtractionMethod", + "ReadmeDocExtractor", + "LLMReadmeDocExtractor", + # Clustering + "ParameterClusterer", + "ParameterVector", + "ParameterCluster", + "ClusteringResult", + # Visualization + "ParameterVisualizer", +] + diff --git a/src/kgpipe_parameters/clustering/__init__.py b/src/kgpipe_parameters/clustering/__init__.py new file mode 100644 index 0000000..7698ade --- /dev/null +++ b/src/kgpipe_parameters/clustering/__init__.py @@ -0,0 +1,20 @@ +""" +Parameter clustering module. + +Groups similar parameters across tools using sentence-transformer embeddings +and agglomerative clustering so that common configuration knobs are surfaced. +""" + +from .models import ParameterVector, ParameterCluster, ClusteringResult +from .similarity import embed_parameters, cosine_similarity_matrix +from .clusterer import ParameterClusterer + +__all__ = [ + "ParameterVector", + "ParameterCluster", + "ClusteringResult", + "embed_parameters", + "cosine_similarity_matrix", + "ParameterClusterer", +] + diff --git a/src/kgpipe_parameters/clustering/clusterer.py b/src/kgpipe_parameters/clustering/clusterer.py new file mode 100644 index 0000000..bd7d62c --- /dev/null +++ b/src/kgpipe_parameters/clustering/clusterer.py @@ -0,0 +1,242 @@ +""" +Main clustering logic. + +Loads extracted parameters from experiment JSON output, embeds them with +sentence-transformers, and applies agglomerative clustering to surface +groups of similar configuration knobs across tools. +""" + +from __future__ import annotations + +import json +import logging +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np + +from .models import ParameterVector, ParameterCluster, ClusteringResult +from .similarity import DEFAULT_MODEL_NAME, embed_parameters + +logger = logging.getLogger(__name__) + + +class ParameterClusterer: + """ + Cluster extracted parameters by semantic similarity. + + Typical usage:: + + clusterer = ParameterClusterer() + result = clusterer.cluster_from_output_dir(Path("output/")) + for c in result.cross_tool_clusters(): + print(c.label, c.tools, c.size()) + """ + + def __init__( + self, + model_name: str = DEFAULT_MODEL_NAME, + distance_threshold: float = 0.55, + min_cluster_size: int = 1, + ): + """ + Parameters + ---------- + model_name : str + Sentence-transformer model to use for embeddings. + distance_threshold : float + Maximum cosine *distance* (1 − similarity) at which two + parameters are still merged into the same cluster. + Lower → tighter clusters. ``0.55`` is a good starting + point for short technical phrases. + min_cluster_size : int + Drop clusters smaller than this after clustering. + """ + self.model_name = model_name + self.distance_threshold = distance_threshold + self.min_cluster_size = min_cluster_size + self._model = None # lazy-loaded + + # ------------------------------------------------------------------ + # Loading helpers + # ------------------------------------------------------------------ + + @staticmethod + def load_parameters_from_json(path: Path) -> List[ParameterVector]: + """ + Load parameters from one tool's JSON output file. + + Expected format: the JSON written by + ``ToolExtractionResult.to_dict()`` — a dict with a + ``"parameters"`` list and a ``"tool_name"`` string. + """ + with open(path) as f: + data = json.load(f) + + tool_name = data.get("tool_name", path.stem) + vectors: List[ParameterVector] = [] + + for p in data.get("parameters", []): + pv = ParameterVector( + name=p.get("name", ""), + tool_name=tool_name, + native_keys=p.get("native_keys", []), + description=p.get("description"), + type_hint=p.get("type_hint"), + default_value=p.get("default_value"), + required=p.get("required", False), + source_label=p.get("_source", ""), + ) + vectors.append(pv) + + return vectors + + def load_from_output_dir(self, output_dir: Path) -> List[ParameterVector]: + """ + Load parameters from *all* tool JSON files in *output_dir*. + + Skips files whose name starts with ``_`` (e.g. ``_summary.json``). + """ + all_params: List[ParameterVector] = [] + for json_file in sorted(output_dir.glob("*.json")): + if json_file.name.startswith("_"): + continue + try: + params = self.load_parameters_from_json(json_file) + logger.info( + "Loaded %d parameters from %s", len(params), json_file.name + ) + all_params.extend(params) + except Exception as e: + logger.warning("Failed to load %s: %s", json_file, e) + + logger.info("Total parameters loaded: %d", len(all_params)) + return all_params + + # ------------------------------------------------------------------ + # Clustering + # ------------------------------------------------------------------ + + def cluster(self, parameters: List[ParameterVector]) -> ClusteringResult: + """ + Embed and cluster a list of parameters. + + Returns a ``ClusteringResult`` with numbered clusters. + """ + if not parameters: + return ClusteringResult( + model_name=self.model_name, + distance_threshold=self.distance_threshold, + ) + + # 1. Compute embeddings + if self._model is None: + from sentence_transformers import SentenceTransformer + + self._model = SentenceTransformer(self.model_name) + + embeddings = embed_parameters( + parameters, model_name=self.model_name, model=self._model + ) + + # 2. Agglomerative clustering with cosine distance + n = len(parameters) + + if n == 1: + # AgglomerativeClustering requires ≥ 2 samples; short-circuit. + labels = np.array([0]) + else: + from sklearn.cluster import AgglomerativeClustering + + sim_matrix = embeddings @ embeddings.T + np.clip(sim_matrix, -1.0, 1.0, out=sim_matrix) + dist_matrix = 1.0 - sim_matrix + + clustering_model = AgglomerativeClustering( + n_clusters=None, + metric="precomputed", + linkage="average", + distance_threshold=self.distance_threshold, + ) + labels = clustering_model.fit_predict(dist_matrix) + + # 3. Build ParameterCluster objects + cluster_map: Dict[int, List[int]] = {} + for idx, label in enumerate(labels): + cluster_map.setdefault(int(label), []).append(idx) + + clusters: List[ParameterCluster] = [] + for cid, member_indices in sorted(cluster_map.items()): + members = [parameters[i] for i in member_indices] + if len(members) < self.min_cluster_size: + continue + + tools = sorted(set(m.tool_name for m in members)) + centroid = embeddings[member_indices].mean(axis=0) + + # Label = most common parameter name in the cluster + name_counts = Counter(m.name for m in members) + label_name = name_counts.most_common(1)[0][0] + + clusters.append( + ParameterCluster( + cluster_id=cid, + label=label_name, + members=members, + tools=tools, + centroid=centroid.tolist(), + ) + ) + + # Sort: cross-tool first, then by size descending + clusters.sort(key=lambda c: (-int(c.is_cross_tool()), -c.size())) + + return ClusteringResult( + n_parameters=len(parameters), + n_clusters=len(clusters), + distance_threshold=self.distance_threshold, + model_name=self.model_name, + clusters=clusters, + ) + + def cluster_from_output_dir(self, output_dir: Path) -> ClusteringResult: + """Convenience: load + cluster in one call.""" + params = self.load_from_output_dir(output_dir) + return self.cluster(params) + + # ------------------------------------------------------------------ + # Output helpers + # ------------------------------------------------------------------ + + @staticmethod + def save_result(result: ClusteringResult, path: Path) -> None: + """Save clustering result as JSON.""" + # Strip large embedding lists to keep the file readable + data = result.model_dump() + for cluster in data.get("clusters", []): + cluster.pop("centroid", None) + for member in cluster.get("members", []): + member.pop("embedding", None) + + with open(path, "w") as f: + json.dump(data, f, indent=2, default=str) + logger.info("Saved clustering result to %s", path) + + @staticmethod + def save_table(result: ClusteringResult, path: Path) -> None: + """Save a flat CSV parameter table from clustering results.""" + import csv + + rows = result.to_table_rows() + if not rows: + logger.warning("No rows to write to table") + return + + fieldnames = list(rows[0].keys()) + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + logger.info("Saved parameter table (%d rows) to %s", len(rows), path) + diff --git a/src/kgpipe_parameters/clustering/models.py b/src/kgpipe_parameters/clustering/models.py new file mode 100644 index 0000000..5570d36 --- /dev/null +++ b/src/kgpipe_parameters/clustering/models.py @@ -0,0 +1,114 @@ +""" +Data models for parameter clustering results. +""" + +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, ConfigDict, Field +import numpy as np + + +class ParameterVector(BaseModel): + """A parameter together with its embedding and origin metadata.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: str = Field(..., description="Normalized parameter name") + tool_name: str = Field(..., description="Tool this parameter belongs to") + native_keys: List[str] = Field(default_factory=list) + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Any] = None + required: bool = False + source_label: str = Field( + "", description="Human-readable source (e.g. 'cli', 'readme:README.md')" + ) + # Embedding stored as plain list for JSON serialisation; converted to + # numpy array for computation. + embedding: Optional[List[float]] = Field( + None, description="Sentence-transformer embedding" + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def text_for_embedding(self) -> str: + """Build the text representation used for embedding computation.""" + parts = [self.name.replace("_", " ")] + if self.description: + parts.append(self.description) + if self.native_keys: + parts.append(" ".join(self.native_keys)) + if self.type_hint: + parts.append(f"type: {self.type_hint}") + return " | ".join(parts) + + +class ParameterCluster(BaseModel): + """A cluster of similar parameters found across one or more tools.""" + + cluster_id: int = Field(..., description="Numeric cluster identifier") + label: str = Field( + "", description="Human-readable label (e.g. most common parameter name)" + ) + members: List[ParameterVector] = Field(default_factory=list) + tools: List[str] = Field( + default_factory=list, + description="Distinct tool names represented in this cluster", + ) + centroid: Optional[List[float]] = Field( + None, description="Mean embedding of the cluster" + ) + + def size(self) -> int: + return len(self.members) + + def is_cross_tool(self) -> bool: + """Return True if parameters from more than one tool are in this cluster.""" + return len(self.tools) > 1 + + +class ClusteringResult(BaseModel): + """Container for an entire clustering run.""" + + n_parameters: int = Field(0, description="Total parameters fed to clustering") + n_clusters: int = Field(0, description="Number of clusters produced") + distance_threshold: float = Field( + 0.0, description="Distance threshold used for clustering" + ) + model_name: str = Field("", description="Sentence-transformer model used") + clusters: List[ParameterCluster] = Field(default_factory=list) + metadata: Dict[str, Any] = Field(default_factory=dict) + + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + def cross_tool_clusters(self) -> List[ParameterCluster]: + """Return only clusters that span more than one tool.""" + return [c for c in self.clusters if c.is_cross_tool()] + + def to_table_rows(self) -> List[Dict[str, Any]]: + """ + Flatten clusters into a list of rows suitable for a pandas DataFrame + or CSV export. + """ + rows: List[Dict[str, Any]] = [] + for cluster in self.clusters: + for member in cluster.members: + rows.append( + { + "cluster_id": cluster.cluster_id, + "cluster_label": cluster.label, + "cluster_size": cluster.size(), + "cross_tool": cluster.is_cross_tool(), + "tool": member.tool_name, + "parameter": member.name, + "native_keys": ", ".join(member.native_keys), + "description": member.description or "", + "type_hint": member.type_hint or "", + "default_value": member.default_value, + "required": member.required, + "source": member.source_label, + } + ) + return rows + diff --git a/src/kgpipe_parameters/clustering/similarity.py b/src/kgpipe_parameters/clustering/similarity.py new file mode 100644 index 0000000..5c52e0c --- /dev/null +++ b/src/kgpipe_parameters/clustering/similarity.py @@ -0,0 +1,96 @@ +""" +Embedding computation and similarity helpers for parameter clustering. + +Uses sentence-transformers to encode parameter descriptions into dense +vectors, then provides numpy-based cosine-similarity utilities. +""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +import numpy as np + +from .models import ParameterVector + +logger = logging.getLogger(__name__) + +# Default lightweight model; works well for short technical phrases. +DEFAULT_MODEL_NAME = "all-MiniLM-L6-v2" + + +def _load_model(model_name: str): + """Load a SentenceTransformer model (cached after first call).""" + from sentence_transformers import SentenceTransformer + + logger.info("Loading sentence-transformer model: %s", model_name) + return SentenceTransformer(model_name) + + +def embed_parameters( + parameters: List[ParameterVector], + model_name: str = DEFAULT_MODEL_NAME, + batch_size: int = 64, + model: Optional[object] = None, +) -> np.ndarray: + """ + Compute embeddings for a list of ParameterVectors. + + Each parameter's ``text_for_embedding()`` is encoded via the + sentence-transformer *model_name*. The resulting embeddings are + stored back into each ``ParameterVector.embedding`` field **and** + returned as a (N, D) numpy array. + + Parameters + ---------- + parameters : list[ParameterVector] + Parameters to embed. + model_name : str + HuggingFace model identifier. + batch_size : int + Encoding batch size. + model : optional + Pre-loaded SentenceTransformer instance (avoids reloading). + + Returns + ------- + np.ndarray + Shape ``(len(parameters), embedding_dim)``. + """ + if not parameters: + return np.empty((0, 0)) + + if model is None: + model = _load_model(model_name) + + texts = [p.text_for_embedding() for p in parameters] + embeddings = model.encode(texts, batch_size=batch_size, show_progress_bar=False) + embeddings = np.asarray(embeddings, dtype=np.float32) + + # Normalise to unit length so cosine similarity = dot product. + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + embeddings = embeddings / norms + + for pv, emb in zip(parameters, embeddings): + pv.embedding = emb.tolist() + + return embeddings + + +def cosine_similarity_matrix(embeddings: np.ndarray) -> np.ndarray: + """ + Compute the pair-wise cosine similarity matrix. + + If the embeddings are already L2-normalised (as ``embed_parameters`` + produces), this is simply ``embeddings @ embeddings.T``. + """ + if embeddings.size == 0: + return np.empty((0, 0)) + # Ensure unit vectors + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + normed = embeddings / norms + return normed @ normed.T + diff --git a/src/kgpipe_parameters/extraction/__init__.py b/src/kgpipe_parameters/extraction/__init__.py new file mode 100644 index 0000000..db5f283 --- /dev/null +++ b/src/kgpipe_parameters/extraction/__init__.py @@ -0,0 +1,75 @@ +""" +Parameter extraction module for mining configuration parameters from various sources. +""" + +from .param_miner import ParameterMiner +from .extractors import ( + CLIExtractor, + PythonLibExtractor, + HTTPAPIExtractor, + DockerExtractor, + ReadmeDocExtractor, + LLMCLIExtractor, + LLMPythonExtractor, + LLMHTTPExtractor, + LLMDockerExtractor, + LLMReadmeDocExtractor, +) +from .models import ( + RawParameter, + ExtractionResult, + SourceType, + ExtractionMethod, +) +from .base import ( + BaseExtractor, + RegexExtractor, + LLMExtractor, +) +from .utils import ( + to_parameter_model, + normalize_parameter_name, + parse_default_value, + infer_parameter_type, + extract_constraints, +) +from .chunk_filter import ( + score_chunk, + has_parameter_signals, + KEYWORD_SETS, +) + +__all__ = [ + # Main class + "ParameterMiner", + # Extractors + "CLIExtractor", + "PythonLibExtractor", + "HTTPAPIExtractor", + "DockerExtractor", + "ReadmeDocExtractor", + "LLMCLIExtractor", + "LLMPythonExtractor", + "LLMHTTPExtractor", + "LLMDockerExtractor", + "LLMReadmeDocExtractor", + # Base classes + "BaseExtractor", + "RegexExtractor", + "LLMExtractor", + # Models + "RawParameter", + "ExtractionResult", + "SourceType", + "ExtractionMethod", + # Utilities + "to_parameter_model", + "normalize_parameter_name", + "parse_default_value", + "infer_parameter_type", + "extract_constraints", + # Chunk filtering + "score_chunk", + "has_parameter_signals", + "KEYWORD_SETS", +] diff --git a/src/kgpipe_parameters/extraction/base.py b/src/kgpipe_parameters/extraction/base.py new file mode 100644 index 0000000..b9c492c --- /dev/null +++ b/src/kgpipe_parameters/extraction/base.py @@ -0,0 +1,85 @@ +""" +Base classes for parameter extractors. +""" + +from abc import ABC, abstractmethod +from typing import List, Optional +from .models import RawParameter, ExtractionResult, SourceType, ExtractionMethod + + +class BaseExtractor(ABC): + """Abstract base class for all parameter extractors.""" + + def __init__(self, source_type: SourceType): + self.source_type = source_type + + @abstractmethod + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """ + Extract parameters from the given source. + + Args: + source: Source content (text, file path, etc.) + tool_name: Optional name of the tool being analyzed + + Returns: + ExtractionResult containing extracted parameters + """ + pass + + +class RegexExtractor(BaseExtractor): + """Base class for regex-based parameter extraction.""" + + def __init__(self, source_type: SourceType, patterns: Optional[dict] = None): + super().__init__(source_type) + self.patterns = patterns or {} + + @abstractmethod + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using regex patterns.""" + pass + + def _apply_patterns(self, text: str) -> List[RawParameter]: + """ + Apply regex patterns to extract parameters. + Subclasses should override this with their specific pattern matching logic. + """ + return [] + + +class LLMExtractor(BaseExtractor): + """Base class for LLM-based parameter extraction.""" + + def __init__(self, source_type: SourceType, llm_client=None): + super().__init__(source_type) + self.llm_client = llm_client + if llm_client is None: + try: + from kgpipe_llm.common.core import LLMClient, get_client_from_env + self.llm_client = get_client_from_env() + except ImportError: + raise ImportError( + "LLM extraction requires kgpipe_llm. " + "Install it or provide an LLMClient instance." + ) + + @abstractmethod + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + pass + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + """ + Create a prompt for LLM extraction. + Subclasses should override this with their specific prompt template. + """ + return f"Extract configuration parameters from:\n\n{source}" + + def _parse_llm_response(self, response: dict) -> List[RawParameter]: + """ + Parse LLM response into RawParameter objects. + Subclasses should override this with their specific parsing logic. + """ + return [] + diff --git a/src/kgpipe_parameters/extraction/chunk_filter.py b/src/kgpipe_parameters/extraction/chunk_filter.py new file mode 100644 index 0000000..fc8feac --- /dev/null +++ b/src/kgpipe_parameters/extraction/chunk_filter.py @@ -0,0 +1,274 @@ +""" +Keyword-based chunk scoring for pre-filtering files before extraction. + +Counts parameter-signal keywords in a text chunk and returns a relevance +score. Files/chunks that score below a configurable threshold are skipped +entirely, preventing noise (e.g. Arabic segmenter scripts in CoreNLP) from +polluting both the regex and LLM extraction paths. + +No embeddings, no extra dependencies — pure keyword counting. +""" + +import re +from typing import Dict, List, Optional, Tuple + +__all__ = ["score_chunk", "has_parameter_signals", "KEYWORD_SETS"] + + +# ── Keyword sets per language / file-type ──────────────────────────────── + +_PYTHON_KEYWORDS: List[str] = [ + # argparse / click / typer + "argparse", + "add_argument", + "ArgumentParser", + "click.option", + "click.argument", + "click.command", + "typer.Option", + "typer.Argument", + # dataclass / pydantic + "@dataclass", + "Field(", + "BaseModel", + "BaseSettings", + # general config signals + "default=", + "default_factory", + "required=", + "choices=", + "type=", + "nargs=", + "help=", + "metavar=", + # plain constructor parameters (frameworks like valentine, etc.) + "def __init__(self,", + "self.__", + "self._", + # env vars + "os.environ", + "os.getenv", + "environ.get", + # configparser / yaml / json config + "configparser", + "ConfigParser", + "config.get", + "config[", + "yaml.load", + "yaml.safe_load", + "json.load", + # hydra / omegaconf + "@hydra.main", + "OmegaConf", + "DictConfig", +] + +_JAVA_KEYWORDS: List[str] = [ + # JCommander / picocli / commons-cli + "@Option", + "@Parameter", + "@CommandLine", + "@Command", + ".addOption(", + "Options(", + "new Option(", + "OptionBuilder", + "CommandLine", + # Java properties / config + "getProperty(", + "setProperty(", + "properties.get(", + "Properties", + ".properties", + "loadProperties", + "getConfig(", + "getString(", + "getInt(", + "getDouble(", + "getBoolean(", + # Spring + "@Value(", + "@ConfigurationProperties", + "@RequestParam", + "@PathVariable", + # general + "default:", + "DEFAULT_", + "CONFIG_", + "PARAM_", +] + +_PROPERTIES_KEYWORDS: List[str] = [ + # .properties files are inherently config + "=", + ":", +] + +_XML_KEYWORDS: List[str] = [ + " str: + """Guess the language/type from a file extension.""" + if not file_path: + return "generic" + # Handle Dockerfile* specially + lower = file_path.lower() + if "dockerfile" in lower or "docker-compose" in lower: + return "docker" + # Extension-based lookup + for ext, lang in _EXT_TO_LANG.items(): + if lower.endswith(ext): + return lang + return "generic" + + +def score_chunk( + text: str, + file_path: Optional[str] = None, + language: Optional[str] = None, +) -> Tuple[int, List[str]]: + """ + Score a text chunk by counting parameter-signal keyword hits. + + Args: + text: The text content to score. + file_path: Optional file path (used to auto-detect language). + language: Explicit language override (python, java, …). + If None, detected from *file_path*. + + Returns: + (score, matched_keywords) — score is the number of distinct keyword + matches found; matched_keywords lists which ones fired. + """ + if not text: + return 0, [] + + lang = language or _detect_language(file_path) + keywords = KEYWORD_SETS.get(lang, KEYWORD_SETS["generic"]) + + matched: List[str] = [] + for kw in keywords: + if kw in text: + matched.append(kw) + + return len(matched), matched + + +def has_parameter_signals( + text: str, + file_path: Optional[str] = None, + language: Optional[str] = None, + threshold: int = 2, +) -> bool: + """ + Return True if *text* contains at least *threshold* distinct + parameter-signal keywords. + + For .properties and .xml files the threshold is automatically lowered + to 1 because their content is inherently config-like. + + Args: + text: The text content to check. + file_path: Optional file path for language detection. + language: Explicit language override. + threshold: Minimum keyword hits required (default 2). + + Returns: + True if the chunk passes the keyword filter. + """ + lang = language or _detect_language(file_path) + + # .properties / .xml files are inherently config — lower bar. + # Java files with *any* annotation-style signal are worth inspecting. + if lang in ("properties", "xml", "java"): + threshold = min(threshold, 1) + + score, _ = score_chunk(text, file_path=file_path, language=lang) + return score >= threshold + diff --git a/src/kgpipe_parameters/extraction/extractors/__init__.py b/src/kgpipe_parameters/extraction/extractors/__init__.py new file mode 100644 index 0000000..c506246 --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/__init__.py @@ -0,0 +1,29 @@ +""" +Extractor implementations for different source types. +""" + +from .cli import CLIExtractor, LLMCLIExtractor +from .python_lib import PythonLibExtractor, LLMPythonExtractor +from .http_api import HTTPAPIExtractor, LLMHTTPExtractor +from .docker import DockerExtractor, LLMDockerExtractor +from .readme_doc import ReadmeDocExtractor, LLMReadmeDocExtractor + +__all__ = [ + # CLI + "CLIExtractor", + "LLMCLIExtractor", + # Python + "PythonLibExtractor", + "LLMPythonExtractor", + # HTTP API + "HTTPAPIExtractor", + "LLMHTTPExtractor", + # Docker + "DockerExtractor", + "LLMDockerExtractor", + # README / documentation + "ReadmeDocExtractor", + "LLMReadmeDocExtractor", +] + + diff --git a/src/kgpipe_parameters/extraction/extractors/cli.py b/src/kgpipe_parameters/extraction/extractors/cli.py new file mode 100644 index 0000000..82ee8ba --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/cli.py @@ -0,0 +1,193 @@ +""" +CLI parameter extraction from help output. +""" + +import re +from typing import Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import CLI_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +class CLIExtractor(RegexExtractor): + """Extract parameters from CLI help output.""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.CLI, CLI_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMCLIExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from CLI help text.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + lines = source.split('\n') + current_param = None + + for line in lines: + # Skip usage lines (they contain brackets and are not actual parameter descriptions) + if line.strip().startswith("usage:") or (line.strip().startswith("[") and "]" in line and "optional" not in line.lower() and "arguments" not in line.lower()): + continue + + # Match long flags: --param or --param=VALUE + long_match = CLI_PATTERNS["long_flag"].search(line) + if long_match: + param_name = long_match.group(1) + # Don't use group(2) from usage line - it's the placeholder, not default + default_val = None + + normalized = normalize_parameter_name(param_name) + native_keys = [f"--{param_name}"] + + # Check for short form on same line (but not -h from usage line) + short_match = CLI_PATTERNS["short_flag"].search(line) + if short_match and short_match.group(1) != 'h': # Skip -h help flag + native_keys.append(f"-{short_match.group(1)}") + + # Extract description - skip placeholder if present + # Pattern: --param PLACEHOLDER Description text + # We want to skip the PLACEHOLDER (uppercase word) if it exists + desc_match = re.search(rf"--{param_name}\s+(?:[A-Z_]+\s+)?(.+)", line) + if not desc_match: + # Fallback: just get everything after the flag + desc_match = re.search(r"--[^\s]+\s+(.+)", line) + description = desc_match.group(1).strip() if desc_match else None + + # Check if required + required = CLI_PATTERNS["required"].search(line) is not None + + # Extract default value from description line (not usage line) + default_match = CLI_PATTERNS["default_value"].search(line) + if default_match: + default_val = default_match.group(1).strip() + + # Extract type hint + type_match = CLI_PATTERNS["type_hint"].search(line) + type_hint = type_match.group(1) if type_match else None + + current_param = RawParameter( + name=normalized, + native_keys=native_keys, + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=required, + source=line, + provenance={"line": lines.index(line) + 1} + ) + parameters.append(current_param) + + # Match short flags: -p + elif CLI_PATTERNS["short_flag"].search(line) and not long_match: + short_match = CLI_PATTERNS["short_flag"].search(line) + param_name = short_match.group(1) + normalized = normalize_parameter_name(param_name) + + current_param = RawParameter( + name=normalized, + native_keys=[f"-{param_name}"], + description=None, + source=line, + provenance={"line": lines.index(line) + 1} + ) + parameters.append(current_param) + + # If we have a current param, try to extract description from continuation lines + elif current_param and line.strip() and not line.strip().startswith('-'): + if not current_param.description: + current_param.description = line.strip() + else: + current_param.description += " " + line.strip() + + except Exception as e: + errors.append(f"Error extracting CLI parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + +class LLMCLIExtractor(LLMExtractor): + """LLM-based CLI parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.CLI, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following CLI help output. +For each parameter, identify: +- Parameter name (normalized, without -- or -) +- Native keys/flags (--flag, -f, etc.) +- Description +- Type (if mentioned) +- Default value (if mentioned) +- Whether it's required or optional + +CLI Help Output: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:100], # First 100 chars + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/docker.py b/src/kgpipe_parameters/extraction/extractors/docker.py new file mode 100644 index 0000000..e26b85a --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/docker.py @@ -0,0 +1,188 @@ +""" +Docker parameter extraction from Dockerfile and docker-compose.yml. +""" + +import yaml +from typing import List, Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import DOCKER_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +class DockerExtractor(RegexExtractor): + """Extract parameters from Docker configurations (Dockerfile, docker-compose.yml).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.DOCKER, DOCKER_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMDockerExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from Docker configuration.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Check if it's a Dockerfile or docker-compose.yml + if "FROM" in source or "RUN" in source: + # Dockerfile + parameters.extend(self._extract_from_dockerfile(source)) + elif "version:" in source or "services:" in source: + # docker-compose.yml + try: + compose = yaml.safe_load(source) + parameters.extend(self._extract_from_compose(compose)) + except yaml.YAMLError: + parameters.extend(self._extract_from_dockerfile(source)) + else: + parameters.extend(self._extract_from_dockerfile(source)) + + except Exception as e: + errors.append(f"Error extracting Docker parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_dockerfile(self, source: str) -> List[RawParameter]: + """Extract ENV and ARG declarations from Dockerfile.""" + parameters = [] + lines = source.split('\n') + + for line in lines: + # ENV declarations + env_match = DOCKER_PATTERNS["env_declaration"].search(line) + if env_match: + var_name = env_match.group(1) + var_value = env_match.group(2) if env_match.group(2) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Environment variable: {var_name}", + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=line, + provenance={"type": "ENV", "line": lines.index(line) + 1} + )) + + # ARG declarations + arg_match = DOCKER_PATTERNS["arg_declaration"].search(line) + if arg_match: + var_name = arg_match.group(1) + var_value = arg_match.group(2) if arg_match.group(2) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Build argument: {var_name}", + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=line, + provenance={"type": "ARG", "line": lines.index(line) + 1} + )) + + return parameters + + def _extract_from_compose(self, compose: dict) -> List[RawParameter]: + """Extract environment variables from docker-compose.yml.""" + parameters = [] + + services = compose.get("services", {}) + for service_name, service_config in services.items(): + env = service_config.get("environment", {}) + if isinstance(env, dict): + for var_name, var_value in env.items(): + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Environment variable for service {service_name}", + default_value=parse_default_value(str(var_value)) if var_value else None, + required=False, + source=f"services.{service_name}.environment", + provenance={"service": service_name, "type": "environment"} + )) + + return parameters + + +class LLMDockerExtractor(LLMExtractor): + """LLM-based Docker parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.DOCKER, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following Docker configuration. +Look for: +- ENV variables +- ARG build arguments +- Environment variables in docker-compose.yml +- Volume mounts and port mappings that could be parameterized + +Docker Configuration: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/http_api.py b/src/kgpipe_parameters/extraction/extractors/http_api.py new file mode 100644 index 0000000..35591ed --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/http_api.py @@ -0,0 +1,186 @@ +""" +HTTP API parameter extraction from OpenAPI/Swagger specs and documentation. +""" + +import json +import yaml +from typing import List, Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..utils import normalize_parameter_name + + +class HTTPAPIExtractor(RegexExtractor): + """Extract parameters from HTTP API documentation (OpenAPI, Swagger, etc.).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.HTTP_API, {}) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMHTTPExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from API documentation.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Try to parse as OpenAPI/Swagger spec + spec = None + try: + # Try JSON first + if source.strip().startswith('{'): + spec = json.loads(source) + else: + # Try YAML + spec = yaml.safe_load(source) + + # Check if it looks like OpenAPI/Swagger spec + if spec and isinstance(spec, dict) and ("openapi" in spec or "swagger" in spec or "paths" in spec): + parameters.extend(self._extract_from_openapi(spec)) + else: + # Not a valid spec, try regex-based extraction + parameters.extend(self._extract_from_docs(source)) + except (json.JSONDecodeError, yaml.YAMLError): + # If parsing fails, try regex-based extraction + parameters.extend(self._extract_from_docs(source)) + + except Exception as e: + errors.append(f"Error extracting API parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_openapi(self, spec: dict) -> List[RawParameter]: + """Extract parameters from OpenAPI specification.""" + parameters = [] + + # Extract from paths + paths = spec.get("paths", {}) + for path, methods in paths.items(): + for method, operation in methods.items(): + # Path parameters + for param in operation.get("parameters", []): + param_name = param.get("name", "") + param_schema = param.get("schema", {}) + + raw_param = RawParameter( + name=normalize_parameter_name(param_name), + native_keys=[param_name], + description=param.get("description"), + type_hint=param_schema.get("type"), + default_value=param_schema.get("default"), + required=param.get("required", False), + source=f"{method.upper()} {path}", + provenance={"location": "path", "method": method} + ) + parameters.append(raw_param) + + # Request body parameters + request_body = operation.get("requestBody", {}) + content = request_body.get("content", {}) + for content_type, schema_obj in content.items(): + schema = schema_obj.get("schema", {}) + if "properties" in schema: + for prop_name, prop_schema in schema["properties"].items(): + raw_param = RawParameter( + name=normalize_parameter_name(prop_name), + native_keys=[prop_name], + description=prop_schema.get("description"), + type_hint=prop_schema.get("type"), + default_value=prop_schema.get("default"), + required=prop_name in schema.get("required", []), + source=f"{method.upper()} {path} (body)", + provenance={"location": "body", "method": method} + ) + parameters.append(raw_param) + + return parameters + + def _extract_from_docs(self, source: str) -> List[RawParameter]: + """Extract parameters from unstructured API documentation.""" + parameters = [] + # Basic regex extraction for common patterns + # This is a simplified version - LLM would be better for complex docs + return parameters + + +class LLMHTTPExtractor(LLMExtractor): + """LLM-based HTTP API parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.HTTP_API, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all API parameters from the following API documentation or specification. +Look for: +- Query parameters +- Path parameters +- Request body parameters +- Header parameters + +API Documentation: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/python_lib.py b/src/kgpipe_parameters/extraction/extractors/python_lib.py new file mode 100644 index 0000000..bb62cef --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/python_lib.py @@ -0,0 +1,359 @@ +""" +Python library parameter extraction from source code. +""" + +import re +import ast +from typing import List, Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import PYTHON_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +class PythonLibExtractor(RegexExtractor): + """Extract parameters from Python code (functions, classes, docstrings).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.PYTHON_LIB, PYTHON_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMPythonExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from Python source code.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Try to parse as Python AST + try: + tree = ast.parse(source) + parameters.extend(self._extract_from_ast(tree, source)) + except SyntaxError: + # If not valid Python, try regex-based extraction + parameters.extend(self._extract_from_regex(source)) + + except Exception as e: + errors.append(f"Error extracting Python parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + # Type hints that almost certainly indicate I/O data, not configuration. + _IO_TYPE_HINTS = frozenset({ + "DataFrame", "pd.DataFrame", "pandas.DataFrame", + "ndarray", "np.ndarray", "numpy.ndarray", + "Series", "pd.Series", + "BaseTable", "BaseColumn", + "Table", "Column", + "Dataset", + "Pool", "Process", + "Iterator", "Generator", + "TextIO", "BinaryIO", "IO", + }) + + @classmethod + def _looks_like_io_param(cls, name: str, type_hint: Optional[str], has_default: bool) -> bool: + """ + Heuristic: return True if a function parameter is likely an I/O + argument rather than a tunable configuration knob. + + Rules: + - Parameters whose type hint is a known data type (DataFrame, ndarray, + BaseTable, etc.) are I/O. + - Required parameters (no default) of non-__init__ methods whose names + suggest data flow (source, target, input, output, data, table, path, + pool, etc.) are I/O. + """ + if type_hint: + # Check the raw type and any component of a composite hint + for io_type in cls._IO_TYPE_HINTS: + if io_type in type_hint: + return True + # Common I/O parameter name stems + io_name_hints = { + "source", "target", "input", "output", "data", + "table", "column", "pool", "file", "path", + "stream", "buffer", "reader", "writer", + } + normalized = name.lower().replace("_", "") + for h in io_name_hints: + if h in normalized: + # If it has a simple scalar default, it might still be config + if has_default: + return False + return True + return False + + def _extract_from_ast(self, tree: ast.AST, source: str) -> List[RawParameter]: + """Extract parameters from Python AST.""" + parameters = [] + extractor_cls = self # reference for nested class + + class ParameterVisitor(ast.NodeVisitor): + def __init__(self): + self.params = [] + self.source_lines = source.split('\n') + self._current_class = None + + def visit_ClassDef(self, node): + prev_class = self._current_class + self._current_class = node.name + + # Extract class-level attributes (dataclasses, Pydantic models, etc.) + for item in node.body: + if isinstance(item, ast.AnnAssign): + # Annotated assignment: name: type = default + if isinstance(item.target, ast.Name): + attr_name = item.target.id + + # Get type hint + type_hint = None + if item.annotation: + type_hint = ast.unparse(item.annotation) if hasattr(ast, 'unparse') else str(item.annotation) + + # Get default value + default_val = None + if item.value: + if hasattr(ast, 'unparse'): + default_val = ast.unparse(item.value) + else: + try: + default_val = ast.literal_eval(item.value) + except (ValueError, TypeError): + default_val = None + + param = RawParameter( + name=normalize_parameter_name(attr_name), + native_keys=[attr_name], + description=None, + type_hint=type_hint, + default_value=parse_default_value(str(default_val)) if default_val is not None else None, + required=default_val is None, + source=f"{node.name}.{attr_name}", + provenance={"class": node.name, "line": item.lineno if hasattr(item, 'lineno') else node.lineno} + ) + self.params.append(param) + elif isinstance(item, ast.Assign): + # Regular assignment: name = value (might be in dataclass) + for target in item.targets: + if isinstance(target, ast.Name): + attr_name = target.id + # Try to get value + default_val = None + if item.value: + try: + default_val = ast.literal_eval(item.value) + except (ValueError, TypeError): + default_val = None + + param = RawParameter( + name=normalize_parameter_name(attr_name), + native_keys=[attr_name], + description=None, + type_hint=None, + default_value=parse_default_value(str(default_val)) if default_val is not None else None, + required=False, + source=f"{node.name}.{attr_name}", + provenance={"class": node.name, "line": item.lineno} + ) + self.params.append(param) + + self.generic_visit(node) + self._current_class = prev_class + + def visit_FunctionDef(self, node): + is_init = node.name == '__init__' + is_method = self._current_class is not None + class_name = self._current_class + # For non-__init__ methods inside a class, only keep params + # that look like configuration (have defaults and don't look + # like I/O data arguments). + skip_io = is_method and not is_init + + for arg in node.args.args: + if arg.arg in ('self', 'cls'): + continue + + # Get type hint + type_hint = None + if arg.annotation: + type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation) + + # Get default value + default_val = None + default_idx = len(node.args.args) - len(node.args.defaults) + if arg in node.args.args[default_idx:]: + default_node = node.args.defaults[node.args.args[default_idx:].index(arg)] + if hasattr(ast, 'unparse'): + default_val = ast.unparse(default_node) + else: + default_val = ast.literal_eval(default_node) if isinstance(default_node, (ast.Constant, ast.Str, ast.Num)) else None + + has_default = default_val is not None + + # ── I/O filter for non-constructor methods ── + if skip_io and extractor_cls._looks_like_io_param(arg.arg, type_hint, has_default): + continue + + # For non-__init__ methods, skip required params that + # have no default — they're almost always data args. + if skip_io and not has_default: + continue + + # Extract docstring info (Sphinx :param: and numpydoc styles) + description = None + if ast.get_docstring(node): + docstring = ast.get_docstring(node) + # Sphinx style — :param name: description + sphinx_pat = re.compile( + rf":param\s+{re.escape(arg.arg)}:\s*(.+?)(?=\n|:param|$)", + re.MULTILINE, + ) + m = sphinx_pat.search(docstring) + if m: + description = m.group(1).strip() + else: + # Numpydoc style — + # name : type + # Description text + numpydoc_pat = re.compile( + rf"^\s*{re.escape(arg.arg)}\s*(?::.*)?$\n((?:[ \t]+.+\n?)+)", + re.MULTILINE, + ) + m = numpydoc_pat.search(docstring) + if m: + # Merge continuation lines and strip indent + desc_lines = [l.strip() for l in m.group(1).splitlines() if l.strip()] + description = " ".join(desc_lines) + + func_label = f"{class_name}.{node.name}" if class_name else node.name + param = RawParameter( + name=normalize_parameter_name(arg.arg), + native_keys=[arg.arg], + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=f"{func_label}()", + provenance={ + "function": node.name, + "class": class_name, + "is_constructor": is_init, + "line": node.lineno, + } + ) + self.params.append(param) + + self.generic_visit(node) + + visitor = ParameterVisitor() + visitor.visit(tree) + return visitor.params + + def _extract_from_regex(self, source: str) -> List[RawParameter]: + """Fallback regex-based extraction.""" + parameters = [] + + # Extract function parameters + func_pattern = re.compile(r"def\s+\w+\s*\(([^)]+)\)", re.MULTILINE) + for match in func_pattern.finditer(source): + params_str = match.group(1) + for param_match in PYTHON_PATTERNS["function_param"].finditer(params_str): + param_name = param_match.group(1) + type_hint = param_match.group(2).strip() if param_match.group(2) else None + default_val = param_match.group(3).strip() if param_match.group(3) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(param_name), + native_keys=[param_name], + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=match.group(0), + provenance={"method": "regex"} + )) + + return parameters + + +class LLMPythonExtractor(LLMExtractor): + """LLM-based Python parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.PYTHON_LIB, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following Python code. +Look for: +- Function parameters with type hints and defaults +- Class attributes with type annotations +- Configuration classes (dataclasses, Pydantic models) +- Environment variables + +Python Code: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], # First 200 chars + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/readme_doc.py b/src/kgpipe_parameters/extraction/extractors/readme_doc.py new file mode 100644 index 0000000..aca8392 --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/readme_doc.py @@ -0,0 +1,320 @@ +""" +README / documentation parameter extraction. + +Extracts configuration parameters from README files, documentation pages, +and other unstructured markdown/text docs that describe tool usage. +""" + +import re +from typing import List, Optional, Set, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import README_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +# Noise words that appear as flags/placeholders but are not real parameters +_NOISE_NAMES: Set[str] = { + "h", "help", "version", "v", "verbose", "quiet", "q", + "the", "a", "an", "is", "are", "was", "were", "be", + "to", "of", "in", "for", "on", "at", "by", "with", + "it", "its", "we", "our", "you", "your", + "e", "g", "i", "x", "s", +} + + +def _extract_code_blocks(text: str) -> List[str]: + """Return contents of fenced code blocks (``` … ```).""" + return re.findall(r"```[^\n]*\n(.*?)```", text, re.DOTALL) + + +class ReadmeDocExtractor(RegexExtractor): + """Extract parameters from README / documentation text (Markdown or plain text).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.README, README_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMReadmeDocExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from README / documentation text.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters: List[RawParameter] = [] + errors: List[str] = [] + seen_names: Set[str] = set() + + try: + # --- 1. Parameters described in markdown list items --- + # e.g. - `threshold`: The matching threshold (default: 0.5) + for match in README_PATTERNS["list_param"].finditer(source): + name_raw = match.group(1) + description = match.group(2).strip() + normalized = normalize_parameter_name(name_raw) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + default_val = self._find_default(description) + type_hint = self._find_type_hint(description) + + parameters.append(RawParameter( + name=normalized, + native_keys=[name_raw], + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=False, + source=match.group(0).strip(), + provenance={"method": "readme_list_param"}, + )) + + # --- 2. Parameters in markdown tables --- + for match in README_PATTERNS["table_param"].finditer(source): + name_raw = match.group(1).strip() + col2 = match.group(2).strip() + col3 = match.group(3).strip() + normalized = normalize_parameter_name(name_raw) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + # Heuristic: second column is often type, third is description + type_hint = col2 if col2 and len(col2) < 30 else None + description = col3 or col2 + + parameters.append(RawParameter( + name=normalized, + native_keys=[name_raw], + description=description if description else None, + type_hint=type_hint, + default_value=None, + required=False, + source=match.group(0).strip(), + provenance={"method": "readme_table"}, + )) + + # --- 3. Flags from code blocks --- + code_blocks = _extract_code_blocks(source) + for block in code_blocks: + for match in README_PATTERNS["code_block_flag"].finditer(block): + flag = match.group(1) + value = match.group(2) + normalized = normalize_parameter_name(flag) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + parameters.append(RawParameter( + name=normalized, + native_keys=[flag], + description=None, + type_hint=None, + default_value=parse_default_value(value) if value else None, + required=False, + source=block[:200].strip(), + provenance={"method": "readme_code_block"}, + )) + + # JVM-style flags + for jvm_match in README_PATTERNS["jvm_flag"].finditer(block): + flag = jvm_match.group(1) + normalized = normalize_parameter_name(flag) + if normalized in seen_names: + continue + seen_names.add(normalized) + + parameters.append(RawParameter( + name=normalized, + native_keys=[flag], + description=f"JVM flag: {flag}", + type_hint=None, + default_value=None, + required=False, + source=block[:200].strip(), + provenance={"method": "readme_jvm_flag"}, + )) + + # --- 4. Inline flags referenced with backticks --- + for match in README_PATTERNS["inline_flag"].finditer(source): + flag = match.group(1) + normalized = normalize_parameter_name(flag) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + # Try to find a surrounding sentence as description + start = max(0, match.start() - 120) + end = min(len(source), match.end() + 120) + context = source[start:end].replace("\n", " ").strip() + + parameters.append(RawParameter( + name=normalized, + native_keys=[flag], + description=context, + type_hint=None, + default_value=None, + required=False, + source=context, + provenance={"method": "readme_inline_flag"}, + )) + + # --- 5. Environment variable references --- + for match in README_PATTERNS["env_reference"].finditer(source): + var_name = match.group(1) + var_value = match.group(2) if match.lastindex >= 2 else None + normalized = normalize_parameter_name(var_name) + if normalized in seen_names or len(normalized) < 2: + continue + seen_names.add(normalized) + + parameters.append(RawParameter( + name=normalized, + native_keys=[var_name], + description=f"Environment variable: {var_name}", + type_hint=None, + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=match.group(0).strip(), + provenance={"method": "readme_env_var"}, + )) + + # --- 6. Placeholder parameters from usage lines --- + for match in README_PATTERNS["placeholder"].finditer(source): + name_raw = match.group(1) + normalized = normalize_parameter_name(name_raw) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + # Grab surrounding line as context + line_start = source.rfind("\n", 0, match.start()) + 1 + line_end = source.find("\n", match.end()) + if line_end == -1: + line_end = len(source) + context_line = source[line_start:line_end].strip() + + parameters.append(RawParameter( + name=normalized, + native_keys=[f"<{name_raw}>"], + description=context_line, + type_hint=None, + default_value=None, + required=True, # placeholders are usually required + source=context_line, + provenance={"method": "readme_placeholder"}, + )) + + except Exception as e: + errors.append(f"Error extracting README parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_readme", + source_type=SourceType.README, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors, + ) + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + @staticmethod + def _find_default(text: str) -> Optional[str]: + """Try to extract a default value from a description string.""" + m = re.search(r"default[=:]\s*[`\"']?([^`\"'\]),\s]+)", text, re.IGNORECASE) + return m.group(1) if m else None + + @staticmethod + def _find_type_hint(text: str) -> Optional[str]: + """Try to infer a type hint from a description string.""" + for token in ("int", "integer", "float", "number", "bool", "boolean", "string", "str", "path", "file"): + if re.search(rf"\b{token}\b", text, re.IGNORECASE): + return token + return None + + +class LLMReadmeDocExtractor(LLMExtractor): + """LLM-based README / documentation parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.README, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following README / documentation text. +Look for: +- Command-line flags and options mentioned in usage examples +- Environment variables +- Configuration keys or settings +- Input/output paths that can be parameterized +- Any tunable values (thresholds, limits, memory sizes, etc.) + +Documentation: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"}, + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_readme", + source_type=SourceType.README, + extraction_method=ExtractionMethod.LLM, + parameters=parameters, + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_readme", + source_type=SourceType.README, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"], + ) + diff --git a/src/kgpipe_parameters/extraction/models.py b/src/kgpipe_parameters/extraction/models.py new file mode 100644 index 0000000..f209fe0 --- /dev/null +++ b/src/kgpipe_parameters/extraction/models.py @@ -0,0 +1,85 @@ +""" +Pydantic models for raw parameter extraction results. +""" + +from typing import List, Optional, Dict, Any, Union +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from enum import Enum + + +class SourceType(str, Enum): + """Types of sources for parameter extraction.""" + CLI = "cli" + PYTHON_LIB = "python_lib" + HTTP_API = "http_api" + DOCKER = "docker" + README = "readme" + UNKNOWN = "unknown" + + +class ExtractionMethod(str, Enum): + """Methods used for parameter extraction.""" + REGEX = "regex" + LLM = "llm" + AUTO = "auto" + + +class RawParameter(BaseModel): + """ + Intermediate representation of an extracted parameter. + This is the raw extraction result before conversion to Parameter model. + """ + name: str = Field(..., description="Normalized parameter name") + native_keys: List[str] = Field(default_factory=list, description="Original parameter names/flags from source") + description: Optional[str] = Field(None, description="Parameter description/documentation") + type_hint: Optional[str] = Field(None, description="Type hint or type name from source") + default_value: Optional[Union[str, int, float, bool]] = Field(None, description="Default value if present") + required: bool = Field(False, description="Whether parameter is required") + constraints: Dict[str, Any] = Field(default_factory=dict, description="Constraints like min, max, allowed_values") + source: str = Field(..., description="Source text or file path where parameter was found") + provenance: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata about extraction") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "threshold", + "native_keys": ["--threshold", "-t", "THRESHOLD"], + "description": "Matching threshold value", + "type_hint": "float", + "default_value": 0.5, + "required": False, + "constraints": {"minimum": 0.0, "maximum": 1.0}, + "source": "tool.py --help", + "provenance": {"line_number": 42, "extraction_method": "regex"} + } + } + ) + + +class ExtractionResult(BaseModel): + """ + Container for extracted parameters with metadata. + """ + tool_name: str = Field(..., description="Name of the tool/library being analyzed") + source_type: SourceType = Field(..., description="Type of source (CLI, Python, API, Docker)") + extraction_method: ExtractionMethod = Field(..., description="Method used for extraction") + parameters: List[RawParameter] = Field(default_factory=list, description="List of extracted parameters") + timestamp: datetime = Field(default_factory=datetime.now, description="When extraction was performed") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata about the extraction") + errors: List[str] = Field(default_factory=list, description="Any errors encountered during extraction") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "tool_name": "paris_matcher", + "source_type": "cli", + "extraction_method": "regex", + "parameters": [], + "timestamp": "2024-01-01T00:00:00", + "metadata": {"source_file": "paris --help"}, + "errors": [] + } + } + ) + diff --git a/src/kgpipe_parameters/extraction/param_miner.py b/src/kgpipe_parameters/extraction/param_miner.py new file mode 100644 index 0000000..bb209d0 --- /dev/null +++ b/src/kgpipe_parameters/extraction/param_miner.py @@ -0,0 +1,204 @@ +""" +Parameter mining/extraction from various sources (CLI, Python, HTTP APIs, Docker). + +This module provides the main ParameterMiner class for unified parameter extraction. +Individual extractors are implemented in the extractors/ submodule. +""" + +import ast +from pathlib import Path +from typing import Optional, Union + +from .models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from .extractors import ( + CLIExtractor, + PythonLibExtractor, + HTTPAPIExtractor, + DockerExtractor, + ReadmeDocExtractor, + LLMCLIExtractor, + LLMPythonExtractor, + LLMHTTPExtractor, + LLMDockerExtractor, + LLMReadmeDocExtractor, +) + +# Re-export extractors for backwards compatibility +__all__ = [ + "ParameterMiner", + "CLIExtractor", + "PythonLibExtractor", + "HTTPAPIExtractor", + "DockerExtractor", + "ReadmeDocExtractor", + "LLMCLIExtractor", + "LLMPythonExtractor", + "LLMHTTPExtractor", + "LLMDockerExtractor", + "LLMReadmeDocExtractor", +] + + +class ParameterMiner: + """ + Main class for parameter extraction from various sources. + Provides unified interface for extracting configuration parameters. + """ + + def __init__(self, llm_client=None): + """ + Initialize ParameterMiner. + + Args: + llm_client: Optional LLMClient instance for LLM-based extraction + """ + self.llm_client = llm_client + self.extractors = { + SourceType.CLI: CLIExtractor(use_llm=False), + SourceType.PYTHON_LIB: PythonLibExtractor(use_llm=False), + SourceType.HTTP_API: HTTPAPIExtractor(use_llm=False), + SourceType.DOCKER: DockerExtractor(use_llm=False), + SourceType.README: ReadmeDocExtractor(use_llm=False), + } + + def extract_parameters( + self, + source: Union[str, Path], + source_type: Optional[SourceType] = None, + method: ExtractionMethod = ExtractionMethod.AUTO, + tool_name: Optional[str] = None + ) -> ExtractionResult: + """ + Extract parameters from a source. + + Args: + source: Source content (text, file path, etc.) + source_type: Type of source (auto-detected if None) + method: Extraction method ('regex', 'llm', or 'auto') + tool_name: Optional name of the tool being analyzed + + Returns: + ExtractionResult containing extracted parameters + """ + # Read file if Path provided + if isinstance(source, Path): + source_path = source + source = source_path.read_text() + if not tool_name: + tool_name = source_path.stem + elif isinstance(source, str): + # Only treat as file path if it's a short string without newlines + # and actually exists as a file + if len(source) < 260 and '\n' not in source and Path(source).exists(): + source_path = Path(source) + source = source_path.read_text() + if not tool_name: + tool_name = source_path.stem + + # Auto-detect source type if not provided + if source_type is None: + source_type = self._detect_source_type(source) + + # Select extraction method + if method == ExtractionMethod.AUTO: + # Try regex first, fallback to LLM if available + try: + if source_type == SourceType.UNKNOWN: + # For unknown source types, try CLI extractor as fallback + extractor = self.extractors[SourceType.CLI] + else: + extractor = self.extractors[source_type] + result = extractor.extract(source, tool_name) + # If regex extraction found few/no parameters and LLM is available, try LLM + if len(result.parameters) == 0 and self.llm_client: + method = ExtractionMethod.LLM + else: + return result + except (KeyError, Exception): + if self.llm_client: + method = ExtractionMethod.LLM + else: + # Return empty result for unknown types + return ExtractionResult( + tool_name=tool_name or "unknown", + source_type=source_type, + extraction_method=ExtractionMethod.REGEX, + parameters=[], + errors=[f"No extractor available for source type: {source_type}"] + ) + + # Use LLM if requested or as fallback + if method == ExtractionMethod.LLM: + if not self.llm_client: + raise ValueError("LLM extraction requires an LLMClient instance") + + # Create LLM extractor for the source type + llm_extractors = { + SourceType.CLI: LLMCLIExtractor(self.llm_client), + SourceType.PYTHON_LIB: LLMPythonExtractor(self.llm_client), + SourceType.HTTP_API: LLMHTTPExtractor(self.llm_client), + SourceType.DOCKER: LLMDockerExtractor(self.llm_client), + SourceType.README: LLMReadmeDocExtractor(self.llm_client), + } + extractor = llm_extractors.get(source_type) + if extractor: + return extractor.extract(source, tool_name) + + # Use regex extractor + extractor = self.extractors[source_type] + return extractor.extract(source, tool_name) + + def _detect_source_type(self, source: str) -> SourceType: + """Auto-detect source type from content.""" + source_lower = source.lower() + + # Check for CLI help patterns + if any(x in source_lower for x in ["usage:", "options:", "--help", "arguments:"]): + return SourceType.CLI + + # Check for Python code + if any(x in source for x in ["def ", "class ", "import ", "@"]): + try: + ast.parse(source) + return SourceType.PYTHON_LIB + except SyntaxError: + pass + + # Check for OpenAPI/Swagger + if any(x in source for x in ['"openapi"', '"swagger"', "paths:", "components:"]): + return SourceType.HTTP_API + + # Check for Docker + if any(x in source for x in ["FROM ", "ENV ", "ARG ", "docker-compose", "services:"]): + return SourceType.DOCKER + + # Check for README / Markdown documentation + if any(x in source for x in ["# ", "## ", "```", "**", "[", "](", "---"]): + return SourceType.README + + return SourceType.UNKNOWN + + def to_parameter_model(self, raw_param: RawParameter): + """ + Convert RawParameter to Parameter model. + + Args: + raw_param: RawParameter instance + + Returns: + Parameter model instance + """ + from .utils import to_parameter_model + return to_parameter_model(raw_param) + + def to_json(self, result: ExtractionResult) -> str: + """ + Convert ExtractionResult to JSON string. + + Args: + result: ExtractionResult instance + + Returns: + JSON string representation + """ + return result.model_dump_json(indent=2) diff --git a/src/kgpipe_parameters/extraction/patterns.py b/src/kgpipe_parameters/extraction/patterns.py new file mode 100644 index 0000000..f1d985b --- /dev/null +++ b/src/kgpipe_parameters/extraction/patterns.py @@ -0,0 +1,156 @@ +""" +Regex patterns for parameter extraction from various sources. +""" + +import re +from typing import Dict, List, Tuple, Optional + + +# CLI argument patterns +CLI_PATTERNS = { + # Long form: --param, --param=VALUE, --param VALUE + "long_flag": re.compile(r"--([a-zA-Z][a-zA-Z0-9_-]*)(?:[=\s]+([^\s]+))?"), + # Short form: -p, -p VALUE, -pVALUE + "short_flag": re.compile(r"-([a-zA-Z])(?:\s+([^\s]+))?"), + # Combined: -p, --param + "combined_flag": re.compile(r"(-[a-zA-Z]|--[a-zA-Z][a-zA-Z0-9_-]+)"), + # Description lines (common in help output) + "description": re.compile(r"^\s+([^\s]+(?:\s+[^\s]+)*)\s+(.+)$"), + # Required/optional indicators + "required": re.compile(r"(required|mandatory|must)", re.IGNORECASE), + "optional": re.compile(r"(optional|\[optional\]|\[default)", re.IGNORECASE), + # Default values: [default: value], (default: value), default=value + # Match: (default: 0.5) -> capture "0.5", [default: value] -> capture "value", default=value -> capture "value" + # The pattern matches "default:" or "default=" and captures the value until closing bracket/paren or end + "default_value": re.compile(r"default[=:]\s*([^\])]+?)(?:\]|\)|$)", re.IGNORECASE), + # Type hints: , [str], (float) + "type_hint": re.compile(r"[<\[\(]([a-zA-Z]+)[>\]\)]"), +} + +# Python code patterns +PYTHON_PATTERNS = { + # Function parameter: param: type = default + "function_param": re.compile(r"(\w+)(?:\s*:\s*([^=]+))?(?:\s*=\s*([^,)]+))?"), + # Type hints: param: int, param: Optional[str] = None + "type_annotation": re.compile(r":\s*([^=,)]+)"), + # Default values in function signatures + "default_in_sig": re.compile(r"=\s*([^,)]+)"), + # Docstring parameter descriptions: :param name: description + "docstring_param": re.compile(r":param\s+(\w+):\s*(.+?)(?=\n|:param|$)", re.MULTILINE), + # Docstring type: :type name: type + "docstring_type": re.compile(r":type\s+(\w+):\s*([^\n]+)"), + # Class attributes with type hints + "class_attr": re.compile(r"(\w+)\s*:\s*([^=\n]+)(?:\s*=\s*([^\n]+))?"), + # Environment variable assignments: VAR = value + "env_var": re.compile(r"([A-Z_][A-Z0-9_]*)\s*=\s*(.+)"), +} + +# HTTP API patterns +API_PATTERNS = { + # Query parameters: ?param=value + "query_param": re.compile(r"[?&]([^=&]+)(?:=([^&]+))?"), + # Path parameters: /{param}/ + "path_param": re.compile(r"/\{([^}]+)\}/"), + # Header parameters: X-Header-Name: value + "header": re.compile(r"([A-Z][a-zA-Z0-9-]+):\s*(.+)"), + # JSON schema properties + "json_property": re.compile(r'"([^"]+)":\s*\{[^}]*"type":\s*"([^"]+)"'), + # OpenAPI parameter definitions + "openapi_param": re.compile(r'"([^"]+)":\s*\{[^}]*"in":\s*"([^"]+)"'), +} + +# Docker patterns +DOCKER_PATTERNS = { + # ENV variable: ENV VAR=value or ENV VAR value + "env_declaration": re.compile(r"ENV\s+([A-Z_][A-Z0-9_]*)(?:\s*=\s*|\s+)(.+)", re.IGNORECASE), + # ARG declaration: ARG VAR[=default] + "arg_declaration": re.compile(r"ARG\s+([A-Z_][A-Z0-9_]*)(?:\s*=\s*([^\s]+))?", re.IGNORECASE), + # Environment variable in docker-compose: VAR: value + "compose_env": re.compile(r"([A-Z_][A-Z0-9_]*)\s*:\s*(.+)"), + # Volume mounts: -v /host:/container + "volume_mount": re.compile(r"-v\s+([^:\s]+):([^:\s]+)"), + # Port mappings: -p HOST:CONTAINER + "port_mapping": re.compile(r"-p\s+(\d+):(\d+)"), +} + +# README / documentation patterns +README_PATTERNS = { + # Flags or options mentioned in code blocks or inline code: --param, -p + "inline_flag": re.compile(r"`(-{1,2}[a-zA-Z][a-zA-Z0-9_-]*)`"), + # Command-line invocations in code blocks: tool --param value + "code_block_flag": re.compile(r"(?:^|\s)(-{1,2}[a-zA-Z][a-zA-Z0-9_-]*)(?:\s+(\S+))?", re.MULTILINE), + # Environment variable references: $VAR, ${VAR}, ENV VAR, set VAR= + "env_reference": re.compile(r"(?:\$\{?|(?:set|export)\s+)([A-Z_][A-Z0-9_]*)(?:\}|=([^\s]+))?"), + # Config key-value in YAML/properties style: key: value or key = value + "config_kv": re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_.]+)\s*[=:]\s*(.+)$", re.MULTILINE), + # JVM-style flags: -Xmx47000m, -XX:+UseG1GC + "jvm_flag": re.compile(r"(-X[a-z]+\d*[a-zA-Z]*|-XX:[+\-]?\w+(?:=\S+)?)"), + # Markdown table rows with parameter-like content: | param | type | description | + "table_param": re.compile(r"\|\s*`?([a-zA-Z_][a-zA-Z0-9_-]*)`?\s*\|([^|]*)\|([^|]*)\|"), + # Setting/configuration references: "set X to Y", "configure X as Y" + "setting_reference": re.compile( + r"(?:set|configure|specify|use)\s+[`\"']?([a-zA-Z_][a-zA-Z0-9_-]*)[`\"']?\s+(?:to|as|=)\s+[`\"']?([^\s,`\"']+)", + re.IGNORECASE, + ), + # Parameter descriptions in lists: - `param`: description or * param — description + "list_param": re.compile(r"^\s*[-*]\s+`([a-zA-Z_][a-zA-Z0-9_-]*)`[:\s]+(.+)$", re.MULTILINE), + # Placeholder patterns like , [param], {param} in usage lines + "placeholder": re.compile(r"<([a-zA-Z_][a-zA-Z0-9_]*)>"), +} + +# Common patterns for all sources +COMMON_PATTERNS = { + # Numeric constraints: min=0, max=100 + "min_max": re.compile(r"(?:min|minimum)[=:]\s*([0-9.]+).*(?:max|maximum)[=:]\s*([0-9.]+)", re.IGNORECASE), + # Allowed values: choices=[a, b, c] or enum: [a, b, c] + "allowed_values": re.compile(r"(?:choices|enum|options)[=:]\s*\[([^\]]+)\]", re.IGNORECASE), + # Boolean flags: true/false, yes/no, 1/0 + "boolean": re.compile(r"(true|false|yes|no|1|0)", re.IGNORECASE), + # Numeric types: int, float, number + "numeric": re.compile(r"(int|integer|float|number|double)", re.IGNORECASE), + # String types: str, string, text + "string": re.compile(r"(str|string|text)", re.IGNORECASE), +} + + +def get_patterns(source_type: str) -> Dict[str, re.Pattern]: + """ + Get regex patterns for a specific source type. + + Args: + source_type: One of 'cli', 'python', 'api', 'docker' + + Returns: + Dictionary of compiled regex patterns + """ + patterns_map = { + "cli": CLI_PATTERNS, + "python": PYTHON_PATTERNS, + "api": API_PATTERNS, + "docker": DOCKER_PATTERNS, + "readme": README_PATTERNS, + } + return patterns_map.get(source_type.lower(), {}) + + +def match_pattern(text: str, pattern: re.Pattern, group_names: Optional[List[str]] = None) -> List[Dict[str, str]]: + """ + Match a pattern against text and return structured results. + + Args: + text: Text to search + pattern: Compiled regex pattern + group_names: Optional names for capture groups + + Returns: + List of dictionaries with match information + """ + matches = [] + for match in pattern.finditer(text): + groups = match.groups() + if group_names and len(group_names) == len(groups): + matches.append(dict(zip(group_names, groups))) + else: + matches.append({"match": match.group(0), "groups": groups}) + return matches + diff --git a/src/kgpipe_parameters/extraction/utils.py b/src/kgpipe_parameters/extraction/utils.py new file mode 100644 index 0000000..1993bf2 --- /dev/null +++ b/src/kgpipe_parameters/extraction/utils.py @@ -0,0 +1,237 @@ +""" +Utility functions for parameter extraction and conversion. +""" + +import re +from typing import Optional, Union, List, Any, Dict +from .models import RawParameter +from kgpipe.common.model.configuration import Parameter, ParameterType + + +def infer_parameter_type(type_hint: Optional[str], default_value: Any = None) -> ParameterType: + """ + Infer ParameterType from type hint string or default value. + + Args: + type_hint: Type hint string (e.g., "int", "float", "str", "bool") + default_value: Default value to infer type from if type_hint is None + + Returns: + ParameterType enum value + """ + if type_hint: + type_hint_lower = type_hint.lower().strip() + + # Check for boolean + if any(x in type_hint_lower for x in ["bool", "boolean"]): + return ParameterType.boolean + + # Check for integer + if any(x in type_hint_lower for x in ["int", "integer"]): + return ParameterType.integer + + # Check for float/number + if any(x in type_hint_lower for x in ["float", "number", "double", "decimal"]): + return ParameterType.number + + # Check for array/list + if any(x in type_hint_lower for x in ["list", "array", "[]", "List"]): + return ParameterType.array + + # Check for object/dict + if any(x in type_hint_lower for x in ["dict", "object", "Dict", "{}"]): + return ParameterType.object + + # Check for enum + if "enum" in type_hint_lower or "choice" in type_hint_lower: + return ParameterType.enum + + # Infer from default value + if default_value is not None: + if isinstance(default_value, bool): + return ParameterType.boolean + elif isinstance(default_value, int): + return ParameterType.integer + elif isinstance(default_value, float): + return ParameterType.number + elif isinstance(default_value, list): + return ParameterType.array + elif isinstance(default_value, dict): + return ParameterType.object + + # Default to string + return ParameterType.string + + +def parse_default_value(value_str: Optional[str]) -> Optional[Union[str, int, float, bool]]: + """ + Parse a default value string into appropriate Python type. + + Args: + value_str: String representation of default value + + Returns: + Parsed value (str, int, float, or bool) or None + """ + if value_str is None: + return None + + value_str = value_str.strip().strip('"').strip("'") + + # Try integer first (before boolean, so "0" and "1" stay numeric) + try: + if value_str.isdigit() or (value_str.startswith("-") and value_str[1:].isdigit()): + return int(value_str) + except ValueError: + pass + + # Try boolean + if value_str.lower() in ["true", "false", "yes", "no"]: + return value_str.lower() in ["true", "yes"] + + # Try float + try: + return float(value_str) + except ValueError: + pass + + # Return as string + return value_str + + +def normalize_parameter_name(name: str) -> str: + """ + Normalize parameter name to a standard format. + + Args: + name: Original parameter name (may include --, -, etc.) + + Returns: + Normalized name (lowercase, underscores instead of hyphens) + """ + # Remove leading dashes and spaces + name = name.lstrip("-").lstrip() + + # Replace hyphens with underscores + name = name.replace("-", "_") + + # Convert to lowercase + name = name.lower() + + # Remove special characters except underscores + name = re.sub(r"[^a-z0-9_]", "", name) + + return name + + +def extract_constraints(description: Optional[str], type_hint: Optional[str] = None) -> Dict[str, Any]: + """ + Extract constraints (min, max, allowed_values) from description or type hint. + + Args: + description: Parameter description text + type_hint: Type hint string + + Returns: + Dictionary with constraint information + """ + constraints = {} + + if not description: + return constraints + + # Extract min/max values - try combined first, then separate + min_max_pattern = re.compile(r"(?:min|minimum)[=:]\s*([0-9.]+).*(?:max|maximum)[=:]\s*([0-9.]+)", re.IGNORECASE) + min_max_match = min_max_pattern.search(description) + if min_max_match: + constraints["minimum"] = float(min_max_match.group(1)) + constraints["maximum"] = float(min_max_match.group(2)) + + # Try separate min and max (even if combined pattern didn't match) + # More flexible pattern to handle "Minimum value: 10" or "min: 10" formats + min_pattern = re.compile(r"(?:min|minimum)(?:\s+value)?[=:]\s*([0-9.]+)", re.IGNORECASE) + max_pattern = re.compile(r"(?:max|maximum)(?:\s+value)?[=:]\s*([0-9.]+)", re.IGNORECASE) + min_match = min_pattern.search(description) + max_match = max_pattern.search(description) + if min_match and "minimum" not in constraints: + constraints["minimum"] = float(min_match.group(1)) + if max_match and "maximum" not in constraints: + constraints["maximum"] = float(max_match.group(1)) + + # Extract allowed values / choices + choices_pattern = re.compile(r"(?:choices|enum|options|allowed)[=:]\s*\[([^\]]+)\]", re.IGNORECASE) + choices_match = choices_pattern.search(description) + if choices_match: + choices_str = choices_match.group(1) + # Split by comma and clean up + choices = [c.strip().strip('"').strip("'") for c in choices_str.split(",")] + constraints["allowed_values"] = choices + + return constraints + + +def to_parameter_model(raw_param: RawParameter) -> Parameter: + """ + Convert a RawParameter to a Parameter model. + + Args: + raw_param: RawParameter instance + + Returns: + Parameter model instance + """ + # Infer parameter type + param_type = infer_parameter_type(raw_param.type_hint, raw_param.default_value) + + # Parse default value + default_val = raw_param.default_value + if isinstance(default_val, str): + default_val = parse_default_value(default_val) + + # Ensure default value matches the inferred type + if default_val is None: + # Set appropriate default based on type + if param_type == ParameterType.boolean: + default_val = False + elif param_type == ParameterType.integer: + default_val = 0 + elif param_type == ParameterType.number: + default_val = 0.0 + elif param_type == ParameterType.string: + default_val = "" + elif param_type == ParameterType.array: + default_val = [] + elif param_type == ParameterType.object: + default_val = {} + + # Extract constraints + constraints = extract_constraints(raw_param.description, raw_param.type_hint) + constraints.update(raw_param.constraints) + + # Get allowed values + allowed_values = constraints.get("allowed_values", []) + if allowed_values: + # Convert to appropriate types + typed_allowed = [] + for val in allowed_values: + parsed = parse_default_value(str(val)) + typed_allowed.append(parsed if parsed is not None else str(val)) + allowed_values = typed_allowed + + # Ensure native_keys includes the name + native_keys = list(raw_param.native_keys) + if raw_param.name not in native_keys: + native_keys.insert(0, raw_param.name) + + return Parameter( + name=raw_param.name, + native_keys=native_keys, + datatype=param_type, + default_value=default_val, + required=raw_param.required, + allowed_values=allowed_values, + minimum=constraints.get("minimum"), + maximum=constraints.get("maximum"), + unit=constraints.get("unit"), + ) + diff --git a/src/kgpipe_parameters/tests/__init__.py b/src/kgpipe_parameters/tests/__init__.py new file mode 100644 index 0000000..c5a603a --- /dev/null +++ b/src/kgpipe_parameters/tests/__init__.py @@ -0,0 +1,4 @@ +""" +Tests for parameter extraction module. +""" + diff --git a/src/kgpipe_parameters/tests/conftest.py b/src/kgpipe_parameters/tests/conftest.py new file mode 100644 index 0000000..5c4004f --- /dev/null +++ b/src/kgpipe_parameters/tests/conftest.py @@ -0,0 +1,131 @@ +""" +Pytest fixtures for parameter extraction tests. +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, MagicMock +from typing import Dict, Any + + +def get_test_data_path(relative_path: str) -> Path: + """Get path to test data file.""" + test_dir = Path(__file__).parent + path = test_dir / "test_data" / relative_path + if not path.exists(): + raise FileNotFoundError(f"Test data path {path} does not exist") + return path + + +@pytest.fixture +def test_data_dir(): + """Fixture for test data directory.""" + return Path(__file__).parent / "test_data" + + +@pytest.fixture +def cli_help_argparse(): + """Fixture for argparse CLI help text.""" + path = get_test_data_path("cli/argparse_help.txt") + return path.read_text() + + +@pytest.fixture +def cli_help_click(): + """Fixture for click CLI help text.""" + path = get_test_data_path("cli/click_help.txt") + return path.read_text() + + +@pytest.fixture +def cli_help_simple(): + """Fixture for simple CLI help text.""" + path = get_test_data_path("cli/simple_help.txt") + return path.read_text() + + +@pytest.fixture +def python_function_code(): + """Fixture for Python function code.""" + path = get_test_data_path("python/function_with_params.py") + return path.read_text() + + +@pytest.fixture +def python_dataclass_code(): + """Fixture for Python dataclass code.""" + path = get_test_data_path("python/dataclass_config.py") + return path.read_text() + + +@pytest.fixture +def python_pydantic_code(): + """Fixture for Python Pydantic model code.""" + path = get_test_data_path("python/pydantic_model.py") + return path.read_text() + + +@pytest.fixture +def openapi_spec(): + """Fixture for OpenAPI specification.""" + path = get_test_data_path("api/openapi_spec.yaml") + return path.read_text() + + +@pytest.fixture +def swagger_spec(): + """Fixture for Swagger specification.""" + path = get_test_data_path("api/swagger_spec.json") + return path.read_text() + + +@pytest.fixture +def dockerfile_content(): + """Fixture for Dockerfile content.""" + path = get_test_data_path("docker/Dockerfile") + return path.read_text() + + +@pytest.fixture +def docker_compose_content(): + """Fixture for docker-compose.yml content.""" + path = get_test_data_path("docker/docker-compose.yml") + return path.read_text() + + +@pytest.fixture +def readme_tool_doc(): + """Fixture for a tool README with configuration parameters.""" + path = get_test_data_path("readme/tool_readme.md") + return path.read_text() + + +@pytest.fixture +def readme_minimal(): + """Fixture for a minimal README.""" + path = get_test_data_path("readme/minimal_readme.md") + return path.read_text() + + +@pytest.fixture +def mock_llm_client(): + """Fixture for mocked LLM client.""" + mock_client = Mock() + + # Mock response structure + mock_response = { + "parameters": [ + { + "name": "threshold", + "native_keys": ["--threshold", "-t"], + "description": "Matching threshold", + "type_hint": "float", + "default_value": 0.5, + "required": False + } + ] + } + + mock_client.send_prompt = Mock(return_value=mock_response) + return mock_client + diff --git a/src/kgpipe_parameters/tests/test_chunk_filter.py b/src/kgpipe_parameters/tests/test_chunk_filter.py new file mode 100644 index 0000000..a8dc404 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_chunk_filter.py @@ -0,0 +1,263 @@ +""" +Tests for keyword-based chunk scoring / filtering. +""" + +import pytest + +from kgpipe_parameters.extraction.chunk_filter import ( + score_chunk, + has_parameter_signals, + KEYWORD_SETS, + _detect_language, +) + + +# ── Language detection ────────────────────────────────────────────────── + +class TestLanguageDetection: + """Tests for _detect_language helper.""" + + def test_python_extension(self): + assert _detect_language("src/foo/bar.py") == "python" + + def test_java_extension(self): + assert _detect_language("src/Main.java") == "java" + + def test_properties_extension(self): + assert _detect_language("conf/server.properties") == "properties" + + def test_xml_extension(self): + assert _detect_language("config.xml") == "xml" + + def test_dockerfile(self): + assert _detect_language("Dockerfile") == "docker" + assert _detect_language("docker-compose.yml") == "docker" + + def test_readme(self): + assert _detect_language("README.md") == "readme" + assert _detect_language("INSTALL.txt") == "readme" + + def test_unknown_defaults_to_generic(self): + assert _detect_language("random.xyz") == "generic" + assert _detect_language(None) == "generic" + + +# ── Scoring ───────────────────────────────────────────────────────────── + +class TestScoreChunk: + """Tests for score_chunk.""" + + def test_empty_text(self): + score, matched = score_chunk("") + assert score == 0 + assert matched == [] + + def test_python_argparse(self): + code = ''' +import argparse +parser = argparse.ArgumentParser() +parser.add_argument("--threshold", type=float, default=0.5, help="Matching threshold") +''' + score, matched = score_chunk(code, file_path="cli.py") + assert score >= 3 # argparse, add_argument, default=, type=, help= + assert "argparse" in matched + assert "add_argument" in matched + + def test_python_dataclass(self): + code = ''' +from dataclasses import dataclass, field + +@dataclass +class Config: + threshold: float = 0.5 + batch_size: int = Field(default=32) +''' + score, matched = score_chunk(code, file_path="config.py") + assert score >= 2 + assert "@dataclass" in matched + assert "Field(" in matched + + def test_python_no_signals(self): + code = ''' +def compute_arabic_segmenter(text): + tokens = text.split() + return [t for t in tokens if len(t) > 2] +''' + score, matched = score_chunk(code, file_path="segmenter.py") + assert score < 2 # No real parameter signals + + def test_java_option_annotation(self): + code = ''' +public class RunPARIS { + @Option(name = "-n", usage = "number of iterations") + int numIterations = 10; + + @Option(name = "-t", usage = "threshold") + double threshold = 0.5; +} +''' + score, matched = score_chunk(code, file_path="RunPARIS.java") + assert score >= 1 # @Option is the signal; Java threshold is 1 + assert "@Option" in matched + # The file still passes the filter (Java auto-lowers threshold to 1) + assert has_parameter_signals(code, file_path="RunPARIS.java") is True + + def test_java_properties_access(self): + code = ''' +Properties props = new Properties(); +props.load(new FileInputStream("config.properties")); +String value = props.getProperty("matchThreshold"); +int maxIter = Integer.parseInt(props.getProperty("maxIterations")); +''' + score, matched = score_chunk(code, file_path="Config.java") + assert score >= 2 + assert "getProperty(" in matched + assert "Properties" in matched + + def test_java_no_signals(self): + code = ''' +public class ArabicTokenizer { + public List tokenize(String text) { + return Arrays.asList(text.split(" ")); + } +} +''' + score, matched = score_chunk(code, file_path="ArabicTokenizer.java") + assert score < 2 + + def test_properties_file(self): + content = ''' +# Server configuration +server.port=8080 +matching.threshold=0.5 +max.iterations=100 +''' + score, matched = score_chunk(content, file_path="server.properties") + assert score >= 1 # .properties files have low bar + + def test_xml_config(self): + content = ''' + + + + +''' + score, matched = score_chunk(content, file_path="config.xml") + assert score >= 2 + assert "= 3 + assert "ENV " in matched + assert "ARG " in matched + assert "EXPOSE " in matched + + def test_readme_with_params(self): + content = ''' +# My Tool + +## Usage + +```bash +mytool --threshold 0.5 --output result.txt +``` + +## Configuration + +- `threshold`: Matching threshold (default: 0.5) +- `max_iter`: Maximum iterations (default: 100) +''' + score, matched = score_chunk(content, file_path="README.md") + assert score >= 3 + + def test_readme_no_params(self): + content = ''' +# My Project + +This is a library for natural language processing. + +## License + +MIT License +''' + score, matched = score_chunk(content, file_path="README.md") + # Very few or no config signals + assert score <= 2 + + def test_explicit_language_override(self): + code = "parser.add_argument('--foo')" + score, matched = score_chunk(code, language="python") + assert "add_argument" in matched + + +# ── has_parameter_signals ─────────────────────────────────────────────── + +class TestHasParameterSignals: + """Tests for the boolean filter function.""" + + def test_python_with_signals(self): + code = 'parser = argparse.ArgumentParser()\nparser.add_argument("--x", default=5)' + assert has_parameter_signals(code, file_path="cli.py") is True + + def test_python_without_signals(self): + code = "x = 1 + 2\nprint(x)" + assert has_parameter_signals(code, file_path="math.py") is False + + def test_threshold_override(self): + code = "argparse" + # With default threshold=2 this would fail (only 1 keyword) + assert has_parameter_signals(code, file_path="x.py", threshold=2) is False + # With threshold=1 it passes + assert has_parameter_signals(code, file_path="x.py", threshold=1) is True + + def test_properties_low_bar(self): + content = "key=value" + # .properties files auto-lower threshold to 1 + assert has_parameter_signals(content, file_path="app.properties") is True + + def test_xml_low_bar(self): + content = '' + assert has_parameter_signals(content, file_path="config.xml") is True + + def test_java_config_class_passes(self): + code = ''' +public class AppConfig { + @Option(name = "-t") + double threshold = DEFAULT_THRESHOLD; +} +''' + assert has_parameter_signals(code, file_path="AppConfig.java") is True + + def test_java_non_config_class_fails(self): + code = ''' +public class Utils { + public static String trim(String s) { + return s.trim(); + } +} +''' + assert has_parameter_signals(code, file_path="Utils.java") is False + + +# ── Keyword set sanity ────────────────────────────────────────────────── + +class TestKeywordSets: + """Sanity checks on the keyword dictionaries.""" + + def test_all_sets_non_empty(self): + for name, kws in KEYWORD_SETS.items(): + assert len(kws) > 0, f"Keyword set '{name}' is empty" + + def test_no_empty_keywords(self): + for name, kws in KEYWORD_SETS.items(): + for kw in kws: + assert kw.strip() != "", f"Empty keyword in set '{name}'" + diff --git a/src/kgpipe_parameters/tests/test_clustering.py b/src/kgpipe_parameters/tests/test_clustering.py new file mode 100644 index 0000000..1839ad8 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_clustering.py @@ -0,0 +1,324 @@ +""" +Tests for the parameter clustering module. +""" + +import json +import pytest +import numpy as np +from pathlib import Path +from unittest.mock import patch, MagicMock +from typing import List + +from kgpipe_parameters.clustering.models import ( + ParameterVector, + ParameterCluster, + ClusteringResult, +) +from kgpipe_parameters.clustering.similarity import ( + embed_parameters, + cosine_similarity_matrix, +) +from kgpipe_parameters.clustering.clusterer import ParameterClusterer + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +def _make_param( + name: str, + tool: str, + description: str = "", + native_keys: List[str] | None = None, + type_hint: str | None = None, + default_value=None, +) -> ParameterVector: + return ParameterVector( + name=name, + tool_name=tool, + native_keys=native_keys or [], + description=description, + type_hint=type_hint, + default_value=default_value, + source_label=f"{tool}/source", + ) + + +@pytest.fixture +def sample_parameters() -> List[ParameterVector]: + """A small set of parameters from two fictitious tools.""" + return [ + # Tool A + _make_param("threshold", "tool_a", "Matching threshold value", ["--threshold", "-t"], "float", 0.5), + _make_param("max_iterations", "tool_a", "Maximum number of iterations", ["--max-iter"], "int", 100), + _make_param("output_dir", "tool_a", "Output directory path", ["--output", "-o"], "str"), + _make_param("batch_size", "tool_a", "Number of items per batch", ["--batch-size"], "int", 32), + # Tool B + _make_param("similarity_threshold", "tool_b", "Threshold for similarity matching", ["--sim-threshold"], "float", 0.7), + _make_param("iterations", "tool_b", "Number of iterations to run", ["--iterations", "-n"], "int", 50), + _make_param("output_path", "tool_b", "Path for output files", ["--output-path"], "str"), + _make_param("learning_rate", "tool_b", "Learning rate for optimizer", ["--lr"], "float", 0.001), + ] + + +@pytest.fixture +def mock_sentence_model(): + """A mock SentenceTransformer that returns deterministic embeddings.""" + model = MagicMock() + # Return embeddings designed so that similar parameters are closer. + # Each "encode" call gets a list of texts; we return a (N, 8) array + # seeded from the text hash so it is deterministic. + + def _encode(texts, batch_size=64, show_progress_bar=False): + rng = np.random.RandomState(42) + # Use a small embedding dim for testing speed + embs = [] + for t in texts: + seed = sum(ord(c) for c in t) % 2**31 + r = np.random.RandomState(seed) + embs.append(r.randn(8).astype(np.float32)) + return np.array(embs) + + model.encode = _encode + return model + + +# ============================================================================ +# ParameterVector tests +# ============================================================================ + + +class TestParameterVector: + def test_text_for_embedding_basic(self): + pv = _make_param("threshold", "t", "matching threshold", ["--threshold"]) + text = pv.text_for_embedding() + assert "threshold" in text + assert "matching threshold" in text + assert "--threshold" in text + + def test_text_for_embedding_minimal(self): + pv = _make_param("x", "t") + text = pv.text_for_embedding() + assert "x" in text + + +# ============================================================================ +# ParameterCluster tests +# ============================================================================ + + +class TestParameterCluster: + def test_size(self): + members = [_make_param("a", "t1"), _make_param("b", "t2")] + cluster = ParameterCluster(cluster_id=0, label="a", members=members, tools=["t1", "t2"]) + assert cluster.size() == 2 + + def test_is_cross_tool(self): + c1 = ParameterCluster(cluster_id=0, label="x", members=[], tools=["t1", "t2"]) + assert c1.is_cross_tool() + + c2 = ParameterCluster(cluster_id=1, label="x", members=[], tools=["t1"]) + assert not c2.is_cross_tool() + + +# ============================================================================ +# ClusteringResult tests +# ============================================================================ + + +class TestClusteringResult: + def test_cross_tool_clusters(self): + c1 = ParameterCluster(cluster_id=0, label="x", members=[], tools=["t1", "t2"]) + c2 = ParameterCluster(cluster_id=1, label="y", members=[], tools=["t1"]) + result = ClusteringResult(clusters=[c1, c2], n_clusters=2) + assert len(result.cross_tool_clusters()) == 1 + + def test_to_table_rows(self): + members = [_make_param("threshold", "t1"), _make_param("threshold", "t2")] + cluster = ParameterCluster(cluster_id=0, label="threshold", members=members, tools=["t1", "t2"]) + result = ClusteringResult(clusters=[cluster], n_clusters=1, n_parameters=2) + rows = result.to_table_rows() + assert len(rows) == 2 + assert rows[0]["cluster_label"] == "threshold" + assert rows[0]["tool"] == "t1" + assert rows[1]["tool"] == "t2" + + def test_to_table_rows_empty(self): + result = ClusteringResult() + assert result.to_table_rows() == [] + + +# ============================================================================ +# Similarity tests +# ============================================================================ + + +class TestSimilarity: + def test_embed_parameters(self, sample_parameters, mock_sentence_model): + embeddings = embed_parameters( + sample_parameters, model=mock_sentence_model + ) + assert embeddings.shape[0] == len(sample_parameters) + assert embeddings.shape[1] > 0 + # All embeddings should be stored back + for pv in sample_parameters: + assert pv.embedding is not None + assert len(pv.embedding) == embeddings.shape[1] + + def test_embed_parameters_empty(self, mock_sentence_model): + embeddings = embed_parameters([], model=mock_sentence_model) + assert embeddings.shape == (0, 0) + + def test_cosine_similarity_matrix_identity(self): + embs = np.eye(3, dtype=np.float32) + sim = cosine_similarity_matrix(embs) + np.testing.assert_allclose(sim, np.eye(3), atol=1e-5) + + def test_cosine_similarity_matrix_same_vector(self): + embs = np.ones((4, 5), dtype=np.float32) + sim = cosine_similarity_matrix(embs) + np.testing.assert_allclose(sim, np.ones((4, 4)), atol=1e-5) + + def test_cosine_similarity_matrix_empty(self): + embs = np.empty((0, 0)) + sim = cosine_similarity_matrix(embs) + assert sim.shape == (0, 0) + + +# ============================================================================ +# ParameterClusterer tests +# ============================================================================ + + +class TestParameterClusterer: + def test_cluster_basic(self, sample_parameters, mock_sentence_model): + """Clustering should produce at least one cluster.""" + clusterer = ParameterClusterer(distance_threshold=0.8) + clusterer._model = mock_sentence_model + + result = clusterer.cluster(sample_parameters) + assert result.n_parameters == len(sample_parameters) + assert result.n_clusters > 0 + # All parameters should be assigned to some cluster + total_members = sum(c.size() for c in result.clusters) + assert total_members == len(sample_parameters) + + def test_cluster_empty(self): + clusterer = ParameterClusterer() + result = clusterer.cluster([]) + assert result.n_parameters == 0 + assert result.n_clusters == 0 + + def test_cluster_single_param(self, mock_sentence_model): + clusterer = ParameterClusterer() + clusterer._model = mock_sentence_model + params = [_make_param("threshold", "tool_a", "test")] + result = clusterer.cluster(params) + assert result.n_parameters == 1 + assert result.n_clusters == 1 + + def test_load_parameters_from_json(self, tmp_path): + """Test loading parameters from a tool JSON output file.""" + data = { + "tool_name": "test_tool", + "parameters": [ + { + "name": "threshold", + "native_keys": ["--threshold"], + "description": "test", + "type_hint": "float", + "default_value": 0.5, + "required": False, + "_source": "cli", + }, + { + "name": "output", + "native_keys": ["--output"], + "description": "output path", + "_source": "cli", + }, + ], + } + json_file = tmp_path / "test_tool.json" + json_file.write_text(json.dumps(data)) + + params = ParameterClusterer.load_parameters_from_json(json_file) + assert len(params) == 2 + assert params[0].name == "threshold" + assert params[0].tool_name == "test_tool" + assert params[1].name == "output" + + def test_load_from_output_dir(self, tmp_path): + """Test loading from a directory with multiple tool files.""" + for tool_name in ["tool_a", "tool_b"]: + data = { + "tool_name": tool_name, + "parameters": [ + {"name": "param1", "native_keys": [], "_source": "cli"}, + ], + } + (tmp_path / f"{tool_name}.json").write_text(json.dumps(data)) + # Summary file should be skipped + (tmp_path / "_summary.json").write_text("{}") + + clusterer = ParameterClusterer() + params = clusterer.load_from_output_dir(tmp_path) + assert len(params) == 2 + tool_names = {p.tool_name for p in params} + assert tool_names == {"tool_a", "tool_b"} + + def test_save_result(self, tmp_path): + members = [_make_param("threshold", "t1")] + cluster = ParameterCluster(cluster_id=0, label="threshold", members=members, tools=["t1"]) + result = ClusteringResult(clusters=[cluster], n_clusters=1, n_parameters=1) + + out_path = tmp_path / "clusters.json" + ParameterClusterer.save_result(result, out_path) + assert out_path.exists() + + saved = json.loads(out_path.read_text()) + assert saved["n_clusters"] == 1 + assert len(saved["clusters"]) == 1 + + def test_save_table(self, tmp_path): + members = [ + _make_param("threshold", "t1", description="test"), + _make_param("threshold", "t2", description="test"), + ] + cluster = ParameterCluster(cluster_id=0, label="threshold", members=members, tools=["t1", "t2"]) + result = ClusteringResult(clusters=[cluster], n_clusters=1, n_parameters=2) + + csv_path = tmp_path / "table.csv" + ParameterClusterer.save_table(result, csv_path) + assert csv_path.exists() + + import csv + with open(csv_path) as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 2 + assert rows[0]["parameter"] == "threshold" + + def test_cluster_from_output_dir(self, tmp_path, mock_sentence_model): + """Integration test: load → cluster from an output directory.""" + for tool_name, params in [ + ("tool_a", [ + {"name": "threshold", "native_keys": ["--threshold"], "description": "match threshold", "_source": "cli"}, + {"name": "output", "native_keys": ["--output"], "description": "output path", "_source": "cli"}, + ]), + ("tool_b", [ + {"name": "similarity_threshold", "native_keys": ["--sim-threshold"], "description": "threshold for similarity", "_source": "cli"}, + {"name": "output_dir", "native_keys": ["--output-dir"], "description": "directory for output", "_source": "cli"}, + ]), + ]: + data = {"tool_name": tool_name, "parameters": params} + (tmp_path / f"{tool_name}.json").write_text(json.dumps(data)) + + clusterer = ParameterClusterer(distance_threshold=0.8) + clusterer._model = mock_sentence_model + result = clusterer.cluster_from_output_dir(tmp_path) + + assert result.n_parameters == 4 + assert result.n_clusters > 0 + diff --git a/src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml b/src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml new file mode 100644 index 0000000..cc3b967 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Matching API + version: 1.0.0 +paths: + /api/match: + post: + summary: Match entities + parameters: + - name: threshold + in: query + schema: + type: number + default: 0.5 + minimum: 0.0 + maximum: 1.0 + description: Matching threshold + - name: max_results + in: query + schema: + type: integer + default: 100 + description: Maximum number of results + requestBody: + content: + application/json: + schema: + type: object + required: + - input_file + properties: + input_file: + type: string + description: Input file path + output_file: + type: string + description: Output file path + verbose: + type: boolean + default: false + description: Enable verbose logging + diff --git a/src/kgpipe_parameters/tests/test_data/api/swagger_spec.json b/src/kgpipe_parameters/tests/test_data/api/swagger_spec.json new file mode 100644 index 0000000..899f978 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/api/swagger_spec.json @@ -0,0 +1,43 @@ +{ + "swagger": "2.0", + "info": { + "title": "Matching API", + "version": "1.0.0" + }, + "paths": { + "/api/match": { + "post": { + "parameters": [ + { + "name": "threshold", + "in": "query", + "type": "number", + "default": 0.5, + "minimum": 0.0, + "maximum": 1.0, + "description": "Matching threshold" + }, + { + "name": "input_file", + "in": "body", + "schema": { + "type": "object", + "required": ["input_file"], + "properties": { + "input_file": { + "type": "string", + "description": "Input file path" + }, + "output_file": { + "type": "string", + "description": "Output file path" + } + } + } + } + ] + } + } + } +} + diff --git a/src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt b/src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt new file mode 100644 index 0000000..e958f50 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt @@ -0,0 +1,8 @@ +usage: tool.py [-h] [--threshold THRESHOLD] [--output OUTPUT] [--verbose] + +optional arguments: + -h, --help show this help message and exit + --threshold THRESHOLD Matching threshold (default: 0.5) + --output OUTPUT Output file path (required) + --verbose Enable verbose logging + diff --git a/src/kgpipe_parameters/tests/test_data/cli/click_help.txt b/src/kgpipe_parameters/tests/test_data/cli/click_help.txt new file mode 100644 index 0000000..8e9d948 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/cli/click_help.txt @@ -0,0 +1,8 @@ +Usage: tool.py [OPTIONS] + +Options: + --threshold FLOAT Matching threshold [default: 0.5] + --output TEXT Output file path (required) + --verbose Enable verbose logging + --help Show this message and exit. + diff --git a/src/kgpipe_parameters/tests/test_data/cli/simple_help.txt b/src/kgpipe_parameters/tests/test_data/cli/simple_help.txt new file mode 100644 index 0000000..bf078f9 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/cli/simple_help.txt @@ -0,0 +1,8 @@ +Usage: matcher [OPTIONS] + + --threshold VALUE Matching threshold (0.0-1.0) [default: 0.5] + --input FILE Input file path (required) + --output FILE Output file path + --max-results INT Maximum number of results [default: 100] + --verbose Enable verbose output + diff --git a/src/kgpipe_parameters/tests/test_data/docker/Dockerfile b/src/kgpipe_parameters/tests/test_data/docker/Dockerfile new file mode 100644 index 0000000..1a99c0b --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/docker/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9 + +ARG BUILD_VERSION=latest +ARG THRESHOLD=0.5 + +ENV THRESHOLD=${THRESHOLD} +ENV OUTPUT_DIR=/output +ENV MAX_RESULTS=100 +ENV VERBOSE=false + +WORKDIR /app +COPY . . +CMD ["python", "app.py"] + diff --git a/src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml b/src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml new file mode 100644 index 0000000..3ecdc59 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + matcher: + image: matcher:latest + environment: + THRESHOLD: 0.5 + OUTPUT_DIR: /output + MAX_RESULTS: 100 + VERBOSE: "false" + volumes: + - ./data:/data + ports: + - "8080:8080" + + processor: + image: processor:latest + environment: + INPUT_DIR: /input + BATCH_SIZE: 50 + depends_on: + - matcher + diff --git a/src/kgpipe_parameters/tests/test_data/python/dataclass_config.py b/src/kgpipe_parameters/tests/test_data/python/dataclass_config.py new file mode 100644 index 0000000..716c2a1 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/python/dataclass_config.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import Optional + +@dataclass +class MatchingConfig: + """Configuration for matching operations.""" + threshold: float = 0.5 + input_file: str + output_file: Optional[str] = None + verbose: bool = False + max_results: int = 100 + diff --git a/src/kgpipe_parameters/tests/test_data/python/function_with_params.py b/src/kgpipe_parameters/tests/test_data/python/function_with_params.py new file mode 100644 index 0000000..be93fce --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/python/function_with_params.py @@ -0,0 +1,16 @@ +def process_data( + input_file: str, + threshold: float = 0.5, + verbose: bool = False, + max_results: int = 100 +) -> None: + """ + Process data with configurable parameters. + + :param input_file: Path to input file (required) + :param threshold: Matching threshold (default: 0.5, min: 0.0, max: 1.0) + :param verbose: Enable verbose logging + :param max_results: Maximum number of results (default: 100) + """ + pass + diff --git a/src/kgpipe_parameters/tests/test_data/python/pydantic_model.py b/src/kgpipe_parameters/tests/test_data/python/pydantic_model.py new file mode 100644 index 0000000..75a27bc --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/python/pydantic_model.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field +from typing import Optional + +class MatchingConfig(BaseModel): + """Configuration for matching operations.""" + threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Matching threshold") + input_file: str = Field(..., description="Input file path (required)") + output_file: Optional[str] = Field(default=None, description="Output file path") + verbose: bool = Field(default=False, description="Enable verbose logging") + max_results: int = Field(default=100, ge=1, description="Maximum number of results") + diff --git a/src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md b/src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md new file mode 100644 index 0000000..8f37aa4 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md @@ -0,0 +1,12 @@ +# SimpleTool + +A minimal tool. + +## Usage + +``` +simpletool +``` + +Set `workers` to control parallelism. + diff --git a/src/kgpipe_parameters/tests/test_data/readme/tool_readme.md b/src/kgpipe_parameters/tests/test_data/readme/tool_readme.md new file mode 100644 index 0000000..ef86be5 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/readme/tool_readme.md @@ -0,0 +1,58 @@ +# EntityMatcher + +A tool for matching entities across knowledge graphs. + +## Installation + +```bash +pip install entity-matcher +``` + +## Usage + +```bash +entity-matcher --input data.nt --output results.tsv --threshold 0.8 --max-iter 10 +entity-matcher --format csv --verbose +``` + +## Configuration + +The following parameters can be set: + +- `threshold`: The matching threshold, a float between 0 and 1 (default: 0.5) +- `max_iter`: Maximum number of iterations (default: 10) +- `input`: Path to input knowledge base (required) +- `output`: Path to output results file (required) +- `format`: Output format, one of csv, tsv, json (default: tsv) +- `similarity_metric`: Similarity metric to use, e.g. jaccard, cosine (default: jaccard) + +## Advanced Configuration + +| Parameter | Type | Description | +|-----------|------|-------------| +| `batch_size` | int | Number of entities per batch | +| `num_threads` | int | Number of parallel threads | +| `cache_dir` | path | Directory for caching intermediate results | +| `log_level` | string | Logging level: DEBUG, INFO, WARNING, ERROR | + +## Environment Variables + +You can also configure via environment: + +```bash +export MATCHER_THRESHOLD=0.8 +export MATCHER_MAX_MEMORY=4096 +``` + +## Running with Java Backend + +For the Java backend, you may need to increase JVM memory: + +```bash +java -Xmx8192m -Xms2048m -jar entity-matcher.jar +``` + +## API + +See the [API documentation](docs/api.md) for details. + diff --git a/src/kgpipe_parameters/tests/test_paramters_extraction.py b/src/kgpipe_parameters/tests/test_paramters_extraction.py new file mode 100644 index 0000000..e98966b --- /dev/null +++ b/src/kgpipe_parameters/tests/test_paramters_extraction.py @@ -0,0 +1,599 @@ +""" +Comprehensive tests for parameter extraction module. +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch + +from kgpipe_parameters.extraction import ( + ParameterMiner, + CLIExtractor, + PythonLibExtractor, + HTTPAPIExtractor, + DockerExtractor, + ReadmeDocExtractor, + RawParameter, + ExtractionResult, + SourceType, + ExtractionMethod, +) +from kgpipe_parameters.extraction.utils import ( + normalize_parameter_name, + parse_default_value, + infer_parameter_type, + extract_constraints, + to_parameter_model, +) +from kgpipe.common.model.configuration import Parameter, ParameterType + + +# ============================================================================= +# Utility Function Tests +# ============================================================================= + +class TestUtils: + """Tests for utility functions.""" + + def test_normalize_parameter_name(self): + """Test parameter name normalization.""" + assert normalize_parameter_name("--threshold") == "threshold" + assert normalize_parameter_name("-t") == "t" + assert normalize_parameter_name("threshold") == "threshold" + assert normalize_parameter_name("THRESHOLD") == "threshold" + assert normalize_parameter_name("max-results") == "max_results" + assert normalize_parameter_name("camelCase") == "camelcase" + + def test_parse_default_value(self): + """Test parsing of default values.""" + assert parse_default_value("0.5") == 0.5 + assert parse_default_value("100") == 100 + assert parse_default_value("true") is True + assert parse_default_value("false") is False + assert parse_default_value("yes") is True + assert parse_default_value("no") is False + assert parse_default_value("hello") == "hello" + assert parse_default_value('"hello"') == "hello" + assert parse_default_value("'world'") == "world" + assert parse_default_value(None) is None + + def test_infer_parameter_type(self): + """Test type inference from type hints and default values.""" + # From type hints + assert infer_parameter_type("int") == ParameterType.integer + assert infer_parameter_type("float") == ParameterType.number + assert infer_parameter_type("str") == ParameterType.string + assert infer_parameter_type("bool") == ParameterType.boolean + assert infer_parameter_type("List[str]") == ParameterType.array + assert infer_parameter_type("Dict[str, Any]") == ParameterType.object + assert infer_parameter_type("enum") == ParameterType.enum + + # From default values + assert infer_parameter_type(None, 42) == ParameterType.integer + assert infer_parameter_type(None, 3.14) == ParameterType.number + assert infer_parameter_type(None, "text") == ParameterType.string + assert infer_parameter_type(None, True) == ParameterType.boolean + assert infer_parameter_type(None, []) == ParameterType.array + assert infer_parameter_type(None, {}) == ParameterType.object + + # Default to string + assert infer_parameter_type(None, None) == ParameterType.string + + def test_extract_constraints(self): + """Test constraint extraction from descriptions.""" + desc1 = "Threshold value (min: 0.0, max: 1.0)" + constraints1 = extract_constraints(desc1) + assert constraints1["minimum"] == 0.0 + assert constraints1["maximum"] == 1.0 + + desc2 = "Choices: [option1, option2, option3]" + constraints2 = extract_constraints(desc2) + assert "allowed_values" in constraints2 + assert len(constraints2["allowed_values"]) == 3 + + desc3 = "Minimum value: 10" + constraints3 = extract_constraints(desc3) + assert constraints3["minimum"] == 10.0 + + desc4 = "Maximum value: 100" + constraints4 = extract_constraints(desc4) + assert constraints4["maximum"] == 100.0 + + def test_to_parameter_model(self): + """Test conversion from RawParameter to Parameter model.""" + raw_param = RawParameter( + name="threshold", + native_keys=["--threshold", "-t"], + description="Matching threshold (min: 0.0, max: 1.0)", + type_hint="float", + default_value=0.5, + required=False, + source="test", + ) + + param = to_parameter_model(raw_param) + + assert isinstance(param, Parameter) + assert param.name == "threshold" + assert "--threshold" in param.native_keys + assert param.datatype == ParameterType.number + assert param.default_value == 0.5 + assert param.required is False + assert param.minimum == 0.0 + assert param.maximum == 1.0 + + +# ============================================================================= +# CLI Extractor Tests +# ============================================================================= + +class TestCLIExtractor: + """Tests for CLI parameter extraction.""" + + def test_cli_extractor_basic(self, cli_help_simple): + """Test basic CLI parameter extraction.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_simple, "matcher") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.CLI + assert result.extraction_method == ExtractionMethod.REGEX + assert len(result.parameters) > 0 + + # Check that threshold parameter was extracted + threshold_params = [p for p in result.parameters if "threshold" in p.name] + assert len(threshold_params) > 0 + + def test_cli_extractor_with_defaults(self, cli_help_argparse): + """Test extraction of parameters with default values.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_argparse, "tool") + + # Find threshold parameter with default + threshold_params = [p for p in result.parameters if "threshold" in p.name] + if threshold_params: + param = threshold_params[0] + assert param.default_value == 0.5 or param.default_value == "0.5" + + def test_cli_extractor_required_flags(self, cli_help_argparse): + """Test detection of required vs optional parameters.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_argparse, "tool") + + # Check for required parameters + output_params = [p for p in result.parameters if "output" in p.name] + if output_params: + # Output is marked as required in the test data + param = output_params[0] + # The extractor should detect "required" in description + + def test_cli_extractor_multiple_flags(self, cli_help_click): + """Test extraction of both long and short flags.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_click, "tool") + + # Check that parameters have native_keys + for param in result.parameters: + assert len(param.native_keys) > 0 + + def test_cli_extractor_description(self, cli_help_simple): + """Test extraction of parameter descriptions.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_simple, "matcher") + + # Check that descriptions are extracted + params_with_desc = [p for p in result.parameters if p.description] + assert len(params_with_desc) > 0 + + +# ============================================================================= +# Python Extractor Tests +# ============================================================================= + +class TestPythonExtractor: + """Tests for Python parameter extraction.""" + + def test_python_extractor_function_params(self, python_function_code): + """Test extraction from function signatures.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.PYTHON_LIB + assert len(result.parameters) > 0 + + # Check for expected parameters + param_names = [p.name for p in result.parameters] + assert "input_file" in param_names or "inputfile" in param_names + assert "threshold" in param_names + + def test_python_extractor_type_hints(self, python_function_code): + """Test extraction of type hints.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + # Check that type hints are extracted + params_with_types = [p for p in result.parameters if p.type_hint] + assert len(params_with_types) > 0 + + def test_python_extractor_docstrings(self, python_function_code): + """Test extraction from docstrings.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + # Check that descriptions from docstrings are extracted + params_with_desc = [p for p in result.parameters if p.description] + assert len(params_with_desc) > 0 + + def test_python_extractor_dataclass(self, python_dataclass_code): + """Test extraction from dataclass attributes.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_dataclass_code, "MatchingConfig") + + assert len(result.parameters) > 0 + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_python_extractor_pydantic_model(self, python_pydantic_code): + """Test extraction from Pydantic models.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_pydantic_code, "MatchingConfig") + + assert len(result.parameters) > 0 + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_python_extractor_ast_parsing(self, python_function_code): + """Test AST-based extraction.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + # AST parsing should work for valid Python code + assert result.extraction_method == ExtractionMethod.REGEX + assert len(result.parameters) > 0 + + +# ============================================================================= +# HTTP API Extractor Tests +# ============================================================================= + +class TestHTTPAPIExtractor: + """Tests for HTTP API parameter extraction.""" + + def test_api_extractor_openapi_spec(self, openapi_spec): + """Test extraction from OpenAPI YAML.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.HTTP_API + assert len(result.parameters) > 0 + + # Check for expected parameters + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_api_extractor_swagger_spec(self, swagger_spec): + """Test extraction from Swagger JSON.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(swagger_spec, "matching_api") + + assert len(result.parameters) > 0 + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_api_extractor_path_params(self, openapi_spec): + """Test path parameter extraction.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + # OpenAPI spec has query params, not path params in our test data + # But we should still extract parameters + assert len(result.parameters) > 0 + + def test_api_extractor_query_params(self, openapi_spec): + """Test query parameter extraction.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + # Check for query parameters + query_params = [p for p in result.parameters if "threshold" in p.name or "max_results" in p.name] + assert len(query_params) > 0 + + def test_api_extractor_request_body(self, openapi_spec): + """Test request body parameter extraction.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + # Check for request body parameters + body_params = [p for p in result.parameters if "input_file" in p.name or "output_file" in p.name] + assert len(body_params) > 0 + + +# ============================================================================= +# Docker Extractor Tests +# ============================================================================= + +class TestDockerExtractor: + """Tests for Docker parameter extraction.""" + + def test_docker_extractor_env_vars(self, dockerfile_content): + """Test ENV variable extraction from Dockerfile.""" + extractor = DockerExtractor() + result = extractor.extract(dockerfile_content, "dockerfile") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.DOCKER + assert len(result.parameters) > 0 + + # Check for ENV variables + env_params = [p for p in result.parameters if "THRESHOLD" in p.native_keys or "threshold" in p.name] + assert len(env_params) > 0 + + def test_docker_extractor_args(self, dockerfile_content): + """Test ARG extraction from Dockerfile.""" + extractor = DockerExtractor() + result = extractor.extract(dockerfile_content, "dockerfile") + + # Check for ARG declarations + arg_params = [p for p in result.parameters if "BUILD_VERSION" in p.native_keys or "build_version" in p.name] + assert len(arg_params) > 0 + + def test_docker_extractor_compose_env(self, docker_compose_content): + """Test environment variable extraction from docker-compose.yml.""" + extractor = DockerExtractor() + result = extractor.extract(docker_compose_content, "docker_compose") + + assert len(result.parameters) > 0 + + # Check for environment variables + env_params = [p for p in result.parameters if "THRESHOLD" in p.native_keys or "threshold" in p.name] + assert len(env_params) > 0 + + def test_docker_extractor_multiple_services(self, docker_compose_content): + """Test extraction from multiple services.""" + extractor = DockerExtractor() + result = extractor.extract(docker_compose_content, "docker_compose") + + # Should extract from both matcher and processor services + assert len(result.parameters) > 0 + + +# ============================================================================= +# README / Documentation Extractor Tests +# ============================================================================= + +class TestReadmeDocExtractor: + """Tests for README / documentation parameter extraction.""" + + def test_readme_extractor_list_params(self, readme_tool_doc): + """Test extraction of parameters from markdown list items.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.README + assert result.extraction_method == ExtractionMethod.REGEX + assert len(result.parameters) > 0 + + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names + assert "max_iter" in param_names + + def test_readme_extractor_table_params(self, readme_tool_doc): + """Test extraction of parameters from markdown tables.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + param_names = [p.name for p in result.parameters] + assert "batch_size" in param_names + assert "num_threads" in param_names + + def test_readme_extractor_env_vars(self, readme_tool_doc): + """Test extraction of environment variable references.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + param_names = [p.name for p in result.parameters] + assert "matcher_threshold" in param_names or "matcher_max_memory" in param_names + + def test_readme_extractor_placeholders(self, readme_tool_doc): + """Test extraction of placeholder parameters from usage lines.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + param_names = [p.name for p in result.parameters] + # , , from the Java usage line + assert "kb1" in param_names or "outputfolder" in param_names + + def test_readme_extractor_defaults(self, readme_tool_doc): + """Test that default values are extracted from descriptions.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + threshold_params = [p for p in result.parameters if p.name == "threshold"] + if threshold_params: + param = threshold_params[0] + assert param.default_value == 0.5 or param.default_value == "0.5" + + def test_readme_extractor_descriptions(self, readme_tool_doc): + """Test that descriptions are extracted.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + params_with_desc = [p for p in result.parameters if p.description] + assert len(params_with_desc) > 0 + + def test_readme_extractor_minimal(self, readme_minimal): + """Test extraction from a minimal README.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_minimal, "simple_tool") + + assert isinstance(result, ExtractionResult) + param_names = [p.name for p in result.parameters] + # Should find at least the and placeholders + assert "inputfile" in param_names or "outputfile" in param_names + + def test_readme_extractor_empty(self): + """Test handling of empty README.""" + extractor = ReadmeDocExtractor() + result = extractor.extract("", "test") + + assert isinstance(result, ExtractionResult) + assert len(result.parameters) == 0 + + +# ============================================================================= +# ParameterMiner Integration Tests +# ============================================================================= + +class TestParameterMiner: + """Integration tests for ParameterMiner.""" + + def test_parameter_miner_auto_detect_cli(self, cli_help_simple): + """Test auto-detection of CLI source.""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.CLI + + def test_parameter_miner_auto_detect_python(self, python_function_code): + """Test auto-detection of Python source.""" + miner = ParameterMiner() + result = miner.extract_parameters(python_function_code, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.PYTHON_LIB + + def test_parameter_miner_auto_detect_api(self, openapi_spec): + """Test auto-detection of API source.""" + miner = ParameterMiner() + result = miner.extract_parameters(openapi_spec, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.HTTP_API + + def test_parameter_miner_auto_detect_docker(self, dockerfile_content): + """Test auto-detection of Docker source.""" + miner = ParameterMiner() + result = miner.extract_parameters(dockerfile_content, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.DOCKER + + def test_parameter_miner_auto_detect_readme(self, readme_tool_doc): + """Test auto-detection of README source.""" + miner = ParameterMiner() + result = miner.extract_parameters(readme_tool_doc, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.README + + def test_parameter_miner_file_path(self, test_data_dir): + """Test extraction from file path.""" + miner = ParameterMiner() + cli_file = test_data_dir / "cli" / "simple_help.txt" + result = miner.extract_parameters(str(cli_file), method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.CLI + assert result.tool_name == "simple_help" + + def test_parameter_miner_method_auto(self, cli_help_simple): + """Test auto method selection (regex → LLM fallback).""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + # Should use regex by default + assert result.extraction_method == ExtractionMethod.REGEX + + def test_parameter_miner_to_json(self, cli_help_simple): + """Test JSON output conversion.""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + json_output = miner.to_json(result) + assert isinstance(json_output, str) + assert "parameters" in json_output or '"parameters"' in json_output + + def test_parameter_miner_to_parameter_model(self, cli_help_simple): + """Test Parameter model conversion.""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + if result.parameters: + param_model = miner.to_parameter_model(result.parameters[0]) + assert isinstance(param_model, Parameter) + assert param_model.name is not None + assert param_model.datatype is not None + + +# ============================================================================= +# Error Handling Tests +# ============================================================================= + +class TestErrorHandling: + """Tests for error handling.""" + + def test_extractor_invalid_source(self): + """Test handling of invalid source content.""" + extractor = CLIExtractor() + result = extractor.extract("This is not valid CLI help", "test") + + # Should not crash, but may return empty or minimal results + assert isinstance(result, ExtractionResult) + + def test_extractor_empty_source(self): + """Test handling of empty source.""" + extractor = CLIExtractor() + result = extractor.extract("", "test") + + assert isinstance(result, ExtractionResult) + assert len(result.parameters) == 0 + + def test_extractor_malformed_spec(self): + """Test handling of malformed specifications.""" + extractor = HTTPAPIExtractor() + result = extractor.extract("{ invalid json }", "test") + + assert isinstance(result, ExtractionResult) + # Should handle gracefully, may have errors + assert len(result.errors) >= 0 + + def test_parameter_miner_unknown_source_type(self): + """Test handling of unknown source types.""" + miner = ParameterMiner() + result = miner.extract_parameters("Random text that doesn't match any pattern", method=ExtractionMethod.AUTO) + + assert isinstance(result, ExtractionResult) + # Should default to UNKNOWN or handle gracefully + assert result.source_type in [SourceType.UNKNOWN, SourceType.CLI, SourceType.PYTHON_LIB] + + +# ============================================================================= +# LLM Extractor Tests (Optional - Mock LLM) +# ============================================================================= + +class TestLLMExtractor: + """Tests for LLM-based extraction (with mocked LLM client).""" + + def test_llm_extractor_cli(self, cli_help_simple, mock_llm_client): + """Test LLM-based CLI extraction.""" + from kgpipe_parameters.extraction.param_miner import LLMCLIExtractor + + extractor = LLMCLIExtractor(mock_llm_client) + result = extractor.extract(cli_help_simple, "test_tool") + + assert isinstance(result, ExtractionResult) + assert result.extraction_method == ExtractionMethod.LLM + # Mock should return parameters + assert len(result.parameters) > 0 + + def test_llm_extractor_fallback(self, cli_help_simple, mock_llm_client): + """Test fallback from regex to LLM when regex fails.""" + miner = ParameterMiner(llm_client=mock_llm_client) + + # Use a source that regex might struggle with + result = miner.extract_parameters( + cli_help_simple, + method=ExtractionMethod.AUTO + ) + + # Should try regex first, but if it fails and LLM is available, use LLM + assert isinstance(result, ExtractionResult) + diff --git a/src/kgpipe_parameters/tests/test_visualization.py b/src/kgpipe_parameters/tests/test_visualization.py new file mode 100644 index 0000000..0b19589 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_visualization.py @@ -0,0 +1,176 @@ +""" +Tests for the parameter visualization module. +""" + +import json +import pytest +import numpy as np +from pathlib import Path +from typing import List + +from kgpipe_parameters.clustering.models import ( + ParameterVector, + ParameterCluster, + ClusteringResult, +) +from kgpipe_parameters.visualization import ParameterVisualizer + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _make_param( + name: str, + tool: str, + description: str = "", + embedding: List[float] | None = None, +) -> ParameterVector: + return ParameterVector( + name=name, + tool_name=tool, + description=description, + native_keys=[f"--{name}"], + source_label=f"{tool}/source", + embedding=embedding, + ) + + +def _random_embedding( + dim: int = 16, rng: np.random.Generator | None = None +) -> List[float]: + rng = rng or np.random.default_rng(42) + vec = rng.standard_normal(dim).astype(np.float32) + vec /= np.linalg.norm(vec) + return vec.tolist() + + +@pytest.fixture +def sample_clustering_result() -> ClusteringResult: + """A small synthetic clustering result for visualization tests.""" + rng = np.random.default_rng(0) + + # Cluster 0: cross-tool (threshold, 3 params, 2 tools) + c0_members = [ + _make_param( + "threshold", "tool_a", "Matching threshold", _random_embedding(rng=rng) + ), + _make_param( + "threshold", "tool_b", "Score threshold", _random_embedding(rng=rng) + ), + _make_param( + "similarity_threshold", + "tool_a", + "Similarity cutoff", + _random_embedding(rng=rng), + ), + ] + c0 = ParameterCluster( + cluster_id=0, + label="threshold", + members=c0_members, + tools=["tool_a", "tool_b"], + ) + + # Cluster 1: cross-tool (output, 2 params, 2 tools) + c1_members = [ + _make_param( + "output_dir", "tool_a", "Output directory", _random_embedding(rng=rng) + ), + _make_param( + "output_path", "tool_b", "Output file path", _random_embedding(rng=rng) + ), + ] + c1 = ParameterCluster( + cluster_id=1, + label="output_dir", + members=c1_members, + tools=["tool_a", "tool_b"], + ) + + # Cluster 2: single-tool (verbose, 2 params) + c2_members = [ + _make_param( + "verbose", "tool_a", "Verbosity level", _random_embedding(rng=rng) + ), + _make_param("debug", "tool_a", "Debug mode", _random_embedding(rng=rng)), + ] + c2 = ParameterCluster( + cluster_id=2, + label="verbose", + members=c2_members, + tools=["tool_a"], + ) + + return ClusteringResult( + n_parameters=7, + n_clusters=3, + distance_threshold=0.55, + model_name="test-model", + clusters=[c0, c1, c2], + ) + + +# ============================================================================ +# Tests +# ============================================================================ + + +class TestParameterVisualizer: + """Tests for ParameterVisualizer.""" + + def test_generate_all_creates_files(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + paths = viz.generate_all() + assert len(paths) == 3 + for p in paths: + assert p.exists() + assert p.suffix == ".png" + + def test_plot_cluster_sizes(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + path = viz.plot_cluster_sizes() + assert path.exists() + assert path.name == "_viz_cluster_sizes.png" + + def test_plot_tool_cluster_heatmap(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + path = viz.plot_tool_cluster_heatmap() + assert path.exists() + assert path.name == "_viz_tool_heatmap.png" + + def test_plot_embedding_scatter(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + path = viz.plot_embedding_scatter() + assert path.exists() + assert path.name == "_viz_embedding_scatter.png" + + def test_empty_result_returns_empty(self, tmp_path): + empty = ClusteringResult() + viz = ParameterVisualizer(empty, tmp_path) + paths = viz.generate_all() + assert paths == [] + + def test_from_clusters_json(self, sample_clustering_result, tmp_path): + # Write a JSON file + json_path = tmp_path / "_clusters.json" + data = sample_clustering_result.model_dump() + # Strip centroids/embeddings like the real save does + for c in data.get("clusters", []): + c.pop("centroid", None) + with open(json_path, "w") as f: + json.dump(data, f, default=str) + + viz = ParameterVisualizer.from_clusters_json(json_path) + assert viz.result.n_clusters == 3 + + def test_scatter_too_few_points(self, tmp_path): + """Scatter plot gracefully handles < 3 embedded parameters.""" + m = _make_param("x", "t", embedding=_random_embedding()) + c = ParameterCluster(cluster_id=0, label="x", members=[m], tools=["t"]) + result = ClusteringResult(n_parameters=1, n_clusters=1, clusters=[c]) + viz = ParameterVisualizer(result, tmp_path) + path = viz.plot_embedding_scatter() + assert path.exists() + diff --git a/src/kgpipe_parameters/visualization/__init__.py b/src/kgpipe_parameters/visualization/__init__.py new file mode 100644 index 0000000..0b7baa9 --- /dev/null +++ b/src/kgpipe_parameters/visualization/__init__.py @@ -0,0 +1,6 @@ +"""Visualization module for parameter clustering results.""" + +from .kgpipe_parameter_explorer import ParameterVisualizer + +__all__ = ["ParameterVisualizer"] + diff --git a/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py b/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py new file mode 100644 index 0000000..f03e086 --- /dev/null +++ b/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py @@ -0,0 +1,257 @@ +""" +Visualization of parameter clustering results. + +Produces static plots (PNG) summarising how extracted parameters group +across tools: + - cluster size distribution + - tool × cluster heatmap (cross-tool clusters) + - 2-D embedding scatter (PCA, coloured by tool) +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import numpy as np +import matplotlib + +matplotlib.use("Agg") # non-interactive backend +import matplotlib.pyplot as plt +import seaborn as sns + +from ..clustering.models import ClusteringResult + +logger = logging.getLogger(__name__) + +# Consistent style +sns.set_theme(style="whitegrid", font_scale=0.9) + + +class ParameterVisualizer: + """Generate static visualizations from a ``ClusteringResult``.""" + + def __init__(self, result: ClusteringResult, output_dir: Path): + self.result = result + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def generate_all(self) -> list[Path]: + """Run every visualization and return list of saved file paths.""" + paths: list[Path] = [] + if not self.result.clusters: + logger.warning("No clusters to visualize") + return paths + + paths.append(self.plot_cluster_sizes()) + paths.append(self.plot_tool_cluster_heatmap()) + paths.append(self.plot_embedding_scatter()) + logger.info( + "Generated %d visualization(s) in %s", len(paths), self.output_dir + ) + return paths + + # ------------------------------------------------------------------ + # Individual plots + # ------------------------------------------------------------------ + + def plot_cluster_sizes( + self, filename: str = "_viz_cluster_sizes.png" + ) -> Path: + """Horizontal bar chart of cluster sizes (top-30).""" + clusters = sorted(self.result.clusters, key=lambda c: -c.size())[:30] + labels = [ + f"[{c.cluster_id}] {c.label}" + (" ★" if c.is_cross_tool() else "") + for c in clusters + ] + sizes = [c.size() for c in clusters] + colors = [ + "#4c72b0" if c.is_cross_tool() else "#c0c0c0" for c in clusters + ] + + fig, ax = plt.subplots(figsize=(8, max(4, len(labels) * 0.35))) + ax.barh(range(len(labels)), sizes, color=colors) + ax.set_yticks(range(len(labels))) + ax.set_yticklabels(labels) + ax.invert_yaxis() + ax.set_xlabel("Number of parameters") + ax.set_title( + f"Cluster sizes (top {len(clusters)} of {self.result.n_clusters})" + ) + # Legend for cross-tool marker + from matplotlib.patches import Patch + + ax.legend( + handles=[ + Patch(facecolor="#4c72b0", label="Cross-tool"), + Patch(facecolor="#c0c0c0", label="Single tool"), + ], + loc="lower right", + ) + fig.tight_layout() + path = self.output_dir / filename + fig.savefig(path, dpi=150) + plt.close(fig) + logger.info("Saved cluster size chart to %s", path) + return path + + def plot_tool_cluster_heatmap( + self, filename: str = "_viz_tool_heatmap.png" + ) -> Path: + """Heatmap of tools × clusters (cross-tool clusters only).""" + import pandas as pd + + cross = self.result.cross_tool_clusters() + if not cross: + # Fall back to top-20 clusters if no cross-tool clusters + cross = sorted(self.result.clusters, key=lambda c: -c.size())[:20] + + all_tools = sorted( + {m.tool_name for c in cross for m in c.members} + ) + cluster_labels = [f"[{c.cluster_id}] {c.label}" for c in cross] + + matrix = np.zeros((len(all_tools), len(cross)), dtype=int) + for j, c in enumerate(cross): + for m in c.members: + i = all_tools.index(m.tool_name) + matrix[i, j] += 1 + + df = pd.DataFrame(matrix, index=all_tools, columns=cluster_labels) + + fig, ax = plt.subplots( + figsize=(max(6, len(cross) * 0.6), max(3, len(all_tools) * 0.5)) + ) + sns.heatmap( + df, + annot=True, + fmt="d", + cmap="YlOrRd", + linewidths=0.5, + ax=ax, + ) + ax.set_title("Parameters per tool × cluster (cross-tool clusters)") + ax.set_ylabel("Tool") + ax.set_xlabel("Cluster") + plt.xticks(rotation=45, ha="right") + fig.tight_layout() + path = self.output_dir / filename + fig.savefig(path, dpi=150) + plt.close(fig) + logger.info("Saved tool×cluster heatmap to %s", path) + return path + + def plot_embedding_scatter( + self, filename: str = "_viz_embedding_scatter.png" + ) -> Path: + """2-D PCA scatter of parameter embeddings, coloured by tool.""" + from sklearn.decomposition import PCA + + # Collect all members across clusters + all_members = [m for c in self.result.clusters for m in c.members] + cluster_for_member = [ + c.cluster_id for c in self.result.clusters for m in c.members + ] + + # Check if embeddings are present; if not, recompute them + has_embeddings = any(m.embedding is not None for m in all_members) + if not has_embeddings and all_members: + logger.info("Embeddings not in clustering result — recomputing") + from ..clustering.similarity import embed_parameters + + embed_parameters(all_members) + + # Collect embeddings and metadata + embeddings = [] + tools = [] + names = [] + cluster_ids = [] + for cid, m in zip(cluster_for_member, all_members): + if m.embedding is not None: + embeddings.append(m.embedding) + tools.append(m.tool_name) + names.append(m.name) + cluster_ids.append(cid) + + if len(embeddings) < 3: + # Not enough points for meaningful 2-D projection + logger.warning( + "Too few embedded parameters (%d) for scatter plot", + len(embeddings), + ) + fig, ax = plt.subplots() + ax.text( + 0.5, + 0.5, + "Too few parameters for scatter plot", + ha="center", + va="center", + transform=ax.transAxes, + ) + path = self.output_dir / filename + fig.savefig(path, dpi=150) + plt.close(fig) + return path + + X = np.array(embeddings, dtype=np.float32) + pca = PCA(n_components=2, random_state=42) + X_2d = pca.fit_transform(X) + + unique_tools = sorted(set(tools)) + palette = sns.color_palette("husl", len(unique_tools)) + tool_to_color = dict(zip(unique_tools, palette)) + + fig, ax = plt.subplots(figsize=(10, 7)) + for tool in unique_tools: + mask = [t == tool for t in tools] + pts = X_2d[mask] + ax.scatter( + pts[:, 0], + pts[:, 1], + label=tool, + color=tool_to_color[tool], + alpha=0.65, + s=30, + edgecolors="white", + linewidth=0.3, + ) + + ax.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%} var)") + ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%} var)") + ax.set_title( + f"Parameter embeddings — {self.result.n_parameters} params, " + f"{self.result.n_clusters} clusters" + ) + ax.legend(title="Tool", bbox_to_anchor=(1.02, 1), loc="upper left") + fig.tight_layout() + path = self.output_dir / filename + fig.savefig(path, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved embedding scatter to %s", path) + return path + + # ------------------------------------------------------------------ + # Alternate constructor from JSON file + # ------------------------------------------------------------------ + + @classmethod + def from_clusters_json( + cls, json_path: Path, output_dir: Optional[Path] = None + ) -> "ParameterVisualizer": + """ + Create a visualizer from a ``_clusters.json`` file. + + If *output_dir* is not given, plots are saved next to the JSON file. + """ + import json + + with open(json_path) as f: + data = json.load(f) + + result = ClusteringResult.model_validate(data) + return cls(result, output_dir or json_path.parent)