fix(bird_x): normalize retries and preserve clean empty results

This commit is contained in:
spiky02plateau
2026-07-19 16:47:05 +02:00
parent 249c7a4c04
commit 89e699a35a
2 changed files with 77 additions and 2 deletions
+22 -2
View File
@@ -111,6 +111,16 @@ def _extract_core_subject(topic: str) -> str:
return extract_core_subject(topic, max_words=5, strip_suffixes=True)
def _plain_query_tokens(text: str) -> list[str]:
"""Return lexical tokens without Bird query grouping syntax."""
separators = str.maketrans({char: " " for char in '\"“”()[]{}'})
return [
clean
for token in text.translate(separators).split()
if (clean := token.strip("'‘’"))
]
def is_bird_installed() -> bool:
"""Check if vendored Bird search module is available.
@@ -361,17 +371,18 @@ def search_x(
timeout = 30 if depth == "quick" else 45 if depth == "default" else 60
# Extract core subject - X search is literal, not semantic
core_topic = _extract_core_subject(topic)
core_words = _plain_query_tokens(_extract_core_subject(topic))
core_topic = " ".join(core_words)
query = f"{core_topic} since:{from_date}"
_log(f"Searching: {query}")
response = _run_bird_search(query, count, timeout)
last_clean_response = response if not response.get("error") else None
# Check if we got results
items = parse_bird_response(response, query=core_topic)
# Retry with OR groups for multi-word queries (X supports OR operator)
core_words = core_topic.split()
if not items and len(core_words) >= 2:
from .query import extract_compound_terms
compounds = extract_compound_terms(topic)
@@ -381,6 +392,8 @@ def search_x(
_log(f"0 results for '{core_topic}', retrying with OR groups: {or_parts}")
query = f"({or_parts}) since:{from_date}"
response = _run_bird_search(query, count, timeout)
if not response.get("error"):
last_clean_response = response
items = parse_bird_response(response, query=core_topic)
# Retry with fewer keywords if still 0 results and query has 3+ words
@@ -389,6 +402,8 @@ def search_x(
_log(f"0 results for '{core_topic}', retrying with '{shorter}'")
query = f"{shorter} since:{from_date}"
response = _run_bird_search(query, count, timeout)
if not response.get("error"):
last_clean_response = response
items = parse_bird_response(response, query=core_topic)
# Last-chance retry: use strongest remaining token (often the product name)
@@ -412,7 +427,12 @@ def search_x(
_log(f"0 results for '{core_topic}', retrying anchored on '{retry_terms}'")
query = f"{retry_terms} since:{from_date}"
response = _run_bird_search(query, count, timeout)
if not response.get("error"):
last_clean_response = response
if response.get("error") and last_clean_response is not None:
_log("Optional retry failed after a clean empty response; preserving no-results outcome")
return last_clean_response
return response
+55
View File
@@ -5,6 +5,7 @@ import subprocess
import textwrap
import unittest
from pathlib import Path
from unittest import mock
from lib.bird_x import parse_bird_response
@@ -507,6 +508,60 @@ class TestStrongestTokenRetryAnchored(unittest.TestCase):
self.assertIn("agentcookie", queries[-1])
class TestBirdRetryQueryCorrectness(unittest.TestCase):
def test_quoted_topic_only_generates_balanced_retry_queries(self):
from lib import bird_x
queries = []
def fake_run(query, count, timeout):
queries.append(query)
if len(queries) == 1:
return {"items": []}
return {"error": "Bird search failed", "items": []}
with mock.patch.object(
bird_x,
"_extract_core_subject",
return_value='immobilienmakler(berlin "mixed-use',
), mock.patch(
"lib.query.extract_compound_terms",
return_value=['"immobilienmakler berlin"'],
), mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
response = bird_x.search_x(
'"Immobilienmakler Berlin" competitors',
"2026-07-12",
"2026-07-19",
)
self.assertGreaterEqual(len(queries), 2)
self.assertEqual(
"immobilienmakler berlin mixed-use since:2026-07-12",
queries[0],
)
for query in queries:
self.assertEqual(0, query.count('"') % 2, query)
self.assertEqual(query.count("("), query.count(")"), query)
self.assertNotIn("error", response)
self.assertEqual([], response["items"])
def test_every_failed_attempt_still_reports_backend_failure(self):
from lib import bird_x
with mock.patch.object(
bird_x, "_extract_core_subject", return_value="immobilienmakler berlin market"
), mock.patch.object(
bird_x,
"_run_bird_search",
return_value={"error": "Bird search failed", "items": []},
):
response = bird_x.search_x(
"Immobilienmakler Berlin market", "2026-07-12", "2026-07-19"
)
self.assertEqual("Bird search failed", response["error"])
class LeadingMentionsTests(unittest.TestCase):
"""U5: leading @mentions parsed from post text identify reply targets."""