Files
rely/lib/features/ai_digest/application/ai_digest_response_parser.dart
T
2026-05-19 19:17:35 +02:00

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;
}
}