File size: 4,919 Bytes
ab4952c
 
 
 
1fee1c2
 
 
 
 
 
 
 
 
 
 
 
 
ab4952c
 
 
 
 
 
 
 
 
1fee1c2
ab4952c
 
1fee1c2
 
ab4952c
 
 
 
1fee1c2
 
ab4952c
 
 
 
 
 
 
 
 
1fee1c2
ab4952c
 
 
 
 
1fee1c2
ab4952c
 
 
 
 
1fee1c2
ab4952c
1fee1c2
 
ab4952c
 
 
 
 
 
1fee1c2
ab4952c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1fee1c2
 
 
 
ab4952c
 
 
 
 
 
 
 
 
 
1fee1c2
ab4952c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# COST: ZERO β€” BM25 keyword scoring (rank-bm25, runs locally).
# Searches across ALL scraped AgentRax pages (home, pricing, about, help, blog, contact).
# Falls back to single-page snapshot if multi-page store is missing.
# A live HTTP fetch only occurs when cached content is stale (>60 min).
# No embedding model or LLM is invoked at any point in this tool.

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from dotenv import load_dotenv

load_dotenv()

from rank_bm25 import BM25Okapi

from scraper.content_store import (
    is_content_stale,
    is_multi_page_stale,
    load_all_pages,
    load_latest_content,
    save_all_pages,
    save_site_content,
)
from scraper.web_scraper import AGENTRAX_URL, scrape_all_pages, scrape_site

_TOP_K = 8       # results returned across all pages
_MIN_CHARS = 30  # minimum length to include a text fragment


def _page_label(url: str) -> str:
    """Return a short human-readable label for a page URL."""
    slug = url.rstrip("/").split("/")[-1]
    return slug if slug else "home"


def _build_corpus(pages: dict[str, dict]) -> tuple[list[str], list[str]]:
    """Return (corpus_texts, corpus_labels) from a {url: page_data} dict."""
    texts: list[str] = []
    labels: list[str] = []

    for url, content in pages.items():
        label = _page_label(url)
        headings = content.get("headings") or []
        body_text = content.get("body_text") or ""

        for h in headings:
            h = h.strip()
            if len(h) >= 5:
                texts.append(f"[{label}] {h}")
                labels.append(label)

        for para in body_text.split("\n"):
            para = para.strip()
            if len(para) >= _MIN_CHARS:
                texts.append(f"[{label}] {para}")
                labels.append(label)

    return texts, labels


async def search_agentrax_website(query: str) -> str:
    """Search all AgentRax website pages for information relevant to the user query.

    Searches across home, pricing, about, help, blog, and contact pages using BM25.
    Content is refreshed automatically every 60 minutes. Falls back to single-page
    snapshot if the multi-page store is unavailable.

    Args:
        query: The question or topic to search on the AgentRax website.

    Returns:
        Relevant extracted sections annotated with which page they came from.
    """
    # ── 1. Load / refresh multi-page content ─────────────────────────────────
    pages = load_all_pages()

    if is_multi_page_stale() or not pages:
        fresh = await scrape_all_pages()
        if fresh:
            save_all_pages(fresh)
            pages = {p["url"]: p for p in fresh}

    # ── 2. Fall back to single-page homepage snapshot ─────────────────────────
    if not pages:
        single = load_latest_content()
        if is_content_stale() or not single:
            single = await scrape_site(AGENTRAX_URL)
            if "error" not in single:
                save_site_content(single)
        if single and "error" not in single:
            pages = {single.get("url", AGENTRAX_URL): single}

    if not pages:
        return (
            "Error: could not retrieve AgentRax website content. "
            "Please try again in a moment."
        )

    # ── 3. Build cross-page searchable corpus ─────────────────────────────────
    corpus, labels = _build_corpus(pages)

    if not corpus:
        # Last resort: return description of first available page
        first = next(iter(pages.values()))
        title = first.get("title") or "AgentRax"
        desc = first.get("description") or ""
        return f"## {title}\n\n{desc}" if desc else f"## {title}\n\n(No content available.)"

    # ── 4. BM25 ranking ───────────────────────────────────────────────────────
    tokenized = [text.lower().split() for text in corpus]
    bm25 = BM25Okapi(tokenized)
    scores = bm25.get_scores(query.lower().split())

    top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:_TOP_K]
    # Preserve document order for readability
    top_indices_sorted = sorted(top_indices)

    # ── 5. Format output ──────────────────────────────────────────────────────
    lines = []
    for i in top_indices_sorted:
        lines.append(f"- {corpus[i]}")

    pages_scraped = ", ".join(sorted({_page_label(u) for u in pages}))
    header = f"## AgentRax β€” Pages searched: {pages_scraped}"

    return f"{header}\n\n### Most Relevant Content\n" + "\n".join(lines)