| """Node: critique executed row shape against the question and optional plan.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| import re |
| from collections.abc import Callable |
| from dataclasses import dataclass |
|
|
| from nl_sql.agent.state import PipelineState |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ShapeVerdict: |
| ok: bool |
| expected_label: str |
| feedback: str |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class _ShapeExpectation: |
| expected_label: str |
| min_rows: int | None = None |
| max_rows: int | None = None |
| list_cap: int | None = None |
|
|
|
|
| def evaluate_shape(question: str, plan_json: str | None, actual_row_count: int) -> ShapeVerdict: |
| expectation = _expectation_from_plan(plan_json) or _expectation_from_question(question) |
| if expectation is None: |
| return ShapeVerdict(ok=True, expected_label="unconstrained rows", feedback="") |
|
|
| if _shape_matches(expectation, actual_row_count): |
| return ShapeVerdict(ok=True, expected_label=expectation.expected_label, feedback="") |
|
|
| feedback = ( |
| f"The previous query returned {actual_row_count} rows, but the question implies " |
| f"{expectation.expected_label}.\n" |
| "Common causes: (a) missing WHERE filter implied by the question, (b) missing\n" |
| 'GROUP BY, (c) wrong join multiplicity, (d) "for X among Y" needing additional\n' |
| "condition. Re-examine the question and produce SQL whose result shape matches." |
| ) |
| return ShapeVerdict(ok=False, expected_label=expectation.expected_label, feedback=feedback) |
|
|
|
|
| def make_grounded_critique_node() -> Callable[[PipelineState], PipelineState]: |
| def node(state: PipelineState) -> PipelineState: |
| outcome = state.get("outcome") |
| actual_row_count = 0 |
| if outcome is not None and outcome.result is not None: |
| actual_row_count = len(outcome.result.rows) |
|
|
| verdict = evaluate_shape( |
| state.get("question", ""), |
| state.get("plan"), |
| actual_row_count, |
| ) |
| if not verdict.ok: |
| return {"last_error": verdict.feedback, "critique_failed": True} |
| return {"critique_failed": False} |
|
|
| return node |
|
|
|
|
| def _expectation_from_plan(plan_json: str | None) -> _ShapeExpectation | None: |
| if not plan_json or not plan_json.strip(): |
| return None |
| try: |
| payload: object = json.loads(plan_json) |
| except json.JSONDecodeError: |
| return None |
| if not isinstance(payload, dict): |
| return None |
|
|
| value = payload.get("expected_row_count") |
| if not isinstance(value, str): |
| return None |
|
|
| normalized = value.strip().lower() |
| if normalized == "1": |
| return _ShapeExpectation(expected_label="exactly 1 row", min_rows=1, max_rows=1) |
| if normalized == "few (2-20)": |
| return _ShapeExpectation(expected_label="2 to 20 rows", min_rows=2, max_rows=20) |
| if normalized == "many (>20)": |
| return _ShapeExpectation(expected_label="more than 20 rows", min_rows=21) |
| return None |
|
|
|
|
| def _expectation_from_question(question: str) -> _ShapeExpectation | None: |
| normalized = question.lower() |
| if ( |
| "how many" in normalized |
| or "count of" in normalized |
| or "number of" in normalized |
| or "percentage" in normalized |
| or "ratio" in normalized |
| or "what fraction" in normalized |
| or "what proportion" in normalized |
| or "average " in normalized |
| or "mean " in normalized |
| or _has_single_total_or_sum(normalized) |
| or _asks_for_single_extreme(normalized) |
| ): |
| return _ShapeExpectation(expected_label="exactly 1 row", min_rows=1, max_rows=1) |
|
|
| top_n = _extract_top_n(normalized) |
| if top_n is not None: |
| return _ShapeExpectation( |
| expected_label=f"at most {top_n} rows", |
| min_rows=1, |
| max_rows=top_n, |
| ) |
|
|
| if re.search(r"\b(which|what|list|name|show|indicate)\b", normalized): |
| return _ShapeExpectation(expected_label="1 to 1000 rows", min_rows=1, list_cap=1000) |
|
|
| return None |
|
|
|
|
| def _shape_matches(expectation: _ShapeExpectation, actual_row_count: int) -> bool: |
| if expectation.min_rows is not None and actual_row_count < expectation.min_rows: |
| return False |
| if expectation.max_rows is not None and actual_row_count > expectation.max_rows: |
| return False |
| return expectation.list_cap is None or actual_row_count <= expectation.list_cap |
|
|
|
|
| def _extract_top_n(question: str) -> int | None: |
| top_match = re.search(r"\btop\s+(\d+)\b", question) |
| if top_match: |
| return int(top_match.group(1)) |
| ranked_match = re.search(r"\b(\d+)\s+(?:highest|lowest)\b", question) |
| if ranked_match: |
| return int(ranked_match.group(1)) |
| return None |
|
|
|
|
| def _has_single_total_or_sum(question: str) -> bool: |
| return bool( |
| re.search(r"\b(total|sum)\b", question) |
| and not re.search(r"\b(by|per|each)\b|for each|grouped by", question) |
| ) |
|
|
|
|
| def _asks_for_single_extreme(question: str) -> bool: |
| return bool( |
| re.search(r"\bwhich\b.+\bhas\s+the\s+(?:most|highest|least)\b", question) |
| or re.search(r"\bwhose\b.+\bis\s+the\s+most\b", question) |
| ) |
|
|
|
|
| __all__ = ["ShapeVerdict", "evaluate_shape", "make_grounded_critique_node"] |
|
|