johnpitteera commited on
Commit
2dd2de0
·
verified ·
1 Parent(s): 87b67e4

Upload folder using huggingface_hub

Browse files
Files changed (17) hide show
  1. .dockerignore +10 -0
  2. .env.example +29 -0
  3. .gitignore +7 -0
  4. Dockerfile +29 -0
  5. agent.py +303 -0
  6. app.py +172 -0
  7. config.py +66 -0
  8. db.py +222 -0
  9. monitoring.py +248 -0
  10. requirements.txt +6 -0
  11. run.ps1 +2 -0
  12. static/demo.html +35 -0
  13. static/embed-inline.html +279 -0
  14. static/index.html +200 -0
  15. static/metrics.html +153 -0
  16. static/preview.html +269 -0
  17. 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>&lt;script&gt;</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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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&nbsp;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." &nbsp;·&nbsp;
28
+ <b>(bar chart):</b> "Top 5 designations by number of candidates." &nbsp;·&nbsp;
29
+ <b>(pie chart):</b> "Break down requirements by status." &nbsp;·&nbsp;
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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
+ })();