Spaces:
Running
Running
Initial Towards AI helper chatbot
Browse files- .env.example +25 -0
- .github/workflows/ci.yml +76 -0
- .github/workflows/keepalive.yml +24 -0
- .gitignore +7 -0
- Dockerfile +19 -0
- README.md +53 -5
- data/assistant_notes.json +92 -0
- data/discovered_urls.json +48 -0
- data/pages.json +0 -0
- pyproject.toml +39 -0
- static/widget.js +466 -0
- tai_helper/__init__.py +1 -0
- tai_helper/api.py +294 -0
- tai_helper/catalog.py +206 -0
- tai_helper/llm.py +119 -0
- tai_helper/monitoring.py +138 -0
- tai_helper/rate_limiter.py +42 -0
- tai_helper/schemas.py +39 -0
- tai_helper/settings.py +102 -0
- tests/test_api.py +157 -0
- tests/test_catalog.py +50 -0
- tests/test_live_smoke.py +94 -0
.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Public domains where the helper widget is allowed to run.
|
| 2 |
+
HELPER_ALLOWED_ORIGINS=https://academy.towardsai.net,https://towardsai.net,https://www.towardsai.net
|
| 3 |
+
HELPER_ALLOWED_HOSTS=academy.towardsai.net,towardsai.net,www.towardsai.net
|
| 4 |
+
|
| 5 |
+
# Gemini.
|
| 6 |
+
GEMINI_API_KEY=
|
| 7 |
+
HELPER_MODEL=gemini-2.5-flash
|
| 8 |
+
HELPER_MAX_OUTPUT_TOKENS=420
|
| 9 |
+
|
| 10 |
+
# Hard public rate limits.
|
| 11 |
+
HELPER_RATE_LIMIT_PER_MINUTE=3
|
| 12 |
+
HELPER_RATE_LIMIT_PER_DAY=20
|
| 13 |
+
HELPER_GLOBAL_RATE_LIMIT_PER_MINUTE=120
|
| 14 |
+
|
| 15 |
+
# Request caps.
|
| 16 |
+
HELPER_MAX_BODY_BYTES=65536
|
| 17 |
+
HELPER_MAX_QUERY_CHARS=600
|
| 18 |
+
HELPER_MAX_HISTORY_TURNS=8
|
| 19 |
+
|
| 20 |
+
# Optional Opik monitoring.
|
| 21 |
+
OPIK_ENABLED=false
|
| 22 |
+
OPIK_PROJECT_NAME=towards-ai-helper
|
| 23 |
+
OPIK_WORKSPACE=
|
| 24 |
+
OPIK_API_KEY=
|
| 25 |
+
OPIK_MAX_TEXT_CHARS=4000
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: ["**"]
|
| 6 |
+
pull_request:
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
inputs:
|
| 9 |
+
run_live_chat:
|
| 10 |
+
description: "Run the live /chat smoke test against the deployed Space"
|
| 11 |
+
required: false
|
| 12 |
+
default: "false"
|
| 13 |
+
type: choice
|
| 14 |
+
options:
|
| 15 |
+
- "false"
|
| 16 |
+
- "true"
|
| 17 |
+
schedule:
|
| 18 |
+
- cron: "41 */6 * * *"
|
| 19 |
+
|
| 20 |
+
jobs:
|
| 21 |
+
fast-tests:
|
| 22 |
+
name: Fast Tests
|
| 23 |
+
runs-on: ubuntu-latest
|
| 24 |
+
timeout-minutes: 15
|
| 25 |
+
|
| 26 |
+
steps:
|
| 27 |
+
- name: Checkout
|
| 28 |
+
uses: actions/checkout@v4
|
| 29 |
+
|
| 30 |
+
- name: Set up Python
|
| 31 |
+
uses: actions/setup-python@v5
|
| 32 |
+
with:
|
| 33 |
+
python-version: "3.13"
|
| 34 |
+
|
| 35 |
+
- name: Install uv
|
| 36 |
+
uses: astral-sh/setup-uv@v5
|
| 37 |
+
|
| 38 |
+
- name: Install dependencies
|
| 39 |
+
run: uv sync --all-groups
|
| 40 |
+
|
| 41 |
+
- name: Ruff
|
| 42 |
+
run: uv run ruff check .
|
| 43 |
+
|
| 44 |
+
- name: Widget syntax
|
| 45 |
+
run: node --check static/widget.js
|
| 46 |
+
|
| 47 |
+
- name: Pytest
|
| 48 |
+
run: uv run pytest -m "not live" -p no:cacheprovider tests
|
| 49 |
+
|
| 50 |
+
live-smoke:
|
| 51 |
+
name: Live HF Smoke
|
| 52 |
+
runs-on: ubuntu-latest
|
| 53 |
+
timeout-minutes: 10
|
| 54 |
+
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
| 55 |
+
|
| 56 |
+
env:
|
| 57 |
+
LIVE_SPACE_BASE_URL: ${{ vars.LIVE_SPACE_BASE_URL || 'https://towardsai-tutors-tai-helper.hf.space' }}
|
| 58 |
+
RUN_LIVE_CHAT_SMOKE: ${{ github.event.inputs.run_live_chat || 'false' }}
|
| 59 |
+
|
| 60 |
+
steps:
|
| 61 |
+
- name: Checkout
|
| 62 |
+
uses: actions/checkout@v4
|
| 63 |
+
|
| 64 |
+
- name: Set up Python
|
| 65 |
+
uses: actions/setup-python@v5
|
| 66 |
+
with:
|
| 67 |
+
python-version: "3.13"
|
| 68 |
+
|
| 69 |
+
- name: Install uv
|
| 70 |
+
uses: astral-sh/setup-uv@v5
|
| 71 |
+
|
| 72 |
+
- name: Install dependencies
|
| 73 |
+
run: uv sync --all-groups
|
| 74 |
+
|
| 75 |
+
- name: Live smoke tests
|
| 76 |
+
run: uv run pytest -m live -p no:cacheprovider tests/test_live_smoke.py
|
.github/workflows/keepalive.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: HF Space Keepalive
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
schedule:
|
| 6 |
+
- cron: "29 */12 * * *"
|
| 7 |
+
|
| 8 |
+
env:
|
| 9 |
+
SPACE_BASE_URL: https://towardsai-tutors-tai-helper.hf.space
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
ping:
|
| 13 |
+
name: Ping HF Space
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
timeout-minutes: 5
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- name: Health check
|
| 19 |
+
run: |
|
| 20 |
+
curl --fail --show-error --silent --max-time 180 "$SPACE_BASE_URL/healthz"
|
| 21 |
+
|
| 22 |
+
- name: Widget check
|
| 23 |
+
run: |
|
| 24 |
+
curl --fail --show-error --silent --max-time 60 "$SPACE_BASE_URL/widget.js" | grep -q "Towards AI Helper"
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.ruff_cache/
|
| 7 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.13-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PORT=7860
|
| 6 |
+
|
| 7 |
+
WORKDIR /opt/tai-helper
|
| 8 |
+
|
| 9 |
+
COPY pyproject.toml README.md ./
|
| 10 |
+
COPY tai_helper ./tai_helper
|
| 11 |
+
COPY static ./static
|
| 12 |
+
COPY data ./data
|
| 13 |
+
|
| 14 |
+
RUN pip install --no-cache-dir uv \
|
| 15 |
+
&& uv pip install --system -e .
|
| 16 |
+
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
CMD ["uvicorn", "tai_helper.api:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,58 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🐢
|
| 4 |
-
colorFrom: indigo
|
| 5 |
-
colorTo: gray
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Towards AI Helper
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
pinned: false
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# Towards AI Helper
|
| 9 |
+
|
| 10 |
+
Public sales helper for anonymous visitors on Towards AI Academy and
|
| 11 |
+
TowardsAI.net. It helps prospective students choose courses, bundles,
|
| 12 |
+
mentorship, free resources, the book, or B2B training/consulting.
|
| 13 |
+
|
| 14 |
+
It is deliberately separate from the Thinkific lesson tutor:
|
| 15 |
+
|
| 16 |
+
- This helper appears only on public, sitemap-discoverable pages.
|
| 17 |
+
- It hides when a visitor appears signed in.
|
| 18 |
+
- The first message must be one of the fixed prompt buttons.
|
| 19 |
+
- It uses Gemini 2.5 Flash with strict rate limits and concise answers.
|
| 20 |
+
- It does not answer general AI questions or give away course lesson content.
|
| 21 |
+
|
| 22 |
+
## Local Setup
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
cd /Users/louis/Documents/GitHub/tai-helper
|
| 26 |
+
uv sync
|
| 27 |
+
cp .env.example .env
|
| 28 |
+
uv run uvicorn tai_helper.api:app --host 0.0.0.0 --port 8001
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Public Widget Snippet
|
| 32 |
+
|
| 33 |
+
Add this to public site footer code on `academy.towardsai.net` and
|
| 34 |
+
`towardsai.net`:
|
| 35 |
+
|
| 36 |
+
```html
|
| 37 |
+
<script
|
| 38 |
+
src="https://YOUR-HF-SPACE/widget.js"
|
| 39 |
+
data-api-base="https://YOUR-HF-SPACE"
|
| 40 |
+
defer
|
| 41 |
+
></script>
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
The widget fetches `/api/helper/config`, checks the current public URL, hides on
|
| 45 |
+
signed-in sessions, and starts as a bottom-right `Ask the helper` bubble.
|
| 46 |
+
|
| 47 |
+
## Monitoring
|
| 48 |
+
|
| 49 |
+
Enable Opik:
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
OPIK_ENABLED=true
|
| 53 |
+
OPIK_API_KEY=...
|
| 54 |
+
OPIK_PROJECT_NAME=towards-ai-helper
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Traces include URL, selected prompt, model, token usage, latency, and sources.
|
| 58 |
+
They intentionally avoid logging the full page dataset.
|
data/assistant_notes.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"assistant_name": "Towards AI Helper",
|
| 3 |
+
"contact_email": "louis@towardsai.net",
|
| 4 |
+
"primary_goal": "Help prospective students and companies choose Towards AI courses, bundles, mentorship, free resources, book resources, or B2B training/consulting.",
|
| 5 |
+
"forced_prompts": [
|
| 6 |
+
"I want help deciding which course to take.",
|
| 7 |
+
"I want help to integrate AI into my company",
|
| 8 |
+
"I want a training inside my company",
|
| 9 |
+
"I'm looking for more free resources to learn before committing to buying a course",
|
| 10 |
+
"I want to find mentors"
|
| 11 |
+
],
|
| 12 |
+
"guardrails": [
|
| 13 |
+
"Only answer questions related to Towards AI courses, bundles, mentorship, B2B training/consulting, the book, community, or free learning resources.",
|
| 14 |
+
"Do not teach valuable course lesson content, solve coding homework, or provide general AI tutoring. Briefly explain that the helper is for choosing the right Towards AI path and redirect to relevant pages.",
|
| 15 |
+
"Keep answers concise and useful to control costs.",
|
| 16 |
+
"For B2B, company training, and consulting leads, ask for company/team context and recommend emailing louis@towardsai.net.",
|
| 17 |
+
"For coupon code requests, do not provide a code. If the user insists, tell them to email louis@towardsai.net with context and what they need.",
|
| 18 |
+
"For eager learners who want broad access, recommend the Get it all bundle as the strongest value option."
|
| 19 |
+
],
|
| 20 |
+
"course_routing": [
|
| 21 |
+
{
|
| 22 |
+
"name": "Get it all! From Novice to Expert",
|
| 23 |
+
"url": "https://academy.towardsai.net/bundles/get-it-all",
|
| 24 |
+
"when_to_recommend": "Best value for motivated learners who want broad access across the academy or are serious about going from fundamentals to advanced AI engineering."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"name": "Python for GenAI / Become a LLM Developer and Python Pro",
|
| 28 |
+
"url": "https://academy.towardsai.net/courses/python-for-genai",
|
| 29 |
+
"when_to_recommend": "Best first step for beginners, non-coders, or people who need Python foundations before building LLM products."
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"name": "Full Stack AI Engineering / Beginner to Advanced LLM Dev",
|
| 33 |
+
"url": "https://academy.towardsai.net/courses/beginner-to-advanced-llm-dev",
|
| 34 |
+
"when_to_recommend": "Best for builders who want to ship LLM products with RAG, context engineering, deployment, and product-oriented AI engineering."
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "Agent Engineering: Building Multi-Agent Systems",
|
| 38 |
+
"url": "https://academy.towardsai.net/courses/agent-engineering",
|
| 39 |
+
"when_to_recommend": "Best for intermediate to advanced engineers who know Python/APIs and want production-grade autonomous agents, evaluation, monitoring, and deployment."
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"name": "No-Code AI Mastery for Businesses and Professionals",
|
| 43 |
+
"url": "https://academy.towardsai.net/courses/ai-business-professionals",
|
| 44 |
+
"when_to_recommend": "Best for business users, operators, founders, and professionals who want practical AI workflows without heavy coding."
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"name": "10-Hours LLM Fundamentals",
|
| 48 |
+
"url": "https://academy.towardsai.net/courses/llm-primer",
|
| 49 |
+
"when_to_recommend": "Best quick primer for LLM fundamentals before taking a deeper course."
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "Building LLMs for Production",
|
| 53 |
+
"url": "https://academy.towardsai.net/courses/buildingllmsforproduction",
|
| 54 |
+
"when_to_recommend": "Best for learners who want production LLM engineering concepts connected to the Towards AI book/resources."
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"name": "Towards AI Mentorship",
|
| 58 |
+
"url": "https://academy.towardsai.net/bundles/tai-mentorship",
|
| 59 |
+
"when_to_recommend": "Best when the user asks for mentors, personalized guidance, accountability, project feedback, or career/course direction."
|
| 60 |
+
}
|
| 61 |
+
],
|
| 62 |
+
"external_resources": [
|
| 63 |
+
{
|
| 64 |
+
"name": "What's AI YouTube",
|
| 65 |
+
"url": "https://www.youtube.com/channel/UCQNjFuhOJM1YqFTPY1Q_kYQ",
|
| 66 |
+
"when_to_recommend": "Free AI videos/tutorials before buying a course."
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"name": "Towards AI Resource Library",
|
| 70 |
+
"url": "https://towardsai.net/towards-ai-resource-library",
|
| 71 |
+
"when_to_recommend": "Free articles, guides, and learning resources."
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "Towards AI book resources",
|
| 75 |
+
"url": "https://towardsai.net/book",
|
| 76 |
+
"when_to_recommend": "Book companion resources and production LLM learning path."
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"name": "Book on Amazon",
|
| 80 |
+
"url": "https://www.amazon.com/s?k=Towards+AI+Building+LLMs+for+Production",
|
| 81 |
+
"when_to_recommend": "Purchase/search destination for the Towards AI book on Amazon."
|
| 82 |
+
}
|
| 83 |
+
],
|
| 84 |
+
"b2b": {
|
| 85 |
+
"urls": [
|
| 86 |
+
"https://towardsai.net/b2b",
|
| 87 |
+
"https://towardsai.net/let-us-transform-your-team-into-ai-first-employees-to-stay-ahead-of-competitors"
|
| 88 |
+
],
|
| 89 |
+
"email": "louis@towardsai.net",
|
| 90 |
+
"instruction": "Ask for company size, team background, use cases, desired training outcome, and timeline. Suggest emailing louis@towardsai.net with details or continuing in chat."
|
| 91 |
+
}
|
| 92 |
+
}
|
data/discovered_urls.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"academy_sitemap_url": "https://academy.towardsai.net/sitemap.xml",
|
| 3 |
+
"towardsai_page_sitemap_url": "https://towardsai.net/page-sitemap1.xml",
|
| 4 |
+
"academy_urls": [
|
| 5 |
+
"https://academy.towardsai.net/",
|
| 6 |
+
"https://academy.towardsai.net/collections",
|
| 7 |
+
"https://academy.towardsai.net/collections/products",
|
| 8 |
+
"https://academy.towardsai.net/collections/developers",
|
| 9 |
+
"https://academy.towardsai.net/collections/professionals",
|
| 10 |
+
"https://academy.towardsai.net/courses/beginner-to-advanced-llm-dev",
|
| 11 |
+
"https://academy.towardsai.net/courses/agent-engineering",
|
| 12 |
+
"https://academy.towardsai.net/courses/llm-primer",
|
| 13 |
+
"https://academy.towardsai.net/courses/python-for-genai",
|
| 14 |
+
"https://academy.towardsai.net/bundles/tai-mentorship",
|
| 15 |
+
"https://academy.towardsai.net/courses/ai-business-professionals",
|
| 16 |
+
"https://academy.towardsai.net/products/digital_downloads/agents-cheatsheet",
|
| 17 |
+
"https://academy.towardsai.net/products/digital_downloads/anti-slop-framework",
|
| 18 |
+
"https://academy.towardsai.net/bundles/get-it-all",
|
| 19 |
+
"https://academy.towardsai.net/courses/buildingllmsforproduction",
|
| 20 |
+
"https://academy.towardsai.net/bundles/10-hour-crash-course-into-llm-developer-expert",
|
| 21 |
+
"https://academy.towardsai.net/bundles/from-coding-novice-to-advanced-llm-developer",
|
| 22 |
+
"https://academy.towardsai.net/pages/agent-course-landing-page",
|
| 23 |
+
"https://academy.towardsai.net/pages/towards-ai-insider",
|
| 24 |
+
"https://academy.towardsai.net/pages/agent-course-new-page-cro",
|
| 25 |
+
"https://academy.towardsai.net/pages/webinar",
|
| 26 |
+
"https://academy.towardsai.net/pages/new-home-page",
|
| 27 |
+
"https://academy.towardsai.net/pages/landing-page-free-email-course",
|
| 28 |
+
"https://academy.towardsai.net/pages/contact-us",
|
| 29 |
+
"https://academy.towardsai.net/pages/about-us",
|
| 30 |
+
"https://academy.towardsai.net/pages/affiliate",
|
| 31 |
+
"https://academy.towardsai.net/pages/choose-your-course",
|
| 32 |
+
"https://academy.towardsai.net/pages/free-resources"
|
| 33 |
+
],
|
| 34 |
+
"selected_towardsai_pages": [
|
| 35 |
+
"https://towardsai.net/",
|
| 36 |
+
"https://towardsai.net/towards-ai-resource-library",
|
| 37 |
+
"https://towardsai.net/book",
|
| 38 |
+
"https://towardsai.net/community",
|
| 39 |
+
"https://towardsai.net/b2b",
|
| 40 |
+
"https://towardsai.net/let-us-transform-your-team-into-ai-first-employees-to-stay-ahead-of-competitors"
|
| 41 |
+
],
|
| 42 |
+
"extra_external_urls": [
|
| 43 |
+
"https://www.youtube.com/channel/UCQNjFuhOJM1YqFTPY1Q_kYQ",
|
| 44 |
+
"https://towardsai.net/towards-ai-resource-library",
|
| 45 |
+
"https://towardsai.net/book",
|
| 46 |
+
"https://www.amazon.com/s?k=Towards+AI+Building+LLMs+for+Production"
|
| 47 |
+
]
|
| 48 |
+
}
|
data/pages.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
pyproject.toml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "tai-helper"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Public Towards AI course-selection helper chatbot"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi",
|
| 9 |
+
"google-genai",
|
| 10 |
+
"opik",
|
| 11 |
+
"pydantic",
|
| 12 |
+
"python-dotenv",
|
| 13 |
+
"requests",
|
| 14 |
+
"uvicorn",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
[build-system]
|
| 18 |
+
requires = ["setuptools>=70", "wheel"]
|
| 19 |
+
build-backend = "setuptools.build_meta"
|
| 20 |
+
|
| 21 |
+
[dependency-groups]
|
| 22 |
+
dev = [
|
| 23 |
+
"httpx",
|
| 24 |
+
"pytest",
|
| 25 |
+
"ruff",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
[tool.pytest.ini_options]
|
| 29 |
+
pythonpath = ["."]
|
| 30 |
+
markers = [
|
| 31 |
+
"live: tests that call the deployed Hugging Face Space",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
[tool.ruff]
|
| 35 |
+
line-length = 88
|
| 36 |
+
target-version = "py313"
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.packages.find]
|
| 39 |
+
include = ["tai_helper*"]
|
static/widget.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function () {
|
| 2 |
+
"use strict";
|
| 3 |
+
|
| 4 |
+
var script = document.currentScript;
|
| 5 |
+
var apiBase = (
|
| 6 |
+
(script && script.getAttribute("data-api-base")) ||
|
| 7 |
+
window.TOWARDS_AI_HELPER_API_BASE ||
|
| 8 |
+
""
|
| 9 |
+
).replace(/\/+$/, "");
|
| 10 |
+
if (!apiBase && script && script.src) {
|
| 11 |
+
try {
|
| 12 |
+
apiBase = new URL(script.src).origin;
|
| 13 |
+
} catch (_error) {
|
| 14 |
+
apiBase = "";
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
if (!apiBase) return;
|
| 18 |
+
|
| 19 |
+
var state = {
|
| 20 |
+
config: null,
|
| 21 |
+
visible: false,
|
| 22 |
+
open: false,
|
| 23 |
+
streaming: false,
|
| 24 |
+
threadId: "",
|
| 25 |
+
messages: [],
|
| 26 |
+
firstMessageSent: false,
|
| 27 |
+
visitorId: visitorId(),
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
var root = document.createElement("div");
|
| 31 |
+
root.id = "towards-ai-helper-widget";
|
| 32 |
+
var shadow = root.attachShadow({ mode: "open" });
|
| 33 |
+
document.documentElement.appendChild(root);
|
| 34 |
+
|
| 35 |
+
shadow.innerHTML = [
|
| 36 |
+
"<style>",
|
| 37 |
+
":host{all:initial;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#182235}",
|
| 38 |
+
".wrap{position:fixed;right:20px;bottom:20px;z-index:2147483000}",
|
| 39 |
+
".bubble{height:46px;min-width:146px;border:0;border-radius:999px;background:#182235;color:#fff;box-shadow:0 14px 34px rgba(24,34,53,.28);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;padding:0 16px 0 13px;font:750 14px/1 system-ui;letter-spacing:0}",
|
| 40 |
+
".bubble:hover{background:#24324b;transform:translateY(-1px)}",
|
| 41 |
+
".bubble-mark{width:24px;height:24px;border-radius:999px;background:#11b7ba;color:#082c33;display:flex;align-items:center;justify-content:center;font:800 15px/1 system-ui}",
|
| 42 |
+
".panel{width:min(414px,calc(100vw - 32px));height:min(650px,calc(100vh - 96px));background:#fff;border:1px solid #d7e0ea;border-radius:14px;box-shadow:0 20px 70px rgba(24,34,53,.24);display:flex;flex-direction:column;overflow:hidden}",
|
| 43 |
+
".head{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0 14px 0 16px;border-bottom:1px solid #e7edf4;background:#fbfcfe}",
|
| 44 |
+
".brand{min-width:0;display:flex;align-items:center;gap:10px}",
|
| 45 |
+
".brand-mark{width:32px;height:32px;border-radius:8px;background:#182235;color:#fff;display:flex;align-items:center;justify-content:center;font:800 12px/1 system-ui}",
|
| 46 |
+
".title{font:750 15px/1.2 system-ui;color:#182235;letter-spacing:0}",
|
| 47 |
+
".subtitle{font:600 12px/1.25 system-ui;color:#64748b;max-width:285px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}",
|
| 48 |
+
".iconbtn{width:34px;height:34px;flex:0 0 auto;border:0;border-radius:9px;background:#eef3f8;color:#475569;cursor:pointer;font:800 20px/1 system-ui}",
|
| 49 |
+
".iconbtn:hover{background:#e2eaf3;color:#182235}",
|
| 50 |
+
".msgs{flex:1;overflow:auto;padding:16px;background:#fff;display:flex;flex-direction:column;gap:12px}",
|
| 51 |
+
".msg{max-width:88%;padding:11px 13px;border-radius:11px;font:400 14px/1.48 system-ui;overflow-wrap:anywhere}",
|
| 52 |
+
".user{align-self:flex-end;background:#182235;color:#fff;border-bottom-right-radius:4px}",
|
| 53 |
+
".assistant{align-self:flex-start;background:#f3f7fb;color:#182235;border:1px solid #e7edf4;border-bottom-left-radius:4px}",
|
| 54 |
+
".assistant.empty{color:#64748b;font-style:italic}",
|
| 55 |
+
".meta{align-self:flex-start;color:#64748b;font:600 12px/1.35 system-ui;background:#f8fafc;border:1px solid #edf2f7;border-radius:999px;padding:6px 10px}",
|
| 56 |
+
".prompts{display:flex;flex-direction:column;gap:8px;align-self:stretch}",
|
| 57 |
+
".prompt{border:1px solid #d7e0ea;border-radius:10px;background:#fff;color:#182235;text-align:left;padding:10px 11px;font:700 13px/1.35 system-ui;cursor:pointer}",
|
| 58 |
+
".prompt:hover{border-color:#11a7b0;background:#f2fbfc}",
|
| 59 |
+
".prompt:disabled{opacity:.55;cursor:not-allowed}",
|
| 60 |
+
".sources{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}",
|
| 61 |
+
".source{font:700 11px/1.2 system-ui;color:#087e8b;background:#e8f8f9;border-radius:999px;padding:5px 8px;text-decoration:none}",
|
| 62 |
+
".md{white-space:normal}",
|
| 63 |
+
".md p{margin:0 0 10px}",
|
| 64 |
+
".md p:last-child{margin-bottom:0}",
|
| 65 |
+
".md ol,.md ul{margin:8px 0 10px 20px;padding:0}",
|
| 66 |
+
".md li{margin:5px 0;padding-left:2px}",
|
| 67 |
+
".md a{color:#087e8b;text-decoration:underline;text-underline-offset:2px;font-weight:650}",
|
| 68 |
+
".md code{background:#e8eef5;border:1px solid #d9e3ee;border-radius:5px;padding:1px 4px;font:500 12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}",
|
| 69 |
+
".md strong{font-weight:760}",
|
| 70 |
+
".form{display:flex;gap:9px;padding:12px;border-top:1px solid #e7edf4;background:#fbfcfe}",
|
| 71 |
+
".input{flex:1;resize:none;min-height:44px;max-height:116px;border:1px solid #c8d3df;border-radius:11px;padding:11px 12px;font:400 14px/1.35 system-ui;outline:none;background:#fff;color:#182235}",
|
| 72 |
+
".input:focus{border-color:#11a7b0;box-shadow:0 0 0 3px rgba(17,167,176,.16)}",
|
| 73 |
+
".send{width:46px;flex:0 0 46px;border:0;border-radius:11px;background:#182235;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center}",
|
| 74 |
+
".send:hover{background:#24324b}",
|
| 75 |
+
".send:disabled{opacity:.45;cursor:not-allowed}",
|
| 76 |
+
".send svg{width:18px;height:18px;display:block}",
|
| 77 |
+
".hidden{display:none!important}",
|
| 78 |
+
"@media(max-width:520px){.wrap{right:12px;bottom:12px}.panel{width:calc(100vw - 24px);height:calc(100vh - 86px)}.subtitle{max-width:220px}.bubble{min-width:136px}}",
|
| 79 |
+
"</style>",
|
| 80 |
+
"<div class='wrap hidden' data-wrap>",
|
| 81 |
+
" <button class='bubble' data-bubble aria-label='Ask the helper'><span class='bubble-mark'>?</span><span>Ask the helper</span></button>",
|
| 82 |
+
" <section class='panel hidden' data-panel aria-label='Towards AI helper chat'>",
|
| 83 |
+
" <header class='head'>",
|
| 84 |
+
" <div class='brand'><div class='brand-mark'>TA</div><div><div class='title'>Towards AI Helper</div><div class='subtitle' data-subtitle></div></div></div>",
|
| 85 |
+
" <button class='iconbtn' data-close aria-label='Minimize helper' title='Minimize'>−</button>",
|
| 86 |
+
" </header>",
|
| 87 |
+
" <div class='msgs' data-msgs><div class='meta'>Choose a starter prompt.</div><div class='prompts' data-prompts></div></div>",
|
| 88 |
+
" <form class='form hidden' data-form>",
|
| 89 |
+
" <textarea class='input' data-input rows='1' placeholder='Ask a follow-up' data-gramm='false' data-gramm_editor='false' data-enable-grammarly='false'></textarea>",
|
| 90 |
+
" <button class='send' data-send type='submit' aria-label='Send message'><svg viewBox='0 0 24 24' aria-hidden='true'><path d='M4 12h14M13 6l6 6-6 6' fill='none' stroke='currentColor' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/></svg></button>",
|
| 91 |
+
" </form>",
|
| 92 |
+
" </section>",
|
| 93 |
+
"</div>",
|
| 94 |
+
].join("");
|
| 95 |
+
|
| 96 |
+
var wrap = shadow.querySelector("[data-wrap]");
|
| 97 |
+
var bubble = shadow.querySelector("[data-bubble]");
|
| 98 |
+
var panel = shadow.querySelector("[data-panel]");
|
| 99 |
+
var closeBtn = shadow.querySelector("[data-close]");
|
| 100 |
+
var form = shadow.querySelector("[data-form]");
|
| 101 |
+
var input = shadow.querySelector("[data-input]");
|
| 102 |
+
var sendBtn = shadow.querySelector("[data-send]");
|
| 103 |
+
var msgs = shadow.querySelector("[data-msgs]");
|
| 104 |
+
var prompts = shadow.querySelector("[data-prompts]");
|
| 105 |
+
var subtitle = shadow.querySelector("[data-subtitle]");
|
| 106 |
+
|
| 107 |
+
function safeString(value) {
|
| 108 |
+
if (value === null || value === undefined) return "";
|
| 109 |
+
return String(value);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function escapeHtml(value) {
|
| 113 |
+
return safeString(value)
|
| 114 |
+
.replace(/&/g, "&")
|
| 115 |
+
.replace(/</g, "<")
|
| 116 |
+
.replace(/>/g, ">")
|
| 117 |
+
.replace(/"/g, """)
|
| 118 |
+
.replace(/'/g, "'");
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function safeHref(value) {
|
| 122 |
+
try {
|
| 123 |
+
var candidate = safeString(value).trim();
|
| 124 |
+
if (/^www\./i.test(candidate)) candidate = "https://" + candidate;
|
| 125 |
+
var url = new URL(candidate, window.location.href);
|
| 126 |
+
if (url.protocol === "http:" || url.protocol === "https:") return url.href;
|
| 127 |
+
} catch (_error) {}
|
| 128 |
+
return "";
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function displayUrl(value) {
|
| 132 |
+
return safeString(value).replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function splitTrailingUrlPunctuation(value) {
|
| 136 |
+
var url = safeString(value);
|
| 137 |
+
var suffix = "";
|
| 138 |
+
while (/[.,;:!?]$/.test(url)) {
|
| 139 |
+
suffix = url.slice(-1) + suffix;
|
| 140 |
+
url = url.slice(0, -1);
|
| 141 |
+
}
|
| 142 |
+
while (
|
| 143 |
+
url.slice(-1) === ")" &&
|
| 144 |
+
(url.match(/\)/g) || []).length > (url.match(/\(/g) || []).length
|
| 145 |
+
) {
|
| 146 |
+
suffix = ")" + suffix;
|
| 147 |
+
url = url.slice(0, -1);
|
| 148 |
+
}
|
| 149 |
+
return { url: url, suffix: suffix };
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function renderBasicMarkdown(text) {
|
| 153 |
+
return escapeHtml(text)
|
| 154 |
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
| 155 |
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function renderInlineMarkdown(text) {
|
| 159 |
+
var output = "";
|
| 160 |
+
var lastIndex = 0;
|
| 161 |
+
var linkPattern =
|
| 162 |
+
/\[([^\]]{1,180})\]\(((?:https?:\/\/|www\.)[^)\s]+)\)|\[((?:https?:\/\/|www\.)[^\]\s]+)\]|((?:https?:\/\/|www\.)[^\s<]+)/g;
|
| 163 |
+
var match;
|
| 164 |
+
while ((match = linkPattern.exec(text)) !== null) {
|
| 165 |
+
output += renderBasicMarkdown(text.slice(lastIndex, match.index));
|
| 166 |
+
var rawUrl = match[2] || match[3] || match[4] || "";
|
| 167 |
+
var suffix = "";
|
| 168 |
+
if (!match[2]) {
|
| 169 |
+
var splitUrl = splitTrailingUrlPunctuation(rawUrl);
|
| 170 |
+
rawUrl = splitUrl.url;
|
| 171 |
+
suffix = splitUrl.suffix;
|
| 172 |
+
}
|
| 173 |
+
var href = safeHref(rawUrl);
|
| 174 |
+
if (href) {
|
| 175 |
+
output +=
|
| 176 |
+
"<a href='" +
|
| 177 |
+
escapeHtml(href) +
|
| 178 |
+
"' target='_blank' rel='noopener noreferrer'>" +
|
| 179 |
+
(match[1] ? renderBasicMarkdown(match[1]) : escapeHtml(displayUrl(rawUrl))) +
|
| 180 |
+
"</a>";
|
| 181 |
+
if (suffix) output += renderBasicMarkdown(suffix);
|
| 182 |
+
} else {
|
| 183 |
+
output += renderBasicMarkdown(match[0]);
|
| 184 |
+
}
|
| 185 |
+
lastIndex = match.index + match[0].length;
|
| 186 |
+
}
|
| 187 |
+
output += renderBasicMarkdown(text.slice(lastIndex));
|
| 188 |
+
return output;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function isListLine(line) {
|
| 192 |
+
return /^\s*(?:[-*]\s+|\d+\.\s+)/.test(line);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function renderMarkdown(markdown) {
|
| 196 |
+
var lines = safeString(markdown).replace(/\r\n/g, "\n").split("\n");
|
| 197 |
+
var html = [];
|
| 198 |
+
var index = 0;
|
| 199 |
+
while (index < lines.length) {
|
| 200 |
+
var line = lines[index];
|
| 201 |
+
var trimmed = line.trim();
|
| 202 |
+
if (!trimmed) {
|
| 203 |
+
index += 1;
|
| 204 |
+
continue;
|
| 205 |
+
}
|
| 206 |
+
if (isListLine(line)) {
|
| 207 |
+
var ordered = /^\s*\d+\.\s+/.test(line);
|
| 208 |
+
var tag = ordered ? "ol" : "ul";
|
| 209 |
+
var items = [];
|
| 210 |
+
while (index < lines.length && isListLine(lines[index])) {
|
| 211 |
+
items.push(
|
| 212 |
+
"<li>" +
|
| 213 |
+
renderInlineMarkdown(lines[index].replace(/^\s*(?:[-*]\s+|\d+\.\s+)/, "")) +
|
| 214 |
+
"</li>",
|
| 215 |
+
);
|
| 216 |
+
index += 1;
|
| 217 |
+
}
|
| 218 |
+
html.push("<" + tag + ">" + items.join("") + "</" + tag + ">");
|
| 219 |
+
continue;
|
| 220 |
+
}
|
| 221 |
+
var paragraph = [trimmed];
|
| 222 |
+
index += 1;
|
| 223 |
+
while (index < lines.length && lines[index].trim() && !isListLine(lines[index])) {
|
| 224 |
+
paragraph.push(lines[index].trim());
|
| 225 |
+
index += 1;
|
| 226 |
+
}
|
| 227 |
+
html.push("<p>" + renderInlineMarkdown(paragraph.join(" ")) + "</p>");
|
| 228 |
+
}
|
| 229 |
+
return "<div class='md'>" + html.join("") + "</div>";
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function visitorId() {
|
| 233 |
+
try {
|
| 234 |
+
var key = "towards_ai_helper_visitor";
|
| 235 |
+
var existing = window.localStorage.getItem(key);
|
| 236 |
+
if (existing) return existing;
|
| 237 |
+
var id =
|
| 238 |
+
Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 12);
|
| 239 |
+
window.localStorage.setItem(key, id);
|
| 240 |
+
return id;
|
| 241 |
+
} catch (_error) {
|
| 242 |
+
return "";
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function normalizePath(pathname) {
|
| 247 |
+
var path = pathname || "/";
|
| 248 |
+
path = path.replace(/\/+$/, "");
|
| 249 |
+
return path || "/";
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function isSignedIn() {
|
| 253 |
+
try {
|
| 254 |
+
if (window.Thinkific && window.Thinkific.current_user) return true;
|
| 255 |
+
if (document.body && document.body.classList.contains("logged-in")) return true;
|
| 256 |
+
if (document.getElementById("wpadminbar")) return true;
|
| 257 |
+
if (/wordpress_logged_in|thinkific_user|signed_in/i.test(document.cookie || "")) {
|
| 258 |
+
return true;
|
| 259 |
+
}
|
| 260 |
+
var signOutLink = document.querySelector(
|
| 261 |
+
"a[href*='sign_out'],a[href*='logout'],a[href*='/account']",
|
| 262 |
+
);
|
| 263 |
+
return Boolean(signOutLink && /sign out|logout|account/i.test(signOutLink.textContent || ""));
|
| 264 |
+
} catch (_error) {
|
| 265 |
+
return false;
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function isBlockedPath() {
|
| 270 |
+
var path = normalizePath(window.location.pathname);
|
| 271 |
+
return /^(courses\/take|enroll|order|checkout|cart|users|account|admin)/.test(
|
| 272 |
+
path.replace(/^\//, ""),
|
| 273 |
+
);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function pageAllowed(config) {
|
| 277 |
+
if (!config || isSignedIn() || isBlockedPath()) return false;
|
| 278 |
+
var host = window.location.hostname.toLowerCase();
|
| 279 |
+
var path = normalizePath(window.location.pathname);
|
| 280 |
+
var paths = config.allowedPathsByHost && config.allowedPathsByHost[host];
|
| 281 |
+
if (!paths && host.indexOf("www.") === 0) {
|
| 282 |
+
paths = config.allowedPathsByHost && config.allowedPathsByHost[host.slice(4)];
|
| 283 |
+
}
|
| 284 |
+
return Array.isArray(paths) && paths.indexOf(path) !== -1;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function setVisible(visible) {
|
| 288 |
+
state.visible = visible;
|
| 289 |
+
wrap.classList.toggle("hidden", !visible);
|
| 290 |
+
if (!visible) {
|
| 291 |
+
state.open = false;
|
| 292 |
+
panel.classList.add("hidden");
|
| 293 |
+
bubble.classList.remove("hidden");
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
function setOpen(open) {
|
| 298 |
+
state.open = open;
|
| 299 |
+
panel.classList.toggle("hidden", !open);
|
| 300 |
+
bubble.classList.toggle("hidden", open);
|
| 301 |
+
if (open) setTimeout(function () { input.focus(); }, 50);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function contextPayload() {
|
| 305 |
+
return {
|
| 306 |
+
url: window.location.href.split("#", 1)[0],
|
| 307 |
+
pageTitle: document.title || "",
|
| 308 |
+
referrer: document.referrer || "",
|
| 309 |
+
signedIn: isSignedIn(),
|
| 310 |
+
};
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function appendMessage(role, text, sources) {
|
| 314 |
+
var node = document.createElement("div");
|
| 315 |
+
node.className = "msg " + (role === "user" ? "user" : "assistant");
|
| 316 |
+
node.innerHTML = role === "assistant" ? renderMarkdown(text) : escapeHtml(text);
|
| 317 |
+
if (role === "assistant" && sources && sources.length) {
|
| 318 |
+
var sourceWrap = document.createElement("div");
|
| 319 |
+
sourceWrap.className = "sources";
|
| 320 |
+
sources.slice(0, 3).forEach(function (source) {
|
| 321 |
+
var a = document.createElement("a");
|
| 322 |
+
a.className = "source";
|
| 323 |
+
a.href = source.url;
|
| 324 |
+
a.target = "_blank";
|
| 325 |
+
a.rel = "noopener noreferrer";
|
| 326 |
+
a.textContent = source.title || source.kind || "Source";
|
| 327 |
+
sourceWrap.appendChild(a);
|
| 328 |
+
});
|
| 329 |
+
node.appendChild(sourceWrap);
|
| 330 |
+
}
|
| 331 |
+
msgs.appendChild(node);
|
| 332 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 333 |
+
return node;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
function setBusy(busy) {
|
| 337 |
+
state.streaming = busy;
|
| 338 |
+
sendBtn.disabled = busy;
|
| 339 |
+
input.disabled = busy;
|
| 340 |
+
Array.prototype.forEach.call(prompts.querySelectorAll("button"), function (button) {
|
| 341 |
+
button.disabled = busy || state.firstMessageSent;
|
| 342 |
+
});
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
function showInput() {
|
| 346 |
+
form.classList.toggle("hidden", !state.firstMessageSent);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function send(text, selectedPrompt) {
|
| 350 |
+
text = safeString(text).trim();
|
| 351 |
+
if (!text || state.streaming) return;
|
| 352 |
+
appendMessage("user", text);
|
| 353 |
+
state.messages.push({ role: "user", content: text });
|
| 354 |
+
setBusy(true);
|
| 355 |
+
var loading = appendMessage("assistant", "Thinking...");
|
| 356 |
+
loading.classList.add("empty");
|
| 357 |
+
|
| 358 |
+
fetch(apiBase + "/api/helper/chat", {
|
| 359 |
+
method: "POST",
|
| 360 |
+
headers: { "Content-Type": "application/json" },
|
| 361 |
+
body: JSON.stringify({
|
| 362 |
+
query: text,
|
| 363 |
+
selectedPrompt: selectedPrompt || "",
|
| 364 |
+
visitorId: state.visitorId,
|
| 365 |
+
threadId: state.threadId,
|
| 366 |
+
history: state.messages.slice(0, -1).slice(-8),
|
| 367 |
+
context: contextPayload(),
|
| 368 |
+
}),
|
| 369 |
+
})
|
| 370 |
+
.then(function (response) {
|
| 371 |
+
if (!response.ok) {
|
| 372 |
+
if (response.status === 429) {
|
| 373 |
+
throw new Error("The helper is rate limited right now. Please try again later.");
|
| 374 |
+
}
|
| 375 |
+
throw new Error("The helper is unavailable on this page.");
|
| 376 |
+
}
|
| 377 |
+
return response.json();
|
| 378 |
+
})
|
| 379 |
+
.then(function (payload) {
|
| 380 |
+
state.threadId = payload.threadId || state.threadId;
|
| 381 |
+
loading.classList.remove("empty");
|
| 382 |
+
loading.innerHTML = renderMarkdown(payload.answer || "I could not answer that.");
|
| 383 |
+
if (payload.sources && payload.sources.length) {
|
| 384 |
+
var sourceWrap = document.createElement("div");
|
| 385 |
+
sourceWrap.className = "sources";
|
| 386 |
+
payload.sources.slice(0, 3).forEach(function (source) {
|
| 387 |
+
var a = document.createElement("a");
|
| 388 |
+
a.className = "source";
|
| 389 |
+
a.href = source.url;
|
| 390 |
+
a.target = "_blank";
|
| 391 |
+
a.rel = "noopener noreferrer";
|
| 392 |
+
a.textContent = source.title || source.kind || "Source";
|
| 393 |
+
sourceWrap.appendChild(a);
|
| 394 |
+
});
|
| 395 |
+
loading.appendChild(sourceWrap);
|
| 396 |
+
}
|
| 397 |
+
state.messages.push({ role: "assistant", content: payload.answer || "" });
|
| 398 |
+
state.firstMessageSent = true;
|
| 399 |
+
prompts.classList.add("hidden");
|
| 400 |
+
showInput();
|
| 401 |
+
})
|
| 402 |
+
.catch(function (error) {
|
| 403 |
+
loading.classList.remove("empty");
|
| 404 |
+
loading.innerHTML = renderMarkdown(error.message || "Something went wrong.");
|
| 405 |
+
})
|
| 406 |
+
.finally(function () {
|
| 407 |
+
setBusy(false);
|
| 408 |
+
input.value = "";
|
| 409 |
+
input.style.height = "auto";
|
| 410 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 411 |
+
});
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
function renderPrompts() {
|
| 415 |
+
prompts.innerHTML = "";
|
| 416 |
+
(state.config.forcedPrompts || []).forEach(function (text) {
|
| 417 |
+
var button = document.createElement("button");
|
| 418 |
+
button.type = "button";
|
| 419 |
+
button.className = "prompt";
|
| 420 |
+
button.textContent = text;
|
| 421 |
+
button.addEventListener("click", function () {
|
| 422 |
+
send(text, text);
|
| 423 |
+
});
|
| 424 |
+
prompts.appendChild(button);
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
bubble.addEventListener("click", function () {
|
| 429 |
+
setOpen(true);
|
| 430 |
+
});
|
| 431 |
+
closeBtn.addEventListener("click", function () {
|
| 432 |
+
setOpen(false);
|
| 433 |
+
});
|
| 434 |
+
form.addEventListener("submit", function (event) {
|
| 435 |
+
event.preventDefault();
|
| 436 |
+
send(input.value, "");
|
| 437 |
+
});
|
| 438 |
+
input.addEventListener("input", function () {
|
| 439 |
+
input.style.height = "auto";
|
| 440 |
+
input.style.height = Math.min(input.scrollHeight, 116) + "px";
|
| 441 |
+
});
|
| 442 |
+
|
| 443 |
+
function refreshVisibility() {
|
| 444 |
+
if (!state.config) return;
|
| 445 |
+
setVisible(pageAllowed(state.config));
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
function init() {
|
| 449 |
+
fetch(apiBase + "/api/helper/config")
|
| 450 |
+
.then(function (response) { return response.json(); })
|
| 451 |
+
.then(function (config) {
|
| 452 |
+
state.config = config;
|
| 453 |
+
subtitle.textContent = document.title || "Course and training guidance";
|
| 454 |
+
renderPrompts();
|
| 455 |
+
showInput();
|
| 456 |
+
refreshVisibility();
|
| 457 |
+
})
|
| 458 |
+
.catch(function () {
|
| 459 |
+
setVisible(false);
|
| 460 |
+
});
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
window.addEventListener("thnc.current_user-initialized", refreshVisibility);
|
| 464 |
+
setTimeout(refreshVisibility, 1000);
|
| 465 |
+
init();
|
| 466 |
+
})();
|
tai_helper/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Towards AI public helper chatbot."""
|
tai_helper/api.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Any
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
|
| 14 |
+
from . import llm
|
| 15 |
+
from .catalog import (
|
| 16 |
+
allowed_paths_by_host,
|
| 17 |
+
coupon_followup,
|
| 18 |
+
coupon_intent,
|
| 19 |
+
forced_prompts,
|
| 20 |
+
in_scope,
|
| 21 |
+
page_is_allowed,
|
| 22 |
+
retrieve,
|
| 23 |
+
sources_from_pages,
|
| 24 |
+
)
|
| 25 |
+
from .monitoring import HelperMonitor
|
| 26 |
+
from .rate_limiter import FixedWindowRateLimiter, RateLimit
|
| 27 |
+
from .schemas import HelperChatRequest, HelperChatResponse, SourceOut
|
| 28 |
+
from .settings import repo_root, settings
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
app = FastAPI(title="Towards AI Helper API", version="0.1.0")
|
| 33 |
+
app.add_middleware(
|
| 34 |
+
CORSMiddleware,
|
| 35 |
+
allow_origins=settings.cors_origins(),
|
| 36 |
+
allow_credentials=False,
|
| 37 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 38 |
+
allow_headers=["*"],
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.middleware("http")
|
| 43 |
+
async def reject_oversized_bodies(request: Request, call_next):
|
| 44 |
+
content_length = request.headers.get("content-length")
|
| 45 |
+
try:
|
| 46 |
+
too_large = (
|
| 47 |
+
content_length is not None and int(content_length) > settings.max_body_bytes
|
| 48 |
+
)
|
| 49 |
+
except ValueError:
|
| 50 |
+
too_large = False
|
| 51 |
+
if too_large:
|
| 52 |
+
return JSONResponse({"detail": "Request body too large"}, status_code=413)
|
| 53 |
+
return await call_next(request)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
STATIC_DIR = repo_root() / "static"
|
| 57 |
+
if STATIC_DIR.is_dir():
|
| 58 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
visitor_limiter = FixedWindowRateLimiter(
|
| 62 |
+
(
|
| 63 |
+
RateLimit("per_minute", settings.rate_limit_per_minute, 60),
|
| 64 |
+
RateLimit("per_day", settings.rate_limit_per_day, 24 * 60 * 60),
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
ip_limiter = FixedWindowRateLimiter(
|
| 68 |
+
(
|
| 69 |
+
RateLimit("ip_per_minute", settings.rate_limit_per_minute, 60),
|
| 70 |
+
RateLimit("ip_per_day", settings.rate_limit_per_day, 24 * 60 * 60),
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
global_limiter = FixedWindowRateLimiter(
|
| 74 |
+
(RateLimit("global_per_minute", settings.rate_limit_global_per_minute, 60),)
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _client_ip(request: Request) -> str:
|
| 79 |
+
forwarded_for = request.headers.get("x-forwarded-for", "")
|
| 80 |
+
if forwarded_for:
|
| 81 |
+
return forwarded_for.split(",", 1)[0].strip()
|
| 82 |
+
return request.client.host if request.client else "unknown"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _header_host_allowed(value: str) -> bool:
|
| 86 |
+
if not value:
|
| 87 |
+
return False
|
| 88 |
+
hostname = (urlparse(value).hostname or "").lower()
|
| 89 |
+
return any(
|
| 90 |
+
hostname == allowed.lower() or hostname.endswith("." + allowed.lower())
|
| 91 |
+
for allowed in settings.allowed_hosts
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _require_allowed_browser_origin(request: Request) -> None:
|
| 96 |
+
origin = request.headers.get("origin", "")
|
| 97 |
+
referer = request.headers.get("referer", "")
|
| 98 |
+
if _header_host_allowed(origin) or _header_host_allowed(referer):
|
| 99 |
+
return
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=403,
|
| 102 |
+
detail="Requests must come from a configured Towards AI public site.",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _rate_key(request: Request, payload: HelperChatRequest) -> str:
|
| 107 |
+
visitor_id = payload.visitorId.strip()[:80]
|
| 108 |
+
if visitor_id:
|
| 109 |
+
return f"visitor:{visitor_id}"
|
| 110 |
+
return f"ip:{_client_ip(request)}"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _check_rate_limits(request: Request, payload: HelperChatRequest) -> None:
|
| 114 |
+
global_result = global_limiter.check("global")
|
| 115 |
+
if not global_result.allowed:
|
| 116 |
+
raise HTTPException(
|
| 117 |
+
status_code=429,
|
| 118 |
+
detail=f"Global rate limit exceeded: {global_result.limit_name}",
|
| 119 |
+
headers={"Retry-After": str(global_result.retry_after_seconds)},
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
ip_result = ip_limiter.check(_client_ip(request))
|
| 123 |
+
if not ip_result.allowed:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=429,
|
| 126 |
+
detail=f"Rate limit exceeded: {ip_result.limit_name}",
|
| 127 |
+
headers={"Retry-After": str(ip_result.retry_after_seconds)},
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
visitor_result = visitor_limiter.check(_rate_key(request, payload))
|
| 131 |
+
if not visitor_result.allowed:
|
| 132 |
+
raise HTTPException(
|
| 133 |
+
status_code=429,
|
| 134 |
+
detail=f"Rate limit exceeded: {visitor_result.limit_name}",
|
| 135 |
+
headers={"Retry-After": str(visitor_result.retry_after_seconds)},
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _history_text(payload: HelperChatRequest) -> list[str]:
|
| 140 |
+
return [turn.content for turn in payload.history[-settings.max_history_turns :]]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _validate_payload(payload: HelperChatRequest) -> None:
|
| 144 |
+
if payload.context.signedIn:
|
| 145 |
+
raise HTTPException(status_code=403, detail="Helper is only for signed-out visitors.")
|
| 146 |
+
if not page_is_allowed(payload.context.url):
|
| 147 |
+
raise HTTPException(status_code=403, detail="Helper is only available on public pages.")
|
| 148 |
+
if len(payload.query.strip()) > settings.max_query_chars:
|
| 149 |
+
raise HTTPException(status_code=400, detail="Question is too long.")
|
| 150 |
+
if not payload.history and payload.query.strip() not in forced_prompts():
|
| 151 |
+
raise HTTPException(
|
| 152 |
+
status_code=400,
|
| 153 |
+
detail="Please choose one of the starter prompts first.",
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _fixed_coupon_answer(payload: HelperChatRequest) -> str:
|
| 158 |
+
history = _history_text(payload)
|
| 159 |
+
if coupon_followup(payload.query, history):
|
| 160 |
+
return (
|
| 161 |
+
"I can't provide a coupon code here. If you have specific context, "
|
| 162 |
+
"email louis@towardsai.net with what you need and we will do the best possible."
|
| 163 |
+
)
|
| 164 |
+
return (
|
| 165 |
+
"I can't provide a coupon code here. The best value option is usually the "
|
| 166 |
+
"Get it all bundle: https://academy.towardsai.net/bundles/get-it-all"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _out_of_scope_answer() -> str:
|
| 171 |
+
return (
|
| 172 |
+
"I can only help with choosing Towards AI courses, bundles, mentorship, "
|
| 173 |
+
"free resources, the book, or B2B training. Tell me your background and goal, "
|
| 174 |
+
"and I can point you to the right option."
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _fallback_answer() -> str:
|
| 179 |
+
return (
|
| 180 |
+
"I can help, but I need one detail: are you learning for your own career, "
|
| 181 |
+
"building AI products, or looking for company training?"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
async def _flush_monitor(monitor: HelperMonitor) -> None:
|
| 186 |
+
await asyncio.to_thread(monitor.flush)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _schedule_monitor(monitor: HelperMonitor) -> None:
|
| 190 |
+
if not monitor.enabled:
|
| 191 |
+
return
|
| 192 |
+
task = asyncio.create_task(_flush_monitor(monitor))
|
| 193 |
+
|
| 194 |
+
def log_failure(done: asyncio.Task) -> None:
|
| 195 |
+
try:
|
| 196 |
+
done.result()
|
| 197 |
+
except Exception:
|
| 198 |
+
logger.warning("Background Opik monitor flush failed.", exc_info=True)
|
| 199 |
+
|
| 200 |
+
task.add_done_callback(log_failure)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@app.get("/healthz")
|
| 204 |
+
def healthcheck() -> dict[str, str]:
|
| 205 |
+
return {"status": "ok"}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@app.get("/widget.js")
|
| 209 |
+
def widget() -> FileResponse:
|
| 210 |
+
path = STATIC_DIR / "widget.js"
|
| 211 |
+
if not path.is_file():
|
| 212 |
+
raise HTTPException(status_code=404, detail="Widget not found")
|
| 213 |
+
return FileResponse(path, media_type="application/javascript")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@app.get("/api/helper/config")
|
| 217 |
+
def public_config() -> dict[str, Any]:
|
| 218 |
+
opik_ready = bool(settings.opik_enabled and settings.opik_api_key)
|
| 219 |
+
return {
|
| 220 |
+
"name": "Towards AI Helper",
|
| 221 |
+
"allowedHosts": list(settings.allowed_hosts),
|
| 222 |
+
"allowedPathsByHost": allowed_paths_by_host(),
|
| 223 |
+
"forcedPrompts": forced_prompts(),
|
| 224 |
+
"rateLimits": {
|
| 225 |
+
"perMinute": settings.rate_limit_per_minute,
|
| 226 |
+
"perDay": settings.rate_limit_per_day,
|
| 227 |
+
},
|
| 228 |
+
"monitoring": {
|
| 229 |
+
"opikEnabled": opik_ready,
|
| 230 |
+
"opikProject": settings.opik_project_name if opik_ready else "",
|
| 231 |
+
},
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@app.post("/api/helper/chat")
|
| 236 |
+
async def chat(request: Request, payload: HelperChatRequest) -> HelperChatResponse:
|
| 237 |
+
_require_allowed_browser_origin(request)
|
| 238 |
+
_validate_payload(payload)
|
| 239 |
+
_check_rate_limits(request, payload)
|
| 240 |
+
|
| 241 |
+
query = payload.query.strip()
|
| 242 |
+
thread_id = payload.threadId.strip() or uuid4().hex
|
| 243 |
+
selected_pages = retrieve(query, current_url=payload.context.url)
|
| 244 |
+
sources = sources_from_pages(selected_pages)
|
| 245 |
+
usage: dict[str, Any] = {}
|
| 246 |
+
latency_ms = 0
|
| 247 |
+
error_message = ""
|
| 248 |
+
|
| 249 |
+
try:
|
| 250 |
+
if coupon_intent(query):
|
| 251 |
+
answer = _fixed_coupon_answer(payload)
|
| 252 |
+
elif not in_scope(query, _history_text(payload)):
|
| 253 |
+
answer = _out_of_scope_answer()
|
| 254 |
+
else:
|
| 255 |
+
prompt = llm.build_prompt(
|
| 256 |
+
query=query,
|
| 257 |
+
selected_prompt=payload.selectedPrompt or query,
|
| 258 |
+
current_url=payload.context.url,
|
| 259 |
+
page_title=payload.context.pageTitle,
|
| 260 |
+
history=[
|
| 261 |
+
(turn.role, turn.content)
|
| 262 |
+
for turn in payload.history[-settings.max_history_turns :]
|
| 263 |
+
],
|
| 264 |
+
selected_pages=selected_pages,
|
| 265 |
+
)
|
| 266 |
+
result = await asyncio.to_thread(llm.generate_answer, prompt)
|
| 267 |
+
answer = result.answer or _fallback_answer()
|
| 268 |
+
usage = result.usage
|
| 269 |
+
latency_ms = result.latency_ms
|
| 270 |
+
except Exception:
|
| 271 |
+
error_message = "helper generation failed"
|
| 272 |
+
logger.exception("helper generation failed")
|
| 273 |
+
answer = _fallback_answer()
|
| 274 |
+
|
| 275 |
+
monitor = HelperMonitor(
|
| 276 |
+
query=query,
|
| 277 |
+
answer=answer,
|
| 278 |
+
current_url=payload.context.url,
|
| 279 |
+
selected_prompt=payload.selectedPrompt or query,
|
| 280 |
+
visitor_key=_rate_key(request, payload),
|
| 281 |
+
thread_id=thread_id,
|
| 282 |
+
sources=sources,
|
| 283 |
+
usage=usage,
|
| 284 |
+
latency_ms=latency_ms,
|
| 285 |
+
error_message=error_message,
|
| 286 |
+
)
|
| 287 |
+
_schedule_monitor(monitor)
|
| 288 |
+
|
| 289 |
+
return HelperChatResponse(
|
| 290 |
+
answer=answer,
|
| 291 |
+
threadId=thread_id,
|
| 292 |
+
sources=[SourceOut(**source) for source in sources],
|
| 293 |
+
usage=usage,
|
| 294 |
+
)
|
tai_helper/catalog.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
from typing import Any
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
|
| 9 |
+
from .settings import repo_root
|
| 10 |
+
|
| 11 |
+
WORD_RE = re.compile(r"[a-z0-9][a-z0-9+#.-]*")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@lru_cache(maxsize=1)
|
| 15 |
+
def pages_payload() -> dict[str, Any]:
|
| 16 |
+
return json.loads((repo_root() / "data" / "pages.json").read_text())
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@lru_cache(maxsize=1)
|
| 20 |
+
def assistant_notes() -> dict[str, Any]:
|
| 21 |
+
return json.loads((repo_root() / "data" / "assistant_notes.json").read_text())
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def forced_prompts() -> list[str]:
|
| 25 |
+
return list(assistant_notes()["forced_prompts"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def pages() -> list[dict[str, Any]]:
|
| 29 |
+
return list(pages_payload()["pages"])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def tokenize(text: str) -> set[str]:
|
| 33 |
+
return {token.lower() for token in WORD_RE.findall(text.lower()) if len(token) > 2}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def normalized_path(url: str) -> tuple[str, str]:
|
| 37 |
+
parsed = urlparse(url)
|
| 38 |
+
host = (parsed.hostname or "").lower()
|
| 39 |
+
path = parsed.path.rstrip("/") or "/"
|
| 40 |
+
return host, path
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def allowed_paths_by_host() -> dict[str, list[str]]:
|
| 44 |
+
result: dict[str, set[str]] = {}
|
| 45 |
+
for page in pages():
|
| 46 |
+
host = str(page.get("host", "")).lower()
|
| 47 |
+
path = str(page.get("path", "/")).rstrip("/") or "/"
|
| 48 |
+
if host:
|
| 49 |
+
result.setdefault(host, set()).add(path)
|
| 50 |
+
if host == "towardsai.net":
|
| 51 |
+
result.setdefault("www.towardsai.net", set()).add(path)
|
| 52 |
+
return {host: sorted(paths) for host, paths in result.items()}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def page_is_allowed(url: str) -> bool:
|
| 56 |
+
host, path = normalized_path(url)
|
| 57 |
+
if not host:
|
| 58 |
+
return False
|
| 59 |
+
if path.startswith(("/courses/take", "/enroll", "/order", "/checkout", "/cart")):
|
| 60 |
+
return False
|
| 61 |
+
if path.startswith(("/users", "/account", "/admin")):
|
| 62 |
+
return False
|
| 63 |
+
allowed = allowed_paths_by_host()
|
| 64 |
+
return path in allowed.get(host, [])
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def source_for_url(url: str) -> dict[str, Any] | None:
|
| 68 |
+
host, path = normalized_path(url)
|
| 69 |
+
for page in pages():
|
| 70 |
+
if str(page.get("host", "")).lower() == host and (
|
| 71 |
+
str(page.get("path", "/")).rstrip("/") or "/"
|
| 72 |
+
) == path:
|
| 73 |
+
return page
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def retrieve(query: str, *, current_url: str = "", limit: int = 7) -> list[dict[str, Any]]:
|
| 78 |
+
query_tokens = tokenize(query)
|
| 79 |
+
scored: list[tuple[float, dict[str, Any]]] = []
|
| 80 |
+
current = source_for_url(current_url)
|
| 81 |
+
notes_text = json.dumps(assistant_notes(), ensure_ascii=False)
|
| 82 |
+
|
| 83 |
+
synthetic_notes = {
|
| 84 |
+
"title": "Towards AI Helper Routing Notes",
|
| 85 |
+
"url": "internal://assistant-notes",
|
| 86 |
+
"kind": "routing_notes",
|
| 87 |
+
"text": notes_text,
|
| 88 |
+
"headings": [],
|
| 89 |
+
"links": [],
|
| 90 |
+
}
|
| 91 |
+
all_pages = [synthetic_notes, *pages()]
|
| 92 |
+
|
| 93 |
+
for page in all_pages:
|
| 94 |
+
text = " ".join(
|
| 95 |
+
[
|
| 96 |
+
str(page.get("title", "")),
|
| 97 |
+
str(page.get("kind", "")),
|
| 98 |
+
" ".join(str(item) for item in page.get("headings", [])[:30]),
|
| 99 |
+
str(page.get("meta_description", "")),
|
| 100 |
+
str(page.get("text", ""))[:8000],
|
| 101 |
+
]
|
| 102 |
+
)
|
| 103 |
+
tokens = tokenize(text)
|
| 104 |
+
overlap = len(query_tokens & tokens)
|
| 105 |
+
score = float(overlap)
|
| 106 |
+
kind = page.get("kind", "")
|
| 107 |
+
lowered = query.lower()
|
| 108 |
+
if page is current:
|
| 109 |
+
score += 7.0
|
| 110 |
+
if kind == "routing_notes":
|
| 111 |
+
score += 5.0
|
| 112 |
+
if "company" in lowered or "b2b" in lowered or "training" in lowered:
|
| 113 |
+
if kind == "b2b":
|
| 114 |
+
score += 8.0
|
| 115 |
+
if "mentor" in lowered or "mentorship" in lowered:
|
| 116 |
+
if "mentorship" in str(page.get("url", "")).lower():
|
| 117 |
+
score += 10.0
|
| 118 |
+
if "free" in lowered or "youtube" in lowered or "resource" in lowered:
|
| 119 |
+
if kind in {"free_resource", "free_content_external", "community"}:
|
| 120 |
+
score += 7.0
|
| 121 |
+
if "book" in lowered or "amazon" in lowered:
|
| 122 |
+
if kind in {"book", "book_external"}:
|
| 123 |
+
score += 8.0
|
| 124 |
+
if "bundle" in lowered or "all" in lowered or "value" in lowered:
|
| 125 |
+
if "get-it-all" in str(page.get("url", "")):
|
| 126 |
+
score += 8.0
|
| 127 |
+
if score > 0:
|
| 128 |
+
scored.append((score, page))
|
| 129 |
+
|
| 130 |
+
scored.sort(key=lambda item: item[0], reverse=True)
|
| 131 |
+
return [page for _score, page in scored[:limit]]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def sources_from_pages(selected: list[dict[str, Any]], limit: int = 4) -> list[dict[str, str]]:
|
| 135 |
+
result = []
|
| 136 |
+
for page in selected:
|
| 137 |
+
url = str(page.get("url", ""))
|
| 138 |
+
if url.startswith("internal://"):
|
| 139 |
+
continue
|
| 140 |
+
result.append(
|
| 141 |
+
{
|
| 142 |
+
"title": str(page.get("title", "")) or url,
|
| 143 |
+
"url": url,
|
| 144 |
+
"kind": str(page.get("kind", "page")),
|
| 145 |
+
}
|
| 146 |
+
)
|
| 147 |
+
seen = set()
|
| 148 |
+
unique = []
|
| 149 |
+
for source in result:
|
| 150 |
+
if source["url"] not in seen:
|
| 151 |
+
seen.add(source["url"])
|
| 152 |
+
unique.append(source)
|
| 153 |
+
return unique[:limit]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def in_scope(text: str, history: list[str] | None = None) -> bool:
|
| 157 |
+
haystack = " ".join([text, *(history or [])]).lower()
|
| 158 |
+
allowed_terms = {
|
| 159 |
+
"course",
|
| 160 |
+
"courses",
|
| 161 |
+
"bundle",
|
| 162 |
+
"academy",
|
| 163 |
+
"towards ai",
|
| 164 |
+
"mentor",
|
| 165 |
+
"mentorship",
|
| 166 |
+
"training",
|
| 167 |
+
"company",
|
| 168 |
+
"business",
|
| 169 |
+
"team",
|
| 170 |
+
"consulting",
|
| 171 |
+
"resource",
|
| 172 |
+
"resources",
|
| 173 |
+
"youtube",
|
| 174 |
+
"book",
|
| 175 |
+
"amazon",
|
| 176 |
+
"learn",
|
| 177 |
+
"learning",
|
| 178 |
+
"python",
|
| 179 |
+
"llm",
|
| 180 |
+
"agent",
|
| 181 |
+
"agents",
|
| 182 |
+
"genai",
|
| 183 |
+
"certificate",
|
| 184 |
+
"refund",
|
| 185 |
+
"price",
|
| 186 |
+
"coupon",
|
| 187 |
+
"discount",
|
| 188 |
+
"promo",
|
| 189 |
+
"career",
|
| 190 |
+
}
|
| 191 |
+
return any(term in haystack for term in allowed_terms)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def coupon_intent(text: str) -> bool:
|
| 195 |
+
lowered = text.lower()
|
| 196 |
+
return any(term in lowered for term in ("coupon", "promo code", "discount code"))
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def coupon_followup(text: str, history: list[str]) -> bool:
|
| 200 |
+
lowered = " ".join([*history, text]).lower()
|
| 201 |
+
prior_coupon_mentions = sum(
|
| 202 |
+
lowered.count(term) for term in ("coupon", "promo code", "discount code")
|
| 203 |
+
)
|
| 204 |
+
return prior_coupon_mentions >= 2 or any(
|
| 205 |
+
term in text.lower() for term in ("please", "really", "need", "student", "can't")
|
| 206 |
+
)
|
tai_helper/llm.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from google import genai
|
| 8 |
+
|
| 9 |
+
from .catalog import assistant_notes
|
| 10 |
+
from .settings import settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
SYSTEM_INSTRUCTION = """You are Towards AI Helper, a concise public assistant for anonymous prospective students.
|
| 14 |
+
|
| 15 |
+
Your only job is to help users choose Towards AI courses, bundles, mentorship,
|
| 16 |
+
free resources, the book, community, or B2B training/consulting.
|
| 17 |
+
|
| 18 |
+
Rules:
|
| 19 |
+
- Stay on this scope. If asked general AI, coding, homework, news, or unrelated questions, briefly say you can only help with choosing Towards AI learning/training options.
|
| 20 |
+
- Do not teach course lesson content, reveal course material, or provide detailed technical lessons. Give high-level fit guidance and link to relevant public pages.
|
| 21 |
+
- Keep answers short: usually 2-5 sentences plus links when useful.
|
| 22 |
+
- For course-choice prompts, ask one practical follow-up if you need background, coding level, goals, or company context.
|
| 23 |
+
- For B2B/company training/consulting, ask for use case, team background, timeline, and suggest emailing louis@towardsai.net.
|
| 24 |
+
- For mentors, recommend the mentorship program and ask what kind of guidance they need.
|
| 25 |
+
- For eager learners wanting the best value, recommend the Get it all bundle.
|
| 26 |
+
- For coupon code requests, do not provide a code.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class LLMResult:
|
| 32 |
+
answer: str
|
| 33 |
+
usage: dict[str, Any] = field(default_factory=dict)
|
| 34 |
+
latency_ms: int = 0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _context_block(selected_pages: list[dict[str, Any]], max_chars: int = 18000) -> str:
|
| 38 |
+
chunks = []
|
| 39 |
+
notes = assistant_notes()
|
| 40 |
+
chunks.append("ROUTING NOTES:\n" + str(notes))
|
| 41 |
+
for index, page in enumerate(selected_pages, start=1):
|
| 42 |
+
if str(page.get("url", "")).startswith("internal://"):
|
| 43 |
+
continue
|
| 44 |
+
text = str(page.get("text", ""))
|
| 45 |
+
chunks.append(
|
| 46 |
+
"\n".join(
|
| 47 |
+
[
|
| 48 |
+
f"SOURCE {index}",
|
| 49 |
+
f"Title: {page.get('title', '')}",
|
| 50 |
+
f"Kind: {page.get('kind', '')}",
|
| 51 |
+
f"URL: {page.get('url', '')}",
|
| 52 |
+
f"Headings: {', '.join(str(h) for h in page.get('headings', [])[:12])}",
|
| 53 |
+
f"Text: {text[:4500]}",
|
| 54 |
+
]
|
| 55 |
+
)
|
| 56 |
+
)
|
| 57 |
+
block = "\n\n".join(chunks)
|
| 58 |
+
return block[:max_chars]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_prompt(
|
| 62 |
+
*,
|
| 63 |
+
query: str,
|
| 64 |
+
selected_prompt: str,
|
| 65 |
+
current_url: str,
|
| 66 |
+
page_title: str,
|
| 67 |
+
history: list[tuple[str, str]],
|
| 68 |
+
selected_pages: list[dict[str, Any]],
|
| 69 |
+
) -> str:
|
| 70 |
+
turns = "\n".join(
|
| 71 |
+
f"{role}: {content[:800]}" for role, content in history[-8:] if content.strip()
|
| 72 |
+
)
|
| 73 |
+
return f"""The visitor is on a public Towards AI page.
|
| 74 |
+
|
| 75 |
+
Current URL: {current_url or "unknown"}
|
| 76 |
+
Current page title: {page_title or "unknown"}
|
| 77 |
+
Initial forced prompt: {selected_prompt or "unknown"}
|
| 78 |
+
|
| 79 |
+
Conversation so far:
|
| 80 |
+
{turns or "(none)"}
|
| 81 |
+
|
| 82 |
+
Visitor message:
|
| 83 |
+
{query}
|
| 84 |
+
|
| 85 |
+
Use only these public sales/resource sources and the routing notes:
|
| 86 |
+
{_context_block(selected_pages)}
|
| 87 |
+
|
| 88 |
+
Answer now. Include links only when directly useful. Do not mention internal routing notes.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def generate_answer(prompt: str) -> LLMResult:
|
| 93 |
+
if not settings.gemini_api_key:
|
| 94 |
+
raise RuntimeError("GEMINI_API_KEY is not configured")
|
| 95 |
+
|
| 96 |
+
started = time.monotonic()
|
| 97 |
+
client = genai.Client(api_key=settings.gemini_api_key)
|
| 98 |
+
response = client.models.generate_content(
|
| 99 |
+
model=settings.model_name,
|
| 100 |
+
contents=prompt,
|
| 101 |
+
config={
|
| 102 |
+
"system_instruction": SYSTEM_INSTRUCTION,
|
| 103 |
+
"temperature": 0.2,
|
| 104 |
+
"max_output_tokens": settings.max_output_tokens,
|
| 105 |
+
},
|
| 106 |
+
)
|
| 107 |
+
usage_metadata = getattr(response, "usage_metadata", None)
|
| 108 |
+
usage = {}
|
| 109 |
+
if usage_metadata is not None:
|
| 110 |
+
usage = {
|
| 111 |
+
"input_tokens": getattr(usage_metadata, "prompt_token_count", None),
|
| 112 |
+
"output_tokens": getattr(usage_metadata, "candidates_token_count", None),
|
| 113 |
+
"total_tokens": getattr(usage_metadata, "total_token_count", None),
|
| 114 |
+
}
|
| 115 |
+
return LLMResult(
|
| 116 |
+
answer=(getattr(response, "text", "") or "").strip(),
|
| 117 |
+
usage={key: value for key, value in usage.items() if value is not None},
|
| 118 |
+
latency_ms=int((time.monotonic() - started) * 1000),
|
| 119 |
+
)
|
tai_helper/monitoring.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from .settings import Settings, settings
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
_OPIK_CONFIGURED_FOR: tuple[str, str, str] | None = None
|
| 14 |
+
_OPIK_IMPORT_WARNING_EMITTED = False
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _truncate(value: str, limit: int) -> str:
|
| 18 |
+
value = value.strip()
|
| 19 |
+
if limit <= 0 or len(value) <= limit:
|
| 20 |
+
return value
|
| 21 |
+
return value[: limit - 1].rstrip() + "..."
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _import_opik() -> Any | None:
|
| 25 |
+
global _OPIK_IMPORT_WARNING_EMITTED
|
| 26 |
+
try:
|
| 27 |
+
import opik # type: ignore[import-not-found]
|
| 28 |
+
except Exception:
|
| 29 |
+
if not _OPIK_IMPORT_WARNING_EMITTED:
|
| 30 |
+
logger.warning("Opik monitoring is enabled but the opik package is unavailable.")
|
| 31 |
+
_OPIK_IMPORT_WARNING_EMITTED = True
|
| 32 |
+
return None
|
| 33 |
+
return opik
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _configure_opik(opik: Any, configured: Settings) -> None:
|
| 37 |
+
global _OPIK_CONFIGURED_FOR
|
| 38 |
+
signature = (
|
| 39 |
+
configured.opik_api_key,
|
| 40 |
+
configured.opik_workspace,
|
| 41 |
+
configured.opik_project_name,
|
| 42 |
+
)
|
| 43 |
+
if _OPIK_CONFIGURED_FOR == signature:
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
os.environ["OPIK_API_KEY"] = configured.opik_api_key
|
| 47 |
+
os.environ["OPIK_PROJECT_NAME"] = configured.opik_project_name
|
| 48 |
+
if configured.opik_workspace:
|
| 49 |
+
os.environ["OPIK_WORKSPACE"] = configured.opik_workspace
|
| 50 |
+
|
| 51 |
+
configure = getattr(opik, "configure", None)
|
| 52 |
+
if callable(configure):
|
| 53 |
+
kwargs = {
|
| 54 |
+
"api_key": configured.opik_api_key,
|
| 55 |
+
"use_local": False,
|
| 56 |
+
"force": True,
|
| 57 |
+
"automatic_approvals": True,
|
| 58 |
+
}
|
| 59 |
+
if configured.opik_workspace:
|
| 60 |
+
kwargs["workspace"] = configured.opik_workspace
|
| 61 |
+
configure(**kwargs)
|
| 62 |
+
|
| 63 |
+
_OPIK_CONFIGURED_FOR = signature
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass(slots=True)
|
| 67 |
+
class HelperMonitor:
|
| 68 |
+
query: str
|
| 69 |
+
answer: str
|
| 70 |
+
current_url: str
|
| 71 |
+
selected_prompt: str
|
| 72 |
+
visitor_key: str
|
| 73 |
+
thread_id: str
|
| 74 |
+
sources: list[dict[str, str]]
|
| 75 |
+
usage: dict[str, Any]
|
| 76 |
+
latency_ms: int
|
| 77 |
+
error_message: str = ""
|
| 78 |
+
configured: Settings = settings
|
| 79 |
+
opik_module: Any | None = None
|
| 80 |
+
started_at: float = field(default_factory=time.monotonic)
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def enabled(self) -> bool:
|
| 84 |
+
return bool(self.configured.opik_enabled and self.configured.opik_api_key)
|
| 85 |
+
|
| 86 |
+
def flush(self) -> None:
|
| 87 |
+
if not self.enabled:
|
| 88 |
+
return
|
| 89 |
+
opik = self.opik_module or _import_opik()
|
| 90 |
+
if opik is None:
|
| 91 |
+
return
|
| 92 |
+
try:
|
| 93 |
+
_configure_opik(opik, self.configured)
|
| 94 |
+
self._write_span(opik)
|
| 95 |
+
except Exception:
|
| 96 |
+
logger.warning("Failed to write Opik helper trace.", exc_info=True)
|
| 97 |
+
|
| 98 |
+
def _write_span(self, opik: Any) -> None:
|
| 99 |
+
tags = ["towards-ai-helper", "public-sales-helper"]
|
| 100 |
+
if self.selected_prompt:
|
| 101 |
+
tags.append("forced-prompt")
|
| 102 |
+
span_kwargs = {
|
| 103 |
+
"name": "towards_ai_helper_chat",
|
| 104 |
+
"type": "llm",
|
| 105 |
+
"input": {
|
| 106 |
+
"query": _truncate(self.query, self.configured.opik_max_text_chars),
|
| 107 |
+
"selected_prompt": self.selected_prompt,
|
| 108 |
+
"current_url": self.current_url,
|
| 109 |
+
},
|
| 110 |
+
"output": {
|
| 111 |
+
"answer": _truncate(self.answer, self.configured.opik_max_text_chars)
|
| 112 |
+
},
|
| 113 |
+
"metadata": {
|
| 114 |
+
"thread_id": self.thread_id,
|
| 115 |
+
"visitor_key": self.visitor_key,
|
| 116 |
+
"current_url": self.current_url,
|
| 117 |
+
"sources": self.sources,
|
| 118 |
+
"latency_ms": self.latency_ms,
|
| 119 |
+
"status": "error" if self.error_message else "completed",
|
| 120 |
+
},
|
| 121 |
+
"tags": tags,
|
| 122 |
+
"project_name": self.configured.opik_project_name,
|
| 123 |
+
"model": self.configured.model_name,
|
| 124 |
+
"provider": "google_genai",
|
| 125 |
+
"flush": True,
|
| 126 |
+
}
|
| 127 |
+
with opik.start_as_current_span(**span_kwargs) as span:
|
| 128 |
+
if self.usage:
|
| 129 |
+
span.usage = {
|
| 130 |
+
"prompt_tokens": self.usage.get("input_tokens"),
|
| 131 |
+
"completion_tokens": self.usage.get("output_tokens"),
|
| 132 |
+
"total_tokens": self.usage.get("total_tokens"),
|
| 133 |
+
}
|
| 134 |
+
if self.error_message:
|
| 135 |
+
span.error_info = {
|
| 136 |
+
"type": "RuntimeError",
|
| 137 |
+
"message": _truncate(self.error_message, 1000),
|
| 138 |
+
}
|
tai_helper/rate_limiter.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from collections import defaultdict, deque
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class RateLimit:
|
| 10 |
+
name: str
|
| 11 |
+
max_requests: int
|
| 12 |
+
window_seconds: int
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class RateLimitResult:
|
| 17 |
+
allowed: bool
|
| 18 |
+
limit_name: str = ""
|
| 19 |
+
retry_after_seconds: int = 0
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class FixedWindowRateLimiter:
|
| 23 |
+
def __init__(self, limits: tuple[RateLimit, ...]) -> None:
|
| 24 |
+
self.limits = limits
|
| 25 |
+
self._events: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
| 26 |
+
|
| 27 |
+
def check(self, key: str) -> RateLimitResult:
|
| 28 |
+
now = time.time()
|
| 29 |
+
for limit in self.limits:
|
| 30 |
+
events = self._events[(limit.name, key)]
|
| 31 |
+
while events and now - events[0] >= limit.window_seconds:
|
| 32 |
+
events.popleft()
|
| 33 |
+
if len(events) >= limit.max_requests:
|
| 34 |
+
retry_after = max(1, int(limit.window_seconds - (now - events[0])) + 1)
|
| 35 |
+
return RateLimitResult(False, limit.name, retry_after)
|
| 36 |
+
|
| 37 |
+
for limit in self.limits:
|
| 38 |
+
self._events[(limit.name, key)].append(now)
|
| 39 |
+
return RateLimitResult(True)
|
| 40 |
+
|
| 41 |
+
def clear(self) -> None:
|
| 42 |
+
self._events.clear()
|
tai_helper/schemas.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class HelperContext(BaseModel):
|
| 9 |
+
url: str = ""
|
| 10 |
+
pageTitle: str = ""
|
| 11 |
+
referrer: str = ""
|
| 12 |
+
signedIn: bool = False
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ChatTurn(BaseModel):
|
| 16 |
+
role: Literal["user", "assistant"]
|
| 17 |
+
content: str = Field(default="", max_length=2000)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class HelperChatRequest(BaseModel):
|
| 21 |
+
query: str = Field(min_length=1, max_length=1000)
|
| 22 |
+
selectedPrompt: str = ""
|
| 23 |
+
visitorId: str = ""
|
| 24 |
+
threadId: str = ""
|
| 25 |
+
history: list[ChatTurn] = Field(default_factory=list)
|
| 26 |
+
context: HelperContext = Field(default_factory=HelperContext)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class SourceOut(BaseModel):
|
| 30 |
+
title: str
|
| 31 |
+
url: str
|
| 32 |
+
kind: str
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class HelperChatResponse(BaseModel):
|
| 36 |
+
answer: str
|
| 37 |
+
threadId: str
|
| 38 |
+
sources: list[SourceOut]
|
| 39 |
+
usage: dict[str, Any] = Field(default_factory=dict)
|
tai_helper/settings.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def repo_root() -> Path:
|
| 13 |
+
return Path(__file__).resolve().parents[1]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _csv_env(name: str, default: str = "") -> tuple[str, ...]:
|
| 17 |
+
raw = os.getenv(name, default)
|
| 18 |
+
return tuple(item.strip() for item in raw.split(",") if item.strip())
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _int_env(name: str, default: int) -> int:
|
| 22 |
+
raw = os.getenv(name)
|
| 23 |
+
if raw is None:
|
| 24 |
+
return default
|
| 25 |
+
try:
|
| 26 |
+
return int(raw)
|
| 27 |
+
except ValueError:
|
| 28 |
+
return default
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _bool_env(name: str, default: bool = False) -> bool:
|
| 32 |
+
raw = os.getenv(name)
|
| 33 |
+
if raw is None:
|
| 34 |
+
return default
|
| 35 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class Settings:
|
| 40 |
+
allowed_origins: tuple[str, ...] = field(
|
| 41 |
+
default_factory=lambda: _csv_env(
|
| 42 |
+
"HELPER_ALLOWED_ORIGINS",
|
| 43 |
+
"https://academy.towardsai.net,https://towardsai.net,https://www.towardsai.net",
|
| 44 |
+
)
|
| 45 |
+
)
|
| 46 |
+
allowed_hosts: tuple[str, ...] = field(
|
| 47 |
+
default_factory=lambda: _csv_env(
|
| 48 |
+
"HELPER_ALLOWED_HOSTS",
|
| 49 |
+
"academy.towardsai.net,towardsai.net,www.towardsai.net",
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
model_name: str = field(
|
| 53 |
+
default_factory=lambda: os.getenv("HELPER_MODEL", "gemini-2.5-flash").strip()
|
| 54 |
+
or "gemini-2.5-flash"
|
| 55 |
+
)
|
| 56 |
+
gemini_api_key: str = field(
|
| 57 |
+
default_factory=lambda: os.getenv("GEMINI_API_KEY", "").strip()
|
| 58 |
+
)
|
| 59 |
+
max_output_tokens: int = field(
|
| 60 |
+
default_factory=lambda: _int_env("HELPER_MAX_OUTPUT_TOKENS", 420)
|
| 61 |
+
)
|
| 62 |
+
max_body_bytes: int = field(
|
| 63 |
+
default_factory=lambda: _int_env("HELPER_MAX_BODY_BYTES", 64 * 1024)
|
| 64 |
+
)
|
| 65 |
+
max_query_chars: int = field(
|
| 66 |
+
default_factory=lambda: _int_env("HELPER_MAX_QUERY_CHARS", 600)
|
| 67 |
+
)
|
| 68 |
+
max_history_turns: int = field(
|
| 69 |
+
default_factory=lambda: _int_env("HELPER_MAX_HISTORY_TURNS", 8)
|
| 70 |
+
)
|
| 71 |
+
rate_limit_per_minute: int = field(
|
| 72 |
+
default_factory=lambda: _int_env("HELPER_RATE_LIMIT_PER_MINUTE", 3)
|
| 73 |
+
)
|
| 74 |
+
rate_limit_per_day: int = field(
|
| 75 |
+
default_factory=lambda: _int_env("HELPER_RATE_LIMIT_PER_DAY", 20)
|
| 76 |
+
)
|
| 77 |
+
rate_limit_global_per_minute: int = field(
|
| 78 |
+
default_factory=lambda: _int_env("HELPER_GLOBAL_RATE_LIMIT_PER_MINUTE", 120)
|
| 79 |
+
)
|
| 80 |
+
opik_enabled: bool = field(default_factory=lambda: _bool_env("OPIK_ENABLED", False))
|
| 81 |
+
opik_api_key: str = field(
|
| 82 |
+
default_factory=lambda: os.getenv("OPIK_API_KEY", "").strip()
|
| 83 |
+
)
|
| 84 |
+
opik_workspace: str = field(
|
| 85 |
+
default_factory=lambda: os.getenv("OPIK_WORKSPACE", "").strip()
|
| 86 |
+
)
|
| 87 |
+
opik_project_name: str = field(
|
| 88 |
+
default_factory=lambda: os.getenv(
|
| 89 |
+
"OPIK_PROJECT_NAME",
|
| 90 |
+
"towards-ai-helper",
|
| 91 |
+
).strip()
|
| 92 |
+
or "towards-ai-helper"
|
| 93 |
+
)
|
| 94 |
+
opik_max_text_chars: int = field(
|
| 95 |
+
default_factory=lambda: _int_env("OPIK_MAX_TEXT_CHARS", 4000)
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
def cors_origins(self) -> list[str]:
|
| 99 |
+
return list(self.allowed_origins)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
settings = Settings()
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
from tai_helper import api
|
| 6 |
+
from tai_helper.llm import LLMResult
|
| 7 |
+
from tai_helper.rate_limiter import FixedWindowRateLimiter, RateLimit
|
| 8 |
+
|
| 9 |
+
client = TestClient(api.app)
|
| 10 |
+
HEADERS = {"Origin": "https://academy.towardsai.net"}
|
| 11 |
+
PUBLIC_URL = "https://academy.towardsai.net/courses/agent-engineering"
|
| 12 |
+
FIRST_PROMPT = "I want help deciding which course to take."
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def reset_limiters() -> None:
|
| 16 |
+
api.visitor_limiter = FixedWindowRateLimiter(
|
| 17 |
+
(
|
| 18 |
+
RateLimit("per_minute", 100, 60),
|
| 19 |
+
RateLimit("per_day", 1000, 24 * 60 * 60),
|
| 20 |
+
)
|
| 21 |
+
)
|
| 22 |
+
api.ip_limiter = FixedWindowRateLimiter(
|
| 23 |
+
(
|
| 24 |
+
RateLimit("ip_per_minute", 100, 60),
|
| 25 |
+
RateLimit("ip_per_day", 1000, 24 * 60 * 60),
|
| 26 |
+
)
|
| 27 |
+
)
|
| 28 |
+
api.global_limiter = FixedWindowRateLimiter(
|
| 29 |
+
(RateLimit("global_per_minute", 1000, 60),)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def payload(query: str = FIRST_PROMPT, *, url: str = PUBLIC_URL, signed_in: bool = False):
|
| 34 |
+
return {
|
| 35 |
+
"query": query,
|
| 36 |
+
"selectedPrompt": query if query == FIRST_PROMPT else FIRST_PROMPT,
|
| 37 |
+
"visitorId": "test-visitor",
|
| 38 |
+
"threadId": "",
|
| 39 |
+
"history": [],
|
| 40 |
+
"context": {
|
| 41 |
+
"url": url,
|
| 42 |
+
"pageTitle": "Agent course",
|
| 43 |
+
"signedIn": signed_in,
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_config_exposes_public_widget_contract() -> None:
|
| 49 |
+
response = client.get("/api/helper/config")
|
| 50 |
+
|
| 51 |
+
assert response.status_code == 200
|
| 52 |
+
data = response.json()
|
| 53 |
+
assert data["name"] == "Towards AI Helper"
|
| 54 |
+
assert FIRST_PROMPT in data["forcedPrompts"]
|
| 55 |
+
assert "/courses/agent-engineering" in data["allowedPathsByHost"]["academy.towardsai.net"]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_chat_requires_allowed_origin_public_page_signed_out_and_first_prompt() -> None:
|
| 59 |
+
reset_limiters()
|
| 60 |
+
|
| 61 |
+
assert client.post("/api/helper/chat", json=payload()).status_code == 403
|
| 62 |
+
assert (
|
| 63 |
+
client.post(
|
| 64 |
+
"/api/helper/chat",
|
| 65 |
+
json=payload(url="https://academy.towardsai.net/courses/take/x"),
|
| 66 |
+
headers=HEADERS,
|
| 67 |
+
).status_code
|
| 68 |
+
== 403
|
| 69 |
+
)
|
| 70 |
+
assert (
|
| 71 |
+
client.post(
|
| 72 |
+
"/api/helper/chat",
|
| 73 |
+
json=payload(signed_in=True),
|
| 74 |
+
headers=HEADERS,
|
| 75 |
+
).status_code
|
| 76 |
+
== 403
|
| 77 |
+
)
|
| 78 |
+
assert (
|
| 79 |
+
client.post(
|
| 80 |
+
"/api/helper/chat",
|
| 81 |
+
json=payload(query="Can I type first?"),
|
| 82 |
+
headers=HEADERS,
|
| 83 |
+
).status_code
|
| 84 |
+
== 400
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_chat_calls_gemini_with_retrieved_sources(monkeypatch) -> None:
|
| 89 |
+
reset_limiters()
|
| 90 |
+
prompts = []
|
| 91 |
+
|
| 92 |
+
def fake_generate_answer(prompt: str) -> LLMResult:
|
| 93 |
+
prompts.append(prompt)
|
| 94 |
+
return LLMResult(
|
| 95 |
+
answer="Tell me your coding background and goal.",
|
| 96 |
+
usage={"input_tokens": 10, "output_tokens": 8, "total_tokens": 18},
|
| 97 |
+
latency_ms=123,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
monkeypatch.setattr(api.llm, "generate_answer", fake_generate_answer)
|
| 101 |
+
|
| 102 |
+
response = client.post("/api/helper/chat", json=payload(), headers=HEADERS)
|
| 103 |
+
|
| 104 |
+
assert response.status_code == 200
|
| 105 |
+
data = response.json()
|
| 106 |
+
assert data["answer"] == "Tell me your coding background and goal."
|
| 107 |
+
assert data["threadId"]
|
| 108 |
+
assert data["sources"]
|
| 109 |
+
assert data["usage"]["total_tokens"] == 18
|
| 110 |
+
assert "Agent Engineering" in prompts[0]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_coupon_answer_is_deterministic_and_does_not_call_model(monkeypatch) -> None:
|
| 114 |
+
reset_limiters()
|
| 115 |
+
|
| 116 |
+
def unexpected_generate_answer(_prompt: str) -> LLMResult:
|
| 117 |
+
raise AssertionError("model should not be called for coupon intent")
|
| 118 |
+
|
| 119 |
+
monkeypatch.setattr(api.llm, "generate_answer", unexpected_generate_answer)
|
| 120 |
+
first = payload(query="Do you have a coupon code?")
|
| 121 |
+
first["selectedPrompt"] = FIRST_PROMPT
|
| 122 |
+
first["history"] = [{"role": "user", "content": FIRST_PROMPT}]
|
| 123 |
+
second = payload(query="Please I really need a coupon code")
|
| 124 |
+
second["selectedPrompt"] = FIRST_PROMPT
|
| 125 |
+
second["history"] = [
|
| 126 |
+
{"role": "user", "content": FIRST_PROMPT},
|
| 127 |
+
{"role": "assistant", "content": "I can't provide a coupon code here."},
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
first_response = client.post("/api/helper/chat", json=first, headers=HEADERS)
|
| 131 |
+
second_response = client.post("/api/helper/chat", json=second, headers=HEADERS)
|
| 132 |
+
|
| 133 |
+
assert first_response.status_code == 200
|
| 134 |
+
assert "Get it all bundle" in first_response.json()["answer"]
|
| 135 |
+
assert second_response.status_code == 200
|
| 136 |
+
assert "louis@towardsai.net" in second_response.json()["answer"]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_rate_limit_is_hard(monkeypatch) -> None:
|
| 140 |
+
api.visitor_limiter = FixedWindowRateLimiter((RateLimit("per_minute", 1, 60),))
|
| 141 |
+
api.ip_limiter = FixedWindowRateLimiter((RateLimit("ip_per_minute", 100, 60),))
|
| 142 |
+
api.global_limiter = FixedWindowRateLimiter(
|
| 143 |
+
(RateLimit("global_per_minute", 100, 60),)
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
monkeypatch.setattr(
|
| 147 |
+
api.llm,
|
| 148 |
+
"generate_answer",
|
| 149 |
+
lambda _prompt: LLMResult(answer="ok"),
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
first = client.post("/api/helper/chat", json=payload(), headers=HEADERS)
|
| 153 |
+
second = client.post("/api/helper/chat", json=payload(), headers=HEADERS)
|
| 154 |
+
|
| 155 |
+
assert first.status_code == 200
|
| 156 |
+
assert second.status_code == 429
|
| 157 |
+
assert int(second.headers["retry-after"]) > 0
|
tests/test_catalog.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from tai_helper.catalog import (
|
| 4 |
+
allowed_paths_by_host,
|
| 5 |
+
coupon_followup,
|
| 6 |
+
coupon_intent,
|
| 7 |
+
forced_prompts,
|
| 8 |
+
in_scope,
|
| 9 |
+
page_is_allowed,
|
| 10 |
+
retrieve,
|
| 11 |
+
sources_from_pages,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_forced_prompts_match_expected_options() -> None:
|
| 16 |
+
assert forced_prompts() == [
|
| 17 |
+
"I want help deciding which course to take.",
|
| 18 |
+
"I want help to integrate AI into my company",
|
| 19 |
+
"I want a training inside my company",
|
| 20 |
+
"I'm looking for more free resources to learn before committing to buying a course",
|
| 21 |
+
"I want to find mentors",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_allowed_public_pages_include_sitemap_urls_and_exclude_private_paths() -> None:
|
| 26 |
+
assert page_is_allowed("https://academy.towardsai.net/courses/agent-engineering")
|
| 27 |
+
assert page_is_allowed("https://towardsai.net/b2b")
|
| 28 |
+
assert page_is_allowed("https://www.towardsai.net/b2b")
|
| 29 |
+
assert not page_is_allowed(
|
| 30 |
+
"https://academy.towardsai.net/courses/take/agent-engineering/lessons/x"
|
| 31 |
+
)
|
| 32 |
+
assert not page_is_allowed("https://academy.towardsai.net/enroll/123")
|
| 33 |
+
assert "/b2b" in allowed_paths_by_host()["www.towardsai.net"]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_retrieval_routes_mentorship_b2b_and_bundle_queries() -> None:
|
| 37 |
+
mentorship = sources_from_pages(retrieve("I want to find mentors"))
|
| 38 |
+
b2b = sources_from_pages(retrieve("I want training inside my company"))
|
| 39 |
+
bundle = sources_from_pages(retrieve("What is the best value bundle?"))
|
| 40 |
+
|
| 41 |
+
assert any("tai-mentorship" in source["url"] for source in mentorship)
|
| 42 |
+
assert any("towardsai.net/b2b" in source["url"] for source in b2b)
|
| 43 |
+
assert any("get-it-all" in source["url"] for source in bundle)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_scope_and_coupon_detection() -> None:
|
| 47 |
+
assert in_scope("Which course is best for learning agents?")
|
| 48 |
+
assert not in_scope("Who won the world cup?")
|
| 49 |
+
assert coupon_intent("Do you have a coupon code?")
|
| 50 |
+
assert coupon_followup("Please I really need a discount code", ["coupon?"])
|
tests/test_live_smoke.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
LIVE_BASE_URL = os.getenv("LIVE_SPACE_BASE_URL", "").rstrip("/")
|
| 10 |
+
RUN_LIVE_CHAT = os.getenv("RUN_LIVE_CHAT_SMOKE", "").lower() in {
|
| 11 |
+
"1",
|
| 12 |
+
"true",
|
| 13 |
+
"yes",
|
| 14 |
+
"on",
|
| 15 |
+
}
|
| 16 |
+
HEADERS = {"Origin": "https://academy.towardsai.net"}
|
| 17 |
+
FIRST_PROMPT = "I want help deciding which course to take."
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def require_live_base_url() -> str:
|
| 21 |
+
if not LIVE_BASE_URL:
|
| 22 |
+
pytest.skip("LIVE_SPACE_BASE_URL is not set")
|
| 23 |
+
return LIVE_BASE_URL
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def max_seconds(name: str, default: float) -> float:
|
| 27 |
+
try:
|
| 28 |
+
return float(os.getenv(name, str(default)))
|
| 29 |
+
except ValueError:
|
| 30 |
+
return default
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def timed_request(method: str, url: str, **kwargs) -> tuple[requests.Response, float]:
|
| 34 |
+
start = time.perf_counter()
|
| 35 |
+
response = requests.request(method, url, **kwargs)
|
| 36 |
+
elapsed = time.perf_counter() - start
|
| 37 |
+
return response, elapsed
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.mark.live
|
| 41 |
+
def test_live_health_widget_and_config() -> None:
|
| 42 |
+
base_url = require_live_base_url()
|
| 43 |
+
|
| 44 |
+
health, health_seconds = timed_request("GET", f"{base_url}/healthz", timeout=120)
|
| 45 |
+
widget, widget_seconds = timed_request("GET", f"{base_url}/widget.js", timeout=120)
|
| 46 |
+
config, config_seconds = timed_request(
|
| 47 |
+
"GET", f"{base_url}/api/helper/config", timeout=120
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
assert health.status_code == 200
|
| 51 |
+
assert health.json() == {"status": "ok"}
|
| 52 |
+
assert health_seconds <= max_seconds("LIVE_SMOKE_MAX_HEALTH_SECONDS", 120)
|
| 53 |
+
assert widget.status_code == 200
|
| 54 |
+
assert "Towards AI Helper" in widget.text
|
| 55 |
+
assert "Choose a starter prompt" in widget.text
|
| 56 |
+
assert "isSignedIn" in widget.text
|
| 57 |
+
assert widget_seconds <= max_seconds("LIVE_SMOKE_MAX_WIDGET_SECONDS", 30)
|
| 58 |
+
assert config.status_code == 200
|
| 59 |
+
payload = config.json()
|
| 60 |
+
assert FIRST_PROMPT in payload["forcedPrompts"]
|
| 61 |
+
assert "/courses/agent-engineering" in payload["allowedPathsByHost"]["academy.towardsai.net"]
|
| 62 |
+
assert config_seconds <= max_seconds("LIVE_SMOKE_MAX_CONFIG_SECONDS", 30)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@pytest.mark.live
|
| 66 |
+
@pytest.mark.skipif(
|
| 67 |
+
not RUN_LIVE_CHAT,
|
| 68 |
+
reason="RUN_LIVE_CHAT_SMOKE is not enabled",
|
| 69 |
+
)
|
| 70 |
+
def test_live_chat_returns_concise_answer() -> None:
|
| 71 |
+
base_url = require_live_base_url()
|
| 72 |
+
response, elapsed = timed_request(
|
| 73 |
+
"POST",
|
| 74 |
+
f"{base_url}/api/helper/chat",
|
| 75 |
+
json={
|
| 76 |
+
"query": FIRST_PROMPT,
|
| 77 |
+
"selectedPrompt": FIRST_PROMPT,
|
| 78 |
+
"visitorId": "github-action-smoke",
|
| 79 |
+
"context": {
|
| 80 |
+
"url": "https://academy.towardsai.net/courses/agent-engineering",
|
| 81 |
+
"pageTitle": "Agent course",
|
| 82 |
+
"signedIn": False,
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
headers=HEADERS,
|
| 86 |
+
timeout=120,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
assert response.status_code == 200
|
| 90 |
+
payload = response.json()
|
| 91 |
+
assert payload["answer"].strip()
|
| 92 |
+
assert len(payload["answer"]) < 1400
|
| 93 |
+
assert payload["sources"]
|
| 94 |
+
assert elapsed <= max_seconds("LIVE_SMOKE_MAX_CHAT_SECONDS", 90)
|