Files

267 lines
9.8 KiB
Python

import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
VERSION_PATTERN = re.compile(r'\bv?\d+\.\d+[\.\d]*\b')
VERSION_CONTEXT = re.compile(r'\b(latest|stable|current|released?|new version)\b', re.IGNORECASE)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [sam] %(message)s")
logger = logging.getLogger("sam")
app = FastAPI(title="sam-research", version="0.1.0")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://172.27.40.20:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4")
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://searxng:8080")
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
HISTORY_FILE = SITES_DIR / "sam" / "history.json"
MAX_HISTORY = 10
def load_history() -> list[dict]:
if HISTORY_FILE.exists():
try:
return json.loads(HISTORY_FILE.read_text())
except Exception:
pass
return []
def save_history(entry: dict):
history = load_history()
history.insert(0, entry)
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text(json.dumps(history[:MAX_HISTORY], indent=2))
async def search_web(query: str, max_results: int) -> list[dict]:
params = {"q": query, "format": "json", "categories": "general", "language": "en"}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{SEARXNG_URL}/search", params=params)
r.raise_for_status()
data = r.json()
return [
{"title": item.get("title", ""), "url": item.get("url", ""), "snippet": item.get("content", "")}
for item in data.get("results", [])[:max_results]
]
def _has_version(results: list[dict]) -> bool:
for r in results:
text = r.get("snippet", "") + " " + r.get("title", "")
if VERSION_PATTERN.search(text) and VERSION_CONTEXT.search(text):
return True
return False
async def search_with_retry(query: str, max_results: int) -> tuple[list[dict], bool]:
"""Search SearXNG, retrying with a refined query if no version number found in results.
Returns (results, retried)."""
results = await search_web(query, max_results)
if _has_version(results):
return results, False
retry_query = query + " latest release version changelog"
logger.info(f"no version found in results — retrying: '{retry_query}'")
retry_results = await search_web(retry_query, max_results)
seen_urls = {r["url"] for r in results}
merged = results + [r for r in retry_results if r["url"] not in seen_urls]
return merged[:max_results], True
async def ollama_synthesize(prompt: str, model: str) -> str:
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
}
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)
r.raise_for_status()
return r.json()["message"]["content"]
def build_prompt(query: str, results: list[dict], instructions: str | None) -> str:
lines = [f"You are a research assistant. The user wants information on: {query}\n"]
if instructions:
lines.append(f"Instructions: {instructions}\n")
lines.append("Web search results:\n")
for i, r in enumerate(results, 1):
lines.append(f"{i}. **{r['title']}**")
lines.append(f" URL: {r['url']}")
if r["snippet"]:
lines.append(f" {r['snippet']}")
lines.append("")
lines.append(
"Based on these results, provide a thorough answer. "
"Follow any format instructions given above. "
"Cite sources by referencing their URLs inline."
)
return "\n".join(lines)
def render_html(history: list[dict]) -> str:
now_iso = datetime.now(timezone.utc).isoformat()
rows = ""
for entry in history:
ts = entry.get("timestamp", "")
ts_span = f'<span data-utc="{ts}"></span>' if ts else ""
query = entry.get("query", "")
model = entry.get("model", "")
result_len = entry.get("summary_chars", 0)
rows += f"""
<tr>
<td>{ts_span}</td>
<td class="query">{query}</td>
<td class="dim">{model}</td>
<td class="dim">{result_len} chars</td>
</tr>"""
empty = "<tr><td colspan='4' class='dim'>No research runs yet.</td></tr>" if not rows else rows
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sam — Research</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{ --bg: #0d1117; --surface: #161b22; --border: #30363d; --dim: #21262d; --text: #e6edf3; --muted: #8b949e; }}
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 15px; line-height: 1.6; }}
.site-nav {{ background: var(--surface); border-bottom: 1px solid var(--border); padding: 0.7rem 2rem; display: flex; align-items: center; gap: 0.6rem; position: sticky; top: 0; z-index: 10; }}
.nav-brand {{ color: #58a6ff; font-weight: 600; font-size: 0.9rem; text-decoration: none; }}
.nav-brand:hover {{ color: var(--text); }}
.nav-sep {{ color: var(--border); }}
.nav-title {{ color: var(--text); font-weight: 500; font-size: 0.9rem; }}
.nav-right {{ margin-left: auto; }}
.nav-back {{ color: var(--muted); font-size: 0.85rem; text-decoration: none; }}
.nav-back:hover {{ color: var(--text); }}
.main {{ max-width: 1100px; margin: 0 auto; padding: 2.5rem 2rem 4rem; }}
h1 {{ font-size: 1.5rem; font-weight: 600; color: var(--text); margin-bottom: 0.2rem; }}
h2 {{ color: var(--muted); font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; margin: 2rem 0 0.875rem; }}
.desc {{ color: var(--muted); font-size: 0.9rem; margin: 0.3rem 0 0.2rem; }}
.meta {{ color: var(--muted); font-size: 0.82rem; margin-top: 0.2rem; margin-bottom: 0; }}
.meta a {{ color: var(--muted); text-decoration: none; }}
.meta a:hover {{ color: var(--text); }}
table {{ width: 100%; border-collapse: collapse; }}
td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--dim); font-size: 0.875rem; }}
.query {{ color: var(--text); }}
.dim {{ color: var(--muted); }}
</style>
</head>
<body>
<nav class="site-nav">
<a class="nav-brand" href="/">◈ NxM</a>
<span class="nav-sep">·</span>
<span class="nav-title">Sam — Research Agent</span>
<span class="nav-right"><a class="nav-back" href="/">← home</a></span>
</nav>
<main class="main">
<h1>Sam — Research Agent</h1>
<p class="desc">Web search via SearXNG + synthesis via Ollama. POST /research to use.</p>
<p class="meta">Updated <span data-utc="{now_iso}"></span></p>
<h2>Recent Queries</h2>
<table><tbody>{empty}</tbody></table>
<script>
document.querySelectorAll('[data-utc]').forEach(el => {{
el.textContent = new Date(el.dataset.utc).toLocaleString(undefined, {{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}});
}});
</script>
</main>
</body>
</html>"""
class ResearchRequest(BaseModel):
query: str
max_results: int = 5
instructions: str | None = None
model: str | None = None
@app.get("/health")
def health():
return {
"status": "ok",
"agent": "sam-research",
"searxng_url": SEARXNG_URL,
"ollama_url": OLLAMA_URL,
"model": OLLAMA_MODEL,
}
@app.post("/research")
async def research(req: ResearchRequest):
model = req.model or OLLAMA_MODEL
timestamp = datetime.now(timezone.utc).isoformat()
logger.info(f"research: query='{req.query}' max_results={req.max_results} model={model}")
try:
results, retried = await search_with_retry(req.query, req.max_results)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"SearXNG error: {e}")
logger.info(f"got {len(results)} results from SearXNG (retried={retried})")
prompt = build_prompt(req.query, results, req.instructions)
try:
summary = await ollama_synthesize(prompt, model)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
logger.info(f"synthesis complete: {len(summary)} chars")
out_dir = SITES_DIR / "sam"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "last-output.md").write_text(
f"# Sam Research — {req.query}\n\n"
f"_Query: {req.query}_ \n"
f"_Model: {model}_ \n"
f"_Results: {len(results)}_ \n"
f"_Timestamp: {timestamp}_\n\n"
f"---\n\n{summary}"
)
history_entry = {
"timestamp": timestamp,
"query": req.query,
"model": model,
"summary_chars": len(summary),
"retried": retried,
}
save_history(history_entry)
(out_dir / "index.html").write_text(render_html(load_history()))
log_dir = AGENT_OS_DIR / "logs" / "sam-research"
log_dir.mkdir(parents=True, exist_ok=True)
(log_dir / "last-run.json").write_text(json.dumps({
"agent": "sam-research",
"timestamp": timestamp,
"status": "success",
"result": f"query='{req.query}' results={len(results)} summary={len(summary)} chars",
}, indent=2))
return {
"query": req.query,
"results": results,
"summary": summary,
"model": model,
"timestamp": timestamp,
"retried": retried,
}