alif-digest 1.0.1
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/.github/workflows/publish.yml +33 -0
- package/.husky/pre-commit +1 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +88 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/run.d.ts +4 -0
- package/dist/cli/commands/run.js +46 -0
- package/dist/cli/commands/run.js.map +1 -0
- package/dist/cli/commands/schedule.d.ts +1 -0
- package/dist/cli/commands/schedule.js +94 -0
- package/dist/cli/commands/schedule.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/config-manager.d.ts +14 -0
- package/dist/core/config-manager.js +65 -0
- package/dist/core/config-manager.js.map +1 -0
- package/dist/core/config-schema.d.ts +40 -0
- package/dist/core/config-schema.js +24 -0
- package/dist/core/config-schema.js.map +1 -0
- package/dist/core/default-keywords.d.ts +1 -0
- package/dist/core/default-keywords.js +10 -0
- package/dist/core/default-keywords.js.map +1 -0
- package/dist/core/filters/deduplicator.d.ts +10 -0
- package/dist/core/filters/deduplicator.js +34 -0
- package/dist/core/filters/deduplicator.js.map +1 -0
- package/dist/core/filters/keywords.d.ts +6 -0
- package/dist/core/filters/keywords.js +17 -0
- package/dist/core/filters/keywords.js.map +1 -0
- package/dist/core/orchestrator.d.ts +6 -0
- package/dist/core/orchestrator.js +44 -0
- package/dist/core/orchestrator.js.map +1 -0
- package/dist/core/pipeline.d.ts +15 -0
- package/dist/core/pipeline.js +140 -0
- package/dist/core/pipeline.js.map +1 -0
- package/dist/core/scheduler.d.ts +9 -0
- package/dist/core/scheduler.js +64 -0
- package/dist/core/scheduler.js.map +1 -0
- package/dist/core/scraper-types.d.ts +27 -0
- package/dist/core/scraper-types.js +3 -0
- package/dist/core/scraper-types.js.map +1 -0
- package/dist/core/scrapers/api-scraper.d.ts +4 -0
- package/dist/core/scrapers/api-scraper.js +46 -0
- package/dist/core/scrapers/api-scraper.js.map +1 -0
- package/dist/core/scrapers/arxiv-scraper.d.ts +4 -0
- package/dist/core/scrapers/arxiv-scraper.js +34 -0
- package/dist/core/scrapers/arxiv-scraper.js.map +1 -0
- package/dist/core/scrapers/json-scraper.d.ts +4 -0
- package/dist/core/scrapers/json-scraper.js +56 -0
- package/dist/core/scrapers/json-scraper.js.map +1 -0
- package/dist/core/scrapers/rss-scraper.d.ts +6 -0
- package/dist/core/scrapers/rss-scraper.js +32 -0
- package/dist/core/scrapers/rss-scraper.js.map +1 -0
- package/dist/core/scrapers/scrape-scraper.d.ts +4 -0
- package/dist/core/scrapers/scrape-scraper.js +49 -0
- package/dist/core/scrapers/scrape-scraper.js.map +1 -0
- package/dist/db/article-store.d.ts +22 -0
- package/dist/db/article-store.js +43 -0
- package/dist/db/article-store.js.map +1 -0
- package/dist/db/connection.d.ts +2 -0
- package/dist/db/connection.js +15 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +2 -0
- package/dist/db/migrate.js +60 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schedule-store.d.ts +17 -0
- package/dist/db/schedule-store.js +23 -0
- package/dist/db/schedule-store.js.map +1 -0
- package/dist/db/source-health-store.d.ts +16 -0
- package/dist/db/source-health-store.js +31 -0
- package/dist/db/source-health-store.js.map +1 -0
- package/dist/providers/delivery/index.d.ts +18 -0
- package/dist/providers/delivery/index.js +2 -0
- package/dist/providers/delivery/index.js.map +1 -0
- package/dist/providers/delivery/slack.d.ts +6 -0
- package/dist/providers/delivery/slack.js +52 -0
- package/dist/providers/delivery/slack.js.map +1 -0
- package/dist/providers/delivery/webhook.d.ts +6 -0
- package/dist/providers/delivery/webhook.js +16 -0
- package/dist/providers/delivery/webhook.js.map +1 -0
- package/dist/providers/factory.d.ts +7 -0
- package/dist/providers/factory.js +33 -0
- package/dist/providers/factory.js.map +1 -0
- package/dist/providers/llm/anthropic.d.ts +12 -0
- package/dist/providers/llm/anthropic.js +43 -0
- package/dist/providers/llm/anthropic.js.map +1 -0
- package/dist/providers/llm/index.d.ts +10 -0
- package/dist/providers/llm/index.js +2 -0
- package/dist/providers/llm/index.js.map +1 -0
- package/dist/providers/llm/ollama.d.ts +12 -0
- package/dist/providers/llm/ollama.js +42 -0
- package/dist/providers/llm/ollama.js.map +1 -0
- package/dist/providers/llm/openrouter.d.ts +13 -0
- package/dist/providers/llm/openrouter.js +53 -0
- package/dist/providers/llm/openrouter.js.map +1 -0
- package/dist/providers/llm/utils.d.ts +6 -0
- package/dist/providers/llm/utils.js +45 -0
- package/dist/providers/llm/utils.js.map +1 -0
- package/dist/resources/default-feeds.json +650 -0
- package/dist/resources/index.d.ts +2 -0
- package/dist/resources/index.js +3 -0
- package/dist/resources/index.js.map +1 -0
- package/eslint.config.mjs +29 -0
- package/package.json +66 -0
- package/src/cli/commands/init.ts +94 -0
- package/src/cli/commands/run.ts +52 -0
- package/src/cli/commands/schedule.ts +99 -0
- package/src/cli/index.ts +34 -0
- package/src/core/config-manager.ts +72 -0
- package/src/core/config-schema.ts +31 -0
- package/src/core/default-keywords.ts +9 -0
- package/src/core/filters/deduplicator.ts +39 -0
- package/src/core/filters/keywords.ts +18 -0
- package/src/core/orchestrator.ts +47 -0
- package/src/core/pipeline.ts +171 -0
- package/src/core/scheduler.ts +74 -0
- package/src/core/scraper-types.ts +30 -0
- package/src/core/scrapers/api-scraper.ts +45 -0
- package/src/core/scrapers/arxiv-scraper.ts +35 -0
- package/src/core/scrapers/json-scraper.ts +54 -0
- package/src/core/scrapers/rss-scraper.ts +34 -0
- package/src/core/scrapers/scrape-scraper.ts +50 -0
- package/src/db/article-store.ts +75 -0
- package/src/db/connection.ts +17 -0
- package/src/db/migrate.ts +68 -0
- package/src/db/schedule-store.ts +41 -0
- package/src/db/source-health-store.ts +42 -0
- package/src/providers/delivery/index.ts +19 -0
- package/src/providers/delivery/slack.ts +55 -0
- package/src/providers/delivery/webhook.ts +16 -0
- package/src/providers/factory.ts +37 -0
- package/src/providers/llm/anthropic.ts +48 -0
- package/src/providers/llm/index.ts +8 -0
- package/src/providers/llm/ollama.ts +44 -0
- package/src/providers/llm/openrouter.ts +56 -0
- package/src/providers/llm/utils.ts +54 -0
- package/src/resources/default-feeds.json +650 -0
- package/src/resources/index.ts +3 -0
- package/tests/config-manager.test.ts +70 -0
- package/tests/db-integration.test.ts +72 -0
- package/tests/filters.test.ts +53 -0
- package/tests/llm-provider.test.ts +115 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface Schedule {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
cron: string;
|
|
7
|
+
scheduled_time?: string | null; // HH:mm
|
|
8
|
+
active: number;
|
|
9
|
+
last_run?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ScheduleStore {
|
|
13
|
+
constructor(private db: Database) { }
|
|
14
|
+
|
|
15
|
+
add(schedule: Schedule) {
|
|
16
|
+
const stmt = this.db.prepare(`
|
|
17
|
+
INSERT INTO schedules (id, name, cron, scheduled_time, active, last_run)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
19
|
+
`);
|
|
20
|
+
stmt.run(
|
|
21
|
+
schedule.id,
|
|
22
|
+
schedule.name,
|
|
23
|
+
schedule.cron,
|
|
24
|
+
schedule.scheduled_time || null,
|
|
25
|
+
schedule.active,
|
|
26
|
+
schedule.last_run || null,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getAll(): Schedule[] {
|
|
31
|
+
return this.db.prepare('SELECT * FROM schedules').all() as Schedule[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
delete(id: string) {
|
|
35
|
+
this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
updateLastRun(id: string, timestamp: string) {
|
|
39
|
+
this.db.prepare('UPDATE schedules SET last_run = ? WHERE id = ?').run(timestamp, id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface SourceHealth {
|
|
4
|
+
source: string;
|
|
5
|
+
status: 'ok' | 'error';
|
|
6
|
+
items_found: number;
|
|
7
|
+
error_message?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SourceHealthStore {
|
|
11
|
+
constructor(private db: Database) {}
|
|
12
|
+
|
|
13
|
+
record(health: SourceHealth) {
|
|
14
|
+
const stmt = this.db.prepare(`
|
|
15
|
+
INSERT INTO source_health (source, status, items_found, error_message)
|
|
16
|
+
VALUES (?, ?, ?, ?)
|
|
17
|
+
`);
|
|
18
|
+
stmt.run(health.source, health.status, health.items_found, health.error_message || null);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getLatest(source: string) {
|
|
22
|
+
return this.db
|
|
23
|
+
.prepare(
|
|
24
|
+
`
|
|
25
|
+
SELECT * FROM source_health WHERE source = ? ORDER BY last_check DESC LIMIT 1
|
|
26
|
+
`,
|
|
27
|
+
)
|
|
28
|
+
.get(source) as { last_check: string } | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
isThrottled(source: string, minutes: number): boolean {
|
|
32
|
+
const latest = this.getLatest(source);
|
|
33
|
+
if (!latest) return false;
|
|
34
|
+
|
|
35
|
+
// SQLite CURRENT_TIMESTAMP is UTC
|
|
36
|
+
const lastCheck = new Date(latest.last_check + 'Z').getTime();
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const diffMinutes = (now - lastCheck) / (1000 * 60);
|
|
39
|
+
|
|
40
|
+
return diffMinutes < minutes;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Digest {
|
|
2
|
+
items: {
|
|
3
|
+
title: string;
|
|
4
|
+
url: string;
|
|
5
|
+
summary: string | null;
|
|
6
|
+
category: string;
|
|
7
|
+
source: string;
|
|
8
|
+
score: number;
|
|
9
|
+
}[];
|
|
10
|
+
metadata: {
|
|
11
|
+
total_new_items: number;
|
|
12
|
+
total_selected: number;
|
|
13
|
+
date: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DeliveryProvider {
|
|
18
|
+
send(digest: Digest): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { DeliveryProvider, Digest } from './index.js';
|
|
3
|
+
|
|
4
|
+
export class SlackDelivery implements DeliveryProvider {
|
|
5
|
+
constructor(private webhookUrl: string) {}
|
|
6
|
+
|
|
7
|
+
async send(digest: Digest): Promise<void> {
|
|
8
|
+
const blocks = [
|
|
9
|
+
{
|
|
10
|
+
type: 'header',
|
|
11
|
+
text: {
|
|
12
|
+
type: 'plain_text',
|
|
13
|
+
text: `🚀 Alif AI Signal Digest - ${digest.metadata.date}`,
|
|
14
|
+
emoji: true,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
type: 'section',
|
|
19
|
+
text: {
|
|
20
|
+
type: 'mrkdwn',
|
|
21
|
+
text: `Found *${digest.metadata.total_selected}* high-signal items out of *${digest.metadata.total_new_items}* new articles analyzed today.`,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{ type: 'divider' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const item of digest.items) {
|
|
28
|
+
blocks.push({
|
|
29
|
+
type: 'section',
|
|
30
|
+
text: {
|
|
31
|
+
type: 'mrkdwn',
|
|
32
|
+
text: `*<${item.url}|${item.title}>*\n_${item.category}_ • ${item.source} • Score: ${item.score}\n${item.summary || 'No summary available.'}`,
|
|
33
|
+
},
|
|
34
|
+
} as any);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
blocks.push({
|
|
38
|
+
type: 'context',
|
|
39
|
+
elements: [
|
|
40
|
+
{
|
|
41
|
+
type: 'mrkdwn',
|
|
42
|
+
text: 'Generated by *Alif CLI* — Built by qarib.',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
} as any);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await axios.post(this.webhookUrl, { blocks });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(
|
|
51
|
+
`[Slack Delivery] Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { DeliveryProvider, Digest } from './index.js';
|
|
3
|
+
|
|
4
|
+
export class WebhookDelivery implements DeliveryProvider {
|
|
5
|
+
constructor(private webhookUrl: string) {}
|
|
6
|
+
|
|
7
|
+
async send(digest: Digest): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
await axios.post(this.webhookUrl, digest);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.error(
|
|
12
|
+
`[Webhook Delivery] Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Config } from '../core/config-schema.js';
|
|
2
|
+
import { LLMProvider } from './llm/index.js';
|
|
3
|
+
import { OllamaProvider } from './llm/ollama.js';
|
|
4
|
+
import { AnthropicProvider } from './llm/anthropic.js';
|
|
5
|
+
import { OpenRouterProvider } from './llm/openrouter.js';
|
|
6
|
+
import { DeliveryProvider } from './delivery/index.js';
|
|
7
|
+
import { SlackDelivery } from './delivery/slack.js';
|
|
8
|
+
import { WebhookDelivery } from './delivery/webhook.js';
|
|
9
|
+
|
|
10
|
+
export class ProviderFactory {
|
|
11
|
+
static createLLM(config: Config): LLMProvider {
|
|
12
|
+
const { provider, apiKey, model, baseUrl } = config.llm;
|
|
13
|
+
switch (provider) {
|
|
14
|
+
case 'ollama':
|
|
15
|
+
return new OllamaProvider({ baseUrl: baseUrl || 'http://localhost:11434', model });
|
|
16
|
+
case 'anthropic':
|
|
17
|
+
return new AnthropicProvider({ apiKey: apiKey || '', model });
|
|
18
|
+
case 'openrouter':
|
|
19
|
+
return new OpenRouterProvider({ apiKey: apiKey || '', model });
|
|
20
|
+
default:
|
|
21
|
+
throw new Error(`Unsupported LLM provider: ${provider}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static createDelivery(config: Config): DeliveryProvider[] {
|
|
26
|
+
return config.delivery.map((d) => {
|
|
27
|
+
switch (d.type) {
|
|
28
|
+
case 'slack':
|
|
29
|
+
return new SlackDelivery(d.webhookUrl);
|
|
30
|
+
case 'webhook':
|
|
31
|
+
return new WebhookDelivery(d.webhookUrl);
|
|
32
|
+
default:
|
|
33
|
+
throw new Error(`Unsupported delivery type: ${d.type}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { LLMProvider, AnalysisResult } from './index.js';
|
|
3
|
+
import { parseLLMJson } from './utils.js';
|
|
4
|
+
|
|
5
|
+
export class AnthropicProvider implements LLMProvider {
|
|
6
|
+
constructor(private options: { apiKey: string; model: string }) { }
|
|
7
|
+
|
|
8
|
+
async analyze(articles: { title: string; content?: string }[]): Promise<AnalysisResult[]> {
|
|
9
|
+
if (articles.length === 0) return [];
|
|
10
|
+
|
|
11
|
+
const prompt = `
|
|
12
|
+
Analyze the following AI-related news items. For each item, provide:
|
|
13
|
+
1. A concise, one-sentence summary (max 30 words).
|
|
14
|
+
2. A category (e.g., "Model Release", "Research", "Tool/SDK", "Policy", "Industry News", "Tutorial").
|
|
15
|
+
|
|
16
|
+
Return ONLY a JSON array of objects with keys "summary" and "category". Match the order of the input items.
|
|
17
|
+
|
|
18
|
+
Items:
|
|
19
|
+
${articles.map((a, idx) => `${idx + 1}. TITLE: ${a.title}\nCONTENT: ${a.content || 'None'}`).join('\n\n')}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await axios.post(
|
|
24
|
+
'https://api.anthropic.com/v1/messages',
|
|
25
|
+
{
|
|
26
|
+
model: this.options.model,
|
|
27
|
+
max_tokens: 4096,
|
|
28
|
+
messages: [{ role: 'user', content: prompt }],
|
|
29
|
+
system:
|
|
30
|
+
'You are an AI signal analyst. Be precise and objective. You respond ONLY with valid JSON array.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
headers: {
|
|
34
|
+
'x-api-key': this.options.apiKey,
|
|
35
|
+
'anthropic-version': '2023-06-01',
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const content = response.data.content[0].text;
|
|
42
|
+
return parseLLMJson(content);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`[Anthropic] Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
return articles.map(() => ({ summary: null, category: 'Uncategorized' }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { LLMProvider, AnalysisResult } from './index.js';
|
|
3
|
+
import { parseLLMJson } from './utils.js';
|
|
4
|
+
|
|
5
|
+
export class OllamaProvider implements LLMProvider {
|
|
6
|
+
constructor(private options: { baseUrl: string; model: string }) {}
|
|
7
|
+
|
|
8
|
+
async analyze(articles: { title: string; content?: string }[]): Promise<AnalysisResult[]> {
|
|
9
|
+
if (articles.length === 0) return [];
|
|
10
|
+
|
|
11
|
+
const prompt = `
|
|
12
|
+
Analyze the following AI-related news items. For each item, provide:
|
|
13
|
+
1. A concise, one-sentence summary (max 30 words).
|
|
14
|
+
2. A category (e.g., "Model Release", "Research", "Tool/SDK", "Policy", "Industry News", "Tutorial").
|
|
15
|
+
|
|
16
|
+
Return ONLY a JSON array of objects with keys "summary" and "category". Match the order of the input items.
|
|
17
|
+
|
|
18
|
+
Items:
|
|
19
|
+
${articles.map((a, idx) => `${idx + 1}. TITLE: ${a.title}\nCONTENT: ${a.content || 'None'}`).join('\n\n')}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await axios.post(`${this.options.baseUrl}/api/generate`, {
|
|
24
|
+
model: this.options.model,
|
|
25
|
+
system:
|
|
26
|
+
'You are an AI signal analyst. Provide direct, objective summaries and categories. Do NOT include any reasoning, thinking process, or <think> tags. Always return a JSON array of objects.',
|
|
27
|
+
prompt: prompt,
|
|
28
|
+
stream: false,
|
|
29
|
+
format: 'json',
|
|
30
|
+
options: {
|
|
31
|
+
stop: ['<think>', '</think>', 'Reasoning:'],
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ollamaData = response.data;
|
|
36
|
+
const finalResponse = ollamaData.response || ollamaData.thinking || '';
|
|
37
|
+
|
|
38
|
+
return parseLLMJson(finalResponse);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`[Ollama] Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
return articles.map(() => ({ summary: null, category: 'Uncategorized' }));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { LLMProvider, AnalysisResult } from './index.js';
|
|
3
|
+
import { parseLLMJson } from './utils.js';
|
|
4
|
+
|
|
5
|
+
export class OpenRouterProvider implements LLMProvider {
|
|
6
|
+
private client: OpenAI;
|
|
7
|
+
|
|
8
|
+
constructor(private options: { apiKey: string; model: string }) {
|
|
9
|
+
this.client = new OpenAI({
|
|
10
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
11
|
+
apiKey: this.options.apiKey,
|
|
12
|
+
defaultHeaders: {
|
|
13
|
+
'HTTP-Referer': 'https://github.com/qarib/alif',
|
|
14
|
+
'X-Title': 'Alif CLI',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async analyze(articles: { title: string; content?: string }[]): Promise<AnalysisResult[]> {
|
|
20
|
+
if (articles.length === 0) return [];
|
|
21
|
+
|
|
22
|
+
const prompt = `
|
|
23
|
+
Analyze the following AI-related news items. For each item, provide:
|
|
24
|
+
1. A concise, one-sentence summary (max 30 words).
|
|
25
|
+
2. A category (e.g., "Model Release", "Research", "Tool/SDK", "Policy", "Industry News", "Tutorial").
|
|
26
|
+
|
|
27
|
+
Return ONLY a JSON array of objects with keys "summary" and "category". Match the order of the input items.
|
|
28
|
+
|
|
29
|
+
Items:
|
|
30
|
+
${articles.map((a, idx) => `${idx + 1}. TITLE: ${a.title}\nCONTENT: ${a.content || 'None'}`).join('\n\n')}
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const response = await this.client.chat.completions.create({
|
|
35
|
+
model: this.options.model,
|
|
36
|
+
messages: [
|
|
37
|
+
{
|
|
38
|
+
role: 'system',
|
|
39
|
+
content:
|
|
40
|
+
'You are an AI signal analyst. Provide direct, objective summaries and categories. Do NOT include any reasoning, thinking process, or <think> tags. Return valid JSON only.',
|
|
41
|
+
},
|
|
42
|
+
{ role: 'user', content: prompt },
|
|
43
|
+
],
|
|
44
|
+
response_format: { type: 'json_object' },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const content = response.choices[0].message.content;
|
|
48
|
+
if (!content) throw new Error('Empty response from OpenRouter');
|
|
49
|
+
|
|
50
|
+
return parseLLMJson(content);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`[OpenRouter] Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
return articles.map(() => ({ summary: null, category: 'Uncategorized' }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AnalysisResult } from './index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Robustly parses a JSON string from LLM output.
|
|
5
|
+
* Handles markdown code blocks and attempts to find the first array/object if surrounded by text.
|
|
6
|
+
*/
|
|
7
|
+
export function parseLLMJson(text: string): AnalysisResult[] {
|
|
8
|
+
if (!text || text.trim() === '') {
|
|
9
|
+
throw new Error('Empty response from LLM');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Clean up markdown blocks if present
|
|
13
|
+
let cleanText = text.trim();
|
|
14
|
+
if (cleanText.includes('```')) {
|
|
15
|
+
const match = cleanText.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
16
|
+
if (match && match[1]) {
|
|
17
|
+
cleanText = match[1].trim();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Find the first instance of [ or {
|
|
22
|
+
const firstArray = cleanText.indexOf('[');
|
|
23
|
+
const firstObject = cleanText.indexOf('{');
|
|
24
|
+
|
|
25
|
+
let startIndex = -1;
|
|
26
|
+
if (firstArray !== -1 && (firstObject === -1 || firstArray < firstObject)) {
|
|
27
|
+
startIndex = firstArray;
|
|
28
|
+
} else if (firstObject !== -1) {
|
|
29
|
+
startIndex = firstObject;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (startIndex === -1) {
|
|
33
|
+
throw new Error('No JSON structure found in LLM response');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Truncate to the last ] or }
|
|
37
|
+
const lastArray = cleanText.lastIndexOf(']');
|
|
38
|
+
const lastObject = cleanText.lastIndexOf('}');
|
|
39
|
+
const endIndex = Math.max(lastArray, lastObject);
|
|
40
|
+
|
|
41
|
+
if (endIndex === -1 || endIndex < startIndex) {
|
|
42
|
+
throw new Error('Incomplete JSON structure in LLM response');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const jsonStr = cleanText.substring(startIndex, endIndex + 1);
|
|
46
|
+
const parsed = JSON.parse(jsonStr);
|
|
47
|
+
|
|
48
|
+
const results = Array.isArray(parsed) ? parsed : [parsed];
|
|
49
|
+
|
|
50
|
+
return results.map((item: any) => ({
|
|
51
|
+
summary: item.summary || null,
|
|
52
|
+
category: item.category || 'Uncategorized',
|
|
53
|
+
}));
|
|
54
|
+
}
|