Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core_data/lib/core_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export 'src/storage/preferences_store.dart';
export 'src/storage/secure_store.dart';
export 'src/storage/flutter_secure_store.dart';
export 'src/storage/life_track_local_data_source.dart';
export 'src/storage/templates/life_track_template_fallback_resolver.dart';
export 'src/storage/templates/template_registry.dart';

// Connectivity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import 'package:core_domain/core_domain.dart';

import '../../connectivity/connectivity_service.dart';
import 'template_registry.dart';

enum LifeTrackTemplateFallbackStatus { noFallbackNeeded, recommended, noMatch }

enum LifeTrackTemplateFallbackReason { offline, generationFailed }

class LifeTrackTemplateFallbackResult {
const LifeTrackTemplateFallbackResult._({
required this.status,
this.reason,
this.template,
this.matchedKeywords = const <String>[],
});

const LifeTrackTemplateFallbackResult.noFallbackNeeded()
: this._(status: LifeTrackTemplateFallbackStatus.noFallbackNeeded);

const LifeTrackTemplateFallbackResult.recommended({
required LifeTrackTemplateFallbackReason reason,
required LifeTrackTemplate template,
required List<String> matchedKeywords,
}) : this._(
status: LifeTrackTemplateFallbackStatus.recommended,
reason: reason,
template: template,
matchedKeywords: matchedKeywords,
);

const LifeTrackTemplateFallbackResult.noMatch({
required LifeTrackTemplateFallbackReason reason,
}) : this._(status: LifeTrackTemplateFallbackStatus.noMatch, reason: reason);

final LifeTrackTemplateFallbackStatus status;
final LifeTrackTemplateFallbackReason? reason;
final LifeTrackTemplate? template;
final List<String> matchedKeywords;

bool get shouldFallback =>
status != LifeTrackTemplateFallbackStatus.noFallbackNeeded;
}

class LifeTrackTemplateFallbackResolver {
LifeTrackTemplateFallbackResolver({
required this._registry,
required this._connectivityService,
Map<String, List<String>>? templateKeywords,
}) : _templateKeywords =
templateKeywords ?? _defaultTemplateKeywordsByTemplateId;

final TemplateRegistry _registry;
final ConnectivityService _connectivityService;
final Map<String, List<String>> _templateKeywords;

Future<LifeTrackTemplateFallbackResult> resolve(
String prompt, {
Object? generationFailure,
}) async {
final normalizedPrompt = _normalize(prompt);
if (normalizedPrompt.isEmpty) {
return generationFailure != null
? const LifeTrackTemplateFallbackResult.noMatch(
reason: LifeTrackTemplateFallbackReason.generationFailed,
)
: const LifeTrackTemplateFallbackResult.noFallbackNeeded();
}

if (generationFailure != null) {
return _resolveMatch(
normalizedPrompt,
reason: LifeTrackTemplateFallbackReason.generationFailed,
);
}

if (!await _connectivityService.isConnected) {
return _resolveMatch(
normalizedPrompt,
reason: LifeTrackTemplateFallbackReason.offline,
);
}

return const LifeTrackTemplateFallbackResult.noFallbackNeeded();
}

LifeTrackTemplateFallbackResult _resolveMatch(
String normalizedPrompt, {
required LifeTrackTemplateFallbackReason reason,
}) {
_ScoredTemplateMatch? bestMatch;

for (final template in _registry.getAll()) {
final keywords =
_templateKeywords[template.templateId] ?? const <String>[];
final matchedKeywords = keywords
.where((keyword) => _matchesKeyword(normalizedPrompt, keyword))
.toList(growable: false);
if (matchedKeywords.isEmpty) {
continue;
}

final candidate = _ScoredTemplateMatch(
template: template,
matchedKeywords: matchedKeywords,
);
if (bestMatch == null || candidate.score > bestMatch.score) {
bestMatch = candidate;
}
}

if (bestMatch == null) {
return LifeTrackTemplateFallbackResult.noMatch(reason: reason);
}

return LifeTrackTemplateFallbackResult.recommended(
reason: reason,
template: bestMatch.template,
matchedKeywords: bestMatch.matchedKeywords,
);
}

bool _matchesKeyword(String normalizedPrompt, String keyword) {
final normalizedKeyword = _normalize(keyword);
if (normalizedKeyword.contains(' ')) {
return normalizedPrompt.contains(normalizedKeyword);
}

final tokens = normalizedPrompt.split(' ');
return tokens.contains(normalizedKeyword);
}

String _normalize(String value) => value
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
}

class _ScoredTemplateMatch {
const _ScoredTemplateMatch({
required this.template,
required this.matchedKeywords,
});

final LifeTrackTemplate template;
final List<String> matchedKeywords;

int get score => matchedKeywords.length;
}

const Map<String, List<String>> _defaultTemplateKeywordsByTemplateId = {
'real_estate_under_construction_v1': <String>[
'flat',
'apartment',
'home',
'house',
'builder',
'property',
'real estate',
'rera',
],
'university_admission_v1': <String>[
'admission',
'college',
'education',
'enrollment',
'student',
'university',
'visa',
],
'medical_surgery_v1': <String>[
'doctor',
'hospital',
'medical',
'operation',
'procedure',
'recovery',
'surgery',
'treatment',
],
'insurance_claim_v1': <String>[
'claim',
'coverage',
'incident',
'insurance',
'insurer',
'reimbursement',
'settlement',
],
'car_purchase_v1': <String>[
'auto',
'automobile',
'car',
'driving',
'loan',
'parking',
'vehicle',
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ class TemplateRegistry {

final List<LifeTrackTemplate> _templates;

factory TemplateRegistry.fromTemplates(List<LifeTrackTemplate> templates) {
return TemplateRegistry._(List.unmodifiable(templates));
}

static Future<TemplateRegistry> loadBundled({AssetBundle? bundle}) async {
final assetBundle = bundle ?? rootBundle;
final templates = <LifeTrackTemplate>[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'package:core_data/core_data.dart';
import 'package:core_domain/core_domain.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('LifeTrackTemplateFallbackResolver', () {
late TemplateRegistry registry;
late _FakeConnectivityService connectivityService;
late LifeTrackTemplateFallbackResolver resolver;

setUp(() {
registry = TemplateRegistry.fromTemplates([
_template(
templateId: 'real_estate_under_construction_v1',
title: 'Flat purchase',
category: LifeTrackCategory.realEstate,
),
_template(
templateId: 'medical_surgery_v1',
title: 'Medical surgery',
category: LifeTrackCategory.medical,
),
_template(
templateId: 'university_admission_v1',
title: 'University admission',
category: LifeTrackCategory.education,
),
]);
connectivityService = _FakeConnectivityService();
resolver = LifeTrackTemplateFallbackResolver(
registry: registry,
connectivityService: connectivityService,
);
});

test(
'returns no fallback when online and generation has not failed',
() async {
final result = await resolver.resolve('I need help buying a flat');

expect(result.status, LifeTrackTemplateFallbackStatus.noFallbackNeeded);
expect(result.shouldFallback, isFalse);
expect(result.template, isNull);
},
);

test('recommends real estate template when offline', () async {
connectivityService.connected = false;

final result = await resolver.resolve('I need help buying a flat');

expect(result.status, LifeTrackTemplateFallbackStatus.recommended);
expect(result.reason, LifeTrackTemplateFallbackReason.offline);
expect(result.template?.templateId, 'real_estate_under_construction_v1');
expect(result.matchedKeywords, contains('flat'));
});

test('recommends medical template when generation fails', () async {
final result = await resolver.resolve(
'Need doctor and operation paperwork',
generationFailure: StateError('local llm failed'),
);

expect(result.status, LifeTrackTemplateFallbackStatus.recommended);
expect(result.reason, LifeTrackTemplateFallbackReason.generationFailed);
expect(result.template?.templateId, 'medical_surgery_v1');
expect(result.matchedKeywords, containsAll(['doctor', 'operation']));
});

test('matches education synonym phrases deterministically', () async {
connectivityService.connected = false;

final result = await resolver.resolve(
'Need a plan for university enrollment and visa steps',
);

expect(result.status, LifeTrackTemplateFallbackStatus.recommended);
expect(result.template?.templateId, 'university_admission_v1');
expect(result.matchedKeywords, containsAll(['university', 'enrollment']));
});

test(
'returns explicit no-match when fallback is needed but no keywords match',
() async {
connectivityService.connected = false;

final result = await resolver.resolve(
'Help me organize my photography side project',
);

expect(result.status, LifeTrackTemplateFallbackStatus.noMatch);
expect(result.reason, LifeTrackTemplateFallbackReason.offline);
expect(result.template, isNull);
expect(result.matchedKeywords, isEmpty);
expect(result.shouldFallback, isTrue);
},
);
});
}

class _FakeConnectivityService extends ConnectivityService {
_FakeConnectivityService() : super();

bool connected = true;

@override
Future<bool> get isConnected async => connected;
}

LifeTrackTemplate _template({
required String templateId,
required String title,
required LifeTrackCategory category,
}) {
return LifeTrackTemplate(
templateId: templateId,
title: title,
description: '$title template',
category: category,
version: '1.0',
milestones: const [
MilestoneTemplate(
name: 'Phase 1',
objective: 'Objective',
tasks: [
ActionItemTemplate(
summary: 'Task',
requirements: [
InputRequirementTemplate(
label: 'Notes',
type: FieldType.text,
isRequired: true,
),
],
),
],
),
],
);
}
Loading