feat(x): resolve X handles for person/brand topics via agent WebSearch
When a topic is a person/brand (e.g. "Dor Brothers", "Jason Calacanis"), the agent now resolves their X handle via WebSearch before running the script, then passes --x-handle to search their posts unfiltered (no topic keywords required). This finds posts the entity made without mentioning their own name. - SKILL.md + OpenClaw variant: Step 0.5 handle resolution instructions - last30days.py: --x-handle CLI arg, passed through to _run_supplemental() - bird_x.search_handles(): topic is now Optional[str] for unfiltered mode - schema.py: resolved_x_handle field on Report - render.py: show resolved handle in stats output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+54
-2
@@ -394,6 +394,7 @@ def _run_supplemental(
|
||||
x_source: str,
|
||||
progress: ui.ProgressDisplay = None,
|
||||
skip_reddit: bool = False,
|
||||
resolved_handle: str = None,
|
||||
) -> tuple:
|
||||
"""Run Phase 2 supplemental searches based on entities from Phase 1.
|
||||
|
||||
@@ -410,6 +411,7 @@ def _run_supplemental(
|
||||
x_source: 'bird' or 'xai'
|
||||
progress: Optional progress display
|
||||
skip_reddit: If True, skip Reddit supplemental (e.g. rate-limited)
|
||||
resolved_handle: X handle resolved by the agent (without @), searched unfiltered
|
||||
|
||||
Returns:
|
||||
Tuple of (supplemental_reddit, supplemental_x)
|
||||
@@ -434,10 +436,19 @@ def _run_supplemental(
|
||||
has_handles = entities["x_handles"] and x_source == "bird"
|
||||
has_subs = entities["reddit_subreddits"] and not skip_reddit
|
||||
|
||||
if not has_handles and not has_subs:
|
||||
# Check if resolved handle is new (not already in extracted entities)
|
||||
has_resolved = (
|
||||
resolved_handle
|
||||
and x_source == "bird"
|
||||
and resolved_handle.lower() not in {h.lower() for h in entities["x_handles"]}
|
||||
)
|
||||
|
||||
if not has_handles and not has_subs and not has_resolved:
|
||||
return [], []
|
||||
|
||||
parts = []
|
||||
if has_resolved:
|
||||
parts.append(f"@{resolved_handle} (resolved)")
|
||||
if has_handles:
|
||||
parts.append(f"@{', @'.join(entities['x_handles'][:3])}")
|
||||
if has_subs:
|
||||
@@ -458,8 +469,10 @@ def _run_supplemental(
|
||||
# Run supplemental searches in parallel
|
||||
reddit_future = None
|
||||
x_future = None
|
||||
resolved_future = None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
max_workers = sum([has_subs, has_handles, has_resolved])
|
||||
with ThreadPoolExecutor(max_workers=max(max_workers, 1)) as executor:
|
||||
if has_subs:
|
||||
reddit_future = executor.submit(
|
||||
openai_reddit.search_subreddits,
|
||||
@@ -479,6 +492,16 @@ def _run_supplemental(
|
||||
count_per,
|
||||
)
|
||||
|
||||
if has_resolved:
|
||||
# Resolved handle: search unfiltered (topic=None) to get all recent posts
|
||||
resolved_future = executor.submit(
|
||||
bird_x.search_handles,
|
||||
[resolved_handle],
|
||||
None, # No topic filter - get all recent activity
|
||||
from_date,
|
||||
10, # More results for the topic entity
|
||||
)
|
||||
|
||||
if reddit_future:
|
||||
try:
|
||||
raw_reddit = reddit_future.result(timeout=30)
|
||||
@@ -504,6 +527,24 @@ def _run_supplemental(
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[Phase 2] Supplemental X error: {e}\n")
|
||||
|
||||
if resolved_future:
|
||||
try:
|
||||
raw_resolved = resolved_future.result(timeout=30)
|
||||
# Lower relevance for unfiltered handle posts (no topic keyword signal)
|
||||
for item in raw_resolved:
|
||||
item["relevance"] = 0.5
|
||||
resolved_new = [
|
||||
item for item in raw_resolved
|
||||
if item.get("url", "") not in existing_urls
|
||||
]
|
||||
supplemental_x.extend(resolved_new)
|
||||
if resolved_new:
|
||||
sys.stderr.write(f"[Phase 2] +{len(resolved_new)} from @{resolved_handle}\n")
|
||||
except TimeoutError:
|
||||
sys.stderr.write(f"[Phase 2] Resolved handle @{resolved_handle} timed out (30s)\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[Phase 2] Resolved handle error: {e}\n")
|
||||
|
||||
if supplemental_reddit or supplemental_x:
|
||||
sys.stderr.write(
|
||||
f"[Phase 2] +{len(supplemental_reddit)} Reddit, +{len(supplemental_x)} X\n"
|
||||
@@ -526,6 +567,7 @@ def run_research(
|
||||
x_source: str = "xai",
|
||||
run_youtube: bool = False,
|
||||
timeouts: dict = None,
|
||||
resolved_handle: str = None,
|
||||
) -> tuple:
|
||||
"""Run the research pipeline.
|
||||
|
||||
@@ -819,6 +861,7 @@ def run_research(
|
||||
topic, reddit_items, x_items,
|
||||
from_date, to_date, depth, x_source, progress,
|
||||
skip_reddit=rate_limited,
|
||||
resolved_handle=resolved_handle,
|
||||
)
|
||||
if sup_reddit:
|
||||
reddit_items.extend(sup_reddit)
|
||||
@@ -896,6 +939,13 @@ def main():
|
||||
metavar="SECS",
|
||||
help="Global timeout in seconds (default: 180, quick: 90, deep: 300)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--x-handle",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="HANDLE",
|
||||
help="Resolved X handle for topic entity (without @). Searched unfiltered in Phase 2.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1062,6 +1112,7 @@ def main():
|
||||
x_source=x_source or "xai",
|
||||
run_youtube=has_ytdlp,
|
||||
timeouts=timeouts,
|
||||
resolved_handle=args.x_handle,
|
||||
)
|
||||
|
||||
# Processing phase
|
||||
@@ -1139,6 +1190,7 @@ def main():
|
||||
report.youtube_error = youtube_error
|
||||
report.hackernews_error = hackernews_error
|
||||
report.web_error = web_error
|
||||
report.resolved_x_handle = args.x_handle
|
||||
|
||||
# Generate context snippet
|
||||
report.context_snippet_md = render.render_context_snippet(report)
|
||||
|
||||
@@ -272,7 +272,7 @@ def search_x(
|
||||
|
||||
def search_handles(
|
||||
handles: List[str],
|
||||
topic: str,
|
||||
topic: Optional[str],
|
||||
from_date: str,
|
||||
count_per: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -283,7 +283,7 @@ def search_handles(
|
||||
|
||||
Args:
|
||||
handles: List of X handles to search (without @)
|
||||
topic: Search topic (core subject, not full verbose query)
|
||||
topic: Search topic (core subject), or None for unfiltered search
|
||||
from_date: Start date (YYYY-MM-DD)
|
||||
count_per: Results to request per handle
|
||||
|
||||
@@ -291,11 +291,14 @@ def search_handles(
|
||||
List of raw item dicts (same format as parse_bird_response output).
|
||||
"""
|
||||
all_items = []
|
||||
core_topic = _extract_core_subject(topic)
|
||||
core_topic = _extract_core_subject(topic) if topic else None
|
||||
|
||||
for handle in handles:
|
||||
handle = handle.lstrip("@")
|
||||
query = f"from:{handle} {core_topic} since:{from_date}"
|
||||
if core_topic:
|
||||
query = f"from:{handle} {core_topic} since:{from_date}"
|
||||
else:
|
||||
query = f"from:{handle} since:{from_date}"
|
||||
|
||||
cmd = [
|
||||
"node", str(_BIRD_SEARCH_MJS),
|
||||
|
||||
@@ -117,6 +117,8 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
||||
lines.append(f"**OpenAI Model:** {report.openai_model_used}")
|
||||
if report.xai_model_used:
|
||||
lines.append(f"**xAI Model:** {report.xai_model_used}")
|
||||
if report.resolved_x_handle:
|
||||
lines.append(f"**Resolved X Handle:** @{report.resolved_x_handle}")
|
||||
lines.append("")
|
||||
|
||||
# Coverage note for partial coverage
|
||||
@@ -330,7 +332,10 @@ def render_source_status(report: schema.Report, source_info: dict = None) -> str
|
||||
if report.x_error:
|
||||
lines.append(f" ❌ X: error — {report.x_error}")
|
||||
elif report.x:
|
||||
lines.append(f" ✅ X: {len(report.x)} posts")
|
||||
x_line = f" ✅ X: {len(report.x)} posts"
|
||||
if report.resolved_x_handle:
|
||||
x_line += f" (via @{report.resolved_x_handle} + keyword search)"
|
||||
lines.append(x_line)
|
||||
elif report.mode in ("both", "x-only", "all", "x-web"):
|
||||
lines.append(" ⚠️ X: 0 posts found")
|
||||
else:
|
||||
|
||||
@@ -288,6 +288,8 @@ class Report:
|
||||
web_error: Optional[str] = None
|
||||
youtube_error: Optional[str] = None
|
||||
hackernews_error: Optional[str] = None
|
||||
# Handle resolution
|
||||
resolved_x_handle: Optional[str] = None
|
||||
# Cache info
|
||||
from_cache: bool = False
|
||||
cache_age_hours: Optional[float] = None
|
||||
@@ -312,6 +314,8 @@ class Report:
|
||||
'prompt_pack': self.prompt_pack,
|
||||
'context_snippet_md': self.context_snippet_md,
|
||||
}
|
||||
if self.resolved_x_handle:
|
||||
d['resolved_x_handle'] = self.resolved_x_handle
|
||||
if self.reddit_error:
|
||||
d['reddit_error'] = self.reddit_error
|
||||
if self.x_error:
|
||||
@@ -472,6 +476,7 @@ class Report:
|
||||
web_error=data.get('web_error'),
|
||||
youtube_error=data.get('youtube_error'),
|
||||
hackernews_error=data.get('hackernews_error'),
|
||||
resolved_x_handle=data.get('resolved_x_handle'),
|
||||
from_cache=data.get('from_cache', False),
|
||||
cache_age_hours=data.get('cache_age_hours'),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user