feat: add local-first private AI digest workflow
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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']),
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user