Initial commit: Oracle AI research pipeline (adapters, pipeline, summarize, query)

Source-controlled baseline before Phase 5 cron. Excludes oracle.db,
logs/, and __pycache__ via .gitignore. Pipeline verified running
clean end-to-end (run_log write confirmed before conn.close()).
This commit is contained in:
Epictetus
2026-07-08 04:03:36 +00:00
commit 67c002b665
10 changed files with 3052 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Oracle generated data — not source, grows over time
oracle.db
oracle.db-journal
oracle.db-wal
oracle.db-shm
# Logs
logs/
# Python bytecache
__pycache__/
*.pyc
*.pyo
# Env / virtualenv
.venv/
.env
+17
View File
@@ -0,0 +1,17 @@
"""Source adapters for AI Research Oracle."""
from abc import ABC, abstractmethod
class SourceAdapter(ABC):
"""Base class for all ingestion adapters."""
@abstractmethod
def name(self) -> str:
"""Source name: 'github', 'arxiv', 'reddit'."""
pass
@abstractmethod
def fetch(self, query: str = "", limit: int = 20) -> list[dict]:
"""Return entries matching DB schema fields."""
pass
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env python3
"""
arXiv adapter for AI Research Oracle.
Fetches recent AI/ML papers via arXiv API (Atom XML).
No scraping — uses the official API endpoint.
Rate limits: 1 req/3s (be polite). arXiv enforces this aggressively.
============================================================================
SCORING DESIGN PRINCIPLE (do not violate when copying this to other adapters)
============================================================================
Structural metadata (recency, author count, abstract length, category
diversity) is a WEAK signal present in EVERY paper regardless of topic. It
must NEVER dominate the final score. Content-relevance signals (AI keyword
density in the abstract, methodology-contribution detection) MUST carry the
weight. A paper that merely USES an existing AI tool as incidental methodology
scores lower than one that CONTRIBUTES a new method — detection is by verb
(creative "we propose/introduce" vs evaluative "we evaluate/using"), not by
domain. Applied-domain papers are TAGGED for filtering, NEVER penalized: for a
startup-idea oracle, novel AI applied to a vertical is desired signal.
(See _score() and _methodology_claim_score() for the implementation.)
============================================================================
"""
import json
import os
import re
import time
import urllib.request
import urllib.error
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from html import unescape
from adapters import SourceAdapter
# arXiv API
ARXIV_API = "http://export.arxiv.org/api/query"
class ArxivAdapter(SourceAdapter):
"""arXiv API adapter."""
# Default categories to scan
DEFAULT_CATEGORIES = ["cs.AI", "cs.LG", "cs.CL"]
def __init__(self, categories=None, rate_limit=3):
"""
Args:
categories: List of arXiv categories. Default: cs.AI, cs.LG, cs.CL
rate_limit: Seconds between API calls (default 3).
"""
self.categories = categories or self.DEFAULT_CATEGORIES
self.rate_limit = rate_limit
def name(self) -> str:
return "arxiv"
def _parse_atom(self, xml_data: str) -> list[dict]:
"""Parse arXiv Atom XML into raw paper dicts."""
root = ET.fromstring(xml_data)
# Handle namespaces — arXiv uses multiple
# Walk all elements and extract what we need
papers = []
for entry in root.iter():
tag = entry.tag.split("}")[-1] if "}" in entry.tag else entry.tag
if tag == "entry":
paper = {
"id": "",
"title": "",
"summary": "",
"published": "",
"updated": "",
"authors": [],
"categories": [],
"comment": "",
"journal_ref": "",
"doi": "",
"link": "",
}
for child in entry:
ctag = child.tag.split("}")[-1]
if ctag == "id":
# arXiv ID like http://arxiv.org/abs/cs.AI/2607.00123
paper["id"] = child.text or ""
# Also extract clean ID
if "abs/" in paper["id"]:
paper["arxiv_id"] = paper["id"].split("abs/")[-1]
elif ctag == "title":
paper["title"] = unescape((child.text or "").strip())
elif ctag == "summary":
paper["summary"] = unescape((child.text or "").strip())
elif ctag == "published":
paper["published"] = child.text or ""
elif ctag == "updated":
paper["updated"] = child.text or ""
elif ctag == "author":
for ac in child:
a_tag = ac.tag.split("}")[-1]
if a_tag == "name":
paper["authors"].append(unescape((ac.text or "").strip()))
elif ctag == "category":
term = child.get("term", "")
if term:
paper["categories"].append(term)
elif ctag == "arxiv":
# arXiv-specific: comment, journal_ref, doi
sub_tag = child.tag.split("}")[-1]
if sub_tag == "comment":
paper["comment"] = unescape((child.text or "").strip())
elif sub_tag == "journal_ref":
paper["journal_ref"] = unescape((child.text or "").strip())
elif sub_tag == "doi":
paper["doi"] = unescape((child.text or "").strip())
elif ctag == "link":
href = child.get("href", "")
# Prefer the abstract page link
if "abs/" in href and not paper["link"]:
paper["link"] = href
elif "pdf/" in href:
paper["pdf_link"] = href
# Clean up arxiv_id if not extracted from ID field
if "arxiv_id" not in paper and "abs/" in paper.get("id", ""):
paper["arxiv_id"] = paper["id"].split("abs/")[-1]
if paper["title"] and paper["summary"]:
papers.append(paper)
return papers
def _request(self, query: str, max_results: int = 20, sort_by="submittedDate") -> list[dict]:
"""Make an arXiv API request."""
url = (
f"{ARXIV_API}"
f"?search_query={urllib.parse.quote(query)}"
f"&sortBy={sort_by}"
f"&sortOrder=descending"
f"&max_results={max_results}"
)
req = urllib.request.Request(url, headers={"User-Agent": "ai-oracle/0.1"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
xml_data = resp.read().decode("utf-8")
return self._parse_atom(xml_data)
except urllib.error.HTTPError as e:
print(f" HTTP {e.code} for arXiv query")
return []
except Exception as e:
print(f" arXiv request error: {e}")
return []
def _score(self, paper: dict, age_days: float) -> float:
"""Score based on AI-methodology relevance, not structural metadata.
arXiv has no upvotes/stars. The key signal is whether the paper is a
genuine AI/ML *contribution* (new architecture, framework, method) vs.
merely *using* an existing AI tool as incidental methodology.
Structural signals (recency, author count, category diversity) are
weak and capped low so they cannot dominate the score — any paper has
them regardless of topic. The relevance signals (methodology claim +
AI keyword density) carry the weight.
Applied-domain papers (healthcare, finance, biology) are TAGGED for
filtering but NOT penalized — for a startup-idea oracle, "novel AI
technique applied to a vertical" is exactly the signal we want.
"""
# --- Structural signals (weak, capped low) ---
# Recency: newer = slightly higher, but max 2.0 (was 5.0)
try:
pub_str = paper.get("published", "")
pub_dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
age_hours = (now - pub_dt).total_seconds() / 3600
recency = max(0, 2.0 - age_hours / 48.0)
except (ValueError, TypeError):
recency = max(0, 2.0 - age_days * 0.1)
# Author count: collaborative work is a weak signal, max 0.5 (was 2.0)
author_count = len(paper.get("authors", []))
author_bonus = min(author_count * 0.05, 0.5)
# Category diversity: cross-domain is mildly interesting, max 0.3
cats = paper.get("categories", [])
diversity_bonus = min(len(cats) * 0.1, 0.3)
# Journal/DOI: published = validated, small bonus
journal_bonus = 0.5 if paper.get("journal_ref") else 0.0
doi_bonus = 0.2 if paper.get("doi") else 0.0
# Comment field (page count etc.): tiny bonus
comment = paper.get("comment", "")
comment_bonus = min(len(comment) / 300.0, 0.5) if comment else 0.0
# --- Relevance signals (carry the weight) ---
title_lower = paper.get("title", "").lower()
abstract = paper.get("summary", "")
abstract_lower = abstract.lower()
# AI keyword density in abstract (not just title)
ai_keywords = [
"agent", "llm", "large language", "gpt", "transformer",
"neural", "deep learning", "machine learning", "reinforcement",
"diffusion", "multimodal", "embedding", "attention", "rag",
"retrieval", "fine-tun", "pretrain", "self-supervised",
"foundation model", "reasoning", "alignment", "policy gradient",
"graph neural", "vision-language", "vla", "vlm",
]
ai_kw_hits = sum(1 for kw in ai_keywords if kw in abstract_lower)
# Density matters more than single mention; saturate at ~6 hits
ai_keyword_bonus = min(ai_kw_hits * 0.35, 2.0)
# Title keyword bonus (small)
title_kw_list = ["agent", "llm", "reasoning", "verif", "multimodal",
"embodied", "robot", "alignment", "autonomous"]
title_kw_hits = sum(1 for kw in title_kw_list if kw in title_lower)
title_kw_bonus = min(title_kw_hits * 0.2, 0.5)
# AI methodology claim: does the abstract propose/invent something?
methodology_bonus = self._methodology_claim_score(abstract_lower)
# --- Domain tag (no penalty) ---
combined_text = f"{title_lower} {abstract_lower}"
applied_domains = {
"applied:healthcare": ["gastric", "biopsy", "pathology", "clinical",
"patient", "diagnosis", "medical imaging", "h. pylori",
"cancer screening", "oncology", "neurology", "cardiology"],
"applied:finance": ["stock market", "trading", "portfolio", "forex",
"cryptocurrency", "fintech", "credit scoring", "fraud detection"],
"applied:biology": ["protein folding", "gene expression", "genome",
"molecular", "bioinformatics", "cell type", "organism"],
}
primary_domain = None
for domain, domain_keywords in applied_domains.items():
hits = sum(1 for kw in domain_keywords if kw in combined_text)
if hits >= 2: # need at least 2 domain keywords to trigger
primary_domain = domain
break
if primary_domain:
paper["_applied_domain"] = primary_domain
# Total: structural (capped ~3.5) + relevance (capped ~5.0)
structural = recency + author_bonus + diversity_bonus + journal_bonus + doi_bonus + comment_bonus
relevance = ai_keyword_bonus + title_kw_bonus + methodology_bonus
score = structural + relevance
return min(round(score, 2), 10.0)
def _methodology_claim_score(self, abstract_lower: str) -> float:
"""Detect whether the abstract proposes a NEW AI method vs. using an existing one.
The core distinction is the VERB: creative verbs (propose/introduce/
develop/design) signal a contribution; evaluative verbs (evaluate/
conduct/retrospective/using/based on) signal applying an existing tool.
Check weak (evaluative) patterns FIRST — if the paper is an evaluation
or application of an existing system, it gets 0.5 even if it also says
'we present'. Only if no weak pattern matches do we look for a genuine
contribution claim.
Returns 0.0 (no claim / unclear), 0.5 (uses existing tool),
2.0 (novel method), or 2.5 (explicit proposal of new AI system).
"""
# Weak FIRST: evaluative / application language
weak_markers = ["we evaluate", "we conducted", "we conduct", "retrospective",
"case study", "pilot study", "using", "we leverage",
"we applied", "based on", "we report a", "we present a retrospective",
"an evaluation of", "we benchmark", "empirical study"]
for wm in weak_markers:
if wm in abstract_lower:
return 0.5
# Strong: genuinely creative verbs + AI noun within 60 chars
proposal_verbs = ["we propose", "we introduce", "we develop", "we design",
"we formulate", "we construct", "we build", "we present a novel",
"we present a new"]
ai_nouns = ["model", "architecture", "framework", "method", "approach",
"agent", "system", "network", "algorithm", "pipeline",
"llm", "transformer", "policy", "graph", "orchestration",
"harness", "memory", "solver", "planner"]
for verb in proposal_verbs:
idx = abstract_lower.find(verb)
if idx != -1:
window = abstract_lower[idx:idx + 60]
if any(noun in window for noun in ai_nouns):
return 2.5
# Strong-ish: 'novel'/'new' + AI architecture/framework/model within 40 chars
for marker in ["novel", "new"]:
idx = abstract_lower.find(marker)
scan = 0
while idx != -1 and scan < 5:
window = abstract_lower[idx:idx + 40]
if any(noun in window for noun in
["architecture", "framework", "model", "method",
"approach", "agent", "system", "harness"]):
return 2.0
idx = abstract_lower.find(marker, idx + 1)
scan += 1
return 0.0
def _tags(self, paper: dict) -> list:
"""Generate category tags from arXiv metadata."""
tags = ["arxiv"]
# Add arXiv categories
for cat in paper.get("categories", [])[:5]:
tags.append(f"cat:{cat}")
# Primary signal categories
cats_lower = [c.lower() for c in paper.get("categories", [])]
if "cs.ai" in cats_lower:
tags.append("ai-general")
if "cs.lg" in cats_lower:
tags.append("machine-learning")
if "cs.cl" in cats_lower:
tags.append("nlp")
if "cs.cv" in cats_lower:
tags.append("computer-vision")
if "cs.ro" in cats_lower:
tags.append("robotics")
if "cs.se" in cats_lower:
tags.append("software-engineering")
# Check if it's a survey/tutorial
title_lower = paper.get("title", "").lower()
summary_lower = paper.get("summary", "").lower()
if any(kw in summary_lower for kw in ["survey", "tutorial", "overview of", "review of"]):
tags.append("survey")
# Check for preprint vs published
if paper.get("journal_ref"):
tags.append("published")
else:
tags.append("preprint")
# Applied-domain tag
if paper.get("_applied_domain"):
tags.append(paper["_applied_domain"])
return tags
def fetch(self, query: str = "", limit: int = 20) -> list[dict]:
"""
Fetch papers from arXiv.
If query is empty, fetch recent papers from configured categories.
If query is provided, search for it.
"""
now = datetime.now(timezone.utc)
all_papers = []
if query:
print(f" Searching arXiv: '{query}'")
papers = self._request(query, max_results=limit, sort_by="submittedDate")
all_papers.extend(papers)
else:
# Fetch from each configured category
for cat in self.categories:
q = f"cat:{cat}"
cat_papers = self._request(q, max_results=limit, sort_by="submittedDate")
all_papers.extend(cat_papers)
time.sleep(self.rate_limit)
# Deduplicate by arxiv_id
seen = set()
unique = []
for p in all_papers:
pid = p.get("arxiv_id", p.get("id", ""))
if pid and pid not in seen:
seen.add(pid)
unique.append(p)
all_papers = unique
# Sort by published date (newest first), take top limit
all_papers.sort(
key=lambda p: p.get("published", ""),
reverse=True,
)
all_papers = all_papers[:limit]
entries = []
for paper in all_papers:
# Calculate age
published = paper.get("published", "")
try:
pub_dt = datetime.fromisoformat(published.replace("Z", "+00:00"))
age_days = (now - pub_dt).days
except (ValueError, TypeError):
age_days = 0
score = self._score(paper, age_days)
tags = self._tags(paper)
# Source ID: arxiv_id
source_id = paper.get("arxiv_id", paper.get("id", "")).split("/")[-1]
# URL: abstract page
url = paper.get("link", "")
if not url and paper.get("arxiv_id"):
url = f"https://arxiv.org/abs/{paper['arxiv_id']}"
# Title (clean — remove trailing category markers)
title = re.sub(r"\s*\([A-Za-z0-9., ]*\)\s*$", "", paper.get("title", "")).strip()
# Extracted text: summary (abstract) — already clean
extracted_text = paper.get("summary", "")
# Structured metadata
raw_meta = {
"arxiv_id": paper.get("arxiv_id", ""),
"authors": paper.get("authors", []),
"author_count": len(paper.get("authors", [])),
"categories": paper.get("categories", []),
"published": paper.get("published", ""),
"updated": paper.get("updated", ""),
"comment": paper.get("comment", ""),
"journal_ref": paper.get("journal_ref", ""),
"doi": paper.get("doi", ""),
"pdf_link": paper.get("pdf_link", ""),
"age_days": age_days,
"abstract_length": len(extracted_text),
"score_type": "estimated", # arXiv has no upvotes
}
# Store applied-domain info if detected
if paper.get("_applied_domain"):
raw_meta["applied_domain"] = paper["_applied_domain"]
now_str = now.strftime("%Y-%m-%dT%H:%M:%SZ")
entries.append({
"source": "arxiv",
"source_id": source_id,
"url": url,
"title": title,
"extracted_text": extracted_text,
"summary": None,
"category_tags": json.dumps(tags),
"signal_score": score,
"raw_metadata": json.dumps(raw_meta),
"first_seen": now_str,
"last_updated": now_str,
})
return entries
if __name__ == "__main__":
import argparse
import sqlite3
import urllib.parse
parser = argparse.ArgumentParser(description="arXiv adapter for AI Research Oracle")
parser.add_argument("--query", default="", help="Search query (empty = recent categories)")
parser.add_argument("--limit", type=int, default=20, help="Max entries")
parser.add_argument("--db", default=os.path.join(os.path.dirname(__file__), "..", "oracle.db"), help="SQLite DB")
parser.add_argument("--schema", default=os.path.join(os.path.dirname(__file__), "..", "schema.sql"), help="Schema file")
parser.add_argument("--dry-run", action="store_true", help="Don't store in DB")
args = parser.parse_args()
print(f"=== arXiv Adapter ===")
print(f" Query: {args.query or '(recent AI categories)'}")
print(f" Limit: {args.limit}")
print()
adapter = ArxivAdapter()
entries = adapter.fetch(query=args.query, limit=args.limit)
print(f" Fetched {len(entries)} entries")
if not args.dry_run:
conn = sqlite3.connect(args.db)
if os.path.exists(args.schema):
with open(args.schema) as f:
conn.executescript(f.read())
conn.commit()
cur = conn.cursor()
stored = 0
for entry in entries:
try:
cur.execute("""
INSERT OR REPLACE INTO entries
(source, source_id, url, title, extracted_text, summary,
category_tags, signal_score, raw_metadata, first_seen, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["source"], entry["source_id"], entry["url"], entry["title"],
entry["extracted_text"], entry["summary"],
entry["category_tags"], entry["signal_score"],
entry["raw_metadata"], entry["first_seen"], entry["last_updated"],
))
stored += 1
except Exception as e:
print(f" DB error: {e}")
conn.commit()
conn.close()
print(f" Stored {stored} entries")
# Print top 5
print(f"\n Top entries:")
for i, e in enumerate(entries[:5]):
meta = json.loads(e["raw_metadata"]) if isinstance(e["raw_metadata"], str) else e["raw_metadata"]
authors = meta.get("authors", [])
author_str = f"{authors[0]} et al." if len(authors) > 2 else ", ".join(authors[:2])
print(f" [{i+1}] score={e['signal_score']:.2f} authors={author_str}")
print(f" {e['title'][:90]}")
print(f" {e['url']}")
print(f" abstract={meta.get('abstract_length', 0)}ch")
print(f"\n Done.")
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""
GitHub adapter for AI Research Oracle.
Fetches repos via GitHub REST API — no scraping, no trending page.
Definition of "trending": created or pushed in last N days, sorted by stars.
Rate limits: 60 req/hr unauthenticated.
Strategy: cache results, deduplicate, budget calls.
"""
import json
import os
import re
import time
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime, timedelta, timezone
from adapters import SourceAdapter
class GitHubAdapter(SourceAdapter):
"""GitHub REST API adapter."""
BASE = "https://api.github.com"
def __init__(self, token: str = None):
"""Initialize with optional read-only token (5000 req/hr vs 60)."""
self.token = token or os.environ.get("GITHUB_TOKEN", "")
self.cache = {}
def name(self) -> str:
return "github"
def _headers(self):
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "ai-oracle/0.1",
}
if self.token:
headers["Authorization"] = f"token {self.token}"
return headers
def _request(self, url: str, max_retries: int = 2) -> dict | list | None:
"""Make a GET request with retry on 403 (rate limit)."""
req = urllib.request.Request(url, headers=self._headers())
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
# Check rate limit headers
remaining = int(resp.headers.get("X-RateLimit-Remaining", 0))
if remaining <= 5:
print(f" ⚠ Rate limit low ({remaining} remaining), stopping")
break
return data
except urllib.error.HTTPError as e:
if e.code == 403:
# Rate limited — reset time is in headers
reset = int(e.headers.get("X-RateLimit-Reset", 0))
if reset:
wait = max(reset - int(time.time()), 0) + 1
print(f" ⚠ Rate limited, wait {wait}s")
else:
wait = 30 * (attempt + 1)
print(f" 403 on attempt {attempt + 1}, retry in {wait}s")
time.sleep(min(wait, 300)) # cap at 5 min
continue
print(f" HTTP {e.code} for {url}")
return None
except Exception as e:
print(f" Request error: {e}")
return None
return None
def _search_repos(self, query: str, sort: str = "stars", order: str = "desc", per_page: int = 30) -> list:
"""Search repositories via GitHub API."""
url = f"{self.BASE}/search/repositories?q={urllib.parse.quote(query)}&sort={sort}&order={order}&per_page={per_page}"
result = self._request(url)
if isinstance(result, dict) and "items" in result:
return result["items"]
return []
def _get_readme(self, repo_url: str) -> str:
"""Fetch README content for a repo."""
if repo_url in self.cache:
return self.cache[repo_url]
result = self._request(repo_url, max_retries=1)
if result and isinstance(result, dict):
import base64
content = result.get("content", "")
encoding = result.get("encoding", "base64")
if encoding == "base64" and content:
try:
decoded = base64.b64decode(content).decode("utf-8", errors="replace")
# Truncate to 8000 chars to keep context manageable
self.cache[repo_url] = decoded[:8000]
return decoded[:8000]
except Exception:
pass
return ""
def _score(self, repo: dict, age_days: float) -> float:
"""Score: stars weighted by recency. Higher = better signal."""
stars = repo.get("stargazers_count", 0)
# Normalize: log scale on stars, decay by age
import math
star_score = min(math.log1p(stars) / 2.0, 10.0) # log(1000) ≈ 6.9 → ~3.5
# Recency bonus: newer repos get a slight boost
recency_bonus = max(0, 1.0 - age_days / 30.0) * 1.5 # up to +1.5 for very recent
return min(star_score + recency_bonus, 10.0)
def _tags(self, repo: dict) -> list:
"""Generate category tags from repo metadata."""
tags = ["github"]
lang = repo.get("language", "")
if lang:
tags.append(f"lang:{lang.lower()}")
topics = repo.get("topics", [])
for t in topics[:5]:
tags.append(f"topic:{t}")
# Check description for AI/ML signals
desc = (repo.get("description") or "").lower()
if any(kw in desc for kw in ["agent", "agents"]):
tags.append("agents")
if any(kw in desc for kw in ["llm", "large language"]):
tags.append("llm")
if any(kw in desc for kw in ["rag", "retrieval"]):
tags.append("rag")
return tags
def fetch(self, query: str = "", limit: int = 20) -> list[dict]:
"""
Fetch repos from GitHub.
If query is empty, fetch "trending" = recent high-star AI repos.
If query is provided, search for it.
"""
now = datetime.now(timezone.utc)
if query:
print(f" Searching: '{query}'")
repos = self._search_repos(query, sort="stars", per_page=min(limit * 2, 100))
else:
# "Trending" = AI repos created in last 14 days, sorted by stars
cutoff = (now - timedelta(days=14)).strftime("%Y-%m-%d")
# Two queries to get breadth
repos = []
for q in ["ai agents created:>=2026-06-01", "llm inference created:>=2026-06-01"]:
batch = self._search_repos(q, sort="stars", per_page=50)
repos.extend(batch)
time.sleep(1) # polite spacing
# Deduplicate by full_name
seen = set()
unique = []
for r in repos:
fn = r.get("full_name", "")
if fn not in seen:
seen.add(fn)
unique.append(r)
repos = unique
# Sort by stars descending, take top limit
repos.sort(key=lambda r: r.get("stargazers_count", 0), reverse=True)
repos = repos[:limit]
entries = []
readme_budget = min(10, limit) # Only fetch README for top 10 to stay under rate limit
for idx, repo in enumerate(repos):
# Calculate age
created = repo.get("created_at")
if created:
try:
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
age_days = (now - created_dt).days
except (ValueError, TypeError):
age_days = 0
else:
age_days = 0
score = self._score(repo, age_days)
tags = self._tags(repo)
# Source ID: repo full_name
source_id = repo.get("full_name", "").replace("/", "__")
# URL
url = repo.get("html_url", "")
# Title: repo name with description
name = repo.get("name", "")
desc = repo.get("description", "") or ""
if desc:
title = f"{name}: {desc[:100]}"
else:
title = name
# README as extracted_text
# Search API doesn't return readme_url — construct it from owner/repo
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
readme_text = ""
if owner and repo_name and idx < readme_budget:
readme_text = self._get_readme(f"https://api.github.com/repos/{owner}/{repo_name}/readme")
time.sleep(0.5) # polite spacing between README fetches
# Structured metadata
raw_meta = {
"full_name": repo.get("full_name", ""),
"owner": repo.get("owner", {}).get("login", ""),
"stars": repo.get("stargazers_count", 0),
"forks": repo.get("forks_count", 0),
"open_issues": repo.get("open_issues_count", 0),
"language": repo.get("language", ""),
"topics": repo.get("topics", []),
"created_at": repo.get("created_at", ""),
"updated_at": repo.get("updated_at", ""),
"pushed_at": repo.get("pushed_at", ""),
"age_days": age_days,
"readme_length": len(readme_text),
"score_type": "actual", # based on real star counts
}
now_str = now.strftime("%Y-%m-%dT%H:%M:%SZ")
entries.append({
"source": "github",
"source_id": source_id,
"url": url,
"title": title,
"extracted_text": readme_text,
"summary": None, # LLM later
"category_tags": json.dumps(tags),
"signal_score": round(score, 2),
"raw_metadata": json.dumps(raw_meta),
"first_seen": now_str,
"last_updated": now_str,
})
return entries
if __name__ == "__main__":
import argparse
import sqlite3
import os
parser = argparse.ArgumentParser(description="GitHub adapter for AI Research Oracle")
parser.add_argument("--query", default="", help="Search query (empty = trending AI)")
parser.add_argument("--limit", type=int, default=20, help="Max entries")
parser.add_argument("--db", default=os.path.join(os.path.dirname(__file__), "..", "oracle.db"), help="SQLite DB")
parser.add_argument("--schema", default=os.path.join(os.path.dirname(__file__), "..", "schema.sql"), help="Schema file")
parser.add_argument("--dry-run", action="store_true", help="Don't store in DB")
args = parser.parse_args()
print(f"=== GitHub Adapter ===")
print(f" Query: {args.query or '(trending AI)'}")
print(f" Limit: {args.limit}")
print()
adapter = GitHubAdapter()
entries = adapter.fetch(query=args.query, limit=args.limit)
print(f" Fetched {len(entries)} entries")
if not args.dry_run:
conn = sqlite3.connect(args.db)
if os.path.exists(args.schema):
with open(args.schema) as f:
conn.executescript(f.read())
conn.commit()
cur = conn.cursor()
stored = 0
for entry in entries:
try:
cur.execute("""
INSERT OR REPLACE INTO entries
(source, source_id, url, title, extracted_text, summary,
category_tags, signal_score, raw_metadata, first_seen, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["source"], entry["source_id"], entry["url"], entry["title"],
entry["extracted_text"], entry["summary"],
entry["category_tags"], entry["signal_score"],
entry["raw_metadata"], entry["first_seen"], entry["last_updated"],
))
stored += 1
except Exception as e:
print(f" DB error: {e}")
conn.commit()
conn.close()
print(f" Stored {stored} entries")
# Print top 5
print(f"\n Top entries:")
for i, e in enumerate(entries[:5]):
meta = json.loads(e["raw_metadata"]) if isinstance(e["raw_metadata"], str) else e["raw_metadata"]
print(f" [{i+1}] score={e['signal_score']:.2f} stars={meta.get('stars', '?')}")
print(f" {e['title'][:90]}")
print(f" {e['url']}")
print(f" text={len(e.get('extracted_text', ''))}ch")
print(f"\n Done.")
+535
View File
@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""
Reddit adapter v2 for AI Research Oracle.
Uses Reddit RSS feeds (Atom XML) with proper filtering and scoring.
Improvements over v1 (proof of concept):
- Filters AutoModerator posts and sticky threads
- Proper User-Agent header
- Attempts old.reddit.com .json for real upvote/comment data
- Falls back to RSS with estimated scoring if JSON blocked
- Rate limit awareness with retry logic
Rate limits: Reddit aggressively 429s unauthenticated requests.
Strategy: 3s spacing between subreddits, retry with backoff.
"""
import json
import os
import re
import time
import urllib.request
import urllib.error
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from html import unescape
from adapters import SourceAdapter
class RedditAdapter(SourceAdapter):
"""Reddit RSS + JSON adapter."""
# Default subreddits for AI content
DEFAULT_SUBREDDITS = [
"MachineLearning", "artificial", "LocalLLaMA", "Startups",
]
# Posts to filter out (sticky threads, recurring mod posts)
FILTER_TITLES = {
"self-promotion thread",
"monthly who's hiring",
"weekly self-promotion",
"discussion thread",
"show and tell",
"weekly show",
}
FILTER_AUTHORS = {
"automod",
"automoderator",
}
def __init__(self, subreddits=None, rate_limit=3, user_agent=None):
"""
Args:
subreddits: List of subreddit names.
rate_limit: Seconds between subreddit requests.
user_agent: Custom User-Agent header.
"""
self.subreddits = subreddits or self.DEFAULT_SUBREDDITS
self.rate_limit = rate_limit
self.user_agent = user_agent or "python:ai-oracle:v0.1 (by tony_tech)"
def name(self) -> str:
return "reddit"
def _clean_html(self, html: str) -> str:
"""Extract readable text from Reddit's HTML content."""
if not html:
return ""
text = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
text = re.sub(r"<div[^>]*>", "\n", text)
text = re.sub(r"</div>", "\n", text)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"<p[^>]*>", "\n", text)
text = re.sub(r"</p>", "\n", text)
text = re.sub(r"<a[^>]*href=\"([^\"]+)\"[^>]*>([^<]*)</a>", r"\2", text)
text = re.sub(r"<[^>]+>", "", text)
text = unescape(text)
text = re.sub(r"\n\s*\n+", "\n\n", text)
return text.strip()
def _is_sticky(self, entry: dict) -> bool:
"""Check if a post is a sticky/mod post that should be filtered."""
title_lower = entry.get("title", "").lower().strip()
author_lower = entry.get("author", "").lower().strip()
# Filter by author
if author_lower in self.FILTER_AUTHORS:
return True
# Filter by title patterns
for pattern in self.FILTER_TITLES:
if pattern in title_lower:
return True
# Filter very short posts (likely link-only with no content)
if len(entry.get("content", "")) < 20:
return True
return False
def _fetch_rss(self, subreddit: str) -> list[dict]:
"""Fetch RSS feed for a subreddit."""
url = f"https://www.reddit.com/r/{subreddit}/hot/.rss?limit=50"
req = urllib.request.Request(url, headers={"User-Agent": self.user_agent})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
xml_data = resp.read().decode("utf-8")
break
except urllib.error.HTTPError as e:
if e.code == 429:
wait = 5 * (attempt + 1)
time.sleep(wait)
continue
print(f" RSS HTTP {e.code} for r/{subreddit}")
return []
except Exception as e:
print(f" RSS error r/{subreddit}: {e}")
return []
else:
print(f" r/{subreddit}: still rate limited, skip")
return []
# Parse Atom XML
entries = []
root = ET.fromstring(xml_data)
for entry_el in root.iter():
tag = entry_el.tag.split("}")[-1] if "}" in entry_el.tag else entry_el.tag
if tag != "entry":
continue
data = {"title": "", "url": "", "author": "", "content": "", "published": "", "id": ""}
for child in entry_el:
ctag = child.tag.split("}")[-1]
if ctag == "title":
data["title"] = unescape((child.text or "").strip())
elif ctag == "link":
data["url"] = child.get("href", "")
elif ctag == "author":
for ac in child:
if ac.tag.split("}")[-1] == "name":
data["author"] = unescape((ac.text or "").strip())
elif ctag == "content":
data["content"] = child.text or ""
elif ctag == "published":
data["published"] = child.text or ""
elif ctag == "id":
data["id"] = child.text or ""
if data["title"] and data["url"]:
entries.append(data)
return entries
def _try_json(self, subreddit: str, sort: str = "hot") -> list[dict]:
"""Try to fetch JSON data from old.reddit.com for real scores."""
url = f"https://old.reddit.com/r/{subreddit}/{sort}.json?limit=25"
req = urllib.request.Request(url, headers={
"User-Agent": self.user_agent,
"Accept": "application/json",
})
for attempt in range(2):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
posts = []
if isinstance(data, dict) and "data" in data:
for child in data["data"].get("children", []):
d = child.get("data", {})
if d.get("title"):
posts.append({
"title": d.get("title", ""),
"url": d.get("url", ""),
"author": d.get("author", ""),
"selftext": d.get("selftext", ""),
"score": d.get("score", 0),
"num_comments": d.get("num_comments", 0),
"ups": d.get("ups", 0),
"id": d.get("id", ""),
"created_utc": d.get("created_utc", 0),
"permalink": d.get("permalink", ""),
"link_flair_text": d.get("link_flair_text", ""),
"subreddit": subreddit,
"source": "json",
})
return posts
except urllib.error.HTTPError as e:
if e.code in (429, 403, 404):
return [] # JSON endpoint blocked, fall back to RSS
if attempt < 1:
time.sleep(5)
continue
return []
except Exception:
return []
return []
def _score(self, entry: dict, source: str) -> float:
"""Score based on available signals.
If source is 'json', use real upvote/comment counts.
If source is 'rss', use heuristics (content length, flair, etc.).
"""
if source == "json" and entry.get("score", 0) > 0:
import math
# Real upvote-based scoring
score = entry.get("score", 0)
comments = entry.get("num_comments", 0)
# Log scale on upvotes, bonus for engagement
star_score = min(math.log1p(score) / 2.0, 8.0)
engagement = min(comments / 50.0, 2.0)
return min(round(star_score + engagement, 2), 10.0)
else:
# RSS heuristic — no real scores available
content = entry.get("content", "")
title = entry.get("title", "")
# Base score
base = 3.0
# Content length bonus (substantive posts get higher scores)
content_len = len(self._clean_html(content))
if content_len > 5000:
base += 2.0
elif content_len > 2000:
base += 1.5
elif content_len > 1000:
base += 1.0
elif content_len > 500:
base += 0.6
elif content_len > 200:
base += 0.3
elif content_len > 50:
base += 0.1
# Post type from title markers
if " [R]" in title or " [r]" in title: # Research
base += 1.5
elif " [P]" in title or " [p]" in title: # Project
base += 1.2
elif " [N]" in title or " [n]" in title: # News
base += 0.8
elif " [D]" in title or " [d]" in title: # Discussion
base += 0.5
# Flair bonus
flair = entry.get("link_flair_text", "").lower()
if "research" in flair:
base += 1.0
elif "project" in flair:
base += 0.8
return min(round(base, 2), 10.0)
def _tags(self, entry: dict, source: str) -> list:
"""Generate category tags from Reddit metadata."""
tags = ["reddit"]
subreddit = entry.get("subreddit", "").lower()
if "machinelearning" in subreddit:
tags.append("machine-learning")
elif "artificial" in subreddit:
tags.append("ai-general")
elif "localllama" in subreddit:
tags.append("local-llm")
elif "startups" in subreddit:
tags.append("startups")
title = entry.get("title", "")
if " [P]" in title or " [p]" in title:
tags.append("project")
elif " [R]" in title or " [r]" in title:
tags.append("research")
elif " [D]" in title or " [d]" in title:
tags.append("discussion")
elif " [N]" in title or " [n]" in title:
tags.append("news")
else:
tags.append("general")
# Check for self-referential virality signal
content = entry.get("content", "").lower()
title_lower = title.lower()
if any(kw in title_lower or kw in content for kw in [
"stars", "viral", "trending", "blow up", "trending github",
]):
tags.append("meta:virality")
return tags
def _fetch_rss(self, subreddit: str) -> list[dict]:
"""Fetch RSS feed for a subreddit."""
url = f"https://www.reddit.com/r/{subreddit}/hot/.rss?limit=50"
req = urllib.request.Request(url, headers={"User-Agent": self.user_agent})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
xml_data = resp.read().decode("utf-8")
break
except urllib.error.HTTPError as e:
if e.code == 429:
wait = 5 * (attempt + 1)
time.sleep(wait)
continue
print(f" RSS HTTP {e.code} for r/{subreddit}")
return []
except Exception as e:
print(f" RSS error r/{subreddit}: {e}")
return []
else:
print(f" r/{subreddit}: still rate limited, skip")
return []
# Parse Atom XML
entries = []
root = ET.fromstring(xml_data)
for entry_el in root.iter():
tag = entry_el.tag.split("}")[-1] if "}" in entry_el.tag else entry_el.tag
if tag != "entry":
continue
data = {"title": "", "url": "", "author": "", "content": "", "published": "", "id": ""}
for child in entry_el:
ctag = child.tag.split("}")[-1]
if ctag == "title":
data["title"] = unescape((child.text or "").strip())
elif ctag == "link":
data["url"] = child.get("href", "")
elif ctag == "author":
for ac in child:
if ac.tag.split("}")[-1] == "name":
data["author"] = unescape((ac.text or "").strip())
elif ctag == "content":
data["content"] = child.text or ""
elif ctag == "published":
data["published"] = child.text or ""
elif ctag == "id":
data["id"] = child.text or ""
if data["title"] and data["url"]:
entries.append(data)
return entries
def fetch(self, query: str = "", limit: int = 20) -> list[dict]:
"""
Fetch Reddit posts.
Tries JSON first (real scores), falls back to RSS (heuristic scoring).
Filters AutoModerator and sticky posts.
"""
now = datetime.now(timezone.utc)
all_entries = []
seen_ids = set()
json_worked = False
# Try JSON first
for sub in self.subreddits[:2]: # Try JSON on first 2 subreddits
posts = self._try_json(sub)
if posts:
json_worked = True
for p in posts:
if p["id"] not in seen_ids:
seen_ids.add(p["id"])
all_entries.append(p)
time.sleep(self.rate_limit)
# If JSON didn't work, fall back to RSS for all subreddits
if not json_worked:
print(" JSON endpoints blocked, using RSS fallback")
for sub in self.subreddits:
entries = self._fetch_rss(sub)
for e in entries:
# Convert RSS format to unified format
entry = {
"title": e["title"],
"url": e["url"],
"author": e["author"],
"content": e["content"],
"published": e["published"],
"id": e["id"].replace("t3_", ""),
"score": 0,
"num_comments": 0,
"ups": 0,
"selftext": "",
"created_utc": 0,
"permalink": "",
"link_flair_text": "",
"subreddit": sub,
"source": "rss",
}
if entry["id"] not in seen_ids:
seen_ids.add(entry["id"])
all_entries.append(entry)
time.sleep(self.rate_limit)
# Filter sticky/mod posts
filtered = []
for entry in all_entries:
if not self._is_sticky(entry):
filtered.append(entry)
print(f" Filtered: {len(all_entries)}{len(filtered)} (removed {len(all_entries) - len(filtered)} sticky/mod)")
# Sort by score, take top limit
for entry in filtered:
entry["_score"] = self._score(entry, entry.get("source", "rss"))
filtered.sort(key=lambda e: e.get("_score", 0), reverse=True)
filtered = filtered[:limit]
# Convert to DB format
entries = []
for entry in filtered:
source_id = entry.get("id", "") or entry.get("url", "").split("/")[-1]
score = entry.pop("_score", 0)
# Clean title
title = entry.get("title", "")
# Extracted text: selftext (JSON) or content (RSS)
if entry.get("source") == "json":
extracted_text = entry.get("selftext", "")
else:
extracted_text = self._clean_html(entry.get("content", ""))
tags = self._tags(entry, entry.get("source", "rss"))
# Structured metadata
raw_meta = {
"subreddit": entry.get("subreddit", ""),
"author": entry.get("author", ""),
"published": entry.get("published", ""),
"score": entry.get("score", 0),
"num_comments": entry.get("num_comments", 0),
"ups": entry.get("ups", 0),
"link_flair_text": entry.get("link_flair_text", ""),
"source_type": entry.get("source", "rss"), # json or rss
"content_length": len(extracted_text),
"score_type": "actual" if entry.get("source") == "json" and entry.get("score", 0) > 0 else "estimated",
}
now_str = now.strftime("%Y-%m-%dT%H:%M:%SZ")
entries.append({
"source": "reddit",
"source_id": source_id,
"url": entry.get("url", ""),
"title": title,
"extracted_text": extracted_text,
"summary": None,
"category_tags": json.dumps(tags),
"signal_score": score,
"raw_metadata": json.dumps(raw_meta),
"first_seen": now_str,
"last_updated": now_str,
})
return entries
if __name__ == "__main__":
import argparse
import sqlite3
parser = argparse.ArgumentParser(description="Reddit adapter v2 for AI Research Oracle")
parser.add_argument("--limit", type=int, default=20, help="Max entries")
parser.add_argument("--db", default=os.path.join(os.path.dirname(__file__), "..", "oracle.db"), help="SQLite DB")
parser.add_argument("--schema", default=os.path.join(os.path.dirname(__file__), "..", "schema.sql"), help="Schema file")
parser.add_argument("--dry-run", action="store_true", help="Don't store in DB")
args = parser.parse_args()
print(f"=== Reddit Adapter v2 ===")
print(f" Limit: {args.limit}")
print()
adapter = RedditAdapter()
entries = adapter.fetch(limit=args.limit)
print(f"\n Fetched {len(entries)} entries")
if not args.dry_run:
conn = sqlite3.connect(args.db)
if os.path.exists(args.schema):
with open(args.schema) as f:
conn.executescript(f.read())
conn.commit()
cur = conn.cursor()
stored = 0
for entry in entries:
try:
cur.execute("""
INSERT OR REPLACE INTO entries
(source, source_id, url, title, extracted_text, summary,
category_tags, signal_score, raw_metadata, first_seen, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["source"], entry["source_id"], entry["url"], entry["title"],
entry["extracted_text"], entry["summary"],
entry["category_tags"], entry["signal_score"],
entry["raw_metadata"], entry["first_seen"], entry["last_updated"],
))
stored += 1
except Exception as e:
print(f" DB error: {e}")
conn.commit()
conn.close()
print(f" Stored {stored} entries")
# Print top 5
print(f"\n Top entries:")
for i, e in enumerate(entries[:5]):
meta = json.loads(e["raw_metadata"]) if isinstance(e["raw_metadata"], str) else e["raw_metadata"]
src = meta.get("source_type", "?")
ups = meta.get("score", "?")
comments = meta.get("num_comments", "?")
score_type = meta.get("score_type", "?")
print(f" [{i+1}] score={e['signal_score']:.2f} ({score_type}) ups={ups} comments={comments} src={src}")
print(f" {e['title'][:90]}")
print(f" r/{meta.get('subreddit', '?')} by {meta.get('author', '?')}")
print(f" text={meta.get('content_length', 0)}ch")
print(f"\n Done.")
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
AI Research Oracle — Pipeline Orchestrator.
Runs source adapters, deduplicates, stores to unified SQLite DB.
Designed so adding a new adapter is one line of registration.
Usage:
python3 pipeline.py # run all enabled adapters
python3 pipeline.py --sources github,arxiv # specific sources
python3 pipeline.py --limit 15 # per-source limit
python3 pipeline.py --dry-run # fetch but don't store
python3 pipeline.py --verify # spot-check N entries
"""
import argparse
import json
import os
import sqlite3
import sys
import time
from datetime import datetime, timezone
# Allow running from project root
sys.path.insert(0, os.path.dirname(__file__))
from adapters import SourceAdapter
# Adapter registry — add new adapters here (one line each)
ADAPTERS = {
"github": lambda: __import__("adapters.github", fromlist=["GitHubAdapter"]).GitHubAdapter(),
"arxiv": lambda: __import__("adapters.arxiv", fromlist=["ArxivAdapter"]).ArxivAdapter(),
"reddit": lambda: __import__("adapters.reddit", fromlist=["RedditAdapter"]).RedditAdapter(),
}
# Default enabled sources
ENABLED_SOURCES = ["github", "arxiv", "reddit"]
def init_db(db_path: str, schema_path: str) -> sqlite3.Connection:
"""Initialize or open the database."""
conn = sqlite3.connect(db_path)
if os.path.exists(schema_path):
with open(schema_path) as f:
conn.executescript(f.read())
conn.commit()
return conn
def store_entries(conn: sqlite3.Connection, entries: list[dict]) -> int:
"""Store entries using INSERT OR REPLACE (dedup by source+source_id)."""
cur = conn.cursor()
stored = 0
for entry in entries:
try:
cur.execute("""
INSERT OR REPLACE INTO entries
(source, source_id, url, title, extracted_text, summary,
category_tags, signal_score, raw_metadata, first_seen, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["source"], entry["source_id"], entry["url"], entry["title"],
entry["extracted_text"] or "", entry["summary"], # None → NULL in DB
entry["category_tags"], entry["signal_score"],
entry["raw_metadata"], entry["first_seen"], entry["last_updated"],
))
stored += 1
except Exception as e:
print(f" ⚠ DB error on {entry.get('source', '?')}/{entry.get('source_id', '?')}: {e}")
conn.commit()
return stored
def verify_entries(conn: sqlite3.Connection, source: str, sample_size: int = 3):
"""Spot-check N entries from a source against live data.
This is a standing quality gate: after each adapter run, verify a
random sample of high-signal entries match the live source.
Protects against bad batches propagating to the reasoning layer.
"""
import urllib.request
import random
cur = conn.cursor()
cur.execute("""
SELECT id, source, source_id, url, title, raw_metadata, signal_score
FROM entries WHERE source = ?
ORDER BY signal_score DESC
LIMIT ?
""", (source, sample_size))
rows = cur.fetchall()
if not rows:
print(f" No entries to verify for {source}")
return True
passed = 0
for row in rows:
eid, src, sid, url, title, meta_json, score = row
meta = json.loads(meta_json) if meta_json else {}
# Source-specific verification
if src == "github":
repo = meta.get("full_name", "")
db_stars = meta.get("stars", 0)
if repo:
try:
api_url = f"https://api.github.com/repos/{repo}"
req = urllib.request.Request(api_url, headers={
"Accept": "application/vnd.github.v3+json",
"User-Agent": "ai-oracle/0.1",
})
with urllib.request.urlopen(req, timeout=10) as resp:
live = json.loads(resp.read().decode("utf-8"))
live_stars = live.get("stargazers_count", 0)
drift = abs(live_stars - db_stars)
# Allow up to 1% drift or 100 stars (whichever larger)
threshold = max(int(db_stars * 0.01), 100)
if drift <= threshold:
print(f" ✓ [{eid}] {title[:60]}... stars={db_stars} drift={drift}")
passed += 1
else:
print(f" ✗ [{eid}] {title[:60]}... stars={db_stars} vs live={live_stars} DRIFT={drift}")
except Exception as e:
print(f" ? [{eid}] {title[:60]}... verify failed: {e}")
elif src == "arxiv":
arxiv_id = meta.get("arxiv_id", "")
if arxiv_id:
# Clean version suffix for URL
clean_id = arxiv_id.split("v")[0]
try:
req = urllib.request.Request(
f"https://arxiv.org/abs/{clean_id}",
headers={"User-Agent": "ai-oracle/0.1"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
status = resp.status
if status == 200:
print(f" ✓ [{eid}] {title[:60]}... arxiv.org/abs/{clean_id} exists")
passed += 1
else:
print(f" ? [{eid}] {title[:60]}... HTTP {status}")
except Exception as e:
print(f" ? [{eid}] {title[:60]}... verify failed: {e}")
elif src == "reddit":
# For Reddit, just check the URL is reachable (no easy verification of scores via RSS)
if url:
try:
req = urllib.request.Request(url, headers={"User-Agent": "ai-oracle/0.1"})
with urllib.request.urlopen(req, timeout=10) as resp:
status = resp.status
if status in (200, 302):
print(f" ✓ [{eid}] {title[:60]}... URL reachable")
passed += 1
else:
print(f" ? [{eid}] {title[:60]}... HTTP {status}")
except Exception as e:
print(f" ? [{eid}] {title[:60]}... verify failed: {e}")
time.sleep(0.5) # polite spacing
return passed > 0
def run_pipeline(sources: list[str] | None = None, limit: int = 20, dry_run: bool = False, verify: bool = False):
"""Run the ingestion pipeline."""
sources = sources or ENABLED_SOURCES
now = datetime.now(timezone.utc)
print(f"=== AI Research Oracle Pipeline ===")
print(f" Sources: {', '.join(sources)}")
print(f" Limit: {limit}/source")
print(f" Dry run: {dry_run}")
print()
db_path = os.path.join(os.path.dirname(__file__), "oracle.db")
schema_path = os.path.join(os.path.dirname(__file__), "schema.sql")
all_entries = []
source_stats = {}
for source_name in sources:
if source_name not in ADAPTERS:
print(f" ⚠ Unknown source: {source_name} (available: {', '.join(ADAPTERS.keys())})")
continue
print(f" [{source_name}]")
adapter = ADAPTERS[source_name]()
try:
entries = adapter.fetch(limit=limit)
except Exception as e:
print(f"{source_name} failed: {e}")
source_stats[source_name] = {"fetched": 0, "stored": 0, "error": str(e)}
continue
# Add adapter_version to metadata
for entry in entries:
meta = json.loads(entry["raw_metadata"]) if isinstance(entry["raw_metadata"], str) else entry["raw_metadata"]
meta["adapter_version"] = "0.1"
entry["raw_metadata"] = json.dumps(meta)
all_entries.extend(entries)
source_stats[source_name] = {"fetched": len(entries), "stored": 0}
print(f" Fetched: {len(entries)} entries")
# Small spacing between sources
time.sleep(1)
# Store
if not dry_run and all_entries:
conn = init_db(db_path, schema_path)
stored = store_entries(conn, all_entries)
# Update per-source stored counts
for entry in all_entries:
src = entry["source"]
if src in source_stats:
source_stats[src]["stored"] += 1
# Verification
if verify:
print(f"\n [Verification]")
for src in sources:
if source_stats.get(src, {}).get("stored", 0) > 0:
print(f" Checking {src}...")
verify_entries(conn, src, sample_size=3)
print()
# Record run log (failure visibility + growth control) — before conn.close()
ok = [s for s, st in source_stats.items() if not st.get("error")]
failed = [s for s, st in source_stats.items() if st.get("error")]
notes = "; ".join(f"{s}: {st['error']}" for s, st in source_stats.items() if st.get("error")) or "all sources ok"
try:
conn.execute("""
INSERT INTO run_log (total_fetched, total_stored, sources_ok, sources_failed, notes)
VALUES (?, ?, ?, ?, ?)
""", (len(all_entries), stored, json.dumps(ok), json.dumps(failed), notes))
conn.commit()
except Exception as e:
print(f" ⚠ run_log write failed: {e}")
conn.close()
print(f" Total stored: {stored} entries")
else:
print(f" Total fetched: {len(all_entries)} entries (dry run, not stored)")
# Summary
print(f"\n Source summary:")
for src, stats in source_stats.items():
error = stats.get("error", "")
error_str = f"{error}" if error else ""
print(f" {src}: {stats['fetched']} fetched, {stats.get('stored', '')} stored{error_str}")
# Top entries across all sources
if all_entries:
print(f"\n Recent entries by signal score (per-source ranking, NOT cross-source comparable):")
print(f" (Note: GitHub scores use actual star counts; arXiv/reddit use estimated heuristics)")
sorted_entries = sorted(all_entries, key=lambda e: e["signal_score"], reverse=True)
for i, entry in enumerate(sorted_entries[:5]):
meta = json.loads(entry["raw_metadata"]) if isinstance(entry["raw_metadata"], str) else entry["raw_metadata"]
score_type = meta.get("score_type", "?")
print(f" [{i+1}] {entry['source'].upper():6} ({score_type:8}) score={entry['signal_score']:.2f} {entry['title'][:75]}")
print(f"\n Done.")
return all_entries
def main():
parser = argparse.ArgumentParser(description="AI Research Oracle Pipeline")
parser.add_argument("--sources", default=None, help="Comma-separated sources (default: github,arxiv)")
parser.add_argument("--limit", type=int, default=20, help="Entries per source")
parser.add_argument("--dry-run", action="store_true", help="Fetch but don't store")
parser.add_argument("--verify", action="store_true", help="Spot-check entries against live sources")
args = parser.parse_args()
sources = args.sources.split(",") if args.sources else None
run_pipeline(sources=sources, limit=args.limit, dry_run=args.dry_run, verify=args.verify)
if __name__ == "__main__":
main()
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""
AI Research Oracle — Query & Snapshot CLI.
Query the unified database and produce Claude-ready snapshots.
Supports filtering by source, score, confidence, tag, and date range.
Usage:
python3 query.py top 10 # top 10 across all sources
python3 query.py by-source github 5 # top 5 from GitHub
python3 query.py by-tag "agent" # entries tagged with "agent"
python3 query.py snapshot # full snapshot for Claude relay
python3 query.py search "world model" # keyword search in titles/summaries
python3 query.py recent --hours 24 # entries from last 24h
python3 query.py stats # database statistics
"""
import argparse
import json
import os
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from urllib.parse import quote_plus
sys.path.insert(0, os.path.dirname(__file__))
DB_PATH = os.path.join(os.path.dirname(__file__), "oracle.db")
def get_db():
"""Open database connection."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def format_entry(row: sqlite3.Row, rank: int = 0) -> dict:
"""Format a DB row into a clean dict for output."""
summary = json.loads(row["summary"]) if row["summary"] else {}
meta = json.loads(row["raw_metadata"]) if row["raw_metadata"] else {}
tags = json.loads(row["category_tags"]) if row["category_tags"] else []
score_type = meta.get("score_type", "?")
source_detail = ""
if row["source"] == "github":
source_detail = f"{meta.get('stars', '?')} stars"
elif row["source"] == "arxiv":
source_detail = f"arXiv:{meta.get('arxiv_id', '?')}"
elif row["source"] == "reddit":
source_detail = f"r/{meta.get('subreddit', '?')}"
return {
"rank": rank,
"source": row["source"],
"title": row["title"],
"url": row["url"],
"score": row["signal_score"],
"score_type": score_type,
"confidence": summary.get("confidence", "?"),
"source_detail": source_detail,
"tags": tags,
"one_liner": summary.get("one_liner", ""),
"key_technical_point": summary.get("key_technical_point", ""),
"potential_use_case": summary.get("potential_use_case", ""),
"first_seen": row["first_seen"],
}
def cmd_top(args):
"""Top N entries across all sources (per-source ranking)."""
conn = get_db()
cur = conn.cursor()
# Score filter
min_score = getattr(args, 'min_score', 0) or 0
# Confidence filter
min_confidence = getattr(args, 'min_confidence', None) or None
# Source filter
source_filter = getattr(args, 'source', None) or None
where = []
params = []
if min_score > 0:
where.append("signal_score >= ?")
params.append(min_score)
if min_confidence:
where.append("json_extract(summary,'$.confidence') = ?")
params.append(min_confidence)
if source_filter:
where.append("source = ?")
params.append(source_filter)
where_str = (" AND " if where else "") + " AND ".join(where) if where else ""
limit = args.n or 10
cur.execute(f"""
SELECT * FROM entries {where_str}
ORDER BY signal_score DESC
LIMIT ?
""", params + [limit])
rows = cur.fetchall()
entries = [format_entry(r, i+1) for i, r in enumerate(rows)]
print(f"Top {len(entries)} entries{' by score' if not source_filter else f' from {source_filter}'}:\n")
_print_entries(entries)
conn.close()
def cmd_by_source(args):
"""Top N from a specific source."""
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT * FROM entries WHERE source = ?
ORDER BY signal_score DESC
LIMIT ?
""", (args.source, args.n or 10))
rows = cur.fetchall()
entries = [format_entry(r, i+1) for i, r in enumerate(rows)]
print(f"Top {len(entries)} from {args.source}:\n")
_print_entries(entries)
conn.close()
def cmd_by_tag(args):
"""Entries matching a tag."""
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT * FROM entries
WHERE json_extract(category_tags,'$') LIKE ?
ORDER BY signal_score DESC
LIMIT 20
""", (f'%"{args.tag}"%',))
rows = cur.fetchall()
entries = [format_entry(r, i+1) for i, r in enumerate(rows)]
print(f"Entries tagged '{args.tag}':\n")
_print_entries(entries)
conn.close()
def cmd_search(args):
"""Keyword search in titles and summaries."""
conn = get_db()
cur = conn.cursor()
q = f"%{args.query}%"
cur.execute("""
SELECT * FROM entries
WHERE title LIKE ?
OR json_extract(summary,'$.one_liner') LIKE ?
OR json_extract(summary,'$.key_technical_point') LIKE ?
ORDER BY signal_score DESC
LIMIT 20
""", (q, q, q))
rows = cur.fetchall()
entries = [format_entry(r, i+1) for i, r in enumerate(rows)]
print(f"Search results for '{args.query}':\n")
_print_entries(entries)
conn.close()
def cmd_recent(args):
"""Entries from the last N hours."""
hours = args.hours or 24
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ")
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT * FROM entries WHERE first_seen >= ?
ORDER BY first_seen DESC
""", (cutoff,))
rows = cur.fetchall()
entries = [format_entry(r, i+1) for i, r in enumerate(rows)]
print(f"Entries from last {hours}h:\n")
_print_entries(entries)
conn.close()
def cmd_snapshot(args):
"""Full snapshot for Claude relay.
Produces a structured summary of the current DB state,
formatted for Claude to reason over.
"""
conn = get_db()
cur = conn.cursor()
# DB stats
cur.execute("SELECT COUNT(*) FROM entries")
total = cur.fetchone()[0]
cur.execute("SELECT source, COUNT(*) as cnt, AVG(signal_score) as avg_score, MIN(first_seen) as oldest, MAX(last_updated) as newest FROM entries GROUP BY source")
source_stats = {r["source"]: dict(r) for r in cur.fetchall()}
# Security-tagged entries
cur.execute("""
SELECT COUNT(*) FROM entries
WHERE summary IS NOT NULL AND summary != '' AND json_extract(summary,'$.potential_use_case') LIKE '%security%'
""")
security_count = cur.fetchone()[0]
# Top 10 overall
cur.execute("SELECT * FROM entries WHERE summary IS NOT NULL AND summary != '' ORDER BY signal_score DESC LIMIT 10")
top_entries = [format_entry(r, i+1) for i, r in enumerate(cur.fetchall())]
# Confidence distribution
cur.execute("""
SELECT json_extract(summary,'$.confidence') as conf, COUNT(*) as cnt
FROM entries WHERE summary IS NOT NULL AND summary != ''
GROUP BY conf
""")
conf_dist = {r["conf"]: r["cnt"] for r in cur.fetchall()}
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
snapshot = {
"snapshot_time": now,
"total_entries": total,
"sources": source_stats,
"security_flagged": security_count,
"confidence_distribution": conf_dist,
"top_10": top_entries,
}
# Output as formatted text for relay
print("=" * 70)
print("AI RESEARCH ORACLE — SNAPSHOT")
print("=" * 70)
print(f"Time: {now}")
print(f"Total entries: {total}")
print()
print("Source breakdown:")
for src, stats in source_stats.items():
print(f" {src}: {stats['cnt']} entries, avg score {stats['avg_score']:.2f}")
print()
if conf_dist:
print(f"Confidence distribution: {conf_dist}")
print()
if security_count:
print(f"{security_count} entries flagged as security:dual-use")
print()
print("Top 10 by signal score (per-source ranking, NOT cross-source comparable):")
print("-" * 70)
for e in top_entries:
score_label = f"{e['score']:.2f} ({e['score_type']})"
conf = e["confidence"]
print(f"\n [{e['rank']}] {e['source'].upper()} | {score_label} | confidence={conf}")
print(f" {e['title']}")
print(f" {e['source_detail']}")
if e["one_liner"]:
print(f"{e['one_liner'][:120]}")
print(f" Tags: {', '.join(e['tags'][:4])}")
print("\n" + "=" * 70)
print("NOTE: Scores are NOT comparable across sources. GitHub uses")
print("actual star counts (log-scaled), arXiv/reddit use estimated")
print("heuristics. Rank within-source, not cross-source.")
print("=" * 70)
conn.close()
def cmd_explain(args):
"""Explain why an entry scored the way it did."""
conn = get_db()
cur = conn.cursor()
if args.entry_id.isdigit():
cur.execute("SELECT * FROM entries WHERE id = ?", (args.entry_id,))
else:
q = f"%{args.entry_id}%"
cur.execute("SELECT * FROM entries WHERE title LIKE ?", (q,))
row = cur.fetchone()
if not row:
print(f"Entry not found: {args.entry_id}")
conn.close()
return
meta = json.loads(row["raw_metadata"]) if row["raw_metadata"] else {}
summary = json.loads(row["summary"]) if row["summary"] else {}
tags = json.loads(row["category_tags"]) if row["category_tags"] else []
print(f"=== Score Explanation ===\n")
print(f"Title: {row['title']}")
print(f"Source: {row['source']}")
print(f"Score: {row['signal_score']:.2f} ({meta.get('score_type', '?')})")
print(f"Confidence: {summary.get('confidence', '?')}")
print()
if row["source"] == "arxiv":
print(f"Authors: {meta.get('author_count', '?')}")
print(f"Categories: {', '.join(meta.get('categories', []))}")
print(f"Published: {meta.get('published', '?')}")
print(f"Abstract length: {meta.get('abstract_length', '?')} chars")
print(f"Applied domain: {meta.get('applied_domain', 'none (core AI)')}")
print()
print("Scoring (arXiv — relevance-weighted, structural capped):")
print(f" - Structural (recency+authors+diversity, capped ~3.5): weak signals")
print(f" - AI keyword density in abstract (capped 2.0)")
print(f" - AI methodology claim (propose/novel = up to 2.5)")
print(f" - Applied-domain is TAGGED, not penalized")
if meta.get('applied_domain'):
print(f" Note: tagged '{meta['applied_domain']}' for filtering — no score penalty")
elif row["source"] == "github":
print(f"Stars: {meta.get('stars', '?')}")
print(f"Language: {meta.get('language', '?')}")
print()
print("Scoring (GitHub actual star count, log-scaled)")
print()
print(f"Tags: {', '.join(tags)}")
if summary.get('one_liner'):
print(f"One-liner: {summary['one_liner'][:120]}")
conn.close()
def cmd_stats(args):
"""Database statistics."""
conn = get_db()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM entries")
total = cur.fetchone()[0]
cur.execute("SELECT source, COUNT(*) as cnt, ROUND(AVG(signal_score),2) as avg_score, MIN(signal_score) as min_score, MAX(signal_score) as max_score FROM entries GROUP BY source")
rows = cur.fetchall()
print(f"Database: {DB_PATH}")
print(f"Total entries: {total}\n")
print(f"{'Source':<12} {'Count':<8} {'Avg':<8} {'Min':<8} {'Max':<8}")
print("-" * 44)
for r in rows:
print(f"{r['source']:<12} {r['cnt']:<8} {r['avg_score']:<8} {r['min_score']:<8} {r['max_score']:<8}")
# Summarization status
cur.execute("SELECT COUNT(*) FROM entries WHERE summary IS NOT NULL")
summarized = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM entries WHERE summary IS NULL")
pending = cur.fetchone()[0]
print(f"\nSummarization: {summarized} done, {pending} pending")
# Confidence distribution
cur.execute("SELECT json_extract(summary,'$.confidence') as c, COUNT(*) as n FROM entries WHERE summary IS NOT NULL AND summary != '' GROUP BY c")
if cur.fetchall():
print(f"Confidence: {' | '.join(f'{r[0]}={r[1]}' for r in cur.fetchall())}")
# Recent run history (failure visibility)
cur.execute("SELECT run_time, total_fetched, total_stored, sources_ok, sources_failed FROM run_log ORDER BY id DESC LIMIT 5")
runs = cur.fetchall()
if runs:
print(f"\nRecent runs (last {len(runs)}):")
for r in runs:
failed = json.loads(r["sources_failed"]) if r["sources_failed"] else []
status = "✓ all ok" if not failed else f"⚠ partial: {', '.join(failed)}"
print(f" {r['run_time']} fetched={r['total_fetched']} stored={r['total_stored']} {status}")
conn.close()
def _print_entries(entries: list[dict]):
"""Pretty-print a list of entries."""
if not entries:
print(" (no results)")
return
for e in entries:
score_label = f"{e['score']:.2f} ({e['score_type']})"
conf = e["confidence"]
print(f" [{e['rank']}] {e['source'].upper():6} | {score_label} | confidence={conf}")
print(f" {e['title']}")
if e["source_detail"]:
print(f" {e['source_detail']}")
if e["one_liner"]:
print(f"{e['one_liner'][:120]}")
# Show applied-domain and security tags prominently
shown_tags = [t for t in e["tags"] if t.startswith("applied:") or t.startswith("security:")]
other_tags = [t for t in e["tags"] if not t.startswith("applied:") and not t.startswith("security:")]
display_tags = shown_tags + other_tags[:4]
if display_tags:
print(f" Tags: {', '.join(display_tags)}")
print()
def main():
parser = argparse.ArgumentParser(description="AI Research Oracle — Query")
sub = parser.add_subparsers(dest="command")
# top
p_top = sub.add_parser("top", help="Top N entries")
p_top.add_argument("n", type=int, nargs="?", default=10)
p_top.add_argument("--source", default=None)
p_top.add_argument("--min-score", type=float, default=0)
# by-source
p_src = sub.add_parser("by-source", help="Top N from a source")
p_src.add_argument("source")
p_src.add_argument("n", type=int, nargs="?", default=10)
# by-tag
p_tag = sub.add_parser("by-tag", help="Entries by tag")
p_tag.add_argument("tag")
# search
p_search = sub.add_parser("search", help="Keyword search")
p_search.add_argument("query")
# recent
p_recent = sub.add_parser("recent", help="Recent entries")
p_recent.add_argument("--hours", type=int, default=24)
# snapshot
sub.add_parser("snapshot", help="Full snapshot for Claude")
# explain
p_explain = sub.add_parser("explain", help="Explain why an entry scored high")
p_explain.add_argument("entry_id", help="Entry ID or partial title")
# stats
sub.add_parser("stats", help="Database statistics")
args = parser.parse_args()
commands = {
"top": cmd_top,
"by-source": cmd_by_source,
"by-tag": cmd_by_tag,
"search": cmd_search,
"recent": cmd_recent,
"snapshot": cmd_snapshot,
"explain": cmd_explain,
"stats": cmd_stats,
}
cmd = commands.get(args.command)
if cmd:
cmd(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
Reddit Idea Generator — Proof of Concept v5
Uses Reddit RSS feeds (Atom XML). No browser needed.
Trafilatura for clean text extraction. SQLite for storage.
Usage: python3 reddit_proof.py [count]
Example: python3 reddit_proof.py 20
"""
import sys
import json
import re
import xml.etree.ElementTree as ET
import sqlite3
import os
import time
import urllib.request
import urllib.error
from datetime import datetime, timezone
from html import unescape
import trafilatura
DB_PATH = os.path.join(os.path.dirname(__file__), "oracle.db")
SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "schema.sql")
SUBREDDITS = [
"MachineLearning", "artificial", "LocalLLaMA", "Startups",
]
def init_db():
conn = sqlite3.connect(DB_PATH)
with open(SCHEMA_PATH) as f:
conn.executescript(f.read())
conn.commit()
return conn
def fetch_rss(subreddit, sort="hot"):
"""Fetch RSS feed for a subreddit. Returns parsed entries."""
url = f"https://www.reddit.com/r/{subreddit}/{sort}/.rss?limit=50"
req = urllib.request.Request(url, headers={"User-Agent": "oracle-reddit-proof/1.0"})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
xml_data = resp.read().decode("utf-8")
break
except urllib.error.HTTPError as e:
if e.code == 429:
wait = 5 * (attempt + 1)
print(f" 429 on r/{subreddit}, retry in {wait}s")
time.sleep(wait)
continue
print(f" RSS error r/{subreddit}: {e}")
return []
except Exception as e:
print(f" RSS error r/{subreddit}: {e}")
return []
else:
print(f" r/{subreddit}: still rate limited, skip")
return []
# Parse Atom XML — find all <entry> elements
root = ET.fromstring(xml_data)
entries = []
# Handle namespace: Atom uses http://www.w3.org/2005/Atom
# But ET.findall with ns prefix requires registering the namespace
# Simpler approach: strip namespace from tags and search directly
for entry in root.iter():
# Get local name (strip namespace)
tag = entry.tag.split("}")[-1] if "}" in entry.tag else entry.tag
if tag == "entry":
title = None
link = None
author = ""
content = ""
pub = ""
eid = ""
for child in entry:
ctag = child.tag.split("}")[-1]
if ctag == "title":
title = child.text
elif ctag == "link":
link = child.get("href", "")
elif ctag == "author":
name_el = child[0] if child else None
if name_el:
name_tag = name_el.tag.split("}")[-1]
if name_tag == "name":
author = name_el.text or ""
elif ctag == "content":
content = child.text or ""
elif ctag == "published":
pub = child.text or ""
elif ctag == "id":
eid = child.text or ""
if title and link:
entries.append({
"title": unescape(title.strip()),
"url": link,
"author": unescape(author.strip()),
"content": content,
"published": pub,
"id": eid,
"subreddit": subreddit,
})
return entries
def clean_html_content(html):
"""Extract readable text from Reddit's HTML content."""
if not html:
return ""
text = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
text = re.sub(r"<div[^>]*>", "\n", text)
text = re.sub(r"</div>", "\n", text)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = unescape(text)
text = re.sub(r"\n\s*\n+", "\n\n", text)
return text.strip()
def main():
if len(sys.argv) > 1:
count = int(sys.argv[1])
else:
count = 20
print(f"=== Reddit Idea Generator — Proof of Concept v5 ===")
print(f" count: {count}")
print()
conn = init_db()
cursor = conn.cursor()
# Step 1: Fetch RSS
print(f"[1/3] Fetching RSS feeds...")
all_entries = []
seen_ids = set()
for i, sub in enumerate(SUBREDDITS):
entries = fetch_rss(sub)
new = [e for e in entries if e["id"] not in seen_ids]
seen_ids.update(e["id"] for e in new)
all_entries.extend(new)
if new:
print(f" r/{sub}: {len(new)} entries")
# Rate limit between subreddits
if i < len(SUBREDDITS) - 1:
time.sleep(3)
print(f" Total: {len(all_entries)} entries")
if not all_entries:
print("\n No entries fetched. Reddit may be rate-limiting this IP.")
print(" Try again later or use fewer subreddits.")
sys.exit(1)
# Limit to count
entries_to_store = all_entries[:count]
print(f" Storing {len(entries_to_store)} entries")
# Step 2: Store
stored = 0
for entry in entries_to_store:
post_id = entry["id"].replace("t3_", "")
content_text = clean_html_content(entry["content"])
# Signal score — RSS hot feed already sorted by relevance
# Use position-based scoring (higher rank = higher score)
idx = entries_to_store.index(entry)
score = max(10.0 - idx * 0.5, 1.0)
# Category tags
category_tags = ["reddit"]
sub = entry.get("subreddit", "").lower()
if "machinelearning" in sub:
category_tags.append("machine-learning")
elif "artificial" in sub:
category_tags.append("ai-general")
elif "localllama" in sub:
category_tags.append("local-llm")
elif "startups" in sub:
category_tags.append("startups")
# Post type from title markers
title = entry.get("title", "")
if " [P]" in title or " [p]" in title:
category_tags.append("project")
elif " [R]" in title or " [r]" in title:
category_tags.append("research")
elif " [D]" in title or " [d]" in title:
category_tags.append("discussion")
elif " [N]" in title or " [n]" in title:
category_tags.append("news")
else:
category_tags.append("general")
# Clean title (remove [X] markers)
clean_title = re.sub(r"\s*\[[A-Z]\]\s*$", "", title)
raw_meta = {
"subreddit": entry["subreddit"],
"author": entry["author"],
"published": entry["published"],
"text_length": len(content_text),
}
source_id = post_id or entry["url"].split("/")[-1] or f"rss_{stored}"
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
try:
cursor.execute("""
INSERT OR REPLACE INTO entries
(source, source_id, url, title, extracted_text, summary,
category_tags, signal_score, raw_metadata, first_seen, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
"reddit", source_id, entry["url"], clean_title,
content_text,
None, # summary — LLM later
json.dumps(category_tags),
score,
json.dumps(raw_meta),
now, now,
))
stored += 1
except Exception as e:
print(f" DB ERROR: {e}")
conn.commit()
print(f" Stored {stored} entries")
# Step 3: Summary
print(f"\n[3/3] Summary")
cursor.execute("SELECT COUNT(*) FROM entries")
total = cursor.fetchone()[0]
print(f" Total entries in DB: {total}")
cursor.execute("SELECT COUNT(*) FROM entries WHERE source='reddit'")
reddit_count = cursor.fetchone()[0]
print(f" Reddit entries: {reddit_count}")
cursor.execute("SELECT AVG(signal_score) FROM entries WHERE source='reddit'")
avg_score = cursor.fetchone()[0] or 0
print(f" Avg signal score: {avg_score:.2f}")
# Subreddit distribution
cursor.execute("""
SELECT raw_metadata, COUNT(*) FROM entries
WHERE source='reddit'
GROUP BY raw_metadata
ORDER BY COUNT(*) DESC
""")
print(f"\n Subreddit distribution:")
for meta, cnt in cursor.fetchall():
d = json.loads(meta)
print(f" r/{d.get('subreddit', '?')}: {cnt}")
# Top 5
print(f"\n Top 5 by signal score:")
cursor.execute("""
SELECT id, title, signal_score, raw_metadata, category_tags,
LENGTH(extracted_text) as text_len
FROM entries WHERE source='reddit'
ORDER BY signal_score DESC
LIMIT 5
""")
for row in cursor.fetchall():
eid, title, score, meta, tags, txt_len = row
meta_dict = json.loads(meta) if meta else {}
print(f" [{eid}] score={score:.1f} text={txt_len}ch")
print(f" {title[:90]}")
print(f" r/{meta_dict.get('subreddit', '?')} "
f"by {meta_dict.get('author', '?')}")
# Extraction quality
print(f"\n Extraction quality (top entry):")
cursor.execute("""
SELECT title, extracted_text
FROM entries WHERE source='reddit'
ORDER BY signal_score DESC
LIMIT 1
""")
row = cursor.fetchone()
if row:
title, excerpt = row
print(f" Title: {title[:80]}")
print(f" Length: {len(excerpt) if excerpt else 0} chars")
if excerpt:
print(f" Preview:\n {excerpt[:400]}...")
else:
print(" (empty)")
# Check for garbled extractions
cursor.execute("""
SELECT COUNT(*) FROM entries
WHERE source='reddit' AND LENGTH(extracted_text) < 100
""")
short_count = cursor.fetchone()[0]
if short_count > 0:
print(f"\n{short_count}/{stored} entries have very short extractions (<100 chars)")
print(" These are likely link-only posts or external links")
conn.close()
print(f"\n Database: {DB_PATH}")
print(" Done.")
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
url TEXT,
title TEXT,
extracted_text TEXT,
summary TEXT,
category_tags TEXT,
signal_score REAL,
raw_metadata TEXT,
first_seen TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_updated TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(source, source_id)
);
CREATE INDEX IF NOT EXISTS idx_entries_source ON entries(source);
CREATE INDEX IF NOT EXISTS idx_entries_signal ON entries(signal_score DESC);
CREATE INDEX IF NOT EXISTS idx_entries_category ON entries(category_tags);
-- Run log: records each pipeline invocation for failure visibility + growth control.
-- Partial failures (e.g. Reddit rate-limited) are detectable here, not hidden
-- as a "complete" run. Also enables future pruning decisions (entries older
-- than N days with no re-fetch can be archived).
CREATE TABLE IF NOT EXISTS run_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_time TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
total_fetched INTEGER DEFAULT 0,
total_stored INTEGER DEFAULT 0,
sources_ok TEXT, -- JSON list of sources that succeeded
sources_failed TEXT, -- JSON list of sources that errored/skipped
notes TEXT
);
+552
View File
@@ -0,0 +1,552 @@
#!/usr/bin/env python3
"""
AI Research Oracle — Summarization Engine (v1).
Generates structured summaries for entries where summary IS NULL.
Uses source-specific extraction logic (no LLM required — eliminates hallucination).
Output schema: {one_liner, key_technical_point, potential_use_case, confidence}
Architecture note: This v1 uses deterministic extraction rules to avoid
hallucination. When a local LLM becomes available (Ollama GPU, Hermes API),
swap in LLM mode via --llm flag. The DB schema is identical.
Usage:
python3 summarize.py # summarize all pending
python3 summarize.py --source github # specific source
python3 summarize.py --limit 10 # max entries
python3 summarize.py --verify # spot-check 2-3 summaries
"""
import argparse
import json
import os
import re
import sqlite3
import sys
import time
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(__file__))
def extract_github_summary(title: str, content: str) -> dict:
"""Extract summary from GitHub README content.
Strategy: Clean HTML, find the first substantive paragraph that
describes the project (usually below the badges), extract the
"what it does" sentence.
"""
# Aggressive HTML cleaning
text = re.sub(r'<p[^>]*>', '\n', content)
text = re.sub(r'</p>', '\n', content)
text = re.sub(r'<h[1-6][^>]*>', '\n## ', text)
text = re.sub(r'</h[1-6]>', '\n', text)
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'&amp;', '&', text)
text = re.sub(r'&mdash;', '', text)
text = re.sub(r'&#39;', "'", text)
text = re.sub(r'&middot;', '·', text)
# Remove code blocks (``` ... ```) — often ASCII art
text = re.sub(r'```[\s\S]*?```', '', text)
text = re.sub(r'\n\s*\n+', '\n\n', text)
text = text.strip()
# Confidence starts from source content quality
source_confidence = "low"
if len(text) > 2000:
source_confidence = "high"
elif len(text) > 500:
source_confidence = "medium"
# Find the one-liner: look for project description paragraph
one_liner = _find_project_description(text, title)
if not one_liner:
one_liner = title[:200]
# Key technical point
key_tech = _extract_technical_point(text, source_confidence)
# Use case
use_case = _extract_use_case(text, title)
# Quality-gate confidence on extraction signals, not raw length
confidence = _assess_extraction_quality(one_liner, key_tech, use_case, source_confidence)
# Tag security tooling if detected
if _is_security_tooling(title, one_liner, key_tech):
use_case = use_case + " [security:dual-use]"
return {
"one_liner": one_liner[:200],
"key_technical_point": key_tech[:200],
"potential_use_case": use_case[:200],
"confidence": confidence,
}
def extract_arxiv_summary(title: str, content: str) -> dict:
"""Extract summary from arXiv abstract.
Strategy: arXiv abstracts have a predictable structure:
1. Background/motivation
2. "In this paper we propose..."
3. Results
4. Implications
We extract the contribution statement and key finding.
"""
text = re.sub(r'<[^>]+>', ' ', content)
text = re.sub(r'\s+', ' ', text).strip()
# Confidence based on abstract clarity
confidence = "high" if len(text) > 300 else "medium"
# One-liner: find the contribution statement
one_liner = _find_contribution(text)
if not one_liner:
# Fallback: use title as base
one_liner = f"This paper presents {title.lower()}"
# Key technical point: look for method description
key_tech = _extract_method(text)
# Use case: look for application statements
use_case = _extract_application(text)
return {
"one_liner": one_liner[:200],
"key_technical_point": key_tech[:200],
"potential_use_case": use_case[:200],
"confidence": confidence,
}
def extract_reddit_summary(title: str, content: str) -> dict:
"""Extract summary from Reddit post.
Strategy: Reddit posts vary wildly in quality. Extract the core
question or claim, note if it's discussion vs announcement.
"""
text = re.sub(r'<[^>]+>', ' ', content)
text = re.sub(r'\s+', ' ', text).strip()
# Confidence based on content length
if len(text) > 500:
confidence = "high"
elif len(text) > 100:
confidence = "medium"
else:
confidence = "low"
# One-liner from title (Reddit titles are usually the summary)
one_liner = title[:200] if title else text[:150]
# Key technical point from content
key_tech = text[:200] if text else "No additional content in post"
# Use case: community relevance
use_case = "AI community discussion"
return {
"one_liner": one_liner,
"key_technical_point": key_tech,
"potential_use_case": use_case,
"confidence": confidence,
}
# --- Extraction helpers ---
def _assess_extraction_quality(one_liner: str, key_tech: str, use_case: str, source_confidence: str) -> str:
"""Assess extraction quality based on output signals, not source length.
A short-but-complete Reddit title should score higher confidence
than a long README that yielded a fragment.
"""
score = 0
penalties = 0
# One-liner quality
ol = one_liner.strip()
ol_len = len(ol)
# Length window: 40-200 chars is a reasonable sentence
if 40 <= ol_len <= 200:
score += 2
elif 20 <= ol_len < 40:
score += 1
elif ol_len > 200:
penalties += 1 # too long, likely grabbed too much
# Ends with terminal punctuation
if ol.endswith(('.', '!', '?', '')):
score += 1
else:
penalties += 1
# Contains subject-verb pattern (basic heuristic)
if re.search(r'\b(?:is|are|provides|enables|implements|makes|allows|builds|creates|runs|uses)\b', ol, re.I):
score += 1
# Or starts with a proper noun/capitalized phrase
elif re.match(r'^[A-Z]\w+', ol) and ol_len > 30:
score += 0.5
# No unmatched brackets (artifact from markdown/HTML)
open_brackets = ol.count('[') + ol.count('(')
close_brackets = ol.count(']') + ol.count(')')
if abs(open_brackets - close_brackets) > 0:
penalties += 1
if open_brackets > 2:
penalties += 1 # likely grabbed markdown link syntax
# Key technical point quality
kt = key_tech.strip()
if kt and len(kt) > 20 and not kt.startswith('See '):
score += 1
else:
penalties += 0.5
# Use case quality
uc = use_case.strip()
if uc and len(uc) > 10 and not uc.startswith('Relevant for'):
score += 1
else:
penalties += 0.5
# Final confidence based on score - penalties
net = score - penalties
if net >= 3:
return source_confidence # extraction is good, trust source quality
elif net >= 1:
return "medium"
else:
return "low"
def _is_security_tooling(title: str, one_liner: str, key_tech: str) -> bool:
"""Detect if a project is security/offensive tooling."""
combined = f"{title} {one_liner} {key_tech}".lower()
security_signals = [
"offensive", "pentest", "red team", "exploit", "kill chain",
"attack surface", "vulnerability scan", "zero-day",
"reverse engineer", "c2", "command and control",
]
return any(sig in combined for sig in security_signals)
def _find_project_description(text: str, title: str) -> str | None:
"""Find the project description paragraph in a README."""
paras = text.split('\n\n')
proj_name = title.split(':')[0].split('/')[0].strip().lower()
for para in paras:
para = para.strip()
if not para or para.startswith('##') or len(para) < 20:
continue
# Skip badges, stats lines, separator lines
if 'img' in para.lower() or 'badge' in para.lower() or 'shields' in para.lower():
continue
# Skip lines that start with stats (~54%, etc.)
if re.match(r'^[~$#€£¥*»\d]', para):
continue
# Skip ASCII art (high ratio of special chars)
special_chars = sum(1 for c in para if not c.isalnum() and not c.isspace() and c not in ',.!?;:\'"-()[]')
if special_chars / max(len(para), 1) > 0.4:
continue
if len(para) < 40:
continue
# Good paragraph — extract first sentence
sentence = re.split(r'[.!?]', para)[0].strip()
if len(sentence) > 30:
return sentence + '.'
# Fallback: look for "is a" pattern anywhere
patterns = [
rf'{re.escape(proj_name[:20])}\s+(?:is|enables|provides|implements)\s+[^.]+\.?',
r'(?:This\s+)?(?:project|library|framework|tool|package)\s+(?:is|enables|provides)\s+[^.]+\.?',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
return None
def _find_what_sentence(text: str, title: str) -> str | None:
"""Find the 'X is a...' sentence that describes what the project does."""
patterns = [
rf'{re.escape(title[:30])}\s+(?:is|enables|provides|implements)\s+[^.]+\.?',
r'(?:This\s+)?(?:project|library|framework|tool|package)\s+(?:is|enables|provides|implements)\s+[^.]+\.?',
r'(?:makes|allows)\s+[^\s]+\s+(?:to|can)\s+[^.]+\.?',
r'(?:\w+\s+(?:is|provides|enables|implements|delivers))\s+[a-z].{10,100}\.',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
# Fallback: first meaningful paragraph
for para in text.split('\n\n'):
para = para.strip()
if len(para) > 30 and not para.startswith('#'):
return para[:200]
return None
def _find_contribution(text: str) -> str | None:
"""Find the 'we propose/introduce/present' statement in an abstract."""
patterns = [
r'(?:we|this\s+paper)\s+(?:propose|introduce|present|propose and evaluate)\s+[^.]{10,150}\.',
r'(?:we\s+(?:show|demonstrate|find|discover|observe))\s+[^.]{10,150}\.',
r'(?:we\s+(?:introduce|present|propose))\s+(?:a|an|our)\s+\w+\s+[^.]{5,150}\.',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
# Fallback: first sentence
first = re.split(r'[.!?]', text)[0].strip()
return first if first else None
def _extract_technical_point(text: str, confidence: str) -> str:
"""Extract the main technical approach or innovation."""
patterns = [
r'architecture(?:\s+designed)?\s+(?:for|to|that)\s+[^.]+\.?',
r'(?:using|via|based\s+on|through)\s+[a-z][^.]{10,100}\.',
r'(?:novel|new|unique|innovative)\s+\w+\s+[^.]{5,80}\.',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
# Fallback: confidence-based
if confidence == "low":
return "Technical details not available in extracted content"
return "See README for technical details"
def _extract_method(text: str) -> str:
"""Extract the method/approach from an arXiv abstract."""
patterns = [
r'(?:method|approach|framework|technique|model|system)\s+(?:based|using|via|through|with)\s+[a-z][^.]{10,120}\.',
r'(?:combining|leveraging|exploiting)\s+[a-z][^.]{10,120}\.',
r'(?:learn|train|optimize|generate)\s+[a-z][^.]{10,120}\.',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
# Fallback: core contribution
for pattern in [
r'(?:propose|introduce)\s+(?:a|an)\s+[^.]{10,100}\.',
]:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
return "See full paper for methodology"
def _extract_use_case(text: str, title: str) -> str:
"""Extract potential use case from README content."""
patterns = [
r'(?:for|to)\s+(?:developers|engineers|researchers|teams)\s+who?\s+[^.]{5,80}\.',
r'(?:enables|allows|helps)\s+[^\s]+\s+to\s+[^.]{10,80}\.',
r'(?:use\s+case|application|target\s+user)\s*:\s*[^.]{10,80}\.',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
return f"Relevant for {title.lower()[:50]} developers and users"
def _extract_application(text: str) -> str:
"""Extract application/use case from arXiv abstract."""
patterns = [
r'(?:application|use\s+case|can\s+be\s+used|could\s+be\s+applied)\s+(?:for|in|to)\s+[a-z][^.]{10,80}\.',
r'(?:improve|enhance|advance)\s+[a-z][^.]{10,80}\.',
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(0)[:200]
# Generic fallback based on title keywords
title_lower = text[:200].lower()
if any(k in title_lower for k in ["agent", "agentic"]):
return "Building AI agent systems"
elif any(k in title_lower for k in ["verification", "verify"]):
return "LLM output verification and reliability"
elif any(k in title_lower for k in ["embodied", "robot"]):
return "Embodied AI and robotics applications"
elif any(k in title_lower for k in ["distill"]):
return "Model distillation and knowledge transfer"
return "See paper for specific applications"
def summarize_entry(entry: dict, conn: sqlite3.Connection) -> bool:
"""Summarize a single entry using rule-based extraction."""
source = entry["source"]
title = entry["title"]
content = entry.get("extracted_text", "")
eid = entry["id"]
if not content or len(content) < 50:
return False
# Source-specific extraction
if source == "github":
summary = extract_github_summary(title, content)
elif source == "arxiv":
summary = extract_arxiv_summary(title, content)
elif source == "reddit":
summary = extract_reddit_summary(title, content)
else:
summary = extract_reddit_summary(title, content) # fallback
# Store
cur = conn.cursor()
cur.execute("UPDATE entries SET summary = ? WHERE id = ?",
(json.dumps(summary), eid))
conn.commit()
return True
def verify_summaries(conn: sqlite3.Connection, source: str, sample_size: int = 3):
"""Spot-check summaries against source text.
Look for hallucinated specifics: numbers, claims, features not
present in the original extracted_text.
NOTE: Rule-based extraction v1 is inherently lower-risk for
hallucination since it extracts actual text, not generates new claims.
But we still verify the extraction logic is working correctly.
"""
cur = conn.cursor()
cur.execute("""
SELECT id, title, extracted_text, summary
FROM entries WHERE source = ? AND summary IS NOT NULL
ORDER BY RANDOM()
LIMIT ?
""", (source, sample_size))
rows = cur.fetchall()
if not rows:
print(f" No summaries to verify for {source}")
return
for eid, title, source_text, summary_json in rows:
summary = json.loads(summary_json)
one_liner = summary.get("one_liner", "")
confidence = summary.get("confidence", "?")
issues = []
# Check: does the one-liner contain text actually present in source?
# (For rule-based extraction, this should always be true)
words = one_liner.split()[:5]
found = sum(1 for w in words if w.lower() in source_text.lower())
if found < 3:
issues.append(f"Low overlap: {found}/5 words from source")
# Check: confidence matches content length
if confidence == "high" and len(source_text) < 500:
issues.append("High confidence on short source")
elif confidence == "low" and len(source_text) > 2000:
issues.append("Low confidence on long source")
if issues:
print(f" ⚠ [{eid}] {title[:50]}... issues: {'; '.join(issues)}")
print(f" Summary: {one_liner[:80]}...")
else:
print(f" ✓ [{eid}] {title[:50]}... confidence={confidence}")
time.sleep(0.3)
def main():
parser = argparse.ArgumentParser(description="AI Research Oracle — Summarization")
parser.add_argument("--source", default=None, help="Filter by source (github/arxiv/reddit)")
parser.add_argument("--limit", type=int, default=0, help="Max entries (0=all)")
parser.add_argument("--verify", action="store_true", help="Spot-check summaries")
args = parser.parse_args()
db_path = os.path.join(os.path.dirname(__file__), "oracle.db")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
# Find pending entries
where = "summary IS NULL"
params = []
if args.source:
where += " AND source = ?"
params.append(args.source)
cur.execute(f"SELECT COUNT(*) FROM entries WHERE {where}", params)
total_pending = cur.fetchone()[0]
print(f"=== Summarization Engine (Rule-based v1) ===")
print(f" Pending entries: {total_pending}")
if total_pending == 0:
print(" Nothing to summarize.")
conn.close()
return
# Fetch entries
limit_clause = " LIMIT ?" if args.limit > 0 else ""
limit_params = params + [args.limit] if args.limit > 0 else params
cur.execute(f"""
SELECT id, source, title, extracted_text
FROM entries WHERE {where}
ORDER BY signal_score DESC
{limit_clause}
""", limit_params)
entries = [{"id": r[0], "source": r[1], "title": r[2], "extracted_text": r[3]} for r in cur.fetchall()]
print(f" Processing: {len(entries)} entries")
print()
success = 0
failed = 0
for entry in entries:
try:
if summarize_entry(entry, conn):
success += 1
print(f" ✓ [{entry['id']}] {entry['title'][:60]}... ({entry['source']})")
else:
failed += 1
print(f" ⚠ [{entry['id']}] Skipped: {entry['title'][:40]}... (too short)")
except Exception as e:
print(f" ✗ [{entry['id']}] Error: {e}")
failed += 1
if args.verify:
print(f"\n [Verification]")
sources = [args.source] if args.source else ["github", "arxiv", "reddit"]
for src in sources:
print(f" Checking {src}...")
verify_summaries(conn, src)
print()
print(f" Results: {success} summarized, {failed} failed")
conn.close()
print(f"\n Done.")
if __name__ == "__main__":
main()