50e4f43738
Implement company AI onboarding/recommendation models, services, repository, and tender UI integration with supporting tests. Co-authored-by: Cursor <cursoragent@cursor.com>
89 lines
3.4 KiB
Dart
89 lines
3.4 KiB
Dart
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:tm_app/core/constants/pref_keys.dart';
|
|
import 'package:tm_app/core/utils/logger.dart';
|
|
import 'package:tm_app/core/utils/result.dart';
|
|
import 'package:tm_app/data/services/company_ai_service.dart';
|
|
import 'package:tm_app/data/services/model/onboarding_response/onboarding_response.dart';
|
|
import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart';
|
|
|
|
/// Outcome of [CompanyAiRepository.maybeStartOnboarding].
|
|
enum AiOnboardingOutcome {
|
|
/// Onboarding was started against the AI service.
|
|
started,
|
|
|
|
/// Skipped: the company has no documents and no website yet.
|
|
skippedNoData,
|
|
|
|
/// Skipped: this profile version was already onboarded.
|
|
skippedUnchanged,
|
|
|
|
/// The onboarding request failed (network/server). Safe to retry later.
|
|
failed,
|
|
}
|
|
|
|
/// Coordinates AI onboarding and recommendation calls.
|
|
///
|
|
/// Onboarding is asynchronous on the AI side and only needs to run when the
|
|
/// company's documents or website change, so this repository fingerprints that
|
|
/// data in [SharedPreferences] and skips redundant calls — mirroring the
|
|
/// `onCompanyProfileReady` guidance in the mobile integration guide.
|
|
class CompanyAiRepository {
|
|
CompanyAiRepository({
|
|
required CompanyAiService companyAiService,
|
|
required SharedPreferences prefs,
|
|
}) : _companyAiService = companyAiService,
|
|
_prefs = prefs;
|
|
|
|
final CompanyAiService _companyAiService;
|
|
final SharedPreferences _prefs;
|
|
|
|
/// Starts onboarding only when the company has data to index and its
|
|
/// fingerprint differs from the last successful run.
|
|
Future<AiOnboardingOutcome> maybeStartOnboarding({
|
|
required List<String> documentFileIds,
|
|
required String? website,
|
|
}) async {
|
|
final hasWebsite = website != null && website.trim().isNotEmpty;
|
|
if (documentFileIds.isEmpty && !hasWebsite) {
|
|
return AiOnboardingOutcome.skippedNoData;
|
|
}
|
|
|
|
final fingerprint = _fingerprint(documentFileIds, website);
|
|
if (fingerprint == _prefs.getString(PrefKeys.onboardingFingerprint)) {
|
|
return AiOnboardingOutcome.skippedUnchanged;
|
|
}
|
|
|
|
final result = await _companyAiService.startOnboarding();
|
|
switch (result) {
|
|
case Ok<OnboardingResponse>():
|
|
await _prefs.setString(PrefKeys.onboardingFingerprint, fingerprint);
|
|
await _prefs.setInt(
|
|
PrefKeys.onboardingStartedAt,
|
|
DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
AppLogger().info('✅ AI onboarding started for fingerprint $fingerprint');
|
|
return AiOnboardingOutcome.started;
|
|
case Error<OnboardingResponse>():
|
|
// Don't persist the fingerprint so the next profile load retries.
|
|
AppLogger().logWarning('⚠️ AI onboarding failed: ${result.error}');
|
|
return AiOnboardingOutcome.failed;
|
|
}
|
|
}
|
|
|
|
Future<Result<RecommendResponse>> getRecommendations() {
|
|
return _companyAiService.getRecommendations();
|
|
}
|
|
|
|
/// Epoch milliseconds of the last started onboarding, or null if none.
|
|
int? lastOnboardingStartedAt() =>
|
|
_prefs.getInt(PrefKeys.onboardingStartedAt);
|
|
|
|
/// Stable, order-independent fingerprint of the data the AI service indexes.
|
|
/// Stored verbatim (it is not sensitive) so it compares reliably across app
|
|
/// launches, unlike [String.hashCode].
|
|
String _fingerprint(List<String> documentFileIds, String? website) {
|
|
final ids = [...documentFileIds]..sort();
|
|
return '${ids.join(',')}|${website?.trim() ?? ''}';
|
|
}
|
|
}
|