"""
ChatGuard — Reliability scoring for chatbot (non-ReAct) responses.
===================================================================

Operates in three modes:
  - native:           Zero-cost lexical/structural features only.
  - synthetic_trace:  Converts the response into a Chain-of-Verification
                      trace and scores it with AgentGuard (requires API key).
  - hybrid:           Average of native and synthetic_trace scores.

Typical use::

    guard = ChatGuard(mode="native")
    result = guard.score_chat("What is the capital of France?", "Paris.")
    print(result.risk_score, result.failure_mode)
Purpose:    Operates in native (zero-cost lexical), synthetic_trace (CoV), or hybrid modes. Auto-fit via Haiku bootstrapping achieves AUROC 0.8948 on HaluEval; judge mode achieves 0.7138 on TruthfulQA.
Exports:    ChatGuard, ChatResult
Added:      v0.26.0
Status:     Validated
"""

from __future__ import annotations

import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

from llm_guard.multi_turn.chat_features import ChatFeatureExtractor, ChatFeatureVector

logger = logging.getLogger(__name__)

_VALID_MODES = frozenset({"native", "synthetic_trace", "hybrid"})
_VALID_FAILURE_MODES = frozenset({
    "clean", "hallucination", "hedge_heavy", "off_topic",
    "over_verbose", "unknown",
})
# Note: "self_contradiction" is not yet implemented — would require intra-response NLI.
# Add here when a detection rule exists.

# Canonical order for CoV feature vector (must match CoVFeatureVector.to_dict() key order)
COV_FEATURE_KEYS = [
    "contradiction_score",
    "final_confidence",
    "claim_count_norm",
    "evidence_gap_ratio",
]

_AUTO_FIT_PROMPT = (
    "You are evaluating an AI assistant's response quality.\n"
    "Question: {query}\n"
    "Response: {response}\n"
    "Is this response accurate, complete, and reliable? "
    "Reply with exactly one word: YES or NO."
)

_JUDGE_SYSTEM = (
    "You are a response quality rater. "
    "You MUST reply with exactly one digit (1, 2, 3, 4, or 5) and nothing else. "
    "No words, no explanation, no punctuation — just the digit."
)

_JUDGE_PROMPT = (
    "Rate this AI response on quality and accuracy (1=wrong, 5=excellent).\n\n"
    "Question: {query}\n"
    "Response: {response}\n\n"
    "Rating (1-5):"
)


# ---------------------------------------------------------------------------
# Module-level helper
# ---------------------------------------------------------------------------

def _risk_to_tier(risk: float) -> str:
    """Convert a risk score to a confidence tier label.

    LOW confidence (high risk) → risk >= 0.70
    MEDIUM confidence          → 0.50 <= risk < 0.70
    HIGH confidence (low risk) → risk < 0.50
    """
    if risk < 0.50:
        return "HIGH"
    if risk < 0.70:
        return "MEDIUM"
    return "LOW"


# ---------------------------------------------------------------------------
# Result dataclass
# ---------------------------------------------------------------------------

@dataclass
class ChatResult:
    """Reliability assessment for a single chatbot (query, response) pair.

    Fields
    ------
    risk_score:
        Float in [0, 1]. Higher values indicate higher likelihood of an
        unreliable or incorrect response.
    confidence_tier:
        One of "HIGH" / "MEDIUM" / "LOW". Derived from risk_score.
    needs_alert:
        True when risk_score >= 0.70.
    failure_mode:
        Detected failure pattern (see _VALID_FAILURE_MODES for options).
    native_features:
        Raw feature values from ChatFeatureVector.to_dict().
    trace_risk:
        Risk score from the synthetic Chain-of-Verification pass.
        None in native mode.
    """

    risk_score: float
    confidence_tier: str
    failure_mode: str
    needs_alert: bool = field(default=False)
    native_features: Dict = field(default_factory=dict)
    behavioral_components: Dict = field(default_factory=dict)
    processing_time_ms: float = field(default=0.0)
    trace_risk: Optional[float] = field(default=None)
    metadata: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict:
        """Return all fields as a plain dict."""
        return {
            "risk_score": self.risk_score,
            "confidence_tier": self.confidence_tier,
            "needs_alert": self.needs_alert,
            "failure_mode": self.failure_mode,
            "native_features": self.native_features,
            "behavioral_components": self.behavioral_components,
            "processing_time_ms": self.processing_time_ms,
            "trace_risk": self.trace_risk,
            "metadata": self.metadata,
        }


# ---------------------------------------------------------------------------
# Main class
# ---------------------------------------------------------------------------

class ChatGuard:
    """Score chatbot responses for reliability without labelled data.

    Parameters
    ----------
    api_key:
        Anthropic API key. Defaults to ``ANTHROPIC_API_KEY`` env var.
        Only used in ``synthetic_trace`` and ``hybrid`` modes.
    mode:
        Scoring mode — one of ``"native"``, ``"synthetic_trace"``,
        ``"hybrid"``.

    Raises
    ------
    ValueError
        If ``mode`` is not one of the three valid options.
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        mode: str = "native",
    ) -> None:
        if mode not in _VALID_MODES:
            raise ValueError(
                f"Invalid mode {mode!r}. Must be one of: {sorted(_VALID_MODES)}"
            )
        self.mode = mode
        self._api_key: str = api_key or os.environ.get("ANTHROPIC_API_KEY", "")

        # Lazy-loaded components
        self._extractor: Optional[ChatFeatureExtractor] = None
        self._judge: Optional[Pipeline] = None
        self._client = None  # anthropic.Anthropic — lazy-loaded
        self._has_cov_features: bool = False

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def score_chat(
        self,
        user_message: str,
        assistant_response: str,
        conversation_history: Optional[List[Dict]] = None,
    ) -> ChatResult:
        """Score a single (user_message, assistant_response) pair.

        Parameters
        ----------
        user_message:
            The user's input text.
        assistant_response:
            The assistant's reply.
        conversation_history:
            Optional list of prior turn dicts (ignored in native mode
            but accepted for API compatibility).

        Returns
        -------
        ChatResult
        """
        if self._judge is None:  # never fitted
            if self._api_key:
                result = self.score_chat_with_judge(user_message, assistant_response)
                result.metadata["routed_to_judge"] = True
                result.metadata["reason"] = (
                    "fit() not called — routed to judge for cross-dataset safety. "
                    "Cross-dataset AUROC 0.4656 (below random) without fit()."
                )
                return result
            else:
                import warnings
                warnings.warn(
                    "ChatGuard.score_chat() called without fit() and no api_key. "
                    "Scores are unreliable outside the HaluEval training distribution "
                    "(cross-dataset AUROC 0.4656, below random). "
                    "Call fit(), auto_fit(), or pass api_key= to ChatGuard().",
                    UserWarning,
                    stacklevel=2,
                )

        native_risk, vec, failure_mode = self._native_score(
            user_message, assistant_response
        )

        trace_risk: Optional[float] = None

        if self.mode == "synthetic_trace":
            trace_risk_raw, cov_feat_dict = self._synthetic_score(
                user_message, assistant_response, conversation_history
            )
            trace_risk = trace_risk_raw

            # 16-feature path: bypass _native_score judge, score directly
            if (
                self._has_cov_features
                and self._judge is not None
                and cov_feat_dict is not None
            ):
                final_risk = self._score_16_feature(user_message, assistant_response, vec, cov_feat_dict)
                failure_mode = self._classify_failure(vec)
            else:
                final_risk = trace_risk_raw

        elif self.mode == "hybrid":
            trace_risk_raw, cov_feat_dict = self._synthetic_score(
                user_message, assistant_response, conversation_history
            )
            trace_risk = trace_risk_raw

            # 16-feature path for hybrid too
            if (
                self._has_cov_features
                and self._judge is not None
                and cov_feat_dict is not None
            ):
                final_risk = self._score_16_feature(user_message, assistant_response, vec, cov_feat_dict)
                failure_mode = self._classify_failure(vec)
            else:
                final_risk = 0.5 * native_risk + 0.5 * trace_risk_raw

        else:  # native
            final_risk = native_risk

        # Clamp to [0, 1]
        final_risk = max(0.0, min(1.0, final_risk))

        # Promote unknown to hallucination when risk is high
        if failure_mode == "unknown" and final_risk >= 0.70:
            failure_mode = "hallucination"

        return ChatResult(
            risk_score=final_risk,
            confidence_tier=_risk_to_tier(final_risk),
            needs_alert=final_risk >= 0.70,
            failure_mode=failure_mode,
            native_features=vec.to_dict(),
            trace_risk=trace_risk,
        )

    def fit(
        self,
        labeled_pairs: List[Dict],
        cov_features_list: Optional[List[Dict]] = None,
    ) -> "ChatGuard":
        """Fit a logistic-regression judge on labelled (query, response) pairs.

        Parameters
        ----------
        labeled_pairs:
            List of dicts with keys ``query`` (str), ``response`` (str),
            ``label`` (0 or 1 — 1 means incorrect/unreliable).
        cov_features_list:
            Optional parallel list of CoV feature dicts (same length as labeled_pairs).
            Each dict must be the output of ``CoVFeatureVector.to_dict()`` —
            exactly 4 keys: contradiction_score, final_confidence,
            claim_count_norm, evidence_gap_ratio.
            If provided, fits a 16-feature model (12 native + 4 CoV).
            If None, fits the standard 12-feature model (v0.26.0 compatible).

        Returns
        -------
        self
        """
        if not labeled_pairs:
            raise ValueError("fit() requires at least 1 labeled pair; got 0.")

        if len(labeled_pairs) < 20:
            logger.warning(
                "fit() called with only %d pairs (< 20). "
                "Model may have poor generalisation.",
                len(labeled_pairs),
            )

        # Validate cov_features_list
        if cov_features_list is not None:
            if len(cov_features_list) != len(labeled_pairs):
                raise ValueError(
                    f"cov_features_list length must match labeled_pairs: "
                    f"got {len(cov_features_list)} vs {len(labeled_pairs)}"
                )
            expected_keys = set(COV_FEATURE_KEYS)
            for i, cf in enumerate(cov_features_list):
                if set(cf.keys()) != expected_keys:
                    raise ValueError(
                        f"cov_features_list[{i}] has keys {set(cf.keys())}; "
                        f"expected {expected_keys}"
                    )

        extractor = self._get_extractor()

        if cov_features_list is not None:
            # 16-feature path: 12 native + 4 CoV
            X = np.array([
                extractor.extract(p["query"], p["response"]).to_array()
                + [cf[k] for k in COV_FEATURE_KEYS]
                for p, cf in zip(labeled_pairs, cov_features_list)
            ])
            self._has_cov_features = True
        else:
            # 12-feature path (v0.26.0 compatible)
            X = np.array([
                extractor.extract(p["query"], p["response"]).to_array()
                for p in labeled_pairs
            ])
            self._has_cov_features = False

        y = np.array([int(p["label"]) for p in labeled_pairs])

        pipeline = Pipeline([
            ("scaler", StandardScaler()),
            ("logreg", LogisticRegression(C=1.0, max_iter=500, random_state=42)),
        ])
        pipeline.fit(X, y)
        self._judge = pipeline
        return self

    def auto_fit(
        self,
        unlabeled_pairs: List[Dict],
        n_pseudo: int = 50,
        api_key: Optional[str] = None,
    ) -> "ChatGuard":
        """Bootstrap ChatMiniJudge using Haiku P(True) as pseudo-labeler.

        Zero-label alternative to fit(): calls Haiku on the first n_pseudo
        pairs, treats YES→0 (reliable) and NO→1 (unreliable), then fits.

        Parameters
        ----------
        unlabeled_pairs:
            Each dict must have ``query`` (str) and ``response`` (str) keys.
            Any ``label`` key is silently ignored.
        n_pseudo:
            Number of pairs to pseudo-label (first n_pseudo of unlabeled_pairs).
            Must be >= 1.
        api_key:
            Anthropic API key. Falls back to self._api_key, then
            ANTHROPIC_API_KEY env var.

        Returns
        -------
        self
        """
        if n_pseudo <= 0:
            raise ValueError(f"n_pseudo must be >= 1; got {n_pseudo}")

        # Resolve API key without mutating instance state
        if api_key:
            try:
                import anthropic
                client = anthropic.Anthropic(api_key=api_key)
            except ImportError:
                raise ValueError("anthropic SDK not installed; cannot call auto_fit.")
        else:
            client = self._get_client()
            if client is None:
                raise ValueError(
                    "auto_fit requires an Anthropic API key. "
                    "Pass api_key= or set ANTHROPIC_API_KEY env var."
                )

        pairs_to_label = unlabeled_pairs[:n_pseudo]
        if len(pairs_to_label) < n_pseudo:
            logger.warning(
                "auto_fit: only %d pairs available, fewer than n_pseudo=%d. "
                "Using all %d pairs.",
                len(pairs_to_label), n_pseudo, len(pairs_to_label),
            )

        pseudo_labeled: List[Dict] = []
        yes_count = 0
        no_count = 0
        skip_count = 0

        for pair in pairs_to_label:
            prompt_text = _AUTO_FIT_PROMPT.format(
                query=pair["query"],
                response=pair["response"],
            )
            reply = None
            for attempt in range(3):
                try:
                    msg = client.messages.create(
                        model="claude-haiku-4-5-20251001",
                        max_tokens=5,
                        messages=[{"role": "user", "content": prompt_text}],
                    )
                    reply = msg.content[0].text.strip().upper()
                    break
                except Exception as exc:
                    wait = 0.5 * (2 ** attempt)
                    logger.warning(
                        "auto_fit: Haiku call failed (attempt %d): %s; retrying in %.1fs",
                        attempt + 1, exc, wait,
                    )
                    time.sleep(wait)

            if reply == "YES":
                pseudo_labeled.append({"query": pair["query"], "response": pair["response"], "label": 0})
                yes_count += 1
            elif reply == "NO":
                pseudo_labeled.append({"query": pair["query"], "response": pair["response"], "label": 1})
                no_count += 1
            else:
                logger.warning(
                    "auto_fit: unexpected Haiku reply %r for pair query=%r; skipping.",
                    reply, pair["query"][:50],
                )
                skip_count += 1

            time.sleep(0.2)

        logger.info(
            "auto_fit: pseudo-labels — YES=%d (label=0), NO=%d (label=1), skipped=%d",
            yes_count, no_count, skip_count,
        )

        if len(pseudo_labeled) < 5:
            raise ValueError(
                f"auto_fit: too few valid pseudo-labels ({len(pseudo_labeled)}). "
                "Check API key and response format."
            )

        # Warn on degenerate signal
        total = len(pseudo_labeled)
        pos_rate = yes_count / total
        if pos_rate < 0.20:
            logger.warning(
                "auto_fit: Haiku agreement rate %.0f%% YES — degenerate signal (< 20%%). Proceeding.",
                pos_rate * 100,
            )
        elif pos_rate > 0.95:
            logger.warning(
                "auto_fit: Haiku agreement rate %.0f%% YES — degenerate signal (> 95%%). Proceeding.",
                pos_rate * 100,
            )

        return self.fit(pseudo_labeled)

    def score_chat_with_judge(
        self,
        user_message: str,
        assistant_response: str,
        api_key: Optional[str] = None,
        blend_native: bool = True,
        native_weight: float = 0.30,
    ) -> ChatResult:
        """Score a (query, response) pair using a Haiku 1-5 quality judge.

        Zero-shot, training-free path — Haiku rates response quality 1-5.
        Risk = (5 - rating) / 4, so rating=5 → risk=0.0, rating=1 → risk=1.0.

        No LogReg training needed; no dataset-specific patterns.
        Expected AUROC: 0.65-0.75 cross-dataset (generalises where native fails).

        Parameters
        ----------
        user_message:
            The user's input text.
        assistant_response:
            The assistant's reply.
        api_key:
            Anthropic API key. Falls back to self._api_key, then
            ANTHROPIC_API_KEY env var.
        blend_native:
            If True (default), blend judge risk with native features at
            native_weight * native + (1-native_weight) * judge_risk.
            Set False to use judge risk only.
        native_weight:
            Weight for native features in the blend (ignored when blend_native=False).

        Returns
        -------
        ChatResult
            trace_risk holds the raw judge risk (before blending).
        """
        # Resolve API client
        if api_key:
            try:
                import anthropic
                client = anthropic.Anthropic(api_key=api_key)
            except ImportError:
                raise ValueError("anthropic SDK not installed.")
        else:
            client = self._get_client()
            if client is None:
                raise ValueError(
                    "score_chat_with_judge requires an Anthropic API key. "
                    "Pass api_key= or set ANTHROPIC_API_KEY env var."
                )

        # Native features (always computed for failure mode and blend)
        native_risk, vec, failure_mode = self._native_score(user_message, assistant_response)

        # Haiku 1-5 quality rating
        judge_risk = 0.50  # default neutral on failure
        prompt_text = _JUDGE_PROMPT.format(
            query=user_message[:500],
            response=assistant_response[:800],
        )
        for attempt in range(3):
            try:
                msg = client.messages.create(
                    model="claude-haiku-4-5-20251001",
                    max_tokens=5,
                    system=_JUDGE_SYSTEM,
                    messages=[{"role": "user", "content": prompt_text}],
                )
                reply = msg.content[0].text.strip()
                # Extract first digit
                digit = None
                for ch in reply:
                    if ch.isdigit():
                        digit = int(ch)
                        break
                if digit is not None and 1 <= digit <= 5:
                    judge_risk = (5 - digit) / 4.0
                    break
                else:
                    logger.warning(
                        "score_chat_with_judge: unexpected Haiku reply %r (attempt %d)",
                        reply, attempt + 1,
                    )
            except Exception as exc:
                import time as _time
                wait = 0.5 * (2 ** attempt)
                logger.warning(
                    "score_chat_with_judge: Haiku call failed (attempt %d): %s; retrying in %.1fs",
                    attempt + 1, exc, wait,
                )
                _time.sleep(wait)

        if blend_native:
            final_risk = native_weight * native_risk + (1.0 - native_weight) * judge_risk
        else:
            final_risk = judge_risk

        final_risk = max(0.0, min(1.0, final_risk))

        if failure_mode == "unknown" and final_risk >= 0.70:
            failure_mode = "hallucination"

        return ChatResult(
            risk_score=final_risk,
            confidence_tier=_risk_to_tier(final_risk),
            needs_alert=final_risk >= 0.70,
            failure_mode=failure_mode,
            native_features=vec.to_dict(),
            trace_risk=judge_risk,
        )

    # ------------------------------------------------------------------
    # Internal scoring helpers
    # ------------------------------------------------------------------

    def _native_score(
        self,
        query: str,
        response: str,
    ):
        """Compute native features and risk score.

        Returns
        -------
        (risk: float, vec: ChatFeatureVector, failure_mode: str)
        """
        extractor = self._get_extractor()
        vec = extractor.extract(query, response)

        # Only use the judge for native (12-feature) scoring when it was not
        # trained on 16 features. If _has_cov_features is True the judge
        # expects 16 features and must be called from score_chat() with
        # the CoV vector appended.
        if self._judge is not None and not self._has_cov_features:
            x = np.array(vec.to_array()).reshape(1, -1)
            risk = float(self._judge.predict_proba(x)[0, 1])
            failure_mode = self._classify_failure(vec)
        else:
            risk, failure_mode = self._rule_engine(vec)

        return risk, vec, failure_mode

    def _rule_engine(self, vec: ChatFeatureVector):
        """Heuristic rule-based risk estimate.

        Returns
        -------
        (risk: float, failure_mode: str)
        """
        risk = 0.0
        failure_mode = "unknown"

        if vec.query_response_cosine < 0.25:
            risk = max(risk, 0.80)
            failure_mode = "off_topic"

        if vec.length_ratio > 12:
            risk = max(risk, 0.65)
            if failure_mode == "unknown":
                failure_mode = "over_verbose"

        hedge_risk = min(vec.hedge_density * 4.0, 0.85)
        if vec.hedge_density > 0.15:
            risk = max(risk, hedge_risk)
            if failure_mode == "unknown":
                failure_mode = "hedge_heavy"

        unc_risk = min(vec.first_person_uncertainty * 0.6, 0.75)
        risk = max(risk, unc_risk)

        if vec.entity_overlap_ratio > 0.3 and vec.hedge_density < 0.05:
            risk = min(risk, 0.35)
            failure_mode = "clean"

        if failure_mode == "unknown" and risk < 0.35:
            failure_mode = "clean"

        return risk, failure_mode

    def _classify_failure(self, vec: ChatFeatureVector) -> str:
        """Classify failure mode when a fitted judge is available."""
        if vec.query_response_cosine < 0.25:
            return "off_topic"
        if vec.length_ratio > 12:
            return "over_verbose"
        if vec.hedge_density > 0.15:
            return "hedge_heavy"
        if vec.first_person_uncertainty > 0.5:
            return "hedge_heavy"
        return "clean"

    def _score_16_feature(self, user_message: str, assistant_response: str, vec, cov_feat_dict: dict) -> float:
        """Score using the 16-feature judge (12 native + 4 CoV features)."""
        native_arr = vec.to_array()
        x_16 = np.array(native_arr + [cov_feat_dict[k] for k in COV_FEATURE_KEYS]).reshape(1, -1)
        return float(self._judge.predict_proba(x_16)[0, 1])



    def _synthetic_score(
        self,
        user_message: str,
        assistant_response: str,
        conversation_history: Optional[List[Dict]],
    ) -> Tuple[float, Optional[dict]]:
        """Score via a synthetic Chain-of-Verification trace.

        Returns
        -------
        (risk_score, cov_feature_dict)
            risk_score: float in [0, 1]
            cov_feature_dict: CoVFeatureVector.to_dict() output if CoV text was
                generated successfully; None on fallback.
        """
        client = self._get_client()
        if client is None:
            logger.warning(
                "_synthetic_score called but no API key available; "
                "falling back to native score."
            )
            return self._native_score(user_message, assistant_response)[0], None

        cov_prompt = (
            "Given this conversation:\n"
            f"User: {user_message}\n"
            f"Assistant: {assistant_response}\n\n"
            "Verify the response step by step:\n"
            "Step 1 - Claims: List the factual claims made in the response.\n"
            "Step 2 - Evidence: What evidence supports each claim?\n"
            "Step 3 - Contradictions: Are there unsupported or contradicted statements?\n"
            "Step 4 - Confidence: Rate overall confidence: HIGH / MEDIUM / LOW and why."
        )

        try:
            message = client.messages.create(
                model="claude-haiku-4-5-20251001",
                max_tokens=400,
                messages=[{"role": "user", "content": cov_prompt}],
            )
            cov_text = message.content[0].text

            from llm_guard.multi_turn.chat_cov_features import ChatCoVFeatureExtractor
            cov_extractor = ChatCoVFeatureExtractor()
            cov_vec = cov_extractor.extract(cov_text)
            cov_feature_dict = cov_vec.to_dict()

            steps = self._cov_to_steps(cov_text, user_message, assistant_response)
            from llm_guard.scoring.agent_guard import AgentGuard
            chain_result = AgentGuard(api_key=self._api_key).score_chain(
                question=user_message,
                steps=steps,
                final_answer=assistant_response[:200],
            )
            return chain_result.risk_score, cov_feature_dict

        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "_synthetic_score failed (%s); falling back to native score.", exc
            )
            return self._native_score(user_message, assistant_response)[0], None

    def _cov_to_steps(
        self,
        cov_text: str,
        query: str,
        response: str,
    ) -> List[Dict]:
        """Parse CoV text into a list of step dicts for AgentGuard."""
        lines = cov_text.splitlines()

        steps: List[Dict] = []
        current_thought: Optional[str] = None
        current_obs_lines: List[str] = []

        for line in lines:
            stripped = line.strip()
            if stripped.lower().startswith("step"):
                # Flush previous step
                if current_thought is not None:
                    steps.append({
                        "thought": current_thought,
                        "action_type": "Verify",
                        "action_arg": query[:100],
                        "observation": " ".join(current_obs_lines).strip(),
                    })
                current_thought = stripped
                current_obs_lines = []
            else:
                if current_thought is not None and stripped:
                    current_obs_lines.append(stripped)

        # Flush last step
        if current_thought is not None:
            steps.append({
                "thought": current_thought,
                "action_type": "Verify",
                "action_arg": query[:100],
                "observation": " ".join(current_obs_lines).strip(),
            })

        # Fallback: no steps detected
        if not steps:
            steps = [{
                "thought": cov_text,
                "action_type": "Verify",
                "action_arg": query[:100],
                "observation": "",
            }]

        # Last step becomes Finish
        if steps:
            steps[-1]["action_type"] = "Finish"
            steps[-1]["action_arg"] = response[:100]

        return steps

    # ------------------------------------------------------------------
    # Lazy loaders
    # ------------------------------------------------------------------

    def _get_extractor(self) -> ChatFeatureExtractor:
        if self._extractor is None:
            self._extractor = ChatFeatureExtractor()
        return self._extractor

    def _get_client(self):
        """Return an anthropic.Anthropic client, or None if no key is set."""
        if self._client is not None:
            return self._client
        if not self._api_key:
            return None
        try:
            import anthropic
            self._client = anthropic.Anthropic(api_key=self._api_key)
        except ImportError:
            logger.warning("anthropic SDK not installed; synthetic_trace mode unavailable.")
            return None
        return self._client
