Files
sift/docs/P3.1.md
T
kyle 7f4f0d50bf docs: P3.1 search filters and multi-query merge
Native /v1/search knobs (domains, time, topic, exact match, min_score)
plus multi-query URL dedupe. Tavily passthrough; SearXNG approximations
with warnings. No crawl/map/research. Claude reviews, titan implements.
2026-09-29 15:03:14 -07:00

235 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sift P3.1 — search filters and multi-query merge
Drafted 2026-09-29 by talos from Kyle's ask to steal useful Tavily-cookbook
patterns for sift (thread in Mattermost). Reviewer: Claude. Implementer: titan.
P3 shipped `/v1/search` + `/v2/search` with SearXNG / Brave LLM Context / Tavily
fan-in, canonical-URL dedupe, RRF, cache, and a daily Tavily cap. The Tavily
client hardcodes `search_depth: "basic"` and sends no filters
(`internal/search/tavily.go`). Native search accepts only `query`, `k`,
`rerank`, `digest`.
This phase adds the cookbook's high-leverage search knobs without taking crawl,
map, research, or `include_answer`.
## 1. Scope
In:
- Native filters on `POST /v1/search` and `sift search`.
- Passing those filters through to Tavily when that source is enabled.
- Honest SearXNG approximations where the engine can do it; warnings where it cannot.
- Multi-query search: several focused queries, one URL-deduped result list.
- Optional `min_score` cut after merge.
- `published_date` on a hit when a source supplies it (Tavily news).
- Skill recipe: search → score/domain filter → `sift batch`, do not fetch every hit.
Out:
- `/v2/search` stays the Firecrawl contract (`query`, `limit` only). Unknown
fields ignored. No filters on v2.
- Crawl, map, Tavily `/extract` `/crawl` `/map`, `include_answer`,
`include_raw_content`, `include_images` — P3 §3.3 still holds.
- HTML lexical chunks, same-host map, Research/SSE/JSON-schema — later phases.
- Changing `internal/guard/` or `internal/auth/`.
- Fetching result URLs inside search.
## 2. Native request (`POST /v1/search`)
Existing fields unchanged. Additive:
```json
{
"query": "…",
"queries": ["…", "…"],
"k": 8,
"rerank": true,
"digest": false,
"include_domains": ["openai.com"],
"exclude_domains": ["pinterest.com"],
"include_domains_mode": "restrict",
"time_range": "month",
"start_date": "2026-09-01",
"end_date": "2026-09-29",
"topic": "news",
"exact_match": false,
"min_score": 0
}
```
Validation (400 `invalid query` or 400 `invalid search`):
- Exactly one of `query` (non-empty) or `queries` (1–4 non-empty strings). Both
set, or `queries` longer than 4, is 400. Each string still has P3's 1500-rune cap.
- `include_domains` / `exclude_domains`: hostnames only (no scheme, no path).
Max 20 include, 20 exclude. Empty list = omitted.
- `include_domains_mode`: omitted/`restrict`/`prefer`. Set without
`include_domains` → 400.
- `time_range`: omitted or `day`/`week`/`month`/`year` (and Tavily aliases
`d`/`w`/`m`/`y`, normalised to the long form).
- `start_date` / `end_date`: `YYYY-MM-DD` or omitted. `end` before `start` → 400.
- `topic`: omitted/`general`/`news`/`finance`.
- `min_score`: omitted or `0` = off; otherwise `0 < x ≤ 1`.
- `digest: true` still warns `digest ignored` (P3).
`/v2/search` does not grow. Hermes never sends these fields.
## 3. Response
`/v1/search` results gain optional `"published_date"` (RFC 1123 or ISO date
string as the vendor sent it; omit the key when unknown). Do not invent dates.
`warnings` grows with per-source honesty, e.g. `searxng: exclude_domains ignored`,
`brave_llm: domain filters ignored`.
`sizes` unchanged. `cached` unchanged.
## 4. Tavily client
Still `POST` the configured URL only. Still `include_answer: false`,
`include_raw_content: false`, `include_images: false`, `search_depth: "basic"`.
Still drop inbound `answer`. Still never call `/extract`, `/crawl`, `/map`.
When the request has filters, add only the fields that are set:
- `include_domains`, `exclude_domains`, `include_domains_mode`
- `time_range` or `start_date`/`end_date`
- `topic` when not `general`
- `exact_match` when true
- `include_published_date: true` when `topic` is `news` or a date filter is set
Map `results[].published_date` onto `Hit`. Count `usage.credits` as today.
A fixture test must still prove `include_answer` is false and no extract/crawl
path exists.
## 5. SearXNG and Brave
Do not pretend they support Tavily's full filter set.
**SearXNG** (`GET {url}/search`, same client/dialer as P3):
- One `include_domains` entry: prefix the query with `site:<host> `.
- Several: `site:a OR site:b OR …` up to 3, then warn `searxng: extra include_domains dropped`.
- `exclude_domains`: ignored + warning.
- `include_domains_mode=prefer`: ignored (site: is always restrict) + warning.
- `time_range`: pass SearXNG's `time_range` query param (`day`/`week`/`month`/`year`)
if we already talk to a SearXNG that accepts it; if the live instance 400s in
tests against the fixture, keep the warning path instead of guessing.
- `topic=news`: prefix or `!news` only if the existing SearXNG config actually
has that engine; otherwise warning `searxng: topic ignored`. Default: warning.
- `exact_match`: wrap the query in ASCII double quotes.
- `start_date`/`end_date`: ignored + warning unless SearXNG is proven to accept them.
**Brave LLM Context**: no documented domain/date filters. Ignore all of the new
fields and add one warning `brave_llm: filters ignored` when any filter was set.
Do not add a second Brave HTTP API.
## 6. Multi-query merge
`queries: [q1, q2, …]` (max 4):
1. Run the existing fan-in **once per query** (same sources, same filters,
same per-source timeout). Parallel across queries is allowed; do not exceed
the existing per-source client. A Tavily call still counts toward the daily cap;
if the cap trips mid-batch, later queries skip Tavily with the same warning.
2. Concatenate hits in query order, then **canonical-URL dedupe** (P3 `Dedupe`:
first source wins attribution; later longer snippet replaces text).
3. RRF as today, treating each `(source, query)` list as a ranked list, **or**
RRF per query then merge by URL — pick one and test it. Simpler and enough:
concatenate per-query RRF results, then `Dedupe`, then truncate to `k`.
4. Apply `min_score` after merge (hits with `Score == 0` from sources that do
not score are kept unless `min_score > 0`, in which case score-less hits are
dropped — document that SearXNG/Brave may have 0).
5. Cache key must include the normalised query list and the filter canonical
form (see §7). A single-`query` request is the list of one.
Do not fetch any result URL.
## 7. Cache
P3 key is `sha256(norm(query) | sources | k | hour_bucket | caller_class)`.
Extend with `| filters` where `filters` is a stable encoding of the §2 fields
that affect retrieval (sorted domains, mode, time_range, dates, topic,
exact_match, min_score, the query list). Same query without filters must not
hit a filtered entry.
Still: do not cache if any attempted source failed. Still no page bodies, no
Tavily answers.
## 8. CLI
```
sift search "<query>" [--k n] [--json]
[--include-domain host]... [--exclude-domain host]...
[--domains-mode restrict|prefer]
[--time-range day|week|month|year]
[--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
[--topic general|news|finance]
[--exact-match]
[--min-score 0.5]
[--queries-file path]
```
`--queries-file` is a JSON array of 1–4 strings. It is mutually exclusive with
the positional query. Repeatable `--include-domain` / `--exclude-domain`.
Default text output: if `published_date` is present, print it as a fourth line
under the snippet. `--json` is the native envelope.
## 9. Skill
`skill/SKILL.md`:
- Document the new flags with one example matching the cookbook Product News
Tracker: official domain search + `topic=news` + `time_range=month`.
- **Recipe:** after `sift search --json`, do not `sift fetch` every URL. Drop
hits below a score you care about (or take top N), then `sift batch submit`.
- `web_search` remains the fallback only when sift is unreachable. Hermes
`web_search` still hits `/v2/search` and does not get filters — say so.
## 10. Tests (minimum)
- Tavily outbound JSON: filters present when set; `include_answer` always false;
`search_depth` basic; no extract/crawl.
- Tavily inbound: `answer` dropped; `published_date` mapped; missing date omitted.
- Validation table for §2.
- SearXNG: `site:` prefix for one domain; exclude_domains warning; exact_match quotes.
- Brave: any filter → `brave_llm: filters ignored`, request URL unchanged.
- Multi-query: two queries, overlapping URL, one hit after Dedupe; k cap applies.
- Cache: same query different `include_domains` → miss; same filters → hit.
- `/v2/search` golden unchanged (no new keys required).
- Daily cap still skips Tavily on the Nth call across queries in one request.
- Restricted caller: same sources; logs still have no query/host/URL.
`make gate` (and contract if P3's contract harness is still in the tree).
## 11. Code layout
Touch, do not explode:
- `internal/search` — `Filter` type; extend `Source.Search`; Tavily/SearXNG/Brave
- `internal/api/search.go` — native decode + validation
- `cmd/sift/searchcmd.go` — flags
- `internal/dcache` search key
- `skill/SKILL.md`, `README.md`
No new dependency. No `internal/api/api.go` growth if a new file is cleaner
(`internal/api/search_filter.go`). Guard and auth stay closed.
## 12. Rollout
1. Claude reviews this doc (filters vs P3 guard/contract; SearXNG honesty).
2. titan implements on `git.wntrmute.dev:kyle/sift` after that review. Split
commits if it helps; one phase, not a P6-style task-file plan unless the
reviewer asks for one.
3. Claude reviews the code.
4. straylight bumps the orion pin. titan does not edit `~/src/nixos`.
## 13. Explicit non-goals (do not sneak in)
Map, crawl, HTML BM25 chunks, Research, `include_answer`, social-platform
crawlers, changing `/v2/search`. Those were ranked later in the cookbook
write-up on purpose.