228 lines
6.4 KiB
Dart
228 lines
6.4 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
const Uuid _uuid = Uuid();
|
|
|
|
class AiDigestParseResult {
|
|
const AiDigestParseResult({required this.drafts, this.error});
|
|
|
|
final List<AiSuggestionDraft> drafts;
|
|
final String? error;
|
|
|
|
bool get success => error == null;
|
|
}
|
|
|
|
class AiDigestResponseParser {
|
|
const AiDigestResponseParser({
|
|
this.maxItems = 10,
|
|
this.maxTitleChars = 90,
|
|
this.maxDetailsChars = 420,
|
|
});
|
|
|
|
final int maxItems;
|
|
final int maxTitleChars;
|
|
final int maxDetailsChars;
|
|
|
|
AiDigestParseResult parse({
|
|
required String response,
|
|
required Map<String, String> tokenToPersonId,
|
|
required String sourceRunId,
|
|
required DateTime suggestedAt,
|
|
}) {
|
|
try {
|
|
final Object? decoded = jsonDecode(_extractJson(response));
|
|
final List<dynamic> items = _extractItems(decoded);
|
|
final List<AiSuggestionDraft> drafts = <AiSuggestionDraft>[];
|
|
|
|
for (final dynamic item in items.take(maxItems)) {
|
|
if (item is! Map<String, dynamic>) {
|
|
continue;
|
|
}
|
|
final String? token = _string(item['personToken']);
|
|
final String? personId = token == null ? null : tokenToPersonId[token];
|
|
if (personId == null) {
|
|
continue;
|
|
}
|
|
|
|
final AiSuggestionKind? kind = _kind(item['kind']);
|
|
final String title = _trimAndCap(
|
|
_string(item['title']) ?? '',
|
|
maxTitleChars,
|
|
);
|
|
if (kind == null || title.isEmpty) {
|
|
continue;
|
|
}
|
|
|
|
drafts.add(
|
|
AiSuggestionDraft(
|
|
id: 'ai-${_uuid.v4()}',
|
|
personId: personId,
|
|
kind: kind,
|
|
title: title,
|
|
details: _trimAndCap(
|
|
_string(item['details']) ?? '',
|
|
maxDetailsChars,
|
|
),
|
|
suggestedAt: suggestedAt,
|
|
suggestedFor:
|
|
_dateOrNull(item['suggestedFor']) ??
|
|
_dateOrNull(item['suggestedTiming']),
|
|
eventEndsAt:
|
|
_dateOrNull(item['eventEndsAt']) ??
|
|
_dateOrNull(item['endsAt']) ??
|
|
_dateOrNull(item['endTime']),
|
|
eventLocation: _optionalTrimAndCap(
|
|
_string(item['eventLocation']) ?? _string(item['location']),
|
|
160,
|
|
),
|
|
eventUrl: _urlOrNull(item['eventUrl']) ?? _urlOrNull(item['url']),
|
|
sourceUrls: _sourceUrls(item['sourceUrls'] ?? item['sources']),
|
|
confidence: _confidence(item['confidence']),
|
|
status: AiSuggestionStatus.pending,
|
|
sourceRunId: sourceRunId,
|
|
reason: _trimAndCap(_string(item['reason']) ?? '', 240),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (drafts.isEmpty) {
|
|
return const AiDigestParseResult(
|
|
drafts: <AiSuggestionDraft>[],
|
|
error: 'No valid digest suggestions returned.',
|
|
);
|
|
}
|
|
|
|
return AiDigestParseResult(drafts: drafts);
|
|
} catch (error) {
|
|
return AiDigestParseResult(
|
|
drafts: const <AiSuggestionDraft>[],
|
|
error: 'Unable to parse digest response: $error',
|
|
);
|
|
}
|
|
}
|
|
|
|
String _extractJson(String response) {
|
|
final String trimmed = response.trim();
|
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
return trimmed;
|
|
}
|
|
final int objectStart = trimmed.indexOf('{');
|
|
final int arrayStart = trimmed.indexOf('[');
|
|
final int start = objectStart == -1
|
|
? arrayStart
|
|
: arrayStart == -1
|
|
? objectStart
|
|
: objectStart < arrayStart
|
|
? objectStart
|
|
: arrayStart;
|
|
final int objectEnd = trimmed.lastIndexOf('}');
|
|
final int arrayEnd = trimmed.lastIndexOf(']');
|
|
final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd;
|
|
if (start < 0 || end < start) {
|
|
throw const FormatException('No JSON object or array found.');
|
|
}
|
|
return trimmed.substring(start, end + 1);
|
|
}
|
|
|
|
List<dynamic> _extractItems(Object? decoded) {
|
|
if (decoded is List<dynamic>) {
|
|
return decoded;
|
|
}
|
|
if (decoded is Map<String, dynamic>) {
|
|
final Object? suggestions = decoded['suggestions'] ?? decoded['items'];
|
|
if (suggestions is List<dynamic>) {
|
|
return suggestions;
|
|
}
|
|
}
|
|
throw const FormatException('Digest response must be a JSON array.');
|
|
}
|
|
|
|
AiSuggestionKind? _kind(Object? raw) {
|
|
final String? value = _string(raw);
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
for (final AiSuggestionKind kind in AiSuggestionKind.values) {
|
|
if (kind.name == value) {
|
|
return kind;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _string(Object? raw) {
|
|
if (raw == null) {
|
|
return null;
|
|
}
|
|
final String value = '$raw'.trim();
|
|
return value.isEmpty ? null : value;
|
|
}
|
|
|
|
DateTime? _dateOrNull(Object? raw) {
|
|
final String? value = _string(raw);
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
return DateTime.tryParse(value)?.toLocal();
|
|
}
|
|
|
|
String? _urlOrNull(Object? raw) {
|
|
final String? value = _string(raw);
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
final Uri? uri = Uri.tryParse(value);
|
|
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
|
return null;
|
|
}
|
|
return uri.toString();
|
|
}
|
|
|
|
List<String> _sourceUrls(Object? raw) {
|
|
final List<Object?> values = raw is List<dynamic> ? raw : <Object?>[raw];
|
|
final Set<String> urls = <String>{};
|
|
for (final Object? value in values) {
|
|
if (value is Map<String, dynamic>) {
|
|
final String? url =
|
|
_urlOrNull(value['url']) ??
|
|
_urlOrNull(value['uri']) ??
|
|
_urlOrNull(value['link']);
|
|
if (url != null) {
|
|
urls.add(url);
|
|
}
|
|
continue;
|
|
}
|
|
final String? url = _urlOrNull(value);
|
|
if (url != null) {
|
|
urls.add(url);
|
|
}
|
|
}
|
|
return urls.take(5).toList(growable: false);
|
|
}
|
|
|
|
double _confidence(Object? raw) {
|
|
if (raw is num) {
|
|
return raw.toDouble().clamp(0.0, 1.0).toDouble();
|
|
}
|
|
return 0.5;
|
|
}
|
|
|
|
String _trimAndCap(String value, int maxChars) {
|
|
final String trimmed = value.trim();
|
|
if (trimmed.length <= maxChars) {
|
|
return trimmed;
|
|
}
|
|
return trimmed.substring(0, maxChars).trim();
|
|
}
|
|
|
|
String? _optionalTrimAndCap(String? value, int maxChars) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
final String trimmed = _trimAndCap(value, maxChars);
|
|
return trimmed.isEmpty ? null : trimmed;
|
|
}
|
|
}
|