Merge pull request 'feat(search): agent-consumption layer — dedupe, filter, rerank, extract content' (#138) from feat/search-agent-consumption-20260926 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 10s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 0s
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 10s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 0s
This commit was merged in pull request #138.
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
# Search ranking policy for the agent-consumption layer.
|
||||||
|
#
|
||||||
|
# Everything here is CONFIG, not code, so it is reviewable and changeable without
|
||||||
|
# touching the module. Read by scripts/search-agent-consume.py.
|
||||||
|
#
|
||||||
|
# Why this file exists: multi-engine aggregation returns results with no
|
||||||
|
# filtering, no dedupe and no reranking. On 2026-09-26 that put a shopping page
|
||||||
|
# and a dictionary definition into "best practices agent context management",
|
||||||
|
# and put four SEO blogs ABOVE the actual Proxmox forum threads on a precise
|
||||||
|
# technical query. Identical queries also ranked differently between runs, which
|
||||||
|
# is the strongest argument for a deterministic layer rather than hoping the
|
||||||
|
# engines behave.
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
# ── Non-answers: dropped outright, never returned ────────────────────────────
|
||||||
|
# These are pages that cannot answer a question: navigational homepages,
|
||||||
|
# shopping/product pages, dictionary definitions, and login walls.
|
||||||
|
non_answer:
|
||||||
|
# URL path is empty -> it is a site's front door, not an answer. Still allowed
|
||||||
|
# when the host is explicitly preferred (see prefer_domains), because some
|
||||||
|
# docs/repo front doors ARE the answer.
|
||||||
|
host_root: true
|
||||||
|
path_patterns:
|
||||||
|
- '/dictionary/'
|
||||||
|
- '/dictionary?'
|
||||||
|
- '/wiki/Wiktionary:'
|
||||||
|
- '/search?'
|
||||||
|
- '/cart'
|
||||||
|
- '/checkout'
|
||||||
|
- '/login'
|
||||||
|
- '/signin'
|
||||||
|
- '/sign-in'
|
||||||
|
- '/account/login'
|
||||||
|
- '/shop/'
|
||||||
|
- '/store/'
|
||||||
|
- '/dp/' # Amazon-style product URL
|
||||||
|
- '/gp/product/'
|
||||||
|
- '/add-to-cart'
|
||||||
|
- '/checkout'
|
||||||
|
# NOTE: '/products/' and '/product/' were REMOVED as path patterns. They fired
|
||||||
|
# on docs.digitalocean.com/products/inference/... — a legitimate documentation
|
||||||
|
# page — which the 2026-09-26 before/after run caught. Shopping is caught by
|
||||||
|
# the shopping HOST list instead, which does not have that false positive.
|
||||||
|
# Query strings that betray a search/shopping surface rather than an article.
|
||||||
|
query_keys:
|
||||||
|
- 'q'
|
||||||
|
- 'query'
|
||||||
|
- 's'
|
||||||
|
- 'search'
|
||||||
|
- 'add-to-cart'
|
||||||
|
# Hosts that are shopping/retail and never answer a technical question.
|
||||||
|
hosts:
|
||||||
|
- bestbuy.com
|
||||||
|
- amazon.com
|
||||||
|
- ebay.com
|
||||||
|
- walmart.com
|
||||||
|
- etsy.com
|
||||||
|
- aliexpress.com
|
||||||
|
- merriam-webster.com
|
||||||
|
- dictionary.com
|
||||||
|
- thesaurus.com
|
||||||
|
- vocabulary.com
|
||||||
|
- collinsdictionary.com
|
||||||
|
|
||||||
|
# ── Demotion: ranked below everything else, never dropped ────────────────────
|
||||||
|
# Low-authority content farms / SEO aggregators. Demoted rather than dropped so
|
||||||
|
# a genuinely useful hit is not lost, but it can never outrank a primary source.
|
||||||
|
# Reviewable: add or remove hosts here, no code change required.
|
||||||
|
demote_domains:
|
||||||
|
- medium.com
|
||||||
|
- sparkco.ai
|
||||||
|
- mindstudio.ai
|
||||||
|
- aitechmonk.com
|
||||||
|
- stackai.com
|
||||||
|
- agentic-design.ai
|
||||||
|
- voxfor.com
|
||||||
|
- bigiron.cc
|
||||||
|
- linuxoperatingsystem.net
|
||||||
|
- riparazioneserver.com
|
||||||
|
- rossmanngroup.com
|
||||||
|
- dev.to
|
||||||
|
- hashnode.dev
|
||||||
|
- substack.com
|
||||||
|
- towardsdatascience.com
|
||||||
|
- analyticsvidhya.com
|
||||||
|
- geeksforgeeks.org
|
||||||
|
- tutorialspoint.com
|
||||||
|
- javatpoint.com
|
||||||
|
- w3schools.com
|
||||||
|
- scaler.com
|
||||||
|
- simplilearn.com
|
||||||
|
- udemy.com
|
||||||
|
- coursera.org
|
||||||
|
|
||||||
|
# ── Preference: promoted above the default rank ──────────────────────────────
|
||||||
|
# Primary sources: upstream repositories, official docs, Q&A, vendor
|
||||||
|
# engineering blogs. These are what an agent should be reading.
|
||||||
|
prefer_domains:
|
||||||
|
# upstream repositories and code hosting
|
||||||
|
- github.com
|
||||||
|
- gitlab.com
|
||||||
|
- codeberg.org
|
||||||
|
- sourceforge.net
|
||||||
|
- kernel.org
|
||||||
|
- git.kernel.org
|
||||||
|
# Q&A
|
||||||
|
- stackoverflow.com
|
||||||
|
- stackexchange.com
|
||||||
|
- superuser.com
|
||||||
|
- serverfault.com
|
||||||
|
- askubuntu.com
|
||||||
|
- discourse.org
|
||||||
|
# vendor / project documentation and forums
|
||||||
|
- proxmox.com
|
||||||
|
- forum.proxmox.com
|
||||||
|
- pve.proxmox.com
|
||||||
|
- docs.python.org
|
||||||
|
- developer.mozilla.org
|
||||||
|
- kernelnewbies.org
|
||||||
|
- man7.org
|
||||||
|
- gnu.org
|
||||||
|
- debian.org
|
||||||
|
- ubuntu.com
|
||||||
|
- redhat.com
|
||||||
|
- kernel.dk # io_uring / Jens Axboe
|
||||||
|
- github.io # project pages (docs, papers) — promoted, not authoritative by itself
|
||||||
|
# vendor engineering blogs
|
||||||
|
- anthropic.com
|
||||||
|
- openai.com
|
||||||
|
- googleblog.com
|
||||||
|
- developers.googleblog.com
|
||||||
|
- engineering.fb.com
|
||||||
|
- netflixtechblog.com
|
||||||
|
- aws.amazon.com
|
||||||
|
- cloud.google.com
|
||||||
|
- microsoft.com
|
||||||
|
- learn.microsoft.com
|
||||||
|
- apple.com
|
||||||
|
- nvidia.com
|
||||||
|
- intel.com
|
||||||
|
- amd.com
|
||||||
|
- redislabs.com
|
||||||
|
- cloudflare.com
|
||||||
|
- langchain.com
|
||||||
|
- jetbrains.com
|
||||||
|
- cursor.com
|
||||||
|
# community discussion with high signal
|
||||||
|
- news.ycombinator.com
|
||||||
|
- lobste.rs
|
||||||
|
- reddit.com
|
||||||
|
|
||||||
|
# ── Ranking weights ──────────────────────────────────────────────────────────
|
||||||
|
# Final score = engine_score - demote_penalty + prefer_bonus, then a stable
|
||||||
|
# tiebreak on original position so ordering is reproducible run to run.
|
||||||
|
ranking:
|
||||||
|
demote_penalty: 1000
|
||||||
|
prefer_bonus: 100
|
||||||
|
# Results that several engines independently returned are more likely real.
|
||||||
|
multi_engine_bonus: 25
|
||||||
|
# Shallow paths (e.g. /blog/x) are slightly less likely to be primary docs.
|
||||||
|
host_root_allowed_when_preferred: true
|
||||||
|
|
||||||
|
# ── Extraction budget (criterion 4) ──────────────────────────────────────────
|
||||||
|
# Return CONTENT, not just links, so an agent gets usable material in ONE call.
|
||||||
|
extraction:
|
||||||
|
top_n: 5 # how many results get page text extracted
|
||||||
|
total_chars: 12000 # global budget across all extracted items
|
||||||
|
per_item_chars: 4000 # cap for any single item, so one page cannot eat the budget
|
||||||
|
timeout_seconds: 45 # per scrape
|
||||||
|
# If extraction fails, the result is still returned with an empty excerpt —
|
||||||
|
# a link is better than nothing, but the failure is recorded in the output.
|
||||||
|
on_failure: keep_with_empty_excerpt
|
||||||
Executable
+395
@@ -0,0 +1,395 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Agent-consumption layer in front of SearXNG + Firecrawl.
|
||||||
|
|
||||||
|
Multi-engine aggregation returns results with no dedupe, no filtering and no
|
||||||
|
reranking. Measured 2026-09-26 that put bestbuy.com and merriam-webster.com into
|
||||||
|
"best practices agent context management", and put four SEO blogs ABOVE the real
|
||||||
|
Proxmox forum threads on a precise technical query. Identical queries also ranked
|
||||||
|
differently between runs, so the fix has to be deterministic rather than
|
||||||
|
dependent on engine mood.
|
||||||
|
|
||||||
|
This module turns the raw result list into something an agent can actually use:
|
||||||
|
|
||||||
|
1. DEDUPE the same page arriving from several engines
|
||||||
|
2. DROP clear non-answers (homepages, shopping, dictionaries, logins)
|
||||||
|
3. DEMOTE config-listed low-authority hosts; PROMOTE primary sources
|
||||||
|
4. STABLE SORT so ordering is reproducible run to run
|
||||||
|
5. EXTRACT page text for the top N under an explicit character budget,
|
||||||
|
so one call returns usable material instead of a snippet
|
||||||
|
6. EMIT stable JSON with engine provenance
|
||||||
|
|
||||||
|
Policy lives in config/search-ranking.yaml, not in this file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
search-agent-consume.py "query text" # JSON to stdout
|
||||||
|
search-agent-consume.py --no-extract "query" # ranking only, no Firecrawl
|
||||||
|
search-agent-consume.py --explain "query" # include drop/demote reasons
|
||||||
|
|
||||||
|
Exit: 0 ok, 1 no results survived filtering, 2 the layer could not run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://192.168.68.7:8888").rstrip("/")
|
||||||
|
FIRECRAWL_URL = os.environ.get("FIRECRAWL_URL", "http://192.168.68.7:3002").rstrip("/")
|
||||||
|
CONFIG_PATH = os.environ.get(
|
||||||
|
"SEARCH_RANKING_CONFIG",
|
||||||
|
str(Path(__file__).resolve().parent.parent / "config" / "search-ranking.yaml"),
|
||||||
|
)
|
||||||
|
HTTP_TIMEOUT = float(os.environ.get("SEARCH_CONSUME_TIMEOUT", "25"))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Load the ranking policy.
|
||||||
|
|
||||||
|
PyYAML is used when present; otherwise a tiny built-in parser handles the
|
||||||
|
flat lists in this specific file, so the layer never hard-fails on a host
|
||||||
|
without PyYAML.
|
||||||
|
"""
|
||||||
|
text = Path(CONFIG_PATH).read_text()
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
|
||||||
|
return yaml.safe_load(text)
|
||||||
|
except ImportError:
|
||||||
|
return _parse_flat_yaml(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_flat_yaml(text: str) -> dict:
|
||||||
|
"""Minimal fallback parser: top-level keys, nested one level, flat lists."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
stack: list[tuple[int, dict]] = [(-1, out)]
|
||||||
|
section: dict | None = None
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.split("#", 1)[0].rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
body = line.strip()
|
||||||
|
if body.startswith("- "):
|
||||||
|
if section is not None:
|
||||||
|
section.setdefault("_list", []).append(
|
||||||
|
body[2:].strip().strip("'\"")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if ":" in body:
|
||||||
|
key, _, val = body.partition(":")
|
||||||
|
key, val = key.strip(), val.strip()
|
||||||
|
if val:
|
||||||
|
# write to the INNERMOST open section, not the document root
|
||||||
|
stack[-1][1][key] = _scalar(val)
|
||||||
|
section = None
|
||||||
|
else:
|
||||||
|
while stack and indent <= stack[-1][0]:
|
||||||
|
stack.pop()
|
||||||
|
parent = stack[-1][1]
|
||||||
|
new: dict = {}
|
||||||
|
parent[key] = new
|
||||||
|
stack.append((indent, new))
|
||||||
|
section = new
|
||||||
|
# flatten "_list" holders back into their parent as plain lists
|
||||||
|
def fix(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
if set(node.keys()) == {"_list"}:
|
||||||
|
return node["_list"]
|
||||||
|
return {k: fix(v) for k, v in node.items()}
|
||||||
|
return node
|
||||||
|
|
||||||
|
return fix(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(v: str):
|
||||||
|
if v.lower() in ("true", "false"):
|
||||||
|
return v.lower() == "true"
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return v.strip("'\"")
|
||||||
|
|
||||||
|
|
||||||
|
# ── filtering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _host(url: str) -> str:
|
||||||
|
return (urllib.parse.urlparse(url).netloc or "").lower().split(":")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _registrable(host: str) -> str:
|
||||||
|
"""Best-effort registrable domain so sub.forum.proxmox.com matches proxmox.com."""
|
||||||
|
parts = host.split(".")
|
||||||
|
if len(parts) <= 2:
|
||||||
|
return host
|
||||||
|
# handle common two-label public suffixes
|
||||||
|
two = ".".join(parts[-2:])
|
||||||
|
if parts[-2] in ("co", "com", "org", "net", "ac", "gov") and len(parts) >= 3:
|
||||||
|
return ".".join(parts[-3:])
|
||||||
|
return two
|
||||||
|
|
||||||
|
|
||||||
|
def _host_in(host: str, domains) -> bool:
|
||||||
|
if not domains:
|
||||||
|
return False
|
||||||
|
reg = _registrable(host)
|
||||||
|
for d in domains:
|
||||||
|
d = str(d).lower()
|
||||||
|
if host == d or host.endswith("." + d) or reg == d:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_url(url: str) -> str:
|
||||||
|
"""Strip tracking params and fragments so the same page dedupes."""
|
||||||
|
p = urllib.parse.urlparse(url)
|
||||||
|
q = [
|
||||||
|
(k, v)
|
||||||
|
for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True)
|
||||||
|
if not k.lower().startswith(("utm_", "fbclid", "gclid", "mc_", "ref"))
|
||||||
|
]
|
||||||
|
path = p.path.rstrip("/") or "/"
|
||||||
|
return urllib.parse.urlunparse(
|
||||||
|
(p.scheme.lower(), p.netloc.lower(), path, "", urllib.parse.urlencode(q), "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def non_answer_reason(result: dict, cfg: dict) -> str | None:
|
||||||
|
"""Return why this result is a non-answer, or None if it may be returned."""
|
||||||
|
na = cfg.get("non_answer", {}) or {}
|
||||||
|
url = result.get("url", "")
|
||||||
|
p = urllib.parse.urlparse(url)
|
||||||
|
host = _host(url)
|
||||||
|
path = p.path or ""
|
||||||
|
|
||||||
|
if _host_in(host, na.get("hosts")):
|
||||||
|
return "shopping_or_dictionary_host"
|
||||||
|
|
||||||
|
if na.get("host_root", True) and path in ("", "/"):
|
||||||
|
# A preferred host's front door may legitimately be the answer
|
||||||
|
# (a repo, a docs site). Everything else is navigational.
|
||||||
|
if not _host_in(host, cfg.get("prefer_domains")):
|
||||||
|
return "navigational_host_root"
|
||||||
|
|
||||||
|
low = url.lower()
|
||||||
|
for pat in na.get("path_patterns", []) or []:
|
||||||
|
if pat.lower() in low:
|
||||||
|
return f"path_pattern:{pat}"
|
||||||
|
|
||||||
|
qkeys = {k.lower() for k in (na.get("query_keys") or [])}
|
||||||
|
if qkeys & {k.lower() for k, _ in urllib.parse.parse_qsl(p.query)}:
|
||||||
|
return "search_or_shopping_query"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def source_type(url: str, cfg: dict) -> str:
|
||||||
|
host = _host(url)
|
||||||
|
if _host_in(host, ["github.com", "gitlab.com", "codeberg.org", "sourceforge.net"]):
|
||||||
|
return "code"
|
||||||
|
if _host_in(host, ["stackoverflow.com", "stackexchange.com", "superuser.com",
|
||||||
|
"serverfault.com", "askubuntu.com"]):
|
||||||
|
return "qa"
|
||||||
|
if _host_in(host, ["forum.proxmox.com", "forum.", "discourse"]) or "forum." in host:
|
||||||
|
return "forum"
|
||||||
|
if _host_in(host, ["news.ycombinator.com", "lobste.rs", "reddit.com"]):
|
||||||
|
return "discussion"
|
||||||
|
if _host_in(host, cfg.get("prefer_domains")):
|
||||||
|
return "official"
|
||||||
|
if _host_in(host, cfg.get("demote_domains")):
|
||||||
|
return "content-farm"
|
||||||
|
return "web"
|
||||||
|
|
||||||
|
|
||||||
|
def rank(results: list[dict], cfg: dict) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Dedupe, drop non-answers, demote/ promote, stable sort.
|
||||||
|
|
||||||
|
Returns (kept, dropped) where dropped carries the reason, because a filter
|
||||||
|
nobody can audit is a filter nobody should trust.
|
||||||
|
"""
|
||||||
|
rank_cfg = cfg.get("ranking", {}) or {}
|
||||||
|
demote_pen = float(rank_cfg.get("demote_penalty", 1000))
|
||||||
|
prefer_bonus = float(rank_cfg.get("prefer_bonus", 100))
|
||||||
|
multi_bonus = float(rank_cfg.get("multi_engine_bonus", 25))
|
||||||
|
|
||||||
|
seen: dict[str, dict] = {}
|
||||||
|
dropped: list[dict] = []
|
||||||
|
|
||||||
|
for pos, r in enumerate(results):
|
||||||
|
url = r.get("url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
key = _normalise_url(url)
|
||||||
|
engine = r.get("engine", "?")
|
||||||
|
|
||||||
|
# 1. dedupe: same normalised URL from several engines
|
||||||
|
if key in seen:
|
||||||
|
seen[key].setdefault("engines", []).append(engine)
|
||||||
|
seen[key]["duplicate_of"] = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
reason = non_answer_reason(r, cfg)
|
||||||
|
if reason:
|
||||||
|
dropped.append({"url": url, "reason": reason, "position": pos + 1})
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen[key] = {
|
||||||
|
"title": (r.get("title") or "").strip(),
|
||||||
|
"url": url,
|
||||||
|
"engines": [engine],
|
||||||
|
"position": pos,
|
||||||
|
"score": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
kept = []
|
||||||
|
for item in seen.values():
|
||||||
|
host = _host(item["url"])
|
||||||
|
score = -float(item["position"]) # original order is the base signal
|
||||||
|
if _host_in(host, cfg.get("demote_domains")):
|
||||||
|
score -= demote_pen
|
||||||
|
if _host_in(host, cfg.get("prefer_domains")):
|
||||||
|
score += prefer_bonus
|
||||||
|
if len(item["engines"]) > 1:
|
||||||
|
score += multi_bonus * (len(item["engines"]) - 1)
|
||||||
|
item["score"] = round(score, 2)
|
||||||
|
item["host"] = host
|
||||||
|
item["source_type"] = source_type(item["url"], cfg)
|
||||||
|
kept.append(item)
|
||||||
|
|
||||||
|
# stable: score desc, then original position asc => reproducible run to run
|
||||||
|
kept.sort(key=lambda i: (-i["score"], i["position"]))
|
||||||
|
return kept, dropped
|
||||||
|
|
||||||
|
|
||||||
|
# ── extraction ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _post_json(url: str, payload: dict, timeout: float) -> dict:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def extract(items: list[dict], cfg: dict) -> dict:
|
||||||
|
"""Fetch page text for the top N under a global character budget."""
|
||||||
|
ex = cfg.get("extraction", {}) or {}
|
||||||
|
top_n = int(ex.get("top_n", 5))
|
||||||
|
total_budget = int(ex.get("total_chars", 12000))
|
||||||
|
per_item = int(ex.get("per_item_chars", 4000))
|
||||||
|
timeout = float(ex.get("timeout_seconds", 45))
|
||||||
|
|
||||||
|
used = 0
|
||||||
|
failures = 0
|
||||||
|
t0 = time.time()
|
||||||
|
for item in items[:top_n]:
|
||||||
|
remaining = total_budget - used
|
||||||
|
if remaining <= 200:
|
||||||
|
item["excerpt"] = ""
|
||||||
|
item["extraction"] = "skipped_budget_exhausted"
|
||||||
|
continue
|
||||||
|
cap = min(per_item, remaining)
|
||||||
|
try:
|
||||||
|
data = _post_json(
|
||||||
|
f"{FIRECRAWL_URL}/v1/scrape",
|
||||||
|
{"url": item["url"], "formats": ["markdown"]},
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
md = ((data.get("data") or {}).get("markdown") or "").strip()
|
||||||
|
if not md:
|
||||||
|
item["excerpt"] = ""
|
||||||
|
item["extraction"] = "empty"
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
item["excerpt"] = md[:cap]
|
||||||
|
item["extraction"] = "ok" if len(md) <= cap else "truncated"
|
||||||
|
used += len(item["excerpt"])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
item["excerpt"] = ""
|
||||||
|
item["extraction"] = f"failed:{type(exc).__name__}"
|
||||||
|
failures += 1
|
||||||
|
return {
|
||||||
|
"extracted": min(top_n, len(items)),
|
||||||
|
"chars_used": used,
|
||||||
|
"budget": total_budget,
|
||||||
|
"failures": failures,
|
||||||
|
"seconds": round(time.time() - t0, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── entry point ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def consume(query: str, do_extract: bool = True, explain: bool = False) -> dict:
|
||||||
|
cfg = _load_config()
|
||||||
|
url = f"{SEARXNG_URL}/search?" + urllib.parse.urlencode(
|
||||||
|
{"q": query, "format": "json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as resp:
|
||||||
|
raw = json.loads(resp.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
results = raw.get("results", [])
|
||||||
|
kept, dropped = rank(results, cfg)
|
||||||
|
extraction = extract(kept, cfg) if do_extract else None
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"query": query,
|
||||||
|
"raw_result_count": len(results),
|
||||||
|
"returned_count": len(kept),
|
||||||
|
"dropped_count": len(dropped),
|
||||||
|
"engines": sorted({r.get("engine", "?") for r in results}),
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"rank": i + 1,
|
||||||
|
"title": it["title"],
|
||||||
|
"url": it["url"],
|
||||||
|
"host": it["host"],
|
||||||
|
"source_type": it["source_type"],
|
||||||
|
"engines": sorted(set(it["engines"])),
|
||||||
|
"score": it["score"],
|
||||||
|
"excerpt": it.get("excerpt", ""),
|
||||||
|
"extraction": it.get("extraction", "not_attempted"),
|
||||||
|
}
|
||||||
|
for i, it in enumerate(kept)
|
||||||
|
],
|
||||||
|
"extraction": extraction,
|
||||||
|
}
|
||||||
|
if explain:
|
||||||
|
out["dropped"] = dropped
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
do_extract = "--no-extract" not in sys.argv
|
||||||
|
explain = "--explain" in sys.argv
|
||||||
|
if not args:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
query = " ".join(args)
|
||||||
|
try:
|
||||||
|
out = consume(query, do_extract=do_extract, explain=explain)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"LAYER FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(json.dumps(out, indent=2))
|
||||||
|
return 0 if out["returned_count"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -114,6 +114,79 @@ def unresponsive_names(pairs: list) -> dict[str, str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── QUALITY GUARD (search-agent-consumption) ─────────────────────────────────
|
||||||
|
# The agent-consumption layer applies a deterministic demote/drop policy. Without
|
||||||
|
# an assertion here it could silently rot back to raw engine ordering - the same
|
||||||
|
# way the endpoint colours silently rotted before 2026-09-26.
|
||||||
|
QUALITY_QUERIES = [
|
||||||
|
"best practices agent context management",
|
||||||
|
"proxmox thin pool metadata exhaustion recovery",
|
||||||
|
]
|
||||||
|
# A demoted (content-farm) host must never occupy the top 3 for these queries.
|
||||||
|
QUALITY_TOP_N = 3
|
||||||
|
# Non-answers that must never be returned for these queries at all.
|
||||||
|
QUALITY_BANNED_HOSTS = ["bestbuy.com", "merriam-webster.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def _consumption_layer_path():
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
return os.path.join(here, "search-agent-consume.py")
|
||||||
|
|
||||||
|
|
||||||
|
def check_ranking_quality() -> list[str]:
|
||||||
|
"""Return a list of quality failures; empty means healthy."""
|
||||||
|
import subprocess as _sp
|
||||||
|
|
||||||
|
layer = _consumption_layer_path()
|
||||||
|
if not os.path.exists(layer):
|
||||||
|
return [f"agent-consumption layer missing: {layer}"]
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
for query in QUALITY_QUERIES:
|
||||||
|
r = _sp.run([sys.executable, layer, "--no-extract", "--explain", query],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
if r.returncode != 0:
|
||||||
|
failures.append(f"{query!r}: layer exited {r.returncode} ({r.stderr[:120]})")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
failures.append(f"{query!r}: layer returned unparseable JSON")
|
||||||
|
continue
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
if len(results) < QUALITY_TOP_N:
|
||||||
|
failures.append(f"{query!r}: only {len(results)} results returned")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# load the demote list from the SAME config the layer uses
|
||||||
|
cfg_path = os.path.join(os.path.dirname(layer), "..", "config", "search-ranking.yaml")
|
||||||
|
demoted: set[str] = set()
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, os.path.dirname(layer))
|
||||||
|
import importlib.util as _iu
|
||||||
|
spec = _iu.spec_from_file_location("_sac_cfg", layer)
|
||||||
|
mod = _iu.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
demoted = set(mod._load_config().get("demote_domains", []) or [])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
failures.append(f"{query!r}: could not load demote_domains from config")
|
||||||
|
|
||||||
|
for item in results[:QUALITY_TOP_N]:
|
||||||
|
host = (item.get("host") or "")
|
||||||
|
for d in demoted:
|
||||||
|
if host == d or host.endswith("." + d):
|
||||||
|
failures.append(
|
||||||
|
f"{query!r}: demoted host {host} in top {QUALITY_TOP_N}"
|
||||||
|
)
|
||||||
|
for item in results:
|
||||||
|
host = (item.get("host") or "")
|
||||||
|
for b in QUALITY_BANNED_HOSTS:
|
||||||
|
if host == b or host.endswith("." + b):
|
||||||
|
failures.append(f"{query!r}: non-answer host {host} returned")
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
print(f"Search stack check -- {SEARXNG_URL}")
|
print(f"Search stack check -- {SEARXNG_URL}")
|
||||||
@@ -216,6 +289,16 @@ def main() -> int:
|
|||||||
print(f" FAIL: {msg}")
|
print(f" FAIL: {msg}")
|
||||||
failures.append(msg)
|
failures.append(msg)
|
||||||
|
|
||||||
|
print("-" * 72)
|
||||||
|
print("RANKING QUALITY (agent-consumption layer)")
|
||||||
|
quality = check_ranking_quality()
|
||||||
|
if quality:
|
||||||
|
for q in quality:
|
||||||
|
print(f" FAIL: {q}")
|
||||||
|
failures.extend(quality)
|
||||||
|
else:
|
||||||
|
print(" ok: no demoted host in the top 3; no banned non-answer returned")
|
||||||
|
|
||||||
print("=" * 72)
|
print("=" * 72)
|
||||||
if failures:
|
if failures:
|
||||||
print("VERDICT: FAIL")
|
print("VERDICT: FAIL")
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
kind: function
|
||||||
|
name: search-agent-consumption
|
||||||
|
description: >
|
||||||
|
Agent-consumption layer in front of SearXNG + Firecrawl. Raw multi-engine
|
||||||
|
aggregation returns results with no dedupe, no filtering and no reranking;
|
||||||
|
measured 2026-09-26 that put bestbuy.com and merriam-webster.com into "best
|
||||||
|
practices agent context management", and put four SEO blogs above the real
|
||||||
|
Proxmox forum threads on a precise technical query. Identical queries also
|
||||||
|
ranked DIFFERENTLY between runs, which is why the layer is deterministic
|
||||||
|
rather than dependent on engine behaviour.
|
||||||
|
|
||||||
|
Pipeline: dedupe -> drop non-answers -> demote content farms / promote primary
|
||||||
|
sources -> stable sort -> extract page text for the top N under an explicit
|
||||||
|
character budget -> stable JSON. Policy lives in config, not code.
|
||||||
|
|
||||||
|
Call it when an agent needs search RESULTS rather than links: it returns usable
|
||||||
|
page text in one call instead of a snippet plus a second fetch.
|
||||||
|
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the policy lives
|
||||||
|
|
||||||
|
`config/search-ranking.yaml` — reviewable, no code change needed to adjust:
|
||||||
|
|
||||||
|
| key | effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `non_answer.hosts` / `path_patterns` / `query_keys` / `host_root` | dropped outright |
|
||||||
|
| `demote_domains` | ranked below everything, never dropped |
|
||||||
|
| `prefer_domains` | promoted above default rank |
|
||||||
|
| `ranking.*` | `demote_penalty`, `prefer_bonus`, `multi_engine_bonus` |
|
||||||
|
| `extraction.*` | `top_n`, `total_chars`, `per_item_chars`, `timeout_seconds` |
|
||||||
|
|
||||||
|
**Demotion, not deletion, for content farms**: a genuinely useful hit is not lost,
|
||||||
|
it simply cannot outrank a primary source. Non-answers are dropped because they
|
||||||
|
cannot answer a question at all.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/search-agent-consume.py "query text" # JSON
|
||||||
|
python3 scripts/search-agent-consume.py --no-extract "query" # ranking only
|
||||||
|
python3 scripts/search-agent-consume.py --explain "query" # + drop reasons
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit `0` ok, `1` nothing survived filtering, `2` the layer could not run.
|
||||||
|
|
||||||
|
## Output shape
|
||||||
|
|
||||||
|
Stable JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "...",
|
||||||
|
"raw_result_count": 46,
|
||||||
|
"returned_count": 44,
|
||||||
|
"dropped_count": 2,
|
||||||
|
"engines": ["bing", "brave", "duckduckgo", "yandex"],
|
||||||
|
"results": [
|
||||||
|
{"rank": 1, "title": "...", "url": "...", "host": "...",
|
||||||
|
"source_type": "official|code|qa|forum|discussion|web|content-farm",
|
||||||
|
"engines": ["bing"], "score": 100.0,
|
||||||
|
"excerpt": "...", "extraction": "ok|truncated|skipped_budget_exhausted|empty|failed:<Type>"}
|
||||||
|
],
|
||||||
|
"extraction": {"extracted": 5, "chars_used": 12000, "budget": 12000,
|
||||||
|
"failures": 0, "seconds": 5.28}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`--explain` adds `dropped: [{url, reason, position}]` so the filter is auditable
|
||||||
|
rather than magic.
|
||||||
|
|
||||||
|
## Measured before/after (2026-09-26)
|
||||||
|
|
||||||
|
Fixed query set. Relevance judged per query, not by impression.
|
||||||
|
|
||||||
|
**`best practices agent context management`**
|
||||||
|
|
||||||
|
| | before (raw SearXNG) | after (layer) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1-2 | anthropic, stackai | anthropic, langchain |
|
||||||
|
| 3-4 | aitechmonk, agentic-design | jetbrains, blog.jetbrains |
|
||||||
|
| 5-6 | mindstudio, sparkco | docs.langchain, reddit |
|
||||||
|
| 7-8 | langchain, medium | cursor, reddit |
|
||||||
|
| verdict | 4 relevant of 10; 4 content farms; medium.com twice | top 8 all primary/discussion; no content farm in the top 8 |
|
||||||
|
|
||||||
|
**`proxmox thin pool metadata exhaustion recovery`**
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1-4 | vormox, linuxoperatingsystem, riparazioneserver, bigiron (all SEO/thin) | forum.proxmox.com, forum.proxmox.com, gist.github, github |
|
||||||
|
| 5-9 | forum.proxmox.com x2, voxfor, github, gist | forum.proxmox.com, serverfault, forum.proxmox.com, reddit |
|
||||||
|
|
||||||
|
The primary sources moved from positions 5-9 to 1-4.
|
||||||
|
|
||||||
|
**Rule proof** (`--explain`, and a direct check of the classifier):
|
||||||
|
|
||||||
|
```
|
||||||
|
DigitalOcean docs -> KEEP (a '/products/' path rule was REMOVED after the
|
||||||
|
before/after run caught it dropping this page)
|
||||||
|
Best Buy -> DROP shopping_or_dictionary_host
|
||||||
|
Merriam-Webster -> DROP shopping_or_dictionary_host
|
||||||
|
bare homepage -> DROP navigational_host_root
|
||||||
|
proxmox.com home -> KEEP (preferred host root: a repo/docs front door is
|
||||||
|
legitimately the answer)
|
||||||
|
github repo -> KEEP
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extraction cost (criterion 4):**
|
||||||
|
|
||||||
|
```
|
||||||
|
extracted 5 items, 12000 chars used of 12000 budget, 0 failures, 5.28s
|
||||||
|
whole run end-to-end: 6.4s wall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regression guard
|
||||||
|
|
||||||
|
`search-stack-visibility` asserts the layer still ranks correctly: for the fixed
|
||||||
|
query set, no `demote_domains` host may appear in the top 3, and the two known
|
||||||
|
non-answers must not be returned. Without it this layer could silently rot back
|
||||||
|
to raw ordering, which is exactly what happened to the endpoint colours.
|
||||||
|
|
||||||
|
## Reachability, and one honest gap
|
||||||
|
|
||||||
|
- **Hermes agents** reach it directly: it reads the same `SEARXNG_URL` and
|
||||||
|
`FIRECRAWL_URL` they already use.
|
||||||
|
- **pi agents (MCP search server)**: the MCP server's request/response shape is
|
||||||
|
**not ours to change**, so this layer is **NOT** wired into it. That is a real
|
||||||
|
gap, stated rather than claimed as coverage. Closing it would require a change
|
||||||
|
on the MCP side, which is outside this repo.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
Does not touch the live SearXNG or Firecrawl service paths. Third-party
|
||||||
|
`google cse` is not a hard requirement of this layer — if it 429s, ranking still
|
||||||
|
works from the remaining engines. No credential is added or required.
|
||||||
Reference in New Issue
Block a user