crowdsonar 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crowdsonar/__init__.py +3 -0
- crowdsonar/arctic_shift.py +248 -0
- crowdsonar/classify.py +147 -0
- crowdsonar/cli.py +262 -0
- crowdsonar/config.py +49 -0
- crowdsonar/configs/example.yaml +55 -0
- crowdsonar/ingest.py +151 -0
- crowdsonar/prompts/classify.md +65 -0
- crowdsonar/prompts/synthesize.md +59 -0
- crowdsonar/storage.py +325 -0
- crowdsonar/synthesize.py +111 -0
- crowdsonar-0.1.2.dist-info/METADATA +134 -0
- crowdsonar-0.1.2.dist-info/RECORD +16 -0
- crowdsonar-0.1.2.dist-info/WHEEL +4 -0
- crowdsonar-0.1.2.dist-info/entry_points.txt +2 -0
- crowdsonar-0.1.2.dist-info/licenses/LICENSE +21 -0
crowdsonar/__init__.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Arctic Shift API adapter — credential-free Reddit ingestion (primary source).
|
|
2
|
+
|
|
3
|
+
Replaces PullPush (dead 2026-09) as the default ingestion path. Source-selection
|
|
4
|
+
rationale, in brief: PullPush's API went dark in September 2026; the official
|
|
5
|
+
Reddit API was a poor fit for research-scale reads (paid tier, per-request
|
|
6
|
+
costs, and 2026-09 policy shifts that killed several projects in this niche);
|
|
7
|
+
Arctic Shift is a volunteer-run public archive with a free, key-less JSON API
|
|
8
|
+
covering full Reddit history. Cost of the trade-off: single-operator
|
|
9
|
+
availability risk — mitigated by per-run local snapshots and a pluggable
|
|
10
|
+
source layer (PRAW overlay retained, HN/RSS adapters planned).
|
|
11
|
+
|
|
12
|
+
Strategy: fetch the full post window per subreddit (server-side time filter,
|
|
13
|
+
paginated), then filter by keywords locally using the same `_matches_keywords`
|
|
14
|
+
semantics as the PRAW path. Server-side keyword search (`title=`/`selftext=`/
|
|
15
|
+
`body=` params) is deliberately avoided — it times out on active subreddits
|
|
16
|
+
and the API docs mark its behavior as "not guaranteed".
|
|
17
|
+
|
|
18
|
+
Comment recovery mirrors PRAW: for each keyword-matched post, fetch comments
|
|
19
|
+
via `link_id`, sort by score locally, keep the top `comments_per_post`.
|
|
20
|
+
|
|
21
|
+
Be polite: this is a free volunteer-run service. Requests are throttled below
|
|
22
|
+
"a couple per second" (their stated safe zone); 429 and query-timeout get
|
|
23
|
+
retried with backoff and a shrunk limit.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from .ingest import IngestResult, RawPost, _matches_keywords
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
BASE_URL = "https://arctic-shift.photon-reddit.com"
|
|
37
|
+
POST_FIELDS = "id,title,selftext,subreddit,author,score,created_utc"
|
|
38
|
+
COMMENT_FIELDS = "id,body,author,score,created_utc,link_id"
|
|
39
|
+
PAGE_SIZE = 100 # explicit (not "auto") so cursor pagination is predictable
|
|
40
|
+
MAX_PAGES_PER_SUB = 20 # safety cap: 20 × 100 = 2000 posts per sub per window
|
|
41
|
+
REQUEST_INTERVAL = 0.6 # seconds between requests — inside their "couple/sec" tolerance
|
|
42
|
+
|
|
43
|
+
TIME_FILTER_DAYS = {
|
|
44
|
+
"hour": 1 / 24,
|
|
45
|
+
"day": 1,
|
|
46
|
+
"week": 7,
|
|
47
|
+
"month": 30,
|
|
48
|
+
"year": 365,
|
|
49
|
+
"all": None,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _iso(ts: float) -> str:
|
|
54
|
+
"""Epoch seconds → ISO string in the format the API accepts (validated)."""
|
|
55
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ArcticShiftClient:
|
|
59
|
+
"""Throttled wrapper over the Arctic Shift search endpoints."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, base_url: str = BASE_URL, timeout: float = 30.0,
|
|
62
|
+
request_interval: float = REQUEST_INTERVAL):
|
|
63
|
+
self._http = httpx.Client(base_url=base_url, timeout=timeout)
|
|
64
|
+
self._interval = request_interval
|
|
65
|
+
self._last = 0.0
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
self._http.close()
|
|
69
|
+
|
|
70
|
+
def search(self, kind: str, params: dict) -> list[dict]:
|
|
71
|
+
"""GET /api/{kind}/search with 429 / query-timeout retry. Returns data list."""
|
|
72
|
+
p = dict(params)
|
|
73
|
+
path = f"/api/{kind}/search"
|
|
74
|
+
for attempt in range(3):
|
|
75
|
+
wait = self._interval - (time.monotonic() - self._last)
|
|
76
|
+
if wait > 0:
|
|
77
|
+
time.sleep(wait)
|
|
78
|
+
self._last = time.monotonic()
|
|
79
|
+
try:
|
|
80
|
+
resp = self._http.get(path, params=p)
|
|
81
|
+
except httpx.HTTPError as e:
|
|
82
|
+
log.warning("Arctic Shift %s: %s (attempt %d/3)", path, e, attempt + 1)
|
|
83
|
+
time.sleep(2 ** attempt)
|
|
84
|
+
continue
|
|
85
|
+
if resp.status_code == 429:
|
|
86
|
+
reset = float(resp.headers.get("X-RateLimit-Reset") or 30)
|
|
87
|
+
log.warning("Arctic Shift: rate limited, sleeping %.0fs", min(reset, 120))
|
|
88
|
+
time.sleep(min(reset, 120))
|
|
89
|
+
continue
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
body = resp.json()
|
|
92
|
+
err = body.get("error")
|
|
93
|
+
if err:
|
|
94
|
+
msg = str(err)
|
|
95
|
+
if "imeout" in msg: # "Query timed out" / "Timeout. Maybe slow down a bit"
|
|
96
|
+
p = dict(p)
|
|
97
|
+
lim = p.get("limit", PAGE_SIZE)
|
|
98
|
+
# "auto" (comment search) has no numeric half — drop to 100.
|
|
99
|
+
p["limit"] = max(10, lim // 2) if isinstance(lim, int) else 100
|
|
100
|
+
log.warning("Arctic Shift: query timed out — retrying with limit=%s", p["limit"])
|
|
101
|
+
time.sleep(2 ** attempt)
|
|
102
|
+
continue
|
|
103
|
+
raise RuntimeError(f"Arctic Shift error: {msg}")
|
|
104
|
+
return body.get("data") or []
|
|
105
|
+
return [] # retries exhausted; caller sees a short/empty page
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch_window_posts(client: ArcticShiftClient, subreddit: str,
|
|
109
|
+
window_days: float | None) -> list[dict]:
|
|
110
|
+
"""All posts in r/<subreddit> within `window_days` (None = no lower bound)."""
|
|
111
|
+
items: list[dict] = []
|
|
112
|
+
cursor: float | None = None
|
|
113
|
+
for _ in range(MAX_PAGES_PER_SUB):
|
|
114
|
+
params = {
|
|
115
|
+
"subreddit": subreddit,
|
|
116
|
+
"limit": PAGE_SIZE,
|
|
117
|
+
"sort": "asc",
|
|
118
|
+
"fields": POST_FIELDS,
|
|
119
|
+
}
|
|
120
|
+
if cursor is not None:
|
|
121
|
+
params["after"] = _iso(cursor)
|
|
122
|
+
elif window_days is not None:
|
|
123
|
+
params["after"] = _iso(
|
|
124
|
+
(datetime.now(timezone.utc) - timedelta(days=window_days)).timestamp()
|
|
125
|
+
)
|
|
126
|
+
batch = client.search("posts", params)
|
|
127
|
+
if not batch:
|
|
128
|
+
break
|
|
129
|
+
items.extend(batch)
|
|
130
|
+
if len(batch) < PAGE_SIZE:
|
|
131
|
+
break
|
|
132
|
+
new_cursor = float(batch[-1].get("created_utc") or 0)
|
|
133
|
+
if cursor is not None and new_cursor <= cursor:
|
|
134
|
+
break # cursor stalled (same-second pileup); dedup handles overlap
|
|
135
|
+
cursor = new_cursor
|
|
136
|
+
return items
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch_post_comments(client: ArcticShiftClient, post_id: str) -> list[dict]:
|
|
140
|
+
"""Comments under a post (up to ~1000 via limit=auto); caller sorts/filters."""
|
|
141
|
+
return client.search("comments", {
|
|
142
|
+
"link_id": f"t3_{post_id}",
|
|
143
|
+
"limit": "auto",
|
|
144
|
+
"fields": COMMENT_FIELDS,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _post_raw(item: dict, kw: str) -> RawPost:
|
|
149
|
+
pid = item.get("id", "")
|
|
150
|
+
sub = item.get("subreddit", "")
|
|
151
|
+
return RawPost(
|
|
152
|
+
source="arctic",
|
|
153
|
+
post_id=pid,
|
|
154
|
+
post_title=item.get("title", ""),
|
|
155
|
+
post_url=f"https://reddit.com/r/{sub}/comments/{pid}",
|
|
156
|
+
post_subreddit=sub,
|
|
157
|
+
post_author=item.get("author") or "[deleted]",
|
|
158
|
+
post_score=int(item.get("score") or 0),
|
|
159
|
+
post_created_utc=float(item.get("created_utc") or 0),
|
|
160
|
+
post_body=item.get("selftext") or "",
|
|
161
|
+
keyword_match=kw,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _comment_raw(item: dict, parent: RawPost, kw: str) -> RawPost:
|
|
166
|
+
return RawPost(
|
|
167
|
+
source="arctic",
|
|
168
|
+
post_id=parent.post_id,
|
|
169
|
+
post_title=parent.post_title,
|
|
170
|
+
post_url=parent.post_url,
|
|
171
|
+
post_subreddit=parent.post_subreddit,
|
|
172
|
+
post_author=parent.post_author,
|
|
173
|
+
post_score=parent.post_score,
|
|
174
|
+
post_created_utc=parent.post_created_utc,
|
|
175
|
+
post_body=parent.post_body,
|
|
176
|
+
comment_id=item.get("id", ""),
|
|
177
|
+
comment_body=item.get("body") or "",
|
|
178
|
+
comment_author=item.get("author") or "[deleted]",
|
|
179
|
+
comment_score=int(item.get("score") or 0),
|
|
180
|
+
comment_created_utc=float(item.get("created_utc") or 0),
|
|
181
|
+
keyword_match=kw,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def ingest_arctic(
|
|
186
|
+
subreddits: list[str],
|
|
187
|
+
keywords: list[str],
|
|
188
|
+
time_filter: str = "week",
|
|
189
|
+
post_limit: int = 50,
|
|
190
|
+
comments_per_post: int = 20,
|
|
191
|
+
backfill_days: int = 0,
|
|
192
|
+
base_url: str = BASE_URL,
|
|
193
|
+
) -> IngestResult:
|
|
194
|
+
"""Ingest via Arctic Shift — semantics mirror ingest_praw.
|
|
195
|
+
|
|
196
|
+
Posts matching any keyword (title or body, case-insensitive substring)
|
|
197
|
+
within the time window, plus the top `comments_per_post` comments (by
|
|
198
|
+
score) of each matched post. `backfill_days > 0` widens the window
|
|
199
|
+
(replaces the dead PullPush backfill). `post_limit` caps matched posts
|
|
200
|
+
per subreddit (newest kept).
|
|
201
|
+
"""
|
|
202
|
+
result = IngestResult()
|
|
203
|
+
window = backfill_days if backfill_days > 0 else TIME_FILTER_DAYS.get(time_filter, 7)
|
|
204
|
+
client = ArcticShiftClient(base_url=base_url)
|
|
205
|
+
try:
|
|
206
|
+
for sub in subreddits:
|
|
207
|
+
try:
|
|
208
|
+
items = fetch_window_posts(client, sub, window)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
result.errors.append(f"posts r/{sub}: {e}")
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Pagination can straddle a boundary second — dedupe by post id.
|
|
214
|
+
seen: set[str] = set()
|
|
215
|
+
unique = [x for x in items
|
|
216
|
+
if not ((x.get("id") or "") in seen or seen.add(x.get("id") or ""))]
|
|
217
|
+
log.info("Arctic Shift: r/%s → %d posts in %.1f-day window",
|
|
218
|
+
sub, len(unique), window if window is not None else -1)
|
|
219
|
+
|
|
220
|
+
matched: list[tuple[dict, str]] = []
|
|
221
|
+
for it in unique:
|
|
222
|
+
kw = _matches_keywords(
|
|
223
|
+
(it.get("title") or "") + " " + (it.get("selftext") or ""), keywords
|
|
224
|
+
)
|
|
225
|
+
if kw:
|
|
226
|
+
matched.append((it, kw))
|
|
227
|
+
|
|
228
|
+
if post_limit and len(matched) > post_limit:
|
|
229
|
+
matched.sort(key=lambda x: x[0].get("created_utc") or 0, reverse=True)
|
|
230
|
+
matched = matched[:post_limit]
|
|
231
|
+
|
|
232
|
+
for it, kw in matched:
|
|
233
|
+
parent = _post_raw(it, kw)
|
|
234
|
+
result.posts.append(parent)
|
|
235
|
+
if comments_per_post > 0:
|
|
236
|
+
try:
|
|
237
|
+
cs = fetch_post_comments(client, it.get("id", ""))
|
|
238
|
+
except Exception as e:
|
|
239
|
+
result.errors.append(f"comments t3_{it.get('id')}: {e}")
|
|
240
|
+
cs = []
|
|
241
|
+
cs.sort(key=lambda c: c.get("score") or 0, reverse=True)
|
|
242
|
+
for c in cs[:comments_per_post]:
|
|
243
|
+
result.posts.append(_comment_raw(c, parent, kw))
|
|
244
|
+
finally:
|
|
245
|
+
client.close()
|
|
246
|
+
|
|
247
|
+
log.info("Arctic Shift: collected %d items, %d errors", len(result.posts), len(result.errors))
|
|
248
|
+
return result
|
crowdsonar/classify.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""LLM classification — batch posts through a cheap model, return structured signals."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from importlib import resources
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
PROMPT_DIR = resources.files("crowdsonar") / "prompts"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_prompt(name: str) -> str:
|
|
16
|
+
return (PROMPT_DIR / f"{name}.md").read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_batch_items(raw_posts) -> list[dict]:
|
|
20
|
+
"""Convert RawPost list into compact items for the LLM."""
|
|
21
|
+
items = []
|
|
22
|
+
for i, p in enumerate(raw_posts):
|
|
23
|
+
text = p.post_title
|
|
24
|
+
if p.comment_body:
|
|
25
|
+
text += f"\n\nComment: {p.comment_body}"
|
|
26
|
+
elif p.post_body:
|
|
27
|
+
text += f"\n\n{p.post_body}"
|
|
28
|
+
|
|
29
|
+
items.append({
|
|
30
|
+
"idx": i,
|
|
31
|
+
"text": text[:1500], # truncate to save tokens
|
|
32
|
+
"subreddit": p.post_subreddit,
|
|
33
|
+
"keyword_match": p.keyword_match,
|
|
34
|
+
})
|
|
35
|
+
return items
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def classify_batch(
|
|
39
|
+
raw_posts: list,
|
|
40
|
+
model: str = "gpt-4o-mini",
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
batch_size: int = 25,
|
|
44
|
+
) -> list[dict]:
|
|
45
|
+
"""Classify a list of RawPost objects via LLM.
|
|
46
|
+
|
|
47
|
+
Returns a list of classification dicts, one per input post,
|
|
48
|
+
aligned by index.
|
|
49
|
+
"""
|
|
50
|
+
api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
51
|
+
base_url = base_url or os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|
52
|
+
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError("No API key provided. Set OPENAI_API_KEY or pass api_key.")
|
|
55
|
+
|
|
56
|
+
system_prompt = _load_prompt("classify")
|
|
57
|
+
all_classifications: list[dict] = []
|
|
58
|
+
|
|
59
|
+
# Process in batches
|
|
60
|
+
for start in range(0, len(raw_posts), batch_size):
|
|
61
|
+
batch = raw_posts[start:start + batch_size]
|
|
62
|
+
items = _build_batch_items(batch)
|
|
63
|
+
|
|
64
|
+
user_msg = json.dumps(items, ensure_ascii=False)
|
|
65
|
+
|
|
66
|
+
log.info("Classifying batch %d-%d (%d items) via %s",
|
|
67
|
+
start, start + len(batch), len(batch), model)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
resp = httpx.post(
|
|
71
|
+
f"{base_url}/chat/completions",
|
|
72
|
+
headers={
|
|
73
|
+
"Authorization": f"Bearer {api_key}",
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
},
|
|
76
|
+
json={
|
|
77
|
+
"model": model,
|
|
78
|
+
"messages": [
|
|
79
|
+
{"role": "system", "content": system_prompt},
|
|
80
|
+
{"role": "user", "content": user_msg},
|
|
81
|
+
],
|
|
82
|
+
"temperature": 0.1,
|
|
83
|
+
# Some backends spend reasoning tokens inside max_tokens —
|
|
84
|
+
# pin effort explicitly so content never gets starved.
|
|
85
|
+
"reasoning_effort": "low",
|
|
86
|
+
"thinking": {"type": "enabled"},
|
|
87
|
+
"max_tokens": 4096,
|
|
88
|
+
},
|
|
89
|
+
timeout=120,
|
|
90
|
+
)
|
|
91
|
+
resp.raise_for_status()
|
|
92
|
+
content = resp.json()["choices"][0]["message"]["content"].strip()
|
|
93
|
+
|
|
94
|
+
# Strip markdown fences if present
|
|
95
|
+
if content.startswith("```"):
|
|
96
|
+
content = content.split("\n", 1)[1] if "\n" in content else content[3:]
|
|
97
|
+
if content.endswith("```"):
|
|
98
|
+
content = content[:-3]
|
|
99
|
+
content = content.strip()
|
|
100
|
+
|
|
101
|
+
parsed = json.loads(content)
|
|
102
|
+
if not isinstance(parsed, list):
|
|
103
|
+
parsed = [parsed]
|
|
104
|
+
|
|
105
|
+
# Pad with defaults if LLM returned fewer items
|
|
106
|
+
default = {
|
|
107
|
+
"signal_type": "noise",
|
|
108
|
+
"sentiment": "neutral",
|
|
109
|
+
"strength": "casual",
|
|
110
|
+
"confirmation_status": "self_announced",
|
|
111
|
+
"entities": [],
|
|
112
|
+
"summary": "",
|
|
113
|
+
}
|
|
114
|
+
while len(parsed) < len(batch):
|
|
115
|
+
parsed.append({**default, "idx": len(parsed)})
|
|
116
|
+
|
|
117
|
+
all_classifications.extend(parsed)
|
|
118
|
+
|
|
119
|
+
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
120
|
+
log.error("Failed to parse LLM response: %s", e)
|
|
121
|
+
# Fill batch with noise defaults
|
|
122
|
+
for i in range(len(batch)):
|
|
123
|
+
all_classifications.append({
|
|
124
|
+
"idx": start + i,
|
|
125
|
+
"signal_type": "noise",
|
|
126
|
+
"sentiment": "neutral",
|
|
127
|
+
"strength": "casual",
|
|
128
|
+
"confirmation_status": "self_announced",
|
|
129
|
+
"entities": [],
|
|
130
|
+
"summary": f"[classification failed: {e}]",
|
|
131
|
+
})
|
|
132
|
+
except Exception as e:
|
|
133
|
+
log.error("LLM call failed: %s", e)
|
|
134
|
+
for i in range(len(batch)):
|
|
135
|
+
all_classifications.append({
|
|
136
|
+
"idx": start + i,
|
|
137
|
+
"signal_type": "noise",
|
|
138
|
+
"sentiment": "neutral",
|
|
139
|
+
"strength": "casual",
|
|
140
|
+
"confirmation_status": "self_announced",
|
|
141
|
+
"entities": [],
|
|
142
|
+
"summary": f"[classification failed: {e}]",
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
# Sort by idx to align with input
|
|
146
|
+
all_classifications.sort(key=lambda x: x.get("idx", 0))
|
|
147
|
+
return all_classifications
|
crowdsonar/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CrowdSonar CLI — ingest, classify, synthesize social signals.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
crowdsonar --topic example
|
|
6
|
+
crowdsonar --topic example --dry-run
|
|
7
|
+
crowdsonar --topic example --source arctic
|
|
8
|
+
crowdsonar --topic example --backfill
|
|
9
|
+
crowdsonar --topic example --classify-only
|
|
10
|
+
crowdsonar --topic example --synthesize-only
|
|
11
|
+
crowdsonar --list
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from crowdsonar import __version__
|
|
21
|
+
from crowdsonar.config import load_topic, list_topics
|
|
22
|
+
from crowdsonar.ingest import (
|
|
23
|
+
IngestResult,
|
|
24
|
+
RawPost,
|
|
25
|
+
ingest_praw,
|
|
26
|
+
create_praw_client,
|
|
27
|
+
)
|
|
28
|
+
from crowdsonar.arctic_shift import ingest_arctic
|
|
29
|
+
from crowdsonar.classify import classify_batch
|
|
30
|
+
from crowdsonar.storage import (
|
|
31
|
+
save_signals,
|
|
32
|
+
save_raw_posts,
|
|
33
|
+
pending_classifications,
|
|
34
|
+
)
|
|
35
|
+
from crowdsonar.synthesize import synthesize_briefing
|
|
36
|
+
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=logging.INFO,
|
|
39
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
40
|
+
datefmt="%H:%M:%S",
|
|
41
|
+
)
|
|
42
|
+
log = logging.getLogger("crowdsonar")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _raw_row_to_post(row: dict) -> RawPost:
|
|
46
|
+
"""Convert a raw-store row dict back into a RawPost for classification."""
|
|
47
|
+
return RawPost(
|
|
48
|
+
source=row["source"],
|
|
49
|
+
post_id=row["post_id"],
|
|
50
|
+
post_title=row["post_title"],
|
|
51
|
+
post_url=row["post_url"],
|
|
52
|
+
post_subreddit=row["post_subreddit"],
|
|
53
|
+
post_author=row["post_author"],
|
|
54
|
+
post_score=row["post_score"],
|
|
55
|
+
post_created_utc=row["post_created_utc"],
|
|
56
|
+
post_body=row["post_body"],
|
|
57
|
+
comment_id=row["comment_id"],
|
|
58
|
+
comment_body=row["comment_body"],
|
|
59
|
+
comment_author=row["comment_author"],
|
|
60
|
+
comment_score=row["comment_score"],
|
|
61
|
+
comment_created_utc=row["comment_created_utc"],
|
|
62
|
+
keyword_match=row["keyword_match"],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_args():
|
|
67
|
+
p = argparse.ArgumentParser(description="CrowdSonar — social signal synthesis engine")
|
|
68
|
+
p.add_argument("--topic", "-t", help="Topic config name (from configs/)")
|
|
69
|
+
p.add_argument("--list", "-l", action="store_true", help="List available topics")
|
|
70
|
+
p.add_argument("--dry-run", action="store_true", help="Ingest and persist the raw store, skip all LLM calls")
|
|
71
|
+
p.add_argument("--source", choices=["auto", "arctic", "praw"], default="auto",
|
|
72
|
+
help="Ingestion source: arctic = Arctic Shift API (credential-free, "
|
|
73
|
+
"default); praw = Reddit OAuth only; auto = arctic + PRAW overlay "
|
|
74
|
+
"when REDDIT_CLIENT_ID/SECRET are set")
|
|
75
|
+
p.add_argument("--backfill", action="store_true",
|
|
76
|
+
help="Widen the ingest window to backfill_days from the topic config "
|
|
77
|
+
"(one-off deep backfill; default window is time_filter)")
|
|
78
|
+
p.add_argument("--classify-only", action="store_true", help="Classify the pending raw/classified delta via LLM, append to signals (idempotent)")
|
|
79
|
+
p.add_argument("--synthesize-only", action="store_true", help="Only generate briefing from classified data")
|
|
80
|
+
p.add_argument("--model", default="gpt-4o-mini", help="LLM model for classification")
|
|
81
|
+
p.add_argument("--synth-model", default=None, help="LLM model for synthesis (default: same as --model)")
|
|
82
|
+
p.add_argument("--config-dir", default=None, help="Override configs directory")
|
|
83
|
+
return p.parse_args()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main():
|
|
87
|
+
args = parse_args()
|
|
88
|
+
|
|
89
|
+
if args.list:
|
|
90
|
+
topics = list_topics(args.config_dir)
|
|
91
|
+
if not topics:
|
|
92
|
+
print("No topic configs found in configs/")
|
|
93
|
+
else:
|
|
94
|
+
print("Available topics:")
|
|
95
|
+
for t in topics:
|
|
96
|
+
print(f" - {t}")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if not args.topic:
|
|
100
|
+
print("Error: --topic is required (or use --list to see available topics)")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
# Load config
|
|
104
|
+
cfg = load_topic(args.topic, args.config_dir)
|
|
105
|
+
topic_name = cfg["name"]
|
|
106
|
+
reddit_cfg = cfg["sources"]["reddit"]
|
|
107
|
+
run_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
108
|
+
|
|
109
|
+
log.info("Topic: %s | Run: %s | Source: %s", topic_name, run_id, args.source)
|
|
110
|
+
|
|
111
|
+
# ── Phase 1: Ingest ──
|
|
112
|
+
all_posts: list[RawPost] = []
|
|
113
|
+
pending: list[dict] = [] # raw/classified delta; computed in Phase 1 or --classify-only
|
|
114
|
+
|
|
115
|
+
if not args.synthesize_only and not args.classify_only:
|
|
116
|
+
backfill_days = reddit_cfg.get("backfill_days", 0) if args.backfill else 0
|
|
117
|
+
|
|
118
|
+
# Arctic Shift — primary, credential-free
|
|
119
|
+
if args.source in ("auto", "arctic"):
|
|
120
|
+
label = f"window={backfill_days}d (backfill)" if backfill_days else \
|
|
121
|
+
f"time_filter={reddit_cfg.get('time_filter', 'week')}"
|
|
122
|
+
log.info("Phase 1a: Arctic Shift ingestion (%s)", label)
|
|
123
|
+
arctic_result = ingest_arctic(
|
|
124
|
+
subreddits=reddit_cfg["subreddits"],
|
|
125
|
+
keywords=reddit_cfg["keywords"],
|
|
126
|
+
time_filter=reddit_cfg.get("time_filter", "week"),
|
|
127
|
+
post_limit=reddit_cfg.get("post_limit", 50),
|
|
128
|
+
comments_per_post=reddit_cfg.get("comments_per_post", 20),
|
|
129
|
+
backfill_days=backfill_days,
|
|
130
|
+
)
|
|
131
|
+
all_posts.extend(arctic_result.posts)
|
|
132
|
+
for e in arctic_result.errors:
|
|
133
|
+
log.warning("Arctic Shift: %s", e)
|
|
134
|
+
|
|
135
|
+
# PRAW — optional real-time overlay
|
|
136
|
+
if args.source in ("auto", "praw"):
|
|
137
|
+
client_id = os.environ.get("REDDIT_CLIENT_ID")
|
|
138
|
+
client_secret = os.environ.get("REDDIT_CLIENT_SECRET")
|
|
139
|
+
if client_id and client_secret:
|
|
140
|
+
log.info("Phase 1b: PRAW real-time ingestion (overlay)")
|
|
141
|
+
reddit = create_praw_client(
|
|
142
|
+
client_id=client_id,
|
|
143
|
+
client_secret=client_secret,
|
|
144
|
+
user_agent=f"crowdsonar/{__version__} (research project)",
|
|
145
|
+
)
|
|
146
|
+
praw_result = ingest_praw(
|
|
147
|
+
reddit=reddit,
|
|
148
|
+
subreddits=reddit_cfg["subreddits"],
|
|
149
|
+
keywords=reddit_cfg["keywords"],
|
|
150
|
+
sort=reddit_cfg.get("sort", "new"),
|
|
151
|
+
time_filter=reddit_cfg.get("time_filter", "week"),
|
|
152
|
+
post_limit=reddit_cfg.get("post_limit", 50),
|
|
153
|
+
comments_per_post=reddit_cfg.get("comments_per_post", 20),
|
|
154
|
+
)
|
|
155
|
+
all_posts.extend(praw_result.posts)
|
|
156
|
+
for e in praw_result.errors:
|
|
157
|
+
log.warning("PRAW: %s", e)
|
|
158
|
+
elif args.source == "praw":
|
|
159
|
+
log.error("--source praw requires REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET")
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
else:
|
|
162
|
+
log.info("No REDDIT_CLIENT_ID/SECRET — PRAW overlay skipped (Arctic Shift only)")
|
|
163
|
+
|
|
164
|
+
# Deduplicate by (post_id, comment_id)
|
|
165
|
+
seen = set()
|
|
166
|
+
deduped = []
|
|
167
|
+
for p in all_posts:
|
|
168
|
+
key = (p.post_id, p.comment_id)
|
|
169
|
+
if key not in seen:
|
|
170
|
+
seen.add(key)
|
|
171
|
+
deduped.append(p)
|
|
172
|
+
all_posts = deduped
|
|
173
|
+
|
|
174
|
+
log.info("Ingest complete: %d unique items", len(all_posts))
|
|
175
|
+
|
|
176
|
+
# Persist raw BEFORE any LLM call (raw store) —
|
|
177
|
+
# banks the fetch even when we stop here (dry-run / empty window).
|
|
178
|
+
raw_path, new_raw = save_raw_posts(all_posts, topic_name, run_id)
|
|
179
|
+
log.info("Raw store: %d new rows -> %s", new_raw, raw_path)
|
|
180
|
+
|
|
181
|
+
# Classification operates on the raw/classified delta — idempotent
|
|
182
|
+
# across re-ingest and crash-resume (raw persists pre-LLM).
|
|
183
|
+
pending = pending_classifications(topic_name)
|
|
184
|
+
|
|
185
|
+
if args.dry_run:
|
|
186
|
+
print(f"\n[DRY RUN] {len(all_posts)} items ingested from {len(reddit_cfg['subreddits'])} subreddits; "
|
|
187
|
+
f"{new_raw} new raw rows persisted to {raw_path}.")
|
|
188
|
+
print(f"Pending classification delta: {len(pending)} item(s). No LLM calls made.")
|
|
189
|
+
for p in all_posts[:5]:
|
|
190
|
+
label = "comment" if p.comment_id else "post"
|
|
191
|
+
print(f" [{label}] r/{p.post_subreddit} ({p.keyword_match}) score={p.post_score} — {p.post_title[:80]}")
|
|
192
|
+
if len(all_posts) > 5:
|
|
193
|
+
print(f" ... and {len(all_posts) - 5} more")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if not all_posts:
|
|
197
|
+
print(f"\nNo new items ingested this run (raw store: {raw_path}); "
|
|
198
|
+
f"{len(pending)} pending item(s) in the classification delta.")
|
|
199
|
+
# fall through to Phase 2 — the delta may hold crash-resume work
|
|
200
|
+
|
|
201
|
+
# ── Phase 2: Classify ──
|
|
202
|
+
if not args.synthesize_only:
|
|
203
|
+
if args.classify_only:
|
|
204
|
+
# Skip ingest; operate purely on the raw/classified delta.
|
|
205
|
+
pending = pending_classifications(topic_name)
|
|
206
|
+
if not pending:
|
|
207
|
+
print(f"\n--classify-only: 0 pending — raw store for '{topic_name}' is fully classified.")
|
|
208
|
+
return
|
|
209
|
+
print(f"--classify-only: {len(pending)} pending item(s) in the raw/classified delta")
|
|
210
|
+
|
|
211
|
+
all_posts = [_raw_row_to_post(r) for r in pending]
|
|
212
|
+
|
|
213
|
+
if all_posts:
|
|
214
|
+
log.info("Phase 2: Classifying %d items via %s", len(all_posts), args.model)
|
|
215
|
+
classifications = classify_batch(all_posts, model=args.model)
|
|
216
|
+
|
|
217
|
+
# Save to Parquet
|
|
218
|
+
from crowdsonar.storage import _row_dict
|
|
219
|
+
rows = []
|
|
220
|
+
for raw, cls in zip(all_posts, classifications):
|
|
221
|
+
rows.append(_row_dict(raw, cls, run_id, topic_name))
|
|
222
|
+
|
|
223
|
+
path = save_signals(rows, topic_name, run_id)
|
|
224
|
+
print(f"\nSaved {len(rows)} classified signals to {path}")
|
|
225
|
+
|
|
226
|
+
# Quick summary
|
|
227
|
+
type_counts = {}
|
|
228
|
+
for r in rows:
|
|
229
|
+
t = r["signal_type"]
|
|
230
|
+
type_counts[t] = type_counts.get(t, 0) + 1
|
|
231
|
+
print("Signal breakdown:")
|
|
232
|
+
for t, c in sorted(type_counts.items(), key=lambda x: -x[1]):
|
|
233
|
+
print(f" {t}: {c}")
|
|
234
|
+
else:
|
|
235
|
+
log.info("Phase 2: classification delta is empty — nothing to classify")
|
|
236
|
+
|
|
237
|
+
# ── Phase 3: Synthesize ──
|
|
238
|
+
if not args.dry_run and not args.classify_only:
|
|
239
|
+
synth_model = args.synth_model or args.model
|
|
240
|
+
log.info("Phase 3: Synthesizing briefing via %s", synth_model)
|
|
241
|
+
briefing = synthesize_briefing(
|
|
242
|
+
topic=topic_name,
|
|
243
|
+
topic_cfg=cfg,
|
|
244
|
+
run_id=run_id,
|
|
245
|
+
model=synth_model,
|
|
246
|
+
)
|
|
247
|
+
print(f"\n{'='*60}")
|
|
248
|
+
print(f"BRIEFING: {topic_name}")
|
|
249
|
+
print(f"{'='*60}")
|
|
250
|
+
print(briefing)
|
|
251
|
+
|
|
252
|
+
# Persist briefing to Zone 1
|
|
253
|
+
from crowdsonar.storage import DATA_DIR
|
|
254
|
+
briefings_dir = DATA_DIR / "briefings"
|
|
255
|
+
briefings_dir.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
briefing_path = briefings_dir / f"{topic_name}_{run_id}.md"
|
|
257
|
+
briefing_path.write_text(briefing, encoding="utf-8")
|
|
258
|
+
log.info("Briefing saved to %s", briefing_path)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
main()
|