Add feed aggregation and embedding generation features

This commit is contained in:
ImBenji
2025-12-12 10:35:26 +00:00
parent 00889301d9
commit 491bf2bbea
14 changed files with 1798 additions and 73 deletions

View File

@@ -1,9 +1,17 @@
import 'package:capstone_project/pages/home.dart';
import 'package:capstone_project/pages/settings.dart';
import 'package:capstone_project/providers/settings.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
void main() {
SettingsProvider _settingsProvider = SettingsProvider();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _settingsProvider.load();
runApp(const MyApp());
}
@@ -13,11 +21,16 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ShadcnApp.router(
theme: ThemeData(
colorScheme: ColorSchemes.darkRose
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _settingsProvider),
],
child: ShadcnApp.router(
theme: ThemeData(
colorScheme: ColorSchemes.darkRose
),
routerConfig: _routerConfig,
),
routerConfig: _routerConfig,
);
}
}

View File

@@ -1,4 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:capstone_project/providers/settings.dart';
import 'package:capstone_project/utils/agrigator.dart';
import 'package:capstone_project/widgets/navbar.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@@ -22,12 +27,108 @@ class HomePage extends StatelessWidget {
currentPage: "home",
)
],
child: Column(
children: [
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Nothing here is final yet!",
).h1,
Gap(16),
Button.primary(
onPressed: () async {
print("Aggregating feeds...");
SettingsProvider settings = SettingsProvider.of(context);
// Fetch all feeds
List<Uri> feedUris = settings.feeds.map((feed) => Uri.parse(feed.url)).toList();
List<FeedItem> aggregatedItems = await fetchFeeds(feedUris);
// Save it to a file before generating embeddings
String agregatedJson = JsonEncoder.withIndent(' ').convert(aggregatedItems);
File agregatedJsonFile = File("${settings.applicationStorageLocation}/aggregated_feed.json");
await agregatedJsonFile.writeAsString(agregatedJson);
print("Aggregated feed saved to ${agregatedJsonFile.path}");
// Generate embeddings for all items
print("Generating embeddings for ${aggregatedItems.length} items...");
List<FeedItem> enrichedItems = [...aggregatedItems];
await generateEmbeddings(enrichedItems, settings.openAIApiKey);
// Save it to a file in the application storage location
String enrichedJson = JsonEncoder.withIndent(' ').convert(enrichedItems);
final file = File("${settings.applicationStorageLocation}/enriched_aggregated_feed.json");
await file.writeAsString(enrichedJson);
print("Enriched aggregated feed saved to ${file.path}");
// Filter out irrelevant items
print("Filtering relevant items...");
await generateKeywordEmbeddings(settings.openAIApiKey);
List<FeedItem> relevantItems = [...aggregatedItems]..removeWhere((item) => !isFeedItemRelevant(item));
String relevantJson = JsonEncoder.withIndent(' ').convert(relevantItems);
final fileRelevant = File("${settings.applicationStorageLocation}/relevant_aggregated_feed.json");
await fileRelevant.writeAsString(relevantJson);
print("Cut down from ${aggregatedItems.length} to ${relevantItems.length} relevant items.");
print("Relevant aggregated feed saved to ${fileRelevant.path}");
// For human readability, save a version without embeddings
List<FeedItem> readableItems = relevantItems.map((item) {
return FeedItem(
title: item.title,
link: item.link,
description: item.description,
);
}).toList();
String readableJson = JsonEncoder.withIndent(' ').convert(readableItems);
final fileReadable = File("${settings.applicationStorageLocation}/readable_relevant_aggregated_feed.json");
await fileReadable.writeAsString(readableJson);
print("Readable relevant aggregated feed saved to ${fileReadable.path}");
// Group by event
print("Grouping feed items by event...");
List<List<FeedItem>> groupedItems = groupFeedItemsByEvent(relevantItems);
List<List<Map<String, dynamic>>> groupedItemsJson = groupedItems.map((group) {
return group.map((item) => item.toJson()).toList();
}).toList();
String groupedJson = JsonEncoder.withIndent(' ').convert(groupedItemsJson);
final fileGrouped = File("${settings.applicationStorageLocation}/grouped_relevant_aggregated_feed.json");
await fileGrouped.writeAsString(groupedJson);
print("Grouped relevant aggregated feed saved to ${fileGrouped.path}");
// For human readability, save a version without embeddings
List<List<FeedItem>> readableGroupedItems = groupedItems.map((group) {
return group.map((item) {
return FeedItem(
title: item.title,
link: item.link,
description: item.description,
);
}).toList();
}).toList();
// Sort groups by size descending
readableGroupedItems.sort((a, b) => b.length.compareTo(a.length));
List<List<Map<String, dynamic>>> readableGroupedItemsJson = readableGroupedItems.map((group) {
return group.map((item) => item.toJson()).toList();
}).toList();
String readableGroupedJson = JsonEncoder.withIndent(' ').convert(readableGroupedItemsJson);
final fileReadableGrouped = File("${settings.applicationStorageLocation}/readable_grouped_relevant_aggregated_feed.json");
await fileReadableGrouped.writeAsString(readableGroupedJson);
print("Readable grouped relevant aggregated feed saved to ${fileReadableGrouped.path}");
},
child: Text(
"Aggregate your feeds"
),
)
],
],
),
),
);
}

View File

@@ -1,18 +1,65 @@
import 'package:capstone_project/providers/settings.dart';
import 'package:capstone_project/widgets/navbar.dart';
import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class SettingsPage extends StatelessWidget {
class SettingsPage extends StatefulWidget {
static GoRoute route = GoRoute(
path: "/settings",
builder: (context, state) => SettingsPage()
);
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
bool _isLoading = true;
String apiKey = "";
List<Feed> feeds = [];
String appStorageLocation = "";
@override
void initState() {
// TODO: implement initState
super.initState();
loadPage();
}
void loadPage() async {
SettingsProvider settings = SettingsProvider.of(context);
setState(() {
_isLoading = true;
});
apiKey = settings.openAIApiKey;
feeds = settings.feeds;
appStorageLocation = settings.applicationStorageLocation;
setState(() {
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
// TODO: implement build
if (_isLoading) {
return Scaffold(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return Scaffold(
headers: [
AppBar(
@@ -24,58 +71,377 @@ class SettingsPage extends StatelessWidget {
currentPage: "settings",
)
],
child: Column(
children: [
SizedBox(
width: double.infinity,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"API Key"
).extraBold,
const SizedBox(height: 16),
TextField(
child: Center(
child: SizedBox(
width: 600,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Open AI"
).h4,
Gap(12),
Text(
"API Key"
).small.normal,
Gap(8),
TextField(
placeholder: Text(
"Enter your OpenAI API key"
),
initialValue: apiKey.substring(0, 8) + "xxxxxx (Redacted for security)" ,
onChanged: (value) {
SettingsProvider settings = SettingsProvider.of(context);
settings.setOpenAIApiKey(value);
},
),
Gap(8),
if (apiKey.isEmpty)
DestructiveBadge(
child: Text(
"API key is required to use AI features."
),
),
Gap(16),
Divider(),
Gap(16),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"News Feeds"
).h4,
Gap(4),
Text(
"Manage your RSS news feeds."
).muted.small,
],
),
),
Button.outline(
child: Text(
"Add Feed"
),
onPressed: () {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AddFeedDialog()
).then((value) {
loadPage();
});
},
)
],
),
Gap(24),
for (Feed feed in feeds) ...[
Row(
children: [
Expanded(
child: Text(
feed.title.isNotEmpty ? feed.title : feed.url,
style: TextStyle(
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
),
),
),
Builder(
builder: (context2) {
return GhostButton(
density: ButtonDensity.dense,
child: Icon(
LucideIcons.ellipsis,
),
onPressed: () {
showDropdown(
context: context2,
builder: (_) {
return DropdownMenu(
children: [
MenuButton(
child: Text(
"Edit"
),
trailing: Icon(
LucideIcons.filePen
),
onPressed: (_) {
// Navigator.of(context).pop();
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AddFeedDialog(
existingFeed: feed,
)
).then((value) {
loadPage();
});
},
)
],
);
}
);
},
);
}
),
Gap(4),
Switch(
value: feed.enabled,
onChanged: (value) {
SettingsProvider settings = SettingsProvider.of(context);
settings.updateFeed(
feed.id,
enabled: value
);
loadPage();
},
),
],
),
SelectableText(
feed.url,
style: TextStyle(
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
),
).muted.small,
Gap(12),
],
),
Gap(12),
Divider(),
Gap(16),
Text(
"Application Data"
).h4,
Gap(12),
Text(
"Application Storage Location"
).small.normal,
Gap(8),
Row(
children: [
Expanded(
child: TextField(
placeholder: Text(
"Select a diretory using Browse"
),
initialValue: appStorageLocation,
enabled: false,
),
),
Gap(8),
Button.outline(
child: Text(
"Browse"
),
onPressed: () {
FilePicker.platform.getDirectoryPath().then((selectedPath) {
if (selectedPath != null) {
SettingsProvider settings = SettingsProvider.of(context);
settings.setApplicationStorageLocation(selectedPath);
loadPage();
}
});
},
),
Gap(8),
IconButton.destructive(
icon: Icon(
LucideIcons.refreshCcw
),
onPressed: () async {
SettingsProvider settings = SettingsProvider.of(context);
settings.setApplicationStorageLocation((await getApplicationDocumentsDirectory()).path);
loadPage();
},
)
],
),
],
).withMargin(
all: 10
),
),
),
).withPadding(all: 24),
);
}
}
const SizedBox(height: 16),
class AddFeedDialog extends StatelessWidget {
SizedBox(
width: double.infinity,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Feed? existingFeed;
AddFeedDialog({super.key, this.existingFeed});
InputKey feedTitleKey = InputKey("feed_title");
InputKey feedUrlKey = InputKey("feed_url");
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
existingFeed == null ? "Add Feed" : "Edit Feed"
),
content: SizedBox(
width: 400,
child: Form(
onSubmit: (context, values) {
String title = values[feedTitleKey] ?? "";
String url = values[feedUrlKey] ?? "";
SettingsProvider settings = SettingsProvider.of(context);
if (existingFeed != null) {
settings.updateFeed(
existingFeed!.id,
title: title,
url: url
);
} else {
settings.addFeed(Feed(
title: title,
url: url,
enabled: true
));
}
Navigator.of(context).pop();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Add a custom RSS feed to your news sources."
).muted,
Gap(16),
FormField(
key: feedTitleKey,
label: Text(
"Title"
),
validator: ConditionalValidator((value) {
if (value is String) {
return value.trim().isNotEmpty;
}
return false;
}, message: "Title cannot be empty"),
child: TextField(
initialValue: existingFeed?.title,
),
),
Gap(12),
FormField(
key: feedUrlKey,
label: Text(
"Resource URL"
),
validator: ConditionalValidator((value) {
if (value is String) {
return value.trim().isNotEmpty;
}
return false;
}, message: "URL cannot be empty"),
child: TextField(
initialValue: existingFeed?.url,
)
),
Gap(24),
Text(
"Only use valid RSS feed URLs. Preferably only use sites you trust, and specifically business and financial news sources."
),
Gap(24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
"Feeds"
).extraBold,
const SizedBox(height: 16),
TextField(
Button.outline(
child: Text(
"Cancel"
),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
),
)
Gap(8),
],
).withMargin(
all: 10
SubmitButton(
child: Text(
existingFeed != null ? "Update Feed" : "Add Feed"
),
),
],
)
],
),
),
),
);
}

View File

@@ -1,10 +1,235 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uid/uid.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
class SettingsProvider extends ChangeNotifier {
String _OPENAI_API_KEY = "";
String get openAIApiKey => _OPENAI_API_KEY;
List<Feed> _feeds = [
// Major Business News
Feed(
title: "Business Insider",
url: "https://feeds.businessinsider.com/custom/all"
),
Feed(
title: "BBC Business",
url: "https://feeds.bbci.co.uk/news/business/rss.xml"
),
Feed(
title: "Reuters Business",
url: "https://www.reutersagency.com/feed/?taxonomy=best-topics&post_type=best"
),
Feed(
title: "Financial Times",
url: "https://www.ft.com/?format=rss"
),
Feed(
title: "Bloomberg",
url: "https://feeds.bloomberg.com/markets/news.rss"
),
// Market-Specific
Feed(
title: "MarketWatch",
url: "https://feeds.marketwatch.com/marketwatch/topstories/"
),
Feed(
title: "Seeking Alpha",
url: "https://seekingalpha.com/feed.xml"
),
Feed(
title: "Yahoo Finance",
url: "https://finance.yahoo.com/news/rssindex"
),
Feed(
title: "CNBC",
url: "https://www.cnbc.com/id/100003114/device/rss/rss.html"
),
Feed(
title: "The Wall Street Journal",
url: "https://feeds.a.dj.com/rss/RSSMarketsMain.xml"
),
// Tech Business
Feed(
title: "TechCrunch",
url: "https://techcrunch.com/feed/"
),
Feed(
title: "The Verge",
url: "https://www.theverge.com/rss/index.xml"
),
Feed(
title: "Ars Technica",
url: "https://feeds.arstechnica.com/arstechnica/index"
),
// Company News
Feed(
title: "Fortune",
url: "https://fortune.com/feed"
),
Feed(
title: "Forbes Business",
url: "https://www.forbes.com/business/feed/"
),
Feed(
title: "Inc Magazine",
url: "https://www.inc.com/rss"
),
// Industry-Specific
Feed(
title: "Retail Dive",
url: "https://www.retaildive.com/feeds/news/"
),
Feed(
title: "Manufacturing Dive",
url: "https://www.manufacturingdive.com/feeds/news/"
),
Feed(
title: "Banking Dive",
url: "https://www.bankingdive.com/feeds/news/"
),
// Economic News
Feed(
title: "The Economist",
url: "https://www.economist.com/finance-and-economics/rss.xml"
),
Feed(
title: "Federal Reserve News",
url: "https://www.federalreserve.gov/feeds/press_all.xml"
),
]; // List of rss feed URLs
List<Feed> get feeds => List.unmodifiable(_feeds);
late String _applicationStorageLocation;
String get applicationStorageLocation => _applicationStorageLocation;
static SettingsProvider of(BuildContext context) {
return context
return Provider.of<SettingsProvider>(context, listen: false);
}
}ç
Future<void> load() async {
// get documents directory dynamicly
final Directory appDocDir = await getApplicationDocumentsDirectory();
_applicationStorageLocation = appDocDir.path;
SharedPreferences prefs = await SharedPreferences.getInstance();
_OPENAI_API_KEY = prefs.getString('openai_api_key') ?? _OPENAI_API_KEY;
List<String>? feedStrings = prefs.getStringList('feeds');
if (feedStrings != null) {
_feeds = feedStrings.map((fStr) {
try {
return Feed.fromJson(jsonDecode(fStr));
} catch (e) {
// old format, parse manually
Map<String, dynamic> json = {};
fStr.substring(1, fStr.length - 1).split(', ').forEach((pair) {
List<String> keyValue = pair.split(': ');
String key = keyValue[0];
String value = keyValue[1];
// convert bool strings to actual bools
if (value == 'true') {
json[key] = true;
} else if (value == 'false') {
json[key] = false;
} else {
json[key] = value;
}
});
return Feed.fromJson(json);
}
}).toList();
}
_applicationStorageLocation = prefs.getString('application_storage_location') ?? applicationStorageLocation;
notifyListeners();
}
Future<void> save() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('openai_api_key', _OPENAI_API_KEY);
await prefs.setStringList('feeds', _feeds.map((f) => jsonEncode(f.toJson())).toList());
await prefs.setString('application_storage_location', _applicationStorageLocation);
}
void setOpenAIApiKey(String key) {
_OPENAI_API_KEY = key;
save();
notifyListeners();
}
void addFeed(Feed feed) {
_feeds.add(feed);
save();
notifyListeners();
}
Feed updateFeed(int id, {String? title, String? url, bool? enabled}) {
for (int i = 0; i < _feeds.length; i++) {
if (_feeds[i].id == id) {
Feed updatedFeed = Feed(
url: url ?? _feeds[i].url,
title: title ?? _feeds[i].title,
enabled: enabled ?? _feeds[i].enabled,
);
_feeds[i] = updatedFeed;
save();
notifyListeners();
return updatedFeed;
}
}
throw Exception("Feed with id $id not found");
}
void removeFeed(Feed feed) {
_feeds.removeWhere((f) => f.url == feed.url);
save();
notifyListeners();
}
void setApplicationStorageLocation(String path) {
_applicationStorageLocation = path;
save();
notifyListeners();
}
}
class Feed {
final String url;
final String title;
final bool enabled;
Feed({required this.url, required this.title, this.enabled = true});
Feed.fromJson(Map<String, dynamic> json)
: url = json['url'],
title = json['title'],
enabled = json['enabled'] ?? true;
Map<String, dynamic> toJson() => {
'url': url,
'title': title,
'enabled': enabled,
};
int get id {
return url.hashCode ^ title.hashCode;
}
}

268
lib/utils/agrigator.dart Normal file
View File

@@ -0,0 +1,268 @@
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import 'openai.dart';
const String KEYWORDS = "Business news corporate earnings revenue profit stock market trading equity shares NYSE NASDAQ stock prices quarterly results annual reports CEO announcements executive leadership management changes board directors company strategy mergers acquisitions takeovers buyouts partnerships joint ventures business deals IPO initial public offerings venture capital funding investment rounds valuation startup unicorn enterprise technology product launches innovation R&D research development market expansion international business global markets trade agreements tariffs import export supply chain logistics manufacturing production operations facilities factories plants workforce hiring layoffs restructuring downsizing labor unions strikes employee relations workplace compensation benefits corporate governance shareholder activism proxy fights dividends stock buybacks analyst ratings price targets market capitalization revenue growth profit margins EBITDA cash flow debt financing credit ratings bonds corporate strategy competitive advantage market share industry trends sector analysis retail consumer goods e-commerce technology software hardware semiconductors pharmaceuticals biotech healthcare energy oil gas renewables automotive electric vehicles aerospace defense banking financial services insurance real estate construction infrastructure telecommunications media entertainment streaming gaming hospitality travel transportation logistics shipping airlines regulatory compliance antitrust competition policy lawsuits litigation settlements data breaches cybersecurity intellectual property patents trademarks brand value customer acquisition market positioning business models revenue streams profitability sustainability ESG environmental social governance";
List<double>? KEYWORD_EMBEDDINGS;
class FeedItem {
final String title;
final String description;
final String link;
List<double>? embedding;
FeedItem({
required this.title,
required this.description,
required this.link,
this.embedding,
});
@override
String toString() {
return "FeedItem(title: $title, link: $link)";
}
FeedItem.fromJson(Map<String, dynamic> json)
: title = json["title"],
description = json["description"],
link = json["link"],
embedding = json["embedding"] != null
? (json["embedding"] as List).map<double>((e) => (e as num).toDouble()).toList()
: null;
Map<String, dynamic> toJson() {
return {
"title": title,
"description": description,
"link": link,
if (embedding != null) "embedding": embedding,
};
}
}
List<FeedItem> parseRssFeed(String rssXml) {
final document = XmlDocument.parse(rssXml);
// find items in the RSS structre
final items = document.findAllElements("item");
return items.map((item) {
final title = item.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled";
final link = item.findElements("link").firstOrNull?.innerText ?? "";
final description = item.findElements("description").firstOrNull?.innerText.trim() ?? "";
return FeedItem(
title: title,
link: link,
description: description,
);
}).toList();
}
List<FeedItem> parseAtomFeed(String atomXml) {
final document = XmlDocument.parse(atomXml);
// find entrys in atom feed
final entries = document.findAllElements("entry");
return entries.map((entry) {
final title = entry.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled";
final linkElement = entry.findElements("link").firstOrNull;
final link = linkElement?.getAttribute("href") ?? "";
final summary = entry.findElements("summary").firstOrNull?.innerText.trim();
final content = entry.findElements("content").firstOrNull?.innerText.trim();
final description = (summary ?? content ?? "").trim();
return FeedItem(
title: title,
link: link,
description: description,
);
}).toList();
}
List<FeedItem> parseFeed(String feedXml) {
final document = XmlDocument.parse(feedXml);
// Check if it's an Atom feed
if (document.findAllElements('feed').isNotEmpty) {
return parseAtomFeed(feedXml);
}
// Check if it's an RSS feed
if (document.findAllElements('rss').isNotEmpty ||
document.findAllElements('channel').isNotEmpty) {
return parseRssFeed(feedXml);
}
// Unknown feed format
throw FormatException('Unknown feed format. Expected RSS or Atom.');
}
Future<List<FeedItem>> fetchFeed(Uri feedUri) async {
final response = await http.get(feedUri);
if (response.statusCode != 200) {
throw Exception("Failed to fetch feed: ${response.statusCode}");
}
// parse the XML response
return parseFeed(response.body);
}
Future<List<FeedItem>> fetchFeeds(List<Uri> feedUris) async {
List<FeedItem> allItems = [];
final results = await Future.wait(
feedUris.map((uri) => fetchFeed(uri).catchError((e) {
print("Error fetching feed $uri: $e");
return <FeedItem>[];
}))
);
for (final items in results) {
allItems.addAll(items);
}
return allItems;
}
// generete embeddng for a feed item
Future<void> generateEmbedding(FeedItem item, String apiKey) async {
final openai = OpenAI(apiKey: apiKey);
// combine tittle and descriptin
final textToEmbed = "${item.title} ${item.description}";
try {
final response = await openai.embeddings.create(
model: "text-embedding-3-small",
input: textToEmbed,
);
if (response.data.isNotEmpty) {
item.embedding = response.data.first.embedding;
}
} catch (e) {
print("Error generatng embedding: $e");
} finally {
openai.dispose();
}
}
// generate embedings for multiple feed items
Future<void> generateEmbeddings(List<FeedItem> items, String apiKey) async {
await Future.wait(
items.map((item) => generateEmbedding(item, apiKey))
);
}
Future<void> generateKeywordEmbeddings(String apiKey) async {
if (KEYWORD_EMBEDDINGS != null) {
return; // already generated
}
final openai = OpenAI(apiKey: apiKey);
try {
final response = await openai.embeddings.create(
model: "text-embedding-3-small",
input: KEYWORDS,
);
if (response.data.isNotEmpty) {
KEYWORD_EMBEDDINGS = response.data.first.embedding;
}
} catch (e) {
print("Error generating keyword embeddings: $e");
} finally {
openai.dispose();
}
}
bool isFeedItemRelevant(FeedItem item, [double threshold = 0.25]) {
if (item.embedding == null || KEYWORD_EMBEDDINGS == null) {
throw Exception("Embeddings not available for comparison.");
}
double similarity = cosineSimilarity(item.embedding!, KEYWORD_EMBEDDINGS!);
return similarity >= threshold;
}
double cosineSimilarity(List<double> vecA, List<double> vecB) {
if (vecA.length != vecB.length) {
throw ArgumentError("Vectors must be of the same length");
}
double dotProduct = 0.0;
double magnitudeA = 0.0;
double magnitudeB = 0.0;
for (int i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
magnitudeA += vecA[i] * vecA[i];
magnitudeB += vecB[i] * vecB[i];
}
if (magnitudeA == 0 || magnitudeB == 0) {
return 0.0;
}
return dotProduct / (sqrt(magnitudeA) * sqrt(magnitudeB));
}
List<List<FeedItem>> groupFeedItemsByEvent(List<FeedItem> items, [double similarityThreshold = 0.7]) {
// Track which group each item belongs to and with what similarity
Map<int, ({int groupIndex, double similarity})> itemGrouping = {};
List<List<FeedItem>> groupedItems = [];
for (int i = 0; i < items.length; i++) {
// Create a new group with item i as the anchor
List<FeedItem> currentGroup = [items[i]];
int currentGroupIndex = groupedItems.length;
// item i belongs to its own group with similarity 1.0
itemGrouping[i] = (groupIndex: currentGroupIndex, similarity: 1.0);
// Check all later items
for (int j = i + 1; j < items.length; j++) {
double similarity = cosineSimilarity(
items[i].embedding!,
items[j].embedding!,
);
if (similarity >= similarityThreshold) {
// Check if j should join this group
if (!itemGrouping.containsKey(j)) {
// j hasn't been grouped yet, add it
currentGroup.add(items[j]);
itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity);
} else if (similarity > itemGrouping[j]!.similarity) {
// j is in another group but this is a better match
// Remove from old group
int oldGroupIndex = itemGrouping[j]!.groupIndex;
groupedItems[oldGroupIndex].remove(items[j]);
// Add to this group
currentGroup.add(items[j]);
itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity);
}
}
}
groupedItems.add(currentGroup);
}
// Filter out empty groups (items may have been moved out)
return groupedItems.where((group) => group.isNotEmpty).toList();
}

434
lib/utils/openai.dart Normal file
View File

@@ -0,0 +1,434 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// OpenAI API client for Dart
class OpenAI {
final String apiKey;
final String baseUrl;
final http.Client _client;
OpenAI({
required this.apiKey,
this.baseUrl = 'https://api.openai.com/v1',
http.Client? client,
}) : _client = client ?? http.Client();
/// Access to chat completions API
ChatCompletions get chat => ChatCompletions(this);
/// Access to embeddings API
Embeddings get embeddings => Embeddings(this);
void dispose() {
_client.close();
}
}
/// Chat completions API
class ChatCompletions {
final OpenAI _openai;
ChatCompletions(this._openai);
/// Access to completions endpoint
Completions get completions => Completions(_openai);
}
/// Completions endpoint
class Completions {
final OpenAI _openai;
Completions(this._openai);
/// Create a chat completion
///
/// If [stream] is true, returns a Stream of ChatCompletionChunk
/// If [stream] is false, returns a single ChatCompletion
Future<dynamic> create({
required String model,
required List<dynamic> messages,
bool stream = false,
StreamOptions? streamOptions,
double? temperature,
double? topP,
int? n,
List<String>? stop,
int? maxTokens,
double? presencePenalty,
double? frequencyPenalty,
Map<String, dynamic>? logitBias,
String? user,
}) async {
final body = {
'model': model,
'messages': messages,
'stream': stream,
if (streamOptions != null) 'stream_options': streamOptions.toJson(),
if (temperature != null) 'temperature': temperature,
if (topP != null) 'top_p': topP,
if (n != null) 'n': n,
if (stop != null) 'stop': stop,
if (maxTokens != null) 'max_tokens': maxTokens,
if (presencePenalty != null) 'presence_penalty': presencePenalty,
if (frequencyPenalty != null) 'frequency_penalty': frequencyPenalty,
if (logitBias != null) 'logit_bias': logitBias,
if (user != null) 'user': user,
};
if (stream) {
return _streamCompletion(body);
} else {
return _createCompletion(body);
}
}
Future<ChatCompletion> _createCompletion(Map<String, dynamic> body) async {
final response = await _openai._client.post(
Uri.parse('${_openai.baseUrl}/chat/completions'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}',
},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw OpenAIException(
statusCode: response.statusCode,
message: response.body,
);
}
return ChatCompletion.fromJson(jsonDecode(response.body));
}
Stream<ChatCompletionChunk> _streamCompletion(
Map<String, dynamic> body) async* {
final request = http.Request(
'POST',
Uri.parse('${_openai.baseUrl}/chat/completions'),
);
request.headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}',
});
request.body = jsonEncode(body);
final streamedResponse = await _openai._client.send(request);
if (streamedResponse.statusCode != 200) {
final body = await streamedResponse.stream.bytesToString();
throw OpenAIException(
statusCode: streamedResponse.statusCode,
message: body,
);
}
final stream = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in stream) {
if (line.isEmpty) continue;
if (line.startsWith(':')) continue; // Skip comments
if (!line.startsWith('data: ')) continue;
final data = line.substring(6); // Remove 'data: ' prefix
if (data == '[DONE]') {
break;
}
try {
final json = jsonDecode(data);
yield ChatCompletionChunk.fromJson(json);
} catch (e) {
// Skip malformed chunks
continue;
}
}
}
}
/// Chat message
class ChatMessage {
final String role;
final String content;
ChatMessage({
required this.role,
required this.content,
});
Map<String, dynamic> toJson() => {
'role': role,
'content': content,
};
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
role: json['role'],
content: json['content'],
);
factory ChatMessage.system(String content) =>
ChatMessage(role: 'system', content: content);
factory ChatMessage.user(String content) =>
ChatMessage(role: 'user', content: content);
factory ChatMessage.assistant(String content) =>
ChatMessage(role: 'assistant', content: content);
}
/// Stream options
class StreamOptions {
final bool includeUsage;
StreamOptions({this.includeUsage = false});
Map<String, dynamic> toJson() => {
'include_usage': includeUsage,
};
}
/// Chat completion response (non-streaming)
class ChatCompletion {
final String id;
final String object;
final int created;
final String model;
final List<Choice> choices;
final Usage? usage;
ChatCompletion({
required this.id,
required this.object,
required this.created,
required this.model,
required this.choices,
this.usage,
});
factory ChatCompletion.fromJson(Map<String, dynamic> json) => ChatCompletion(
id: json['id'],
object: json['object'],
created: json['created'],
model: json['model'],
choices: (json['choices'] as List)
.map((c) => Choice.fromJson(c))
.toList(),
usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null,
);
}
/// Chat completion chunk (streaming)
class ChatCompletionChunk {
final String id;
final String object;
final int created;
final String model;
final List<ChunkChoice> choices;
final Usage? usage;
ChatCompletionChunk({
required this.id,
required this.object,
required this.created,
required this.model,
required this.choices,
this.usage,
});
factory ChatCompletionChunk.fromJson(Map<String, dynamic> json) =>
ChatCompletionChunk(
id: json['id'],
object: json['object'],
created: json['created'],
model: json['model'],
choices: (json['choices'] as List)
.map((c) => ChunkChoice.fromJson(c))
.toList(),
usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null,
);
}
/// Choice in non-streaming response
class Choice {
final int index;
final ChatMessage message;
final String? finishReason;
Choice({
required this.index,
required this.message,
this.finishReason,
});
factory Choice.fromJson(Map<String, dynamic> json) => Choice(
index: json['index'],
message: ChatMessage.fromJson(json['message']),
finishReason: json['finish_reason'],
);
}
/// Choice in streaming response
class ChunkChoice {
final int index;
final Delta? delta;
final String? finishReason;
ChunkChoice({
required this.index,
this.delta,
this.finishReason,
});
factory ChunkChoice.fromJson(Map<String, dynamic> json) => ChunkChoice(
index: json['index'],
delta: json['delta'] != null ? Delta.fromJson(json['delta']) : null,
finishReason: json['finish_reason'],
);
}
/// Delta content in streaming chunks
class Delta {
final String? role;
final String? content;
Delta({
this.role,
this.content,
});
factory Delta.fromJson(Map<String, dynamic> json) => Delta(
role: json['role'],
content: json['content'],
);
}
/// Token usage information
class Usage {
final int? promptTokens;
final int? completionTokens;
final int? totalTokens;
Usage({
this.promptTokens,
this.completionTokens,
this.totalTokens,
});
factory Usage.fromJson(Map<String, dynamic> json) => Usage(
promptTokens: json['prompt_tokens'],
completionTokens: json['completion_tokens'],
totalTokens: json['total_tokens'],
);
Map<String, dynamic> toJson() => {
if (promptTokens != null) 'prompt_tokens': promptTokens,
if (completionTokens != null) 'completion_tokens': completionTokens,
if (totalTokens != null) 'total_tokens': totalTokens,
};
}
/// OpenAI API exception
class OpenAIException implements Exception {
final int statusCode;
final String message;
OpenAIException({
required this.statusCode,
required this.message,
});
@override
String toString() => 'OpenAIException($statusCode): $message';
}
/// Embeddings API
class Embeddings {
final OpenAI _openai;
Embeddings(this._openai);
/// Create embeddings for input text
Future<EmbeddingResponse> create({
required String model,
required dynamic input, // String or List<String>
String? user,
String? encodingFormat, // 'float' or 'base64'
int? dimensions,
}) async {
final body = {
'model': model,
'input': input,
if (user != null) 'user': user,
if (encodingFormat != null) 'encoding_format': encodingFormat,
if (dimensions != null) 'dimensions': dimensions,
};
final response = await _openai._client.post(
Uri.parse('${_openai.baseUrl}/embeddings'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}',
},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw OpenAIException(
statusCode: response.statusCode,
message: response.body,
);
}
return EmbeddingResponse.fromJson(jsonDecode(response.body));
}
}
/// Embedding response
class EmbeddingResponse {
final String object;
final List<Embedding> data;
final String model;
final Usage? usage;
EmbeddingResponse({
required this.object,
required this.data,
required this.model,
this.usage,
});
factory EmbeddingResponse.fromJson(Map<String, dynamic> json) =>
EmbeddingResponse(
object: json['object'],
data: (json['data'] as List).map((e) => Embedding.fromJson(e)).toList(),
model: json['model'],
usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null,
);
}
/// Individual embedding
class Embedding {
final String object;
final int index;
final List<double> embedding;
Embedding({
required this.object,
required this.index,
required this.embedding,
});
factory Embedding.fromJson(Map<String, dynamic> json) => Embedding(
object: json['object'],
index: json['index'],
embedding: (json['embedding'] as List).map<double>((e) => (e as num).toDouble()).toList(),
);
}