Complete local hybrid search and improve agent reliability

This commit is contained in:
Hamza Ayed
2026-10-02 23:38:02 +03:00
parent 3563a104a3
commit 140f6eb287
62 changed files with 7546 additions and 314 deletions
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/core/settings/local_settings_store.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
@@ -13,16 +14,81 @@ class _VersionTestApi extends ApiRepository {
['الجواب الثاني'],
];
SavedConversation? lastSaved;
String? indexedWorkspace;
List<String> indexedFiles = const [];
List<String> deletedFiles = const [];
@override
Future<String> getModelName() async => 'gemma4:e2b';
@override
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
Future<List<ModelDescriptor>> getAvailableModels() async => const [
ModelDescriptor(
id: 'gemma4:e2b',
capabilities: ['completion', 'vision', 'audio', 'tools'],
capabilitiesVerified: true,
),
ModelDescriptor(
id: 'granite-embedding:278m',
capabilities: ['embedding'],
capabilitiesVerified: true,
),
];
@override
Future<List<AgentSkillDescriptor>> getAgentSkills() async => const [
AgentSkillDescriptor(
id: 'code_explain',
name: 'شرح الكود',
description: 'شرح المصدر.',
),
AgentSkillDescriptor(
id: 'code_review',
name: 'مراجعة الكود',
description: 'مراجعة المصدر.',
),
AgentSkillDescriptor(
id: 'test_plan',
name: 'خطة اختبارات',
description: 'اقتراح الاختبارات.',
),
];
@override
Future<List<SavedConversation>> listConversations() async => [];
@override
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
'README.md',
];
@override
Future<Map<String, dynamic>> indexWorkspaceKnowledge(
String workspacePath,
List<String> files,
) async {
indexedWorkspace = workspacePath;
indexedFiles = files;
return {
'indexed': [
for (final file in files) {'path': file, 'chunks': 2},
],
};
}
@override
Future<Map<String, dynamic>> deleteWorkspaceKnowledge(
String workspacePath,
List<String> files,
) async {
deletedFiles = files;
return {
'deleted': [
for (final file in files) {'path': file, 'deleted': true},
],
};
}
@override
Future<void> saveConversation(SavedConversation conversation) async {
lastSaved = conversation;
@@ -65,10 +131,58 @@ class _CancellableTestApi extends _VersionTestApi {
}
}
class _MemorySettingsStore extends LocalSettingsStore {
final values = <String, Object?>{};
@override
bool get persistenceAvailable => true;
@override
Future<Map<String, Object?>> read() async => Map.of(values);
@override
Future<bool> update(Map<String, Object?> updates) async {
values.addAll(updates);
return true;
}
}
void main() {
test('embedding-only models are not selectable for chat', () async {
final api = _VersionTestApi();
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
await Future<void>.delayed(Duration.zero);
expect(cubit.state.availableModels.map((model) => model.id), [
'gemma4:e2b',
]);
await cubit.close();
});
test('selected workspace files can be indexed as local knowledge', () async {
final api = _VersionTestApi();
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
await Future<void>.delayed(Duration.zero);
await cubit.selectWorkspace(r'C:\Projects\sample');
cubit.selectWorkspaceFiles(['README.md']);
final message = await cubit.indexSelectedWorkspaceKnowledge();
expect(api.indexedWorkspace, r'C:\Projects\sample');
expect(api.indexedFiles, ['README.md']);
expect(message, contains('2 مقطعًا'));
expect(cubit.state.agentProgress, isNull);
final removal = await cubit.removeSelectedWorkspaceKnowledge();
expect(api.deletedFiles, ['README.md']);
expect(removal, contains('أُزيل 1 ملف'));
await cubit.close();
});
test('regeneration keeps prior answers and selection is persisted', () async {
final api = _VersionTestApi();
final cubit = ChatCubit(api);
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
await Future<void>.delayed(Duration.zero);
await cubit.send('ما معنى API؟');
@@ -97,11 +211,52 @@ void main() {
await cubit.close();
});
test(
'local API, model, and notification settings survive Cubit restart',
() async {
final settings = _MemorySettingsStore();
final firstApi = _VersionTestApi();
final first = ChatCubit(firstApi, settingsStore: settings);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(await first.setApiBaseUrl('file:///unsafe'), isFalse);
expect(firstApi.baseUrl, 'http://127.0.0.1:8000');
expect(await first.setApiBaseUrl('http://127.0.0.1:9123'), isTrue);
await first.setNotificationsEnabled(false);
await first.setDarkMode(true);
first.selectModel('gemma4:e2b');
await Future<void>.delayed(Duration.zero);
await first.close();
final restoredApi = _VersionTestApi();
final restored = ChatCubit(restoredApi, settingsStore: settings);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(restored.state.apiBaseUrl, 'http://127.0.0.1:9123');
expect(restored.state.notificationsEnabled, isFalse);
expect(restored.state.isDarkMode, isTrue);
expect(restored.state.selectedModel, 'gemma4:e2b');
await Future<void>.delayed(Duration.zero);
restored.selectAgentSkill('code_review');
expect(restored.state.selectedSkillId, 'code_review');
restored.selectAgentSkill('unknown');
expect(restored.state.selectedSkillId, 'code_review');
restored.selectAgentSkill(null);
expect(restored.state.selectedSkillId, isNull);
await restored.close();
},
);
test(
'cancelling generation preserves partial text and records duration',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
final cubit = ChatCubit(
api,
settingsStore: LocalSettingsStore.inMemory(),
);
await Future<void>.delayed(Duration.zero);
final send = cubit.send('اكتب فقرة قصيرة');
@@ -127,7 +282,10 @@ void main() {
'cancelling regeneration before its first token keeps saved answer',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
final cubit = ChatCubit(
api,
settingsStore: LocalSettingsStore.inMemory(),
);
await Future<void>.delayed(Duration.zero);
await cubit.openConversation(api.storedConversation);