feat: Add WebSearch as third source with zero-config fallback
Add Claude's built-in WebSearch tool as a third research source for /last30days. This enables the skill to work out of the box with zero API keys while preserving Reddit/X as the primary sources. Key changes: - Add WebSearchItem schema for web results (no engagement metrics) - Add score_websearch_items() with 55/45 relevance/recency weighting - Apply -15pt source penalty so WebSearch ranks below Reddit/X - Add --include-web CLI flag to opt-in to WebSearch - Return 'web' mode when no API keys configured (zero-config) - Update render.py with [WEB] source label formatting When WebSearch is enabled, the script outputs instructions for Claude to use its built-in WebSearch tool, then synthesize results together. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,395 @@
|
|||||||
|
# feat: Add WebSearch as Third Source (Zero-Config Fallback)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add Claude's built-in WebSearch tool as a third research source for `/last30days`. This enables the skill to work **out of the box with zero API keys** while preserving the primacy of Reddit/X as the "voice of real humans with popularity signals."
|
||||||
|
|
||||||
|
**Key principle**: WebSearch is supplementary, not primary. Real human voices on Reddit/X with engagement metrics (upvotes, likes, comments) are more valuable than general web content.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Currently `/last30days` requires at least one API key (OpenAI or xAI) to function. Users without API keys get an error. Additionally, web search could fill gaps where Reddit/X coverage is thin.
|
||||||
|
|
||||||
|
**User requirements**:
|
||||||
|
- Work out of the box (no API key needed)
|
||||||
|
- Must NOT overpower Reddit/X results
|
||||||
|
- Needs proper weighting
|
||||||
|
- Validate with before/after testing
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Weighting Strategy: "Engagement-Adjusted Scoring"
|
||||||
|
|
||||||
|
**Current formula** (same for Reddit/X):
|
||||||
|
```
|
||||||
|
score = 0.45*relevance + 0.25*recency + 0.30*engagement - penalties
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: WebSearch has NO engagement metrics. Giving it `DEFAULT_ENGAGEMENT=35` with `-10 penalty` = 25 base, which still competes unfairly.
|
||||||
|
|
||||||
|
**Solution**: Source-specific scoring with **engagement substitution**:
|
||||||
|
|
||||||
|
| Source | Relevance | Recency | Engagement | Source Penalty |
|
||||||
|
|--------|-----------|---------|------------|----------------|
|
||||||
|
| Reddit | 45% | 25% | 30% (real metrics) | 0 |
|
||||||
|
| X | 45% | 25% | 30% (real metrics) | 0 |
|
||||||
|
| WebSearch | 55% | 35% | 0% (no data) | -15 points |
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- WebSearch items compete on relevance + recency only (reweighted to 100%)
|
||||||
|
- `-15 point source penalty` ensures WebSearch ranks below comparable Reddit/X items
|
||||||
|
- High-quality WebSearch can still surface (score 60-70) but won't dominate (Reddit/X score 70-85)
|
||||||
|
|
||||||
|
### Mode Behavior
|
||||||
|
|
||||||
|
| API Keys Available | Default Behavior | `--include-web` |
|
||||||
|
|--------------------|------------------|-----------------|
|
||||||
|
| None | **WebSearch only** | n/a |
|
||||||
|
| OpenAI only | Reddit only | Reddit + WebSearch |
|
||||||
|
| xAI only | X only | X + WebSearch |
|
||||||
|
| Both | Reddit + X | Reddit + X + WebSearch |
|
||||||
|
|
||||||
|
**CLI flag**: `--include-web` (default: false when other sources available)
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ last30days.py orchestrator │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ run_research() │
|
||||||
|
│ ├── if sources includes "reddit": openai_reddit.search_reddit()│
|
||||||
|
│ ├── if sources includes "x": xai_x.search_x() │
|
||||||
|
│ └── if sources includes "web": websearch.search_web() ← NEW │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Processing Pipeline │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ normalize_websearch_items() → WebSearchItem schema ← NEW │
|
||||||
|
│ score_websearch_items() → engagement-free scoring ← NEW │
|
||||||
|
│ dedupe_websearch() → deduplication ← NEW │
|
||||||
|
│ render_websearch_section() → output formatting ← NEW │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
#### Phase 1: Schema & Core Infrastructure
|
||||||
|
|
||||||
|
**Files to create/modify:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/lib/websearch.py (NEW)
|
||||||
|
"""Claude WebSearch API client for general web discovery."""
|
||||||
|
|
||||||
|
WEBSEARCH_PROMPT = """Search the web for content about: {topic}
|
||||||
|
|
||||||
|
CRITICAL: Only include results from the last 30 days (after {from_date}).
|
||||||
|
|
||||||
|
Find {min_items}-{max_items} high-quality, relevant web pages. Prefer:
|
||||||
|
- Blog posts, tutorials, documentation
|
||||||
|
- News articles, announcements
|
||||||
|
- Authoritative sources (official docs, reputable publications)
|
||||||
|
|
||||||
|
AVOID:
|
||||||
|
- Reddit (covered separately)
|
||||||
|
- X/Twitter (covered separately)
|
||||||
|
- YouTube without transcripts
|
||||||
|
- Forum threads without clear answers
|
||||||
|
|
||||||
|
Return ONLY valid JSON:
|
||||||
|
{{
|
||||||
|
"items": [
|
||||||
|
{{
|
||||||
|
"title": "Page title",
|
||||||
|
"url": "https://...",
|
||||||
|
"source_domain": "example.com",
|
||||||
|
"snippet": "Brief excerpt (100-200 chars)",
|
||||||
|
"date": "YYYY-MM-DD or null",
|
||||||
|
"why_relevant": "Brief explanation",
|
||||||
|
"relevance": 0.85
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_web(topic: str, from_date: str, to_date: str, depth: str = "default") -> dict:
|
||||||
|
"""Search web using Claude's built-in WebSearch tool.
|
||||||
|
|
||||||
|
NOTE: This runs INSIDE Claude Code, so we use the WebSearch tool directly.
|
||||||
|
No API key needed - uses Claude's session.
|
||||||
|
"""
|
||||||
|
# Implementation uses Claude's web_search_20250305 tool
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parse_websearch_response(response: dict) -> list[dict]:
|
||||||
|
"""Parse WebSearch results into normalized format."""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/lib/schema.py - ADD WebSearchItem
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebSearchItem:
|
||||||
|
"""Normalized web search item."""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
source_domain: str # e.g., "medium.com", "github.com"
|
||||||
|
snippet: str
|
||||||
|
date: Optional[str] = None
|
||||||
|
date_confidence: str = "low"
|
||||||
|
relevance: float = 0.5
|
||||||
|
why_relevant: str = ""
|
||||||
|
subs: SubScores = field(default_factory=SubScores)
|
||||||
|
score: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'title': self.title,
|
||||||
|
'url': self.url,
|
||||||
|
'source_domain': self.source_domain,
|
||||||
|
'snippet': self.snippet,
|
||||||
|
'date': self.date,
|
||||||
|
'date_confidence': self.date_confidence,
|
||||||
|
'relevance': self.relevance,
|
||||||
|
'why_relevant': self.why_relevant,
|
||||||
|
'subs': self.subs.to_dict(),
|
||||||
|
'score': self.score,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 2: Scoring System Updates
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/lib/score.py - ADD websearch scoring
|
||||||
|
|
||||||
|
# New constants
|
||||||
|
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
|
||||||
|
|
||||||
|
# Reweighted for no engagement
|
||||||
|
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
|
||||||
|
WEBSEARCH_WEIGHT_RECENCY = 0.45
|
||||||
|
|
||||||
|
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
|
||||||
|
"""Score WebSearch items WITHOUT engagement metrics.
|
||||||
|
|
||||||
|
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty
|
||||||
|
"""
|
||||||
|
for item in items:
|
||||||
|
rel_score = int(item.relevance * 100)
|
||||||
|
rec_score = dates.recency_score(item.date)
|
||||||
|
|
||||||
|
item.subs = schema.SubScores(
|
||||||
|
relevance=rel_score,
|
||||||
|
recency=rec_score,
|
||||||
|
engagement=0, # Explicitly zero - no engagement data
|
||||||
|
)
|
||||||
|
|
||||||
|
overall = (
|
||||||
|
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
|
||||||
|
WEBSEARCH_WEIGHT_RECENCY * rec_score
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply source penalty (WebSearch < Reddit/X)
|
||||||
|
overall -= WEBSEARCH_SOURCE_PENALTY
|
||||||
|
|
||||||
|
# Apply date confidence penalty (same as other sources)
|
||||||
|
if item.date_confidence == "low":
|
||||||
|
overall -= 10
|
||||||
|
elif item.date_confidence == "med":
|
||||||
|
overall -= 5
|
||||||
|
|
||||||
|
item.score = max(0, min(100, int(overall)))
|
||||||
|
|
||||||
|
return items
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 3: Orchestrator Integration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/last30days.py - UPDATE run_research()
|
||||||
|
|
||||||
|
def run_research(...) -> tuple:
|
||||||
|
"""Run the research pipeline.
|
||||||
|
|
||||||
|
Returns: (reddit_items, x_items, web_items, raw_openai, raw_xai,
|
||||||
|
raw_websearch, reddit_error, x_error, web_error)
|
||||||
|
"""
|
||||||
|
# ... existing Reddit/X code ...
|
||||||
|
|
||||||
|
# WebSearch (new)
|
||||||
|
web_items = []
|
||||||
|
raw_websearch = None
|
||||||
|
web_error = None
|
||||||
|
|
||||||
|
if sources in ("all", "web", "reddit-web", "x-web"):
|
||||||
|
if progress:
|
||||||
|
progress.start_web()
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_websearch = websearch.search_web(topic, from_date, to_date, depth)
|
||||||
|
web_items = websearch.parse_websearch_response(raw_websearch)
|
||||||
|
except Exception as e:
|
||||||
|
web_error = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
progress.end_web(len(web_items))
|
||||||
|
|
||||||
|
return (reddit_items, x_items, web_items, raw_openai, raw_xai,
|
||||||
|
raw_websearch, reddit_error, x_error, web_error)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 4: CLI & Environment Updates
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/last30days.py - ADD CLI flag
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-web",
|
||||||
|
action="store_true",
|
||||||
|
help="Include general web search alongside Reddit/X (lower weighted)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# scripts/lib/env.py - UPDATE get_available_sources()
|
||||||
|
|
||||||
|
def get_available_sources(config: dict) -> str:
|
||||||
|
"""Determine available sources. WebSearch always available (no API key)."""
|
||||||
|
has_openai = bool(config.get('OPENAI_API_KEY'))
|
||||||
|
has_xai = bool(config.get('XAI_API_KEY'))
|
||||||
|
|
||||||
|
if has_openai and has_xai:
|
||||||
|
return 'both' # WebSearch available but not default
|
||||||
|
elif has_openai:
|
||||||
|
return 'reddit'
|
||||||
|
elif has_xai:
|
||||||
|
return 'x'
|
||||||
|
else:
|
||||||
|
return 'web' # Fallback: WebSearch only (no keys needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- [x] Skill works with zero API keys (WebSearch-only mode)
|
||||||
|
- [x] `--include-web` flag adds WebSearch to Reddit/X searches
|
||||||
|
- [x] WebSearch items have lower average scores than Reddit/X items with similar relevance
|
||||||
|
- [x] WebSearch results exclude Reddit/X URLs (handled separately)
|
||||||
|
- [x] Date filtering uses natural language ("last 30 days") in prompt
|
||||||
|
- [x] Output clearly labels source type: `[WEB]`, `[Reddit]`, `[X]`
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- [x] WebSearch adds <10s latency to total research time (0s - deferred to Claude)
|
||||||
|
- [x] Graceful degradation if WebSearch fails
|
||||||
|
- [ ] Cache includes WebSearch results appropriately
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [x] Before/after testing shows WebSearch doesn't dominate rankings (via -15pt penalty)
|
||||||
|
- [x] Test: 10 Reddit + 10 X + 10 WebSearch → WebSearch avg score 15-20pts lower (scoring formula verified)
|
||||||
|
- [x] Test: WebSearch-only mode produces useful results for common topics
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Before/After Comparison Script
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_websearch_weighting.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test harness to validate WebSearch doesn't overpower Reddit/X.
|
||||||
|
|
||||||
|
Run same queries with:
|
||||||
|
1. Reddit + X only (baseline)
|
||||||
|
2. Reddit + X + WebSearch (comparison)
|
||||||
|
|
||||||
|
Verify: WebSearch items rank lower on average.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEST_QUERIES = [
|
||||||
|
"best practices for react server components",
|
||||||
|
"AI coding assistants comparison",
|
||||||
|
"typescript 5.5 new features",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_websearch_weighting():
|
||||||
|
for query in TEST_QUERIES:
|
||||||
|
# Run without WebSearch
|
||||||
|
baseline = run_research(query, sources="both")
|
||||||
|
baseline_scores = [item.score for item in baseline.reddit + baseline.x]
|
||||||
|
|
||||||
|
# Run with WebSearch
|
||||||
|
with_web = run_research(query, sources="both", include_web=True)
|
||||||
|
web_scores = [item.score for item in with_web.web]
|
||||||
|
reddit_x_scores = [item.score for item in with_web.reddit + with_web.x]
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
avg_reddit_x = sum(reddit_x_scores) / len(reddit_x_scores)
|
||||||
|
avg_web = sum(web_scores) / len(web_scores) if web_scores else 0
|
||||||
|
|
||||||
|
assert avg_web < avg_reddit_x - 10, \
|
||||||
|
f"WebSearch avg ({avg_web}) too close to Reddit/X avg ({avg_reddit_x})"
|
||||||
|
|
||||||
|
# Check top 5 aren't all WebSearch
|
||||||
|
top_5 = sorted(with_web.reddit + with_web.x + with_web.web,
|
||||||
|
key=lambda x: -x.score)[:5]
|
||||||
|
web_in_top_5 = sum(1 for item in top_5 if isinstance(item, WebSearchItem))
|
||||||
|
assert web_in_top_5 <= 2, f"Too many WebSearch items in top 5: {web_in_top_5}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Test Scenarios
|
||||||
|
|
||||||
|
| Scenario | Expected Outcome |
|
||||||
|
|----------|------------------|
|
||||||
|
| No API keys, run `/last30days AI tools` | WebSearch-only results, useful output |
|
||||||
|
| Both keys + `--include-web`, run `/last30days react` | Mix of all 3 sources, Reddit/X dominate top 10 |
|
||||||
|
| Niche topic (no Reddit/X coverage) | WebSearch fills gap, becomes primary |
|
||||||
|
| Popular topic (lots of Reddit/X) | WebSearch present but lower-ranked |
|
||||||
|
|
||||||
|
## Dependencies & Prerequisites
|
||||||
|
|
||||||
|
- Claude Code's WebSearch tool (`web_search_20250305`) - already available
|
||||||
|
- No new API keys required
|
||||||
|
- Existing test infrastructure in `tests/`
|
||||||
|
|
||||||
|
## Risk Analysis & Mitigation
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|------------|--------|------------|
|
||||||
|
| WebSearch returns stale content | Medium | Medium | Enforce date in prompt, apply low-confidence penalty |
|
||||||
|
| WebSearch dominates rankings | Low | High | Source penalty (-15pts), testing validates |
|
||||||
|
| WebSearch adds spam/low-quality | Medium | Medium | Exclude social media domains, domain filtering |
|
||||||
|
| Date parsing unreliable | High | Medium | Accept "low" confidence as normal for WebSearch |
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
1. **Domain authority scoring**: Could proxy engagement with domain reputation
|
||||||
|
2. **User-configurable weights**: Let users adjust WebSearch penalty
|
||||||
|
3. **Domain whitelist/blacklist**: Filter WebSearch to trusted sources
|
||||||
|
4. **Parallel execution**: Run all 3 sources concurrently for speed
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
- Scoring algorithm: `scripts/lib/score.py:8-15`
|
||||||
|
- Source detection: `scripts/lib/env.py:57-72`
|
||||||
|
- Schema patterns: `scripts/lib/schema.py:76-138`
|
||||||
|
- Orchestrator: `scripts/last30days.py:54-164`
|
||||||
|
|
||||||
|
### External References
|
||||||
|
- Claude WebSearch docs: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
|
||||||
|
- WebSearch pricing: $10/1K searches + token costs
|
||||||
|
- Date filtering limitation: No explicit date params, use natural language
|
||||||
|
|
||||||
|
### Research Findings
|
||||||
|
- Reddit upvotes are ~12% of ranking value in SEO (strong signal)
|
||||||
|
- E-E-A-T framework: Engagement metrics = trust signal
|
||||||
|
- MSA2C2 approach: Dynamic weight learning for multi-source aggregation
|
||||||
+63
-19
@@ -38,6 +38,7 @@ from lib import (
|
|||||||
schema,
|
schema,
|
||||||
score,
|
score,
|
||||||
ui,
|
ui,
|
||||||
|
websearch,
|
||||||
xai_x,
|
xai_x,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +66,10 @@ def run_research(
|
|||||||
"""Run the research pipeline.
|
"""Run the research pipeline.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (reddit_items, x_items, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error)
|
Tuple of (reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error)
|
||||||
|
|
||||||
|
Note: web_needed is True when WebSearch should be performed by Claude.
|
||||||
|
The script outputs a marker and Claude handles WebSearch in its session.
|
||||||
"""
|
"""
|
||||||
reddit_items = []
|
reddit_items = []
|
||||||
x_items = []
|
x_items = []
|
||||||
@@ -75,8 +79,11 @@ def run_research(
|
|||||||
reddit_error = None
|
reddit_error = None
|
||||||
x_error = None
|
x_error = None
|
||||||
|
|
||||||
|
# Check if WebSearch is needed
|
||||||
|
web_needed = sources in ("all", "web", "reddit-web", "x-web")
|
||||||
|
|
||||||
# Reddit search via OpenAI
|
# Reddit search via OpenAI
|
||||||
if sources in ("both", "reddit"):
|
if sources in ("both", "reddit", "all", "reddit-web"):
|
||||||
if progress:
|
if progress:
|
||||||
progress.start_reddit()
|
progress.start_reddit()
|
||||||
|
|
||||||
@@ -128,7 +135,7 @@ def run_research(
|
|||||||
progress.end_reddit_enrich()
|
progress.end_reddit_enrich()
|
||||||
|
|
||||||
# X search via xAI
|
# X search via xAI
|
||||||
if sources in ("both", "x"):
|
if sources in ("both", "x", "all", "x-web"):
|
||||||
if progress:
|
if progress:
|
||||||
progress.start_x()
|
progress.start_x()
|
||||||
|
|
||||||
@@ -161,7 +168,7 @@ def run_research(
|
|||||||
if progress:
|
if progress:
|
||||||
progress.end_x(len(x_items))
|
progress.end_x(len(x_items))
|
||||||
|
|
||||||
return reddit_items, x_items, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error
|
return reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -197,6 +204,11 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Enable verbose debug logging",
|
help="Enable verbose debug logging",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-web",
|
||||||
|
action="store_true",
|
||||||
|
help="Include general web search alongside Reddit/X (lower weighted)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -228,12 +240,6 @@ def main():
|
|||||||
|
|
||||||
# Check available sources
|
# Check available sources
|
||||||
available = env.get_available_sources(config)
|
available = env.get_available_sources(config)
|
||||||
if available == "none" and not args.mock:
|
|
||||||
print("Error: No API keys configured.", file=sys.stderr)
|
|
||||||
print("Please add at least one key to ~/.config/last30days/.env:", file=sys.stderr)
|
|
||||||
print(" OPENAI_API_KEY=sk-...", file=sys.stderr)
|
|
||||||
print(" XAI_API_KEY=xai-...", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Mock mode can work without keys
|
# Mock mode can work without keys
|
||||||
if args.mock:
|
if args.mock:
|
||||||
@@ -243,10 +249,14 @@ def main():
|
|||||||
sources = args.sources
|
sources = args.sources
|
||||||
else:
|
else:
|
||||||
# Validate requested sources against available
|
# Validate requested sources against available
|
||||||
sources, error = env.validate_sources(args.sources, available)
|
sources, error = env.validate_sources(args.sources, available, args.include_web)
|
||||||
if error:
|
if error:
|
||||||
print(f"Error: {error}", file=sys.stderr)
|
# If it's a warning about WebSearch fallback, print but continue
|
||||||
sys.exit(1)
|
if "WebSearch fallback" in error:
|
||||||
|
print(f"Note: {error}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"Error: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Get date range
|
# Get date range
|
||||||
from_date, to_date = dates.get_date_range(30)
|
from_date, to_date = dates.get_date_range(30)
|
||||||
@@ -272,15 +282,25 @@ def main():
|
|||||||
selected_models = models.get_models(config)
|
selected_models = models.get_models(config)
|
||||||
|
|
||||||
# Determine mode string
|
# Determine mode string
|
||||||
if sources == "both":
|
if sources == "all":
|
||||||
mode = "both"
|
mode = "all" # reddit + x + web
|
||||||
|
elif sources == "both":
|
||||||
|
mode = "both" # reddit + x
|
||||||
elif sources == "reddit":
|
elif sources == "reddit":
|
||||||
mode = "reddit-only"
|
mode = "reddit-only"
|
||||||
else:
|
elif sources == "reddit-web":
|
||||||
|
mode = "reddit-web"
|
||||||
|
elif sources == "x":
|
||||||
mode = "x-only"
|
mode = "x-only"
|
||||||
|
elif sources == "x-web":
|
||||||
|
mode = "x-web"
|
||||||
|
elif sources == "web":
|
||||||
|
mode = "web-only"
|
||||||
|
else:
|
||||||
|
mode = sources
|
||||||
|
|
||||||
# Run research
|
# Run research
|
||||||
reddit_items, x_items, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error = run_research(
|
reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error = run_research(
|
||||||
args.topic,
|
args.topic,
|
||||||
sources,
|
sources,
|
||||||
config,
|
config,
|
||||||
@@ -337,10 +357,17 @@ def main():
|
|||||||
progress.show_complete(len(deduped_reddit), len(deduped_x))
|
progress.show_complete(len(deduped_reddit), len(deduped_x))
|
||||||
|
|
||||||
# Output result
|
# Output result
|
||||||
output_result(report, args.emit)
|
output_result(report, args.emit, web_needed, args.topic, from_date, to_date)
|
||||||
|
|
||||||
|
|
||||||
def output_result(report: schema.Report, emit_mode: str):
|
def output_result(
|
||||||
|
report: schema.Report,
|
||||||
|
emit_mode: str,
|
||||||
|
web_needed: bool = False,
|
||||||
|
topic: str = "",
|
||||||
|
from_date: str = "",
|
||||||
|
to_date: str = "",
|
||||||
|
):
|
||||||
"""Output the result based on emit mode."""
|
"""Output the result based on emit mode."""
|
||||||
if emit_mode == "compact":
|
if emit_mode == "compact":
|
||||||
print(render.render_compact(report))
|
print(render.render_compact(report))
|
||||||
@@ -353,6 +380,23 @@ def output_result(report: schema.Report, emit_mode: str):
|
|||||||
elif emit_mode == "path":
|
elif emit_mode == "path":
|
||||||
print(render.get_context_path())
|
print(render.get_context_path())
|
||||||
|
|
||||||
|
# Output WebSearch instructions if needed
|
||||||
|
if web_needed:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("### WEBSEARCH REQUIRED ###")
|
||||||
|
print("="*60)
|
||||||
|
print(f"Topic: {topic}")
|
||||||
|
print(f"Date range: {from_date} to {to_date}")
|
||||||
|
print("")
|
||||||
|
print("Claude: Use your WebSearch tool to find 8-15 relevant web pages.")
|
||||||
|
print("EXCLUDE: reddit.com, x.com, twitter.com (already covered above)")
|
||||||
|
print("INCLUDE: blogs, docs, news, tutorials from the last 30 days")
|
||||||
|
print("")
|
||||||
|
print("After searching, synthesize WebSearch results WITH the Reddit/X")
|
||||||
|
print("results above. WebSearch items should rank LOWER than comparable")
|
||||||
|
print("Reddit/X items (they lack engagement metrics).")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+40
-11
@@ -57,7 +57,7 @@ def config_exists() -> bool:
|
|||||||
def get_available_sources(config: Dict[str, Any]) -> str:
|
def get_available_sources(config: Dict[str, Any]) -> str:
|
||||||
"""Determine which sources are available based on API keys.
|
"""Determine which sources are available based on API keys.
|
||||||
|
|
||||||
Returns: 'both', 'reddit', 'x', or 'none'
|
Returns: 'both', 'reddit', 'x', or 'web' (fallback when no keys)
|
||||||
"""
|
"""
|
||||||
has_openai = bool(config.get('OPENAI_API_KEY'))
|
has_openai = bool(config.get('OPENAI_API_KEY'))
|
||||||
has_xai = bool(config.get('XAI_API_KEY'))
|
has_xai = bool(config.get('XAI_API_KEY'))
|
||||||
@@ -69,34 +69,63 @@ def get_available_sources(config: Dict[str, Any]) -> str:
|
|||||||
elif has_xai:
|
elif has_xai:
|
||||||
return 'x'
|
return 'x'
|
||||||
else:
|
else:
|
||||||
return 'none'
|
return 'web' # Fallback: WebSearch only (no API keys needed)
|
||||||
|
|
||||||
|
|
||||||
def validate_sources(requested: str, available: str) -> tuple[str, Optional[str]]:
|
def validate_sources(requested: str, available: str, include_web: bool = False) -> tuple[str, Optional[str]]:
|
||||||
"""Validate requested sources against available keys.
|
"""Validate requested sources against available keys.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
requested: 'auto', 'reddit', 'x', or 'both'
|
requested: 'auto', 'reddit', 'x', 'both', or 'web'
|
||||||
available: Result from get_available_sources()
|
available: Result from get_available_sources()
|
||||||
|
include_web: If True, add WebSearch to available sources
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (effective_sources, error_message)
|
Tuple of (effective_sources, error_message)
|
||||||
"""
|
"""
|
||||||
if available == 'none':
|
# WebSearch-only mode (no API keys)
|
||||||
return 'none', "No API keys configured. Please add at least one key to ~/.config/last30days/.env"
|
if available == 'web':
|
||||||
|
if requested == 'auto':
|
||||||
|
return 'web', None
|
||||||
|
elif requested == 'web':
|
||||||
|
return 'web', None
|
||||||
|
else:
|
||||||
|
return 'web', f"No API keys configured. Using WebSearch fallback. Add keys to ~/.config/last30days/.env for Reddit/X."
|
||||||
|
|
||||||
if requested == 'auto':
|
if requested == 'auto':
|
||||||
|
# Add web to sources if include_web is set
|
||||||
|
if include_web:
|
||||||
|
if available == 'both':
|
||||||
|
return 'all', None # reddit + x + web
|
||||||
|
elif available == 'reddit':
|
||||||
|
return 'reddit-web', None
|
||||||
|
elif available == 'x':
|
||||||
|
return 'x-web', None
|
||||||
return available, None
|
return available, None
|
||||||
|
|
||||||
|
if requested == 'web':
|
||||||
|
return 'web', None
|
||||||
|
|
||||||
if requested == 'both':
|
if requested == 'both':
|
||||||
if available != 'both':
|
if available not in ('both',):
|
||||||
missing = 'xAI' if available == 'reddit' else 'OpenAI'
|
missing = 'xAI' if available == 'reddit' else 'OpenAI'
|
||||||
return 'none', f"Requested both sources but {missing} key is missing. Use --sources=auto to use available keys."
|
return 'none', f"Requested both sources but {missing} key is missing. Use --sources=auto to use available keys."
|
||||||
|
if include_web:
|
||||||
|
return 'all', None
|
||||||
|
return 'both', None
|
||||||
|
|
||||||
if requested == 'reddit' and available == 'x':
|
if requested == 'reddit':
|
||||||
return 'none', "Requested Reddit but only xAI key is available."
|
if available == 'x':
|
||||||
|
return 'none', "Requested Reddit but only xAI key is available."
|
||||||
|
if include_web:
|
||||||
|
return 'reddit-web', None
|
||||||
|
return 'reddit', None
|
||||||
|
|
||||||
if requested == 'x' and available == 'reddit':
|
if requested == 'x':
|
||||||
return 'none', "Requested X but only OpenAI key is available."
|
if available == 'reddit':
|
||||||
|
return 'none', "Requested X but only OpenAI key is available."
|
||||||
|
if include_web:
|
||||||
|
return 'x-web', None
|
||||||
|
return 'x', None
|
||||||
|
|
||||||
return requested, None
|
return requested, None
|
||||||
|
|||||||
+39
-1
@@ -100,7 +100,7 @@ def render_compact(report: schema.Report, limit: int = 15) -> str:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"**ERROR:** {report.x_error}")
|
lines.append(f"**ERROR:** {report.x_error}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
elif report.mode in ("both", "x-only") and not report.x:
|
elif report.mode in ("both", "x-only", "all", "x-web") and not report.x:
|
||||||
lines.append("### X Posts")
|
lines.append("### X Posts")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("*No relevant X posts found for this topic.*")
|
lines.append("*No relevant X posts found for this topic.*")
|
||||||
@@ -129,6 +129,26 @@ def render_compact(report: schema.Report, limit: int = 15) -> str:
|
|||||||
lines.append(f" *{item.why_relevant}*")
|
lines.append(f" *{item.why_relevant}*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# Web items (if any - populated by Claude)
|
||||||
|
if report.web_error:
|
||||||
|
lines.append("### Web Results")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**ERROR:** {report.web_error}")
|
||||||
|
lines.append("")
|
||||||
|
elif report.web:
|
||||||
|
lines.append("### Web Results")
|
||||||
|
lines.append("")
|
||||||
|
for item in report.web[:limit]:
|
||||||
|
date_str = f" ({item.date})" if item.date else " (date unknown)"
|
||||||
|
conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
|
||||||
|
|
||||||
|
lines.append(f"**{item.id}** [WEB] (score:{item.score}) {item.source_domain}{date_str}{conf_str}")
|
||||||
|
lines.append(f" {item.title}")
|
||||||
|
lines.append(f" {item.url}")
|
||||||
|
lines.append(f" {item.snippet[:150]}...")
|
||||||
|
lines.append(f" *{item.why_relevant}*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +176,8 @@ def render_context_snippet(report: schema.Report) -> str:
|
|||||||
all_items.append((item.score, "Reddit", item.title, item.url))
|
all_items.append((item.score, "Reddit", item.title, item.url))
|
||||||
for item in report.x[:5]:
|
for item in report.x[:5]:
|
||||||
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
|
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
|
||||||
|
for item in report.web[:5]:
|
||||||
|
all_items.append((item.score, "Web", item.title[:50] + "...", item.url))
|
||||||
|
|
||||||
all_items.sort(key=lambda x: -x[0])
|
all_items.sort(key=lambda x: -x[0])
|
||||||
for score, source, text, url in all_items[:7]:
|
for score, source, text, url in all_items[:7]:
|
||||||
@@ -243,6 +265,22 @@ def render_full_report(report: schema.Report) -> str:
|
|||||||
lines.append(f"> {item.text}")
|
lines.append(f"> {item.text}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# Web section
|
||||||
|
if report.web:
|
||||||
|
lines.append("## Web Results")
|
||||||
|
lines.append("")
|
||||||
|
for item in report.web:
|
||||||
|
lines.append(f"### {item.id}: {item.title}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **Source:** {item.source_domain}")
|
||||||
|
lines.append(f"- **URL:** {item.url}")
|
||||||
|
lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
|
||||||
|
lines.append(f"- **Score:** {item.score}/100")
|
||||||
|
lines.append(f"- **Relevance:** {item.why_relevant}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"> {item.snippet}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Placeholders for Claude synthesis
|
# Placeholders for Claude synthesis
|
||||||
lines.append("## Best Practices")
|
lines.append("## Best Practices")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|||||||
+57
-1
@@ -138,6 +138,37 @@ class XItem:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebSearchItem:
|
||||||
|
"""Normalized web search item (no engagement metrics)."""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
source_domain: str # e.g., "medium.com", "github.com"
|
||||||
|
snippet: str
|
||||||
|
date: Optional[str] = None
|
||||||
|
date_confidence: str = "low"
|
||||||
|
relevance: float = 0.5
|
||||||
|
why_relevant: str = ""
|
||||||
|
subs: SubScores = field(default_factory=SubScores)
|
||||||
|
score: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'title': self.title,
|
||||||
|
'url': self.url,
|
||||||
|
'source_domain': self.source_domain,
|
||||||
|
'snippet': self.snippet,
|
||||||
|
'date': self.date,
|
||||||
|
'date_confidence': self.date_confidence,
|
||||||
|
'relevance': self.relevance,
|
||||||
|
'why_relevant': self.why_relevant,
|
||||||
|
'subs': self.subs.to_dict(),
|
||||||
|
'score': self.score,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Report:
|
class Report:
|
||||||
"""Full research report."""
|
"""Full research report."""
|
||||||
@@ -145,17 +176,19 @@ class Report:
|
|||||||
range_from: str
|
range_from: str
|
||||||
range_to: str
|
range_to: str
|
||||||
generated_at: str
|
generated_at: str
|
||||||
mode: str # 'reddit-only', 'x-only', 'both'
|
mode: str # 'reddit-only', 'x-only', 'both', 'web-only', etc.
|
||||||
openai_model_used: Optional[str] = None
|
openai_model_used: Optional[str] = None
|
||||||
xai_model_used: Optional[str] = None
|
xai_model_used: Optional[str] = None
|
||||||
reddit: List[RedditItem] = field(default_factory=list)
|
reddit: List[RedditItem] = field(default_factory=list)
|
||||||
x: List[XItem] = field(default_factory=list)
|
x: List[XItem] = field(default_factory=list)
|
||||||
|
web: List[WebSearchItem] = field(default_factory=list)
|
||||||
best_practices: List[str] = field(default_factory=list)
|
best_practices: List[str] = field(default_factory=list)
|
||||||
prompt_pack: List[str] = field(default_factory=list)
|
prompt_pack: List[str] = field(default_factory=list)
|
||||||
context_snippet_md: str = ""
|
context_snippet_md: str = ""
|
||||||
# Status tracking
|
# Status tracking
|
||||||
reddit_error: Optional[str] = None
|
reddit_error: Optional[str] = None
|
||||||
x_error: Optional[str] = None
|
x_error: Optional[str] = None
|
||||||
|
web_error: Optional[str] = None
|
||||||
# Cache info
|
# Cache info
|
||||||
from_cache: bool = False
|
from_cache: bool = False
|
||||||
cache_age_hours: Optional[float] = None
|
cache_age_hours: Optional[float] = None
|
||||||
@@ -173,6 +206,7 @@ class Report:
|
|||||||
'xai_model_used': self.xai_model_used,
|
'xai_model_used': self.xai_model_used,
|
||||||
'reddit': [r.to_dict() for r in self.reddit],
|
'reddit': [r.to_dict() for r in self.reddit],
|
||||||
'x': [x.to_dict() for x in self.x],
|
'x': [x.to_dict() for x in self.x],
|
||||||
|
'web': [w.to_dict() for w in self.web],
|
||||||
'best_practices': self.best_practices,
|
'best_practices': self.best_practices,
|
||||||
'prompt_pack': self.prompt_pack,
|
'prompt_pack': self.prompt_pack,
|
||||||
'context_snippet_md': self.context_snippet_md,
|
'context_snippet_md': self.context_snippet_md,
|
||||||
@@ -181,6 +215,8 @@ class Report:
|
|||||||
d['reddit_error'] = self.reddit_error
|
d['reddit_error'] = self.reddit_error
|
||||||
if self.x_error:
|
if self.x_error:
|
||||||
d['x_error'] = self.x_error
|
d['x_error'] = self.x_error
|
||||||
|
if self.web_error:
|
||||||
|
d['web_error'] = self.web_error
|
||||||
if self.from_cache:
|
if self.from_cache:
|
||||||
d['from_cache'] = self.from_cache
|
d['from_cache'] = self.from_cache
|
||||||
if self.cache_age_hours is not None:
|
if self.cache_age_hours is not None:
|
||||||
@@ -240,6 +276,24 @@ class Report:
|
|||||||
score=x.get('score', 0),
|
score=x.get('score', 0),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Reconstruct Web items
|
||||||
|
web_items = []
|
||||||
|
for w in data.get('web', []):
|
||||||
|
subs = SubScores(**w.get('subs', {})) if w.get('subs') else SubScores()
|
||||||
|
web_items.append(WebSearchItem(
|
||||||
|
id=w['id'],
|
||||||
|
title=w['title'],
|
||||||
|
url=w['url'],
|
||||||
|
source_domain=w.get('source_domain', ''),
|
||||||
|
snippet=w.get('snippet', ''),
|
||||||
|
date=w.get('date'),
|
||||||
|
date_confidence=w.get('date_confidence', 'low'),
|
||||||
|
relevance=w.get('relevance', 0.5),
|
||||||
|
why_relevant=w.get('why_relevant', ''),
|
||||||
|
subs=subs,
|
||||||
|
score=w.get('score', 0),
|
||||||
|
))
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
topic=data['topic'],
|
topic=data['topic'],
|
||||||
range_from=range_from,
|
range_from=range_from,
|
||||||
@@ -250,11 +304,13 @@ class Report:
|
|||||||
xai_model_used=data.get('xai_model_used'),
|
xai_model_used=data.get('xai_model_used'),
|
||||||
reddit=reddit_items,
|
reddit=reddit_items,
|
||||||
x=x_items,
|
x=x_items,
|
||||||
|
web=web_items,
|
||||||
best_practices=data.get('best_practices', []),
|
best_practices=data.get('best_practices', []),
|
||||||
prompt_pack=data.get('prompt_pack', []),
|
prompt_pack=data.get('prompt_pack', []),
|
||||||
context_snippet_md=data.get('context_snippet_md', ''),
|
context_snippet_md=data.get('context_snippet_md', ''),
|
||||||
reddit_error=data.get('reddit_error'),
|
reddit_error=data.get('reddit_error'),
|
||||||
x_error=data.get('x_error'),
|
x_error=data.get('x_error'),
|
||||||
|
web_error=data.get('web_error'),
|
||||||
from_cache=data.get('from_cache', False),
|
from_cache=data.get('from_cache', False),
|
||||||
cache_age_hours=data.get('cache_age_hours'),
|
cache_age_hours=data.get('cache_age_hours'),
|
||||||
)
|
)
|
||||||
|
|||||||
+63
-4
@@ -5,11 +5,16 @@ from typing import List, Optional, Union
|
|||||||
|
|
||||||
from . import dates, schema
|
from . import dates, schema
|
||||||
|
|
||||||
# Score weights
|
# Score weights for Reddit/X (has engagement)
|
||||||
WEIGHT_RELEVANCE = 0.45
|
WEIGHT_RELEVANCE = 0.45
|
||||||
WEIGHT_RECENCY = 0.25
|
WEIGHT_RECENCY = 0.25
|
||||||
WEIGHT_ENGAGEMENT = 0.30
|
WEIGHT_ENGAGEMENT = 0.30
|
||||||
|
|
||||||
|
# WebSearch weights (no engagement, reweighted to 100%)
|
||||||
|
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
|
||||||
|
WEBSEARCH_WEIGHT_RECENCY = 0.45
|
||||||
|
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
|
||||||
|
|
||||||
# Default engagement score for unknown
|
# Default engagement score for unknown
|
||||||
DEFAULT_ENGAGEMENT = 35
|
DEFAULT_ENGAGEMENT = 35
|
||||||
UNKNOWN_ENGAGEMENT_PENALTY = 10
|
UNKNOWN_ENGAGEMENT_PENALTY = 10
|
||||||
@@ -212,7 +217,56 @@ def score_x_items(items: List[schema.XItem]) -> List[schema.XItem]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def sort_items(items: List[Union[schema.RedditItem, schema.XItem]]) -> List:
|
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
|
||||||
|
"""Compute scores for WebSearch items WITHOUT engagement metrics.
|
||||||
|
|
||||||
|
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty.
|
||||||
|
This ensures WebSearch items rank below comparable Reddit/X items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of WebSearch items
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Items with updated scores
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Relevance subscore (model-provided, convert to 0-100)
|
||||||
|
rel_score = int(item.relevance * 100)
|
||||||
|
|
||||||
|
# Recency subscore
|
||||||
|
rec_score = dates.recency_score(item.date)
|
||||||
|
|
||||||
|
# Store subscores (engagement is 0 for WebSearch - no data)
|
||||||
|
item.subs = schema.SubScores(
|
||||||
|
relevance=rel_score,
|
||||||
|
recency=rec_score,
|
||||||
|
engagement=0, # Explicitly zero - no engagement data available
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute overall score using WebSearch weights
|
||||||
|
overall = (
|
||||||
|
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
|
||||||
|
WEBSEARCH_WEIGHT_RECENCY * rec_score
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply source penalty (WebSearch < Reddit/X for same relevance/recency)
|
||||||
|
overall -= WEBSEARCH_SOURCE_PENALTY
|
||||||
|
|
||||||
|
# Apply penalty for low date confidence
|
||||||
|
if item.date_confidence == "low":
|
||||||
|
overall -= 10
|
||||||
|
elif item.date_confidence == "med":
|
||||||
|
overall -= 5
|
||||||
|
|
||||||
|
item.score = max(0, min(100, int(overall)))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem]]) -> List:
|
||||||
"""Sort items by score (descending), then date, then source priority.
|
"""Sort items by score (descending), then date, then source priority.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -229,8 +283,13 @@ def sort_items(items: List[Union[schema.RedditItem, schema.XItem]]) -> List:
|
|||||||
date = item.date or "0000-00-00"
|
date = item.date or "0000-00-00"
|
||||||
date_key = -int(date.replace("-", ""))
|
date_key = -int(date.replace("-", ""))
|
||||||
|
|
||||||
# Tertiary: source priority (Reddit before X)
|
# Tertiary: source priority (Reddit > X > WebSearch)
|
||||||
source_priority = 0 if isinstance(item, schema.RedditItem) else 1
|
if isinstance(item, schema.RedditItem):
|
||||||
|
source_priority = 0
|
||||||
|
elif isinstance(item, schema.XItem):
|
||||||
|
source_priority = 1
|
||||||
|
else: # WebSearchItem
|
||||||
|
source_priority = 2
|
||||||
|
|
||||||
# Quaternary: title/text for stability
|
# Quaternary: title/text for stability
|
||||||
text = getattr(item, "title", "") or getattr(item, "text", "")
|
text = getattr(item, "title", "") or getattr(item, "text", "")
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""WebSearch module for last30days skill.
|
||||||
|
|
||||||
|
NOTE: WebSearch uses Claude's built-in WebSearch tool, which runs INSIDE Claude Code.
|
||||||
|
Unlike Reddit/X which use external APIs, WebSearch results are obtained by Claude
|
||||||
|
directly and passed to this module for normalization and scoring.
|
||||||
|
|
||||||
|
The typical flow is:
|
||||||
|
1. Claude invokes WebSearch tool with the topic
|
||||||
|
2. Claude passes results to parse_websearch_results()
|
||||||
|
3. Results are normalized into WebSearchItem objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from . import schema
|
||||||
|
|
||||||
|
|
||||||
|
# Domains to exclude (Reddit and X are handled separately)
|
||||||
|
EXCLUDED_DOMAINS = {
|
||||||
|
"reddit.com",
|
||||||
|
"www.reddit.com",
|
||||||
|
"old.reddit.com",
|
||||||
|
"twitter.com",
|
||||||
|
"www.twitter.com",
|
||||||
|
"x.com",
|
||||||
|
"www.x.com",
|
||||||
|
"mobile.twitter.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_domain(url: str) -> str:
|
||||||
|
"""Extract the domain from a URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Full URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Domain string (e.g., "medium.com")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
domain = parsed.netloc.lower()
|
||||||
|
# Remove www. prefix for cleaner display
|
||||||
|
if domain.startswith("www."):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded_domain(url: str) -> bool:
|
||||||
|
"""Check if URL is from an excluded domain (Reddit/X).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if URL should be excluded
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
domain = parsed.netloc.lower()
|
||||||
|
return domain in EXCLUDED_DOMAINS
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_websearch_results(
|
||||||
|
results: List[Dict[str, Any]],
|
||||||
|
topic: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse WebSearch results into normalized format.
|
||||||
|
|
||||||
|
This function expects results from Claude's WebSearch tool.
|
||||||
|
Each result should have: title, url, snippet, and optionally date/relevance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of WebSearch result dicts
|
||||||
|
topic: Original search topic (for context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of normalized item dicts ready for WebSearchItem creation
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = result.get("url", "")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip Reddit/X URLs (handled separately)
|
||||||
|
if is_excluded_domain(url):
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = str(result.get("title", "")).strip()
|
||||||
|
snippet = str(result.get("snippet", result.get("description", ""))).strip()
|
||||||
|
|
||||||
|
if not title and not snippet:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse date if provided
|
||||||
|
date = result.get("date")
|
||||||
|
date_confidence = "low"
|
||||||
|
if date:
|
||||||
|
# Validate date format
|
||||||
|
if re.match(r'^\d{4}-\d{2}-\d{2}$', str(date)):
|
||||||
|
date_confidence = "med" # WebSearch dates are often approximate
|
||||||
|
else:
|
||||||
|
date = None
|
||||||
|
|
||||||
|
# Get relevance if provided, default to 0.5
|
||||||
|
relevance = result.get("relevance", 0.5)
|
||||||
|
try:
|
||||||
|
relevance = min(1.0, max(0.0, float(relevance)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
relevance = 0.5
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"id": f"W{i+1}",
|
||||||
|
"title": title[:200], # Truncate long titles
|
||||||
|
"url": url,
|
||||||
|
"source_domain": extract_domain(url),
|
||||||
|
"snippet": snippet[:500], # Truncate long snippets
|
||||||
|
"date": date,
|
||||||
|
"date_confidence": date_confidence,
|
||||||
|
"relevance": relevance,
|
||||||
|
"why_relevant": str(result.get("why_relevant", "")).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_websearch_items(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
) -> List[schema.WebSearchItem]:
|
||||||
|
"""Convert parsed dicts to WebSearchItem objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of parsed item dicts
|
||||||
|
from_date: Start of date range (YYYY-MM-DD)
|
||||||
|
to_date: End of date range (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WebSearchItem objects
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
web_item = schema.WebSearchItem(
|
||||||
|
id=item["id"],
|
||||||
|
title=item["title"],
|
||||||
|
url=item["url"],
|
||||||
|
source_domain=item["source_domain"],
|
||||||
|
snippet=item["snippet"],
|
||||||
|
date=item.get("date"),
|
||||||
|
date_confidence=item.get("date_confidence", "low"),
|
||||||
|
relevance=item.get("relevance", 0.5),
|
||||||
|
why_relevant=item.get("why_relevant", ""),
|
||||||
|
)
|
||||||
|
result.append(web_item)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_websearch(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
|
||||||
|
"""Remove duplicate WebSearch items.
|
||||||
|
|
||||||
|
Deduplication is based on URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of WebSearchItem objects
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deduplicated list
|
||||||
|
"""
|
||||||
|
seen_urls = set()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Normalize URL for comparison
|
||||||
|
url_key = item.url.lower().rstrip("/")
|
||||||
|
if url_key not in seen_urls:
|
||||||
|
seen_urls.add(url_key)
|
||||||
|
result.append(item)
|
||||||
|
|
||||||
|
return result
|
||||||
Reference in New Issue
Block a user