Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .dockerignore +10 -0
- .env.example +29 -0
- .gitignore +7 -0
- Dockerfile +29 -0
- agent.py +303 -0
- app.py +172 -0
- config.py +66 -0
- db.py +222 -0
- monitoring.py +248 -0
- requirements.txt +6 -0
- run.ps1 +2 -0
- static/demo.html +35 -0
- static/embed-inline.html +279 -0
- static/index.html +200 -0
- static/metrics.html +153 -0
- static/preview.html +269 -0
- static/widget.js +285 -0
.dockerignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Never ship secrets, local logs, or downloaded binaries
|
| 2 |
+
.env
|
| 3 |
+
logs/
|
| 4 |
+
tools/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
docs/*.png
|
| 10 |
+
.git/
|
.env.example
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Database (SQL Server) ---
|
| 2 |
+
DB_SERVER=162.246.19.13
|
| 3 |
+
DB_PORT=1433
|
| 4 |
+
DB_NAME=IAmInterviewed_QA
|
| 5 |
+
DB_USER=iaiuser_qa
|
| 6 |
+
DB_PASSWORD=your_password_here
|
| 7 |
+
DB_DRIVER=ODBC Driver 18 for SQL Server
|
| 8 |
+
|
| 9 |
+
# --- LLM backend (OpenAI-compatible) — default: Groq free tier ---
|
| 10 |
+
# Get a free key at https://console.groq.com/keys
|
| 11 |
+
# Works with any OpenAI-compatible endpoint (Groq, ngrok AI Gateway, OpenAI, ...).
|
| 12 |
+
LLM_BASE_URL=https://api.groq.com/openai/v1
|
| 13 |
+
LLM_API_KEY=your_groq_key_here
|
| 14 |
+
LLM_MODEL=llama-3.3-70b-versatile
|
| 15 |
+
# Price per 1M tokens for the projected-cost metric
|
| 16 |
+
LLM_PRICE_IN=0.59
|
| 17 |
+
LLM_PRICE_OUT=0.79
|
| 18 |
+
|
| 19 |
+
# --- Monitoring --- set a token to protect /api/metrics (empty = open)
|
| 20 |
+
METRICS_TOKEN=
|
| 21 |
+
|
| 22 |
+
# --- Bot behaviour ---
|
| 23 |
+
# Max rows returned to the model from any single query
|
| 24 |
+
MAX_RESULT_ROWS=200
|
| 25 |
+
# Per-query timeout in seconds
|
| 26 |
+
QUERY_TIMEOUT=30
|
| 27 |
+
# Web origins allowed to embed/call the bot. "*" = any (dev only).
|
| 28 |
+
# In production set to your site: https://www.antern.com,https://antern.com
|
| 29 |
+
ALLOWED_ORIGINS=*
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
logs/
|
| 7 |
+
tools/
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Antern Bot — Hugging Face Spaces (Docker) deployment
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Microsoft ODBC Driver 18 for SQL Server (needed by pyodbc)
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
curl gnupg2 ca-certificates apt-transport-https unixodbc \
|
| 7 |
+
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
|
| 8 |
+
| gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
| 9 |
+
&& echo "deb [arch=amd64,arm64,armhf signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \
|
| 10 |
+
> /etc/apt/sources.list.d/mssql-release.list \
|
| 11 |
+
&& apt-get update \
|
| 12 |
+
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 \
|
| 13 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Writable log dir regardless of the runtime user
|
| 23 |
+
RUN mkdir -p /app/logs && chmod 777 /app/logs
|
| 24 |
+
|
| 25 |
+
# Hugging Face Spaces serves on port 7860
|
| 26 |
+
ENV PORT=7860
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
agent.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Antern Bot — natural-language-to-SQL agent over the IAmInterviewed_QA DB.
|
| 2 |
+
|
| 3 |
+
Talks to any OpenAI-compatible LLM endpoint (Groq, ngrok AI Gateway, OpenAI,
|
| 4 |
+
self-hosted, ...) in a manual tool-calling loop:
|
| 5 |
+
user question -> model writes T-SQL -> we run it READ-ONLY -> model explains.
|
| 6 |
+
|
| 7 |
+
The schema is placed in the system instruction so the model knows the tables,
|
| 8 |
+
columns, and joins available. Two tools are exposed: `run_sql` (run a read-only
|
| 9 |
+
query) and `present` (choose how to display the result: table / chart).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
from openai import OpenAI
|
| 16 |
+
|
| 17 |
+
import config
|
| 18 |
+
import db
|
| 19 |
+
|
| 20 |
+
# How many result rows to show the MODEL (the full set still goes to the
|
| 21 |
+
# frontend). Keeps the prompt small and cheap.
|
| 22 |
+
MODEL_ROW_PREVIEW = 12
|
| 23 |
+
MAX_TOOL_ITERATIONS = 8
|
| 24 |
+
|
| 25 |
+
RUN_SQL_TOOL = {
|
| 26 |
+
"type": "function",
|
| 27 |
+
"function": {
|
| 28 |
+
"name": "run_sql",
|
| 29 |
+
"description": (
|
| 30 |
+
"Run a single READ-ONLY T-SQL SELECT query against the "
|
| 31 |
+
"IAmInterviewed_QA SQL Server database and return the rows. Only "
|
| 32 |
+
"SELECT / WITH queries are permitted. Use this whenever you need "
|
| 33 |
+
"data to answer the user."
|
| 34 |
+
),
|
| 35 |
+
"parameters": {
|
| 36 |
+
"type": "object",
|
| 37 |
+
"properties": {
|
| 38 |
+
"query": {
|
| 39 |
+
"type": "string",
|
| 40 |
+
"description": "A single T-SQL SELECT statement. Use TOP "
|
| 41 |
+
"(not LIMIT) to cap rows. Do not end with a semicolon.",
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"required": ["query"],
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
PRESENT_TOOL = {
|
| 50 |
+
"type": "function",
|
| 51 |
+
"function": {
|
| 52 |
+
"name": "present",
|
| 53 |
+
"description": (
|
| 54 |
+
"Choose how the MOST RECENT run_sql result is displayed. Call this "
|
| 55 |
+
"AFTER run_sql once you have the data. Use 'table' for multi-row "
|
| 56 |
+
"results, a chart ('bar', 'line', 'pie') to visualize an "
|
| 57 |
+
"aggregation, or 'text' for a single value / simple answer."
|
| 58 |
+
),
|
| 59 |
+
"parameters": {
|
| 60 |
+
"type": "object",
|
| 61 |
+
"properties": {
|
| 62 |
+
"format": {
|
| 63 |
+
"type": "string",
|
| 64 |
+
"enum": ["text", "table", "bar", "line", "pie"],
|
| 65 |
+
"description": "How to render the latest result.",
|
| 66 |
+
},
|
| 67 |
+
"title": {
|
| 68 |
+
"type": "string",
|
| 69 |
+
"description": "Short title/caption for the table or chart.",
|
| 70 |
+
},
|
| 71 |
+
"x_field": {
|
| 72 |
+
"type": "string",
|
| 73 |
+
"description": "For charts: column name for the category / "
|
| 74 |
+
"x-axis (also the pie slice labels).",
|
| 75 |
+
},
|
| 76 |
+
"y_fields": {
|
| 77 |
+
"type": "array",
|
| 78 |
+
"items": {"type": "string"},
|
| 79 |
+
"description": "For charts: one or more numeric column names "
|
| 80 |
+
"to plot on the y-axis (pie uses the first).",
|
| 81 |
+
},
|
| 82 |
+
},
|
| 83 |
+
"required": ["format"],
|
| 84 |
+
},
|
| 85 |
+
},
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
SYSTEM_INTRO = """You are "Antern Bot", a helpful data assistant for the Antern \
|
| 89 |
+
recruitment / interview platform. You answer questions about the data in the \
|
| 90 |
+
IAmInterviewed_QA database (Microsoft SQL Server 2019).
|
| 91 |
+
|
| 92 |
+
How you work:
|
| 93 |
+
- When a question needs data, call the `run_sql` tool with a single T-SQL SELECT \
|
| 94 |
+
query, read the rows, then answer in clear natural language.
|
| 95 |
+
- This is SQL Server / T-SQL. Use `TOP n` (never `LIMIT`), `OFFSET ... FETCH` for \
|
| 96 |
+
paging, `GETDATE()` for the current time, and square brackets for reserved names.
|
| 97 |
+
- Use ONLY tables and columns that appear in the schema below. Do not guess table \
|
| 98 |
+
names, and give every table an alias and reference columns by that alias. Watch \
|
| 99 |
+
column types when joining (an int id only joins to an int id).
|
| 100 |
+
- You have READ-ONLY access. Never attempt INSERT/UPDATE/DELETE/DDL.
|
| 101 |
+
- Most tables use soft deletes: rows have IsActive (bit) and DeletedDate. Unless \
|
| 102 |
+
the user asks otherwise, filter to active rows (IsActive = 1) for "live" counts.
|
| 103 |
+
- Keep result sets small: aggregate (COUNT, SUM, GROUP BY) or use TOP for examples.
|
| 104 |
+
- If a query fails, READ the error and fix the query — do not repeat the same \
|
| 105 |
+
failing query.
|
| 106 |
+
- Explain results conversationally. Never invent data that isn't in the results.
|
| 107 |
+
- After you have the data, call the `present` tool to choose how it is shown: \
|
| 108 |
+
`table` for multi-row results, `bar`/`line`/`pie` to visualize an aggregation \
|
| 109 |
+
(pass x_field and y_fields as exact column names from your query), or `text` for a \
|
| 110 |
+
single value. For charts, aggregate in SQL first (GROUP BY / TOP n). When you show \
|
| 111 |
+
a table or chart, keep your written answer short — the visual carries the detail.
|
| 112 |
+
|
| 113 |
+
Note: every table also has standard audit columns not listed below — \
|
| 114 |
+
CreatedDate, ModifiedDate, DeletedDate (datetimeoffset) — in addition to the \
|
| 115 |
+
IsActive (bit) column shown.
|
| 116 |
+
|
| 117 |
+
Below is the database schema (each table with its meaningful columns, and the \
|
| 118 |
+
foreign-key relationships you can join on):
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
# Minimal system prompt for the final "summarise the results" call — the schema
|
| 122 |
+
# isn't needed there, so we drop it to save tokens.
|
| 123 |
+
FINALIZE_SYSTEM = (
|
| 124 |
+
"You are Antern Bot. The user's question and the SQL query results are in the "
|
| 125 |
+
"conversation above. Write a clear, concise, friendly answer based only on "
|
| 126 |
+
"those results. If a table or chart is being shown, keep your text short."
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class AnternBot:
|
| 131 |
+
def __init__(self) -> None:
|
| 132 |
+
if not config.LLM_API_KEY:
|
| 133 |
+
raise RuntimeError(
|
| 134 |
+
"LLM_API_KEY is not set. Add it to your .env file. "
|
| 135 |
+
"For the default Groq backend, get a free key at "
|
| 136 |
+
"https://console.groq.com/keys"
|
| 137 |
+
)
|
| 138 |
+
self.model = config.LLM_MODEL
|
| 139 |
+
self.client = OpenAI(
|
| 140 |
+
base_url=config.LLM_BASE_URL, api_key=config.LLM_API_KEY, timeout=60
|
| 141 |
+
)
|
| 142 |
+
# Introspect once; the schema goes into the system instruction.
|
| 143 |
+
self.schema = db.introspect_schema()
|
| 144 |
+
self.system_instruction = SYSTEM_INTRO + "\n" + self.schema
|
| 145 |
+
self.tools = [RUN_SQL_TOOL, PRESENT_TOOL]
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _run_sql(sql: str) -> tuple:
|
| 149 |
+
"""Execute a query. Returns (record_for_ui, model_response, full_result).
|
| 150 |
+
The model gets only a row preview; the full result is for the frontend."""
|
| 151 |
+
record: dict = {"query": sql}
|
| 152 |
+
try:
|
| 153 |
+
result = db.run_query(sql)
|
| 154 |
+
record["row_count"] = result["row_count"]
|
| 155 |
+
record["truncated"] = result["truncated"]
|
| 156 |
+
preview = result["rows"][:MODEL_ROW_PREVIEW]
|
| 157 |
+
model_view = {
|
| 158 |
+
"columns": result["columns"],
|
| 159 |
+
"rows": preview,
|
| 160 |
+
"row_count": result["row_count"],
|
| 161 |
+
"preview_truncated": len(result["rows"]) > len(preview),
|
| 162 |
+
}
|
| 163 |
+
return record, {"result": model_view}, result
|
| 164 |
+
except db.UnsafeQueryError as exc:
|
| 165 |
+
record["error"] = f"Blocked: {exc}"
|
| 166 |
+
return record, {"error": f"Query rejected by safety guard: {exc}"}, None
|
| 167 |
+
except Exception as exc: # SQL error, connection, etc.
|
| 168 |
+
record["error"] = str(exc)
|
| 169 |
+
return record, {"error": f"Query failed: {exc}"}, None
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
def _present(args: dict, last_result) -> tuple:
|
| 173 |
+
"""Build a presentation directive paired with the most recent result."""
|
| 174 |
+
fmt = (args.get("format") or "text").lower()
|
| 175 |
+
if fmt == "text" or not last_result or not last_result.get("rows"):
|
| 176 |
+
return None, {"status": "shown as text"}
|
| 177 |
+
presentation = {
|
| 178 |
+
"format": fmt,
|
| 179 |
+
"title": args.get("title"),
|
| 180 |
+
"x_field": args.get("x_field"),
|
| 181 |
+
"y_fields": list(args.get("y_fields") or []),
|
| 182 |
+
"columns": last_result["columns"],
|
| 183 |
+
"rows": last_result["rows"],
|
| 184 |
+
"truncated": last_result.get("truncated", False),
|
| 185 |
+
}
|
| 186 |
+
return presentation, {"status": f"shown as {fmt}"}
|
| 187 |
+
|
| 188 |
+
def chat(self, history: list, user_message: str) -> dict:
|
| 189 |
+
"""Run one user turn through the tool-calling loop.
|
| 190 |
+
|
| 191 |
+
`history` is the prior list of clean {role, content} text turns. Returns
|
| 192 |
+
{answer, queries, presentation, messages}.
|
| 193 |
+
"""
|
| 194 |
+
messages = [{"role": "system", "content": self.system_instruction}]
|
| 195 |
+
messages.extend(history)
|
| 196 |
+
messages.append({"role": "user", "content": user_message})
|
| 197 |
+
|
| 198 |
+
queries: list[dict] = []
|
| 199 |
+
presentation = None
|
| 200 |
+
last_result = None
|
| 201 |
+
presented = False
|
| 202 |
+
answer = ""
|
| 203 |
+
usage = {"prompt": 0, "completion": 0, "total": 0}
|
| 204 |
+
llm_calls = 0
|
| 205 |
+
|
| 206 |
+
for _ in range(MAX_TOOL_ITERATIONS):
|
| 207 |
+
if presented:
|
| 208 |
+
# Finalize call: just summarise the results — schema not needed,
|
| 209 |
+
# so swap in the minimal system prompt to save tokens.
|
| 210 |
+
call_messages = [
|
| 211 |
+
{"role": "system", "content": FINALIZE_SYSTEM}
|
| 212 |
+
] + messages[1:]
|
| 213 |
+
kwargs = dict(model=self.model, messages=call_messages, temperature=0)
|
| 214 |
+
else:
|
| 215 |
+
kwargs = dict(
|
| 216 |
+
model=self.model, messages=messages, temperature=0,
|
| 217 |
+
tools=self.tools,
|
| 218 |
+
)
|
| 219 |
+
resp = self.client.chat.completions.create(**kwargs)
|
| 220 |
+
llm_calls += 1
|
| 221 |
+
u = getattr(resp, "usage", None)
|
| 222 |
+
if u:
|
| 223 |
+
usage["prompt"] += getattr(u, "prompt_tokens", 0) or 0
|
| 224 |
+
usage["completion"] += getattr(u, "completion_tokens", 0) or 0
|
| 225 |
+
usage["total"] += getattr(u, "total_tokens", 0) or 0
|
| 226 |
+
msg = resp.choices[0].message
|
| 227 |
+
content = (msg.content or "").strip()
|
| 228 |
+
tool_calls = msg.tool_calls or []
|
| 229 |
+
print(
|
| 230 |
+
f"[antern] turn calls={[tc.function.name for tc in tool_calls]} "
|
| 231 |
+
f"text={'yes' if content else 'no'}",
|
| 232 |
+
flush=True,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Echo the assistant turn back into the conversation.
|
| 236 |
+
assistant_msg = {"role": "assistant", "content": msg.content or ""}
|
| 237 |
+
if tool_calls:
|
| 238 |
+
assistant_msg["tool_calls"] = [
|
| 239 |
+
{
|
| 240 |
+
"id": tc.id,
|
| 241 |
+
"type": "function",
|
| 242 |
+
"function": {
|
| 243 |
+
"name": tc.function.name,
|
| 244 |
+
"arguments": tc.function.arguments,
|
| 245 |
+
},
|
| 246 |
+
}
|
| 247 |
+
for tc in tool_calls
|
| 248 |
+
]
|
| 249 |
+
messages.append(assistant_msg)
|
| 250 |
+
|
| 251 |
+
if content:
|
| 252 |
+
answer = content
|
| 253 |
+
if not tool_calls:
|
| 254 |
+
break
|
| 255 |
+
|
| 256 |
+
# run_sql calls first (so a 'present' in the same batch uses fresh data).
|
| 257 |
+
sql_calls = [tc for tc in tool_calls if tc.function.name == "run_sql"]
|
| 258 |
+
other_calls = [tc for tc in tool_calls if tc.function.name != "run_sql"]
|
| 259 |
+
for tc in sql_calls:
|
| 260 |
+
args = self._args(tc)
|
| 261 |
+
record, model_response, full = self._run_sql(args.get("query", ""))
|
| 262 |
+
if full is not None:
|
| 263 |
+
last_result = full
|
| 264 |
+
if record.get("error"):
|
| 265 |
+
print(f"[antern] sql error: {record['error']} | sql={record['query'][:200]}", flush=True)
|
| 266 |
+
queries.append(record)
|
| 267 |
+
messages.append({"role": "tool", "tool_call_id": tc.id,
|
| 268 |
+
"content": json.dumps(model_response, default=str)})
|
| 269 |
+
for tc in other_calls:
|
| 270 |
+
if tc.function.name == "present":
|
| 271 |
+
presentation, model_response = self._present(self._args(tc), last_result)
|
| 272 |
+
presented = True
|
| 273 |
+
else:
|
| 274 |
+
model_response = {"error": f"Unknown tool: {tc.function.name}"}
|
| 275 |
+
messages.append({"role": "tool", "tool_call_id": tc.id,
|
| 276 |
+
"content": json.dumps(model_response, default=str)})
|
| 277 |
+
|
| 278 |
+
if not answer:
|
| 279 |
+
answer = (
|
| 280 |
+
"I found the data but had trouble summarising it. Please try "
|
| 281 |
+
"rephrasing or narrowing your question."
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
new_history = list(history)
|
| 285 |
+
new_history.append({"role": "user", "content": user_message})
|
| 286 |
+
new_history.append({"role": "assistant", "content": answer})
|
| 287 |
+
|
| 288 |
+
return {
|
| 289 |
+
"answer": answer,
|
| 290 |
+
"queries": queries,
|
| 291 |
+
"presentation": presentation,
|
| 292 |
+
"messages": new_history,
|
| 293 |
+
"usage": usage,
|
| 294 |
+
"llm_calls": llm_calls,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
@staticmethod
|
| 298 |
+
def _args(tool_call) -> dict:
|
| 299 |
+
"""Parse a tool call's JSON-string arguments into a dict."""
|
| 300 |
+
try:
|
| 301 |
+
return json.loads(tool_call.function.arguments or "{}")
|
| 302 |
+
except Exception:
|
| 303 |
+
return {}
|
app.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI web app for Antern Bot: serves a chat UI and a /api/chat endpoint."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, Header
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import config
|
| 16 |
+
import monitoring
|
| 17 |
+
from agent import AnternBot
|
| 18 |
+
|
| 19 |
+
STATIC_DIR = Path(__file__).parent / "static"
|
| 20 |
+
|
| 21 |
+
# In-memory conversation store: session_id -> list of API message dicts.
|
| 22 |
+
# Fine for a single-process app; swap for Redis/DB if you scale out.
|
| 23 |
+
SESSIONS: dict[str, list[dict]] = {}
|
| 24 |
+
MAX_HISTORY_MESSAGES = 24 # cap stored turns to bound token growth
|
| 25 |
+
|
| 26 |
+
bot: AnternBot | None = None
|
| 27 |
+
INIT_ERROR: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def lifespan(app: FastAPI):
|
| 32 |
+
global bot, INIT_ERROR
|
| 33 |
+
try:
|
| 34 |
+
bot = AnternBot() # introspects schema + checks API key at startup
|
| 35 |
+
monitoring.log_event("startup", status="ready", model=bot.model)
|
| 36 |
+
except Exception as exc: # missing API key, DB unreachable, etc.
|
| 37 |
+
INIT_ERROR = str(exc)
|
| 38 |
+
monitoring.log_event("startup", status="failed", error=str(exc))
|
| 39 |
+
yield
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
app = FastAPI(title="Antern Bot", lifespan=lifespan)
|
| 43 |
+
|
| 44 |
+
# Allow the widget to call the API from other web pages (configurable origins).
|
| 45 |
+
app.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=config.ALLOWED_ORIGINS,
|
| 48 |
+
allow_methods=["GET", "POST"],
|
| 49 |
+
allow_headers=["*"],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ChatRequest(BaseModel):
|
| 54 |
+
message: str
|
| 55 |
+
session_id: str | None = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ResetRequest(BaseModel):
|
| 59 |
+
session_id: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.get("/")
|
| 63 |
+
def index() -> FileResponse:
|
| 64 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@app.get("/metrics")
|
| 68 |
+
def metrics_dashboard() -> FileResponse:
|
| 69 |
+
"""Human-friendly metrics dashboard (fetches /api/metrics with ?token=...)."""
|
| 70 |
+
return FileResponse(STATIC_DIR / "metrics.html")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.get("/api/health")
|
| 74 |
+
def health() -> JSONResponse:
|
| 75 |
+
return JSONResponse(
|
| 76 |
+
{"ready": bot is not None, "error": INIT_ERROR, "model": getattr(bot, "model", None)}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.post("/api/chat")
|
| 81 |
+
def chat(req: ChatRequest) -> JSONResponse:
|
| 82 |
+
request_id = uuid.uuid4().hex
|
| 83 |
+
received = time.time()
|
| 84 |
+
|
| 85 |
+
if bot is None:
|
| 86 |
+
monitoring.log_event("api_failure", request_id=request_id,
|
| 87 |
+
reason="bot_not_initialised", detail=INIT_ERROR)
|
| 88 |
+
return JSONResponse(
|
| 89 |
+
{"error": f"Bot not initialised: {INIT_ERROR}"}, status_code=503
|
| 90 |
+
)
|
| 91 |
+
if not req.message.strip():
|
| 92 |
+
return JSONResponse({"error": "Empty message."}, status_code=400)
|
| 93 |
+
|
| 94 |
+
session_id = req.session_id or uuid.uuid4().hex
|
| 95 |
+
history = SESSIONS.get(session_id, [])
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
result = bot.chat(history, req.message)
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
err = f"{type(exc).__name__}: {exc}"
|
| 101 |
+
monitoring.log_request(
|
| 102 |
+
request_id=request_id, session_id=session_id, question=req.message,
|
| 103 |
+
answer="", latency_ms=(time.time() - received) * 1000,
|
| 104 |
+
model=getattr(bot, "model", None), llm_calls=0, tokens=None,
|
| 105 |
+
queries=[], presentation=None, error=err,
|
| 106 |
+
)
|
| 107 |
+
monitoring.log_event("api_failure", request_id=request_id,
|
| 108 |
+
session_id=session_id, error=err)
|
| 109 |
+
return JSONResponse({"error": err}, status_code=500)
|
| 110 |
+
|
| 111 |
+
SESSIONS[session_id] = result["messages"][-MAX_HISTORY_MESSAGES:]
|
| 112 |
+
pres = result.get("presentation")
|
| 113 |
+
usage = result.get("usage") or {}
|
| 114 |
+
chat_cost = monitoring.cost(usage.get("prompt", 0), usage.get("completion", 0))
|
| 115 |
+
|
| 116 |
+
monitoring.log_request(
|
| 117 |
+
request_id=request_id, session_id=session_id, question=req.message,
|
| 118 |
+
answer=result["answer"], latency_ms=(time.time() - received) * 1000,
|
| 119 |
+
model=getattr(bot, "model", None), llm_calls=result.get("llm_calls", 0),
|
| 120 |
+
tokens=result.get("usage"), queries=result["queries"],
|
| 121 |
+
presentation=(pres or {}).get("format"), error=None,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
return JSONResponse(
|
| 125 |
+
{
|
| 126 |
+
"session_id": session_id,
|
| 127 |
+
"request_id": request_id,
|
| 128 |
+
"answer": result["answer"],
|
| 129 |
+
"queries": result["queries"],
|
| 130 |
+
"presentation": pres,
|
| 131 |
+
"tokens": usage.get("total", 0),
|
| 132 |
+
"cost_usd": chat_cost,
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@app.get("/api/metrics")
|
| 138 |
+
def metrics(
|
| 139 |
+
x_metrics_token: str | None = Header(default=None),
|
| 140 |
+
token: str | None = None, # query param, for viewing in a browser
|
| 141 |
+
) -> JSONResponse:
|
| 142 |
+
"""Aggregate metrics (uptime, requests, errors, tokens, latency, cost, sessions).
|
| 143 |
+
If METRICS_TOKEN is set, supply it via the X-Metrics-Token header OR a
|
| 144 |
+
?token=... query parameter (the query param is handy in a browser)."""
|
| 145 |
+
supplied = x_metrics_token or token
|
| 146 |
+
if config.METRICS_TOKEN and supplied != config.METRICS_TOKEN:
|
| 147 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 148 |
+
return JSONResponse(monitoring.get_metrics())
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.get("/api/metrics/recent")
|
| 152 |
+
def metrics_recent(
|
| 153 |
+
limit: int = 20,
|
| 154 |
+
x_metrics_token: str | None = Header(default=None),
|
| 155 |
+
token: str | None = None,
|
| 156 |
+
) -> JSONResponse:
|
| 157 |
+
"""Recent chats from the durable log (survives restarts). Token-protected."""
|
| 158 |
+
supplied = x_metrics_token or token
|
| 159 |
+
if config.METRICS_TOKEN and supplied != config.METRICS_TOKEN:
|
| 160 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 161 |
+
return JSONResponse({"recent": monitoring.recent_chats(min(max(limit, 1), 100))})
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@app.post("/api/reset")
|
| 165 |
+
def reset(req: ResetRequest) -> JSONResponse:
|
| 166 |
+
SESSIONS.pop(req.session_id, None)
|
| 167 |
+
return JSONResponse({"ok": True})
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# Serve any other static assets (none required, but handy for extension).
|
| 171 |
+
if STATIC_DIR.exists():
|
| 172 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration, loaded from the environment / .env file."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _require(name: str) -> str:
|
| 10 |
+
value = os.getenv(name)
|
| 11 |
+
if not value:
|
| 12 |
+
raise RuntimeError(
|
| 13 |
+
f"Missing required environment variable {name!r}. "
|
| 14 |
+
"Copy .env.example to .env and fill it in."
|
| 15 |
+
)
|
| 16 |
+
return value
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# --- Database ---
|
| 20 |
+
DB_SERVER = _require("DB_SERVER")
|
| 21 |
+
DB_PORT = os.getenv("DB_PORT", "1433")
|
| 22 |
+
DB_NAME = _require("DB_NAME")
|
| 23 |
+
DB_USER = _require("DB_USER")
|
| 24 |
+
DB_PASSWORD = _require("DB_PASSWORD")
|
| 25 |
+
DB_DRIVER = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server")
|
| 26 |
+
|
| 27 |
+
# --- LLM backend (OpenAI-compatible: Groq, ngrok AI Gateway, OpenAI, etc.) ---
|
| 28 |
+
# Default: Groq free tier. Swap LLM_BASE_URL/LLM_MODEL to use another backend.
|
| 29 |
+
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.groq.com/openai/v1")
|
| 30 |
+
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
|
| 31 |
+
LLM_MODEL = os.getenv("LLM_MODEL", "llama-3.3-70b-versatile")
|
| 32 |
+
|
| 33 |
+
# Price per 1M tokens for cost roll-ups (defaults = Groq Llama 3.3 70B paid rate).
|
| 34 |
+
# On the free tier your real cost is $0 — these power the projected-cost metric.
|
| 35 |
+
LLM_PRICE_IN = float(os.getenv("LLM_PRICE_IN", "0.59"))
|
| 36 |
+
LLM_PRICE_OUT = float(os.getenv("LLM_PRICE_OUT", "0.79"))
|
| 37 |
+
|
| 38 |
+
# --- Monitoring ---
|
| 39 |
+
# If set, /api/metrics requires header X-Metrics-Token: <this> (empty = open).
|
| 40 |
+
METRICS_TOKEN = os.getenv("METRICS_TOKEN", "")
|
| 41 |
+
|
| 42 |
+
# --- Behaviour ---
|
| 43 |
+
MAX_RESULT_ROWS = int(os.getenv("MAX_RESULT_ROWS", "200"))
|
| 44 |
+
QUERY_TIMEOUT = int(os.getenv("QUERY_TIMEOUT", "30"))
|
| 45 |
+
|
| 46 |
+
# Comma-separated list of web origins allowed to call the API (for embedding the
|
| 47 |
+
# widget on another site). Use "*" for any origin (dev only). In production set
|
| 48 |
+
# this to your site, e.g. "https://www.antern.com,https://antern.com".
|
| 49 |
+
ALLOWED_ORIGINS = [
|
| 50 |
+
o.strip() for o in os.getenv("ALLOWED_ORIGINS", "*").split(",") if o.strip()
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def connection_string() -> str:
|
| 55 |
+
"""Build a pyodbc connection string. Password is wrapped in braces so
|
| 56 |
+
special characters (e.g. & in the password) are handled safely."""
|
| 57 |
+
return (
|
| 58 |
+
f"DRIVER={{{DB_DRIVER}}};"
|
| 59 |
+
f"SERVER={DB_SERVER},{DB_PORT};"
|
| 60 |
+
f"DATABASE={DB_NAME};"
|
| 61 |
+
f"UID={DB_USER};"
|
| 62 |
+
f"PWD={{{DB_PASSWORD}}};"
|
| 63 |
+
"Encrypt=yes;"
|
| 64 |
+
"TrustServerCertificate=yes;"
|
| 65 |
+
f"Connection Timeout=15;"
|
| 66 |
+
)
|
db.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database access layer.
|
| 2 |
+
|
| 3 |
+
Two responsibilities:
|
| 4 |
+
1. Introspect the schema once at startup (cached) so the bot can be told
|
| 5 |
+
what tables/columns/relationships exist.
|
| 6 |
+
2. Execute model-generated SQL safely: READ-ONLY, single statement, row-capped.
|
| 7 |
+
|
| 8 |
+
The read-only guard is the core safety mechanism. We connect with a single
|
| 9 |
+
login, so we cannot rely on database-level permissions; instead every query is
|
| 10 |
+
validated to be a single SELECT/WITH statement before it ever reaches the server.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import datetime
|
| 15 |
+
import decimal
|
| 16 |
+
import re
|
| 17 |
+
import struct
|
| 18 |
+
import uuid
|
| 19 |
+
from collections import OrderedDict
|
| 20 |
+
|
| 21 |
+
import pyodbc
|
| 22 |
+
|
| 23 |
+
import config
|
| 24 |
+
|
| 25 |
+
# SQL Server's datetimeoffset (ODBC type -155) isn't decoded by pyodbc natively;
|
| 26 |
+
# without this converter, selecting any CreatedDate/ModifiedDate/DeletedDate
|
| 27 |
+
# column raises "ODBC SQL type -155 is not yet supported". Decode the 20-byte
|
| 28 |
+
# SQL_SS_TIMESTAMPOFFSET struct into a tz-aware datetime.
|
| 29 |
+
SQL_SS_TIMESTAMPOFFSET = -155
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _decode_datetimeoffset(raw: bytes):
|
| 33 |
+
try:
|
| 34 |
+
y, mo, d, h, mi, s, frac, tzh, tzm = struct.unpack("<6hI2h", raw)
|
| 35 |
+
return datetime.datetime(
|
| 36 |
+
y, mo, d, h, mi, s, frac // 1000,
|
| 37 |
+
datetime.timezone(datetime.timedelta(hours=tzh, minutes=tzm)),
|
| 38 |
+
)
|
| 39 |
+
except Exception:
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class UnsafeQueryError(Exception):
|
| 44 |
+
"""Raised when a query fails the read-only safety checks."""
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Whole-word keywords that must never appear in a query. SELECT INTO (which
|
| 48 |
+
# creates a table) is covered by the INTO entry.
|
| 49 |
+
_FORBIDDEN = [
|
| 50 |
+
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE",
|
| 51 |
+
"MERGE", "EXEC", "EXECUTE", "GRANT", "REVOKE", "DENY", "BACKUP",
|
| 52 |
+
"RESTORE", "INTO", "SHUTDOWN", "RECONFIGURE", "WAITFOR",
|
| 53 |
+
]
|
| 54 |
+
_FORBIDDEN_RE = re.compile(r"\b(" + "|".join(_FORBIDDEN) + r")\b", re.IGNORECASE)
|
| 55 |
+
# Stored-procedure prefixes (sp_, xp_) used for system access.
|
| 56 |
+
_PROC_RE = re.compile(r"\b(sp_|xp_)\w+", re.IGNORECASE)
|
| 57 |
+
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.DOTALL)
|
| 58 |
+
_COMMENT_LINE = re.compile(r"--[^\n]*")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _connect() -> pyodbc.Connection:
|
| 62 |
+
conn = pyodbc.connect(config.connection_string(), timeout=15)
|
| 63 |
+
conn.timeout = config.QUERY_TIMEOUT # query (command) timeout in seconds
|
| 64 |
+
conn.add_output_converter(SQL_SS_TIMESTAMPOFFSET, _decode_datetimeoffset)
|
| 65 |
+
return conn
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _strip_comments(sql: str) -> str:
|
| 69 |
+
sql = _COMMENT_BLOCK.sub(" ", sql)
|
| 70 |
+
sql = _COMMENT_LINE.sub(" ", sql)
|
| 71 |
+
return sql
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def validate_readonly(sql: str) -> str:
|
| 75 |
+
"""Validate that `sql` is a single read-only statement. Returns the cleaned
|
| 76 |
+
SQL (no trailing semicolon) or raises UnsafeQueryError."""
|
| 77 |
+
if not sql or not sql.strip():
|
| 78 |
+
raise UnsafeQueryError("Empty query.")
|
| 79 |
+
|
| 80 |
+
cleaned = _strip_comments(sql).strip()
|
| 81 |
+
|
| 82 |
+
# Disallow stacked statements: a semicolon is only allowed as the very
|
| 83 |
+
# last character.
|
| 84 |
+
body = cleaned[:-1] if cleaned.endswith(";") else cleaned
|
| 85 |
+
if ";" in body:
|
| 86 |
+
raise UnsafeQueryError(
|
| 87 |
+
"Multiple statements are not allowed. Send a single SELECT query."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
first = body.lstrip().split(None, 1)[0].upper() if body.strip() else ""
|
| 91 |
+
if first not in ("SELECT", "WITH"):
|
| 92 |
+
raise UnsafeQueryError(
|
| 93 |
+
"Only SELECT (or WITH ... SELECT) queries are allowed."
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
if _FORBIDDEN_RE.search(body):
|
| 97 |
+
bad = _FORBIDDEN_RE.search(body).group(1).upper()
|
| 98 |
+
raise UnsafeQueryError(f"Disallowed keyword in query: {bad}.")
|
| 99 |
+
|
| 100 |
+
if _PROC_RE.search(body):
|
| 101 |
+
raise UnsafeQueryError("Stored-procedure calls are not allowed.")
|
| 102 |
+
|
| 103 |
+
return body
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _jsonify(value):
|
| 107 |
+
"""Convert SQL Server values into JSON-serialisable Python values."""
|
| 108 |
+
if value is None:
|
| 109 |
+
return None
|
| 110 |
+
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
|
| 111 |
+
return value.isoformat()
|
| 112 |
+
if isinstance(value, decimal.Decimal):
|
| 113 |
+
# keep integers as ints, others as float
|
| 114 |
+
return int(value) if value == value.to_integral_value() else float(value)
|
| 115 |
+
if isinstance(value, uuid.UUID):
|
| 116 |
+
return str(value)
|
| 117 |
+
if isinstance(value, (bytes, bytearray)):
|
| 118 |
+
return value.hex()
|
| 119 |
+
return value
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def run_query(sql: str) -> dict:
|
| 123 |
+
"""Validate and execute a read-only query.
|
| 124 |
+
|
| 125 |
+
Returns a dict: {columns: [...], rows: [[...]], row_count, truncated}.
|
| 126 |
+
"""
|
| 127 |
+
body = validate_readonly(sql)
|
| 128 |
+
conn = _connect()
|
| 129 |
+
try:
|
| 130 |
+
cur = conn.cursor()
|
| 131 |
+
cur.execute(body)
|
| 132 |
+
if cur.description is None:
|
| 133 |
+
return {"columns": [], "rows": [], "row_count": 0, "truncated": False}
|
| 134 |
+
columns = [d[0] for d in cur.description]
|
| 135 |
+
cap = config.MAX_RESULT_ROWS
|
| 136 |
+
raw = cur.fetchmany(cap + 1)
|
| 137 |
+
truncated = len(raw) > cap
|
| 138 |
+
raw = raw[:cap]
|
| 139 |
+
rows = [[_jsonify(v) for v in row] for row in raw]
|
| 140 |
+
return {
|
| 141 |
+
"columns": columns,
|
| 142 |
+
"rows": rows,
|
| 143 |
+
"row_count": len(rows),
|
| 144 |
+
"truncated": truncated,
|
| 145 |
+
}
|
| 146 |
+
finally:
|
| 147 |
+
conn.close()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def introspect_schema() -> str:
|
| 151 |
+
"""Build a compact text description of the schema for the system prompt:
|
| 152 |
+
every table with its columns, plus foreign-key relationships."""
|
| 153 |
+
conn = _connect()
|
| 154 |
+
try:
|
| 155 |
+
cur = conn.cursor()
|
| 156 |
+
cur.execute(
|
| 157 |
+
"""
|
| 158 |
+
SELECT t.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE
|
| 159 |
+
FROM INFORMATION_SCHEMA.TABLES t
|
| 160 |
+
JOIN INFORMATION_SCHEMA.COLUMNS c
|
| 161 |
+
ON t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_SCHEMA = c.TABLE_SCHEMA
|
| 162 |
+
WHERE t.TABLE_TYPE = 'BASE TABLE'
|
| 163 |
+
ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION
|
| 164 |
+
"""
|
| 165 |
+
)
|
| 166 |
+
# Boilerplate audit columns present on nearly every table — omitted from
|
| 167 |
+
# the listing to save prompt tokens (IsActive is kept; it's meaningful).
|
| 168 |
+
AUDIT_COLS = {
|
| 169 |
+
"CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate",
|
| 170 |
+
"DeletedBy", "DeletedDate",
|
| 171 |
+
}
|
| 172 |
+
tables: "OrderedDict[str, list[str]]" = OrderedDict()
|
| 173 |
+
for tname, cname, dtype in cur.fetchall():
|
| 174 |
+
if cname in AUDIT_COLS:
|
| 175 |
+
continue
|
| 176 |
+
tables.setdefault(tname, []).append(f"{cname} {dtype}")
|
| 177 |
+
|
| 178 |
+
# Foreign keys for join hints.
|
| 179 |
+
cur.execute(
|
| 180 |
+
"""
|
| 181 |
+
SELECT
|
| 182 |
+
fk_tab.name AS fk_table, fk_col.name AS fk_column,
|
| 183 |
+
pk_tab.name AS pk_table, pk_col.name AS pk_column
|
| 184 |
+
FROM sys.foreign_key_columns fkc
|
| 185 |
+
JOIN sys.tables fk_tab ON fkc.parent_object_id = fk_tab.object_id
|
| 186 |
+
JOIN sys.columns fk_col
|
| 187 |
+
ON fkc.parent_object_id = fk_col.object_id
|
| 188 |
+
AND fkc.parent_column_id = fk_col.column_id
|
| 189 |
+
JOIN sys.tables pk_tab ON fkc.referenced_object_id = pk_tab.object_id
|
| 190 |
+
JOIN sys.columns pk_col
|
| 191 |
+
ON fkc.referenced_object_id = pk_col.object_id
|
| 192 |
+
AND fkc.referenced_column_id = pk_col.column_id
|
| 193 |
+
ORDER BY fk_tab.name, fk_col.name
|
| 194 |
+
"""
|
| 195 |
+
)
|
| 196 |
+
fks = [
|
| 197 |
+
f"{r.fk_table}.{r.fk_column} -> {r.pk_table}.{r.pk_column}"
|
| 198 |
+
for r in cur.fetchall()
|
| 199 |
+
]
|
| 200 |
+
finally:
|
| 201 |
+
conn.close()
|
| 202 |
+
|
| 203 |
+
lines = ["# Tables (table(column type, ...))", ""]
|
| 204 |
+
for tname, cols in tables.items():
|
| 205 |
+
lines.append(f"{tname}({', '.join(cols)})")
|
| 206 |
+
if fks:
|
| 207 |
+
lines.append("")
|
| 208 |
+
lines.append("# Foreign keys (from -> to)")
|
| 209 |
+
lines.append("")
|
| 210 |
+
lines.extend(fks)
|
| 211 |
+
return "\n".join(lines)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def ping() -> str:
|
| 215 |
+
"""Quick connectivity check; returns the server version."""
|
| 216 |
+
conn = _connect()
|
| 217 |
+
try:
|
| 218 |
+
cur = conn.cursor()
|
| 219 |
+
cur.execute("SELECT @@VERSION")
|
| 220 |
+
return cur.fetchone()[0]
|
| 221 |
+
finally:
|
| 222 |
+
conn.close()
|
monitoring.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monitoring & logging for Antern Bot.
|
| 2 |
+
|
| 3 |
+
Writes one structured JSON line per event to logs/antern.jsonl (and to the
|
| 4 |
+
console), and keeps in-memory aggregate counters exposed via /api/metrics.
|
| 5 |
+
|
| 6 |
+
Per chat request we record:
|
| 7 |
+
- when the request was received + how long it took (latency_ms)
|
| 8 |
+
- which model was used + how many LLM calls
|
| 9 |
+
- tokens consumed (prompt / completion / total, from the LLM response)
|
| 10 |
+
- the SQL executed and row counts
|
| 11 |
+
- security events (read-only guard blocking a query)
|
| 12 |
+
- errors / API failures
|
| 13 |
+
- CPU & RAM utilisation (GPU N/A — the LLM runs remotely on Groq)
|
| 14 |
+
- a full audit trail (session_id, question, answer length)
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import datetime
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import config
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
import psutil
|
| 29 |
+
except ImportError: # optional dependency
|
| 30 |
+
psutil = None
|
| 31 |
+
|
| 32 |
+
LOG_DIR = Path(__file__).parent / "logs"
|
| 33 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 34 |
+
LOG_FILE = LOG_DIR / "antern.jsonl"
|
| 35 |
+
|
| 36 |
+
# --- JSON-lines logger (file + console) ---
|
| 37 |
+
logger = logging.getLogger("antern.monitor")
|
| 38 |
+
if not logger.handlers: # guard against duplicate handlers on reload
|
| 39 |
+
logger.setLevel(logging.INFO)
|
| 40 |
+
_fmt = logging.Formatter("%(message)s")
|
| 41 |
+
_fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
|
| 42 |
+
_fh.setFormatter(_fmt)
|
| 43 |
+
logger.addHandler(_fh)
|
| 44 |
+
_ch = logging.StreamHandler()
|
| 45 |
+
_ch.setFormatter(_fmt)
|
| 46 |
+
logger.addHandler(_ch)
|
| 47 |
+
logger.propagate = False
|
| 48 |
+
|
| 49 |
+
# --- In-memory aggregate counters (reset on restart) ---
|
| 50 |
+
_lock = threading.Lock()
|
| 51 |
+
_metrics = {
|
| 52 |
+
"started": time.time(),
|
| 53 |
+
"requests": 0,
|
| 54 |
+
"errors": 0,
|
| 55 |
+
"blocked_queries": 0,
|
| 56 |
+
"sql_errors": 0,
|
| 57 |
+
"tokens_total": 0,
|
| 58 |
+
"latency_ms_sum": 0.0,
|
| 59 |
+
"cost_usd_sum": 0.0,
|
| 60 |
+
}
|
| 61 |
+
# Per-session roll-up: session_id -> {requests, prompt, completion, total, cost_usd}
|
| 62 |
+
_sessions: dict[str, dict] = {}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _cost(prompt: int, completion: int) -> float:
|
| 66 |
+
"""Projected $ cost from token counts and configured per-1M prices."""
|
| 67 |
+
return round(
|
| 68 |
+
prompt / 1e6 * config.LLM_PRICE_IN + completion / 1e6 * config.LLM_PRICE_OUT,
|
| 69 |
+
6,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def cost(prompt: int, completion: int) -> float:
|
| 74 |
+
"""Public alias for computing a chat's projected $ cost."""
|
| 75 |
+
return _cost(prompt, completion)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _now() -> str:
|
| 79 |
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def system_stats() -> dict:
|
| 83 |
+
"""CPU and RAM utilisation. GPU is not tracked (LLM is remote; no local GPU)."""
|
| 84 |
+
if not psutil:
|
| 85 |
+
return {}
|
| 86 |
+
return {
|
| 87 |
+
"cpu_pct": psutil.cpu_percent(interval=None),
|
| 88 |
+
"ram_pct": psutil.virtual_memory().percent,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _write(record: dict) -> None:
|
| 93 |
+
record.setdefault("ts", _now())
|
| 94 |
+
logger.info(json.dumps(record, default=str))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def log_event(event: str, **fields) -> None:
|
| 98 |
+
"""Log a discrete event (e.g. security, api_failure, startup)."""
|
| 99 |
+
_write({"event": event, **fields})
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def log_request(
|
| 103 |
+
*,
|
| 104 |
+
request_id: str,
|
| 105 |
+
session_id: str,
|
| 106 |
+
question: str,
|
| 107 |
+
answer: str,
|
| 108 |
+
latency_ms: float,
|
| 109 |
+
model: str | None,
|
| 110 |
+
llm_calls: int,
|
| 111 |
+
tokens: dict | None,
|
| 112 |
+
queries: list[dict],
|
| 113 |
+
presentation: str | None,
|
| 114 |
+
error: str | None,
|
| 115 |
+
) -> None:
|
| 116 |
+
"""Record one chat request (the audit trail) and update aggregate counters."""
|
| 117 |
+
blocked = [q for q in queries if str(q.get("error", "")).startswith("Blocked")]
|
| 118 |
+
sql_errored = [
|
| 119 |
+
q for q in queries
|
| 120 |
+
if q.get("error") and not str(q.get("error")).startswith("Blocked")
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
p = (tokens or {}).get("prompt", 0)
|
| 124 |
+
comp = (tokens or {}).get("completion", 0)
|
| 125 |
+
tot = (tokens or {}).get("total", 0)
|
| 126 |
+
cost = _cost(p, comp)
|
| 127 |
+
|
| 128 |
+
with _lock:
|
| 129 |
+
_metrics["requests"] += 1
|
| 130 |
+
_metrics["latency_ms_sum"] += latency_ms
|
| 131 |
+
_metrics["tokens_total"] += tot
|
| 132 |
+
_metrics["cost_usd_sum"] += cost
|
| 133 |
+
_metrics["blocked_queries"] += len(blocked)
|
| 134 |
+
_metrics["sql_errors"] += len(sql_errored)
|
| 135 |
+
if error:
|
| 136 |
+
_metrics["errors"] += 1
|
| 137 |
+
s = _sessions.setdefault(
|
| 138 |
+
session_id,
|
| 139 |
+
{"requests": 0, "prompt": 0, "completion": 0, "total": 0, "cost_usd": 0.0},
|
| 140 |
+
)
|
| 141 |
+
s["requests"] += 1
|
| 142 |
+
s["prompt"] += p
|
| 143 |
+
s["completion"] += comp
|
| 144 |
+
s["total"] += tot
|
| 145 |
+
s["cost_usd"] = round(s["cost_usd"] + cost, 6)
|
| 146 |
+
|
| 147 |
+
# Emit a discrete security event for each blocked (write-attempt) query.
|
| 148 |
+
for q in blocked:
|
| 149 |
+
log_event(
|
| 150 |
+
"security",
|
| 151 |
+
request_id=request_id,
|
| 152 |
+
session_id=session_id,
|
| 153 |
+
reason=q.get("error"),
|
| 154 |
+
query=q.get("query"),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
_write({
|
| 158 |
+
"event": "chat",
|
| 159 |
+
"request_id": request_id,
|
| 160 |
+
"session_id": session_id,
|
| 161 |
+
"question": question,
|
| 162 |
+
"answer_chars": len(answer or ""),
|
| 163 |
+
"latency_ms": round(latency_ms, 1),
|
| 164 |
+
"model": model,
|
| 165 |
+
"llm_calls": llm_calls,
|
| 166 |
+
"tokens": tokens,
|
| 167 |
+
"cost_usd": cost,
|
| 168 |
+
"sql": [
|
| 169 |
+
{"query": q.get("query"), "row_count": q.get("row_count"),
|
| 170 |
+
"error": q.get("error")}
|
| 171 |
+
for q in queries
|
| 172 |
+
],
|
| 173 |
+
"presentation": presentation,
|
| 174 |
+
"security_events": len(blocked),
|
| 175 |
+
"sql_errors": len(sql_errored),
|
| 176 |
+
"error": error,
|
| 177 |
+
"system": system_stats(),
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def top_sessions(n: int = 10) -> list[dict]:
|
| 182 |
+
"""Per-session cost roll-up, highest cost first."""
|
| 183 |
+
with _lock:
|
| 184 |
+
items = [{"session_id": sid, **vals} for sid, vals in _sessions.items()]
|
| 185 |
+
items.sort(key=lambda x: x["cost_usd"], reverse=True)
|
| 186 |
+
return items[:n]
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def recent_chats(n: int = 20) -> list[dict]:
|
| 190 |
+
"""Read the last `n` chat records from the log file (most recent first).
|
| 191 |
+
Sourced from the log, so it survives restarts (unlike the live counters)."""
|
| 192 |
+
if not LOG_FILE.exists():
|
| 193 |
+
return []
|
| 194 |
+
try:
|
| 195 |
+
lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
|
| 196 |
+
except Exception:
|
| 197 |
+
return []
|
| 198 |
+
out: list[dict] = []
|
| 199 |
+
for line in reversed(lines):
|
| 200 |
+
if '"chat"' not in line:
|
| 201 |
+
continue
|
| 202 |
+
try:
|
| 203 |
+
rec = json.loads(line)
|
| 204 |
+
except Exception:
|
| 205 |
+
continue
|
| 206 |
+
if rec.get("event") != "chat":
|
| 207 |
+
continue
|
| 208 |
+
out.append({
|
| 209 |
+
"ts": rec.get("ts"),
|
| 210 |
+
"session_id": rec.get("session_id"),
|
| 211 |
+
"question": rec.get("question"),
|
| 212 |
+
"latency_ms": rec.get("latency_ms"),
|
| 213 |
+
"tokens": (rec.get("tokens") or {}).get("total"),
|
| 214 |
+
"cost_usd": rec.get("cost_usd"),
|
| 215 |
+
"presentation": rec.get("presentation"),
|
| 216 |
+
"security_events": rec.get("security_events", 0),
|
| 217 |
+
"sql_errors": rec.get("sql_errors", 0),
|
| 218 |
+
"error": rec.get("error"),
|
| 219 |
+
})
|
| 220 |
+
if len(out) >= n:
|
| 221 |
+
break
|
| 222 |
+
return out
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def get_metrics() -> dict:
|
| 226 |
+
"""Aggregate counters for /api/metrics."""
|
| 227 |
+
with _lock:
|
| 228 |
+
m = dict(_metrics)
|
| 229 |
+
n_sessions = len(_sessions)
|
| 230 |
+
uptime = time.time() - m["started"]
|
| 231 |
+
reqs = m["requests"]
|
| 232 |
+
return {
|
| 233 |
+
"uptime_seconds": round(uptime, 1),
|
| 234 |
+
"requests": reqs,
|
| 235 |
+
"sessions": n_sessions,
|
| 236 |
+
"errors": m["errors"],
|
| 237 |
+
"error_rate": round(m["errors"] / reqs, 3) if reqs else 0,
|
| 238 |
+
"blocked_queries": m["blocked_queries"],
|
| 239 |
+
"sql_errors": m["sql_errors"],
|
| 240 |
+
"tokens_total": m["tokens_total"],
|
| 241 |
+
"avg_latency_ms": round(m["latency_ms_sum"] / reqs, 1) if reqs else 0,
|
| 242 |
+
"avg_tokens_per_request": round(m["tokens_total"] / reqs, 1) if reqs else 0,
|
| 243 |
+
"total_cost_usd": round(m["cost_usd_sum"], 6),
|
| 244 |
+
"avg_cost_per_request_usd": round(m["cost_usd_sum"] / reqs, 6) if reqs else 0,
|
| 245 |
+
"price_per_1m": {"input": config.LLM_PRICE_IN, "output": config.LLM_PRICE_OUT},
|
| 246 |
+
"top_sessions": top_sessions(10),
|
| 247 |
+
"system": system_stats(),
|
| 248 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.136.0
|
| 2 |
+
uvicorn[standard]>=0.49.0
|
| 3 |
+
pyodbc>=5.2.0
|
| 4 |
+
openai>=1.50.0
|
| 5 |
+
python-dotenv>=1.0.1
|
| 6 |
+
psutil>=5.9.0
|
run.ps1
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Launch Antern Bot
|
| 2 |
+
python -m uvicorn app:app --host 127.0.0.1 --port 8000
|
static/demo.html
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Embed demo — your website</title>
|
| 7 |
+
<style>
|
| 8 |
+
body { font-family: Georgia, serif; margin: 0; color: #222; background: #fafafa; }
|
| 9 |
+
.hero { max-width: 760px; margin: 0 auto; padding: 80px 24px; }
|
| 10 |
+
h1 { font-size: 38px; }
|
| 11 |
+
p { font-size: 18px; line-height: 1.7; color: #444; }
|
| 12 |
+
.note { background: #fff; border: 1px solid #e5e5e5; border-radius: 10px; padding: 16px 20px; margin-top: 30px; font-size: 15px; }
|
| 13 |
+
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; }
|
| 14 |
+
</style>
|
| 15 |
+
</head>
|
| 16 |
+
<body>
|
| 17 |
+
<div class="hero">
|
| 18 |
+
<h1>This is a pretend website.</h1>
|
| 19 |
+
<p>Imagine this is your company's webpage. The only thing added to it is a
|
| 20 |
+
single <code><script></code> tag at the bottom — and now there's an
|
| 21 |
+
Antern Bot chat button in the corner. Click the 💬 button bottom-right.</p>
|
| 22 |
+
<div class="note">
|
| 23 |
+
The chat bubble is the embeddable widget. On a real site you'd paste the
|
| 24 |
+
one-line snippet from <code>EMBED.md</code> into your HTML.
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<!-- The embed snippet (served from the same host in this demo) -->
|
| 29 |
+
<script src="/static/widget.js"
|
| 30 |
+
data-api=""
|
| 31 |
+
data-title="Antern Bot"
|
| 32 |
+
data-accent="#4f8cff"
|
| 33 |
+
data-greeting="Hi! Ask me about candidates, requirements, applications, and more."></script>
|
| 34 |
+
</body>
|
| 35 |
+
</html>
|
static/embed-inline.html
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- ======================================================================
|
| 2 |
+
ANTERN BOT — embeddable chat widget (inline build for ngrok free tier)
|
| 3 |
+
|
| 4 |
+
HOW TO USE:
|
| 5 |
+
Copy the entire <script>...</script> block below and paste it just
|
| 6 |
+
before the closing </body> tag on any page of your website.
|
| 7 |
+
|
| 8 |
+
Why inline? ngrok's free tier shows a browser-warning page on a normal
|
| 9 |
+
<script src="...ngrok...">, so the widget code is inlined here instead.
|
| 10 |
+
The only call that touches ngrok is the chat API request, which sends the
|
| 11 |
+
"ngrok-skip-browser-warning" header and works fine. (Chart.js, when a chart
|
| 12 |
+
is shown, loads from the jsDelivr CDN — not from ngrok.)
|
| 13 |
+
|
| 14 |
+
To change the bot URL, title, colour, or greeting, edit the four CONFIG
|
| 15 |
+
lines at the top of the script.
|
| 16 |
+
====================================================================== -->
|
| 17 |
+
<script>
|
| 18 |
+
(function () {
|
| 19 |
+
/* ---- CONFIG (edit these) ---- */
|
| 20 |
+
var API = "https://duckling-bovine-blitz.ngrok-free.dev"; // your stable bot URL
|
| 21 |
+
var TITLE = "Antern Bot";
|
| 22 |
+
var ACCENT = "#4f8cff";
|
| 23 |
+
var GREETING = "👋 Hi! I'm Antern Bot, your data assistant.\n\nAsk me anything about your recruitment data — candidates, requirements, applications, interviews — in plain English.\n\nTry: “How many active candidates do we have?”";
|
| 24 |
+
/* ----------------------------- */
|
| 25 |
+
var STORAGE_KEY = "antern_bot_session";
|
| 26 |
+
API = API.replace(/\/$/, "");
|
| 27 |
+
|
| 28 |
+
var host = document.createElement("div");
|
| 29 |
+
host.id = "antern-bot-widget";
|
| 30 |
+
document.body.appendChild(host);
|
| 31 |
+
var root = host.attachShadow({ mode: "open" });
|
| 32 |
+
|
| 33 |
+
root.innerHTML =
|
| 34 |
+
'<style>' +
|
| 35 |
+
':host{all:initial;}' +
|
| 36 |
+
'*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
|
| 37 |
+
'.launcher{position:fixed;bottom:24px;right:24px;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);color:#fff;z-index:2147483000;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 28px rgba(0,0,0,.30),0 4px 12px rgba(79,140,255,.45);transition:transform .25s cubic-bezier(.2,.9,.3,1.2),box-shadow .25s;animation:antern-pop .5s cubic-bezier(.2,.9,.3,1.2);}' +
|
| 38 |
+
'.launcher::after{content:"";position:absolute;inset:0;border-radius:50%;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);z-index:-1;animation:antern-pulse 2.6s ease-out infinite;}' +
|
| 39 |
+
'.launcher.open::after{animation:none;opacity:0;}' +
|
| 40 |
+
'.launcher:hover{transform:translateY(-3px) scale(1.05);box-shadow:0 16px 38px rgba(0,0,0,.34),0 6px 18px rgba(79,140,255,.55);}' +
|
| 41 |
+
'.launcher:active{transform:translateY(-1px) scale(.97);}' +
|
| 42 |
+
'.launcher svg{width:27px;height:27px;}' +
|
| 43 |
+
'.launcher .ic-close{display:none;}' +
|
| 44 |
+
'.launcher.open .ic-chat{display:none;}' +
|
| 45 |
+
'.launcher.open .ic-close{display:block;}' +
|
| 46 |
+
'@keyframes antern-pop{0%{transform:scale(0) rotate(-25deg);}100%{transform:scale(1) rotate(0);}}' +
|
| 47 |
+
'@keyframes antern-pulse{0%{transform:scale(1);opacity:.45;}70%{transform:scale(1.7);opacity:0;}100%{opacity:0;}}' +
|
| 48 |
+
'.panel{position:fixed;bottom:92px;right:22px;width:760px;max-width:calc(100vw - 32px);height:1120px;max-height:calc(100vh - 120px);background:#0f1115;border:1px solid #2a303c;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.4);z-index:2147483000;display:none;flex-direction:column;overflow:hidden;}' +
|
| 49 |
+
'.panel.open{display:flex;}' +
|
| 50 |
+
'.head{background:#171a21;border-bottom:1px solid #2a303c;padding:13px 16px;display:flex;align-items:center;gap:10px;color:#e8eaed;}' +
|
| 51 |
+
'.head .badge{width:28px;height:28px;border-radius:8px;background:' + ACCENT + ';display:flex;align-items:center;justify-content:center;font-weight:700;}' +
|
| 52 |
+
'.head h3{margin:0;font-size:15px;flex:1;}' +
|
| 53 |
+
'.head button{background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:20px;line-height:1;}' +
|
| 54 |
+
'.body{flex:1;overflow-y:auto;padding:16px;background:#0f1115;}' +
|
| 55 |
+
'.msg{display:flex;gap:9px;margin-bottom:14px;}' +
|
| 56 |
+
'.msg .av{width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;}' +
|
| 57 |
+
'.msg.bot .av{background:' + ACCENT + ';}' +
|
| 58 |
+
'.msg.user .av{background:#374151;}' +
|
| 59 |
+
'.bubble{background:#232936;border:1px solid #2a303c;border-radius:11px;padding:10px 13px;color:#e8eaed;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;max-width:100%;}' +
|
| 60 |
+
'.msg.user .bubble{background:#1f242e;}' +
|
| 61 |
+
'.typing{color:#9aa3b2;font-style:italic;}' +
|
| 62 |
+
'.foot{border-top:1px solid #2a303c;background:#171a21;padding:11px;display:flex;gap:8px;}' +
|
| 63 |
+
'.foot textarea{flex:1;resize:none;background:#1f242e;color:#e8eaed;border:1px solid #2a303c;border-radius:9px;padding:9px 11px;font-size:14px;max-height:120px;}' +
|
| 64 |
+
'.foot textarea:focus{outline:none;border-color:' + ACCENT + ';}' +
|
| 65 |
+
'.foot button{background:' + ACCENT + ';color:#fff;border:none;border-radius:9px;padding:0 16px;font-weight:600;cursor:pointer;font-size:14px;}' +
|
| 66 |
+
'.foot button:disabled{opacity:.5;cursor:not-allowed;}' +
|
| 67 |
+
'.cap{font-size:11.5px;color:#9aa3b2;margin:10px 0 4px;}' +
|
| 68 |
+
'.tbl-wrap{margin-top:8px;max-height:260px;overflow:auto;border:1px solid #2a303c;border-radius:9px;}' +
|
| 69 |
+
'table.tbl{border-collapse:collapse;width:100%;font-size:12.5px;}' +
|
| 70 |
+
'table.tbl th,table.tbl td{padding:6px 9px;border-bottom:1px solid #2a303c;text-align:left;white-space:nowrap;}' +
|
| 71 |
+
'table.tbl th{position:sticky;top:0;background:#1f242e;color:#cbd5e1;font-weight:600;}' +
|
| 72 |
+
'table.tbl td{color:#e8eaed;}' +
|
| 73 |
+
'table.tbl tr:hover td{background:#1a1f29;}' +
|
| 74 |
+
'.chart-wrap{margin-top:8px;background:#fff;border-radius:9px;padding:10px;}' +
|
| 75 |
+
'.chart-wrap canvas{max-width:100%;}' +
|
| 76 |
+
'.stats{padding:7px 12px;background:#12151b;border-top:1px solid #2a303c;color:#9aa3b2;font-size:11px;text-align:center;}' +
|
| 77 |
+
'</style>' +
|
| 78 |
+
'<button class="launcher" aria-label="Open chat">' +
|
| 79 |
+
'<svg class="ic-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.5 7.48L3 21l2.02-5.5A8.5 8.5 0 1 1 21 11.5z"/><circle cx="8.5" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="15.5" cy="11.5" r="1" fill="currentColor" stroke="none"/></svg>' +
|
| 80 |
+
'<svg class="ic-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' +
|
| 81 |
+
'</button>' +
|
| 82 |
+
'<div class="panel" role="dialog" aria-label="' + TITLE + '">' +
|
| 83 |
+
' <div class="head"><div class="badge">A</div><h3>' + TITLE + '</h3><button class="close" aria-label="Close">×</button></div>' +
|
| 84 |
+
' <div class="body"></div>' +
|
| 85 |
+
' <div class="foot"><textarea rows="1" placeholder="Type your question…"></textarea><button class="send">Send</button></div>' +
|
| 86 |
+
' <div class="stats"></div>' +
|
| 87 |
+
'</div>';
|
| 88 |
+
|
| 89 |
+
var launcher = root.querySelector(".launcher");
|
| 90 |
+
var panel = root.querySelector(".panel");
|
| 91 |
+
var closeBtn = root.querySelector(".close");
|
| 92 |
+
var body = root.querySelector(".body");
|
| 93 |
+
var input = root.querySelector("textarea");
|
| 94 |
+
var sendBtn = root.querySelector(".send");
|
| 95 |
+
var statsEl = root.querySelector(".stats");
|
| 96 |
+
var sessionId = localStorage.getItem(STORAGE_KEY) || null;
|
| 97 |
+
var greeted = false;
|
| 98 |
+
var PALETTE = ["#4f8cff","#8a5cff","#38d39f","#ffb020","#ff6b6b","#22b8cf","#f783ac","#a0d911"];
|
| 99 |
+
var chartJsPromise = null;
|
| 100 |
+
|
| 101 |
+
function loadChartJs() {
|
| 102 |
+
if (window.Chart) return Promise.resolve(window.Chart);
|
| 103 |
+
if (chartJsPromise) return chartJsPromise;
|
| 104 |
+
chartJsPromise = new Promise(function (resolve, reject) {
|
| 105 |
+
var s = document.createElement("script");
|
| 106 |
+
s.src = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js";
|
| 107 |
+
s.onload = function () { resolve(window.Chart); };
|
| 108 |
+
s.onerror = function () { reject(new Error("chart lib failed")); };
|
| 109 |
+
document.head.appendChild(s);
|
| 110 |
+
});
|
| 111 |
+
return chartJsPromise;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function renderTable(container, p) {
|
| 115 |
+
var cols = p.columns || [], rows = p.rows || [];
|
| 116 |
+
if (p.title) { var c = document.createElement("div"); c.className = "cap"; c.textContent = p.title; container.appendChild(c); }
|
| 117 |
+
var wrap = document.createElement("div"); wrap.className = "tbl-wrap";
|
| 118 |
+
var t = document.createElement("table"); t.className = "tbl";
|
| 119 |
+
var thead = document.createElement("thead"), trh = document.createElement("tr");
|
| 120 |
+
cols.forEach(function (col) { var th = document.createElement("th"); th.textContent = col; trh.appendChild(th); });
|
| 121 |
+
thead.appendChild(trh); t.appendChild(thead);
|
| 122 |
+
var tb = document.createElement("tbody");
|
| 123 |
+
rows.forEach(function (r) {
|
| 124 |
+
var tr = document.createElement("tr");
|
| 125 |
+
r.forEach(function (v) { var td = document.createElement("td"); td.textContent = (v === null || v === undefined) ? "" : v; tr.appendChild(td); });
|
| 126 |
+
tb.appendChild(tr);
|
| 127 |
+
});
|
| 128 |
+
t.appendChild(tb); wrap.appendChild(t); container.appendChild(wrap);
|
| 129 |
+
if (p.truncated) { var n = document.createElement("div"); n.className = "cap"; n.textContent = "(showing first " + rows.length + " rows)"; container.appendChild(n); }
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function renderChart(container, p) {
|
| 133 |
+
var cols = p.columns || [], rows = p.rows || [];
|
| 134 |
+
var xi = cols.indexOf(p.x_field);
|
| 135 |
+
var yIdx = (p.y_fields || []).map(function (f) { return cols.indexOf(f); }).filter(function (i) { return i >= 0; });
|
| 136 |
+
if (xi < 0 || yIdx.length === 0) { renderTable(container, p); return; }
|
| 137 |
+
if (p.title) { var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = p.title; container.appendChild(cap); }
|
| 138 |
+
var wrap = document.createElement("div"); wrap.className = "chart-wrap";
|
| 139 |
+
var canvas = document.createElement("canvas"); wrap.appendChild(canvas); container.appendChild(wrap);
|
| 140 |
+
var labels = rows.map(function (r) { return r[xi]; });
|
| 141 |
+
loadChartJs().then(function (Chart) {
|
| 142 |
+
if (p.format === "pie") {
|
| 143 |
+
var fi = yIdx[0];
|
| 144 |
+
new Chart(canvas, {
|
| 145 |
+
type: "pie",
|
| 146 |
+
data: { labels: labels, datasets: [{ data: rows.map(function (r) { return Number(r[fi]) || 0; }), backgroundColor: labels.map(function (_, i) { return PALETTE[i % PALETTE.length]; }) }] },
|
| 147 |
+
options: { responsive: true, plugins: { legend: { position: "right" } } },
|
| 148 |
+
});
|
| 149 |
+
} else {
|
| 150 |
+
var datasets = yIdx.map(function (ci, k) {
|
| 151 |
+
return { label: cols[ci], data: rows.map(function (r) { return Number(r[ci]) || 0; }), backgroundColor: PALETTE[k % PALETTE.length], borderColor: PALETTE[k % PALETTE.length], fill: false, tension: 0.25 };
|
| 152 |
+
});
|
| 153 |
+
new Chart(canvas, {
|
| 154 |
+
type: p.format === "line" ? "line" : "bar",
|
| 155 |
+
data: { labels: labels, datasets: datasets },
|
| 156 |
+
options: { responsive: true, plugins: { legend: { display: yIdx.length > 1 } }, scales: { y: { beginAtZero: true } } },
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
body.scrollTop = body.scrollHeight;
|
| 160 |
+
}).catch(function () {
|
| 161 |
+
var e = document.createElement("div"); e.className = "cap"; e.textContent = "(couldn't load chart — showing table)"; container.appendChild(e);
|
| 162 |
+
renderTable(container, p);
|
| 163 |
+
});
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function renderPresentation(bubble, p) {
|
| 167 |
+
if (!p || !p.format || p.format === "text") return;
|
| 168 |
+
var box = document.createElement("div");
|
| 169 |
+
bubble.appendChild(box);
|
| 170 |
+
if (p.format === "table") renderTable(box, p);
|
| 171 |
+
else renderChart(box, p);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
var STATS_KEY = "antern_bot_daystats";
|
| 175 |
+
var lastTokens = null;
|
| 176 |
+
function _today() { return new Date().toISOString().slice(0, 10); }
|
| 177 |
+
function getStats() {
|
| 178 |
+
var s;
|
| 179 |
+
try { s = JSON.parse(localStorage.getItem(STATS_KEY) || "{}"); } catch (e) { s = {}; }
|
| 180 |
+
if (s.date !== _today()) s = { date: _today(), chats: 0, cost: 0 };
|
| 181 |
+
return s;
|
| 182 |
+
}
|
| 183 |
+
function bumpStats(cost) {
|
| 184 |
+
var s = getStats();
|
| 185 |
+
s.chats += 1; s.cost += (cost || 0);
|
| 186 |
+
localStorage.setItem(STATS_KEY, JSON.stringify(s));
|
| 187 |
+
}
|
| 188 |
+
function renderStats() {
|
| 189 |
+
var s = getStats();
|
| 190 |
+
var avg = s.chats ? s.cost / s.chats : 0;
|
| 191 |
+
var parts = ["Today: " + s.chats + " chat" + (s.chats === 1 ? "" : "s"),
|
| 192 |
+
"avg $" + avg.toFixed(4) + "/chat"];
|
| 193 |
+
if (lastTokens != null) parts.push("last: " + lastTokens.toLocaleString() + " tokens");
|
| 194 |
+
statsEl.textContent = parts.join(" · ");
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function esc(s) {
|
| 198 |
+
return (s == null ? "" : String(s)).replace(/[&<>"]/g, function (c) {
|
| 199 |
+
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function addMsg(role, text) {
|
| 204 |
+
var m = document.createElement("div");
|
| 205 |
+
m.className = "msg " + role;
|
| 206 |
+
m.innerHTML =
|
| 207 |
+
'<div class="av">' + (role === "user" ? "You" : "A") + "</div>" +
|
| 208 |
+
'<div class="bubble"></div>';
|
| 209 |
+
m.querySelector(".bubble").textContent = text;
|
| 210 |
+
body.appendChild(m);
|
| 211 |
+
body.scrollTop = body.scrollHeight;
|
| 212 |
+
return m;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function setOpen(open) {
|
| 216 |
+
panel.classList.toggle("open", open);
|
| 217 |
+
launcher.classList.toggle("open", open);
|
| 218 |
+
launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat");
|
| 219 |
+
if (open && !greeted) { addMsg("bot", GREETING); greeted = true; }
|
| 220 |
+
if (open) { renderStats(); input.focus(); }
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
async function send(text) {
|
| 224 |
+
addMsg("user", text);
|
| 225 |
+
sendBtn.disabled = true;
|
| 226 |
+
var m = addMsg("bot", "");
|
| 227 |
+
var bubble = m.querySelector(".bubble");
|
| 228 |
+
bubble.innerHTML = '<span class="typing">Thinking…</span>';
|
| 229 |
+
try {
|
| 230 |
+
var r = await fetch(API + "/api/chat", {
|
| 231 |
+
method: "POST",
|
| 232 |
+
headers: {
|
| 233 |
+
"Content-Type": "application/json",
|
| 234 |
+
"ngrok-skip-browser-warning": "true",
|
| 235 |
+
},
|
| 236 |
+
body: JSON.stringify({ message: text, session_id: sessionId }),
|
| 237 |
+
});
|
| 238 |
+
var d = await r.json();
|
| 239 |
+
if (!r.ok) {
|
| 240 |
+
bubble.innerHTML = '<span class="typing">⚠ ' + esc(d.error || "Error") + "</span>";
|
| 241 |
+
} else {
|
| 242 |
+
sessionId = d.session_id;
|
| 243 |
+
localStorage.setItem(STORAGE_KEY, sessionId);
|
| 244 |
+
bubble.textContent = d.answer || "(no answer)";
|
| 245 |
+
renderPresentation(bubble, d.presentation);
|
| 246 |
+
if (typeof d.cost_usd === "number") bumpStats(d.cost_usd);
|
| 247 |
+
if (typeof d.tokens === "number") lastTokens = d.tokens;
|
| 248 |
+
renderStats();
|
| 249 |
+
}
|
| 250 |
+
} catch (e) {
|
| 251 |
+
bubble.innerHTML = '<span class="typing">⚠ Network error</span>';
|
| 252 |
+
} finally {
|
| 253 |
+
sendBtn.disabled = false;
|
| 254 |
+
body.scrollTop = body.scrollHeight;
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function submit() {
|
| 259 |
+
var t = input.value.trim();
|
| 260 |
+
if (!t) return;
|
| 261 |
+
input.value = "";
|
| 262 |
+
input.style.height = "auto";
|
| 263 |
+
send(t);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
launcher.addEventListener("click", function () { setOpen(!panel.classList.contains("open")); });
|
| 267 |
+
closeBtn.addEventListener("click", function () { setOpen(false); });
|
| 268 |
+
sendBtn.addEventListener("click", submit);
|
| 269 |
+
input.addEventListener("keydown", function (e) {
|
| 270 |
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
| 271 |
+
});
|
| 272 |
+
input.addEventListener("input", function () {
|
| 273 |
+
input.style.height = "auto";
|
| 274 |
+
input.style.height = Math.min(input.scrollHeight, 120) + "px";
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
renderStats();
|
| 278 |
+
})();
|
| 279 |
+
</script>
|
static/index.html
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Antern Bot</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0f1115; --panel: #171a21; --panel2: #1f242e; --border: #2a303c;
|
| 10 |
+
--text: #e8eaed; --muted: #9aa3b2; --accent: #4f8cff; --user: #2563eb;
|
| 11 |
+
--bot: #232936; --danger: #ff6b6b; --code: #11151c;
|
| 12 |
+
}
|
| 13 |
+
* { box-sizing: border-box; }
|
| 14 |
+
body {
|
| 15 |
+
margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 16 |
+
background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column;
|
| 17 |
+
}
|
| 18 |
+
header {
|
| 19 |
+
padding: 14px 20px; background: var(--panel); border-bottom: 1px solid var(--border);
|
| 20 |
+
display: flex; align-items: center; gap: 12px;
|
| 21 |
+
}
|
| 22 |
+
header .logo { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(135deg, #4f8cff, #8a5cff); display: flex; align-items: center; justify-content: center; font-weight: 700; }
|
| 23 |
+
header h1 { font-size: 16px; margin: 0; }
|
| 24 |
+
header .sub { font-size: 12px; color: var(--muted); margin-left: 2px; }
|
| 25 |
+
header .spacer { flex: 1; }
|
| 26 |
+
header button { background: var(--panel2); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 7px 12px; cursor: pointer; font-size: 13px; }
|
| 27 |
+
header button:hover { border-color: var(--accent); }
|
| 28 |
+
#status { font-size: 12px; color: var(--muted); }
|
| 29 |
+
#status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); margin-right: 5px; }
|
| 30 |
+
#status.ok .dot { background: #38d39f; }
|
| 31 |
+
#status.bad .dot { background: var(--danger); }
|
| 32 |
+
|
| 33 |
+
main { flex: 1; overflow-y: auto; padding: 24px 0; }
|
| 34 |
+
.wrap { max-width: 820px; margin: 0 auto; padding: 0 20px; }
|
| 35 |
+
.msg { display: flex; margin-bottom: 18px; gap: 12px; }
|
| 36 |
+
.msg .avatar { width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; }
|
| 37 |
+
.msg.user .avatar { background: var(--user); }
|
| 38 |
+
.msg.bot .avatar { background: linear-gradient(135deg, #4f8cff, #8a5cff); }
|
| 39 |
+
.bubble { background: var(--bot); border: 1px solid var(--border); border-radius: 12px; padding: 12px 15px; line-height: 1.55; white-space: pre-wrap; word-wrap: break-word; max-width: 100%; }
|
| 40 |
+
.msg.user .bubble { background: var(--panel2); }
|
| 41 |
+
.queries { margin-top: 10px; }
|
| 42 |
+
.queries summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
| 43 |
+
.queries pre { background: var(--code); border: 1px solid var(--border); border-radius: 8px; padding: 10px; overflow-x: auto; font-size: 12.5px; margin: 8px 0 0; color: #cbd5e1; }
|
| 44 |
+
.queries .meta { font-size: 11.5px; color: var(--muted); margin-top: 4px; }
|
| 45 |
+
.queries .err { color: var(--danger); }
|
| 46 |
+
.typing { color: var(--muted); font-style: italic; }
|
| 47 |
+
|
| 48 |
+
footer { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 0; }
|
| 49 |
+
form { max-width: 820px; margin: 0 auto; padding: 0 20px; display: flex; gap: 10px; }
|
| 50 |
+
textarea { flex: 1; resize: none; background: var(--panel2); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; font-size: 14px; font-family: inherit; max-height: 160px; }
|
| 51 |
+
textarea:focus { outline: none; border-color: var(--accent); }
|
| 52 |
+
form button { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
| 53 |
+
form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 54 |
+
.hint { max-width: 820px; margin: 8px auto 0; padding: 0 20px; font-size: 11.5px; color: var(--muted); }
|
| 55 |
+
</style>
|
| 56 |
+
</head>
|
| 57 |
+
<body>
|
| 58 |
+
<header>
|
| 59 |
+
<div class="logo">A</div>
|
| 60 |
+
<div>
|
| 61 |
+
<h1>Antern Bot</h1>
|
| 62 |
+
<div class="sub">Ask about the IAmInterviewed_QA database in plain English</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="spacer"></div>
|
| 65 |
+
<div id="status"><span class="dot"></span><span id="status-text">connecting…</span></div>
|
| 66 |
+
<button id="reset">New chat</button>
|
| 67 |
+
</header>
|
| 68 |
+
|
| 69 |
+
<main id="main">
|
| 70 |
+
<div class="wrap" id="messages"></div>
|
| 71 |
+
</main>
|
| 72 |
+
|
| 73 |
+
<footer>
|
| 74 |
+
<form id="form">
|
| 75 |
+
<textarea id="input" rows="1" placeholder="e.g. How many active candidates are there?" autocomplete="off"></textarea>
|
| 76 |
+
<button type="submit" id="send">Send</button>
|
| 77 |
+
</form>
|
| 78 |
+
<div class="hint">Read-only — the bot can only run SELECT queries. Press Enter to send, Shift+Enter for a new line.</div>
|
| 79 |
+
</footer>
|
| 80 |
+
|
| 81 |
+
<script>
|
| 82 |
+
const messagesEl = document.getElementById('messages');
|
| 83 |
+
const form = document.getElementById('form');
|
| 84 |
+
const input = document.getElementById('input');
|
| 85 |
+
const sendBtn = document.getElementById('send');
|
| 86 |
+
const resetBtn = document.getElementById('reset');
|
| 87 |
+
const statusEl = document.getElementById('status');
|
| 88 |
+
const statusText = document.getElementById('status-text');
|
| 89 |
+
let sessionId = null;
|
| 90 |
+
|
| 91 |
+
function esc(s) {
|
| 92 |
+
return (s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function addMessage(role, text) {
|
| 96 |
+
const msg = document.createElement('div');
|
| 97 |
+
msg.className = 'msg ' + role;
|
| 98 |
+
const avatar = role === 'user' ? 'You' : 'A';
|
| 99 |
+
msg.innerHTML = `<div class="avatar">${avatar}</div><div class="bubble"></div>`;
|
| 100 |
+
msg.querySelector('.bubble').textContent = text;
|
| 101 |
+
messagesEl.appendChild(msg);
|
| 102 |
+
scroll();
|
| 103 |
+
return msg;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function renderQueries(bubble, queries) {
|
| 107 |
+
if (!queries || !queries.length) return;
|
| 108 |
+
const det = document.createElement('details');
|
| 109 |
+
det.className = 'queries';
|
| 110 |
+
let inner = `<summary>${queries.length} quer${queries.length===1?'y':'ies'} run</summary>`;
|
| 111 |
+
for (const q of queries) {
|
| 112 |
+
inner += `<pre>${esc(q.query)}</pre>`;
|
| 113 |
+
if (q.error) {
|
| 114 |
+
inner += `<div class="meta err">${esc(q.error)}</div>`;
|
| 115 |
+
} else {
|
| 116 |
+
inner += `<div class="meta">${q.row_count} row(s)${q.truncated ? ' (truncated)' : ''}</div>`;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
det.innerHTML = inner;
|
| 120 |
+
bubble.appendChild(det);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function scroll() { document.getElementById('main').scrollTop = document.getElementById('main').scrollHeight; }
|
| 124 |
+
|
| 125 |
+
async function checkHealth() {
|
| 126 |
+
try {
|
| 127 |
+
const r = await fetch('/api/health');
|
| 128 |
+
const d = await r.json();
|
| 129 |
+
if (d.ready) {
|
| 130 |
+
statusEl.className = 'ok';
|
| 131 |
+
statusText.textContent = d.model || 'ready';
|
| 132 |
+
} else {
|
| 133 |
+
statusEl.className = 'bad';
|
| 134 |
+
statusText.textContent = d.error || 'not ready';
|
| 135 |
+
}
|
| 136 |
+
} catch {
|
| 137 |
+
statusEl.className = 'bad';
|
| 138 |
+
statusText.textContent = 'offline';
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
async function send(text) {
|
| 143 |
+
addMessage('user', text);
|
| 144 |
+
sendBtn.disabled = true;
|
| 145 |
+
const botMsg = addMessage('bot', '');
|
| 146 |
+
const bubble = botMsg.querySelector('.bubble');
|
| 147 |
+
bubble.innerHTML = '<span class="typing">Antern Bot is thinking…</span>';
|
| 148 |
+
try {
|
| 149 |
+
const r = await fetch('/api/chat', {
|
| 150 |
+
method: 'POST',
|
| 151 |
+
headers: { 'Content-Type': 'application/json' },
|
| 152 |
+
body: JSON.stringify({ message: text, session_id: sessionId }),
|
| 153 |
+
});
|
| 154 |
+
const d = await r.json();
|
| 155 |
+
if (!r.ok) {
|
| 156 |
+
bubble.innerHTML = `<span class="typing">⚠ ${esc(d.error || 'Error')}</span>`;
|
| 157 |
+
} else {
|
| 158 |
+
sessionId = d.session_id;
|
| 159 |
+
bubble.textContent = d.answer || '(no answer)';
|
| 160 |
+
renderQueries(bubble, d.queries);
|
| 161 |
+
}
|
| 162 |
+
} catch (e) {
|
| 163 |
+
bubble.innerHTML = `<span class="typing">⚠ Network error: ${esc(e.message)}</span>`;
|
| 164 |
+
} finally {
|
| 165 |
+
sendBtn.disabled = false;
|
| 166 |
+
scroll();
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
form.addEventListener('submit', e => {
|
| 171 |
+
e.preventDefault();
|
| 172 |
+
const text = input.value.trim();
|
| 173 |
+
if (!text) return;
|
| 174 |
+
input.value = '';
|
| 175 |
+
input.style.height = 'auto';
|
| 176 |
+
send(text);
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
input.addEventListener('keydown', e => {
|
| 180 |
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
|
| 181 |
+
});
|
| 182 |
+
input.addEventListener('input', () => {
|
| 183 |
+
input.style.height = 'auto';
|
| 184 |
+
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
resetBtn.addEventListener('click', async () => {
|
| 188 |
+
if (sessionId) {
|
| 189 |
+
try { await fetch('/api/reset', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ session_id: sessionId }) }); } catch {}
|
| 190 |
+
}
|
| 191 |
+
sessionId = null;
|
| 192 |
+
messagesEl.innerHTML = '';
|
| 193 |
+
addMessage('bot', "Hi! I'm Antern Bot. Ask me anything about the recruitment data — candidates, requirements, applications, interviews, skills, and more.");
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
checkHealth();
|
| 197 |
+
addMessage('bot', "Hi! I'm Antern Bot. Ask me anything about the recruitment data — candidates, requirements, applications, interviews, skills, and more.");
|
| 198 |
+
</script>
|
| 199 |
+
</body>
|
| 200 |
+
</html>
|
static/metrics.html
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Antern Bot — Metrics</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --border:#2a303c; --text:#e8eaed; --muted:#9aa3b2; --accent:#4f8cff; --ok:#38d39f; --warn:#ffb020; --bad:#ff6b6b; }
|
| 9 |
+
* { box-sizing:border-box; }
|
| 10 |
+
body { margin:0; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; padding:28px; }
|
| 11 |
+
h1 { font-size:22px; margin:0 0 4px; display:flex; align-items:center; gap:10px; }
|
| 12 |
+
h1 .logo { width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#4f8cff,#8a5cff);display:flex;align-items:center;justify-content:center;font-weight:700; }
|
| 13 |
+
.sub { color:var(--muted); font-size:13px; margin-bottom:22px; }
|
| 14 |
+
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:14px; margin-bottom:26px; }
|
| 15 |
+
.card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
|
| 16 |
+
.card .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
| 17 |
+
.card .value { font-size:26px; font-weight:700; margin-top:6px; }
|
| 18 |
+
.card .value.small { font-size:20px; }
|
| 19 |
+
h2 { font-size:15px; color:var(--muted); margin:24px 0 10px; font-weight:600; }
|
| 20 |
+
table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--border); border-radius:12px; overflow:hidden; }
|
| 21 |
+
th,td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13.5px; }
|
| 22 |
+
th { background:var(--panel2); color:var(--muted); font-weight:600; }
|
| 23 |
+
td.num,th.num { text-align:right; font-variant-numeric:tabular-nums; }
|
| 24 |
+
tr:last-child td { border-bottom:none; }
|
| 25 |
+
.bar { height:8px; background:var(--panel2); border-radius:5px; overflow:hidden; margin-top:8px; }
|
| 26 |
+
.bar > span { display:block; height:100%; background:var(--accent); }
|
| 27 |
+
.muted { color:var(--muted); }
|
| 28 |
+
.pill { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12px; font-weight:600; }
|
| 29 |
+
.pill.ok { background:rgba(56,211,159,.15); color:var(--ok); }
|
| 30 |
+
.pill.bad { background:rgba(255,107,107,.15); color:var(--bad); }
|
| 31 |
+
#updated { color:var(--muted); font-size:12px; }
|
| 32 |
+
.err { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:20px; }
|
| 33 |
+
.err code { background:var(--panel2); padding:2px 6px; border-radius:5px; }
|
| 34 |
+
.empty { color:var(--muted); padding:14px; }
|
| 35 |
+
</style>
|
| 36 |
+
</head>
|
| 37 |
+
<body>
|
| 38 |
+
<h1><span class="logo">A</span> Antern Bot — Live Metrics</h1>
|
| 39 |
+
<div class="sub">Auto-refreshing every 5s · <span id="updated">—</span></div>
|
| 40 |
+
<div id="content"><div class="empty">Loading…</div></div>
|
| 41 |
+
|
| 42 |
+
<script>
|
| 43 |
+
const token = new URLSearchParams(location.search).get("token") || "";
|
| 44 |
+
const $ = (id) => document.getElementById(id);
|
| 45 |
+
|
| 46 |
+
function commas(n) { return (n ?? 0).toLocaleString("en-US"); }
|
| 47 |
+
function money(n) { return "$" + (n ?? 0).toFixed(4); }
|
| 48 |
+
function uptime(s) {
|
| 49 |
+
s = Math.floor(s || 0);
|
| 50 |
+
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
| 51 |
+
return (h?h+"h ":"") + (m?m+"m ":"") + sec + "s";
|
| 52 |
+
}
|
| 53 |
+
function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
| 54 |
+
|
| 55 |
+
function render(d, recent) {
|
| 56 |
+
const errPill = d.errors > 0 ? `<span class="pill bad">${d.errors}</span>` : `<span class="pill ok">0</span>`;
|
| 57 |
+
const cards = [
|
| 58 |
+
["Requests", commas(d.requests)],
|
| 59 |
+
["Sessions", commas(d.sessions)],
|
| 60 |
+
["Errors", errPill, true],
|
| 61 |
+
["Total tokens", commas(d.tokens_total)],
|
| 62 |
+
["Total cost", money(d.total_cost_usd)],
|
| 63 |
+
["Avg latency", commas(Math.round(d.avg_latency_ms)) + " ms"],
|
| 64 |
+
["Avg tokens / chat", commas(Math.round(d.avg_tokens_per_request))],
|
| 65 |
+
["Avg cost / chat", money(d.avg_cost_per_request_usd)],
|
| 66 |
+
["Uptime", uptime(d.uptime_seconds)],
|
| 67 |
+
];
|
| 68 |
+
let html = '<div class="grid">';
|
| 69 |
+
for (const [label, value, raw] of cards) {
|
| 70 |
+
html += `<div class="card"><div class="label">${label}</div><div class="value ${String(value).length>10?'small':''}">${raw?value:esc(value)}</div></div>`;
|
| 71 |
+
}
|
| 72 |
+
html += '</div>';
|
| 73 |
+
|
| 74 |
+
// Reliability + pricing table
|
| 75 |
+
html += '<h2>Reliability & pricing</h2><table><tbody>';
|
| 76 |
+
html += `<tr><td>Error rate</td><td class="num">${((d.error_rate||0)*100).toFixed(1)}%</td></tr>`;
|
| 77 |
+
html += `<tr><td>Blocked queries (security)</td><td class="num">${commas(d.blocked_queries)}</td></tr>`;
|
| 78 |
+
html += `<tr><td>SQL errors</td><td class="num">${commas(d.sql_errors)}</td></tr>`;
|
| 79 |
+
html += `<tr><td>Price (input / output per 1M tokens)</td><td class="num">$${d.price_per_1m?.input} / $${d.price_per_1m?.output}</td></tr>`;
|
| 80 |
+
html += '</tbody></table>';
|
| 81 |
+
|
| 82 |
+
// System
|
| 83 |
+
const cpu = d.system?.cpu_pct ?? 0, ram = d.system?.ram_pct ?? 0;
|
| 84 |
+
html += '<h2>System</h2><table><tbody>';
|
| 85 |
+
html += `<tr><td style="width:140px">CPU</td><td>${cpu}%<div class="bar"><span style="width:${cpu}%"></span></div></td></tr>`;
|
| 86 |
+
html += `<tr><td>RAM</td><td>${ram}%<div class="bar"><span style="width:${ram}%"></span></div></td></tr>`;
|
| 87 |
+
html += '</tbody></table>';
|
| 88 |
+
|
| 89 |
+
// Top sessions / cost per customer
|
| 90 |
+
html += '<h2>Top sessions (cost per customer)</h2>';
|
| 91 |
+
const s = d.top_sessions || [];
|
| 92 |
+
if (!s.length) {
|
| 93 |
+
html += '<div class="empty">No sessions yet — ask a few questions in the chat, then refresh.</div>';
|
| 94 |
+
} else {
|
| 95 |
+
html += '<table><thead><tr><th>Session</th><th class="num">Chats</th><th class="num">Prompt</th><th class="num">Completion</th><th class="num">Total tokens</th><th class="num">Cost</th></tr></thead><tbody>';
|
| 96 |
+
for (const r of s) {
|
| 97 |
+
html += `<tr><td>${esc(r.session_id)}</td><td class="num">${commas(r.requests)}</td><td class="num">${commas(r.prompt)}</td><td class="num">${commas(r.completion)}</td><td class="num">${commas(r.total)}</td><td class="num">${money(r.cost_usd)}</td></tr>`;
|
| 98 |
+
}
|
| 99 |
+
html += '</tbody></table>';
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Recent chats (from the durable log)
|
| 103 |
+
html += '<h2>Recent chats</h2>';
|
| 104 |
+
if (!recent || !recent.length) {
|
| 105 |
+
html += '<div class="empty">No chats logged yet.</div>';
|
| 106 |
+
} else {
|
| 107 |
+
html += '<table><thead><tr><th>Time</th><th>Session</th><th>Question</th><th>Output</th><th class="num">Latency</th><th class="num">Tokens</th><th class="num">Cost</th><th>Status</th></tr></thead><tbody>';
|
| 108 |
+
for (const c of recent) {
|
| 109 |
+
const t = c.ts ? new Date(c.ts).toLocaleTimeString() : "";
|
| 110 |
+
const out = c.presentation || "text";
|
| 111 |
+
let status = '<span class="pill ok">ok</span>';
|
| 112 |
+
if (c.error) status = '<span class="pill bad">error</span>';
|
| 113 |
+
else if (c.security_events) status = '<span class="pill bad">blocked</span>';
|
| 114 |
+
else if (c.sql_errors) status = '<span class="pill" style="background:rgba(255,176,32,.15);color:var(--warn)">sql err</span>';
|
| 115 |
+
const q = (c.question || "");
|
| 116 |
+
const qShort = esc(q.slice(0, 60)) + (q.length > 60 ? "…" : "");
|
| 117 |
+
html += `<tr><td class="muted">${t}</td><td>${esc(c.session_id)}</td><td>${qShort}</td><td>${esc(out)}</td><td class="num">${commas(Math.round(c.latency_ms||0))} ms</td><td class="num">${commas(c.tokens||0)}</td><td class="num">${money(c.cost_usd||0)}</td><td>${status}</td></tr>`;
|
| 118 |
+
}
|
| 119 |
+
html += '</tbody></table>';
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
$("content").innerHTML = html;
|
| 123 |
+
$("updated").textContent = "updated " + new Date().toLocaleTimeString();
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
async function load() {
|
| 127 |
+
const H = { headers: { "ngrok-skip-browser-warning": "true" } };
|
| 128 |
+
const q = token ? ("?token=" + encodeURIComponent(token)) : "";
|
| 129 |
+
try {
|
| 130 |
+
const r = await fetch("/api/metrics" + q, H);
|
| 131 |
+
if (r.status === 401) {
|
| 132 |
+
$("content").innerHTML = '<div class="err">🔒 <b>Unauthorized.</b> Add your metrics token to the URL:<br><br><code>'
|
| 133 |
+
+ location.pathname + '?token=YOUR_TOKEN</code></div>';
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
const d = await r.json();
|
| 137 |
+
let recent = [];
|
| 138 |
+
try {
|
| 139 |
+
const rUrl = "/api/metrics/recent?limit=20" + (token ? ("&token=" + encodeURIComponent(token)) : "");
|
| 140 |
+
const rr = await fetch(rUrl, H);
|
| 141 |
+
if (rr.ok) recent = (await rr.json()).recent || [];
|
| 142 |
+
} catch (e) { /* recent is best-effort */ }
|
| 143 |
+
render(d, recent);
|
| 144 |
+
} catch (e) {
|
| 145 |
+
$("content").innerHTML = '<div class="err">⚠ Cannot reach the server. Is the bot running on this host?</div>';
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
load();
|
| 150 |
+
setInterval(load, 5000);
|
| 151 |
+
</script>
|
| 152 |
+
</body>
|
| 153 |
+
</html>
|
static/preview.html
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Embed preview — your website</title>
|
| 7 |
+
<style>
|
| 8 |
+
body { font-family: Georgia, "Times New Roman", serif; margin: 0; color: #1f2430; background: #f6f4ee; }
|
| 9 |
+
.topbar { background: #fff; border-bottom: 1px solid #e7e3d8; padding: 16px 32px; font-weight: 700; font-size: 20px; }
|
| 10 |
+
.hero { max-width: 780px; margin: 0 auto; padding: 70px 24px; }
|
| 11 |
+
h1 { font-size: 40px; line-height: 1.15; margin: 0 0 18px; }
|
| 12 |
+
p { font-size: 18px; line-height: 1.75; color: #444; }
|
| 13 |
+
.card { background: #fff; border: 1px solid #e7e3d8; border-radius: 12px; padding: 18px 22px; margin-top: 32px; font-size: 15px; font-family: -apple-system, "Segoe UI", sans-serif; }
|
| 14 |
+
.card b { color: #1f2430; }
|
| 15 |
+
</style>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<div class="topbar">Antern Tech</div>
|
| 19 |
+
<div class="hero">
|
| 20 |
+
<h1>This is a preview of your website with the bot embedded.</h1>
|
| 21 |
+
<p>Pretend this page is one of your real web pages. The only thing added is the
|
| 22 |
+
inline embed snippet (the same one in <code>embed-inline.html</code>). Look
|
| 23 |
+
at the bottom-right corner — there's a chat button. Click it and ask a
|
| 24 |
+
question; it talks to your live bot over the public URL. The footer inside
|
| 25 |
+
the chat window shows today's chat count, average cost, and last chat's tokens.</p>
|
| 26 |
+
<div class="card">
|
| 27 |
+
<b>Try (table):</b> "List candidates with their designation and experience." ·
|
| 28 |
+
<b>(bar chart):</b> "Top 5 designations by number of candidates." ·
|
| 29 |
+
<b>(pie chart):</b> "Break down requirements by status." ·
|
| 30 |
+
<b>(text):</b> "How many active candidates are there?"
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- ===== Antern Bot inline embed (points at the public ngrok URL) ===== -->
|
| 35 |
+
<script>
|
| 36 |
+
(function () {
|
| 37 |
+
var API = "https://duckling-bovine-blitz.ngrok-free.dev";
|
| 38 |
+
var TITLE = "Antern Bot";
|
| 39 |
+
var ACCENT = "#4f8cff";
|
| 40 |
+
var GREETING = "👋 Hi! I'm Antern Bot, your data assistant.\n\nAsk me anything about your recruitment data — candidates, requirements, applications, interviews — in plain English.\n\nTry: “How many active candidates do we have?”";
|
| 41 |
+
var STORAGE_KEY = "antern_bot_session";
|
| 42 |
+
API = API.replace(/\/$/, "");
|
| 43 |
+
|
| 44 |
+
var host = document.createElement("div");
|
| 45 |
+
host.id = "antern-bot-widget";
|
| 46 |
+
document.body.appendChild(host);
|
| 47 |
+
var root = host.attachShadow({ mode: "open" });
|
| 48 |
+
|
| 49 |
+
root.innerHTML =
|
| 50 |
+
'<style>' +
|
| 51 |
+
':host{all:initial;}' +
|
| 52 |
+
'*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
|
| 53 |
+
'.launcher{position:fixed;bottom:24px;right:24px;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);color:#fff;z-index:2147483000;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 28px rgba(0,0,0,.30),0 4px 12px rgba(79,140,255,.45);transition:transform .25s cubic-bezier(.2,.9,.3,1.2),box-shadow .25s;animation:antern-pop .5s cubic-bezier(.2,.9,.3,1.2);}' +
|
| 54 |
+
'.launcher::after{content:"";position:absolute;inset:0;border-radius:50%;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);z-index:-1;animation:antern-pulse 2.6s ease-out infinite;}' +
|
| 55 |
+
'.launcher.open::after{animation:none;opacity:0;}' +
|
| 56 |
+
'.launcher:hover{transform:translateY(-3px) scale(1.05);box-shadow:0 16px 38px rgba(0,0,0,.34),0 6px 18px rgba(79,140,255,.55);}' +
|
| 57 |
+
'.launcher:active{transform:translateY(-1px) scale(.97);}' +
|
| 58 |
+
'.launcher svg{width:27px;height:27px;}' +
|
| 59 |
+
'.launcher .ic-close{display:none;}' +
|
| 60 |
+
'.launcher.open .ic-chat{display:none;}' +
|
| 61 |
+
'.launcher.open .ic-close{display:block;}' +
|
| 62 |
+
'@keyframes antern-pop{0%{transform:scale(0) rotate(-25deg);}100%{transform:scale(1) rotate(0);}}' +
|
| 63 |
+
'@keyframes antern-pulse{0%{transform:scale(1);opacity:.45;}70%{transform:scale(1.7);opacity:0;}100%{opacity:0;}}' +
|
| 64 |
+
'.panel{position:fixed;bottom:92px;right:22px;width:760px;max-width:calc(100vw - 32px);height:1120px;max-height:calc(100vh - 120px);background:#0f1115;border:1px solid #2a303c;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.4);z-index:2147483000;display:none;flex-direction:column;overflow:hidden;}' +
|
| 65 |
+
'.panel.open{display:flex;}' +
|
| 66 |
+
'.head{background:#171a21;border-bottom:1px solid #2a303c;padding:13px 16px;display:flex;align-items:center;gap:10px;color:#e8eaed;}' +
|
| 67 |
+
'.head .badge{width:28px;height:28px;border-radius:8px;background:' + ACCENT + ';display:flex;align-items:center;justify-content:center;font-weight:700;}' +
|
| 68 |
+
'.head h3{margin:0;font-size:15px;flex:1;}' +
|
| 69 |
+
'.head button{background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:20px;line-height:1;}' +
|
| 70 |
+
'.body{flex:1;overflow-y:auto;padding:16px;background:#0f1115;}' +
|
| 71 |
+
'.msg{display:flex;gap:9px;margin-bottom:14px;}' +
|
| 72 |
+
'.msg .av{width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;}' +
|
| 73 |
+
'.msg.bot .av{background:' + ACCENT + ';}' +
|
| 74 |
+
'.msg.user .av{background:#374151;}' +
|
| 75 |
+
'.bubble{background:#232936;border:1px solid #2a303c;border-radius:11px;padding:10px 13px;color:#e8eaed;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;max-width:100%;}' +
|
| 76 |
+
'.msg.user .bubble{background:#1f242e;}' +
|
| 77 |
+
'.typing{color:#9aa3b2;font-style:italic;}' +
|
| 78 |
+
'.foot{border-top:1px solid #2a303c;background:#171a21;padding:11px;display:flex;gap:8px;}' +
|
| 79 |
+
'.foot textarea{flex:1;resize:none;background:#1f242e;color:#e8eaed;border:1px solid #2a303c;border-radius:9px;padding:9px 11px;font-size:14px;max-height:120px;}' +
|
| 80 |
+
'.foot textarea:focus{outline:none;border-color:' + ACCENT + ';}' +
|
| 81 |
+
'.foot button{background:' + ACCENT + ';color:#fff;border:none;border-radius:9px;padding:0 16px;font-weight:600;cursor:pointer;font-size:14px;}' +
|
| 82 |
+
'.foot button:disabled{opacity:.5;cursor:not-allowed;}' +
|
| 83 |
+
'.cap{font-size:11.5px;color:#9aa3b2;margin:10px 0 4px;}' +
|
| 84 |
+
'.tbl-wrap{margin-top:8px;max-height:260px;overflow:auto;border:1px solid #2a303c;border-radius:9px;}' +
|
| 85 |
+
'table.tbl{border-collapse:collapse;width:100%;font-size:12.5px;}' +
|
| 86 |
+
'table.tbl th,table.tbl td{padding:6px 9px;border-bottom:1px solid #2a303c;text-align:left;white-space:nowrap;}' +
|
| 87 |
+
'table.tbl th{position:sticky;top:0;background:#1f242e;color:#cbd5e1;font-weight:600;}' +
|
| 88 |
+
'table.tbl td{color:#e8eaed;}' +
|
| 89 |
+
'table.tbl tr:hover td{background:#1a1f29;}' +
|
| 90 |
+
'.chart-wrap{margin-top:8px;background:#fff;border-radius:9px;padding:10px;}' +
|
| 91 |
+
'.chart-wrap canvas{max-width:100%;}' +
|
| 92 |
+
'.stats{padding:7px 12px;background:#12151b;border-top:1px solid #2a303c;color:#9aa3b2;font-size:11px;text-align:center;}' +
|
| 93 |
+
'</style>' +
|
| 94 |
+
'<button class="launcher" aria-label="Open chat">' +
|
| 95 |
+
'<svg class="ic-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.5 7.48L3 21l2.02-5.5A8.5 8.5 0 1 1 21 11.5z"/><circle cx="8.5" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="15.5" cy="11.5" r="1" fill="currentColor" stroke="none"/></svg>' +
|
| 96 |
+
'<svg class="ic-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' +
|
| 97 |
+
'</button>' +
|
| 98 |
+
'<div class="panel" role="dialog" aria-label="' + TITLE + '">' +
|
| 99 |
+
' <div class="head"><div class="badge">A</div><h3>' + TITLE + '</h3><button class="close" aria-label="Close">×</button></div>' +
|
| 100 |
+
' <div class="body"></div>' +
|
| 101 |
+
' <div class="foot"><textarea rows="1" placeholder="Type your question…"></textarea><button class="send">Send</button></div>' +
|
| 102 |
+
' <div class="stats"></div>' +
|
| 103 |
+
'</div>';
|
| 104 |
+
|
| 105 |
+
var launcher = root.querySelector(".launcher");
|
| 106 |
+
var panel = root.querySelector(".panel");
|
| 107 |
+
var closeBtn = root.querySelector(".close");
|
| 108 |
+
var body = root.querySelector(".body");
|
| 109 |
+
var input = root.querySelector("textarea");
|
| 110 |
+
var sendBtn = root.querySelector(".send");
|
| 111 |
+
var statsEl = root.querySelector(".stats");
|
| 112 |
+
var sessionId = localStorage.getItem(STORAGE_KEY) || null;
|
| 113 |
+
var greeted = false;
|
| 114 |
+
var PALETTE = ["#4f8cff","#8a5cff","#38d39f","#ffb020","#ff6b6b","#22b8cf","#f783ac","#a0d911"];
|
| 115 |
+
var chartJsPromise = null;
|
| 116 |
+
|
| 117 |
+
function loadChartJs() {
|
| 118 |
+
if (window.Chart) return Promise.resolve(window.Chart);
|
| 119 |
+
if (chartJsPromise) return chartJsPromise;
|
| 120 |
+
chartJsPromise = new Promise(function (resolve, reject) {
|
| 121 |
+
var s = document.createElement("script");
|
| 122 |
+
s.src = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js";
|
| 123 |
+
s.onload = function () { resolve(window.Chart); };
|
| 124 |
+
s.onerror = function () { reject(new Error("chart lib failed")); };
|
| 125 |
+
document.head.appendChild(s);
|
| 126 |
+
});
|
| 127 |
+
return chartJsPromise;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function renderTable(container, p) {
|
| 131 |
+
var cols = p.columns || [], rows = p.rows || [];
|
| 132 |
+
if (p.title) { var c = document.createElement("div"); c.className = "cap"; c.textContent = p.title; container.appendChild(c); }
|
| 133 |
+
var wrap = document.createElement("div"); wrap.className = "tbl-wrap";
|
| 134 |
+
var t = document.createElement("table"); t.className = "tbl";
|
| 135 |
+
var thead = document.createElement("thead"), trh = document.createElement("tr");
|
| 136 |
+
cols.forEach(function (col) { var th = document.createElement("th"); th.textContent = col; trh.appendChild(th); });
|
| 137 |
+
thead.appendChild(trh); t.appendChild(thead);
|
| 138 |
+
var tb = document.createElement("tbody");
|
| 139 |
+
rows.forEach(function (r) {
|
| 140 |
+
var tr = document.createElement("tr");
|
| 141 |
+
r.forEach(function (v) { var td = document.createElement("td"); td.textContent = (v === null || v === undefined) ? "" : v; tr.appendChild(td); });
|
| 142 |
+
tb.appendChild(tr);
|
| 143 |
+
});
|
| 144 |
+
t.appendChild(tb); wrap.appendChild(t); container.appendChild(wrap);
|
| 145 |
+
if (p.truncated) { var n = document.createElement("div"); n.className = "cap"; n.textContent = "(showing first " + rows.length + " rows)"; container.appendChild(n); }
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function renderChart(container, p) {
|
| 149 |
+
var cols = p.columns || [], rows = p.rows || [];
|
| 150 |
+
var xi = cols.indexOf(p.x_field);
|
| 151 |
+
var yIdx = (p.y_fields || []).map(function (f) { return cols.indexOf(f); }).filter(function (i) { return i >= 0; });
|
| 152 |
+
if (xi < 0 || yIdx.length === 0) { renderTable(container, p); return; }
|
| 153 |
+
if (p.title) { var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = p.title; container.appendChild(cap); }
|
| 154 |
+
var wrap = document.createElement("div"); wrap.className = "chart-wrap";
|
| 155 |
+
var canvas = document.createElement("canvas"); wrap.appendChild(canvas); container.appendChild(wrap);
|
| 156 |
+
var labels = rows.map(function (r) { return r[xi]; });
|
| 157 |
+
loadChartJs().then(function (Chart) {
|
| 158 |
+
if (p.format === "pie") {
|
| 159 |
+
var fi = yIdx[0];
|
| 160 |
+
new Chart(canvas, {
|
| 161 |
+
type: "pie",
|
| 162 |
+
data: { labels: labels, datasets: [{ data: rows.map(function (r) { return Number(r[fi]) || 0; }), backgroundColor: labels.map(function (_, i) { return PALETTE[i % PALETTE.length]; }) }] },
|
| 163 |
+
options: { responsive: true, plugins: { legend: { position: "right" } } },
|
| 164 |
+
});
|
| 165 |
+
} else {
|
| 166 |
+
var datasets = yIdx.map(function (ci, k) {
|
| 167 |
+
return { label: cols[ci], data: rows.map(function (r) { return Number(r[ci]) || 0; }), backgroundColor: PALETTE[k % PALETTE.length], borderColor: PALETTE[k % PALETTE.length], fill: false, tension: 0.25 };
|
| 168 |
+
});
|
| 169 |
+
new Chart(canvas, {
|
| 170 |
+
type: p.format === "line" ? "line" : "bar",
|
| 171 |
+
data: { labels: labels, datasets: datasets },
|
| 172 |
+
options: { responsive: true, plugins: { legend: { display: yIdx.length > 1 } }, scales: { y: { beginAtZero: true } } },
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
body.scrollTop = body.scrollHeight;
|
| 176 |
+
}).catch(function () {
|
| 177 |
+
var e = document.createElement("div"); e.className = "cap"; e.textContent = "(couldn't load chart — showing table)"; container.appendChild(e);
|
| 178 |
+
renderTable(container, p);
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function renderPresentation(bubble, p) {
|
| 183 |
+
if (!p || !p.format || p.format === "text") return;
|
| 184 |
+
var box = document.createElement("div");
|
| 185 |
+
bubble.appendChild(box);
|
| 186 |
+
if (p.format === "table") renderTable(box, p);
|
| 187 |
+
else renderChart(box, p);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
var STATS_KEY = "antern_bot_daystats";
|
| 191 |
+
var lastTokens = null;
|
| 192 |
+
function _today() { return new Date().toISOString().slice(0, 10); }
|
| 193 |
+
function getStats() {
|
| 194 |
+
var s;
|
| 195 |
+
try { s = JSON.parse(localStorage.getItem(STATS_KEY) || "{}"); } catch (e) { s = {}; }
|
| 196 |
+
if (s.date !== _today()) s = { date: _today(), chats: 0, cost: 0 };
|
| 197 |
+
return s;
|
| 198 |
+
}
|
| 199 |
+
function bumpStats(cost) {
|
| 200 |
+
var s = getStats();
|
| 201 |
+
s.chats += 1; s.cost += (cost || 0);
|
| 202 |
+
localStorage.setItem(STATS_KEY, JSON.stringify(s));
|
| 203 |
+
}
|
| 204 |
+
function renderStats() {
|
| 205 |
+
var s = getStats();
|
| 206 |
+
var avg = s.chats ? s.cost / s.chats : 0;
|
| 207 |
+
var parts = ["Today: " + s.chats + " chat" + (s.chats === 1 ? "" : "s"),
|
| 208 |
+
"avg $" + avg.toFixed(4) + "/chat"];
|
| 209 |
+
if (lastTokens != null) parts.push("last: " + lastTokens.toLocaleString() + " tokens");
|
| 210 |
+
statsEl.textContent = parts.join(" · ");
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function esc(s) {
|
| 214 |
+
return (s == null ? "" : String(s)).replace(/[&<>"]/g, function (c) {
|
| 215 |
+
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function addMsg(role, text) {
|
| 220 |
+
var m = document.createElement("div");
|
| 221 |
+
m.className = "msg " + role;
|
| 222 |
+
m.innerHTML = '<div class="av">' + (role === "user" ? "You" : "A") + "</div><div class=\"bubble\"></div>";
|
| 223 |
+
m.querySelector(".bubble").textContent = text;
|
| 224 |
+
body.appendChild(m); body.scrollTop = body.scrollHeight; return m;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function setOpen(open) {
|
| 228 |
+
panel.classList.toggle("open", open);
|
| 229 |
+
launcher.classList.toggle("open", open);
|
| 230 |
+
launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat");
|
| 231 |
+
if (open && !greeted) { addMsg("bot", GREETING); greeted = true; }
|
| 232 |
+
if (open) { renderStats(); input.focus(); }
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
async function send(text) {
|
| 236 |
+
addMsg("user", text); sendBtn.disabled = true;
|
| 237 |
+
var m = addMsg("bot", ""); var bubble = m.querySelector(".bubble");
|
| 238 |
+
bubble.innerHTML = '<span class="typing">Thinking…</span>';
|
| 239 |
+
try {
|
| 240 |
+
var r = await fetch(API + "/api/chat", {
|
| 241 |
+
method: "POST",
|
| 242 |
+
headers: { "Content-Type": "application/json", "ngrok-skip-browser-warning": "true" },
|
| 243 |
+
body: JSON.stringify({ message: text, session_id: sessionId }),
|
| 244 |
+
});
|
| 245 |
+
var d = await r.json();
|
| 246 |
+
if (!r.ok) { bubble.innerHTML = '<span class="typing">⚠ ' + esc(d.error || "Error") + "</span>"; }
|
| 247 |
+
else {
|
| 248 |
+
sessionId = d.session_id; localStorage.setItem(STORAGE_KEY, sessionId);
|
| 249 |
+
bubble.textContent = d.answer || "(no answer)";
|
| 250 |
+
renderPresentation(bubble, d.presentation);
|
| 251 |
+
if (typeof d.cost_usd === "number") bumpStats(d.cost_usd);
|
| 252 |
+
if (typeof d.tokens === "number") lastTokens = d.tokens;
|
| 253 |
+
renderStats();
|
| 254 |
+
}
|
| 255 |
+
} catch (e) { bubble.innerHTML = '<span class="typing">⚠ Network error</span>'; }
|
| 256 |
+
finally { sendBtn.disabled = false; body.scrollTop = body.scrollHeight; }
|
| 257 |
+
}
|
| 258 |
+
function submit() { var t = input.value.trim(); if (!t) return; input.value = ""; input.style.height = "auto"; send(t); }
|
| 259 |
+
launcher.addEventListener("click", function () { setOpen(!panel.classList.contains("open")); });
|
| 260 |
+
closeBtn.addEventListener("click", function () { setOpen(false); });
|
| 261 |
+
sendBtn.addEventListener("click", submit);
|
| 262 |
+
input.addEventListener("keydown", function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } });
|
| 263 |
+
input.addEventListener("input", function () { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 120) + "px"; });
|
| 264 |
+
|
| 265 |
+
renderStats();
|
| 266 |
+
})();
|
| 267 |
+
</script>
|
| 268 |
+
</body>
|
| 269 |
+
</html>
|
static/widget.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Antern Bot embeddable chat widget.
|
| 2 |
+
*
|
| 3 |
+
* Add to any page:
|
| 4 |
+
* <script src="https://YOUR_BOT_HOST/static/widget.js"
|
| 5 |
+
* data-api="https://YOUR_BOT_HOST"
|
| 6 |
+
* data-title="Antern Bot"
|
| 7 |
+
* data-accent="#4f8cff"
|
| 8 |
+
* data-greeting="Hi! Ask me anything about your data."></script>
|
| 9 |
+
*
|
| 10 |
+
* The widget renders a floating button + chat panel. It calls
|
| 11 |
+
* `${data-api}/api/chat` and keeps a session id in localStorage.
|
| 12 |
+
*/
|
| 13 |
+
(function () {
|
| 14 |
+
var script = document.currentScript;
|
| 15 |
+
// data-api is the bot's base URL. Empty/omitted => same origin as this page
|
| 16 |
+
// (only works when the page is served by the bot itself, e.g. the demo).
|
| 17 |
+
var API = (script.getAttribute("data-api") || "").replace(/\/$/, "");
|
| 18 |
+
var TITLE = script.getAttribute("data-title") || "Antern Bot";
|
| 19 |
+
var ACCENT = script.getAttribute("data-accent") || "#4f8cff";
|
| 20 |
+
var GREETING =
|
| 21 |
+
script.getAttribute("data-greeting") ||
|
| 22 |
+
"👋 Hi! I'm Antern Bot, your data assistant.\n\nAsk me anything about your recruitment data — candidates, requirements, applications, interviews — in plain English.\n\nTry: “How many active candidates do we have?”";
|
| 23 |
+
var STORAGE_KEY = "antern_bot_session";
|
| 24 |
+
|
| 25 |
+
if (!script.hasAttribute("data-api")) {
|
| 26 |
+
console.warn(
|
| 27 |
+
"[antern-bot] No data-api attribute set; calling same origin. " +
|
| 28 |
+
"When embedding on another site, set data-api to your bot's URL."
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// --- Host element + shadow root (style isolation) ---
|
| 33 |
+
var host = document.createElement("div");
|
| 34 |
+
host.id = "antern-bot-widget";
|
| 35 |
+
document.body.appendChild(host);
|
| 36 |
+
var root = host.attachShadow({ mode: "open" });
|
| 37 |
+
|
| 38 |
+
root.innerHTML =
|
| 39 |
+
'<style>' +
|
| 40 |
+
':host{all:initial;}' +
|
| 41 |
+
'*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
|
| 42 |
+
'.launcher{position:fixed;bottom:24px;right:24px;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);color:#fff;z-index:2147483000;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 28px rgba(0,0,0,.30),0 4px 12px rgba(79,140,255,.45);transition:transform .25s cubic-bezier(.2,.9,.3,1.2),box-shadow .25s;animation:antern-pop .5s cubic-bezier(.2,.9,.3,1.2);}' +
|
| 43 |
+
'.launcher::after{content:"";position:absolute;inset:0;border-radius:50%;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);z-index:-1;animation:antern-pulse 2.6s ease-out infinite;}' +
|
| 44 |
+
'.launcher.open::after{animation:none;opacity:0;}' +
|
| 45 |
+
'.launcher:hover{transform:translateY(-3px) scale(1.05);box-shadow:0 16px 38px rgba(0,0,0,.34),0 6px 18px rgba(79,140,255,.55);}' +
|
| 46 |
+
'.launcher:active{transform:translateY(-1px) scale(.97);}' +
|
| 47 |
+
'.launcher svg{width:27px;height:27px;}' +
|
| 48 |
+
'.launcher .ic-close{display:none;}' +
|
| 49 |
+
'.launcher.open .ic-chat{display:none;}' +
|
| 50 |
+
'.launcher.open .ic-close{display:block;}' +
|
| 51 |
+
'@keyframes antern-pop{0%{transform:scale(0) rotate(-25deg);}100%{transform:scale(1) rotate(0);}}' +
|
| 52 |
+
'@keyframes antern-pulse{0%{transform:scale(1);opacity:.45;}70%{transform:scale(1.7);opacity:0;}100%{opacity:0;}}' +
|
| 53 |
+
'.panel{position:fixed;bottom:92px;right:22px;width:760px;max-width:calc(100vw - 32px);height:1120px;max-height:calc(100vh - 120px);background:#0f1115;border:1px solid #2a303c;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.4);z-index:2147483000;display:none;flex-direction:column;overflow:hidden;}' +
|
| 54 |
+
'.panel.open{display:flex;}' +
|
| 55 |
+
'.head{background:#171a21;border-bottom:1px solid #2a303c;padding:13px 16px;display:flex;align-items:center;gap:10px;color:#e8eaed;}' +
|
| 56 |
+
'.head .badge{width:28px;height:28px;border-radius:8px;background:' + ACCENT + ';display:flex;align-items:center;justify-content:center;font-weight:700;}' +
|
| 57 |
+
'.head h3{margin:0;font-size:15px;flex:1;}' +
|
| 58 |
+
'.head button{background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:20px;line-height:1;}' +
|
| 59 |
+
'.body{flex:1;overflow-y:auto;padding:16px;background:#0f1115;}' +
|
| 60 |
+
'.msg{display:flex;gap:9px;margin-bottom:14px;}' +
|
| 61 |
+
'.msg .av{width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;}' +
|
| 62 |
+
'.msg.bot .av{background:' + ACCENT + ';}' +
|
| 63 |
+
'.msg.user .av{background:#374151;}' +
|
| 64 |
+
'.bubble{background:#232936;border:1px solid #2a303c;border-radius:11px;padding:10px 13px;color:#e8eaed;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;max-width:100%;}' +
|
| 65 |
+
'.msg.user .bubble{background:#1f242e;}' +
|
| 66 |
+
'.typing{color:#9aa3b2;font-style:italic;}' +
|
| 67 |
+
'.foot{border-top:1px solid #2a303c;background:#171a21;padding:11px;display:flex;gap:8px;}' +
|
| 68 |
+
'.foot textarea{flex:1;resize:none;background:#1f242e;color:#e8eaed;border:1px solid #2a303c;border-radius:9px;padding:9px 11px;font-size:14px;max-height:120px;}' +
|
| 69 |
+
'.foot textarea:focus{outline:none;border-color:' + ACCENT + ';}' +
|
| 70 |
+
'.foot button{background:' + ACCENT + ';color:#fff;border:none;border-radius:9px;padding:0 16px;font-weight:600;cursor:pointer;font-size:14px;}' +
|
| 71 |
+
'.foot button:disabled{opacity:.5;cursor:not-allowed;}' +
|
| 72 |
+
'.cap{font-size:11.5px;color:#9aa3b2;margin:10px 0 4px;}' +
|
| 73 |
+
'.tbl-wrap{margin-top:8px;max-height:260px;overflow:auto;border:1px solid #2a303c;border-radius:9px;}' +
|
| 74 |
+
'table.tbl{border-collapse:collapse;width:100%;font-size:12.5px;}' +
|
| 75 |
+
'table.tbl th,table.tbl td{padding:6px 9px;border-bottom:1px solid #2a303c;text-align:left;white-space:nowrap;}' +
|
| 76 |
+
'table.tbl th{position:sticky;top:0;background:#1f242e;color:#cbd5e1;font-weight:600;}' +
|
| 77 |
+
'table.tbl td{color:#e8eaed;}' +
|
| 78 |
+
'table.tbl tr:hover td{background:#1a1f29;}' +
|
| 79 |
+
'.chart-wrap{margin-top:8px;background:#fff;border-radius:9px;padding:10px;}' +
|
| 80 |
+
'.chart-wrap canvas{max-width:100%;}' +
|
| 81 |
+
'.stats{padding:7px 12px;background:#12151b;border-top:1px solid #2a303c;color:#9aa3b2;font-size:11px;text-align:center;}' +
|
| 82 |
+
'</style>' +
|
| 83 |
+
'<button class="launcher" aria-label="Open chat">' +
|
| 84 |
+
'<svg class="ic-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.5 7.48L3 21l2.02-5.5A8.5 8.5 0 1 1 21 11.5z"/><circle cx="8.5" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="15.5" cy="11.5" r="1" fill="currentColor" stroke="none"/></svg>' +
|
| 85 |
+
'<svg class="ic-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' +
|
| 86 |
+
'</button>' +
|
| 87 |
+
'<div class="panel" role="dialog" aria-label="' + TITLE + '">' +
|
| 88 |
+
' <div class="head"><div class="badge">A</div><h3>' + TITLE + '</h3><button class="close" aria-label="Close">×</button></div>' +
|
| 89 |
+
' <div class="body"></div>' +
|
| 90 |
+
' <div class="foot"><textarea rows="1" placeholder="Type your question…"></textarea><button class="send">Send</button></div>' +
|
| 91 |
+
' <div class="stats"></div>' +
|
| 92 |
+
'</div>';
|
| 93 |
+
|
| 94 |
+
var launcher = root.querySelector(".launcher");
|
| 95 |
+
var panel = root.querySelector(".panel");
|
| 96 |
+
var closeBtn = root.querySelector(".close");
|
| 97 |
+
var body = root.querySelector(".body");
|
| 98 |
+
var input = root.querySelector("textarea");
|
| 99 |
+
var sendBtn = root.querySelector(".send");
|
| 100 |
+
var statsEl = root.querySelector(".stats");
|
| 101 |
+
var sessionId = localStorage.getItem(STORAGE_KEY) || null;
|
| 102 |
+
var greeted = false;
|
| 103 |
+
var PALETTE = ["#4f8cff","#8a5cff","#38d39f","#ffb020","#ff6b6b","#22b8cf","#f783ac","#a0d911"];
|
| 104 |
+
var chartJsPromise = null;
|
| 105 |
+
|
| 106 |
+
function loadChartJs() {
|
| 107 |
+
if (window.Chart) return Promise.resolve(window.Chart);
|
| 108 |
+
if (chartJsPromise) return chartJsPromise;
|
| 109 |
+
chartJsPromise = new Promise(function (resolve, reject) {
|
| 110 |
+
var s = document.createElement("script");
|
| 111 |
+
s.src = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js";
|
| 112 |
+
s.onload = function () { resolve(window.Chart); };
|
| 113 |
+
s.onerror = function () { reject(new Error("chart lib failed")); };
|
| 114 |
+
document.head.appendChild(s);
|
| 115 |
+
});
|
| 116 |
+
return chartJsPromise;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function renderTable(container, p) {
|
| 120 |
+
var cols = p.columns || [], rows = p.rows || [];
|
| 121 |
+
if (p.title) { var c = document.createElement("div"); c.className = "cap"; c.textContent = p.title; container.appendChild(c); }
|
| 122 |
+
var wrap = document.createElement("div"); wrap.className = "tbl-wrap";
|
| 123 |
+
var t = document.createElement("table"); t.className = "tbl";
|
| 124 |
+
var thead = document.createElement("thead"), trh = document.createElement("tr");
|
| 125 |
+
cols.forEach(function (col) { var th = document.createElement("th"); th.textContent = col; trh.appendChild(th); });
|
| 126 |
+
thead.appendChild(trh); t.appendChild(thead);
|
| 127 |
+
var tb = document.createElement("tbody");
|
| 128 |
+
rows.forEach(function (r) {
|
| 129 |
+
var tr = document.createElement("tr");
|
| 130 |
+
r.forEach(function (v) { var td = document.createElement("td"); td.textContent = (v === null || v === undefined) ? "" : v; tr.appendChild(td); });
|
| 131 |
+
tb.appendChild(tr);
|
| 132 |
+
});
|
| 133 |
+
t.appendChild(tb); wrap.appendChild(t); container.appendChild(wrap);
|
| 134 |
+
if (p.truncated) { var n = document.createElement("div"); n.className = "cap"; n.textContent = "(showing first " + rows.length + " rows)"; container.appendChild(n); }
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function renderChart(container, p) {
|
| 138 |
+
var cols = p.columns || [], rows = p.rows || [];
|
| 139 |
+
var xi = cols.indexOf(p.x_field);
|
| 140 |
+
var yIdx = (p.y_fields || []).map(function (f) { return cols.indexOf(f); }).filter(function (i) { return i >= 0; });
|
| 141 |
+
if (xi < 0 || yIdx.length === 0) { renderTable(container, p); return; } // fall back
|
| 142 |
+
if (p.title) { var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = p.title; container.appendChild(cap); }
|
| 143 |
+
var wrap = document.createElement("div"); wrap.className = "chart-wrap";
|
| 144 |
+
var canvas = document.createElement("canvas"); wrap.appendChild(canvas); container.appendChild(wrap);
|
| 145 |
+
var labels = rows.map(function (r) { return r[xi]; });
|
| 146 |
+
loadChartJs().then(function (Chart) {
|
| 147 |
+
if (p.format === "pie") {
|
| 148 |
+
var fi = yIdx[0];
|
| 149 |
+
new Chart(canvas, {
|
| 150 |
+
type: "pie",
|
| 151 |
+
data: { labels: labels, datasets: [{ data: rows.map(function (r) { return Number(r[fi]) || 0; }), backgroundColor: labels.map(function (_, i) { return PALETTE[i % PALETTE.length]; }) }] },
|
| 152 |
+
options: { responsive: true, plugins: { legend: { position: "right" } } },
|
| 153 |
+
});
|
| 154 |
+
} else {
|
| 155 |
+
var datasets = yIdx.map(function (ci, k) {
|
| 156 |
+
return { label: cols[ci], data: rows.map(function (r) { return Number(r[ci]) || 0; }), backgroundColor: PALETTE[k % PALETTE.length], borderColor: PALETTE[k % PALETTE.length], fill: false, tension: 0.25 };
|
| 157 |
+
});
|
| 158 |
+
new Chart(canvas, {
|
| 159 |
+
type: p.format === "line" ? "line" : "bar",
|
| 160 |
+
data: { labels: labels, datasets: datasets },
|
| 161 |
+
options: { responsive: true, plugins: { legend: { display: yIdx.length > 1 } }, scales: { y: { beginAtZero: true } } },
|
| 162 |
+
});
|
| 163 |
+
}
|
| 164 |
+
body.scrollTop = body.scrollHeight;
|
| 165 |
+
}).catch(function () {
|
| 166 |
+
var e = document.createElement("div"); e.className = "cap"; e.textContent = "(couldn't load chart — showing table)"; container.appendChild(e);
|
| 167 |
+
renderTable(container, p);
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function renderPresentation(bubble, p) {
|
| 172 |
+
if (!p || !p.format || p.format === "text") return;
|
| 173 |
+
var box = document.createElement("div");
|
| 174 |
+
bubble.appendChild(box);
|
| 175 |
+
if (p.format === "table") renderTable(box, p);
|
| 176 |
+
else renderChart(box, p);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
var STATS_KEY = "antern_bot_daystats";
|
| 180 |
+
var lastTokens = null;
|
| 181 |
+
function _today() { return new Date().toISOString().slice(0, 10); }
|
| 182 |
+
function getStats() {
|
| 183 |
+
var s;
|
| 184 |
+
try { s = JSON.parse(localStorage.getItem(STATS_KEY) || "{}"); } catch (e) { s = {}; }
|
| 185 |
+
if (s.date !== _today()) s = { date: _today(), chats: 0, cost: 0 };
|
| 186 |
+
return s;
|
| 187 |
+
}
|
| 188 |
+
function bumpStats(cost) {
|
| 189 |
+
var s = getStats();
|
| 190 |
+
s.chats += 1; s.cost += (cost || 0);
|
| 191 |
+
localStorage.setItem(STATS_KEY, JSON.stringify(s));
|
| 192 |
+
}
|
| 193 |
+
function renderStats() {
|
| 194 |
+
var s = getStats();
|
| 195 |
+
var avg = s.chats ? s.cost / s.chats : 0;
|
| 196 |
+
var parts = ["Today: " + s.chats + " chat" + (s.chats === 1 ? "" : "s"),
|
| 197 |
+
"avg $" + avg.toFixed(4) + "/chat"];
|
| 198 |
+
if (lastTokens != null) parts.push("last: " + lastTokens.toLocaleString() + " tokens");
|
| 199 |
+
statsEl.textContent = parts.join(" · ");
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
function esc(s) {
|
| 203 |
+
return (s == null ? "" : String(s)).replace(/[&<>"]/g, function (c) {
|
| 204 |
+
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function addMsg(role, text) {
|
| 209 |
+
var m = document.createElement("div");
|
| 210 |
+
m.className = "msg " + role;
|
| 211 |
+
m.innerHTML =
|
| 212 |
+
'<div class="av">' + (role === "user" ? "You" : "A") + "</div>" +
|
| 213 |
+
'<div class="bubble"></div>';
|
| 214 |
+
m.querySelector(".bubble").textContent = text;
|
| 215 |
+
body.appendChild(m);
|
| 216 |
+
body.scrollTop = body.scrollHeight;
|
| 217 |
+
return m;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function setOpen(open) {
|
| 221 |
+
panel.classList.toggle("open", open);
|
| 222 |
+
launcher.classList.toggle("open", open);
|
| 223 |
+
launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat");
|
| 224 |
+
if (open && !greeted) { addMsg("bot", GREETING); greeted = true; }
|
| 225 |
+
if (open) { renderStats(); input.focus(); }
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
async function send(text) {
|
| 229 |
+
addMsg("user", text);
|
| 230 |
+
sendBtn.disabled = true;
|
| 231 |
+
var m = addMsg("bot", "");
|
| 232 |
+
var bubble = m.querySelector(".bubble");
|
| 233 |
+
bubble.innerHTML = '<span class="typing">Thinking…</span>';
|
| 234 |
+
try {
|
| 235 |
+
var r = await fetch(API + "/api/chat", {
|
| 236 |
+
method: "POST",
|
| 237 |
+
headers: {
|
| 238 |
+
"Content-Type": "application/json",
|
| 239 |
+
// Skip ngrok's free-tier browser-warning interstitial on API calls.
|
| 240 |
+
// Harmless on other hosts.
|
| 241 |
+
"ngrok-skip-browser-warning": "true",
|
| 242 |
+
},
|
| 243 |
+
body: JSON.stringify({ message: text, session_id: sessionId }),
|
| 244 |
+
});
|
| 245 |
+
var d = await r.json();
|
| 246 |
+
if (!r.ok) {
|
| 247 |
+
bubble.innerHTML = '<span class="typing">⚠ ' + esc(d.error || "Error") + "</span>";
|
| 248 |
+
} else {
|
| 249 |
+
sessionId = d.session_id;
|
| 250 |
+
localStorage.setItem(STORAGE_KEY, sessionId);
|
| 251 |
+
bubble.textContent = d.answer || "(no answer)";
|
| 252 |
+
renderPresentation(bubble, d.presentation);
|
| 253 |
+
if (typeof d.cost_usd === "number") bumpStats(d.cost_usd);
|
| 254 |
+
if (typeof d.tokens === "number") lastTokens = d.tokens;
|
| 255 |
+
renderStats();
|
| 256 |
+
}
|
| 257 |
+
} catch (e) {
|
| 258 |
+
bubble.innerHTML = '<span class="typing">⚠ Network error</span>';
|
| 259 |
+
} finally {
|
| 260 |
+
sendBtn.disabled = false;
|
| 261 |
+
body.scrollTop = body.scrollHeight;
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function submit() {
|
| 266 |
+
var t = input.value.trim();
|
| 267 |
+
if (!t) return;
|
| 268 |
+
input.value = "";
|
| 269 |
+
input.style.height = "auto";
|
| 270 |
+
send(t);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
launcher.addEventListener("click", function () { setOpen(!panel.classList.contains("open")); });
|
| 274 |
+
closeBtn.addEventListener("click", function () { setOpen(false); });
|
| 275 |
+
sendBtn.addEventListener("click", submit);
|
| 276 |
+
input.addEventListener("keydown", function (e) {
|
| 277 |
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
| 278 |
+
});
|
| 279 |
+
input.addEventListener("input", function () {
|
| 280 |
+
input.style.height = "auto";
|
| 281 |
+
input.style.height = Math.min(input.scrollHeight, 120) + "px";
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
renderStats();
|
| 285 |
+
})();
|