1 line
28 KiB
JSON
1 line
28 KiB
JSON
{"repo": "areebahmer936/lens-vare", "files": {"app.py": {"fb62ba9a55e55b7b8227db80be920bbafdb695e9": {"t": 1785674495.2492673, "n": 22741, "h": "547c2c23ea09bb9b", "c": "\"\"\"\nAudit Data Intelligence — Demo\n================================\nNatural language queries over Excel audit data using Qwen / Kimi / open models.\n\nPipeline: Question → SQL (T=0) → Execute on real data → Grounded prose\nGuarantee: The response layer only sees rows the SQL actually returned.\n If a number isn't in the result set, the model cannot say it.\n\"\"\"\n\nimport re\nimport json\nimport sqlite3\nimport traceback\n\nimport pandas as pd\nimport streamlit as st\nfrom openai import OpenAI\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# PROVIDERS\n# All use the OpenAI-compatible chat completions interface.\n# ──────────────────────────────────────────────────────────────────────────────\nPROVIDERS: dict[str, dict] = {\n \"DashScope (Qwen)\": {\n \"base_url\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"models\": [\n \"qwen3-235b-a22b\", # strongest, extended thinking\n \"qwen3-30b-a3b\",\n \"qwen-max\",\n \"qwen-plus\",\n \"qwen-turbo\",\n \"qwen2.5-72b-instruct\",\n \"qwen2.5-32b-instruct\",\n ],\n \"key_url\": \"https://dashscope.console.aliyun.com/apiKey\",\n \"note\": \"Qwen3-235B-A22B is the flagship model with extended thinking.\",\n },\n \"Moonshot (Kimi)\": {\n \"base_url\": \"https://api.moonshot.cn/v1\",\n \"models\": [\n \"moonshot-v1-128k\",\n \"moonshot-v1-32k\",\n \"moonshot-v1-8k\",\n ],\n \"key_url\": \"https://platform.moonshot.cn/console/api-keys\",\n \"note\": \"moonshot-v1-128k handles large datasets with a 128k context window.\",\n },\n \"OpenRouter (all models)\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n \"qwen/qwen3-235b-a22b\",\n \"qwen/qwen3-32b\",\n \"qwen/qwen2.5-72b-instruct\",\n \"moonshotai/kimi-dev-72b\",\n \"meta-llama/llama-3.3-70b-instruct\",\n \"deepseek/deepseek-r1\",\n \"mistralai/mistral-large\",\n ],\n \"key_url\": \"https://openrouter.ai/keys\",\n \"note\": \"One key for every model. Best for comparing providers.\",\n },\n \"Together.ai\": {\n \"base_url\": \"https://api.together.xyz/v1\",\n \"models\": [\n \"Qwen/Qwen3-235B-A22B\",\n \"Qwen/Qwen2.5-72B-Instruct-Turbo\",\n \"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo\",\n \"deepseek-ai/DeepSeek-R1\",\n ],\n \"key_url\": \"https://api.together.ai/settings/api-keys\",\n \"note\": \"Low-latency inference for open-weight models.\",\n },\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# DATA LAYER — Excel → SQLite\n# ──────────────────────────────────────────────────────────────────────────────\n\ndef _safe_col(name: str) -> str:\n \"\"\"Convert an arbitrary Excel header to a valid SQL identifier.\"\"\"\n s = re.sub(r\"[^\\w]\", \"_\", name.strip())\n s = re.sub(r\"_+\", \"_\", s).strip(\"_\")\n return (\"col_\" + s) if s and s[0].isdigit() else (s or \"unnamed\")\n\n\ndef load_excel(file) -> tuple[pd.DataFrame, sqlite3.Connection, dict]:\n \"\"\"\n Read the uploaded Excel into a pandas DataFrame, then persist it to an\n in-memory SQLite database as the table 'audit_data'.\n\n Returns\n -------\n df : cleaned DataFrame (column names are SQL-safe)\n conn : sqlite3 connection (check_same_thread=False for Streamlit)\n col_map : {original header → sql column name}\n \"\"\"\n fname = getattr(file, \"name\", \"\")\n engine = \"xlrd\" if fname.endswith(\".xls\") else \"openpyxl\"\n raw = pd.read_excel(file, engine=engine)\n\n originals = raw.columns.tolist()\n seen: dict[str, int] = {}\n clean: list[str] = []\n for c in originals:\n base = _safe_col(str(c))\n if base in seen:\n seen[base] += 1\n clean.append(f\"{base}_{seen[base]}\")\n else:\n seen[base] = 0\n clean.append(base)\n\n raw.columns = clean\n col_map = dict(zip(originals, clean))\n\n conn = sqlite3.connect(\":memory:\", check_same_thread=False)\n raw.to_sql(\"audit_data\", conn, if_exists=\"replace\", index=False)\n return raw, conn, col_map\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# SCHEMA LAYER — Auto-generate rich column context\n#\n# Why this matters:\n# Listing every actual value for categorical columns (e.g., Risk Rating:\n# HIGH | MEDIUM | LOW) means the model can't invent \"CRITICAL\" or \"EXTREME\".\n# Numeric range/mean lets the model sanity-check its own SQL aggregations.\n# This is the entire foundation of hallucination prevention.\n# ──────────────────────────────────────────────────────────────────────────────\n\ndef build_schema(df: pd.DataFrame) -> str:\n \"\"\"\n Build a plain-text schema description that the LLM receives as system\n context on every call. Kept under ~3 KB so it doesn't eat context budget.\n \"\"\"\n lines = [\n \"=== SQLITE DATABASE SCHEMA ===\",\n \"TABLE NAME : audit_data\",\n f\"TOTAL ROWS : {len(df):,}\",\n \"\",\n \"COLUMNS (use these EXACT names in every SQL query)\",\n \"─\" * 60,\n ]\n\n for col in df.columns:\n s = df[col]\n non_null = int(s.notna().sum())\n lines.append(f\"\\n{col}\")\n\n if pd.api.types.is_numeric_dtype(s.dtype):\n lines.append(f\" type : numeric\")\n if non_null:\n lines.append(f\" range : {s.min()} → {s.max()}\")\n lines.append(f\" mean : {s.mean():.2f}\")\n lines.append(f\" non-null : {non_null:,} / {len(df):,}\")\n\n elif pd.api.types.is_datetime64_any_dtype(s.dtype):\n lines.append(f\" type : datetime\")\n if non_null:\n lines.append(f\" range : {s.min().date()} → {s.max().date()}\")\n\n else:\n uniq = s.dropna().unique()\n n = len(uniq)\n lines.append(f\" type : text ({n} distinct values)\")\n if n <= 40:\n # List every valid value — model cannot fabricate anything else\n vals = \" | \".join(sorted(str(v) for v in uniq))\n lines.append(f\" values : {vals}\")\n else:\n top = s.dropna().value_counts().head(8).index.tolist()\n lines.append(f\" top-8 : {' | '.join(str(v) for v in top)}\")\n lines.append(f\" non-null : {non_null:,} / {len(df):,}\")\n\n lines += [\n \"\",\n \"─\" * 60,\n \"SQL NOTES\",\n \" • Partial text : column LIKE '%value%'\",\n \" • Case-insensitive : LOWER(column) LIKE LOWER('%value%')\",\n \" • NULL check : column IS NULL / IS NOT NULL\",\n \" • Aggregation : COUNT(*), SUM(col), AVG(col), GROUP BY col\",\n \" • Sorting : ORDER BY col DESC/ASC LIMIT n\",\n \"=== END SCHEMA ===\",\n ]\n return \"\\n\".join(lines)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# LLM UTILITIES\n# ──────────────────────────────────────────────────────────────────────────────\n\ndef _strip_think(text: str) -> str:\n \"\"\"Remove Qwen3 / DeepSeek-R1 chain-of-thought blocks before returning.\"\"\"\n return re.sub(r\"<think>.*?</think>\", \"\", text, flags=re.DOTALL).strip()\n\n\ndef _extract_sql(raw: str) -> str:\n \"\"\"Pull clean SQL out of potentially markdown-wrapped LLM output.\"\"\"\n s = re.sub(r\"```[a-z]*\\n?\", \"\", raw).replace(\"```\", \"\").strip()\n m = re.search(r\"(SELECT\\b.+)\", s, re.IGNORECASE | re.DOTALL)\n return m.group(1).strip() if m else s.strip()\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# STEP 1 — Question → SQL\n# ──────────────────────────────────────────────────────────────────────────────\n\ndef gen_sql(\n client: OpenAI,\n model: str,\n schema: str,\n question: str,\n history: list[dict],\n) -> str:\n \"\"\"\n Ask the model to produce a SQLite SELECT query.\n Temperature = 0 → deterministic, no creativity.\n History injects the last 3 Q→SQL pairs so follow-up questions work\n (e.g., \"now break that down by department\" after a count question).\n \"\"\"\n system = f\"\"\"You are a precise SQLite query generator for an audit management system.\n\n{schema}\n\nSTRICT OUTPUT RULES:\n1. Return ONLY the raw SQL SELECT statement — nothing else.\n2. No markdown, no explanation, no comments.\n3. Use the exact column names listed in the schema.\n4. For text matching: use LIKE or exact equality depending on context.\n5. For \"why\" / \"what causes\" questions: SELECT all relevant columns including\n any root cause, department, risk rating, and description columns.\n6. Never use INSERT, UPDATE, DELETE, DROP, CREATE, or any mutating statement.\n7. If the question cannot be answered with SQL, write:\n SELECT 'Cannot answer this with SQL' AS message;\"\"\"\n\n msgs = [{\"role\": \"system\", \"content\": system}]\n for h in history[-3:]:\n msgs.append({\"role\": \"user\", \"content\": h[\"q\"]})\n msgs.append({\"role\": \"assistant\", \"content\": h[\"sql\"]})\n msgs.append({\"role\": \"user\", \"content\": question})\n\n resp = client.chat.completions.create(\n model=model, messages=msgs, temperature=0.0, max_tokens=600,\n )\n raw = _strip_think(resp.choices[0].message.content)\n return _extract_sql(raw)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# STEP 2 — Execute SQL\n# ──────────────────────────────────────────────────────────────────────────────\n\ndef run_sql(\n conn: sqlite3.Connection, sql: str\n) -> tuple[list[dict], str | None]:\n \"\"\"Execute SQL; returns (rows_as_dicts, error_message_or_None).\"\"\"\n try:\n cur = conn.cursor()\n cur.execute(sql)\n if not cur.description:\n return [], None\n cols = [d[0] for d in cur.description]\n return [dict(zip(cols, row)) for row in cur.fetchall()], None\n except Exception as exc:\n return [], str(exc)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# STEP 3 — SQL results → Natural language response\n# ──────────────────────────────────────────────────────────────────────────────\n\ndef gen_response(\n client: OpenAI,\n model: str,\n question: str,\n sql: str,\n results: list[dict],\n) -> str:\n \"\"\"\n Turn raw SQL rows into professional prose.\n The model is given ONLY the rows that SQL returned — it cannot reference\n anything that wasn't in those rows.\n \"\"\"\n n = len(results)\n if n == 0:\n data_str = \"The query returned zero records.\"\n elif n <= 60:\n data_str = json.dumps(results, default=str, indent=2)\n else:\n data_str = (\n f\"Total matching records: {n}\\n\\n\"\n f\"First 50 records (representative sample):\\n\"\n + json.dumps(results[:50], default=str, indent=2)\n + f\"\\n\\n[... {n - 50} additional records with similar structure]\"\n )\n\n system = \"\"\"You are a senior audit partner presenting findings to the audit committee.\n\nABSOLUTE RULES — violating any of these is unacceptable:\n• Every number or fact you state must come directly from the query results.\n• Do NOT invent risks, departments, percentages, or root causes not present in the data.\n• Write flowing professional prose — 2 to 4 paragraphs, no bullet lists.\n• Use specific figures: exact counts, percentages calculated from the data, named departments.\n• For \"why\" or root-cause questions: analyze the actual text in Root_Cause (or similar)\n columns from the rows returned — synthesize patterns across multiple entries.\n• If zero records matched, say exactly that and note what filter was applied.\n• Never pad with generic audit boilerplate that isn't supported by the data.\"\"\"\n\n user = (\n f\"Question asked: {question}\\n\\n\"\n f\"SQL that was executed:\\n{sql}\\n\\n\"\n f\"Data returned ({n} records):\\n{data_str}\\n\\n\"\n \"Write a concise, grounded analytical response to the question \"\n \"using only the data above.\"\n )\n\n resp = client.chat.completions.create(\n model=model,\n messages=[\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user},\n ],\n temperature=0.3,\n max_tokens=1400,\n )\n return _strip_think(resp.choices[0].message.content)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# STREAMLIT UI\n# ──────────────────────────────────────────────────────────────────────────────\n\nst.set_page_config(\n page_title=\"Audit Intelligence\",\n page_icon=\"🔍\",\n layout=\"wide\",\n initial_sidebar_state=\"expanded\",\n)\n\nst.title(\"🔍 Audit Data Intelligence\")\nst.caption(\n \"Upload Excel → ask natural language questions → get analysis grounded in real data. \"\n \"No hallucination: every number in the response came from an actual SQL query.\"\n)\n\n# ── Sidebar ───────────────────────────────────────────────────────────────────\nwith st.sidebar:\n st.header(\"⚙️ Model Configuration\")\n\n provider_name = st.selectbox(\"Provider\", list(PROVIDERS.keys()))\n prov = PROVIDERS[provider_name]\n\n st.caption(prov[\"note\"])\n st.markdown(f\"[→ Get API key]({prov['key_url']})\")\n\n api_key = st.text_input(\"API Key\", type=\"password\", placeholder=\"sk-…\")\n model = st.selectbox(\"Model\", prov[\"models\"])\n\n if \"qwen3\" in model.lower() or \"deepseek-r1\" in model.lower():\n st.info(\n \"This model uses extended thinking (chain-of-thought). \"\n \"The reasoning is stripped before displaying the answer.\"\n )\n\n st.divider()\n st.header(\"📁 Data\")\n uploaded = st.file_uploader(\n \"Excel file (.xlsx / .xls)\",\n type=[\"xlsx\", \"xls\"],\n help=\"500+ row files work fine. The data stays local in SQLite.\",\n )\n\n if st.button(\"🗑️ Clear conversation\", use_container_width=True):\n st.session_state.msgs = []\n st.session_state.hist = []\n st.rerun()\n\n# ── Session state defaults ────────────────────────────────────────────────────\nfor key, val in [\n (\"df\", None),\n (\"conn\", None),\n (\"schema\", None),\n (\"fname\", None),\n (\"msgs\", []),\n (\"hist\", []),\n]:\n if key not in st.session_state:\n st.session_state[key] = val\n\n# ── Load Excel on first upload or file change ─────────────────────────────────\nif uploaded and uploaded.name != st.session_state.fname:\n with st.spinner(f\"Loading {uploaded.name}…\"):\n df, conn, col_map = load_excel(uploaded)\n schema = build_schema(df)\n st.session_state.df = df\n st.session_state.conn = conn\n st.session_state.schema = schema\n st.session_state.fname = uploaded.name\n st.session_state.msgs = []\n st.session_state.hist = []\n st.sidebar.success(f\"✅ {len(df):,} records, {len(df.columns)} columns\")\n\n# ── Nothing uploaded yet ──────────────────────────────────────────────────────\nif st.session_state.df is None:\n st.info(\"⬅️ Upload an Excel file from the sidebar to begin.\")\n\n with st.expander(\"📐 Architecture — how zero-hallucination works\"):\n st.markdown(\"\"\"\n**Step 1 — Schema generation (what you're asking about)**\n\nWhen you upload Excel, the app reads every column header and infers its type.\nFor categorical columns with ≤40 unique values it records **every single valid\nvalue** — so if `Risk_Rating` only ever contains `HIGH`, `MEDIUM`, `LOW`, the\nmodel is told exactly that. It cannot fabricate `CRITICAL` or `VERY HIGH`\nbecause those strings don't appear in the schema it received.\nFor numeric columns it computes min, max, and mean so the model can\nsanity-check its own aggregation SQL.\n\nThe schema is injected as system context on every call. It's typically 1–3 KB,\nso it costs very little context budget.\n\n---\n\n**Step 2 — Text → SQL (temperature = 0)**\n\nYour question + the schema go to the model. It outputs **only a SQL SELECT\nstatement**, nothing else. Temperature zero means deterministic — no creative\nliberties taken. The model cannot invent a number it didn't query for because\nit hasn't seen the data yet.\n\n---\n\n**Step 3 — SQL executes against real rows**\n\nThe SQL runs on your actual data in SQLite. The model sees only the rows it\nasked for. Zero rows returned → the response layer says \"no records matched\"\nrather than guessing.\n\n---\n\n**Step 4 — Rows → Grounded prose**\n\nThe response prompt receives only the query result rows and the original\nquestion. Every figure in the answer is traceable to a specific row.\n\n---\n\n**Minimal production architecture (integrating with your existing system)**\n\n```\n ┌─────────────────────────────────────────────────────────────┐\n │ Existing .NET / SQL Server system (unchanged) │\n └────────────────────────┬────────────────────────────────────┘\n │ read-only SELECT only\n ┌───────────▼───────────┐\n │ SQL MCP Server │ tiny Node.js or Python\n │ (sidecar process) │ sidecar — no DB changes\n └───────────┬───────────┘\n │\n ┌───────────▼───────────┐\n │ schema.yaml │ maintained by audit team,\n │ (static context) │ describes columns in\n │ │ business language, not\n │ tables, columns, │ just SQL types\n │ value enumerations, │\n │ join patterns, │\n │ business glossary │\n └───────────┬───────────┘\n │\n ┌───────────▼───────────┐\n │ Agent endpoint │ single new POST /ask\n │ on your existing API │ endpoint; frontend\n │ │ unchanged\n │ schema + question │\n │ → SQL tool │\n │ → real rows │\n │ → prose response │\n └───────────────────────┘\n```\n\nThe schema file is the most important piece. Instead of `RiskRating TEXT`,\nwrite: *\"RiskRating — composite Likelihood × Impact score; values: HIGH,\nMEDIUM, LOW; used to prioritise remediation.\"* That 20-word description\neliminates an entire class of SQL generation errors.\n \"\"\")\n\n st.stop()\n\n# ── Metrics row ───────────────────────────────────────────────────────────────\ndf = st.session_state.df\nc1, c2, c3, c4 = st.columns(4)\nc1.metric(\"Records\", f\"{len(df):,}\")\nc2.metric(\"Columns\", len(df.columns))\nc3.metric(\"File\", st.session_state.fname or \"—\")\nc4.metric(\"Turns\", len(st.session_state.msgs) // 2)\n\nwith st.expander(\"📋 Auto-generated schema (what the model receives as context)\"):\n st.code(st.session_state.schema, language=\"text\")\n\nst.divider()\n\n# ── Chat history ──────────────────────────────────────────────────────────────\nfor msg in st.session_state.msgs:\n with st.chat_message(msg[\"role\"]):\n st.write(msg[\"content\"])\n if msg.get(\"sql\"):\n with st.expander(\"🔎 SQL that was executed\"):\n st.code(msg[\"sql\"], language=\"sql\")\n if msg.get(\"n\") is not None:\n st.caption(f\"Response grounded in {msg['n']:,} matching records\")\n\n# ── Guard: need API key ───────────────────────────────────────────────────────\nif not api_key:\n st.warning(\"🔑 Enter your API key in the sidebar to start querying.\")\n st.stop()\n\n# ── Sample questions (shown only before first message) ───────────────────────\nif not st.session_state.msgs:\n st.markdown(\"**Sample questions to try:**\")\n st.markdown(\n \"- *How many high risk findings are there?* \\n\"\n \"- *Which departments have the most risks, and what are the root causes?* \\n\"\n \"- *Why are risks in the Shared Services department rated high?* \\n\"\n \"- *How many findings are overdue and who owns them?* \\n\"\n \"- *Break down risk count by department and rating*\"\n )\n\n# ── Chat input ────────────────────────────────────────────────────────────────\nquestion = st.chat_input(\"Ask anything about your audit data…\")\nif not question:\n st.stop()\n\n# User bubble\nst.session_state.msgs.append({\"role\": \"user\", \"content\": question})\nwith st.chat_message(\"user\"):\n st.write(question)\n\n# Assistant bubble\nwith st.chat_message(\"assistant\"):\n try:\n client = OpenAI(api_key=api_key, base_url=prov[\"base_url\"])\n\n with st.status(\"Analysing…\", expanded=True) as status:\n\n # ── Step 1: generate SQL ──────────────────────────────────────\n status.write(\"⚙️ Generating SQL query from your question…\")\n sql = gen_sql(\n client, model,\n st.session_state.schema,\n question,\n st.session_state.hist,\n )\n\n # ── Step 2: execute ───────────────────────────────────────────\n status.write(\"🗄️ Executing query against your data…\")\n results, err = run_sql(st.session_state.conn, sql)\n\n if err:\n # One automatic retry with the error fed back\n status.write(f\"⚠️ SQL error — retrying ({err[:80]}…)\")\n sql = gen_sql(\n client, model,\n st.session_state.schema,\n f\"Question: {question}\\n\\n\"\n f\"Your previous SQL failed with: {err}\\n\"\n \"Generate a corrected SQL query.\",\n [], # no history context on retry\n )\n results, err = run_sql(st.session_state.conn, sql)\n\n # ── Step 3: generate response ─────────────────────────────────\n status.write(\"✍️ Generating analytical response…\")\n if err:\n answer = (\n f\"I was unable to query the data after two attempts.\\n\\n\"\n f\"**Last error:** `{err}`\\n\\n\"\n f\"**SQL attempted:**\\n```sql\\n{sql}\\n```\"\n )\n n_rows = 0\n else:\n answer = gen_response(client, model, question, sql, results)\n n_rows = len(results)\n\n status.update(label=\"Done\", state=\"complete\", expanded=False)\n\n # Display\n st.write(answer)\n with st.expander(\"🔎 SQL that was executed\"):\n st.code(sql, language=\"sql\")\n st.caption(f\"Response grounded in {n_rows:,} matching records from your data\")\n\n # Save to history\n st.session_state.msgs.append({\n \"role\": \"assistant\",\n \"content\": answer,\n \"sql\": sql,\n \"n\": n_rows,\n })\n st.session_state.hist.append({\"q\": question, \"sql\": sql})\n\n except Exception:\n st.error(\"An unexpected error occurred:\")\n st.code(traceback.format_exc())\n"}}}} |