diff --git a/atlas/connectors/web_search.py b/atlas/connectors/web_search.py index 553b59b..1811a1b 100644 --- a/atlas/connectors/web_search.py +++ b/atlas/connectors/web_search.py @@ -1,9 +1,10 @@ -"""Web search connector (L7). READ-only — current facts the model can cite. +"""Web search connector (L7). READ-only — current facts the model can cite, plus +RICH results (related images + the top article) for the visual HUD, and an article +reader so ATLAS can summarize / TLDR / read a page aloud on request. -Default provider is **DuckDuckGo** (no API key, works out of the box). Tavily or -Brave can be selected in settings for higher quality; their key is read from the -env or the macOS Keychain (never stored in settings.json). All failures degrade -to an honest message rather than raising into the chat path. +Default provider is DuckDuckGo (no key). Tavily/Brave selectable in settings; keys +come from env or the macOS Keychain. All failures degrade to an honest message +rather than raising into the chat path. """ from __future__ import annotations @@ -12,13 +13,15 @@ from .. import settings as cfg +_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125 Safari/537.36") + def _web_cfg() -> dict[str, Any]: return cfg.settings().get("web") or {} def _key(web: dict[str, Any], env_name: str) -> str | None: - """API key from env first, then macOS Keychain via the configured ref.""" if os.environ.get(env_name): return os.environ[env_name] from ..orchestration.router import keychain_secret # lazy: avoid import cycle @@ -35,57 +38,150 @@ def _format(rows: list[tuple[str, str, str]], query: str) -> str: return "\n".join(out) -def search(query: str, *, max_results: int | None = None) -> str: - query = (query or "").strip() - if not query: - return "No search query provided." - web = _web_cfg() - n = max_results or web.get("max_results", 5) - provider = (web.get("provider") or "duckduckgo").lower() - try: - if provider == "duckduckgo": - return _ddg(query, n) - if provider == "tavily": - return _tavily(query, n, _key(web, "TAVILY_API_KEY")) - if provider == "brave": - return _brave(query, n, _key(web, "BRAVE_API_KEY")) - return f"Unknown search provider '{provider}'. Use duckduckgo, tavily, or brave." - except Exception as exc: # network / parse / rate-limit — stay graceful - return f"Web search failed ({type(exc).__name__}). Try again shortly." - - -def _ddg(query: str, n: int) -> str: - try: - from ddgs import DDGS - except ImportError: - return "Web search needs the 'ddgs' package (pip install ddgs)." +# ----- provider row-getters → (title, body, url) ------------------------- # +def _ddg_rows(query: str, n: int) -> list[tuple[str, str, str]]: + from ddgs import DDGS with DDGS() as d: res = list(d.text(query, max_results=n)) - return _format([(r.get("title", ""), r.get("body", ""), r.get("href", "")) for r in res], query) + return [(r.get("title", ""), r.get("body", ""), r.get("href", "")) for r in res] -def _tavily(query: str, n: int, key: str | None) -> str: - if not key: - return "Tavily selected but no key found. Add TAVILY_API_KEY (env or Keychain)." +def _tavily_rows(query: str, n: int, key: str) -> list[tuple[str, str, str]]: import httpx r = httpx.post("https://api.tavily.com/search", json={ - "api_key": key, "query": query, "max_results": n, "include_answer": False, - }, timeout=15.0) + "api_key": key, "query": query, "max_results": n, "include_answer": False}, timeout=15.0) r.raise_for_status() - data = r.json() - rows = [(x.get("title", ""), x.get("content", ""), x.get("url", "")) for x in data.get("results", [])] - return _format(rows, query) + return [(x.get("title", ""), x.get("content", ""), x.get("url", "")) for x in r.json().get("results", [])] -def _brave(query: str, n: int, key: str | None) -> str: - if not key: - return "Brave selected but no key found. Add BRAVE_API_KEY (env or Keychain)." +def _brave_rows(query: str, n: int, key: str) -> list[tuple[str, str, str]]: import httpx r = httpx.get("https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, - headers={"X-Subscription-Token": key, "Accept": "application/json"}, - timeout=15.0) + headers={"X-Subscription-Token": key, "Accept": "application/json"}, timeout=15.0) r.raise_for_status() - rows = [(x.get("title", ""), x.get("description", ""), x.get("url", "")) + return [(x.get("title", ""), x.get("description", ""), x.get("url", "")) for x in r.json().get("web", {}).get("results", [])] - return _format(rows, query) + + +def _rows(query: str, n: int, web: dict[str, Any]) -> list[tuple[str, str, str]]: + provider = (web.get("provider") or "duckduckgo").lower() + if provider == "duckduckgo": + try: + import ddgs # noqa: F401 + except ImportError: + raise RuntimeError("ddgs-missing") + return _ddg_rows(query, n) + if provider == "tavily": + key = _key(web, "TAVILY_API_KEY") + if not key: + raise RuntimeError("no-tavily-key") + return _tavily_rows(query, n, key) + if provider == "brave": + key = _key(web, "BRAVE_API_KEY") + if not key: + raise RuntimeError("no-brave-key") + return _brave_rows(query, n, key) + raise RuntimeError(f"unknown-provider:{provider}") + + +def search(query: str, *, max_results: int | None = None) -> str: + """Plain-text results for the model (with source URLs to cite).""" + query = (query or "").strip() + if not query: + return "No search query provided." + web = _web_cfg() + n = max_results or web.get("max_results", 5) + try: + return _format(_rows(query, n, web), query) + except RuntimeError as exc: + m = str(exc) + if m == "ddgs-missing": + return "Web search needs the 'ddgs' package (pip install ddgs)." + if m.startswith("no-"): + return f"Search provider needs an API key ({m})." + return f"Web search unavailable ({m})." + except Exception as exc: # network / parse / rate-limit — stay graceful + return f"Web search failed ({type(exc).__name__}). Try again shortly." + + +# ----- images (for the floating photos on the HUD) ----------------------- # +def _images(query: str, n: int = 6) -> list[dict[str, str]]: + try: + from ddgs import DDGS + with DDGS() as d: + res = list(d.images(query, max_results=n)) + except Exception: + return [] + out: list[dict[str, str]] = [] + for r in res: + img = r.get("image") + if not img: + continue + out.append({"image": img, "thumbnail": r.get("thumbnail") or img, + "source": r.get("url") or img, "title": r.get("title", "")}) + return out + + +def rich(query: str, *, max_results: int | None = None) -> dict[str, Any]: + """Text (for the model) + related images + the top article (for the HUD).""" + query = (query or "").strip() + if not query: + return {"query": query, "text": "No search query provided.", "images": [], "article": None} + web = _web_cfg() + n = max_results or web.get("max_results", 5) + try: + rows = _rows(query, n, web) + text = _format(rows, query) + except Exception as exc: + rows, text = [], f"Web search failed ({type(exc).__name__})." + images = _images(query, max(n + 1, 6)) + article = None + if rows: + title, body, url = rows[0] + article = {"title": title, "url": url, "summary": body, + "image": images[0]["image"] if images else None} + return {"query": query, "text": text, "images": images, "article": article} + + +# ----- article reader (fetch + extract main text) ------------------------ # +def read_article(url: str) -> str: + """Fetch a page and return its readable main text so the model can summarize, + TLDR, or read it aloud. Truncated so it fits the context window.""" + url = (url or "").strip() + if not url.startswith("http"): + return "There's no article URL to read yet — search for something first." + try: + import httpx + r = httpx.get(url, timeout=15.0, follow_redirects=True, headers={"User-Agent": _UA}) + r.raise_for_status() + html = r.text + except Exception as exc: + return f"Couldn't fetch the article ({type(exc).__name__})." + text = _extract_text(html) + if not text: + return "Couldn't extract readable text from that page (it may be paywalled or JS-only)." + return text[:6000] + + +def _extract_text(html: str) -> str: + try: + from lxml import html as lhtml + except ImportError: + return "" + try: + doc = lhtml.fromstring(html) + except Exception: + return "" + for bad in doc.xpath("//script|//style|//noscript|//nav|//footer|//header|//aside|//form"): + parent = bad.getparent() + if parent is not None: + parent.remove(bad) + title = (doc.findtext(".//title") or "").strip() + nodes = doc.xpath("//article//p") or doc.xpath("//main//p") or doc.xpath("//p") + paras = [" ".join(p.text_content().split()).strip() for p in nodes] + paras = [p for p in paras if len(p) > 40] + if not paras: + return "" + body = "\n\n".join(paras) + return (f"{title}\n\n{body}" if title else body).strip() diff --git a/atlas/interface/web/app.js b/atlas/interface/web/app.js index ad813a0..ef8d9d9 100644 --- a/atlas/interface/web/app.js +++ b/atlas/interface/web/app.js @@ -253,6 +253,7 @@ async function ask(text, { speak = false } = {}) { const reply = data.reply || "(no reply)"; addMsg("atlas", reply); setTranscript(`ATLAS: ${reply}`); + renderSearchMedia(data.media); if (speak) speakReply(reply); else setState("idle"); } catch { thinking.remove(); @@ -1017,6 +1018,57 @@ $("#backendTest").addEventListener("click", async () => { finally { btn.disabled = false; btn.textContent = "Test"; } }); +/* ---------- search visuals: floating related photos + article card ---------- */ +function renderSearchMedia(media) { + const wrap = $("#searchVisuals"), card = $("#articleCard"); + if (!wrap || !card) return; + wrap.innerHTML = ""; // clear previous search's photos + if (!media || (!(media.images && media.images.length) && !media.article)) { card.hidden = true; return; } + + // floating related photos, scattered around the edges (away from the orb) + const spots = [[6, 16], [80, 12], [9, 58], [83, 54], [40, 6], [63, 70], [22, 82], [72, 84]]; + (media.images || []).slice(0, 6).forEach((im, i) => { + const el = document.createElement("img"); + el.className = "float-photo"; el.loading = "lazy"; + el.src = im.thumbnail || im.image; el.alt = im.title || ""; + if (im.title) el.title = im.title; + const [x, y] = spots[i % spots.length]; + el.style.left = x + "%"; el.style.top = y + "%"; + el.style.animationDelay = `${i * 0.35}s, ${i * 0.6}s`; // photoIn, floaty + el.addEventListener("click", () => window.open(im.source || im.image, "_blank", "noopener")); + el.addEventListener("error", () => el.remove()); + wrap.appendChild(el); + }); + + // top article card (hero + title + summary + Open + TLDR) + const a = media.article; + if (a && a.url) { + card.innerHTML = ""; + const close = document.createElement("button"); + close.className = "artc-close"; close.title = "Close"; close.textContent = "✕"; + close.addEventListener("click", () => { card.hidden = true; }); + card.appendChild(close); + if (a.image) { + const hero = document.createElement("div"); hero.className = "artc-hero"; + hero.style.backgroundImage = `url("${String(a.image).replace(/"/g, "%22")}")`; + card.appendChild(hero); + } + const body = document.createElement("div"); body.className = "artc-body"; + const title = document.createElement("div"); title.className = "artc-title"; title.textContent = a.title || "Article"; + const sum = document.createElement("p"); sum.className = "artc-sum"; sum.textContent = a.summary || ""; + const actions = document.createElement("div"); actions.className = "artc-actions"; + const open = document.createElement("a"); + open.className = "navbtn small primary"; open.target = "_blank"; open.rel = "noopener"; + open.href = a.url; open.textContent = "Open article ↗"; + const tldr = document.createElement("button"); + tldr.className = "navbtn small"; tldr.textContent = "TLDR"; + tldr.addEventListener("click", () => ask(`Give me a short TLDR of this article: ${a.url}`, true)); + actions.append(open, tldr); + body.append(title, sum, actions); card.appendChild(body); + card.hidden = false; + } else card.hidden = true; +} + /* ---------- stable session id (so conversation history accumulates) ---------- */ function sessionId() { let s = localStorage.getItem("atlas_session"); diff --git a/atlas/interface/web/index.html b/atlas/interface/web/index.html index 6baa51f..366e1c3 100644 --- a/atlas/interface/web/index.html +++ b/atlas/interface/web/index.html @@ -143,6 +143,10 @@

Settings

+ + + +
diff --git a/atlas/interface/web/styles.css b/atlas/interface/web/styles.css index 74a857d..d2a3d29 100644 --- a/atlas/interface/web/styles.css +++ b/atlas/interface/web/styles.css @@ -274,6 +274,28 @@ code{font-family:var(--mono);color:var(--accent);font-size:12px} background:rgba(0,0,0,.35);border:1px solid var(--line);border-radius:8px; color:var(--text);padding:7px 10px;font-family:var(--mono);font-size:13px;outline:none} .tier-row select:focus,.tier-row input:focus{border-color:var(--line-bright)} +/* ---------- search visuals: floating related photos + article card ---------- */ +.search-visuals{position:fixed;inset:0;z-index:6;pointer-events:none;overflow:hidden} +.float-photo{position:absolute;width:118px;height:118px;object-fit:cover;border-radius:14px; + border:1px solid var(--line-bright);box-shadow:0 10px 34px rgba(0,0,0,.5),0 0 0 1px rgba(0,224,255,.15); + pointer-events:auto;cursor:pointer;opacity:0; + animation:photoIn .5s ease forwards,floaty 7s ease-in-out infinite;transition:transform .2s,box-shadow .2s} +.float-photo:hover{transform:scale(1.07);box-shadow:0 14px 44px rgba(0,0,0,.6),0 0 0 1px var(--accent);z-index:2} +@keyframes photoIn{to{opacity:.92}} +@keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-12px)}} +@media (max-width:900px){.float-photo{width:82px;height:82px}} +.article-card{position:fixed;right:22px;bottom:22px;z-index:8;width:344px;max-width:calc(100vw - 32px); + border-radius:16px;overflow:hidden;display:flex;flex-direction:column;opacity:0;animation:photoIn .4s ease forwards} +.artc-hero{height:150px;background-size:cover;background-position:center;border-bottom:1px solid var(--line)} +.artc-body{padding:14px 16px;display:flex;flex-direction:column;gap:8px} +.artc-title{font-weight:600;font-size:15px;line-height:1.35} +.artc-sum{margin:0;font-size:12.5px;color:var(--muted);line-height:1.5; + display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden} +.artc-actions{display:flex;gap:8px;align-items:center;margin-top:2px} +.artc-close{position:absolute;top:8px;right:8px;z-index:2;background:rgba(2,4,10,.55); + border:1px solid var(--line);color:var(--text);width:26px;height:26px;border-radius:50%; + cursor:pointer;font-size:12px;line-height:1} +.artc-close:hover{border-color:var(--accent);color:var(--accent)} .code-hint code{display:inline-block;background:rgba(0,0,0,.4);padding:5px 9px;border-radius:6px; border:1px solid var(--line);margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--accent)} .status-pill{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1px; diff --git a/atlas/orchestration/config.json b/atlas/orchestration/config.json index edc2851..e477a04 100644 --- a/atlas/orchestration/config.json +++ b/atlas/orchestration/config.json @@ -87,8 +87,15 @@ "name": "web_search", "risk": "READ", "connector": "web", - "description": "Search the web for current information. Summarize results with sources.", + "description": "Search the web for current information. Summarize results with sources. The top related images and article are shown to the Owner on the HUD automatically.", "input_schema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] } + }, + { + "name": "read_article", + "risk": "READ", + "connector": "web", + "description": "Fetch and read the full text of an article so you can summarize it, give a TLDR, or read it aloud. Use when the Owner says things like 'read that article', 'summarize it', or 'TLDR'. Omit url to use the most recent search's top article.", + "input_schema": { "type": "object", "properties": { "url": { "type": "string" } } } } ], "risk_policy": { diff --git a/atlas/orchestration/router.py b/atlas/orchestration/router.py index 69d31de..403e9fd 100644 --- a/atlas/orchestration/router.py +++ b/atlas/orchestration/router.py @@ -177,6 +177,7 @@ def _history(self, session: str) -> list[dict[str, Any]]: def chat(self, text: str, *, session: str = "s_000", turn: int = 1, channel: str = "chat", confirmed: bool = False) -> dict[str, Any]: t0 = time.monotonic() + self.tools.last_media = None # cleared each turn; set if web_search runs tier = None if self.routing_enabled: tier = classify_tier(text) @@ -208,7 +209,8 @@ def chat(self, text: str, *, session: str = "s_000", turn: int = 1, backend=(f"{tier}:{backend}" if tier else backend), tools=used, session=session, turn=turn) return {"reply": reply, "backend": backend, "tier": tier, - "latency_ms": latency_ms, "tools_used": used} + "latency_ms": latency_ms, "tools_used": used, + "media": self.tools.last_media} def _dispatch_mode(self, text: str, *, confirmed: bool, session: str = "") -> tuple[str, list[str], int | None]: diff --git a/atlas/orchestration/tools.py b/atlas/orchestration/tools.py index 0c3f2b2..7ff7350 100644 --- a/atlas/orchestration/tools.py +++ b/atlas/orchestration/tools.py @@ -50,6 +50,7 @@ def __init__(self, vault: VaultStore | None = None, registry: ConnectorRegistry | None = None): self.vault = vault or VaultStore() self.registry = registry or ConnectorRegistry() + self.last_media: dict[str, Any] | None = None # visuals from the last web search def dispatch(self, name: str, args: dict[str, Any], *, confirmed: bool = False) -> str: @@ -84,8 +85,17 @@ def _t_forget(self, args: dict[str, Any]) -> str: return self.vault.forget(args.get("fact"), args.get("entity")) def _t_web_search(self, args: dict[str, Any]) -> str: - from ..connectors.web_search import search - return search(args.get("query", "")) + from ..connectors.web_search import rich + data = rich(args.get("query", "")) + # Stash visuals (images + top article) for the HUD; the model gets the text. + self.last_media = {"type": "search", "query": data.get("query"), + "images": data.get("images") or [], "article": data.get("article")} + return data.get("text", "") + + def _t_read_article(self, args: dict[str, Any]) -> str: + from ..connectors.web_search import read_article + url = args.get("url") or ((self.last_media or {}).get("article") or {}).get("url", "") + return read_article(url) def _t_atlas_status(self, args: dict[str, Any]) -> str: """Introspect ATLAS's own layers/state so the model can answer questions diff --git a/atlas/tests/test_web_search.py b/atlas/tests/test_web_search.py new file mode 100644 index 0000000..ea705b0 --- /dev/null +++ b/atlas/tests/test_web_search.py @@ -0,0 +1,64 @@ +"""Web search: rich results (images + article) + the article reader. Hermetic — +no network; extraction and wiring are tested with monkeypatched providers.""" +from __future__ import annotations + +from atlas.connectors import web_search as ws +from atlas.orchestration.tools import ToolBox + + +def test_extract_text_pulls_paragraphs_and_drops_chrome(): + html = ("Hello World" + "" + "

This is a sufficiently long paragraph about the topic at hand.

" + "

short

Another long and meaningful paragraph with real content here.

" + "
") + out = ws._extract_text(html) + assert "Hello World" in out + assert "sufficiently long paragraph" in out + assert "Another long and meaningful" in out + assert "junk()" not in out and "menu" not in out # script/nav stripped + assert "short" not in out # <40 chars dropped + + +def test_read_article_rejects_non_url(): + assert "search for something" in ws.read_article("not a url").lower() + + +def test_rich_shapes_media(monkeypatch): + monkeypatch.setattr(ws, "_rows", lambda q, n, web: [("Title A", "Body A", "https://a.example/post")]) + monkeypatch.setattr(ws, "_images", lambda q, n=6: [ + {"image": "https://img/1.jpg", "thumbnail": "https://img/1t.jpg", + "source": "https://a.example", "title": "one"}]) + d = ws.rich("quantum computing") + assert d["query"] == "quantum computing" + assert d["images"][0]["image"].endswith("1.jpg") + assert d["article"]["url"] == "https://a.example/post" + assert d["article"]["image"] == "https://img/1.jpg" + assert "Web results" in d["text"] + + +def test_toolbox_web_search_sets_last_media(monkeypatch): + monkeypatch.setattr(ws, "rich", lambda q: { + "query": q, "text": "the text for the model", + "images": [{"image": "x", "thumbnail": "x", "source": "s", "title": "t"}], + "article": {"title": "T", "url": "https://u", "summary": "s", "image": "x"}}) + tb = ToolBox() + out = tb._t_web_search({"query": "cats"}) + assert out == "the text for the model" # model gets the text + assert tb.last_media["type"] == "search" + assert tb.last_media["article"]["url"] == "https://u" # HUD gets the visuals + assert len(tb.last_media["images"]) == 1 + + +def test_toolbox_read_article_defaults_to_last_article(monkeypatch): + captured = {} + + def fake_read(url): + captured["url"] = url + return "READ" + + monkeypatch.setattr(ws, "read_article", fake_read) + tb = ToolBox() + tb.last_media = {"article": {"url": "https://last-article"}} + assert tb._t_read_article({}) == "READ" # no url → uses last article + assert captured["url"] == "https://last-article" diff --git a/requirements.txt b/requirements.txt index d77d35b..95f1d3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,11 @@ uvicorn[standard]>=0.29 pydantic>=2.6 psutil>=5.9 # live system memory on macOS/Linux (System panel) anthropic>=0.40 # optional at runtime — offline mode works without a key -httpx>=0.27 # used by the test client +httpx>=0.27 # used by the test client + web/article fetching + +# Web search + visual results (no API key — DuckDuckGo) +ddgs>=9.0 # web search: text results + related images +lxml>=5.0 # article reader: extract a page's main text for TLDR/read-aloud # dev / test pytest>=8.0