CoolFace
Apppublic

ArchCoder/llm-excel-plotter-agent

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
llm_agent.py360 linesDownload Raw Back to root
1import ast2import difflib3import json4import logging5import os6import re7import time8 9from dotenv import load_dotenv10 11from chart_generator import ChartGenerator12from data_processor import DataProcessor13 14load_dotenv()15 16logger = logging.getLogger(__name__)17 18# ---------------------------------------------------------------------------19# Model IDs (downloaded at Docker build, cached in HF_HOME)20# ---------------------------------------------------------------------------21QWEN_MODEL_ID = os.getenv("QWEN_MODEL_ID", "Qwen/Qwen2.5-Coder-0.5B-Instruct")22BART_MODEL_ID = os.getenv("BART_MODEL_ID", "ArchCoder/fine-tuned-bart-large")23 24# ---------------------------------------------------------------------------25# Prompt templates with few-shot examples26# ---------------------------------------------------------------------------27 28_SYSTEM_PROMPT = """\29You are a data visualization expert. Given the user request and dataset schema, \30output ONLY a valid JSON object. No explanation, no markdown fences, no extra text.31 32Required JSON keys:33  "x"          : string  — exact column name for the x-axis34  "y"          : array   — one or more exact column names for the y-axis35  "chart_type" : string  — one of: line, bar, scatter, pie, histogram, box, area36  "color"      : string or null — optional CSS color like "red", "#4f8cff"37 38Rules:39- Use ONLY column names from the schema. Never invent names.40- For pie charts: y must contain exactly one column.41- For histogram/box: x may equal the first element of y.42- Default to "line" if chart type is ambiguous.43 44### Examples45 46Example 1:47Schema: Year (integer), Sales (float), Profit (float)48User: "plot sales over the years with a red line"49Output: {"x": "Year", "y": ["Sales"], "chart_type": "line", "color": "red"}50 51Example 2:52Schema: Month (string), Revenue (float), Expenses (float)53User: "bar chart comparing revenue and expenses by month"54Output: {"x": "Month", "y": ["Revenue", "Expenses"], "chart_type": "bar", "color": null}55 56Example 3:57Schema: Category (string), Count (integer)58User: "pie chart of count by category"59Output: {"x": "Category", "y": ["Count"], "chart_type": "pie", "color": null}60 61Example 4:62Schema: Date (string), Temperature (float), Humidity (float)63User: "scatter plot of temperature vs humidity in blue"64Output: {"x": "Temperature", "y": ["Humidity"], "chart_type": "scatter", "color": "blue"}65 66Example 5:67Schema: Year (integer), Sales (float), Employee expense (float), Marketing expense (float)68User: "show me an area chart of sales and marketing expense over years"69Output: {"x": "Year", "y": ["Sales", "Marketing expense"], "chart_type": "area", "color": null}70"""71 72 73def _user_message(query: str, columns: list, dtypes: dict, sample_rows: list) -> str:74    schema = "\n".join(f"  - {c} ({dtypes.get(c, 'unknown')})" for c in columns)75    samples = "".join(f"  {json.dumps(r)}\n" for r in sample_rows[:3])76    return (77        f"Schema:\n{schema}\n\n"78        f"Sample rows:\n{samples}\n"79        f"User: \"{query}\"\n"80        f"Output:"81    )82 83 84# ---------------------------------------------------------------------------85# Output parsing & validation86# ---------------------------------------------------------------------------87 88def _parse_output(text: str):89    text = text.strip()90    if "```" in text:91        for part in text.split("```"):92            part = part.strip().lstrip("json").strip()93            if part.startswith("{"):94                text = part95                break96    try:97        return json.loads(text)98    except json.JSONDecodeError:99        pass100    try:101        return ast.literal_eval(text)102    except (SyntaxError, ValueError):103        pass104    return None105 106 107def _validate(args: dict, columns: list):108    if not isinstance(args, dict):109        return None110    if not all(k in args for k in ("x", "y", "chart_type")):111        return None112    if isinstance(args["y"], str):113        args["y"] = [args["y"]]114    valid = {"line", "bar", "scatter", "pie", "histogram", "box", "area"}115    if args["chart_type"] not in valid:116        args["chart_type"] = "line"117    if args["x"] not in columns:118        return None119    if not all(c in columns for c in args["y"]):120        return None121    return args122 123 124def _pick_chart_type(query: str) -> str:125    lowered = query.lower()126    aliases = {127        "scatter": ["scatter", "scatterplot"],128        "bar": ["bar", "column"],129        "pie": ["pie", "donut"],130        "histogram": ["histogram", "distribution"],131        "box": ["box", "boxplot"],132        "area": ["area"],133        "line": ["line", "trend", "over time", "over the years"],134    }135    for chart_type, keywords in aliases.items():136        if any(keyword in lowered for keyword in keywords):137            return chart_type138    return "line"139 140 141def _pick_color(query: str):142    lowered = query.lower()143    colors = [144        "red", "blue", "green", "yellow", "orange", "purple", "pink",145        "black", "white", "gray", "grey", "cyan", "teal", "indigo",146    ]147    for color in colors:148        if re.search(rf"\b{re.escape(color)}\b", lowered):149            return color150    return None151 152 153def _pick_columns(query: str, columns: list, dtypes: dict):154    lowered = query.lower()155    query_tokens = re.findall(r"[a-zA-Z0-9_]+", lowered)156 157    def score_column(column: str) -> float:158        col_lower = column.lower()159        score = 0.0160        if col_lower in lowered:161            score += 10.0162        for token in query_tokens:163            if token and token in col_lower:164                score += 2.0165        score += difflib.SequenceMatcher(None, lowered, col_lower).ratio()166        return score167 168    sorted_columns = sorted(columns, key=score_column, reverse=True)169    numeric_columns = [col for col in columns if dtypes.get(col) in {"integer", "float"}]170    temporal_columns = [col for col in columns if dtypes.get(col) == "datetime"]171    year_like = [col for col in columns if "year" in col.lower() or "date" in col.lower() or "month" in col.lower()]172 173    x_col = None174    for candidate in year_like + temporal_columns + sorted_columns:175        if candidate in columns:176            x_col = candidate177            break178    if x_col is None and columns:179        x_col = columns[0]180 181    y_candidates = [col for col in sorted_columns if col != x_col and col in numeric_columns]182    if not y_candidates:183        y_candidates = [col for col in numeric_columns if col != x_col]184    if not y_candidates:185        y_candidates = [col for col in columns if col != x_col]186 187    return x_col, y_candidates[:1]188 189 190def _heuristic_plot_args(query: str, columns: list, dtypes: dict) -> dict:191    x_col, y_cols = _pick_columns(query, columns, dtypes)192    if not x_col:193        x_col = "Year"194    if not y_cols:195        fallback_y = next((col for col in columns if col != x_col), columns[:1])196        y_cols = list(fallback_y) if isinstance(fallback_y, tuple) else fallback_y197        if isinstance(y_cols, str):198            y_cols = [y_cols]199    return {200        "x": x_col,201        "y": y_cols,202        "chart_type": _pick_chart_type(query),203        "color": _pick_color(query),204    }205 206 207# ---------------------------------------------------------------------------208# Agent209# ---------------------------------------------------------------------------210 211class LLM_Agent:212    def __init__(self, data_path=None):213        logger.info("Initializing LLM_Agent")214        self.data_processor = DataProcessor(data_path)215        self.chart_generator = ChartGenerator(self.data_processor.data)216        self._bart_tokenizer = None217        self._bart_model = None218        self._qwen_tokenizer = None219        self._qwen_model = None220 221    # -- model runners -------------------------------------------------------222 223    def _run_qwen(self, user_msg: str) -> str:224        """Qwen2.5-Coder-0.5B-Instruct — fast structured-JSON generation."""225        if self._qwen_model is None:226            from transformers import AutoModelForCausalLM, AutoTokenizer227            logger.info(f"Loading Qwen model: {QWEN_MODEL_ID}")228            self._qwen_tokenizer = AutoTokenizer.from_pretrained(QWEN_MODEL_ID)229            self._qwen_model = AutoModelForCausalLM.from_pretrained(QWEN_MODEL_ID)230            logger.info("Qwen model loaded.")231        messages = [232            {"role": "system", "content": _SYSTEM_PROMPT},233            {"role": "user",   "content": user_msg},234        ]235        text = self._qwen_tokenizer.apply_chat_template(236            messages, tokenize=False, add_generation_prompt=True237        )238        inputs = self._qwen_tokenizer(text, return_tensors="pt")239        outputs = self._qwen_model.generate(240            **inputs, max_new_tokens=256, temperature=0.1, do_sample=True241        )242        return self._qwen_tokenizer.decode(243            outputs[0][inputs.input_ids.shape[-1]:], skip_special_tokens=True244        )245 246    def _run_gemini(self, user_msg: str) -> str:247        import google.generativeai as genai248        api_key = os.getenv("GEMINI_API_KEY")249        if not api_key:250            raise ValueError("GEMINI_API_KEY is not set")251        genai.configure(api_key=api_key)252        model = genai.GenerativeModel(253            "gemini-2.0-flash",254            system_instruction=_SYSTEM_PROMPT,255        )256        return model.generate_content(user_msg).text257 258    def _run_grok(self, user_msg: str) -> str:259        from openai import OpenAI260        api_key = os.getenv("GROK_API_KEY")261        if not api_key:262            raise ValueError("GROK_API_KEY is not set")263        client = OpenAI(api_key=api_key, base_url="https://api.x.ai/v1")264        resp = client.chat.completions.create(265            model="grok-3-mini",266            messages=[267                {"role": "system", "content": _SYSTEM_PROMPT},268                {"role": "user",   "content": user_msg},269            ],270            max_tokens=256,271            temperature=0.1,272        )273        return resp.choices[0].message.content274 275    def _run_bart(self, query: str) -> str:276        """ArchCoder/fine-tuned-bart-large — lightweight Seq2Seq fallback."""277        if self._bart_model is None:278            from transformers import AutoModelForSeq2SeqLM, AutoTokenizer279            logger.info(f"Loading BART model: {BART_MODEL_ID}")280            self._bart_tokenizer = AutoTokenizer.from_pretrained(BART_MODEL_ID)281            self._bart_model = AutoModelForSeq2SeqLM.from_pretrained(BART_MODEL_ID)282            logger.info("BART model loaded.")283        inputs = self._bart_tokenizer(284            query, return_tensors="pt", max_length=512, truncation=True285        )286        outputs = self._bart_model.generate(**inputs, max_length=100)287        return self._bart_tokenizer.decode(outputs[0], skip_special_tokens=True)288 289    # -- main entry point ----------------------------------------------------290 291    def process_request(self, data: dict) -> dict:292        t0        = time.time()293        query     = data.get("query", "")294        data_path = data.get("file_path")295        model     = data.get("model", "qwen")296 297        if data_path and os.path.exists(data_path):298            self.data_processor  = DataProcessor(data_path)299            self.chart_generator = ChartGenerator(self.data_processor.data)300 301        columns     = self.data_processor.get_columns()302        dtypes      = self.data_processor.get_dtypes()303        sample_rows = self.data_processor.preview(3)304 305        default_args = {306            "x":          columns[0] if columns else "Year",307            "y":          [columns[1]] if len(columns) > 1 else ["Sales"],308            "chart_type": "line",309        }310 311        raw_text  = ""312        plot_args = None313        try:314            user_msg = _user_message(query, columns, dtypes, sample_rows)315            if   model == "gemini": raw_text = self._run_gemini(user_msg)316            elif model == "grok":   raw_text = self._run_grok(user_msg)317            elif model == "bart":   raw_text = self._run_bart(query)318            elif model == "qwen":319                try:320                    raw_text = self._run_qwen(user_msg)321                except Exception as qwen_exc:322                    logger.warning(f"Qwen failed, falling back to BART: {qwen_exc}")323                    raw_text = self._run_bart(query)324            else:325                raw_text = self._run_qwen(user_msg)326 327            logger.info(f"LLM [{model}] output: {raw_text}")328            parsed    = _parse_output(raw_text)329            plot_args = _validate(parsed, columns) if parsed else None330        except Exception as exc:331            logger.error(f"LLM error [{model}]: {exc}")332            raw_text = str(exc)333 334        if not plot_args:335            logger.warning("Falling back to heuristic plot args")336            plot_args = _validate(_heuristic_plot_args(query, columns, dtypes), columns) or default_args337 338        try:339            chart_result = self.chart_generator.generate_chart(plot_args)340            chart_path   = chart_result["chart_path"]341            chart_spec   = chart_result["chart_spec"]342        except Exception as exc:343            logger.error(f"Chart generation error: {exc}")344            return {345                "response":   f"Chart generation failed: {exc}",346                "chart_path": "",347                "chart_spec": None,348                "verified":   False,349                "plot_args":  plot_args,350            }351 352        logger.info(f"Request processed in {time.time() - t0:.2f}s")353        return {354            "response":   json.dumps(plot_args),355            "chart_path": chart_path,356            "chart_spec": chart_spec,357            "verified":   True,358            "plot_args":  plot_args,359        }360