Spaces:
Sleeping
Sleeping
Add Docker setup for Hugging Face Spaces deployment
Browse files- Add Dockerfile with Python 3.11, uv, uid 1000, port 7860
- Add .dockerignore to exclude .env, logs, and runtime data
- Update README.md with app_port: 7860 for HF Spaces
- Add CORS origin for *.hf.space
- Add full project source: FastAPI RAG agent backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .dockerignore +19 -0
- .env.example +1 -0
- .gitattributes +35 -35
- COST_NOTES.md +80 -0
- Dockerfile +41 -0
- README.md +12 -11
- agents/__init__.py +0 -0
- agents/__pycache__/__init__.cpython-312.pyc +0 -0
- agents/__pycache__/rag_agent.cpython-312.pyc +0 -0
- agents/rag_agent.py +105 -0
- api/__init__.py +0 -0
- api/__pycache__/__init__.cpython-312.pyc +0 -0
- api/__pycache__/routes.cpython-312.pyc +0 -0
- api/routes.py +220 -0
- cache/__init__.py +0 -0
- cache/__pycache__/__init__.cpython-312.pyc +0 -0
- cache/__pycache__/query_cache.cpython-312.pyc +0 -0
- cache/query_cache.py +90 -0
- cache/query_store/cache.db +0 -0
- chroma.py +34 -0
- db/__init__.py +0 -0
- db/__pycache__/__init__.cpython-312.pyc +0 -0
- db/__pycache__/history.cpython-312.pyc +0 -0
- db/history.py +130 -0
- ingestion.py +157 -0
- main.py +39 -0
- pyproject.toml +34 -0
- scraper/__init__.py +0 -0
- scraper/__pycache__/__init__.cpython-312.pyc +0 -0
- scraper/__pycache__/content_store.cpython-312.pyc +0 -0
- scraper/__pycache__/scheduler.cpython-312.pyc +0 -0
- scraper/__pycache__/web_scraper.cpython-312.pyc +0 -0
- scraper/_test_store.json +42 -0
- scraper/content_store.py +71 -0
- scraper/scheduler.py +70 -0
- scraper/site_content.json +202 -0
- scraper/web_scraper.py +74 -0
- tools/__init__.py +0 -0
- tools/__pycache__/__init__.cpython-312.pyc +0 -0
- tools/__pycache__/document_loader_tool.cpython-312.pyc +0 -0
- tools/__pycache__/list_documents_tool.cpython-312.pyc +0 -0
- tools/__pycache__/retrieval_tool.cpython-312.pyc +0 -0
- tools/__pycache__/summarize_tool.cpython-312.pyc +0 -0
- tools/__pycache__/web_search_tool.cpython-312.pyc +0 -0
- tools/document_loader_tool.py +64 -0
- tools/list_documents_tool.py +42 -0
- tools/retrieval_tool.py +52 -0
- tools/summarize_tool.py +98 -0
- tools/web_search_tool.py +62 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
__pycache__
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
*.pyd
|
| 8 |
+
.Python
|
| 9 |
+
*.egg-info
|
| 10 |
+
.venv
|
| 11 |
+
venv
|
| 12 |
+
node_modules
|
| 13 |
+
*.log
|
| 14 |
+
server.log
|
| 15 |
+
server.err
|
| 16 |
+
COST_NOTES.md
|
| 17 |
+
documents/
|
| 18 |
+
vector_store/
|
| 19 |
+
cache/
|
.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
OPENAI_API_KEY=your_key_here
|
.gitattributes
CHANGED
|
@@ -1,35 +1,35 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
COST_NOTES.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Cost Notes
|
| 2 |
+
|
| 3 |
+
## Operations that cost OpenAI tokens
|
| 4 |
+
|
| 5 |
+
| Operation | Model | When | Frequency |
|
| 6 |
+
|---|---|---|---|
|
| 7 |
+
| Final answer generation | gpt-4o-mini (chat) | Every **uncached** user query | Per unique question |
|
| 8 |
+
| Document summarization | gpt-4o-mini (chat) | First call per document | Once per file; sidecar caches result |
|
| 9 |
+
| Document ingestion (embedding) | text-embedding-3-small | When a document is indexed | Once per document; re-indexing re-bills |
|
| 10 |
+
| Site re-indexing after scrape | text-embedding-3-small | When scheduler detects stale content | At most once per 60-minute window |
|
| 11 |
+
|
| 12 |
+
## Operations that are free (zero OpenAI tokens)
|
| 13 |
+
|
| 14 |
+
| Operation | How |
|
| 15 |
+
|---|---|
|
| 16 |
+
| Semantic cache lookup | Local sentence-transformers (`all-MiniLM-L6-v2`) + pure-Python cosine similarity |
|
| 17 |
+
| Cache embedding on save | Same local model β no API call |
|
| 18 |
+
| BM25 paragraph scoring | `rank-bm25` library, runs in-process |
|
| 19 |
+
| Website scraping | Plain HTTP fetch + BeautifulSoup |
|
| 20 |
+
| Content diff checking | `difflib.unified_diff` (stdlib) |
|
| 21 |
+
| Staleness check | Timestamp arithmetic on cached JSON |
|
| 22 |
+
| ChromaDB metadata queries | Reads stored metadata; no embedding triggered |
|
| 23 |
+
| Document summarization (repeat) | Reads `.summary.json` sidecar; OpenAI never called |
|
| 24 |
+
| Cache hit response | Returns stored answer string; agent never instantiated |
|
| 25 |
+
|
| 26 |
+
## Token estimates per request
|
| 27 |
+
|
| 28 |
+
### Uncached query (cache MISS)
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
System prompt: ~100 tokens (input)
|
| 32 |
+
Tool schemas (3 tools): ~200 tokens (input)
|
| 33 |
+
Conversation history: ~50 tokens (input, typical 1-2 turns)
|
| 34 |
+
User message: ~20 tokens (input)
|
| 35 |
+
Tool call result ~400 tokens (input β BM25 top-5 paragraphs from website)
|
| 36 |
+
(search_agentrax_website)
|
| 37 |
+
ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
Total input: ~770 tokens
|
| 39 |
+
Agent response: ~150 tokens (output)
|
| 40 |
+
ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
+
Total per query: ~920 tokens β ~$0.00028 at gpt-4o-mini pricing
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### Cached query (cache HIT)
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
OpenAI tokens: 0
|
| 48 |
+
Local compute: sentence-transformers inference + cosine scan over cache
|
| 49 |
+
Latency: <50 ms (no network call)
|
| 50 |
+
Cost: $0.00
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### Document summarization (first call)
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
System prompt: ~50 tokens (input)
|
| 57 |
+
Document text: up to 12,000 characters (~3,000 tokens, input)
|
| 58 |
+
Summary output: ~300 tokens (output)
|
| 59 |
+
ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
Total first call: ~3,350 tokens β ~$0.001 at gpt-4o-mini pricing
|
| 61 |
+
Subsequent calls: 0 tokens (sidecar cache)
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### Document ingestion (text-embedding-3-small)
|
| 65 |
+
|
| 66 |
+
```
|
| 67 |
+
Rate: $0.00002 per 1K tokens
|
| 68 |
+
Typical document: ~5,000 tokens across all chunks
|
| 69 |
+
Cost per document: ~$0.0001
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
## Cost reduction mechanisms in place
|
| 73 |
+
|
| 74 |
+
1. **Semantic cache** β identical or near-identical questions (cosine β₯ 0.85) skip the agent entirely.
|
| 75 |
+
2. **Scrape cache** β website content is served from disk for 60 minutes; HTTP fetch only on stale.
|
| 76 |
+
3. **Summary sidecar** β document summaries are written to disk and never regenerated.
|
| 77 |
+
4. **Staleness guard in scheduler** β `is_content_stale()` checked before any scrape or re-index.
|
| 78 |
+
5. **Local embeddings for cache** β sentence-transformers runs on CPU, no API cost for cache operations.
|
| 79 |
+
6. **ChromaDB singleton** β client opened once per process; no per-request reconnect overhead.
|
| 80 |
+
7. **gpt-4o-mini** β ~15Γ cheaper than gpt-4o for equivalent tasks in this domain.
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces requires port 7860 and uid 1000
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# System dependencies for chromadb, lxml, sentence-transformers, unstructured
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
build-essential \
|
| 7 |
+
libxml2-dev \
|
| 8 |
+
libxslt-dev \
|
| 9 |
+
libsqlite3-dev \
|
| 10 |
+
libffi-dev \
|
| 11 |
+
curl \
|
| 12 |
+
git \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# Install uv for fast dependency resolution
|
| 16 |
+
RUN pip install --no-cache-dir uv
|
| 17 |
+
|
| 18 |
+
# HF Spaces requires non-root user with uid 1000
|
| 19 |
+
RUN useradd -m -u 1000 -s /bin/bash appuser
|
| 20 |
+
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
|
| 23 |
+
# Install deps before copying source (better layer caching)
|
| 24 |
+
COPY pyproject.toml uv.lock ./
|
| 25 |
+
RUN uv sync --frozen --no-dev
|
| 26 |
+
|
| 27 |
+
# Copy source code
|
| 28 |
+
COPY --chown=appuser:appuser . .
|
| 29 |
+
|
| 30 |
+
# Create writable runtime directories
|
| 31 |
+
RUN mkdir -p vector_store cache documents \
|
| 32 |
+
&& chown -R appuser:appuser /app
|
| 33 |
+
|
| 34 |
+
USER appuser
|
| 35 |
+
|
| 36 |
+
# Pre-download the sentence transformer model so first request is fast
|
| 37 |
+
RUN uv run python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
|
| 38 |
+
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: AgentraXhelpAgent
|
| 3 |
-
emoji: π¦
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: indigo
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgentraXhelpAgent
|
| 3 |
+
emoji: π¦
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: usershelpAgent
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
agents/__init__.py
ADDED
|
File without changes
|
agents/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (136 Bytes). View file
|
|
|
agents/__pycache__/rag_agent.cpython-312.pyc
ADDED
|
Binary file (4.61 kB). View file
|
|
|
agents/rag_agent.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# The local backend/agents/ package shadows the openai-agents SDK `agents` package.
|
| 6 |
+
# Fix: force .venv/Lib/site-packages to index 0, then clear the cached local entry.
|
| 7 |
+
_venv_site = str((Path(__file__).parent.parent / ".venv" / "Lib" / "site-packages").resolve())
|
| 8 |
+
if _venv_site in sys.path:
|
| 9 |
+
sys.path.remove(_venv_site)
|
| 10 |
+
sys.path.insert(0, _venv_site)
|
| 11 |
+
for _key in [k for k in sys.modules if k == "agents" or (k.startswith("agents.") and k != __name__)]:
|
| 12 |
+
del sys.modules[_key]
|
| 13 |
+
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 16 |
+
|
| 17 |
+
from agents import Agent, RunConfig, function_tool # openai-agents SDK
|
| 18 |
+
from agents.models.openai_provider import OpenAIProvider
|
| 19 |
+
|
| 20 |
+
from tools.list_documents_tool import list_indexed_documents as _list_indexed_documents
|
| 21 |
+
from tools.summarize_tool import summarize_document as _summarize_document
|
| 22 |
+
from tools.web_search_tool import search_agentrax_website as _search_agentrax_website
|
| 23 |
+
|
| 24 |
+
_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
| 25 |
+
_GEMINI_MODEL = "gemini-2.5-flash"
|
| 26 |
+
|
| 27 |
+
# Module-level provider β created once, reused for every request
|
| 28 |
+
_provider = OpenAIProvider(
|
| 29 |
+
api_key=os.environ.get("GEMINI_API_KEY", ""),
|
| 30 |
+
base_url=_GEMINI_BASE_URL,
|
| 31 |
+
use_responses=False, # Gemini supports Chat Completions, not the Responses API
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
SYSTEM_PROMPT = """You are an expert assistant for AgentRax (https://agentrax.net/).
|
| 35 |
+
|
| 36 |
+
RULES:
|
| 37 |
+
- ALWAYS call search_agentrax_website first before answering any question about AgentRax
|
| 38 |
+
- Never answer from general knowledge β only from tool results
|
| 39 |
+
- Do NOT include source URLs or links in your response
|
| 40 |
+
- If the website content does not contain the answer, say so clearly
|
| 41 |
+
- Be concise β summarize tool output, do not dump raw text at the user
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@function_tool
|
| 46 |
+
async def search_agentrax_website(query: str) -> str:
|
| 47 |
+
"""Search the AgentRax website for information relevant to the user query.
|
| 48 |
+
|
| 49 |
+
PRIMARY tool β call this first for any question about AgentRax services,
|
| 50 |
+
pricing, features, or agents. Returns live or recently cached website content.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
query: The question or topic to look up on the AgentRax website.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Relevant extracted sections from the website as a formatted string.
|
| 57 |
+
"""
|
| 58 |
+
return await _search_agentrax_website(query)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@function_tool
|
| 62 |
+
def summarize_document(file_path: str) -> str:
|
| 63 |
+
"""Return a concise summary of a document, using a local cache when available.
|
| 64 |
+
|
| 65 |
+
Use when the user explicitly asks to summarise a specific document file.
|
| 66 |
+
Caches the result as a JSON sidecar β no repeated LLM cost on subsequent calls.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
file_path: Path to the .pdf or .docx document to summarise.
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
A plain-text summary, or an error message if the file is missing.
|
| 73 |
+
"""
|
| 74 |
+
return _summarize_document(file_path)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@function_tool
|
| 78 |
+
def list_indexed_documents() -> list[str]:
|
| 79 |
+
"""List all documents currently indexed in the vector store.
|
| 80 |
+
|
| 81 |
+
Use when the user asks what documents or files are available.
|
| 82 |
+
Queries ChromaDB metadata β no embeddings or LLM call involved.
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Sorted list of source file names, or an empty list if none are indexed.
|
| 86 |
+
"""
|
| 87 |
+
return _list_indexed_documents()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def create_rag_agent() -> Agent:
|
| 91 |
+
return Agent(
|
| 92 |
+
name="AgentRax Assistant",
|
| 93 |
+
instructions=SYSTEM_PROMPT,
|
| 94 |
+
model=_GEMINI_MODEL,
|
| 95 |
+
tools=[
|
| 96 |
+
search_agentrax_website,
|
| 97 |
+
summarize_document,
|
| 98 |
+
list_indexed_documents,
|
| 99 |
+
],
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def get_run_config() -> RunConfig:
|
| 104 |
+
"""Return a RunConfig that routes all model calls through Gemini."""
|
| 105 |
+
return RunConfig(model_provider=_provider)
|
api/__init__.py
ADDED
|
File without changes
|
api/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (133 Bytes). View file
|
|
|
api/__pycache__/routes.cpython-312.pyc
ADDED
|
Binary file (11.2 kB). View file
|
|
|
api/routes.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import importlib.util
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# ββ SDK shadowing fix ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
# Load rag_agent.py directly via importlib so its own SDK-fix code runs first,
|
| 9 |
+
# setting sys.modules['agents'] to the openai-agents SDK. After exec_module()
|
| 10 |
+
# returns, `from agents import Runner` resolves to the SDK, not the local package.
|
| 11 |
+
_rag_agent_path = Path(__file__).parent.parent / "agents" / "rag_agent.py"
|
| 12 |
+
_spec = importlib.util.spec_from_file_location("agents.rag_agent", str(_rag_agent_path))
|
| 13 |
+
_rag_mod = importlib.util.module_from_spec(_spec)
|
| 14 |
+
sys.modules["agents.rag_agent"] = _rag_mod
|
| 15 |
+
_spec.loader.exec_module(_rag_mod) # runs rag_agent.py β SDK is now sys.modules['agents']
|
| 16 |
+
create_rag_agent = _rag_mod.create_rag_agent
|
| 17 |
+
get_run_config = _rag_mod.get_run_config
|
| 18 |
+
|
| 19 |
+
# Now safe to import SDK symbols β agents points to the SDK in sys.modules
|
| 20 |
+
from agents import Runner # noqa: E402
|
| 21 |
+
from agents.stream_events import RawResponsesStreamEvent # noqa: E402
|
| 22 |
+
from fastapi import APIRouter, HTTPException # noqa: E402
|
| 23 |
+
from fastapi.responses import JSONResponse, StreamingResponse # noqa: E402
|
| 24 |
+
from openai.types.chat import ChatCompletionChunk # noqa: E402
|
| 25 |
+
from openai.types.responses import ResponseTextDeltaEvent # noqa: E402
|
| 26 |
+
from pydantic import BaseModel # noqa: E402
|
| 27 |
+
|
| 28 |
+
from cache.query_cache import _cache as _disk_cache # noqa: E402
|
| 29 |
+
from cache.query_cache import find_similar_cached, get_cache_stats, save_to_cache # noqa: E402
|
| 30 |
+
from db.history import list_sessions, load_history, save_turn # noqa: E402
|
| 31 |
+
from ingestion import ingest_all_documents # noqa: E402
|
| 32 |
+
from scraper.content_store import is_content_stale, load_latest_content # noqa: E402
|
| 33 |
+
from tools.list_documents_tool import list_indexed_documents # noqa: E402
|
| 34 |
+
|
| 35 |
+
router = APIRouter()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ββ request / response models ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
class HistoryMessage(BaseModel):
|
| 41 |
+
role: str # "user" | "assistant"
|
| 42 |
+
content: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ChatRequest(BaseModel):
|
| 46 |
+
message: str
|
| 47 |
+
session_id: str = ""
|
| 48 |
+
history: list[HistoryMessage] = []
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
def _build_input(db_history: list[dict], message: str) -> list[dict]:
|
| 54 |
+
"""Merge DB history with the current message into Agents SDK input format."""
|
| 55 |
+
messages = [{"role": t["role"], "content": t["content"]} for t in db_history]
|
| 56 |
+
messages.append({"role": "user", "content": message})
|
| 57 |
+
return messages
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def _stream_and_cache(request: ChatRequest, db_history: list[dict]):
|
| 61 |
+
"""Stream token deltas as SSE, then persist the turn and save to cache."""
|
| 62 |
+
full_text: list[str] = []
|
| 63 |
+
try:
|
| 64 |
+
agent = create_rag_agent()
|
| 65 |
+
result = Runner.run_streamed(
|
| 66 |
+
agent,
|
| 67 |
+
_build_input(db_history, request.message),
|
| 68 |
+
run_config=get_run_config(),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
async for event in result.stream_events():
|
| 72 |
+
if not isinstance(event, RawResponsesStreamEvent):
|
| 73 |
+
continue
|
| 74 |
+
data = event.data
|
| 75 |
+
delta: str | None = None
|
| 76 |
+
if isinstance(data, ResponseTextDeltaEvent):
|
| 77 |
+
# Responses API (OpenAI native)
|
| 78 |
+
delta = data.delta
|
| 79 |
+
elif isinstance(data, ChatCompletionChunk):
|
| 80 |
+
# Chat Completions API (Gemini compatible)
|
| 81 |
+
delta = data.choices[0].delta.content if data.choices else None
|
| 82 |
+
if delta:
|
| 83 |
+
full_text.append(delta)
|
| 84 |
+
yield f"data: {json.dumps({'delta': delta})}\n\n"
|
| 85 |
+
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
| 88 |
+
|
| 89 |
+
finally:
|
| 90 |
+
yield "data: [DONE]\n\n"
|
| 91 |
+
if full_text:
|
| 92 |
+
response_text = "".join(full_text)
|
| 93 |
+
save_to_cache(request.message, response_text, [])
|
| 94 |
+
if request.session_id:
|
| 95 |
+
await asyncio.to_thread(save_turn, request.session_id, request.message, response_text)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ββ routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
|
| 100 |
+
@router.post("/chat")
|
| 101 |
+
async def chat(request: ChatRequest):
|
| 102 |
+
"""Return AgentRax answer, served from semantic cache when possible.
|
| 103 |
+
|
| 104 |
+
Flow:
|
| 105 |
+
1. Load conversation history from PostgreSQL by session_id.
|
| 106 |
+
2. Check semantic cache (threshold 0.92). On HIT: return immediately, save turn.
|
| 107 |
+
3. On MISS: stream agent response, save to cache + DB on completion.
|
| 108 |
+
|
| 109 |
+
Cache HIT β JSON { answer, sources } with X-Cache: HIT (zero LLM cost)
|
| 110 |
+
Cache MISS β SSE token stream with X-Cache: MISS
|
| 111 |
+
"""
|
| 112 |
+
# Load DB history first β used by both HIT and MISS paths for context
|
| 113 |
+
db_history = await asyncio.to_thread(load_history, request.session_id)
|
| 114 |
+
|
| 115 |
+
cached = find_similar_cached(request.message)
|
| 116 |
+
if cached:
|
| 117 |
+
# Still persist the cached answer so history is complete
|
| 118 |
+
if request.session_id:
|
| 119 |
+
await asyncio.to_thread(
|
| 120 |
+
save_turn, request.session_id, request.message, cached["answer"]
|
| 121 |
+
)
|
| 122 |
+
return JSONResponse(
|
| 123 |
+
content={"answer": cached["answer"], "sources": cached.get("sources", [])},
|
| 124 |
+
headers={"X-Cache": "HIT"},
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return StreamingResponse(
|
| 128 |
+
_stream_and_cache(request, db_history),
|
| 129 |
+
media_type="text/event-stream",
|
| 130 |
+
headers={
|
| 131 |
+
"Cache-Control": "no-cache",
|
| 132 |
+
"X-Accel-Buffering": "no",
|
| 133 |
+
"X-Cache": "MISS",
|
| 134 |
+
},
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/sessions")
|
| 139 |
+
async def get_sessions():
|
| 140 |
+
"""Return all sessions ordered by last activity, with title and message count."""
|
| 141 |
+
try:
|
| 142 |
+
sessions = await asyncio.to_thread(list_sessions)
|
| 143 |
+
except Exception as exc:
|
| 144 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 145 |
+
# Serialize datetime objects to ISO strings
|
| 146 |
+
return {
|
| 147 |
+
"sessions": [
|
| 148 |
+
{**s, "last_active": s["last_active"].isoformat() if s.get("last_active") else None}
|
| 149 |
+
for s in sessions
|
| 150 |
+
]
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@router.get("/history/{session_id}")
|
| 155 |
+
async def get_history(session_id: str):
|
| 156 |
+
"""Return full conversation history for a session from PostgreSQL."""
|
| 157 |
+
try:
|
| 158 |
+
turns = await asyncio.to_thread(load_history, session_id, 100)
|
| 159 |
+
except Exception as exc:
|
| 160 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 161 |
+
return {"session_id": session_id, "history": turns}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.get("/cache/stats")
|
| 165 |
+
async def cache_stats():
|
| 166 |
+
"""Return hit counts and top queries from the semantic cache."""
|
| 167 |
+
return get_cache_stats()
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@router.post("/cache/clear")
|
| 171 |
+
async def cache_clear():
|
| 172 |
+
"""Wipe all entries from the semantic cache (admin use only)."""
|
| 173 |
+
_disk_cache.clear()
|
| 174 |
+
return {"status": "ok", "message": "Cache cleared."}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.get("/site/status")
|
| 178 |
+
async def site_status():
|
| 179 |
+
"""Return metadata about the last scraped AgentRax snapshot."""
|
| 180 |
+
content = load_latest_content()
|
| 181 |
+
if not content:
|
| 182 |
+
return {"status": "no_data", "is_stale": True}
|
| 183 |
+
return {
|
| 184 |
+
"title": content.get("title"),
|
| 185 |
+
"url": content.get("url"),
|
| 186 |
+
"scraped_at": content.get("scraped_at") or content.get("saved_at"),
|
| 187 |
+
"is_stale": is_content_stale(),
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.post("/ingest")
|
| 192 |
+
async def ingest():
|
| 193 |
+
"""Scan documents/ and index all .pdf and .docx files into ChromaDB."""
|
| 194 |
+
try:
|
| 195 |
+
result = await asyncio.to_thread(ingest_all_documents)
|
| 196 |
+
except Exception as exc:
|
| 197 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
"status": "ok",
|
| 201 |
+
"documents_indexed": result.get("ingested", 0),
|
| 202 |
+
"total_chunks": result.get("total_chunks", 0),
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@router.get("/documents")
|
| 207 |
+
async def documents():
|
| 208 |
+
"""Return all source file names currently indexed in the vector store."""
|
| 209 |
+
try:
|
| 210 |
+
docs = await asyncio.to_thread(list_indexed_documents)
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 213 |
+
|
| 214 |
+
return {"documents": docs}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@router.get("/health")
|
| 218 |
+
async def health():
|
| 219 |
+
"""Liveness check."""
|
| 220 |
+
return {"status": "ok"}
|
cache/__init__.py
ADDED
|
File without changes
|
cache/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (135 Bytes). View file
|
|
|
cache/__pycache__/query_cache.cpython-312.pyc
ADDED
|
Binary file (4.3 kB). View file
|
|
|
cache/query_cache.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# COST: ZERO OpenAI tokens.
|
| 2 |
+
# All embedding uses sentence-transformers 'all-MiniLM-L6-v2' running locally.
|
| 3 |
+
# Similarity scoring is pure-Python cosine distance against diskcache entries.
|
| 4 |
+
# No network call is made during cache lookup or save.
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import math
|
| 9 |
+
import uuid
|
| 10 |
+
|
| 11 |
+
import diskcache
|
| 12 |
+
|
| 13 |
+
STORE_PATH = Path(__file__).parent / "query_store"
|
| 14 |
+
_cache = diskcache.Cache(str(STORE_PATH))
|
| 15 |
+
|
| 16 |
+
# --- model singleton ---
|
| 17 |
+
_model = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get_model():
|
| 21 |
+
global _model
|
| 22 |
+
if _model is None:
|
| 23 |
+
from sentence_transformers import SentenceTransformer
|
| 24 |
+
_model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 25 |
+
return _model
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --- math helpers ---
|
| 29 |
+
|
| 30 |
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
| 31 |
+
dot = sum(x * y for x, y in zip(a, b))
|
| 32 |
+
mag_a = math.sqrt(sum(x * x for x in a))
|
| 33 |
+
mag_b = math.sqrt(sum(y * y for y in b))
|
| 34 |
+
if mag_a == 0 or mag_b == 0:
|
| 35 |
+
return 0.0
|
| 36 |
+
return dot / (mag_a * mag_b)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --- public API ---
|
| 40 |
+
|
| 41 |
+
def embed_query_local(query: str) -> list[float]:
|
| 42 |
+
return _get_model().encode(query, convert_to_numpy=True).tolist()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def find_similar_cached(query: str, threshold: float = 0.92) -> dict | None:
|
| 46 |
+
query_emb = embed_query_local(query)
|
| 47 |
+
|
| 48 |
+
best_key = None
|
| 49 |
+
best_score = -1.0
|
| 50 |
+
|
| 51 |
+
for key in _cache:
|
| 52 |
+
entry = _cache[key]
|
| 53 |
+
score = _cosine_similarity(query_emb, entry["query_embedding"])
|
| 54 |
+
if score > best_score:
|
| 55 |
+
best_score = score
|
| 56 |
+
best_key = key
|
| 57 |
+
|
| 58 |
+
if best_key is None or best_score < threshold:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
entry = _cache[best_key]
|
| 62 |
+
entry["hit_count"] += 1
|
| 63 |
+
_cache[best_key] = entry
|
| 64 |
+
return entry
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def save_to_cache(query: str, answer: str, sources: list[str]) -> None:
|
| 68 |
+
entry = {
|
| 69 |
+
"query": query,
|
| 70 |
+
"query_embedding": embed_query_local(query),
|
| 71 |
+
"answer": answer,
|
| 72 |
+
"sources": sources,
|
| 73 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 74 |
+
"hit_count": 0,
|
| 75 |
+
}
|
| 76 |
+
_cache[str(uuid.uuid4())] = entry
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_cache_stats() -> dict:
|
| 80 |
+
entries = [_cache[k] for k in _cache]
|
| 81 |
+
total_hits = sum(e["hit_count"] for e in entries)
|
| 82 |
+
most_asked = [
|
| 83 |
+
e["query"]
|
| 84 |
+
for e in sorted(entries, key=lambda e: e["hit_count"], reverse=True)[:5]
|
| 85 |
+
]
|
| 86 |
+
return {
|
| 87 |
+
"total_entries": len(entries),
|
| 88 |
+
"total_hits": total_hits,
|
| 89 |
+
"most_asked": most_asked,
|
| 90 |
+
}
|
cache/query_store/cache.db
ADDED
|
Binary file (32.8 kB). View file
|
|
|
chroma.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Singleton ChromaDB client and collection.
|
| 2 |
+
|
| 3 |
+
All modules that need the vector store import get_collection() from here.
|
| 4 |
+
The PersistentClient and collection are created once on first call and
|
| 5 |
+
reused for the lifetime of the process.
|
| 6 |
+
|
| 7 |
+
COST: ZERO external API tokens.
|
| 8 |
+
Embeddings use sentence-transformers 'all-MiniLM-L6-v2' running locally β
|
| 9 |
+
the same model used by the semantic query cache.
|
| 10 |
+
"""
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
import chromadb
|
| 15 |
+
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
|
| 16 |
+
|
| 17 |
+
VECTOR_STORE_DIR = Path(__file__).parent / "vector_store"
|
| 18 |
+
COLLECTION_NAME = "rag_documents"
|
| 19 |
+
|
| 20 |
+
_client: Optional[chromadb.Client] = None
|
| 21 |
+
_collection: Optional[chromadb.Collection] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_collection() -> chromadb.Collection:
|
| 25 |
+
"""Return the shared ChromaDB collection, creating it on first call."""
|
| 26 |
+
global _client, _collection
|
| 27 |
+
if _collection is None:
|
| 28 |
+
_client = chromadb.PersistentClient(path=str(VECTOR_STORE_DIR))
|
| 29 |
+
ef = SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
|
| 30 |
+
_collection = _client.get_or_create_collection(
|
| 31 |
+
name=COLLECTION_NAME,
|
| 32 |
+
embedding_function=ef,
|
| 33 |
+
)
|
| 34 |
+
return _collection
|
db/__init__.py
ADDED
|
File without changes
|
db/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (132 Bytes). View file
|
|
|
db/__pycache__/history.cpython-312.pyc
ADDED
|
Binary file (6.85 kB). View file
|
|
|
db/history.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PostgreSQL-backed conversation history.
|
| 3 |
+
|
| 4 |
+
Each session_id maps to an ordered list of {role, content} turns.
|
| 5 |
+
The table is created on first call to init_db() β safe to run on every startup.
|
| 6 |
+
"""
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import psycopg2
|
| 12 |
+
import psycopg2.extras
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_DATABASE_URL = os.environ.get(
|
| 20 |
+
"DATABASE_URL",
|
| 21 |
+
"postgresql://neondb_owner:npg_8QHOFkvXuxj6@ep-flat-sea-and50v08-pooler.c-6.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
_CREATE_TABLE = """
|
| 25 |
+
CREATE TABLE IF NOT EXISTS chat_history (
|
| 26 |
+
id BIGSERIAL PRIMARY KEY,
|
| 27 |
+
session_id TEXT NOT NULL,
|
| 28 |
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
| 29 |
+
content TEXT NOT NULL,
|
| 30 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 31 |
+
);
|
| 32 |
+
CREATE INDEX IF NOT EXISTS idx_chat_history_session
|
| 33 |
+
ON chat_history (session_id, created_at);
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _connect() -> psycopg2.extensions.connection:
|
| 38 |
+
return psycopg2.connect(_DATABASE_URL)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def init_db() -> None:
|
| 42 |
+
"""Create table and index if they don't exist. Called once at app startup."""
|
| 43 |
+
try:
|
| 44 |
+
with _connect() as conn:
|
| 45 |
+
with conn.cursor() as cur:
|
| 46 |
+
cur.execute(_CREATE_TABLE)
|
| 47 |
+
logger.info("chat_history table ready.")
|
| 48 |
+
except Exception:
|
| 49 |
+
logger.exception("Failed to initialise chat_history table.")
|
| 50 |
+
raise
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def save_turn(session_id: str, user_message: str, assistant_message: str) -> None:
|
| 54 |
+
"""Persist a single Q-A turn for the given session."""
|
| 55 |
+
if not session_id:
|
| 56 |
+
return
|
| 57 |
+
try:
|
| 58 |
+
with _connect() as conn:
|
| 59 |
+
with conn.cursor() as cur:
|
| 60 |
+
cur.execute(
|
| 61 |
+
"INSERT INTO chat_history (session_id, role, content) VALUES (%s, %s, %s), (%s, %s, %s)",
|
| 62 |
+
(session_id, "user", user_message,
|
| 63 |
+
session_id, "assistant", assistant_message),
|
| 64 |
+
)
|
| 65 |
+
except Exception:
|
| 66 |
+
logger.exception("Failed to save conversation turn for session %s.", session_id)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def list_sessions(limit: int = 50) -> list[dict]:
|
| 70 |
+
"""Return the most recent sessions with their title (first user message) and metadata."""
|
| 71 |
+
try:
|
| 72 |
+
with _connect() as conn:
|
| 73 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 74 |
+
cur.execute(
|
| 75 |
+
"""
|
| 76 |
+
WITH first_user AS (
|
| 77 |
+
SELECT DISTINCT ON (session_id)
|
| 78 |
+
session_id, content AS title
|
| 79 |
+
FROM chat_history
|
| 80 |
+
WHERE role = 'user'
|
| 81 |
+
ORDER BY session_id, created_at ASC
|
| 82 |
+
),
|
| 83 |
+
agg AS (
|
| 84 |
+
SELECT session_id,
|
| 85 |
+
MAX(created_at) AS last_active,
|
| 86 |
+
COUNT(*) AS message_count
|
| 87 |
+
FROM chat_history
|
| 88 |
+
GROUP BY session_id
|
| 89 |
+
)
|
| 90 |
+
SELECT f.session_id, f.title, a.last_active, a.message_count
|
| 91 |
+
FROM first_user f
|
| 92 |
+
JOIN agg a ON f.session_id = a.session_id
|
| 93 |
+
ORDER BY a.last_active DESC
|
| 94 |
+
LIMIT %s
|
| 95 |
+
""",
|
| 96 |
+
(limit,),
|
| 97 |
+
)
|
| 98 |
+
return [dict(row) for row in cur.fetchall()]
|
| 99 |
+
except Exception:
|
| 100 |
+
logger.exception("Failed to list sessions.")
|
| 101 |
+
return []
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def load_history(session_id: str, limit: int = 20) -> list[dict]:
|
| 105 |
+
"""Return the most recent `limit` turns for the session, oldest first.
|
| 106 |
+
|
| 107 |
+
Returns a list of {role, content} dicts compatible with the Agents SDK input format.
|
| 108 |
+
"""
|
| 109 |
+
if not session_id:
|
| 110 |
+
return []
|
| 111 |
+
try:
|
| 112 |
+
with _connect() as conn:
|
| 113 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 114 |
+
cur.execute(
|
| 115 |
+
"""
|
| 116 |
+
SELECT role, content FROM (
|
| 117 |
+
SELECT role, content, created_at
|
| 118 |
+
FROM chat_history
|
| 119 |
+
WHERE session_id = %s
|
| 120 |
+
ORDER BY created_at DESC
|
| 121 |
+
LIMIT %s
|
| 122 |
+
) sub
|
| 123 |
+
ORDER BY created_at ASC
|
| 124 |
+
""",
|
| 125 |
+
(session_id, limit),
|
| 126 |
+
)
|
| 127 |
+
return [dict(row) for row in cur.fetchall()]
|
| 128 |
+
except Exception:
|
| 129 |
+
logger.exception("Failed to load history for session %s.", session_id)
|
| 130 |
+
return []
|
ingestion.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
from chroma import get_collection # noqa: E402 β after load_dotenv
|
| 9 |
+
|
| 10 |
+
DOCUMENTS_DIR = Path(__file__).parent / "documents"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_document(file_path: str | Path) -> list[dict[str, Any]]:
|
| 14 |
+
"""Return list of {text, page_number} dicts from a .pdf or .docx file."""
|
| 15 |
+
file_path = Path(file_path)
|
| 16 |
+
suffix = file_path.suffix.lower()
|
| 17 |
+
|
| 18 |
+
if suffix == ".pdf":
|
| 19 |
+
from pypdf import PdfReader
|
| 20 |
+
|
| 21 |
+
reader = PdfReader(str(file_path))
|
| 22 |
+
pages = []
|
| 23 |
+
for i, page in enumerate(reader.pages):
|
| 24 |
+
text = page.extract_text() or ""
|
| 25 |
+
if text.strip():
|
| 26 |
+
pages.append({"text": text, "page_number": i + 1})
|
| 27 |
+
return pages
|
| 28 |
+
|
| 29 |
+
if suffix == ".docx":
|
| 30 |
+
from docx import Document
|
| 31 |
+
|
| 32 |
+
doc = Document(str(file_path))
|
| 33 |
+
full_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
| 34 |
+
return [{"text": full_text, "page_number": None}]
|
| 35 |
+
|
| 36 |
+
raise ValueError(f"Unsupported file type: {suffix!r}. Only .pdf and .docx are supported.")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def chunk_text(
|
| 40 |
+
text: str,
|
| 41 |
+
chunk_size: int = 500,
|
| 42 |
+
overlap: int = 50,
|
| 43 |
+
) -> list[str]:
|
| 44 |
+
"""Split text into overlapping word-based chunks."""
|
| 45 |
+
words = text.split()
|
| 46 |
+
if not words:
|
| 47 |
+
return []
|
| 48 |
+
|
| 49 |
+
chunks: list[str] = []
|
| 50 |
+
start = 0
|
| 51 |
+
while start < len(words):
|
| 52 |
+
end = min(start + chunk_size, len(words))
|
| 53 |
+
chunks.append(" ".join(words[start:end]))
|
| 54 |
+
if end == len(words):
|
| 55 |
+
break
|
| 56 |
+
start += chunk_size - overlap
|
| 57 |
+
|
| 58 |
+
return chunks
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def embed_and_store(
|
| 62 |
+
chunks: list[str],
|
| 63 |
+
metadata: dict[str, Any],
|
| 64 |
+
) -> None:
|
| 65 |
+
"""Embed chunks and upsert them into ChromaDB with per-chunk metadata."""
|
| 66 |
+
if not chunks:
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
collection = get_collection()
|
| 70 |
+
|
| 71 |
+
source_file = metadata.get("source_file", "unknown")
|
| 72 |
+
page_number = metadata.get("page_number") # may be None for docx
|
| 73 |
+
|
| 74 |
+
ids = [
|
| 75 |
+
f"{source_file}__p{page_number}__c{i}" if page_number is not None else f"{source_file}__c{i}"
|
| 76 |
+
for i in range(len(chunks))
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
chunk_metadata = [
|
| 80 |
+
{
|
| 81 |
+
"source_file": source_file,
|
| 82 |
+
"chunk_index": i,
|
| 83 |
+
**({"page_number": page_number} if page_number is not None else {}),
|
| 84 |
+
}
|
| 85 |
+
for i in range(len(chunks))
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
collection.upsert(documents=chunks, metadatas=chunk_metadata, ids=ids)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def ingest_all_documents() -> dict[str, Any]:
|
| 92 |
+
"""Scan documents/ folder and run the full ingestion pipeline."""
|
| 93 |
+
supported = {".pdf", ".docx"}
|
| 94 |
+
files = [f for f in DOCUMENTS_DIR.iterdir() if f.suffix.lower() in supported]
|
| 95 |
+
|
| 96 |
+
if not files:
|
| 97 |
+
print("No documents found in", DOCUMENTS_DIR)
|
| 98 |
+
return {"ingested": 0, "files": []}
|
| 99 |
+
|
| 100 |
+
results: list[str] = []
|
| 101 |
+
total_chunks = 0
|
| 102 |
+
|
| 103 |
+
for file_path in files:
|
| 104 |
+
print(f"Ingesting: {file_path.name}")
|
| 105 |
+
try:
|
| 106 |
+
pages = parse_document(file_path)
|
| 107 |
+
file_chunks = 0
|
| 108 |
+
|
| 109 |
+
for page in pages:
|
| 110 |
+
chunks = chunk_text(page["text"])
|
| 111 |
+
embed_and_store(
|
| 112 |
+
chunks,
|
| 113 |
+
metadata={
|
| 114 |
+
"source_file": file_path.name,
|
| 115 |
+
"page_number": page["page_number"],
|
| 116 |
+
},
|
| 117 |
+
)
|
| 118 |
+
file_chunks += len(chunks)
|
| 119 |
+
|
| 120 |
+
total_chunks += file_chunks
|
| 121 |
+
results.append(file_path.name)
|
| 122 |
+
print(f" -> {file_chunks} chunks stored")
|
| 123 |
+
|
| 124 |
+
except Exception as e:
|
| 125 |
+
print(f" -> ERROR: {e}")
|
| 126 |
+
|
| 127 |
+
print(f"\nDone. {len(results)} file(s), {total_chunks} total chunks.")
|
| 128 |
+
return {"ingested": len(results), "total_chunks": total_chunks, "files": results}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def ingest_scraped_content(data: dict) -> None:
|
| 132 |
+
"""Chunk and upsert scraped website content into ChromaDB."""
|
| 133 |
+
parts = []
|
| 134 |
+
if data.get("title"):
|
| 135 |
+
parts.append(data["title"])
|
| 136 |
+
if data.get("description"):
|
| 137 |
+
parts.append(data["description"])
|
| 138 |
+
if data.get("headings"):
|
| 139 |
+
parts.append("\n".join(data["headings"]))
|
| 140 |
+
if data.get("body_text"):
|
| 141 |
+
parts.append(data["body_text"])
|
| 142 |
+
|
| 143 |
+
full_text = "\n\n".join(parts)
|
| 144 |
+
chunks = chunk_text(full_text)
|
| 145 |
+
if not chunks:
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
url = data.get("url", "scraped")
|
| 149 |
+
scraped_at = data.get("scraped_at", "")
|
| 150 |
+
embed_and_store(
|
| 151 |
+
chunks,
|
| 152 |
+
metadata={"source_file": url, "scraped_at": scraped_at},
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
ingest_all_documents()
|
main.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv(Path(__file__).parent / ".env")
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
|
| 11 |
+
from api.routes import router
|
| 12 |
+
from db.history import init_db
|
| 13 |
+
from scraper.scheduler import start_scheduler, stop_scheduler
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
init_db()
|
| 19 |
+
start_scheduler()
|
| 20 |
+
yield
|
| 21 |
+
stop_scheduler()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
app = FastAPI(title="RAG Agent API", lifespan=lifespan)
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=["http://localhost:3000", "https://*.hf.space"],
|
| 29 |
+
allow_credentials=True,
|
| 30 |
+
allow_methods=["*"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
app.include_router(router)
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
import uvicorn
|
| 38 |
+
|
| 39 |
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "rag-agent-backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "RAG Agent backend"
|
| 5 |
+
requires-python = ">=3.11"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"fastapi",
|
| 8 |
+
"uvicorn[standard]",
|
| 9 |
+
"openai",
|
| 10 |
+
"openai-agents",
|
| 11 |
+
"chromadb",
|
| 12 |
+
"pypdf",
|
| 13 |
+
"python-docx",
|
| 14 |
+
"unstructured",
|
| 15 |
+
"python-dotenv",
|
| 16 |
+
"tiktoken",
|
| 17 |
+
"httpx",
|
| 18 |
+
"beautifulsoup4",
|
| 19 |
+
"lxml",
|
| 20 |
+
"apscheduler",
|
| 21 |
+
"diskcache",
|
| 22 |
+
"sentence-transformers",
|
| 23 |
+
"rank-bm25",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
[build-system]
|
| 27 |
+
requires = ["hatchling"]
|
| 28 |
+
build-backend = "hatchling.build"
|
| 29 |
+
|
| 30 |
+
[tool.hatch.build.targets.wheel]
|
| 31 |
+
packages = ["."]
|
| 32 |
+
|
| 33 |
+
[dependency-groups]
|
| 34 |
+
dev = []
|
scraper/__init__.py
ADDED
|
File without changes
|
scraper/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (137 Bytes). View file
|
|
|
scraper/__pycache__/content_store.cpython-312.pyc
ADDED
|
Binary file (4.77 kB). View file
|
|
|
scraper/__pycache__/scheduler.cpython-312.pyc
ADDED
|
Binary file (2.71 kB). View file
|
|
|
scraper/__pycache__/web_scraper.cpython-312.pyc
ADDED
|
Binary file (3.89 kB). View file
|
|
|
scraper/_test_store.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"title": "Build Autonomous AI Agents in 30 Seconds | No-Code AI Platform",
|
| 4 |
+
"description": "Create no-code AI agents in 30 seconds with Agentrax. Build autonomous, adaptive AI agents for your business without coding. From 3 hours of manual coding to instant automation for SMEs.",
|
| 5 |
+
"headings": [
|
| 6 |
+
"Build Autonomous AI Agents Fasterwith Agentrax",
|
| 7 |
+
"See It in Action",
|
| 8 |
+
"Harness the Power of No-Code AI, Making Agent Building Intuitive and Effective for All Skill Levels",
|
| 9 |
+
"SEO goal setting",
|
| 10 |
+
"User-friendly dashboard",
|
| 11 |
+
"Visual reports",
|
| 12 |
+
"Smart Keyword Generator",
|
| 13 |
+
"Features",
|
| 14 |
+
"Intuitive No-Code Builder",
|
| 15 |
+
"Autonomous Agent Testing",
|
| 16 |
+
"Deployment Wizard",
|
| 17 |
+
"Visual Agent Analytics",
|
| 18 |
+
"AI Goal Setting",
|
| 19 |
+
"One-Click Agent Creation",
|
| 20 |
+
"Smart Agent Generator",
|
| 21 |
+
"Automated Agent Alerts",
|
| 22 |
+
"Competitor AI Insights",
|
| 23 |
+
"Pricing",
|
| 24 |
+
"Pro Daily",
|
| 25 |
+
"Pro",
|
| 26 |
+
"Enterprise",
|
| 27 |
+
"Start Building Your First Agent Today.",
|
| 28 |
+
"Latest from Our Blog",
|
| 29 |
+
"Building AI Agents Without Coding",
|
| 30 |
+
"Optimizing Agent Performance",
|
| 31 |
+
"Real-World Agent Applications",
|
| 32 |
+
"Product Updates",
|
| 33 |
+
"Advanced Agent Analytics Dashboard",
|
| 34 |
+
"Multi-Model Support",
|
| 35 |
+
"Enhanced Deployment Options"
|
| 36 |
+
],
|
| 37 |
+
"body_text": "Create no-code AI agents in 30 seconds β autonomous, adaptive, and ready for action. From 3 hours of manual coding to instant automation for SMEs\nWatch how Agentrax transforms your workflow with autonomous AI agents\nHelps you set and achieve SEO goals with guided assistance.\nPerform complex SEO audits and optimizations with a single click.\nVisual insights into your site's performance.\nAutomatic suggestions and the best keywords to target.\nDrag-drop for autonomous agents.\nSelf-optimizes workflows in real-time.\nEasy API, sharable links, self-hosted setup\nTrack performance and adaptations.\nSet tasks for agents with guided assistance.\n30-sec builds with OpenAI SDK.\nAutomatic suggestions for tasks/triggers.\nNotifications on performance/health.\nCompare agents with market benchmarks.\nChoose the Right Plan to Meet Your AI Agent Needs and Start Building Today.\nNo credit card required β’ 7-days free trial\nStay updated with the latest news, tutorials, and insights about AI agents and automation.\nDiscover how our no-code platform enables anyone to create powerful AI agents in minutes.\nLearn best practices for maximizing the efficiency and effectiveness of your AI agents.\nExplore how businesses are leveraging AI agents to automate workflows and increase productivity.\nSee what's new in Agentrax and how we're continuously improving our platform.\nIntroducing comprehensive analytics to track your agent's performance, usage, and effectiveness with detailed metrics and visualizations.\nNow supports OpenAI, Google Gemini, and xAI Grok models with seamless switching between providers based on your needs.\nNew deployment options including direct cloud hosting, Docker containers, and API endpoints for seamless integration.\nNo-Code Agent Builder\nAutomated Deployments\nBasic Autonomous Agents\nAPI Endpoint Access\nEmail Support\nNo-Code Agent Builder\nAutomated Meta Tags\nAutonomous Agent Testing\nDeployment Wizard\nVisual Agent Analytics\nAI Goal Setting\nPriority Support\nAdvanced Integrations\nEverything in Pro\nUnlimited Agent Creation\nCustom AI Models\nDedicated Account Manager\n24/7 Priority Support\nMulti-User Access\nAdvanced API Access\nCustom Integrations\nSLA Guarantee\nOn-premise Deployment",
|
| 38 |
+
"url": "https://agentrax.net/",
|
| 39 |
+
"scraped_at": "2026-04-28T20:56:16.212258+00:00",
|
| 40 |
+
"saved_at": "2026-04-28T20:56:16.212788+00:00"
|
| 41 |
+
}
|
| 42 |
+
]
|
scraper/content_store.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import difflib
|
| 2 |
+
import json
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
STORE_PATH = Path(__file__).parent / "site_content.json"
|
| 7 |
+
MAX_SNAPSHOTS = 10
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _load_store() -> list[dict]:
|
| 11 |
+
if not STORE_PATH.exists():
|
| 12 |
+
return []
|
| 13 |
+
with STORE_PATH.open("r", encoding="utf-8") as f:
|
| 14 |
+
return json.load(f)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _write_store(snapshots: list[dict]) -> None:
|
| 18 |
+
with STORE_PATH.open("w", encoding="utf-8") as f:
|
| 19 |
+
json.dump(snapshots, f, ensure_ascii=False, indent=2)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def save_site_content(data: dict) -> None:
|
| 23 |
+
snapshots = _load_store()
|
| 24 |
+
entry = {**data, "saved_at": datetime.now(timezone.utc).isoformat()}
|
| 25 |
+
snapshots.append(entry)
|
| 26 |
+
snapshots = snapshots[-MAX_SNAPSHOTS:]
|
| 27 |
+
_write_store(snapshots)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def load_latest_content() -> dict:
|
| 31 |
+
snapshots = _load_store()
|
| 32 |
+
if not snapshots:
|
| 33 |
+
return {}
|
| 34 |
+
return snapshots[-1]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_content_diff(snapshot_1: dict, snapshot_2: dict) -> dict:
|
| 38 |
+
text_a = (snapshot_1.get("body_text") or "").splitlines()
|
| 39 |
+
text_b = (snapshot_2.get("body_text") or "").splitlines()
|
| 40 |
+
|
| 41 |
+
diff_lines = list(
|
| 42 |
+
difflib.unified_diff(text_a, text_b, lineterm="", n=2)
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if not diff_lines:
|
| 46 |
+
return {"is_changed": False, "summary_of_changes": "No changes detected."}
|
| 47 |
+
|
| 48 |
+
added = [l[1:] for l in diff_lines if l.startswith("+") and not l.startswith("+++")]
|
| 49 |
+
removed = [l[1:] for l in diff_lines if l.startswith("-") and not l.startswith("---")]
|
| 50 |
+
|
| 51 |
+
parts = []
|
| 52 |
+
if removed:
|
| 53 |
+
parts.append(f"Removed ({len(removed)} line(s)):\n" + "\n".join(f" - {l}" for l in removed[:5]))
|
| 54 |
+
if added:
|
| 55 |
+
parts.append(f"Added ({len(added)} line(s)):\n" + "\n".join(f" + {l}" for l in added[:5]))
|
| 56 |
+
if len(removed) > 5 or len(added) > 5:
|
| 57 |
+
parts.append("(truncated β showing first 5 lines per section)")
|
| 58 |
+
|
| 59 |
+
return {"is_changed": True, "summary_of_changes": "\n".join(parts)}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_content_stale(max_age_minutes: int = 60) -> bool:
|
| 63 |
+
latest = load_latest_content()
|
| 64 |
+
if not latest:
|
| 65 |
+
return True
|
| 66 |
+
saved_at_str = latest.get("saved_at") or latest.get("scraped_at")
|
| 67 |
+
if not saved_at_str:
|
| 68 |
+
return True
|
| 69 |
+
saved_at = datetime.fromisoformat(saved_at_str)
|
| 70 |
+
age_minutes = (datetime.now(timezone.utc) - saved_at).total_seconds() / 60
|
| 71 |
+
return age_minutes > max_age_minutes
|
scraper/scheduler.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# COST: ZERO OpenAI chat tokens.
|
| 2 |
+
# Scraping is plain HTTP + BeautifulSoup. Diff checking uses difflib (stdlib).
|
| 3 |
+
# is_content_stale() is checked first β if content is fresh the job returns
|
| 4 |
+
# immediately, skipping both the HTTP fetch and re-indexing.
|
| 5 |
+
# Re-indexing (ingest_scraped_content) calls text-embedding-3-small for new
|
| 6 |
+
# chunks only β one-time embedding cost, no chat LLM.
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 11 |
+
|
| 12 |
+
from ingestion import ingest_scraped_content
|
| 13 |
+
from scraper.content_store import (
|
| 14 |
+
get_content_diff,
|
| 15 |
+
is_content_stale,
|
| 16 |
+
load_latest_content,
|
| 17 |
+
save_site_content,
|
| 18 |
+
)
|
| 19 |
+
from scraper.web_scraper import AGENTRAX_URL, scrape_site
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
_scheduler: AsyncIOScheduler | None = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def refresh_agentrax_content() -> None:
|
| 27 |
+
if not is_content_stale():
|
| 28 |
+
logger.info("Site content is fresh β skipping refresh.")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
logger.info("Scraping %s ...", AGENTRAX_URL)
|
| 32 |
+
data = await scrape_site(AGENTRAX_URL)
|
| 33 |
+
|
| 34 |
+
if "error" in data:
|
| 35 |
+
logger.error("Scrape failed: %s", data["error"])
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
previous = load_latest_content()
|
| 39 |
+
if previous:
|
| 40 |
+
diff = get_content_diff(previous, data)
|
| 41 |
+
if diff["is_changed"]:
|
| 42 |
+
logger.warning("SITE UPDATED: %s", diff["summary_of_changes"])
|
| 43 |
+
|
| 44 |
+
save_site_content(data)
|
| 45 |
+
ingest_scraped_content(data)
|
| 46 |
+
logger.info("Site content refreshed and re-indexed.")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def start_scheduler() -> None:
|
| 50 |
+
global _scheduler
|
| 51 |
+
_scheduler = AsyncIOScheduler()
|
| 52 |
+
_scheduler.add_job(
|
| 53 |
+
refresh_agentrax_content,
|
| 54 |
+
trigger="interval",
|
| 55 |
+
minutes=60,
|
| 56 |
+
id="refresh_agentrax",
|
| 57 |
+
replace_existing=True,
|
| 58 |
+
)
|
| 59 |
+
_scheduler.start()
|
| 60 |
+
logger.info("Scheduler started β refresh every 60 minutes.")
|
| 61 |
+
|
| 62 |
+
import asyncio
|
| 63 |
+
asyncio.get_event_loop().create_task(refresh_agentrax_content())
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def stop_scheduler() -> None:
|
| 67 |
+
global _scheduler
|
| 68 |
+
if _scheduler and _scheduler.running:
|
| 69 |
+
_scheduler.shutdown(wait=False)
|
| 70 |
+
logger.info("Scheduler stopped.")
|
scraper/site_content.json
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"title": "Build Autonomous AI Agents in 30 Seconds | No-Code AI Platform",
|
| 4 |
+
"description": "Create no-code AI agents in 30 seconds with Agentrax. Build autonomous, adaptive AI agents for your business without coding. From 3 hours of manual coding to instant automation for SMEs.",
|
| 5 |
+
"headings": [
|
| 6 |
+
"Build Autonomous AI Agents Fasterwith Agentrax",
|
| 7 |
+
"See It in Action",
|
| 8 |
+
"Harness the Power of No-Code AI, Making Agent Building Intuitive and Effective for All Skill Levels",
|
| 9 |
+
"SEO goal setting",
|
| 10 |
+
"User-friendly dashboard",
|
| 11 |
+
"Visual reports",
|
| 12 |
+
"Smart Keyword Generator",
|
| 13 |
+
"Features",
|
| 14 |
+
"Intuitive No-Code Builder",
|
| 15 |
+
"Autonomous Agent Testing",
|
| 16 |
+
"Deployment Wizard",
|
| 17 |
+
"Visual Agent Analytics",
|
| 18 |
+
"AI Goal Setting",
|
| 19 |
+
"One-Click Agent Creation",
|
| 20 |
+
"Smart Agent Generator",
|
| 21 |
+
"Automated Agent Alerts",
|
| 22 |
+
"Competitor AI Insights",
|
| 23 |
+
"Pricing",
|
| 24 |
+
"Pro Daily",
|
| 25 |
+
"Pro",
|
| 26 |
+
"Enterprise",
|
| 27 |
+
"Start Building Your First Agent Today.",
|
| 28 |
+
"Latest from Our Blog",
|
| 29 |
+
"Building AI Agents Without Coding",
|
| 30 |
+
"Optimizing Agent Performance",
|
| 31 |
+
"Real-World Agent Applications",
|
| 32 |
+
"Product Updates",
|
| 33 |
+
"Advanced Agent Analytics Dashboard",
|
| 34 |
+
"Multi-Model Support",
|
| 35 |
+
"Enhanced Deployment Options"
|
| 36 |
+
],
|
| 37 |
+
"body_text": "Create no-code AI agents in 30 seconds β autonomous, adaptive, and ready for action. From 3 hours of manual coding to instant automation for SMEs\nWatch how Agentrax transforms your workflow with autonomous AI agents\nHelps you set and achieve SEO goals with guided assistance.\nPerform complex SEO audits and optimizations with a single click.\nVisual insights into your site's performance.\nAutomatic suggestions and the best keywords to target.\nDrag-drop for autonomous agents.\nSelf-optimizes workflows in real-time.\nEasy API, sharable links, self-hosted setup\nTrack performance and adaptations.\nSet tasks for agents with guided assistance.\n30-sec builds with OpenAI SDK.\nAutomatic suggestions for tasks/triggers.\nNotifications on performance/health.\nCompare agents with market benchmarks.\nChoose the Right Plan to Meet Your AI Agent Needs and Start Building Today.\nNo credit card required β’ 7-days free trial\nStay updated with the latest news, tutorials, and insights about AI agents and automation.\nDiscover how our no-code platform enables anyone to create powerful AI agents in minutes.\nLearn best practices for maximizing the efficiency and effectiveness of your AI agents.\nExplore how businesses are leveraging AI agents to automate workflows and increase productivity.\nSee what's new in Agentrax and how we're continuously improving our platform.\nIntroducing comprehensive analytics to track your agent's performance, usage, and effectiveness with detailed metrics and visualizations.\nNow supports OpenAI, Google Gemini, and xAI Grok models with seamless switching between providers based on your needs.\nNew deployment options including direct cloud hosting, Docker containers, and API endpoints for seamless integration.\nNo-Code Agent Builder\nAutomated Deployments\nBasic Autonomous Agents\nAPI Endpoint Access\nEmail Support\nNo-Code Agent Builder\nAutomated Meta Tags\nAutonomous Agent Testing\nDeployment Wizard\nVisual Agent Analytics\nAI Goal Setting\nPriority Support\nAdvanced Integrations\nEverything in Pro\nUnlimited Agent Creation\nCustom AI Models\nDedicated Account Manager\n24/7 Priority Support\nMulti-User Access\nAdvanced API Access\nCustom Integrations\nSLA Guarantee\nOn-premise Deployment",
|
| 38 |
+
"url": "https://agentrax.net/",
|
| 39 |
+
"scraped_at": "2026-04-28T20:49:55.863991+00:00",
|
| 40 |
+
"saved_at": "2026-04-28T20:49:55.863991+00:00"
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"title": "Build Autonomous AI Agents in 30 Seconds | No-Code AI Platform",
|
| 44 |
+
"description": "Create no-code AI agents in 30 seconds with Agentrax. Build autonomous, adaptive AI agents for your business without coding. From 3 hours of manual coding to instant automation for SMEs.",
|
| 45 |
+
"headings": [
|
| 46 |
+
"Build Autonomous AI Agents Fasterwith Agentrax",
|
| 47 |
+
"See It in Action",
|
| 48 |
+
"Harness the Power of No-Code AI, Making Agent Building Intuitive and Effective for All Skill Levels",
|
| 49 |
+
"SEO goal setting",
|
| 50 |
+
"User-friendly dashboard",
|
| 51 |
+
"Visual reports",
|
| 52 |
+
"Smart Keyword Generator",
|
| 53 |
+
"Features",
|
| 54 |
+
"Intuitive No-Code Builder",
|
| 55 |
+
"Autonomous Agent Testing",
|
| 56 |
+
"Deployment Wizard",
|
| 57 |
+
"Visual Agent Analytics",
|
| 58 |
+
"AI Goal Setting",
|
| 59 |
+
"One-Click Agent Creation",
|
| 60 |
+
"Smart Agent Generator",
|
| 61 |
+
"Automated Agent Alerts",
|
| 62 |
+
"Competitor AI Insights",
|
| 63 |
+
"Pricing",
|
| 64 |
+
"Pro Daily",
|
| 65 |
+
"Pro",
|
| 66 |
+
"Enterprise",
|
| 67 |
+
"Start Building Your First Agent Today.",
|
| 68 |
+
"Latest from Our Blog",
|
| 69 |
+
"Building AI Agents Without Coding",
|
| 70 |
+
"Optimizing Agent Performance",
|
| 71 |
+
"Real-World Agent Applications",
|
| 72 |
+
"Product Updates",
|
| 73 |
+
"Advanced Agent Analytics Dashboard",
|
| 74 |
+
"Multi-Model Support",
|
| 75 |
+
"Enhanced Deployment Options"
|
| 76 |
+
],
|
| 77 |
+
"body_text": "Create no-code AI agents in 30 seconds β autonomous, adaptive, and ready for action. From 3 hours of manual coding to instant automation for SMEs\nWatch how Agentrax transforms your workflow with autonomous AI agents\nHelps you set and achieve SEO goals with guided assistance.\nPerform complex SEO audits and optimizations with a single click.\nVisual insights into your site's performance.\nAutomatic suggestions and the best keywords to target.\nDrag-drop for autonomous agents.\nSelf-optimizes workflows in real-time.\nEasy API, sharable links, self-hosted setup\nTrack performance and adaptations.\nSet tasks for agents with guided assistance.\n30-sec builds with OpenAI SDK.\nAutomatic suggestions for tasks/triggers.\nNotifications on performance/health.\nCompare agents with market benchmarks.\nChoose the Right Plan to Meet Your AI Agent Needs and Start Building Today.\nNo credit card required β’ 7-days free trial\nStay updated with the latest news, tutorials, and insights about AI agents and automation.\nDiscover how our no-code platform enables anyone to create powerful AI agents in minutes.\nLearn best practices for maximizing the efficiency and effectiveness of your AI agents.\nExplore how businesses are leveraging AI agents to automate workflows and increase productivity.\nSee what's new in Agentrax and how we're continuously improving our platform.\nIntroducing comprehensive analytics to track your agent's performance, usage, and effectiveness with detailed metrics and visualizations.\nNow supports OpenAI, Google Gemini, and xAI Grok models with seamless switching between providers based on your needs.\nNew deployment options including direct cloud hosting, Docker containers, and API endpoints for seamless integration.\nNo-Code Agent Builder\nAutomated Deployments\nBasic Autonomous Agents\nAPI Endpoint Access\nEmail Support\nNo-Code Agent Builder\nAutomated Meta Tags\nAutonomous Agent Testing\nDeployment Wizard\nVisual Agent Analytics\nAI Goal Setting\nPriority Support\nAdvanced Integrations\nEverything in Pro\nUnlimited Agent Creation\nCustom AI Models\nDedicated Account Manager\n24/7 Priority Support\nMulti-User Access\nAdvanced API Access\nCustom Integrations\nSLA Guarantee\nOn-premise Deployment\nNew line added to site.",
|
| 78 |
+
"url": "https://agentrax.net/",
|
| 79 |
+
"scraped_at": "2026-04-28T20:49:55.863991+00:00",
|
| 80 |
+
"saved_at": "2026-04-28T20:49:55.886537+00:00"
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"title": "Build Autonomous AI Agents in 30 Seconds | No-Code AI Platform",
|
| 84 |
+
"description": "Create no-code AI agents in 30 seconds with Agentrax. Build autonomous, adaptive AI agents for your business without coding. From 3 hours of manual coding to instant automation for SMEs.",
|
| 85 |
+
"headings": [
|
| 86 |
+
"Build Autonomous AI Agents Fasterwith Agentrax",
|
| 87 |
+
"See It in Action",
|
| 88 |
+
"Harness the Power of No-Code AI, Making Agent Building Intuitive and Effective for All Skill Levels",
|
| 89 |
+
"SEO goal setting",
|
| 90 |
+
"User-friendly dashboard",
|
| 91 |
+
"Visual reports",
|
| 92 |
+
"Smart Keyword Generator",
|
| 93 |
+
"Features",
|
| 94 |
+
"Intuitive No-Code Builder",
|
| 95 |
+
"Autonomous Agent Testing",
|
| 96 |
+
"Deployment Wizard",
|
| 97 |
+
"Visual Agent Analytics",
|
| 98 |
+
"AI Goal Setting",
|
| 99 |
+
"One-Click Agent Creation",
|
| 100 |
+
"Smart Agent Generator",
|
| 101 |
+
"Automated Agent Alerts",
|
| 102 |
+
"Competitor AI Insights",
|
| 103 |
+
"Pricing",
|
| 104 |
+
"Pro Daily",
|
| 105 |
+
"Pro",
|
| 106 |
+
"Enterprise",
|
| 107 |
+
"Start Building Your First Agent Today.",
|
| 108 |
+
"Latest from Our Blog",
|
| 109 |
+
"Building AI Agents Without Coding",
|
| 110 |
+
"Optimizing Agent Performance",
|
| 111 |
+
"Real-World Agent Applications",
|
| 112 |
+
"Product Updates",
|
| 113 |
+
"Advanced Agent Analytics Dashboard",
|
| 114 |
+
"Multi-Model Support",
|
| 115 |
+
"Enhanced Deployment Options"
|
| 116 |
+
],
|
| 117 |
+
"body_text": "Create no-code AI agents in 30 seconds β autonomous, adaptive, and ready for action. From 3 hours of manual coding to instant automation for SMEs\nWatch how Agentrax transforms your workflow with autonomous AI agents\nHelps you set and achieve SEO goals with guided assistance.\nPerform complex SEO audits and optimizations with a single click.\nVisual insights into your site's performance.\nAutomatic suggestions and the best keywords to target.\nDrag-drop for autonomous agents.\nSelf-optimizes workflows in real-time.\nEasy API, sharable links, self-hosted setup\nTrack performance and adaptations.\nSet tasks for agents with guided assistance.\n30-sec builds with OpenAI SDK.\nAutomatic suggestions for tasks/triggers.\nNotifications on performance/health.\nCompare agents with market benchmarks.\nChoose the Right Plan to Meet Your AI Agent Needs and Start Building Today.\nNo credit card required β’ 7-days free trial\nStay updated with the latest news, tutorials, and insights about AI agents and automation.\nDiscover how our no-code platform enables anyone to create powerful AI agents in minutes.\nLearn best practices for maximizing the efficiency and effectiveness of your AI agents.\nExplore how businesses are leveraging AI agents to automate workflows and increase productivity.\nSee what's new in Agentrax and how we're continuously improving our platform.\nIntroducing comprehensive analytics to track your agent's performance, usage, and effectiveness with detailed metrics and visualizations.\nNow supports OpenAI, Google Gemini, and xAI Grok models with seamless switching between providers based on your needs.\nNew deployment options including direct cloud hosting, Docker containers, and API endpoints for seamless integration.\nNo-Code Agent Builder\nAutomated Deployments\nBasic Autonomous Agents\nAPI Endpoint Access\nEmail Support\nNo-Code Agent Builder\nAutomated Meta Tags\nAutonomous Agent Testing\nDeployment Wizard\nVisual Agent Analytics\nAI Goal Setting\nPriority Support\nAdvanced Integrations\nEverything in Pro\nUnlimited Agent Creation\nCustom AI Models\nDedicated Account Manager\n24/7 Priority Support\nMulti-User Access\nAdvanced API Access\nCustom Integrations\nSLA Guarantee\nOn-premise Deployment",
|
| 118 |
+
"url": "https://agentrax.net/",
|
| 119 |
+
"scraped_at": "2026-04-30T00:49:26.376898+00:00",
|
| 120 |
+
"saved_at": "2026-04-30T00:49:26.391892+00:00"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"title": "Build Autonomous AI Agents in 30 Seconds | No-Code AI Platform",
|
| 124 |
+
"description": "Create no-code AI agents in 30 seconds with Agentrax. Build autonomous, adaptive AI agents for your business without coding. From 3 hours of manual coding to instant automation for SMEs.",
|
| 125 |
+
"headings": [
|
| 126 |
+
"Build Autonomous AI Agents Fasterwith Agentrax",
|
| 127 |
+
"See It in Action",
|
| 128 |
+
"Harness the Power of No-Code AI, Making Agent Building Intuitive and Effective for All Skill Levels",
|
| 129 |
+
"SEO goal setting",
|
| 130 |
+
"User-friendly dashboard",
|
| 131 |
+
"Visual reports",
|
| 132 |
+
"Smart Keyword Generator",
|
| 133 |
+
"Features",
|
| 134 |
+
"Intuitive No-Code Builder",
|
| 135 |
+
"Autonomous Agent Testing",
|
| 136 |
+
"Deployment Wizard",
|
| 137 |
+
"Visual Agent Analytics",
|
| 138 |
+
"AI Goal Setting",
|
| 139 |
+
"One-Click Agent Creation",
|
| 140 |
+
"Smart Agent Generator",
|
| 141 |
+
"Automated Agent Alerts",
|
| 142 |
+
"Competitor AI Insights",
|
| 143 |
+
"Pricing",
|
| 144 |
+
"Pro Daily",
|
| 145 |
+
"Pro",
|
| 146 |
+
"Enterprise",
|
| 147 |
+
"Start Building Your First Agent Today.",
|
| 148 |
+
"Latest from Our Blog",
|
| 149 |
+
"Building AI Agents Without Coding",
|
| 150 |
+
"Optimizing Agent Performance",
|
| 151 |
+
"Real-World Agent Applications",
|
| 152 |
+
"Product Updates",
|
| 153 |
+
"Advanced Agent Analytics Dashboard",
|
| 154 |
+
"Multi-Model Support",
|
| 155 |
+
"Enhanced Deployment Options"
|
| 156 |
+
],
|
| 157 |
+
"body_text": "Create no-code AI agents in 30 seconds β autonomous, adaptive, and ready for action. From 3 hours of manual coding to instant automation for SMEs\nWatch how Agentrax transforms your workflow with autonomous AI agents\nHelps you set and achieve SEO goals with guided assistance.\nPerform complex SEO audits and optimizations with a single click.\nVisual insights into your site's performance.\nAutomatic suggestions and the best keywords to target.\nDrag-drop for autonomous agents.\nSelf-optimizes workflows in real-time.\nEasy API, sharable links, self-hosted setup\nTrack performance and adaptations.\nSet tasks for agents with guided assistance.\n30-sec builds with OpenAI SDK.\nAutomatic suggestions for tasks/triggers.\nNotifications on performance/health.\nCompare agents with market benchmarks.\nChoose the Right Plan to Meet Your AI Agent Needs and Start Building Today.\nNo credit card required β’ 7-days free trial\nStay updated with the latest news, tutorials, and insights about AI agents and automation.\nDiscover how our no-code platform enables anyone to create powerful AI agents in minutes.\nLearn best practices for maximizing the efficiency and effectiveness of your AI agents.\nExplore how businesses are leveraging AI agents to automate workflows and increase productivity.\nSee what's new in Agentrax and how we're continuously improving our platform.\nIntroducing comprehensive analytics to track your agent's performance, usage, and effectiveness with detailed metrics and visualizations.\nNow supports OpenAI, Google Gemini, and xAI Grok models with seamless switching between providers based on your needs.\nNew deployment options including direct cloud hosting, Docker containers, and API endpoints for seamless integration.\nNo-Code Agent Builder\nAutomated Deployments\nBasic Autonomous Agents\nAPI Endpoint Access\nEmail Support\nNo-Code Agent Builder\nAutomated Meta Tags\nAutonomous Agent Testing\nDeployment Wizard\nVisual Agent Analytics\nAI Goal Setting\nPriority Support\nAdvanced Integrations\nEverything in Pro\nUnlimited Agent Creation\nCustom AI Models\nDedicated Account Manager\n24/7 Priority Support\nMulti-User Access\nAdvanced API Access\nCustom Integrations\nSLA Guarantee\nOn-premise Deployment",
|
| 158 |
+
"url": "https://agentrax.net/",
|
| 159 |
+
"scraped_at": "2026-04-30T01:49:46.518110+00:00",
|
| 160 |
+
"saved_at": "2026-04-30T01:49:46.518110+00:00"
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"title": "Build Autonomous AI Agents in 30 Seconds | No-Code AI Platform",
|
| 164 |
+
"description": "Create no-code AI agents in 30 seconds with Agentrax. Build autonomous, adaptive AI agents for your business without coding. From 3 hours of manual coding to instant automation for SMEs.",
|
| 165 |
+
"headings": [
|
| 166 |
+
"Build Autonomous AI Agents Fasterwith Agentrax",
|
| 167 |
+
"See It in Action",
|
| 168 |
+
"Harness the Power of No-Code AI, Making Agent Building Intuitive and Effective for All Skill Levels",
|
| 169 |
+
"SEO goal setting",
|
| 170 |
+
"User-friendly dashboard",
|
| 171 |
+
"Visual reports",
|
| 172 |
+
"Smart Keyword Generator",
|
| 173 |
+
"Features",
|
| 174 |
+
"Intuitive No-Code Builder",
|
| 175 |
+
"Autonomous Agent Testing",
|
| 176 |
+
"Deployment Wizard",
|
| 177 |
+
"Visual Agent Analytics",
|
| 178 |
+
"AI Goal Setting",
|
| 179 |
+
"One-Click Agent Creation",
|
| 180 |
+
"Smart Agent Generator",
|
| 181 |
+
"Automated Agent Alerts",
|
| 182 |
+
"Competitor AI Insights",
|
| 183 |
+
"Pricing",
|
| 184 |
+
"Pro Daily",
|
| 185 |
+
"Pro",
|
| 186 |
+
"Enterprise",
|
| 187 |
+
"Start Building Your First Agent Today.",
|
| 188 |
+
"Latest from Our Blog",
|
| 189 |
+
"Building AI Agents Without Coding",
|
| 190 |
+
"Optimizing Agent Performance",
|
| 191 |
+
"Real-World Agent Applications",
|
| 192 |
+
"Product Updates",
|
| 193 |
+
"Advanced Agent Analytics Dashboard",
|
| 194 |
+
"Multi-Model Support",
|
| 195 |
+
"Enhanced Deployment Options"
|
| 196 |
+
],
|
| 197 |
+
"body_text": "Create no-code AI agents in 30 seconds β autonomous, adaptive, and ready for action. From 3 hours of manual coding to instant automation for SMEs\nWatch how Agentrax transforms your workflow with autonomous AI agents\nHelps you set and achieve SEO goals with guided assistance.\nPerform complex SEO audits and optimizations with a single click.\nVisual insights into your site's performance.\nAutomatic suggestions and the best keywords to target.\nDrag-drop for autonomous agents.\nSelf-optimizes workflows in real-time.\nEasy API, sharable links, self-hosted setup\nTrack performance and adaptations.\nSet tasks for agents with guided assistance.\n30-sec builds with OpenAI SDK.\nAutomatic suggestions for tasks/triggers.\nNotifications on performance/health.\nCompare agents with market benchmarks.\nChoose the Right Plan to Meet Your AI Agent Needs and Start Building Today.\nNo credit card required β’ 7-days free trial\nStay updated with the latest news, tutorials, and insights about AI agents and automation.\nDiscover how our no-code platform enables anyone to create powerful AI agents in minutes.\nLearn best practices for maximizing the efficiency and effectiveness of your AI agents.\nExplore how businesses are leveraging AI agents to automate workflows and increase productivity.\nSee what's new in Agentrax and how we're continuously improving our platform.\nIntroducing comprehensive analytics to track your agent's performance, usage, and effectiveness with detailed metrics and visualizations.\nNow supports OpenAI, Google Gemini, and xAI Grok models with seamless switching between providers based on your needs.\nNew deployment options including direct cloud hosting, Docker containers, and API endpoints for seamless integration.\nNo-Code Agent Builder\nAutomated Deployments\nBasic Autonomous Agents\nAPI Endpoint Access\nEmail Support\nNo-Code Agent Builder\nAutomated Meta Tags\nAutonomous Agent Testing\nDeployment Wizard\nVisual Agent Analytics\nAI Goal Setting\nPriority Support\nAdvanced Integrations\nEverything in Pro\nUnlimited Agent Creation\nCustom AI Models\nDedicated Account Manager\n24/7 Priority Support\nMulti-User Access\nAdvanced API Access\nCustom Integrations\nSLA Guarantee\nOn-premise Deployment",
|
| 198 |
+
"url": "https://agentrax.net/",
|
| 199 |
+
"scraped_at": "2026-04-30T03:19:45.326252+00:00",
|
| 200 |
+
"saved_at": "2026-04-30T03:19:45.328781+00:00"
|
| 201 |
+
}
|
| 202 |
+
]
|
scraper/web_scraper.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from bs4 import BeautifulSoup
|
| 3 |
+
import httpx
|
| 4 |
+
|
| 5 |
+
AGENTRAX_URL = "https://agentrax.net/"
|
| 6 |
+
|
| 7 |
+
_HEADERS = {
|
| 8 |
+
"User-Agent": (
|
| 9 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 10 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 11 |
+
"Chrome/124.0.0.0 Safari/537.36"
|
| 12 |
+
)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
_NOISE_TAGS = {"nav", "footer", "header", "script", "style", "aside"}
|
| 16 |
+
_NOISE_CLASSES = {
|
| 17 |
+
"ad", "ads", "advert", "advertisement", "banner", "cookie",
|
| 18 |
+
"popup", "modal", "sidebar", "widget", "promo",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def fetch_page(url: str) -> str:
|
| 23 |
+
async with httpx.AsyncClient(headers=_HEADERS, timeout=15, follow_redirects=True) as client:
|
| 24 |
+
response = await client.get(url)
|
| 25 |
+
response.raise_for_status()
|
| 26 |
+
return response.text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def extract_clean_text(html: str, url: str) -> dict:
|
| 30 |
+
soup = BeautifulSoup(html, "lxml")
|
| 31 |
+
|
| 32 |
+
for tag in soup.find_all(_NOISE_TAGS):
|
| 33 |
+
tag.decompose()
|
| 34 |
+
|
| 35 |
+
for tag in soup.find_all(True):
|
| 36 |
+
classes = set(tag.get("class") or [])
|
| 37 |
+
if classes & _NOISE_CLASSES:
|
| 38 |
+
tag.decompose()
|
| 39 |
+
|
| 40 |
+
title = soup.title.get_text(strip=True) if soup.title else ""
|
| 41 |
+
|
| 42 |
+
description = ""
|
| 43 |
+
meta = soup.find("meta", attrs={"name": "description"})
|
| 44 |
+
if meta:
|
| 45 |
+
description = meta.get("content", "").strip()
|
| 46 |
+
|
| 47 |
+
headings = [
|
| 48 |
+
tag.get_text(strip=True)
|
| 49 |
+
for tag in soup.find_all(["h1", "h2", "h3"])
|
| 50 |
+
if tag.get_text(strip=True)
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
paragraphs = [p.get_text(strip=True) for p in soup.find_all("p") if p.get_text(strip=True)]
|
| 54 |
+
list_items = [li.get_text(strip=True) for li in soup.find_all("li") if li.get_text(strip=True)]
|
| 55 |
+
|
| 56 |
+
body_parts = paragraphs + list_items
|
| 57 |
+
body_text = "\n".join(body_parts)
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"title": title,
|
| 61 |
+
"description": description,
|
| 62 |
+
"headings": headings,
|
| 63 |
+
"body_text": body_text,
|
| 64 |
+
"url": url,
|
| 65 |
+
"scraped_at": datetime.now(timezone.utc).isoformat(),
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def scrape_site(url: str = AGENTRAX_URL) -> dict:
|
| 70 |
+
try:
|
| 71 |
+
html = await fetch_page(url)
|
| 72 |
+
return extract_clean_text(html, url)
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
return {"error": str(exc), "url": url}
|
tools/__init__.py
ADDED
|
File without changes
|
tools/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (135 Bytes). View file
|
|
|
tools/__pycache__/document_loader_tool.cpython-312.pyc
ADDED
|
Binary file (2.54 kB). View file
|
|
|
tools/__pycache__/list_documents_tool.cpython-312.pyc
ADDED
|
Binary file (1.73 kB). View file
|
|
|
tools/__pycache__/retrieval_tool.cpython-312.pyc
ADDED
|
Binary file (2.43 kB). View file
|
|
|
tools/__pycache__/summarize_tool.cpython-312.pyc
ADDED
|
Binary file (4.28 kB). View file
|
|
|
tools/__pycache__/web_search_tool.cpython-312.pyc
ADDED
|
Binary file (3.51 kB). View file
|
|
|
tools/document_loader_tool.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# COST: OpenAI text-embedding-3-small called during indexing (one-time per document).
|
| 2 |
+
# No chat LLM is invoked. Embedding cost is ~$0.00002/1K tokens β negligible for
|
| 3 |
+
# typical documents. Upsert semantics mean re-indexing the same file is idempotent
|
| 4 |
+
# but does re-embed (and re-bill) all chunks.
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 10 |
+
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
from ingestion import chunk_text, embed_and_store, parse_document
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_and_index_document(file_path: str) -> str:
|
| 19 |
+
"""Parse, chunk, embed, and store a single document into the vector store.
|
| 20 |
+
|
| 21 |
+
Accepts a .pdf or .docx file, extracts text (preserving per-page structure
|
| 22 |
+
for PDFs), splits it into overlapping 500-word chunks with a 50-word overlap,
|
| 23 |
+
and upserts every chunk with source metadata into the persistent ChromaDB
|
| 24 |
+
collection. Calling this function on an already-indexed file is safe β all
|
| 25 |
+
chunks are upserted so duplicates are automatically overwritten.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
file_path: Absolute or relative path to the document to ingest.
|
| 29 |
+
Must be a .pdf or .docx file.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
A human-readable confirmation string such as:
|
| 33 |
+
"Successfully indexed 'report.pdf': 42 chunks stored across 10 page(s)."
|
| 34 |
+
If the file cannot be parsed an error message is returned instead of
|
| 35 |
+
raising, so the agent can surface it gracefully.
|
| 36 |
+
"""
|
| 37 |
+
path = Path(file_path)
|
| 38 |
+
|
| 39 |
+
if not path.exists():
|
| 40 |
+
return f"Error: file not found at '{file_path}'."
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
pages = parse_document(path)
|
| 44 |
+
except ValueError as exc:
|
| 45 |
+
return f"Error: {exc}"
|
| 46 |
+
|
| 47 |
+
total_chunks = 0
|
| 48 |
+
for page in pages:
|
| 49 |
+
chunks = chunk_text(page["text"])
|
| 50 |
+
embed_and_store(
|
| 51 |
+
chunks,
|
| 52 |
+
metadata={
|
| 53 |
+
"source_file": path.name,
|
| 54 |
+
"page_number": page["page_number"],
|
| 55 |
+
},
|
| 56 |
+
)
|
| 57 |
+
total_chunks += len(chunks)
|
| 58 |
+
|
| 59 |
+
page_count = len(pages)
|
| 60 |
+
page_label = f"{page_count} page(s)" if pages[0]["page_number"] is not None else "1 section"
|
| 61 |
+
return (
|
| 62 |
+
f"Successfully indexed '{path.name}': "
|
| 63 |
+
f"{total_chunks} chunks stored across {page_label}."
|
| 64 |
+
)
|
tools/list_documents_tool.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# COST: ZERO OpenAI tokens.
|
| 2 |
+
# Reads ChromaDB metadata only β no embedding or similarity search performed.
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
from chroma import get_collection # noqa: E402
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def list_indexed_documents() -> list[str]:
|
| 17 |
+
"""Return the names of all documents that have been indexed in the vector store.
|
| 18 |
+
|
| 19 |
+
Queries the ChromaDB collection metadata to collect every unique
|
| 20 |
+
'source_file' value without loading any embeddings or performing any
|
| 21 |
+
similarity search. No LLM call is made.
|
| 22 |
+
|
| 23 |
+
This is useful for letting the agent know which documents are available
|
| 24 |
+
before deciding whether to retrieve chunks or ask the user to upload more
|
| 25 |
+
files.
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
A sorted list of unique source file names (e.g. ['manual.pdf', 'notes.docx']).
|
| 29 |
+
Returns an empty list if no documents have been indexed yet.
|
| 30 |
+
"""
|
| 31 |
+
collection = get_collection()
|
| 32 |
+
if collection.count() == 0:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
result = collection.get(include=["metadatas"])
|
| 36 |
+
seen: set[str] = set()
|
| 37 |
+
for meta in result["metadatas"]:
|
| 38 |
+
source = meta.get("source_file")
|
| 39 |
+
if source:
|
| 40 |
+
seen.add(source)
|
| 41 |
+
|
| 42 |
+
return sorted(seen)
|
tools/retrieval_tool.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
from chroma import get_collection # noqa: E402
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def retrieve_chunks(query: str, top_k: int = 5) -> list[str]:
|
| 14 |
+
"""Search the vector store for document chunks most relevant to the query.
|
| 15 |
+
|
| 16 |
+
Embeds the query using the same OpenAI embedding model used during ingestion,
|
| 17 |
+
performs a cosine-similarity search against the ChromaDB collection, and
|
| 18 |
+
returns the top-k matching text chunks formatted with source metadata so
|
| 19 |
+
the caller can cite the origin of each passage.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
query: The natural-language question or search string to look up.
|
| 23 |
+
top_k: Maximum number of chunks to return (default 5).
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
A list of strings, each formatted as:
|
| 27 |
+
"[source: <filename>, page: <n>, chunk: <i>]\\n<chunk text>"
|
| 28 |
+
Page number is omitted for DOCX files where it is unavailable.
|
| 29 |
+
"""
|
| 30 |
+
collection = get_collection()
|
| 31 |
+
count = collection.count()
|
| 32 |
+
if count == 0:
|
| 33 |
+
return ["No documents have been indexed yet. Please ingest documents first."]
|
| 34 |
+
|
| 35 |
+
results = collection.query(
|
| 36 |
+
query_texts=[query],
|
| 37 |
+
n_results=min(top_k, count),
|
| 38 |
+
include=["documents", "metadatas"],
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
chunks: list[str] = []
|
| 42 |
+
for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
|
| 43 |
+
source = meta.get("source_file", "unknown")
|
| 44 |
+
chunk_idx = meta.get("chunk_index", "?")
|
| 45 |
+
page = meta.get("page_number")
|
| 46 |
+
if page is not None:
|
| 47 |
+
header = f"[source: {source}, page: {page}, chunk: {chunk_idx}]"
|
| 48 |
+
else:
|
| 49 |
+
header = f"[source: {source}, chunk: {chunk_idx}]"
|
| 50 |
+
chunks.append(f"{header}\n{doc}")
|
| 51 |
+
|
| 52 |
+
return chunks
|
tools/summarize_tool.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# COST: Gemini gemini-2.5-flash called ONCE per document on first summarization.
|
| 2 |
+
# Result is written to a JSON sidecar file (<filename>.summary.json) beside
|
| 3 |
+
# the source document. Every subsequent call reads the sidecar and returns
|
| 4 |
+
# immediately β zero additional tokens spent.
|
| 5 |
+
# Sidecar is invalidated only by manual deletion.
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 13 |
+
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
from ingestion import parse_document
|
| 19 |
+
|
| 20 |
+
SUMMARY_SUFFIX = ".summary.json"
|
| 21 |
+
MAX_SUMMARY_CHARS = 12_000 # truncation guard before sending to LLM
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _cache_path(file_path: Path) -> Path:
|
| 25 |
+
return file_path.with_name(file_path.name + SUMMARY_SUFFIX)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def summarize_document(file_path: str) -> str:
|
| 29 |
+
"""Return a concise summary of a document, using a local cache when available.
|
| 30 |
+
|
| 31 |
+
On the first call the function extracts the full text from the document,
|
| 32 |
+
sends it to the OpenAI Chat Completions API (gpt-4o-mini), and writes the
|
| 33 |
+
resulting summary to a JSON sidecar file next to the original document
|
| 34 |
+
(e.g. 'report.pdf.summary.json'). Every subsequent call for the same file
|
| 35 |
+
reads the sidecar and returns immediately without making another LLM call,
|
| 36 |
+
keeping costs low and responses fast.
|
| 37 |
+
|
| 38 |
+
The cache is invalidated only by deleting the sidecar file manually.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
file_path: Absolute or relative path to the .pdf or .docx document.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
A plain-text summary paragraph. If the file cannot be found or parsed,
|
| 45 |
+
an error message is returned instead of raising so the agent can handle
|
| 46 |
+
it gracefully.
|
| 47 |
+
"""
|
| 48 |
+
path = Path(file_path)
|
| 49 |
+
|
| 50 |
+
if not path.exists():
|
| 51 |
+
return f"Error: file not found at '{file_path}'."
|
| 52 |
+
|
| 53 |
+
cache = _cache_path(path)
|
| 54 |
+
if cache.exists():
|
| 55 |
+
try:
|
| 56 |
+
data = json.loads(cache.read_text(encoding="utf-8"))
|
| 57 |
+
return data["summary"]
|
| 58 |
+
except (json.JSONDecodeError, KeyError):
|
| 59 |
+
cache.unlink(missing_ok=True) # corrupt cache β regenerate
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
pages = parse_document(path)
|
| 63 |
+
except ValueError as exc:
|
| 64 |
+
return f"Error: {exc}"
|
| 65 |
+
|
| 66 |
+
full_text = "\n\n".join(p["text"] for p in pages)
|
| 67 |
+
truncated = full_text[:MAX_SUMMARY_CHARS]
|
| 68 |
+
if len(full_text) > MAX_SUMMARY_CHARS:
|
| 69 |
+
truncated += "\n\n[...document truncated for summarisation...]"
|
| 70 |
+
|
| 71 |
+
from openai import OpenAI
|
| 72 |
+
|
| 73 |
+
client = OpenAI(
|
| 74 |
+
api_key=os.environ["GEMINI_API_KEY"],
|
| 75 |
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 76 |
+
)
|
| 77 |
+
response = client.chat.completions.create(
|
| 78 |
+
model="gemini-2.5-flash",
|
| 79 |
+
messages=[
|
| 80 |
+
{
|
| 81 |
+
"role": "system",
|
| 82 |
+
"content": (
|
| 83 |
+
"You are a document summarisation assistant. "
|
| 84 |
+
"Write a clear, concise summary (3-5 paragraphs) of the document "
|
| 85 |
+
"the user provides. Focus on key topics, findings, and conclusions."
|
| 86 |
+
),
|
| 87 |
+
},
|
| 88 |
+
{"role": "user", "content": f"Summarise this document:\n\n{truncated}"},
|
| 89 |
+
],
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
summary = response.choices[0].message.content or ""
|
| 93 |
+
cache.write_text(
|
| 94 |
+
json.dumps({"source_file": path.name, "summary": summary}, ensure_ascii=False, indent=2),
|
| 95 |
+
encoding="utf-8",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return summary
|
tools/web_search_tool.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# COST: ZERO OpenAI tokens.
|
| 2 |
+
# Retrieval uses BM25 keyword scoring (rank-bm25, runs locally).
|
| 3 |
+
# Website content is read from the local scrape cache (content_store).
|
| 4 |
+
# A live HTTP fetch only occurs when the cached snapshot is stale (>60 min).
|
| 5 |
+
# No embedding model or LLM is invoked at any point in this tool.
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 11 |
+
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
from rank_bm25 import BM25Okapi
|
| 17 |
+
|
| 18 |
+
from scraper.content_store import is_content_stale, load_latest_content, save_site_content
|
| 19 |
+
from scraper.web_scraper import AGENTRAX_URL, scrape_site
|
| 20 |
+
|
| 21 |
+
_TOP_K = 5
|
| 22 |
+
_MIN_CHARS = 40 # skip fragment-length paragraphs
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def search_agentrax_website(query: str) -> str:
|
| 26 |
+
"""Search the AgentRax website (https://agentrax.net/) for information relevant to the user query.
|
| 27 |
+
|
| 28 |
+
Use this tool when the user asks about AgentRax services, pricing, features, agents, or any
|
| 29 |
+
topic that would be found on the AgentRax website. This tool retrieves live or recently cached
|
| 30 |
+
website content β never answer AgentRax questions from general knowledge.
|
| 31 |
+
|
| 32 |
+
Returns: Relevant extracted sections from the website as a formatted string with source URL.
|
| 33 |
+
"""
|
| 34 |
+
content = load_latest_content()
|
| 35 |
+
|
| 36 |
+
if is_content_stale(max_age_minutes=60) or not content:
|
| 37 |
+
fresh = await scrape_site(AGENTRAX_URL)
|
| 38 |
+
if "error" not in fresh:
|
| 39 |
+
save_site_content(fresh)
|
| 40 |
+
content = fresh
|
| 41 |
+
|
| 42 |
+
if not content:
|
| 43 |
+
return "Error: could not retrieve AgentRax website content."
|
| 44 |
+
|
| 45 |
+
body_text: str = content.get("body_text") or ""
|
| 46 |
+
title: str = content.get("title") or ""
|
| 47 |
+
url: str = content.get("url") or AGENTRAX_URL
|
| 48 |
+
|
| 49 |
+
paragraphs = [p.strip() for p in body_text.split("\n") if len(p.strip()) >= _MIN_CHARS]
|
| 50 |
+
|
| 51 |
+
if not paragraphs:
|
| 52 |
+
return f"Title: {title}\nRelevant Content:\n- (no content available)"
|
| 53 |
+
|
| 54 |
+
tokenized = [p.lower().split() for p in paragraphs]
|
| 55 |
+
bm25 = BM25Okapi(tokenized)
|
| 56 |
+
scores = bm25.get_scores(query.lower().split())
|
| 57 |
+
|
| 58 |
+
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:_TOP_K]
|
| 59 |
+
top_paragraphs = [paragraphs[i] for i in sorted(top_indices)]
|
| 60 |
+
|
| 61 |
+
bullet_lines = "\n".join(f"- {p}" for p in top_paragraphs)
|
| 62 |
+
return f"Title: {title}\nRelevant Content:\n{bullet_lines}"
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|