Shurem Claude Sonnet 4.6 commited on
Commit
1fee1c2
Β·
1 Parent(s): 27d3e93

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>

Files changed (50) hide show
  1. .dockerignore +19 -0
  2. .env.example +1 -0
  3. .gitattributes +35 -35
  4. COST_NOTES.md +80 -0
  5. Dockerfile +41 -0
  6. README.md +12 -11
  7. agents/__init__.py +0 -0
  8. agents/__pycache__/__init__.cpython-312.pyc +0 -0
  9. agents/__pycache__/rag_agent.cpython-312.pyc +0 -0
  10. agents/rag_agent.py +105 -0
  11. api/__init__.py +0 -0
  12. api/__pycache__/__init__.cpython-312.pyc +0 -0
  13. api/__pycache__/routes.cpython-312.pyc +0 -0
  14. api/routes.py +220 -0
  15. cache/__init__.py +0 -0
  16. cache/__pycache__/__init__.cpython-312.pyc +0 -0
  17. cache/__pycache__/query_cache.cpython-312.pyc +0 -0
  18. cache/query_cache.py +90 -0
  19. cache/query_store/cache.db +0 -0
  20. chroma.py +34 -0
  21. db/__init__.py +0 -0
  22. db/__pycache__/__init__.cpython-312.pyc +0 -0
  23. db/__pycache__/history.cpython-312.pyc +0 -0
  24. db/history.py +130 -0
  25. ingestion.py +157 -0
  26. main.py +39 -0
  27. pyproject.toml +34 -0
  28. scraper/__init__.py +0 -0
  29. scraper/__pycache__/__init__.cpython-312.pyc +0 -0
  30. scraper/__pycache__/content_store.cpython-312.pyc +0 -0
  31. scraper/__pycache__/scheduler.cpython-312.pyc +0 -0
  32. scraper/__pycache__/web_scraper.cpython-312.pyc +0 -0
  33. scraper/_test_store.json +42 -0
  34. scraper/content_store.py +71 -0
  35. scraper/scheduler.py +70 -0
  36. scraper/site_content.json +202 -0
  37. scraper/web_scraper.py +74 -0
  38. tools/__init__.py +0 -0
  39. tools/__pycache__/__init__.cpython-312.pyc +0 -0
  40. tools/__pycache__/document_loader_tool.cpython-312.pyc +0 -0
  41. tools/__pycache__/list_documents_tool.cpython-312.pyc +0 -0
  42. tools/__pycache__/retrieval_tool.cpython-312.pyc +0 -0
  43. tools/__pycache__/summarize_tool.cpython-312.pyc +0 -0
  44. tools/__pycache__/web_search_tool.cpython-312.pyc +0 -0
  45. tools/document_loader_tool.py +64 -0
  46. tools/list_documents_tool.py +42 -0
  47. tools/retrieval_tool.py +52 -0
  48. tools/summarize_tool.py +98 -0
  49. tools/web_search_tool.py +62 -0
  50. 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
- pinned: false
8
- short_description: usershelpAgent
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
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