| import json |
| import os |
| import re |
| from typing import Any, Dict, List, Optional |
|
|
| import anthropic |
| import numpy as np |
| import pandas as pd |
| from model import FEATURES, FEATURE_DISPLAY_NAMES, QUALITY_LABELS, predict |
| from tabpfn_client import TabPFNClassifier |
|
|
| MODEL = "claude-sonnet-4-6" |
|
|
|
|
| def _anthropic_client(api_key: Optional[str] = None) -> anthropic.Anthropic: |
| resolved = (api_key or "").strip() or os.environ.get("ANTHROPIC_API_KEY", "").strip() |
| if not resolved: |
| raise RuntimeError("Anthropic API key is required.") |
| return anthropic.Anthropic(api_key=resolved) |
|
|
| EXTRACTION_SYSTEM = """You are a wine chemistry assistant. Extract physicochemical measurements from |
| the user's natural-language wine description and return ONLY a JSON object with these keys: |
| |
| fixed_acidity, volatile_acidity, citric_acid, residual_sugar, chlorides, |
| free_sulfur_dioxide, total_sulfur_dioxide, density, pH, sulphates, alcohol, wine_type |
| |
| wine_type: 0 for red, 1 for white. Use null for values the user did not mention. |
| |
| Output rules: respond with raw JSON only β no markdown fences, no commentary.""" |
|
|
| DEFAULTS = { |
| "fixed_acidity": 7.0, |
| "volatile_acidity": 0.33, |
| "citric_acid": 0.32, |
| "residual_sugar": 5.4, |
| "chlorides": 0.056, |
| "free_sulfur_dioxide": 30.0, |
| "total_sulfur_dioxide": 115.0, |
| "density": 0.994, |
| "pH": 3.21, |
| "sulphates": 0.53, |
| "alcohol": 10.5, |
| "wine_type": 1, |
| } |
|
|
|
|
| def _response_text(response) -> str: |
| """Concatenate all text blocks from an Anthropic message response.""" |
| parts = [] |
| for block in response.content: |
| if getattr(block, "type", None) == "text": |
| parts.append(block.text) |
| return "\n".join(parts).strip() |
|
|
|
|
| def _parse_json_object(text: str) -> dict: |
| """Parse a JSON object from model output (handles fences and preamble).""" |
| if not text: |
| raise ValueError("Claude returned an empty response during feature extraction.") |
|
|
| candidate = text.strip() |
| fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", candidate, re.DOTALL | re.IGNORECASE) |
| if fenced: |
| candidate = fenced.group(1).strip() |
| else: |
| start = candidate.find("{") |
| end = candidate.rfind("}") |
| if start != -1 and end != -1 and end > start: |
| candidate = candidate[start : end + 1] |
|
|
| try: |
| parsed = json.loads(candidate) |
| except json.JSONDecodeError as exc: |
| preview = text[:200].replace("\n", " ") |
| raise ValueError( |
| "Could not parse feature JSON from Claude. " |
| f"Preview: {preview!r}" |
| ) from exc |
|
|
| if not isinstance(parsed, dict): |
| raise ValueError("Claude feature extraction did not return a JSON object.") |
| return parsed |
|
|
|
|
| def extract_features( |
| user_text: str, |
| anthropic_api_key: Optional[str] = None, |
| ) -> tuple[dict, list[str]]: |
| """Parse user description into a feature dict. Returns (features, assumed_fields).""" |
| text = "" |
| for attempt in range(2): |
| response = _anthropic_client(anthropic_api_key).messages.create( |
| model=MODEL, |
| max_tokens=512, |
| system=EXTRACTION_SYSTEM, |
| messages=[{"role": "user", "content": user_text}], |
| ) |
| text = _response_text(response) |
| if text: |
| break |
| raw = _parse_json_object(text) |
| assumed = [] |
| features = {} |
| for f in FEATURES: |
| val = raw.get(f) |
| if val is None: |
| features[f] = DEFAULTS[f] |
| assumed.append(FEATURE_DISPLAY_NAMES[f]) |
| else: |
| features[f] = float(val) |
| return features, assumed |
|
|
|
|
| ADVICE_SYSTEM = """You are a helpful wine quality consultant. Given physicochemical measurements |
| of a wine, a TabPFN model's predicted quality class (Low/Medium/High), its confidence scores, |
| and a list of counterfactual probes, write a concise plain-English response (3β5 sentences) that: |
| 1. States the predicted quality and confidence. |
| 2. Highlights the 1β2 most influential factors. |
| 3. Gives one concrete actionable suggestion for improvement. |
| Keep the tone friendly and accessible to non-chemists.""" |
|
|
|
|
| def generate_advice( |
| features: dict, |
| prediction: str, |
| probabilities: dict, |
| counterfactuals: list[dict], |
| anthropic_api_key: Optional[str] = None, |
| ) -> str: |
| context = { |
| "features": features, |
| "prediction": prediction, |
| "probabilities": probabilities, |
| "counterfactuals": counterfactuals, |
| } |
| response = _anthropic_client(anthropic_api_key).messages.create( |
| model=MODEL, |
| max_tokens=512, |
| system=ADVICE_SYSTEM, |
| messages=[{"role": "user", "content": json.dumps(context)}], |
| ) |
| return response.content[0].text |
|
|
|
|
| def run_counterfactuals(clf: TabPFNClassifier, features: dict) -> list[dict]: |
| """Probe a few key dimensions Β±10% β batched into one predict_proba call.""" |
| from model import FEATURES, QUALITY_LABELS |
|
|
| probe_keys = ["alcohol", "volatile_acidity", "sulphates", "residual_sugar"] |
| rows = [] |
| meta = [] |
| for key in probe_keys: |
| original_val = features[key] |
| for delta_pct, label in [(+0.10, "+10%"), (-0.10, "β10%")]: |
| modified = dict(features) |
| modified[key] = original_val * (1 + delta_pct) |
| rows.append([modified[f] for f in FEATURES]) |
| meta.append((key, label, round(modified[key], 4))) |
|
|
| probs_all = clf.predict_proba(np.array(rows, dtype=float)) |
| results = [] |
| for (key, label, new_value), probs in zip(meta, probs_all): |
| pred_idx = int(np.argmax(probs)) |
| prob_map = {QUALITY_LABELS[i]: float(probs[i]) for i in range(len(probs))} |
| results.append({ |
| "feature": FEATURE_DISPLAY_NAMES[key], |
| "change": label, |
| "new_value": new_value, |
| "prediction": QUALITY_LABELS[pred_idx], |
| "probabilities": prob_map, |
| }) |
| return results |
|
|
|
|
| INTERPRETATION_SYSTEM = """You are a wine educator explaining machine-learning results to a curious non-expert. |
| |
| You will receive JSON with TabPFN prediction outputs and shapiq Shapley feature attributions. |
| Write a clear plain-English summary (about 4β6 short paragraphs) covering: |
| |
| 1. **The prediction** β the quality class, confidence (probabilities), and what the estimated 0β10 quality index means in everyday wine terms. |
| 2. **The waterfall chart** β explain that the model starts from an average baseline probability for this class, then each measured feature pushes that probability up (positive Shapley) or down (negative). Name the top 2β3 features helping quality and the top 2β3 holding it back, using accessible wine language. |
| 3. **Interactions** β if only first-order Shapley values are provided (no pairwise terms), say this view treats each factor independently; the model may still combine them internally. |
| 4. **Ground truth** (only if present) β how the prediction compares to the actual score and what that gap might suggest. |
| 5. **What-if probes** (only if present) β briefly note which small changes would most likely shift the prediction. |
| |
| Rules: |
| - Use only numbers from the input; do not invent measurements or probabilities. |
| - Avoid jargon like "Shapley" or "coalition" in the main text β say "contribution" or "push" instead. |
| - Friendly, concise tone. No bullet lists longer than 3 items.""" |
|
|
|
|
| def _features_for_prompt(features: dict) -> dict: |
| return {FEATURE_DISPLAY_NAMES[k]: round(float(features[k]), 4) for k in FEATURES} |
|
|
|
|
| def generate_layman_explanation( |
| features: dict, |
| prediction: str, |
| probabilities: dict, |
| quality_index: float, |
| attributions: pd.DataFrame, |
| shapley_meta: dict, |
| *, |
| user_description: Optional[str] = None, |
| ground_truth_class: Optional[str] = None, |
| ground_truth_index: Optional[int] = None, |
| counterfactuals: Optional[List[dict]] = None, |
| assumed_fields: Optional[List[str]] = None, |
| anthropic_api_key: Optional[str] = None, |
| ) -> str: |
| """Explain prediction + Shapley attributions in accessible language.""" |
| shapley_rows = [] |
| if attributions is not None and not attributions.empty and "Shapley" in attributions.columns: |
| shapley_rows = attributions[["Feature", "Shapley", "Effect"]].to_dict(orient="records") |
|
|
| context: Dict[str, Any] = { |
| "user_description": user_description, |
| "assumed_fields": assumed_fields or [], |
| "measurements": _features_for_prompt(features), |
| "prediction": { |
| "quality_class": prediction, |
| "probabilities": probabilities, |
| "estimated_quality_index_0_to_10": quality_index, |
| }, |
| "shapley_attribution": { |
| **shapley_meta, |
| "contributions": shapley_rows, |
| "how_to_read_waterfall": ( |
| "Baseline is the average model score for this quality class; " |
| "each bar shows how a feature moves that score up or down." |
| ), |
| }, |
| } |
| if ground_truth_class is not None: |
| context["ground_truth"] = { |
| "quality_class": ground_truth_class, |
| "quality_index": ground_truth_index, |
| } |
| if counterfactuals: |
| context["what_if_probes"] = counterfactuals |
|
|
| response = _anthropic_client(anthropic_api_key).messages.create( |
| model=MODEL, |
| max_tokens=900, |
| system=INTERPRETATION_SYSTEM, |
| messages=[{"role": "user", "content": json.dumps(context, indent=2)}], |
| ) |
| return response.content[0].text |
|
|
|
|
| def analyse_wine(clf: TabPFNClassifier, user_text: str) -> tuple[str, dict, float, int, list[str], str, dict]: |
| """Full agentic pipeline. Returns (prediction, probabilities, quality_index, pred_idx, assumed, advice, features).""" |
| features, assumed = extract_features(user_text) |
| prediction, probabilities, quality_index, pred_idx = predict(clf, features) |
| counterfactuals = run_counterfactuals(clf, features) |
| advice = generate_advice(features, prediction, probabilities, counterfactuals) |
| return prediction, probabilities, quality_index, pred_idx, assumed, advice, features |
|
|