File size: 5,172 Bytes
f8f02c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
from __future__ import annotations
"""
query_engine/query_executor.py - QueryWeaver: NL → Cypher → FalkorDB execution.

Responsibility:
  1. Build an LLM prompt using the graph schema description.
  2. Generate a Cypher query.
  3. Validate it is read-only (block DELETE / DROP / SET / REMOVE / MERGE writes).
  4. Execute against FalkorDB and return structured results.
"""


import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

from RULE.query_engine.falkordb_client import FalkorDBClient
from RULE.query_engine.schema_manager import SchemaManager

logger = logging.getLogger(__name__)

__all__ = ["ExecutionResult", "QueryExecutor"]

# ---------------------------------------------------------------------------
# Safety: patterns that must NOT appear in generated Cypher
# ---------------------------------------------------------------------------

_BLOCKED_PATTERNS = re.compile(
    r"\b(DELETE|DROP|SET\s+\w|REMOVE|CREATE|MERGE|DETACH\s+DELETE)\b",
    re.IGNORECASE,
)


@dataclass
class ExecutionResult:
    """Result of a QueryExecutor.execute() call."""
    success: bool
    cypher: str
    rows: List[Dict[str, Any]] = field(default_factory=list)
    error: Optional[str] = None
    graph_name: str = ""

    def row_count(self) -> int:
        return len(self.rows)


class QueryExecutor:
    """
    Translates NL → Cypher and executes against FalkorDB.

    Args:
        client:         Connected (or gracefully degraded) FalkorDBClient.
        schema_manager: SchemaManager instance for schema-aware prompts.
    """

    def __init__(self, client: Optional[FalkorDBClient], schema_manager: SchemaManager) -> None:
        self._client = client
        self._schema_manager = schema_manager

    def execute(self, nl_query: str, graph_name: str) -> ExecutionResult:
        """
        Execute nl_query as a Cypher query against graph_name.

        Pipeline:
          1. Build schema-aware LLM prompt.
          2. Generate Cypher.
          3. Validate safety.
          4. Run against FalkorDB.

        Returns:
            ExecutionResult — always returned; check success flag.
        """
        schema_desc = self._schema_manager.describe_schema(graph_name)

        cypher = self._generate_cypher(nl_query, schema_desc, graph_name)
        if not cypher:
            return ExecutionResult(
                success=False,
                cypher="",
                error="LLM did not generate a valid Cypher query.",
                graph_name=graph_name,
            )

        logger.debug("[QueryExecutor] Generated Cypher:\n%s", cypher)

        if not self._validate_cypher(cypher):
            logger.warning("[QueryExecutor] Cypher blocked by safety validator: %s", cypher)
            return ExecutionResult(
                success=False,
                cypher=cypher,
                error=(
                    "Generated Cypher contains write operations "
                    "(DELETE/DROP/SET/CREATE/MERGE). Only read queries are permitted."
                ),
                graph_name=graph_name,
            )

        if self._client is None or not self._client.is_connected():
            return ExecutionResult(
                success=False,
                cypher=cypher,
                error="FalkorDB is not connected. Start FalkorDB and retry.",
                graph_name=graph_name,
            )

        rows = self._client.execute_cypher(cypher, graph_name=graph_name)
        return ExecutionResult(
            success=True,
            cypher=cypher,
            rows=rows,
            graph_name=graph_name,
        )

    @staticmethod
    def _call_llm(prompt: str) -> str:
        """Invoke LLM via RULE's call_gemini."""
        try:
            from pandas_rule import call_gemini
            return call_gemini(prompt)
        except Exception as exc:
            logger.debug("[QueryExecutor] LLM call failed: %s", exc)
            return ""

    def _generate_cypher(self, nl_query: str, schema_desc: str, graph_name: str) -> str:
        """Ask the LLM to generate a Cypher query for nl_query."""
        prompt = f"""You are a Cypher query generator for FalkorDB.

Graph Schema:
{schema_desc}

User request: "{nl_query}"

Rules:
- Generate ONLY a single valid Cypher query.
- Use only MATCH and RETURN clauses (read-only).
- Do NOT use DELETE, DROP, SET, CREATE, MERGE, or REMOVE.
- Do NOT wrap the query in markdown code fences.
- Do NOT add any explanation. Output ONLY the Cypher query.

Example:
  User: "Which rules depend on AML_RULE?"
  Cypher: MATCH (r:Rule)-[:DEPENDS_ON]->(p:Rule {{name:'AML_RULE'}}) RETURN r.name, r.severity

Cypher:"""

        raw = self._call_llm(prompt).strip()
        raw = re.sub(r"```(?:cypher)?", "", raw, flags=re.IGNORECASE).strip()
        raw = raw.strip("`").strip()
        raw = raw.split(";")[0].strip()

        return raw

    @staticmethod
    def _validate_cypher(cypher: str) -> bool:
        """Return True if the Cypher query is safe (read-only)."""
        if not cypher or not cypher.strip():
            return False
        return _BLOCKED_PATTERNS.search(cypher) is None