Skip to content

Commit 5387050

Browse files
fix: ground tweets in verifiable history so the quality filter passes
The quality filter was blocking 100% of tweets because the generator was forced to cite breaking news, which is past Gemini's training cutoff and therefore unverifiable (factual score ~0.2 -> below 0.65 threshold). The filter was working correctly; the prompt steered into its blind spot. - signals.py: add BBC World, BBC Middle East, Al Jazeera, Guardian World, NPR World feeds so geopolitics/macro markets get real context (40->110 headlines) - prove_the_loop.py: single-word signal matching with >=4 char tokens so 4-letter entities like "iran" match; drop digit tokens, expand stopwords - prove_the_loop.py: reframe news context as background awareness and steer line 2 to cite a VERIFIABLE HISTORICAL fact instead of breaking news - prove_the_loop.py: 3-attempt quality-filter retry loop with shared groq_client - prove_the_loop.py: expand SPORTS_KEYWORDS, add category-rotation logging Verify 3 now passes on attempt 1 (score 0.73-0.88). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b86e77f commit 5387050

2 files changed

Lines changed: 167 additions & 64 deletions

File tree

agent/integrations/signals.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,34 @@
4343
"url": "https://cryptopanic.com/news/rss/",
4444
"timeout": 8.0,
4545
},
46+
# General + geopolitics coverage — gives non-crypto markets (Iran, macro,
47+
# elections) real headlines to cite instead of forcing the generator to
48+
# invent unverifiable facts.
49+
{
50+
"name": "BBC World",
51+
"url": "https://feeds.bbci.co.uk/news/world/rss.xml",
52+
"timeout": 8.0,
53+
},
54+
{
55+
"name": "BBC Middle East",
56+
"url": "https://feeds.bbci.co.uk/news/world/middle_east/rss.xml",
57+
"timeout": 8.0,
58+
},
59+
{
60+
"name": "Al Jazeera",
61+
"url": "https://www.aljazeera.com/xml/rss/all.xml",
62+
"timeout": 8.0,
63+
},
64+
{
65+
"name": "Guardian World",
66+
"url": "https://www.theguardian.com/world/rss",
67+
"timeout": 8.0,
68+
},
69+
{
70+
"name": "NPR World",
71+
"url": "https://feeds.npr.org/1004/rss.xml",
72+
"timeout": 8.0,
73+
},
4674
]
4775

4876
GDELT_URL = (

prove_the_loop.py

Lines changed: 139 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import httpx
1818
import asyncio
1919
import os
20+
import re
2021
import sys
2122
import time
2223
from groq import Groq
@@ -79,6 +80,14 @@
7980
"Cubs",
8081
"Padres",
8182
"Giants",
83+
# Additional sports to filter with wider probability range
84+
"Toulouse", "Marseille", "Lyon", "Monaco",
85+
"Ligue 1", "Serie A", "Bundesliga", "Eredivisie",
86+
"Roland Garros", "Wimbledon", "US Open", "Australian Open",
87+
"Tour de France", "Superbowl", "Super Bowl",
88+
"World Series", "Stanley Cup", "NBA Finals",
89+
"cricket", "rugby", "MLS", "ATP", "WTA",
90+
"CS2", "CSGO", "Dota 2", "Overwatch", "PUBG",
8291
]
8392

8493
DASHBOARD_URL = os.getenv("DASHBOARD_URL", "arke.live")
@@ -205,19 +214,25 @@ def days_left(m: dict) -> int:
205214
# Pass 1: urgent, not in cooldown
206215
urgent = [m for m in feed if not in_cooldown(m) and 0 <= days_left(m) <= 7]
207216
if urgent:
208-
return urgent[0]
217+
selected = urgent[0]
218+
print(f" [urgent {days_left(selected)}d] {selected.get('question','')[:60]}")
219+
return selected
209220

210221
# Pass 2: medium term, not in cooldown
211222
medium = [m for m in feed if not in_cooldown(m) and 7 < days_left(m) <= 30]
212223
if medium:
213-
return medium[0]
224+
selected = medium[0]
225+
print(f" [medium {days_left(selected)}d] {selected.get('question','')[:60]}")
226+
return selected
214227

215228
# Pass 3: any not in cooldown (long-dated)
216229
available = [m for m in feed if not in_cooldown(m)]
217230
if available:
218-
return available[0]
231+
selected = available[0]
232+
print(f" [long-dated] {selected.get('question','')[:60]}")
233+
return selected
219234

220-
# Final fallback
235+
# Final fallback: ignore cooldown entirely
221236
print(" [WARN] All markets in cooldown — falling back to top market")
222237
return feed[0] if feed else None
223238

@@ -227,10 +242,8 @@ def days_left(m: dict) -> int:
227242
# ------------------------------------------------------------------ #
228243

229244

230-
def generate_tweet(market: dict, groq_api_key: str, news_context: str = "") -> str:
231-
"""Generate analytical tweet via Groq gpt-oss-120b."""
232-
client = Groq(api_key=groq_api_key)
233-
245+
def generate_tweet(market: dict, groq_client, news_context: str = "") -> str:
246+
"""Generate analytical tweet via Groq using the shared groq_client."""
234247
price = float(market.get("lastTradePrice", 0.5))
235248
pct = int(price * 100)
236249
question = market["question"]
@@ -246,53 +259,56 @@ def generate_tweet(market: dict, groq_api_key: str, news_context: str = "") -> s
246259

247260
news_block = ""
248261
if news_context:
249-
news_block = (
250-
f"\n\nBreaking news context (CITE THIS in your take):\n{news_context}"
251-
"\n\nYour take MUST reference one specific detail from the breaking news above."
252-
)
262+
news_block = f"""
263+
264+
CURRENT SITUATION (background only — do NOT cite this directly):
265+
{news_context[:400]}
266+
267+
Use this to understand the present moment. Your line-2 fact must be a
268+
VERIFIABLE HISTORICAL event, established pattern, or institutional record
269+
that a fact-checker can confirm from public record — NOT this breaking
270+
news, which is too recent to verify."""
253271

254-
prompt = f"""You are Arke, an autonomous prediction market intelligence agent. You write sharp, analytical tweets that crypto-native traders respect and engage with.
272+
prompt = f"""You are Arke, an autonomous prediction market intelligence agent.
273+
Write a sharp analytical tweet that crypto-native traders respect.
255274
256275
Market: {question}
257-
Current probability: {pct}% YES
258-
24hr volume: ${vol24:,.0f} USDC
276+
Probability: {pct}% YES
277+
Volume: ${vol24:,.0f} USDC/24h
259278
Resolves: {end_date}
260279
Context: {context}{news_block}
261280
262-
TWEET STRUCTURE (follow exactly):
263-
Line 1: State the event and the market's implied probability as a fact. One sentence. End with a period.
264-
Line 2: Your take — agree or disagree — with exactly one specific, data-grounded reason. Start with "I think" or "I disagree —". End with a period.
281+
TWEET STRUCTURE (3 lines, follow exactly):
282+
Line 1: State the market and probability as fact. One sentence. End with period.
283+
Line 2: Your take starting with "I think" or "I disagree —". MUST cite ONE
284+
specific verifiable fact: a named event, year, organization, or number.
285+
NOT vague phrases like "historical patterns" or "market dynamics".
265286
Line 3: "Bet: {market_url}"
266287
267-
RULES:
268-
- Total length under 240 characters including the URL
269-
- The probability must appear as a specific percentage number
270-
- Your reason must be specific — cite a mechanism, a historical pattern, or a named data point. Never vague generalities
271-
- Slightly contrarian is better than agreeing with consensus
272-
- No hashtags, no exclamation marks, no emojis, no ellipses
273-
- No comma splices — each clause is its own sentence
274-
- Do not start with "Market says" — vary the opening
275-
- Return only the tweet text, nothing else
276-
277-
GOOD EXAMPLES:
278-
"MicroStrategy holds $40B in Bitcoin with zero liquidation pressure. Market prices 55% chance they sell by May 31 — that contradicts every public commitment Saylor has made since 2020.
279-
Bet: polymarket.com/event/microstrategy-sell-any-bitcoin-in-2025"
280-
281-
"Strait of Hormuz at 30% normal traffic by June. Iran has closed it twice before and reopened within weeks under economic pressure. The market is underpricing normalization.
282-
Bet: polymarket.com/event/strait-of-hormuz-traffic-2026"
283-
284-
BAD EXAMPLES (never do these):
285-
"Market says 55% chance MicroStrategy sells Bitcoin by May 31, I disagree, Saylor's long term strategy is unchanged" — comma splice, vague reason
286-
"55% YES on MicroStrategy selling BTC! Interesting market! 🔥" — exclamations, emoji, no take"""
287-
288-
response = client.chat.completions.create(
288+
SPECIFICITY RULES — your take will be rejected if it is vague:
289+
GOOD: "Iran closed its airspace for 72 hours in April 2019 before IATA pressure forced reopening."
290+
GOOD: "The Fed has paused rates at 8 of the last 9 meetings since March 2023."
291+
GOOD: "MicroStrategy's 10-K filed February 2024 showed a 20% increase in BTC holdings."
292+
BAD: "historical patterns suggest this is unlikely"
293+
BAD: "market dynamics indicate underpricing"
294+
BAD: "this seems too high given recent events"
295+
296+
TWEET RULES:
297+
- Under 240 characters total including URL
298+
- Specific percentage number must appear in line 1
299+
- No hashtags, exclamation marks, emojis, ellipses
300+
- No comma splices — each clause its own sentence
301+
- Do not start with "Market says"
302+
- Return only the 3-line tweet, nothing else"""
303+
304+
response = groq_client.chat.completions.create(
289305
model="llama-3.3-70b-versatile",
290306
messages=[{"role": "user", "content": prompt}],
291307
temperature=0.7,
292308
max_tokens=280,
293309
)
294310

295-
return response.choices[0].message.content.strip()
311+
return (response.choices[0].message.content or "").strip()
296312

297313

298314
# ------------------------------------------------------------------ #
@@ -425,28 +441,48 @@ async def main(post: bool | None = None):
425441
try:
426442
from agent.integrations.signals import fetch_headlines
427443
headlines = await fetch_headlines()
428-
# Match headlines to market question
429-
question_words = set(
430-
w.lower() for w in market.get("question", "").split()
431-
if len(w) > 4
432-
)
433-
relevant = []
434-
for h in headlines:
435-
title_words = set(h.get("title", "").lower().split())
436-
if len(question_words & title_words) >= 2:
437-
relevant.append(h["title"])
438-
news_context = " | ".join(relevant[:3])
439-
if news_context:
440-
print(f" Signal context: {news_context[:100]}...")
444+
445+
if headlines:
446+
question = market.get("question", "").lower()
447+
448+
# Extract key entities: tokens >=4 chars, not pure digits, not stopwords.
449+
# >=4 (not >4) so 4-letter entities like "iran"/"gaza" match — the
450+
# critical fix for geopolitics markets that the >4 rule silently dropped.
451+
STOPWORDS = {"will", "what", "when", "does", "have", "this",
452+
"that", "with", "from", "they", "their", "been",
453+
"than", "into", "more", "also", "some", "such",
454+
"over", "most", "many", "year", "next", "week",
455+
"days", "time", "like", "just", "only"}
456+
question_words = [
457+
w for w in re.findall(r"[a-z0-9]+", question)
458+
if len(w) >= 4 and not w.isdigit() and w not in STOPWORDS
459+
]
460+
461+
relevant = []
462+
for h in headlines:
463+
title_lower = h.get("title", "").lower()
464+
# Match if ANY single key word from question appears in headline
465+
if any(w in title_lower for w in question_words):
466+
relevant.append(h["title"])
467+
468+
news_context = " | ".join(relevant[:4])
469+
if news_context:
470+
print(f" Signal context ({len(relevant)} matches): {news_context[:120]}...")
471+
else:
472+
print(f" No signal match in {len(headlines)} headlines — generating without context")
441473
else:
442-
print(" No relevant signals found — generating without context")
474+
print(" No headlines fetched — generating without context")
475+
443476
except Exception as e:
444477
print(f" Signal fetch failed: {e} — continuing without context")
445478

479+
# Groq client — shared by tweet generation and the quality-filter retry loop
480+
groq_client = Groq(api_key=groq_key)
481+
446482
# ── 3. Generate tweet ──────────────────────────────────────────
447483
print("\n[3/4] Generating tweet...")
448484
try:
449-
tweet = generate_tweet(market, groq_key, news_context=news_context)
485+
tweet = generate_tweet(market, groq_client, news_context=news_context)
450486
except Exception as e:
451487
print(f" ERROR generating tweet: {e}")
452488
if DB_AVAILABLE:
@@ -484,16 +520,55 @@ async def main(post: bool | None = None):
484520
filter_score = 0.8
485521
filter_passed = True
486522
filter_reason = "filter_not_run"
487-
try:
488-
from agent.agents.filter import quality_check
489-
filter_score, filter_passed, filter_reason = quality_check(tweet, market)
490-
print(f" Score: {filter_score:.2f} | Passed: {filter_passed} | {filter_reason}")
491-
except Exception as e:
492-
print(f" Filter error: {e} — fail-open")
523+
MAX_FILTER_RETRIES = 2
524+
525+
for attempt in range(MAX_FILTER_RETRIES + 1):
526+
try:
527+
from agent.agents.filter import quality_check
528+
filter_score, filter_passed, filter_reason = quality_check(tweet, market)
529+
print(f" Attempt {attempt+1}: score={filter_score:.2f} passed={filter_passed}")
530+
if filter_passed:
531+
break
532+
print(f" Blocked: {filter_reason}")
533+
if attempt < MAX_FILTER_RETRIES:
534+
print(f" Retrying with enriched prompt...")
535+
# Retry with a more prescriptive prompt
536+
enriched_prompt = f"""Previous take was rejected for being too vague: {filter_reason}
537+
538+
Rewrite this take citing a VERIFIABLE HISTORICAL fact — a named past event
539+
with a year, or an institutional record a fact-checker can confirm from
540+
public record. Do NOT cite recent breaking news (too recent to verify).
541+
542+
Market: {market.get('question', '')}
543+
Probability: {pct}% YES
544+
Resolves: {market.get('endDateIso', '')}
545+
{f'Background (do not cite directly): {news_context[:200]}' if news_context else ''}
546+
547+
REQUIREMENTS:
548+
- Line 1: state market + probability as fact
549+
- Line 2: start with "I think" or "I disagree —" + cite a specific past
550+
event, year, or named institution that can be independently verified.
551+
- Line 3: "Bet: {market_url}"
552+
- Under 240 chars total
553+
- No vague phrases. Specific verifiable facts only.
554+
- Return only the 3-line tweet."""
555+
556+
retry_response = groq_client.chat.completions.create(
557+
model="llama-3.3-70b-versatile",
558+
messages=[{"role": "user", "content": enriched_prompt}],
559+
temperature=0.5,
560+
max_tokens=280,
561+
)
562+
tweet = (retry_response.choices[0].message.content or "").strip()
563+
position = infer_arke_position(tweet)
564+
print(f" Retry tweet: {tweet[:100]}...")
565+
except Exception as e:
566+
print(f" Filter error: {e} — fail-open")
567+
filter_passed = True
568+
break
493569

494570
if not filter_passed:
495-
print(f" BLOCKED by quality filter — skipping post")
496-
print(f" Reason: {filter_reason}")
571+
print(f" BLOCKED after {MAX_FILTER_RETRIES+1} attempts — skipping post")
497572
if DB_AVAILABLE:
498573
db.record_run(
499574
"skipped",

0 commit comments

Comments
 (0)