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 ADDED
@@ -0,0 +1,3 @@
1
+ """CrowdSonar — social signal synthesis engine (Reddit listening → LLM classification → briefings)."""
2
+
3
+ __version__ = "0.1.2"
@@ -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()