Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive geoprocessing workflow system for QGIS integration, introducing algorithm discovery, selection, parameter extraction, and execution capabilities. The changes add substantial functionality for automated geospatial processing tasks.
Key Changes:
- Added new processing agent graph that orchestrates multi-step geoprocessing workflows (routing → algorithm discovery → selection → parameter gathering → execution)
- Implemented three new tools for algorithm introspection:
list_processing_algorithms,get_algorithm_parameters, andfind_processing_algorithm - Enhanced
execute_processingto automatically load results into QGIS and handle both vector and raster outputs
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 26 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/geoprocessing.py | Added 4 new tools for algorithm discovery and execution; enhanced execute_processing with automatic layer loading |
| tools/init.py | Imported new processing tools but commented them out in TOOLS dict (needs clarification) |
| prompts/system.py | Added 3 new prompt templates for processing routing, algorithm selection, and parameter gathering |
| agents/states.py | Defined 5 new state classes for processing workflow (QGISLayerState, layerListState, RouteState, etc.) |
| agents/processing.py | New 794-line file implementing complete processing graph with 7 nodes and comprehensive logging |
| agents/graph.py | Integrated processing graph into unified graph builder; minor formatting fixes |
| agents/init.py | Updated exports to include ProcessingState and processing-related functions |
Comments suppressed due to low confidence (4)
agents/graph.py:87
- Inconsistent whitespace handling. Line 87 removes trailing whitespace, but other similar lines (e.g., 72, 80) don't have this cleanup. This inconsistency suggests the whitespace fix on line 87 is incidental to other changes and may not have been intentional.
agents/graph.py:17 - The function signature uses
anyas the return type hint. This should be more specific or useAnyfrom typing (which is already imported). Using the lowercaseanyis not a valid type hint.
agents/graph.py:62 - The function signature uses
anyas the return type hint. This should be more specific or useAnyfrom typing. Using the lowercaseanyis not a valid type hint.
agents/graph.py:10 - Import of 'HumanMessage' is not used.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| lyr = iface.addVectorLayer( | ||
| output_layer_obj, f"Result - {algorithm.split(':')[-1]}", "ogr" | ||
| ) | ||
| if lyr and lyr.isValid(): | ||
| layer_added = True | ||
| else: | ||
| # Try raster | ||
| lyr = iface.addRasterLayer( | ||
| output_layer_obj, f"Result - {algorithm.split(':')[-1]}" | ||
| ) | ||
| if lyr and lyr.isValid(): | ||
| layer_added = True |
There was a problem hiding this comment.
The function attempts to add vector layers first, then falls back to raster layers. However, invalid layers may still be added to the project. Consider checking layer validity before adding to the project, and potentially removing invalid layers that were added. Also, the function doesn't handle other layer types (e.g., mesh layers) that QGIS processing might produce.
| try: | ||
| layers_result = list_qgis_layers.invoke({}) | ||
| # Extract layer names from the result | ||
| layer_names = re.findall(r"^(\d+)\.\s+(\w+)", layers_result, re.MULTILINE) |
There was a problem hiding this comment.
The regex pattern r"^(\d+)\.\s+(\w+)" is too restrictive. It only matches layer names containing word characters (letters, digits, underscore). Layer names in QGIS can contain spaces, hyphens, and other characters. This will fail to extract layer names like "My Layer-2023" or "data file.shp". Consider using a more permissive pattern like r"^(\d+)\.\s+(.+)$" to capture the full layer name.
| layer_names = re.findall(r"^(\d+)\.\s+(\w+)", layers_result, re.MULTILINE) | |
| layer_names = re.findall(r"^(\d+)\.\s+(.+)$", layers_result, re.MULTILINE) |
| try: | ||
| # Use find_processing_algorithm to get relevant candidates | ||
| # LLM will select the best match in the next node | ||
| result = find_processing_algorithm.invoke({"query": query, "limit": 500}) |
There was a problem hiding this comment.
The discover_algorithms_node function uses a limit of 500 algorithms (line 312), but this could be excessive and impact performance. The default limit for find_processing_algorithm is 20, which suggests 500 may be unnecessarily high. Consider using a more reasonable limit or making it configurable based on the use case.
| error_msg = f"Missing required parameters: {', '.join(missing_required)}. LLM extracted: {parameters}" | ||
| _logger.warning(error_msg) |
There was a problem hiding this comment.
The error message includes implementation details ("LLM extracted: {parameters}") that may expose internal state and confuse users. Consider simplifying this to focus on what the user needs to provide, or move the extracted parameters detail to debug logging only.
| error_msg = f"Missing required parameters: {', '.join(missing_required)}. LLM extracted: {parameters}" | |
| _logger.warning(error_msg) | |
| error_msg = f"Missing required parameters: {', '.join(missing_required)}." | |
| _logger.warning(error_msg) | |
| _logger.debug( | |
| "LLM-extracted parameters when checking for missing required values: %s", | |
| parameters, | |
| ) |
| return inferred | ||
|
|
||
|
|
||
| def build_processing_graph(llm) -> any: |
There was a problem hiding this comment.
The function signature uses any as the return type hint. This should be more specific. Consider using the actual type returned by graph.compile(), such as CompiledGraph from langgraph, or at minimum use Any from typing instead of the lowercase any.
| # candidates_text = "\n".join( | ||
| # [f" - {c['id']}: {c['name']}" for c in candidates[:500]] # Limit to 500 | ||
| # ) |
There was a problem hiding this comment.
The comment formatting is inconsistent. Line 362 has a regular Python comment while line 363 has a similar comment. Line 363's comment should use consistent formatting with the surrounding code, or both commented lines should be removed if not needed.
| # candidates_text = "\n".join( | |
| # [f" - {c['id']}: {c['name']}" for c in candidates[:500]] # Limit to 500 | |
| # ) |
| _logger.warning("Using first candidate as fallback") | ||
| selected_alg = candidates[0]["id"] if candidates else None | ||
| confidence = 0.5 | ||
| reasoning = "Error in selection, using first candidate" |
There was a problem hiding this comment.
Variable confidence is not used.
| reasoning = "Error in selection, using first candidate" | |
| reasoning = "Error in selection, using first candidate" | |
| _logger.info( | |
| f"Selected algorithm (fallback): {selected_alg} (confidence: {confidence}, reasoning: {reasoning})" | |
| ) |
| confidence = 0.5 | ||
| reasoning = "Error in selection, using first candidate" |
There was a problem hiding this comment.
Variable reasoning is not used.
| confidence = 0.5 | |
| reasoning = "Error in selection, using first candidate" |
| from ..tools import ( | ||
| list_processing_algorithms, | ||
| find_processing_algorithm, | ||
| get_algorithm_parameters, | ||
| execute_processing, | ||
| list_qgis_layers, | ||
| ) |
There was a problem hiding this comment.
Import of 'list_processing_algorithms' is not used.
| _logger.debug( | ||
| f"Inferred {param_name} = {value} (extracted from query)" | ||
| ) | ||
| except ValueError: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| except ValueError: | |
| except ValueError: | |
| # If parsing fails, just skip inferring this numeric parameter |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 19 comments.
Comments suppressed due to low confidence (3)
agents/graph.py:17
- The return type annotation uses
anywhich is not a valid Python type. It should beAnyfrom the typing module (which needs to be imported) or a more specific type likeCompiledGraphif available from LangGraph.
agents/graph.py:62 - The return type annotation uses
anywhich is not a valid Python type. It should beAnyfrom the typing module (which needs to be imported) or a more specific type likeCompiledGraphif available from LangGraph.
agents/graph.py:10 - Import of 'HumanMessage' is not used.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # execute_processing, | ||
| # list_processing_algorithms, | ||
| # get_algorithm_parameters, | ||
| # find_processing_algorithm, |
There was a problem hiding this comment.
The new geoprocessing tools are imported but commented out in the TOOLS dictionary. This means they are not actually available for use by the agent system. If these tools should not be enabled yet, consider removing the imports and adding a comment explaining why they're disabled.
| # Create global logger instance | ||
| _logger = _setup_logger() |
There was a problem hiding this comment.
The global _logger variable is created at module import time, which means a log file is created even if the processing functionality is never used. Consider lazy initialization of the logger when build_processing_graph is first called.
| # Create global logger instance | |
| _logger = _setup_logger() | |
| # Logger instance (initialized lazily to avoid creating log files on import) | |
| _logger: Optional[logging.Logger] = None |
| tool_inst = TOOLS[call["name"]] | ||
| result = tool_inst.invoke(call["args"]) | ||
| tool_messages.append(ToolMessage(content=result, tool_call_id=call["id"])) |
There was a problem hiding this comment.
The tool_node function doesn't handle potential KeyError when accessing TOOLS[call["name"]]. If a tool name is not found in the TOOLS dictionary, this will raise an unhandled exception. Consider adding error handling or validation.
| tool_inst = TOOLS[call["name"]] | |
| result = tool_inst.invoke(call["args"]) | |
| tool_messages.append(ToolMessage(content=result, tool_call_id=call["id"])) | |
| # Safely resolve the tool instance to avoid KeyError if the name is unknown | |
| tool_name = call.get("name") if isinstance(call, dict) else None | |
| if not tool_name: | |
| _logger.warning("Skipping tool call without a valid 'name': %r", call) | |
| continue | |
| tool_inst = TOOLS.get(tool_name) | |
| if tool_inst is None: | |
| _logger.error("Requested tool '%s' not found in TOOLS registry; skipping.", tool_name) | |
| continue | |
| result = tool_inst.invoke(call["args"]) | |
| tool_call_id = call.get("id") if isinstance(call, dict) else None | |
| tool_messages.append(ToolMessage(content=result, tool_call_id=tool_call_id)) |
| "_setup_logger", | ||
| ] |
There was a problem hiding this comment.
The _setup_logger function is exported in __all__ with a leading underscore, which conventionally indicates it's a private/internal function. Private functions should not be included in the public API via __all__.
| "_setup_logger", | |
| ] | |
| ] |
| class DiscoveryState(RouteState, total=False): | ||
| """State after algorithm discovery.""" | ||
|
|
||
| algorithm_candidates: Optional[list] |
There was a problem hiding this comment.
The algorithm_candidates field uses the generic list type instead of a more specific type hint. Consider using List[Dict[str, Any]] to better document the structure of algorithm candidates (which contain 'id', 'name', and 'provider' fields).
| # Fallback: pick first candidate | ||
| _logger.warning("Using first candidate as fallback") | ||
| selected_alg = candidates[0]["id"] if candidates else None | ||
| confidence = 0.1 |
There was a problem hiding this comment.
Variable confidence is not used.
| _logger.warning("Using first candidate as fallback") | ||
| selected_alg = candidates[0]["id"] if candidates else None | ||
| confidence = 0.1 | ||
| reasoning = "Error in selection, using first candidate" |
There was a problem hiding this comment.
Variable reasoning is not used.
| # Default value if available | ||
| try: | ||
| item["default"] = p.defaultValue() | ||
| except Exception: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| item["allowMultiple"] = bool( | ||
| getattr(p, "allowMultiple", lambda: False)() | ||
| ) | ||
| except Exception: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| ) | ||
| except Exception: | ||
| pass | ||
| except Exception: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
|
@iamtekson I've opened a new pull request, #22, to work on those changes. Once the pull request is ready, I'll request review from you. |
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
…ion limit configurable Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
…ng conversion Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Fix infinite loop in processing graph conditional routing
|
Referenced to #7 |
No description provided.