lexsi-ds-agent / lexsi_ds /agent /reference_resolver.py
bp-lexsi's picture
multi-turn sessions, lexsi-sdk 0.1.51, family-aware pods, dark code css
e23d022
Raw
History Blame Contribute Delete
4.14 kB
"""Reference resolver β€” ground anaphora / positional / predicate refs
against session state before the planner sees them.
Used by the multi-turn loop after `classify_turn` to convert phrases
like "show me 20 instead", "the second one", "those", "loans over
30000" into explicit args the planner can pass straight to a tool.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
import pandas as pd
if TYPE_CHECKING:
from lexsi_ds.agent.session import SessionContext
log = logging.getLogger(__name__)
ResolutionKind = Literal["slot", "positional", "predicate", "drilldown", "none"]
@dataclass
class ResolvedReference:
kind: ResolutionKind = "none"
source_slot: str = ""
resolved_args: dict[str, Any] = field(default_factory=dict)
confidence: float = 0.0
notes: str = ""
_DRILLDOWN_RE = re.compile(
r"\bwhy is (?:loan |row |id |entity |case )?#?(\d+)\b", re.IGNORECASE,
)
_TOP_K_RE = re.compile(r"\btop\s+(\d+)\b", re.IGNORECASE)
_POSITIONAL_RE = re.compile(
r"\bthe\s+(first|second|third|fourth|fifth|last)\b", re.IGNORECASE,
)
_PREDICATE_RE = re.compile(
r"\b(higher than|larger than|more than|greater than|over|above|>)\s+"
r"\$?(\d+(?:[.,]\d{3})*(?:\.\d+)?)\b",
re.IGNORECASE,
)
_THOSE_RE = re.compile(r"\b(those|them|they|these)\b", re.IGNORECASE)
_POSITION_MAP = {
"first": 0, "second": 1, "third": 2,
"fourth": 3, "fifth": 4, "last": -1,
}
def resolve(user_msg: str, session: "SessionContext") -> ResolvedReference:
"""Find the highest-priority grounded reference in `user_msg`."""
msg = user_msg or ""
# 1. Drilldown β€” "why is loan 5314 here?"
m = _DRILLDOWN_RE.search(msg)
if m:
return ResolvedReference(
kind="drilldown",
source_slot="last_predictions",
resolved_args={"row_id": int(m.group(1))},
confidence=0.85,
notes="drilldown on numeric id",
)
# 2. Top-K override β€” "top 20"
m = _TOP_K_RE.search(msg)
if m:
return ResolvedReference(
kind="positional",
resolved_args={"top_k": int(m.group(1))},
confidence=0.9,
notes="top-k override",
)
# 3. Positional β€” "the second one", "the last entry"
m = _POSITIONAL_RE.search(msg)
if m:
return ResolvedReference(
kind="positional",
resolved_args={"index": _POSITION_MAP[m.group(1).lower()]},
confidence=0.8,
notes=f"positional: {m.group(1)}",
)
# 4. Predicate filter β€” "over 30000"
m = _PREDICATE_RE.search(msg)
if m:
value = m.group(2).replace(",", "")
cols = _columns_in_last_table(session)
column_hint = cols[0] if cols else "amount"
return ResolvedReference(
kind="predicate",
source_slot="last_predictions" if session.cache.get("last_predictions") is not None
else "last_sql_result",
resolved_args={"filter": f"> {value}", "column_hint": column_hint},
confidence=0.7,
notes=f"predicate over {value}",
)
# 5. Anaphora β€” "those", "them" β†’ previous predictions if present
if _THOSE_RE.search(msg):
if session.cache.get("last_predictions") is not None:
return ResolvedReference(
kind="slot",
source_slot="last_predictions",
resolved_args={},
confidence=0.6,
notes="anaphora to last predictions",
)
return ResolvedReference(kind="none", confidence=1.0, notes="no reference detected")
def _columns_in_last_table(session: "SessionContext") -> list[str]:
"""Return column names from the freshest cached table, if any."""
df = session.cache.get("last_predictions")
if isinstance(df, pd.DataFrame) and not df.empty:
return list(df.columns)
df = session.cache.get("last_sql_result")
if isinstance(df, pd.DataFrame) and not df.empty:
return list(df.columns)
return []