pythx-cli 0.0.3 → 0.0.5

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.
package/dist/cli.js CHANGED
@@ -1,9 +1,761 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
7
+
8
+ // src/cli.tsx
3
9
  import { render } from "ink";
4
10
  import meow from "meow";
5
- import { App } from "./app.js";
6
- const cli = meow(`
11
+
12
+ // src/app.tsx
13
+ import { useState as useState3 } from "react";
14
+ import { Box as Box5, Text as Text5, useApp, useInput } from "ink";
15
+ import chalk5 from "chalk";
16
+
17
+ // src/components/header.tsx
18
+ import { useState, useEffect } from "react";
19
+ import { Box, Text } from "ink";
20
+ import chalk from "chalk";
21
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
22
+ function Header({ lastUpdated, loading }) {
23
+ const [secondsAgo, setSecondsAgo] = useState(0);
24
+ useEffect(() => {
25
+ if (!lastUpdated) return;
26
+ setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1e3));
27
+ const interval = setInterval(() => {
28
+ setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1e3));
29
+ }, 1e3);
30
+ return () => clearInterval(interval);
31
+ }, [lastUpdated]);
32
+ return /* @__PURE__ */ jsxs(
33
+ Box,
34
+ {
35
+ borderStyle: "single",
36
+ borderColor: "green",
37
+ paddingX: 1,
38
+ justifyContent: "space-between",
39
+ children: [
40
+ /* @__PURE__ */ jsxs(Text, { children: [
41
+ chalk.green.bold("PYTHX"),
42
+ chalk.dim(" \u2591\u2591 "),
43
+ chalk.white("SENTIMENT TERMINAL")
44
+ ] }),
45
+ /* @__PURE__ */ jsx(Text, { children: loading ? chalk.yellow("\u27F3 Loading...") : lastUpdated ? /* @__PURE__ */ jsxs(Fragment, { children: [
46
+ chalk.green("\u25CF "),
47
+ chalk.dim(`LIVE \xB7 ${secondsAgo}s ago`)
48
+ ] }) : chalk.dim("Waiting...") })
49
+ ]
50
+ }
51
+ );
52
+ }
53
+
54
+ // src/components/entity-row.tsx
55
+ import { Box as Box2, Text as Text2 } from "ink";
56
+ import chalk2 from "chalk";
57
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
58
+ var COL = {
59
+ name: 15,
60
+ score: 8,
61
+ trend: 10,
62
+ stat: 5
63
+ };
64
+ function formatScore(score) {
65
+ const prefix = score >= 0 ? "+" : "";
66
+ return `${prefix}${score.toFixed(2)}`;
67
+ }
68
+ function sparkline(distribution) {
69
+ const { positive, neutral, negative, total } = distribution;
70
+ if (total === 0) return chalk2.dim("\u2591".repeat(COL.trend));
71
+ const posWidth = Math.round(positive / total * COL.trend);
72
+ const negWidth = Math.round(negative / total * COL.trend);
73
+ const neuWidth = COL.trend - posWidth - negWidth;
74
+ return chalk2.green("\u2588".repeat(posWidth)) + chalk2.gray("\u2591".repeat(Math.max(0, neuWidth))) + chalk2.red("\u2588".repeat(negWidth));
75
+ }
76
+ function EntityRow({ analysis, isActive }) {
77
+ const { entity, snapshot } = analysis;
78
+ const { distribution } = snapshot;
79
+ const cursor = isActive ? chalk2.green("\u25B6 ") : " ";
80
+ const name = entity.name.length > COL.name ? entity.name.substring(0, COL.name - 1) + "\u2026" : entity.name.padEnd(COL.name);
81
+ const score = formatScore(snapshot.averageScore).padStart(COL.score);
82
+ const trend = sparkline(distribution);
83
+ const pos = String(distribution.positive).padStart(COL.stat);
84
+ const neu = String(distribution.neutral).padStart(COL.stat);
85
+ const neg = String(distribution.negative).padStart(COL.stat);
86
+ const tot = String(distribution.total).padStart(COL.stat);
87
+ const colorName = isActive ? chalk2.green.bold(name) : chalk2.white(name);
88
+ const colorScore = snapshot.averageScore > 0.05 ? chalk2.green(score) : snapshot.averageScore < -0.05 ? chalk2.red(score) : chalk2.gray(score);
89
+ return /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsxs2(Text2, { children: [
90
+ cursor,
91
+ colorName,
92
+ colorScore,
93
+ " ",
94
+ trend,
95
+ chalk2.green(pos),
96
+ chalk2.gray(neu),
97
+ chalk2.red(neg),
98
+ chalk2.dim(tot)
99
+ ] }) });
100
+ }
101
+
102
+ // src/components/detail-panel.tsx
103
+ import { Box as Box3, Text as Text3 } from "ink";
104
+ import chalk3 from "chalk";
105
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
106
+ function formatPost(post) {
107
+ const { sentiment } = post;
108
+ const labelMap = {
109
+ positive: chalk3.green,
110
+ negative: chalk3.red,
111
+ neutral: chalk3.gray
112
+ };
113
+ const colorFn = labelMap[sentiment.label];
114
+ const tag = colorFn(
115
+ `[${sentiment.label.substring(0, 3).toUpperCase()} ${(sentiment.score * 100).toFixed(0)}%]`
116
+ );
117
+ const author = post.authorUsername ? chalk3.dim(`@${post.authorUsername}`) : chalk3.dim("@unknown");
118
+ const text2 = post.text.replace(/\n/g, " ").substring(0, 70).trim();
119
+ const ellipsis = post.text.length > 70 ? chalk3.dim("...") : "";
120
+ return ` ${tag} ${author}: "${text2}${ellipsis}"`;
121
+ }
122
+ function DetailPanel({ analysis }) {
123
+ const { entity, snapshot } = analysis;
124
+ const scoreColor = snapshot.averageScore > 0.05 ? chalk3.green : snapshot.averageScore < -0.05 ? chalk3.red : chalk3.gray;
125
+ const allPosts = [
126
+ ...snapshot.topPositive,
127
+ ...snapshot.topNegative
128
+ ].sort((a, b) => b.sentiment.score - a.sentiment.score);
129
+ return /* @__PURE__ */ jsxs3(
130
+ Box3,
131
+ {
132
+ flexDirection: "column",
133
+ borderStyle: "single",
134
+ borderColor: "gray",
135
+ paddingX: 1,
136
+ children: [
137
+ /* @__PURE__ */ jsxs3(Box3, { justifyContent: "space-between", children: [
138
+ /* @__PURE__ */ jsxs3(Text3, { children: [
139
+ chalk3.green("\u25B6"),
140
+ " ",
141
+ chalk3.bold(entity.name.toUpperCase()),
142
+ chalk3.dim(" \u2014 Detail")
143
+ ] }),
144
+ /* @__PURE__ */ jsxs3(Text3, { children: [
145
+ chalk3.dim("avg: "),
146
+ scoreColor(
147
+ `${snapshot.averageScore >= 0 ? "+" : ""}${snapshot.averageScore.toFixed(3)}`
148
+ )
149
+ ] })
150
+ ] }),
151
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, flexDirection: "column", children: allPosts.length === 0 ? /* @__PURE__ */ jsx3(Text3, { children: chalk3.dim(" No posts to display") }) : allPosts.slice(0, 8).map((post) => /* @__PURE__ */ jsx3(Text3, { children: formatPost(post) }, post.id)) })
152
+ ]
153
+ }
154
+ );
155
+ }
156
+
157
+ // src/components/status-bar.tsx
158
+ import { Box as Box4, Text as Text4 } from "ink";
159
+ import chalk4 from "chalk";
160
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
161
+ function StatusBar() {
162
+ return /* @__PURE__ */ jsx4(
163
+ Box4,
164
+ {
165
+ borderStyle: "single",
166
+ borderColor: "gray",
167
+ paddingX: 1,
168
+ justifyContent: "center",
169
+ children: /* @__PURE__ */ jsxs4(Text4, { children: [
170
+ chalk4.dim("\u2591 "),
171
+ chalk4.white("q"),
172
+ chalk4.dim(":quit "),
173
+ chalk4.white("\u2191\u2193"),
174
+ chalk4.dim(":navigate "),
175
+ chalk4.white("enter"),
176
+ chalk4.dim(":expand "),
177
+ chalk4.white("r"),
178
+ chalk4.dim(":refresh")
179
+ ] })
180
+ }
181
+ );
182
+ }
183
+
184
+ // src/hooks/use-live-data.ts
185
+ import { useState as useState2, useEffect as useEffect2, useCallback } from "react";
186
+
187
+ // ../core/src/entities.ts
188
+ var DEFAULT_ENTITIES = [
189
+ {
190
+ id: "trump",
191
+ name: "Trump",
192
+ type: "person",
193
+ queries: [{ source: "x", query: "Trump -is:retweet lang:en" }],
194
+ icon: "user"
195
+ },
196
+ {
197
+ id: "bitcoin",
198
+ name: "Bitcoin",
199
+ type: "asset",
200
+ queries: [
201
+ {
202
+ source: "x",
203
+ query: "(Bitcoin OR BTC OR #Bitcoin) -is:retweet lang:en"
204
+ }
205
+ ],
206
+ icon: "bitcoin"
207
+ },
208
+ {
209
+ id: "ai",
210
+ name: "Artificial Intelligence",
211
+ type: "topic",
212
+ queries: [
213
+ {
214
+ source: "x",
215
+ query: '("artificial intelligence" OR #AI OR "machine learning") -is:retweet lang:en'
216
+ }
217
+ ],
218
+ icon: "brain"
219
+ }
220
+ ];
221
+
222
+ // ../core/src/providers/x.ts
223
+ var X_API_BASE = "https://api.x.com/2";
224
+ var HTML_ENTITIES = {
225
+ "&amp;": "&",
226
+ "&lt;": "<",
227
+ "&gt;": ">",
228
+ "&quot;": '"',
229
+ "&#39;": "'",
230
+ "&apos;": "'"
231
+ };
232
+ function decodeHtmlEntities(text2) {
233
+ return text2.replace(
234
+ /&(?:amp|lt|gt|quot|#39|apos);/g,
235
+ (match) => HTML_ENTITIES[match] ?? match
236
+ );
237
+ }
238
+ var XProvider = class {
239
+ type = "x";
240
+ async fetchPosts(query, options) {
241
+ const token = process.env.X_BEARER_TOKEN;
242
+ if (!token) {
243
+ throw new Error("X_BEARER_TOKEN environment variable is required");
244
+ }
245
+ const params = new URLSearchParams({
246
+ query,
247
+ max_results: String(Math.min(Math.max(options.count, 10), 100)),
248
+ "tweet.fields": "created_at,public_metrics,author_id",
249
+ expansions: "author_id",
250
+ "user.fields": "username"
251
+ });
252
+ if (options.sinceId) {
253
+ params.set("since_id", options.sinceId);
254
+ }
255
+ const url = `${X_API_BASE}/tweets/search/recent?${params}`;
256
+ const response = await fetch(url, {
257
+ headers: {
258
+ Authorization: `Bearer ${token}`
259
+ }
260
+ });
261
+ if (!response.ok) {
262
+ const errorText = await response.text();
263
+ throw new Error(
264
+ `X API error (${response.status}): ${errorText}`
265
+ );
266
+ }
267
+ const json = await response.json();
268
+ if (!json.data || json.data.length === 0) {
269
+ return [];
270
+ }
271
+ const userMap = /* @__PURE__ */ new Map();
272
+ if (json.includes?.users) {
273
+ for (const user of json.includes.users) {
274
+ userMap.set(user.id, user.username);
275
+ }
276
+ }
277
+ return json.data.map((tweet) => ({
278
+ id: tweet.id,
279
+ source: "x",
280
+ text: decodeHtmlEntities(tweet.text),
281
+ authorId: tweet.author_id,
282
+ authorUsername: userMap.get(tweet.author_id),
283
+ createdAt: tweet.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
284
+ url: `https://x.com/i/status/${tweet.id}`,
285
+ metrics: {
286
+ upvotes: tweet.public_metrics?.like_count ?? 0,
287
+ comments: tweet.public_metrics?.reply_count ?? 0,
288
+ shares: tweet.public_metrics?.retweet_count ?? 0
289
+ }
290
+ }));
291
+ }
292
+ getModelId() {
293
+ return "cardiffnlp/twitter-roberta-base-sentiment-latest";
294
+ }
295
+ };
296
+
297
+ // ../core/src/providers/index.ts
298
+ function getProvider(source) {
299
+ switch (source) {
300
+ case "x":
301
+ return new XProvider();
302
+ default:
303
+ throw new Error(`Provider not implemented: ${source}`);
304
+ }
305
+ }
306
+
307
+ // ../core/src/sentiment/classify.ts
308
+ import { HfInference } from "@huggingface/inference";
309
+
310
+ // ../core/src/sentiment/models.ts
311
+ var MODEL_LABEL_MAP = {
312
+ "cardiffnlp/twitter-roberta-base-sentiment-latest": {
313
+ positive: "positive",
314
+ negative: "negative",
315
+ neutral: "neutral"
316
+ },
317
+ "nlptown/bert-base-multilingual-uncased-sentiment": {
318
+ "1 star": "negative",
319
+ "2 stars": "negative",
320
+ "3 stars": "neutral",
321
+ "4 stars": "positive",
322
+ "5 stars": "positive"
323
+ },
324
+ "ProsusAI/finbert": {
325
+ positive: "positive",
326
+ negative: "negative",
327
+ neutral: "neutral"
328
+ },
329
+ "distilbert-base-uncased-finetuned-sst-2-english": {
330
+ POSITIVE: "positive",
331
+ NEGATIVE: "negative"
332
+ }
333
+ };
334
+ function normalizeLabel(modelId, rawLabel) {
335
+ const map = MODEL_LABEL_MAP[modelId];
336
+ if (map) {
337
+ const normalized = map[rawLabel];
338
+ if (normalized) return normalized;
339
+ }
340
+ const lower = rawLabel.toLowerCase();
341
+ if (lower.includes("positive")) return "positive";
342
+ if (lower.includes("negative")) return "negative";
343
+ return "neutral";
344
+ }
345
+
346
+ // ../core/src/sentiment/classify.ts
347
+ var hf = null;
348
+ function getClient() {
349
+ if (!hf) {
350
+ const token = process.env.HF_API_TOKEN;
351
+ if (!token) {
352
+ throw new Error("HF_API_TOKEN environment variable is required");
353
+ }
354
+ hf = new HfInference(token);
355
+ }
356
+ return hf;
357
+ }
358
+ async function classifyPost(text2, modelId) {
359
+ const client = getClient();
360
+ const results = await client.textClassification({
361
+ model: modelId,
362
+ inputs: text2
363
+ });
364
+ if (!results || results.length === 0) {
365
+ return { label: "neutral", score: 0.5 };
366
+ }
367
+ const top = results[0];
368
+ return {
369
+ label: normalizeLabel(modelId, top.label),
370
+ score: top.score
371
+ };
372
+ }
373
+ var BATCH_SIZE = 5;
374
+ async function classifyPosts(posts2, modelId) {
375
+ const results = [];
376
+ for (let i = 0; i < posts2.length; i += BATCH_SIZE) {
377
+ const batch = posts2.slice(i, i + BATCH_SIZE);
378
+ const classified = await Promise.all(
379
+ batch.map(async (post) => {
380
+ const sentiment = await classifyPost(post.text, modelId);
381
+ return { ...post, sentiment };
382
+ })
383
+ );
384
+ results.push(...classified);
385
+ }
386
+ return results;
387
+ }
388
+
389
+ // ../core/src/aggregator.ts
390
+ function computeDistribution(posts2) {
391
+ let positive = 0;
392
+ let negative = 0;
393
+ let neutral = 0;
394
+ for (const post of posts2) {
395
+ switch (post.sentiment.label) {
396
+ case "positive":
397
+ positive++;
398
+ break;
399
+ case "negative":
400
+ negative++;
401
+ break;
402
+ case "neutral":
403
+ neutral++;
404
+ break;
405
+ }
406
+ }
407
+ return { positive, negative, neutral, total: posts2.length };
408
+ }
409
+ function computeAverageScore(posts2) {
410
+ if (posts2.length === 0) return 0;
411
+ const sum = posts2.reduce((acc, post) => {
412
+ if (post.sentiment.label === "positive") return acc + post.sentiment.score;
413
+ if (post.sentiment.label === "negative") return acc - post.sentiment.score;
414
+ return acc;
415
+ }, 0);
416
+ return sum / posts2.length;
417
+ }
418
+ function aggregate(posts2) {
419
+ const distribution = computeDistribution(posts2);
420
+ const averageScore = computeAverageScore(posts2);
421
+ const topPositive = posts2.filter((p) => p.sentiment.label === "positive").sort((a, b) => b.sentiment.score - a.sentiment.score).slice(0, 5);
422
+ const topNegative = posts2.filter((p) => p.sentiment.label === "negative").sort((a, b) => b.sentiment.score - a.sentiment.score).slice(0, 5);
423
+ const sourceGroups = /* @__PURE__ */ new Map();
424
+ for (const post of posts2) {
425
+ const group = sourceGroups.get(post.source) ?? [];
426
+ group.push(post);
427
+ sourceGroups.set(post.source, group);
428
+ }
429
+ const bySource = [];
430
+ for (const [source, sourcePosts] of sourceGroups) {
431
+ bySource.push({
432
+ source,
433
+ distribution: computeDistribution(sourcePosts),
434
+ averageScore: computeAverageScore(sourcePosts)
435
+ });
436
+ }
437
+ return {
438
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
439
+ distribution,
440
+ bySource,
441
+ averageScore,
442
+ topPositive,
443
+ topNegative
444
+ };
445
+ }
446
+
447
+ // ../core/src/db/client.ts
448
+ import { drizzle } from "drizzle-orm/postgres-js";
449
+ import postgres from "postgres";
450
+
451
+ // ../core/src/db/schema.ts
452
+ var schema_exports = {};
453
+ __export(schema_exports, {
454
+ posts: () => posts,
455
+ snapshots: () => snapshots
456
+ });
457
+ import {
458
+ pgTable,
459
+ serial,
460
+ varchar,
461
+ text,
462
+ integer,
463
+ real,
464
+ timestamp,
465
+ jsonb,
466
+ uniqueIndex,
467
+ index
468
+ } from "drizzle-orm/pg-core";
469
+ var snapshots = pgTable(
470
+ "snapshots",
471
+ {
472
+ id: serial("id").primaryKey(),
473
+ entityId: varchar("entity_id", { length: 50 }).notNull(),
474
+ source: varchar("source", { length: 20 }),
475
+ timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(),
476
+ avgScore: real("avg_score").notNull(),
477
+ positiveCount: integer("positive_count").notNull(),
478
+ negativeCount: integer("negative_count").notNull(),
479
+ neutralCount: integer("neutral_count").notNull(),
480
+ totalCount: integer("total_count").notNull()
481
+ },
482
+ (table) => [
483
+ index("idx_snapshots_entity_time").on(table.entityId, table.timestamp),
484
+ index("idx_snapshots_source").on(
485
+ table.entityId,
486
+ table.source,
487
+ table.timestamp
488
+ )
489
+ ]
490
+ );
491
+ var posts = pgTable(
492
+ "posts",
493
+ {
494
+ id: serial("id").primaryKey(),
495
+ externalId: varchar("external_id", { length: 100 }).notNull(),
496
+ source: varchar("source", { length: 20 }).notNull(),
497
+ entityId: varchar("entity_id", { length: 50 }).notNull(),
498
+ text: text("text").notNull(),
499
+ authorId: varchar("author_id", { length: 100 }),
500
+ authorUsername: varchar("author_username", { length: 100 }),
501
+ url: text("url"),
502
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
503
+ fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull().defaultNow(),
504
+ upvotes: integer("upvotes").default(0),
505
+ comments: integer("comments").default(0),
506
+ shares: integer("shares").default(0),
507
+ sentimentLabel: varchar("sentiment_label", { length: 10 }).notNull(),
508
+ sentimentConfidence: real("sentiment_confidence").notNull(),
509
+ modelId: varchar("model_id", { length: 200 }).notNull(),
510
+ rawResponse: jsonb("raw_response")
511
+ },
512
+ (table) => [
513
+ uniqueIndex("idx_posts_dedup").on(table.externalId, table.source),
514
+ index("idx_posts_entity_time").on(table.entityId, table.createdAt),
515
+ index("idx_posts_source").on(table.source, table.createdAt),
516
+ index("idx_posts_sentiment").on(table.entityId, table.sentimentLabel)
517
+ ]
518
+ );
519
+
520
+ // ../core/src/db/client.ts
521
+ var db = null;
522
+ function getDb() {
523
+ if (db) return db;
524
+ const connectionString = process.env.POSTGRES_URL;
525
+ if (!connectionString) {
526
+ throw new Error("POSTGRES_URL environment variable is required");
527
+ }
528
+ const client = postgres(connectionString, { prepare: false });
529
+ db = drizzle(client, { schema: schema_exports });
530
+ return db;
531
+ }
532
+
533
+ // ../core/src/db/store.ts
534
+ import { desc, eq } from "drizzle-orm";
535
+ async function persistAnalysis(entityId, snapshot, posts2, modelId) {
536
+ const db2 = getDb();
537
+ await db2.insert(schema_exports.snapshots).values({
538
+ entityId,
539
+ source: null,
540
+ avgScore: snapshot.averageScore,
541
+ positiveCount: snapshot.distribution.positive,
542
+ negativeCount: snapshot.distribution.negative,
543
+ neutralCount: snapshot.distribution.neutral,
544
+ totalCount: snapshot.distribution.total
545
+ });
546
+ for (const breakdown of snapshot.bySource) {
547
+ await db2.insert(schema_exports.snapshots).values({
548
+ entityId,
549
+ source: breakdown.source,
550
+ avgScore: breakdown.averageScore,
551
+ positiveCount: breakdown.distribution.positive,
552
+ negativeCount: breakdown.distribution.negative,
553
+ neutralCount: breakdown.distribution.neutral,
554
+ totalCount: breakdown.distribution.total
555
+ });
556
+ }
557
+ if (posts2.length > 0) {
558
+ await db2.insert(schema_exports.posts).values(
559
+ posts2.map((post) => ({
560
+ externalId: post.id,
561
+ source: post.source,
562
+ entityId,
563
+ text: post.text,
564
+ authorId: post.authorId,
565
+ authorUsername: post.authorUsername ?? null,
566
+ url: post.url,
567
+ createdAt: new Date(post.createdAt),
568
+ upvotes: post.metrics.upvotes,
569
+ comments: post.metrics.comments,
570
+ shares: post.metrics.shares,
571
+ sentimentLabel: post.sentiment.label,
572
+ sentimentConfidence: post.sentiment.score,
573
+ modelId
574
+ }))
575
+ ).onConflictDoNothing({ target: [schema_exports.posts.externalId, schema_exports.posts.source] });
576
+ }
577
+ }
578
+ async function persistEntityAnalysis(analysis, modelId) {
579
+ await persistAnalysis(
580
+ analysis.entity.id,
581
+ analysis.snapshot,
582
+ analysis.posts,
583
+ modelId
584
+ );
585
+ }
586
+ async function loadCachedPosts(entityId, limit = 50) {
587
+ const db2 = getDb();
588
+ const rows = await db2.select().from(schema_exports.posts).where(eq(schema_exports.posts.entityId, entityId)).orderBy(desc(schema_exports.posts.createdAt)).limit(limit);
589
+ return rows.map((row) => ({
590
+ id: row.externalId,
591
+ source: row.source,
592
+ text: row.text,
593
+ authorId: row.authorId ?? "",
594
+ authorUsername: row.authorUsername ?? void 0,
595
+ createdAt: row.createdAt.toISOString(),
596
+ url: row.url ?? "",
597
+ metrics: {
598
+ upvotes: row.upvotes ?? 0,
599
+ comments: row.comments ?? 0,
600
+ shares: row.shares ?? 0
601
+ },
602
+ sentiment: {
603
+ label: row.sentimentLabel,
604
+ score: row.sentimentConfidence
605
+ }
606
+ }));
607
+ }
608
+
609
+ // src/hooks/use-live-data.ts
610
+ var POLL_INTERVAL = 30 * 6e4;
611
+ function useLiveData(apiUrl) {
612
+ const [data, setData] = useState2([]);
613
+ const [loading, setLoading] = useState2(true);
614
+ const [error, setError] = useState2(null);
615
+ const [lastUpdated, setLastUpdated] = useState2(null);
616
+ const fetchViaApi = useCallback(async () => {
617
+ if (!apiUrl) return;
618
+ try {
619
+ const res = await fetch(`${apiUrl}/api/compare`, {
620
+ method: "POST",
621
+ headers: { "Content-Type": "application/json" },
622
+ body: JSON.stringify({
623
+ entityIds: DEFAULT_ENTITIES.map((e) => e.id),
624
+ count: 10
625
+ })
626
+ });
627
+ const json = await res.json();
628
+ if (json.success && json.data) {
629
+ setData(json.data);
630
+ setLastUpdated(/* @__PURE__ */ new Date());
631
+ setError(null);
632
+ } else {
633
+ setError(json.error ?? "API error");
634
+ }
635
+ } catch (err) {
636
+ setError(err instanceof Error ? err.message : "Fetch failed");
637
+ } finally {
638
+ setLoading(false);
639
+ }
640
+ }, [apiUrl]);
641
+ const loadFromCache = useCallback(async () => {
642
+ const results = [];
643
+ for (const entity of DEFAULT_ENTITIES) {
644
+ const cached = await loadCachedPosts(entity.id, 50);
645
+ if (cached.length === 0) continue;
646
+ const snapshot = aggregate(cached);
647
+ results.push({
648
+ entity,
649
+ snapshot,
650
+ posts: cached,
651
+ fetchedAt: cached[0]?.sentiment ? (/* @__PURE__ */ new Date()).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
652
+ });
653
+ }
654
+ return results;
655
+ }, []);
656
+ const fetchDirect = useCallback(async () => {
657
+ try {
658
+ const results = [];
659
+ for (const entity of DEFAULT_ENTITIES) {
660
+ const allClassified = [];
661
+ for (const sq of entity.queries) {
662
+ const provider = getProvider(sq.source);
663
+ const posts2 = await provider.fetchPosts(sq.query, { count: 10 });
664
+ const classified = await classifyPosts(posts2, provider.getModelId());
665
+ allClassified.push(...classified);
666
+ }
667
+ const snapshot = aggregate(allClassified);
668
+ const analysis = {
669
+ entity,
670
+ snapshot,
671
+ posts: allClassified,
672
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
673
+ };
674
+ results.push(analysis);
675
+ try {
676
+ await persistEntityAnalysis(analysis, "hf-default");
677
+ } catch {
678
+ }
679
+ }
680
+ setData(results);
681
+ setLastUpdated(/* @__PURE__ */ new Date());
682
+ setError(null);
683
+ } catch (err) {
684
+ try {
685
+ const cached = await loadFromCache();
686
+ if (cached.length > 0) {
687
+ setData(cached);
688
+ setLastUpdated(/* @__PURE__ */ new Date());
689
+ setError("Live fetch failed \u2014 showing cached data");
690
+ return;
691
+ }
692
+ } catch {
693
+ }
694
+ setError(err instanceof Error ? err.message : "Analysis failed");
695
+ } finally {
696
+ setLoading(false);
697
+ }
698
+ }, [loadFromCache]);
699
+ const fetchData = apiUrl ? fetchViaApi : fetchDirect;
700
+ useEffect2(() => {
701
+ fetchData();
702
+ const interval = setInterval(fetchData, POLL_INTERVAL);
703
+ return () => clearInterval(interval);
704
+ }, [fetchData]);
705
+ return { data, loading, error, lastUpdated, refresh: fetchData };
706
+ }
707
+
708
+ // src/app.tsx
709
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
710
+ function App({ apiUrl }) {
711
+ const { data, loading, error, lastUpdated, refresh } = useLiveData(apiUrl);
712
+ const [activeIndex, setActiveIndex] = useState3(0);
713
+ const [showDetail, setShowDetail] = useState3(true);
714
+ const { exit } = useApp();
715
+ useInput((input, key) => {
716
+ if (input === "q") {
717
+ exit();
718
+ }
719
+ if (input === "r") {
720
+ refresh();
721
+ }
722
+ if (key.upArrow) {
723
+ setActiveIndex((i) => Math.max(0, i - 1));
724
+ }
725
+ if (key.downArrow) {
726
+ setActiveIndex((i) => Math.min(data.length - 1, i + 1));
727
+ }
728
+ if (key.return) {
729
+ setShowDetail((s) => !s);
730
+ }
731
+ });
732
+ const activeAnalysis = data[activeIndex];
733
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
734
+ /* @__PURE__ */ jsx5(Header, { lastUpdated, loading }),
735
+ error && /* @__PURE__ */ jsx5(Box5, { paddingX: 1, children: /* @__PURE__ */ jsx5(Text5, { children: chalk5.red(`Error: ${error}`) }) }),
736
+ loading && data.length === 0 ? /* @__PURE__ */ jsx5(Box5, { paddingX: 1, paddingY: 1, children: /* @__PURE__ */ jsx5(Text5, { children: chalk5.yellow("\u27F3 Fetching sentiment data...") }) }) : /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 1, children: [
737
+ /* @__PURE__ */ jsx5(Box5, { marginY: 1, children: /* @__PURE__ */ jsx5(Text5, { children: chalk5.dim(
738
+ " " + "ENTITY".padEnd(COL.name) + "SCORE".padStart(COL.score) + " " + "TREND".padEnd(COL.trend) + "POS".padStart(COL.stat) + "NEU".padStart(COL.stat) + "NEG".padStart(COL.stat) + "TOT".padStart(COL.stat)
739
+ ) }) }),
740
+ /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { children: chalk5.dim(" " + "\u2500".repeat(COL.name + COL.score + 2 + COL.trend + COL.stat * 4)) }) }),
741
+ data.map((analysis, i) => /* @__PURE__ */ jsx5(
742
+ EntityRow,
743
+ {
744
+ analysis,
745
+ isActive: i === activeIndex
746
+ },
747
+ analysis.entity.id
748
+ ))
749
+ ] }),
750
+ showDetail && activeAnalysis && /* @__PURE__ */ jsx5(DetailPanel, { analysis: activeAnalysis }),
751
+ /* @__PURE__ */ jsx5(StatusBar, {})
752
+ ] });
753
+ }
754
+
755
+ // src/cli.tsx
756
+ import { jsx as jsx6 } from "react/jsx-runtime";
757
+ var cli = meow(
758
+ `
7
759
  Usage
8
760
  $ pythx
9
761
 
@@ -14,18 +766,19 @@ const cli = meow(`
14
766
  $ pythx
15
767
  $ pythx --api-url http://localhost:3000
16
768
  $ pythx --api-url https://pythx.vercel.app
17
- `, {
769
+ `,
770
+ {
18
771
  importMeta: import.meta,
19
772
  flags: {
20
- apiUrl: {
21
- type: "string",
22
- },
23
- },
24
- });
25
- // Use alternate screen buffer so re-renders don't scroll the terminal
26
- process.stdout.write("\x1b[?1049h"); // enter alternate screen
27
- process.stdout.write("\x1b[H"); // move cursor to top
28
- const instance = render(_jsx(App, { apiUrl: cli.flags.apiUrl }), { patchConsole: false });
773
+ apiUrl: {
774
+ type: "string"
775
+ }
776
+ }
777
+ }
778
+ );
779
+ process.stdout.write("\x1B[?1049h");
780
+ process.stdout.write("\x1B[H");
781
+ var instance = render(/* @__PURE__ */ jsx6(App, { apiUrl: cli.flags.apiUrl }), { patchConsole: false });
29
782
  instance.waitUntilExit().then(() => {
30
- process.stdout.write("\x1b[?1049l"); // restore main screen on exit
783
+ process.stdout.write("\x1B[?1049l");
31
784
  });