diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 87c1d64..adaa77b 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -8,6 +8,7 @@ import 'models.dart'; import 'native_bridge.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'public_study_tools.dart'; import 'studyos_tool_catalog.dart'; import 'studyos_tool_executor.dart'; @@ -31,6 +32,7 @@ class AgentLlmRequest { this.searchTalks = _unavailableTalks, required this.mailTools, required this.onToolTrace, + this.publicStudyTools, this.onDelta, this.cancelToken, }); @@ -47,6 +49,7 @@ class AgentLlmRequest { final Future Function() readAcademicStatus; final Future Function(String query, int limit) searchTalks; final MailToolRunner mailTools; + final PublicStudyToolRunner? publicStudyTools; final void Function(ToolTrace trace) onToolTrace; /// Optional sink for streamed reply fragments. Cloud uses it for SSE; the @@ -150,6 +153,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { searchTalks: request.searchTalks, mailTools: request.mailTools, nativeTools: nativeTools, + publicStudyTools: request.publicStudyTools, ); for (var round = 0; round < _maxToolRounds; round += 1) { @@ -337,6 +341,7 @@ class CloudLlmProvider implements AgentLlmProvider { readSchedule: request.readSchedule, searchTalks: request.searchTalks, mailTools: request.mailTools, + publicStudyTools: request.publicStudyTools, onToolTrace: request.onToolTrace, onDelta: request.onDelta, cancelToken: request.cancelToken, diff --git a/flutter_app/lib/src/agent_message_sender.dart b/flutter_app/lib/src/agent_message_sender.dart index b32ceaa..2c39d5e 100644 --- a/flutter_app/lib/src/agent_message_sender.dart +++ b/flutter_app/lib/src/agent_message_sender.dart @@ -5,6 +5,7 @@ import 'memory_store.dart'; import 'models.dart'; import 'native_bridge.dart'; import 'prompt_context.dart'; +import 'public_study_tools.dart'; Future _unavailableTalks(String query, int limit) async => 'Tübingen Talks are not available.'; @@ -27,6 +28,7 @@ Future sendAgentMessage({ Future Function(String query, int limit) searchTalks = _unavailableTalks, required MailToolRunner mailTools, + required PublicStudyToolRunner publicStudyTools, required void Function(ToolTrace trace) onToolTrace, AgentStreamSink? onDelta, AgentCancelToken? cancelToken, @@ -54,6 +56,7 @@ Future sendAgentMessage({ readAcademicStatus: readAcademicStatus, searchTalks: searchTalks, mailTools: mailTools, + publicStudyTools: publicStudyTools, onDelta: onDelta, cancelToken: cancelToken, ); diff --git a/flutter_app/lib/src/agent_request_runner.dart b/flutter_app/lib/src/agent_request_runner.dart index c3a20fc..b600dcb 100644 --- a/flutter_app/lib/src/agent_request_runner.dart +++ b/flutter_app/lib/src/agent_request_runner.dart @@ -7,6 +7,7 @@ import 'models.dart'; import 'native_bridge.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'public_study_tools.dart'; Future _unavailableAcademicStatus() async => 'Academic status is not available.'; @@ -53,6 +54,7 @@ class AgentRequestRunner { Future Function(String query, int limit) searchTalks = _unavailableTalks, required MailToolRunner mailTools, + PublicStudyToolRunner? publicStudyTools, AgentStreamSink? onDelta, AgentCancelToken? cancelToken, }) async { @@ -71,6 +73,7 @@ class AgentRequestRunner { readAcademicStatus: readAcademicStatus, searchTalks: searchTalks, mailTools: mailTools, + publicStudyTools: publicStudyTools, onToolTrace: onToolTrace, onDelta: onDelta, cancelToken: cancelToken, diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index ec354ef..334ecda 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -18,6 +18,7 @@ import 'native_bridge.dart'; import 'official_document_models.dart'; import 'official_documents_repository.dart'; import 'profile_context.dart'; +import 'public_study_tools.dart'; import 'send_error_message.dart'; import 'session_store.dart'; import 'timetable_repository.dart'; @@ -75,6 +76,7 @@ class AppShellController extends ChangeNotifier { final AcademicRepository _academicRepository = AcademicRepository(); final OfficialDocumentsRepository _documentsRepository = OfficialDocumentsRepository(); + final PublicStudyToolRunner _publicStudyTools = LivePublicStudyToolRunner(); final TextEditingController inputController = TextEditingController(); final ScrollController messageScrollController = ScrollController(); @@ -199,6 +201,7 @@ class AppShellController extends ChangeNotifier { _streamNotifyTimer?.cancel(); _eventSubscription?.cancel(); voice.dispose(); + _publicStudyTools.close(); if (_ownsTalksRepository) talksRepository.dispose(); inputController.dispose(); messageScrollController.dispose(); @@ -474,6 +477,7 @@ class AppShellController extends ChangeNotifier { repository: _mailRepository, profile: _profile, ), + publicStudyTools: _publicStudyTools, onToolTrace: addToolTrace, onDelta: _handleStreamDelta, cancelToken: cancelToken, diff --git a/flutter_app/lib/src/campus_client.dart b/flutter_app/lib/src/campus_client.dart index f1d101f..35fe97a 100644 --- a/flutter_app/lib/src/campus_client.dart +++ b/flutter_app/lib/src/campus_client.dart @@ -5,11 +5,14 @@ import 'package:http/http.dart' as http; import 'campus_models.dart'; class CampusClient { - CampusClient({http.Client? httpClient}) - : _httpClient = httpClient ?? http.Client(); + CampusClient({ + http.Client? httpClient, + this.timeout = const Duration(seconds: 12), + }) : _httpClient = httpClient ?? http.Client(); - static const _mealplanUrl = - 'https://www.my-stuwe.de/wp-json/mealplans/v1/canteens?lang=de'; + static final sourceUri = Uri.parse( + 'https://www.my-stuwe.de/wp-json/mealplans/v1/canteens?lang=de', + ); static const _tuebingenCanteenIds = { '611', '621', @@ -19,9 +22,10 @@ class CampusClient { }; final http.Client _httpClient; + final Duration timeout; Future> fetchTuebingenCanteens() async { - final response = await _httpClient.get(Uri.parse(_mealplanUrl)); + final response = await _httpClient.get(sourceUri).timeout(timeout); if (response.statusCode < 200 || response.statusCode >= 300) { throw CampusException('Mensa data returned HTTP ${response.statusCode}.'); } diff --git a/flutter_app/lib/src/capability_result.dart b/flutter_app/lib/src/capability_result.dart new file mode 100644 index 0000000..c45703c --- /dev/null +++ b/flutter_app/lib/src/capability_result.dart @@ -0,0 +1,100 @@ +enum CapabilityState { fresh, stale, empty, unavailable, failed } + +enum CapabilityPrivacy { publicExternal, privateLocal } + +enum CapabilityEffect { readOnly, userApproved } + +class CapabilityPolicy { + const CapabilityPolicy({required this.privacy, required this.effect}); + + static const publicRead = CapabilityPolicy( + privacy: CapabilityPrivacy.publicExternal, + effect: CapabilityEffect.readOnly, + ); + + final CapabilityPrivacy privacy; + final CapabilityEffect effect; + + Map toJson() => { + 'privacy': _snakeCase(privacy.name), + 'effect': _snakeCase(effect.name), + }; +} + +class CapabilitySource { + const CapabilitySource({ + required this.id, + required this.label, + required this.url, + this.reliability = 'live', + }); + + final String id; + final String label; + final String url; + final String reliability; + + Map toJson() => { + 'id': id, + 'label': label, + 'url': url, + 'reliability': reliability, + }; +} + +class CapabilityResult { + const CapabilityResult({ + required this.state, + required this.policy, + required this.source, + required this.fetchedAt, + this.expiresAt, + this.data, + this.message, + }); + + final CapabilityState state; + final CapabilityPolicy policy; + final CapabilitySource source; + final DateTime fetchedAt; + final DateTime? expiresAt; + final T? data; + final String? message; + + Map toJson( + Object? Function(T value) encodeData, + ) => { + 'state': state.name, + 'policy': policy.toJson(), + 'source': source.toJson(), + 'fetched_at': fetchedAt.toUtc().toIso8601String(), + if (expiresAt != null) 'expires_at': expiresAt!.toUtc().toIso8601String(), + if (data != null) 'data': encodeData(data as T), + if (message != null) 'message': message, + }; +} + +String boundedCapabilityMessage(Object error, {int maxLength = 240}) { + final text = error + .toString() + .replaceAll(RegExp(r'\s+'), ' ') + .trim() + .replaceAllMapped( + RegExp( + r'(password|authorization|cookie|token|api[_-]?key|secret)=[^\s]+', + caseSensitive: false, + ), + (match) => '${match.group(1)}=[redacted]', + ) + .replaceAllMapped( + RegExp(r'Bearer\s+[^\s]+', caseSensitive: false), + (_) => 'Bearer [redacted]', + ); + if (text.isEmpty) return 'The live data source failed.'; + return text.length <= maxLength ? text : '${text.substring(0, maxLength)}…'; +} + +String _snakeCase(String value) => value.replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match.group(1)}_${match.group(2)!.toLowerCase()}', +); diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index b0ef26b..aa2463c 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -9,6 +9,7 @@ import 'mail_tools.dart'; import 'models.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'public_study_tools.dart'; import 'studyos_tool_catalog.dart'; import 'studyos_tool_executor.dart'; @@ -46,6 +47,7 @@ class CloudAgentClient { Future Function(String query, int limit) searchTalks = _unavailableTalks, required MailToolRunner mailTools, + PublicStudyToolRunner? publicStudyTools, void Function(ToolTrace trace)? onToolTrace, AgentStreamSink? onDelta, AgentCancelToken? cancelToken, @@ -83,6 +85,7 @@ class CloudAgentClient { searchTalks: searchTalks, mailTools: mailTools, nativeTools: _nativeTools, + publicStudyTools: publicStudyTools, ); for (var round = 0; ; round += 1) { final message = await _fetchTurn( diff --git a/flutter_app/lib/src/map_search_client.dart b/flutter_app/lib/src/map_search_client.dart index afe8422..922efcc 100644 --- a/flutter_app/lib/src/map_search_client.dart +++ b/flutter_app/lib/src/map_search_client.dart @@ -3,12 +3,18 @@ import 'package:http/http.dart' as http; import 'map_location_models.dart'; class MapSearchClient { - MapSearchClient({http.Client? client}) - : _client = client ?? http.Client(), - _ownsClient = client == null; + MapSearchClient({ + http.Client? client, + this.timeout = const Duration(seconds: 12), + }) : _client = client ?? http.Client(), + _ownsClient = client == null; final http.Client _client; final bool _ownsClient; + final Duration timeout; + static final sourceUri = Uri.parse( + 'https://nominatim.openstreetmap.org/search', + ); Future> search(String query) async { final trimmed = query.trim(); @@ -21,12 +27,14 @@ class MapSearchClient { 'viewbox': '8.93,48.57,9.16,48.47', 'q': '$trimmed Tuebingen', }); - final response = await _client.get( - uri, - headers: const { - 'User-Agent': 'StudyOS Agent course prototype', - }, - ); + final response = await _client + .get( + uri, + headers: const { + 'User-Agent': 'StudyOS Agent course prototype', + }, + ) + .timeout(timeout); if (response.statusCode < 200 || response.statusCode >= 300) { throw MapSearchException( 'Search failed with HTTP ${response.statusCode}.', diff --git a/flutter_app/lib/src/public_study_capabilities.dart b/flutter_app/lib/src/public_study_capabilities.dart new file mode 100644 index 0000000..5aee549 --- /dev/null +++ b/flutter_app/lib/src/public_study_capabilities.dart @@ -0,0 +1,281 @@ +import 'capability_result.dart'; +import 'campus_client.dart'; +import 'campus_models.dart'; +import 'map_location_models.dart'; +import 'map_search_client.dart'; +import 'student_profile.dart'; + +typedef CapabilityClock = DateTime Function(); + +class MensaOptionsQuery { + const MensaOptionsQuery({ + this.date, + this.canteen = '', + this.preference = FoodPreference.noPreference, + this.limit = 12, + }); + + final DateTime? date; + final String canteen; + final FoodPreference preference; + final int limit; +} + +class MensaOption { + const MensaOption({required this.canteen, required this.menu}); + + final CampusCanteen canteen; + final CampusMenu menu; + + Map toJson() => { + 'id': menu.id.isEmpty + ? '${canteen.id}:${menu.date}:${menu.line}'.toLowerCase() + : '${canteen.id}:${menu.id}', + 'canteen_id': canteen.id, + 'canteen': canteen.name, + 'date': menu.date, + 'line': menu.line, + 'items': menu.items, + 'dietary_markers': menu.icons, + 'student_price': menu.studentPrice, + }; +} + +class MensaOptionsCapability { + MensaOptionsCapability({ + CampusClient? client, + CapabilityClock? clock, + this.ttl = const Duration(minutes: 15), + }) : _client = client ?? CampusClient(), + _clock = clock ?? DateTime.now; + + static final CapabilitySource source = CapabilitySource( + id: 'my_stuwe_mensa', + label: 'Studierendenwerk Tübingen-Hohenheim meal plans', + url: CampusClient.sourceUri.toString(), + ); + + final CampusClient _client; + final CapabilityClock _clock; + final Duration ttl; + _TimedValue>? _cache; + Future>? _inFlight; + + Future>> load( + MensaOptionsQuery query, + ) async { + final fetchedAt = _clock(); + try { + final canteens = await _loadCanteens(fetchedAt); + final options = _filter(canteens.value, query); + return CapabilityResult>( + state: canteens.stale + ? CapabilityState.stale + : options.isEmpty + ? CapabilityState.empty + : CapabilityState.fresh, + policy: CapabilityPolicy.publicRead, + source: source, + fetchedAt: canteens.fetchedAt, + expiresAt: canteens.expiresAt, + data: options, + message: canteens.message, + ); + } on Object catch (error) { + return CapabilityResult>( + state: CapabilityState.failed, + policy: CapabilityPolicy.publicRead, + source: source, + fetchedAt: fetchedAt, + message: boundedCapabilityMessage(error), + ); + } + } + + void invalidate() => _cache = null; + + void close() => _client.close(); + + Future<_CapabilityValue>> _loadCanteens( + DateTime now, + ) async { + final cached = _cache; + if (cached != null && now.isBefore(cached.expiresAt)) { + return cached.current; + } + try { + final pending = _inFlight ??= _client.fetchTuebingenCanteens(); + final value = await pending; + final stored = _TimedValue(value, fetchedAt: now, ttl: ttl); + _cache = stored; + return stored.current; + } on Object catch (error) { + if (cached == null) rethrow; + return cached.stale(boundedCapabilityMessage(error)); + } finally { + _inFlight = null; + } + } + + List _filter( + List canteens, + MensaOptionsQuery query, + ) { + final canteenFilter = query.canteen.trim().toLowerCase(); + final options = []; + for (final canteen in canteens) { + if (canteenFilter.isNotEmpty && + canteen.id != canteenFilter && + !canteen.name.toLowerCase().contains(canteenFilter)) { + continue; + } + final filtered = canteen.filteredFor(query.preference); + for (final menu in filtered.menus) { + if (query.date != null && !_sameDate(menu.parsedDate, query.date)) { + continue; + } + options.add(MensaOption(canteen: canteen, menu: menu)); + } + } + options.sort((first, second) { + final dateOrder = (first.menu.date).compareTo(second.menu.date); + if (dateOrder != 0) return dateOrder; + return first.canteen.name.compareTo(second.canteen.name); + }); + return options.take(query.limit).toList(growable: false); + } +} + +class CampusLocationResult { + const CampusLocationResult(this.location); + + final MapLocation location; + + Map toJson() => { + 'id': + '${location.source}:${location.latitude.toStringAsFixed(6)},${location.longitude.toStringAsFixed(6)}', + 'name': location.name, + 'address': location.address, + 'latitude': location.latitude, + 'longitude': location.longitude, + 'category': location.category, + }; +} + +class CampusLocationCapability { + CampusLocationCapability({ + MapSearchClient? client, + CapabilityClock? clock, + this.ttl = const Duration(minutes: 10), + }) : _client = client ?? MapSearchClient(), + _clock = clock ?? DateTime.now; + + static final CapabilitySource source = CapabilitySource( + id: 'openstreetmap_nominatim', + label: 'OpenStreetMap Nominatim', + url: MapSearchClient.sourceUri.toString(), + ); + + final MapSearchClient _client; + final CapabilityClock _clock; + final Duration ttl; + final Map>> _cache = {}; + + Future>> search( + String query, { + int limit = 8, + }) async { + final now = _clock(); + final key = query.trim().toLowerCase(); + try { + final cached = _cache[key]; + late final _CapabilityValue> locations; + if (cached != null && now.isBefore(cached.expiresAt)) { + locations = cached.current; + } else { + try { + final value = await _client.search(query); + final stored = _TimedValue(value, fetchedAt: now, ttl: ttl); + _cache[key] = stored; + locations = stored.current; + if (_cache.length > 32) _cache.remove(_cache.keys.first); + } on Object catch (error) { + if (cached == null) rethrow; + locations = cached.stale(boundedCapabilityMessage(error)); + } + } + final data = locations.value + .take(limit) + .map(CampusLocationResult.new) + .toList(growable: false); + return CapabilityResult>( + state: locations.stale + ? CapabilityState.stale + : data.isEmpty + ? CapabilityState.empty + : CapabilityState.fresh, + policy: CapabilityPolicy.publicRead, + source: source, + fetchedAt: locations.fetchedAt, + expiresAt: locations.expiresAt, + data: data, + message: locations.message, + ); + } on Object catch (error) { + return CapabilityResult>( + state: CapabilityState.failed, + policy: CapabilityPolicy.publicRead, + source: source, + fetchedAt: now, + message: boundedCapabilityMessage(error), + ); + } + } + + void invalidate() => _cache.clear(); + + void close() => _client.close(); +} + +class _TimedValue { + _TimedValue(this.value, {required this.fetchedAt, required Duration ttl}) + : expiresAt = fetchedAt.add(ttl); + + final T value; + final DateTime fetchedAt; + final DateTime expiresAt; + + _CapabilityValue get current => + _CapabilityValue(value, fetchedAt: fetchedAt, expiresAt: expiresAt); + + _CapabilityValue stale(String message) => _CapabilityValue( + value, + fetchedAt: fetchedAt, + expiresAt: expiresAt, + stale: true, + message: message, + ); +} + +class _CapabilityValue { + const _CapabilityValue( + this.value, { + required this.fetchedAt, + required this.expiresAt, + this.stale = false, + this.message, + }); + + final T value; + final DateTime fetchedAt; + final DateTime expiresAt; + final bool stale; + final String? message; +} + +bool _sameDate(DateTime? first, DateTime? second) => + first != null && + second != null && + first.year == second.year && + first.month == second.month && + first.day == second.day; diff --git a/flutter_app/lib/src/public_study_tools.dart b/flutter_app/lib/src/public_study_tools.dart new file mode 100644 index 0000000..817e1ea --- /dev/null +++ b/flutter_app/lib/src/public_study_tools.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; + +import 'capability_result.dart'; +import 'public_study_capabilities.dart'; +import 'student_profile.dart'; + +const getMensaOptionsToolName = 'get_mensa_options'; +const searchCampusLocationsToolName = 'search_campus_locations'; + +abstract interface class PublicStudyToolRunner { + Future execute(String toolName, String arguments); + + void close(); +} + +class LivePublicStudyToolRunner implements PublicStudyToolRunner { + LivePublicStudyToolRunner({ + MensaOptionsCapability? mensa, + CampusLocationCapability? locations, + }) : _mensa = mensa ?? MensaOptionsCapability(), + _locations = locations ?? CampusLocationCapability(); + + final MensaOptionsCapability _mensa; + final CampusLocationCapability _locations; + + @override + Future execute(String toolName, String arguments) async { + final Map args; + try { + args = _decodeArguments(arguments); + } on FormatException { + return _failure(toolName, 'Tool arguments must be a JSON object.'); + } + return switch (toolName) { + getMensaOptionsToolName => _mensas(args), + searchCampusLocationsToolName => _campusLocations(args), + _ => _failure(toolName, 'Public study tool is not available.'), + }; + } + + Future _mensas(Map args) async { + final preference = _preference(args['preference']); + if (preference == null) { + return _failure( + getMensaOptionsToolName, + 'Preference must be any, vegetarian, or vegan.', + ); + } + final dateValue = _string(args, 'date'); + final date = dateValue.isEmpty ? null : _isoDate(dateValue); + if (dateValue.isNotEmpty && date == null) { + return _failure( + getMensaOptionsToolName, + 'Date must use a valid YYYY-MM-DD value.', + ); + } + final result = await _mensa.load( + MensaOptionsQuery( + date: date, + canteen: _string(args, 'canteen'), + preference: preference, + limit: _boundedInt(args, 'limit', fallback: 12, max: 30), + ), + ); + return jsonEncode( + result.toJson( + (items) => items.map((item) => item.toJson()).toList(growable: false), + ), + ); + } + + Future _campusLocations(Map args) async { + final query = _string(args, 'query'); + if (query.isEmpty) { + return _failure( + searchCampusLocationsToolName, + 'A non-empty campus location query is required.', + ); + } + if (query.length > 120) { + return _failure( + searchCampusLocationsToolName, + 'Campus location queries are limited to 120 characters.', + ); + } + final result = await _locations.search( + query, + limit: _boundedInt(args, 'limit', fallback: 8, max: 8), + ); + return jsonEncode( + result.toJson( + (items) => items.map((item) => item.toJson()).toList(growable: false), + ), + ); + } + + String _failure(String toolName, String message) { + final now = DateTime.now(); + final source = toolName == getMensaOptionsToolName + ? MensaOptionsCapability.source + : CampusLocationCapability.source; + return jsonEncode( + CapabilityResult>( + state: CapabilityState.failed, + policy: CapabilityPolicy.publicRead, + source: source, + fetchedAt: now, + message: message, + ).toJson((items) => items), + ); + } + + @override + void close() { + _mensa.close(); + _locations.close(); + } +} + +Map _decodeArguments(String arguments) { + if (arguments.trim().isEmpty) return const {}; + final decoded = jsonDecode(arguments); + if (decoded is! Map) throw const FormatException('Expected a JSON object.'); + return Map.from(decoded); +} + +String _string(Map args, String key) => + args[key]?.toString().trim() ?? ''; + +int _boundedInt( + Map args, + String key, { + required int fallback, + required int max, +}) { + final value = args[key]; + final parsed = value is num + ? value.toInt() + : int.tryParse(value?.toString() ?? ''); + return (parsed ?? fallback).clamp(1, max).toInt(); +} + +FoodPreference? _preference(Object? value) { + return switch (value?.toString().trim().toLowerCase() ?? 'any') { + '' || 'any' => FoodPreference.noPreference, + 'vegetarian' => FoodPreference.vegetarian, + 'vegan' => FoodPreference.vegan, + _ => null, + }; +} + +DateTime? _isoDate(String value) { + if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) return null; + final parsed = DateTime.tryParse(value); + if (parsed == null) return null; + final canonical = + '${parsed.year.toString().padLeft(4, '0')}-${parsed.month.toString().padLeft(2, '0')}-${parsed.day.toString().padLeft(2, '0')}'; + return canonical == value ? parsed : null; +} diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index 46fa3e2..049b939 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -1,4 +1,5 @@ import 'native_tool_router.dart'; +import 'public_study_tools.dart'; class StudyOsToolSpec { const StudyOsToolSpec({ @@ -80,6 +81,51 @@ const searchTalksTool = StudyOsToolSpec( required: [], ); +const getMensaOptionsTool = StudyOsToolSpec( + name: getMensaOptionsToolName, + description: + 'Get live Tübingen Mensa meal options, optionally filtered by date, canteen, and dietary preference.', + traceSummary: 'Loading live Tübingen Mensa options.', + properties: { + 'date': { + 'type': 'string', + 'description': 'Optional date in YYYY-MM-DD format.', + }, + 'canteen': { + 'type': 'string', + 'description': 'Optional canteen ID or name filter.', + }, + 'preference': { + 'type': 'string', + 'enum': ['any', 'vegetarian', 'vegan'], + 'description': 'Optional dietary filter. Defaults to any.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximum menu entries to return. Capped at 30.', + }, + }, + required: [], +); + +const searchCampusLocationsTool = StudyOsToolSpec( + name: searchCampusLocationsToolName, + description: + 'Search public OpenStreetMap place data bounded to Tübingen without using the device location.', + traceSummary: 'Searching public Tübingen campus locations.', + properties: { + 'query': { + 'type': 'string', + 'description': 'Campus building, facility, street, or place to find.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximum locations to return. Capped at 8.', + }, + }, + required: ['query'], +); + const listMailboxesTool = StudyOsToolSpec( name: 'list_mailboxes', description: 'List local university mail folders and unread counts.', @@ -338,6 +384,8 @@ const studyOsTools = [ getScheduleTool, getAcademicStatusTool, searchTalksTool, + getMensaOptionsTool, + searchCampusLocationsTool, listMailboxesTool, getRecentMailTool, searchMailTool, diff --git a/flutter_app/lib/src/studyos_tool_executor.dart b/flutter_app/lib/src/studyos_tool_executor.dart index 865f6af..c706892 100644 --- a/flutter_app/lib/src/studyos_tool_executor.dart +++ b/flutter_app/lib/src/studyos_tool_executor.dart @@ -4,6 +4,7 @@ import 'mail_tools.dart'; import 'memory_store.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'public_study_tools.dart'; Future _unavailableAcademicStatus() async => 'Academic status is not available.'; @@ -20,6 +21,7 @@ class StudyOsToolContext { this.searchTalks = _unavailableTalks, required this.mailTools, required this.nativeTools, + this.publicStudyTools, }); final PromptContext promptContext; @@ -30,6 +32,7 @@ class StudyOsToolContext { final Future Function(String query, int limit) searchTalks; final MailToolRunner mailTools; final NativeToolRunner? nativeTools; + final PublicStudyToolRunner? publicStudyTools; } class StudyOsToolExecutor { @@ -47,6 +50,8 @@ class StudyOsToolExecutor { 'get_schedule' => context.readSchedule(), 'get_academic_status' => context.readAcademicStatus(), 'search_talks' => _searchTalks(arguments, context.searchTalks), + getMensaOptionsToolName || searchCampusLocationsToolName => + _executePublicStudyTool(toolName, arguments, context.publicStudyTools), 'list_mailboxes' || 'get_recent_mail' || 'search_mail' || @@ -61,6 +66,19 @@ class StudyOsToolExecutor { }; } + Future _executePublicStudyTool( + String toolName, + String arguments, + PublicStudyToolRunner? publicStudyTools, + ) { + if (publicStudyTools == null) { + return Future.value( + 'Public study tool is not available in this runtime: $toolName', + ); + } + return publicStudyTools.execute(toolName, arguments); + } + Future _executeNativeTool( String toolName, String arguments, @@ -126,6 +144,7 @@ StudyOsToolContext studyOsToolContext({ _unavailableTalks, required MailToolRunner mailTools, NativeToolRunner? nativeTools, + PublicStudyToolRunner? publicStudyTools, }) { return StudyOsToolContext( promptContext: promptContext, @@ -136,5 +155,6 @@ StudyOsToolContext studyOsToolContext({ searchTalks: searchTalks, mailTools: mailTools, nativeTools: nativeTools, + publicStudyTools: publicStudyTools, ); } diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index aebdbc9..0c31c7d 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -28,6 +28,8 @@ void main() { 'read_memories', 'get_study_context', 'get_schedule', + 'get_mensa_options', + 'search_campus_locations', 'list_mailboxes', 'get_recent_mail', 'search_mail', diff --git a/flutter_app/test/public_study_tools_test.dart b/flutter_app/test/public_study_tools_test.dart new file mode 100644 index 0000000..d046a42 --- /dev/null +++ b/flutter_app/test/public_study_tools_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:studyos_agent/src/capability_result.dart'; +import 'package:studyos_agent/src/campus_client.dart'; +import 'package:studyos_agent/src/map_search_client.dart'; +import 'package:studyos_agent/src/public_study_capabilities.dart'; +import 'package:studyos_agent/src/public_study_tools.dart'; + +void main() { + test('get_mensa_options filters live menus and returns metadata', () async { + var calls = 0; + final now = DateTime.utc(2026, 7, 17, 12); + final runner = _runner( + now: () => now, + campusClient: MockClient((request) async { + calls += 1; + expect(request.url, CampusClient.sourceUri); + return http.Response(_mensaPayload, 200, request: request); + }), + ); + addTearDown(runner.close); + + final first = _json( + await runner.execute( + getMensaOptionsToolName, + '{"date":"2026-07-18","preference":"vegan","limit":1}', + ), + ); + final second = _json( + await runner.execute(getMensaOptionsToolName, '{"limit":2}'), + ); + + expect(first['state'], 'fresh'); + expect(_policy(first)['privacy'], 'public_external'); + expect(_source(first)['id'], 'my_stuwe_mensa'); + expect(_data(first), hasLength(1)); + expect(_data(first).single['canteen'], 'Mensa Wilhelmstraße'); + expect(_data(first).single['dietary_markers'], contains('vegan')); + expect(_data(second), hasLength(2)); + expect(calls, 1, reason: 'the second tool call should reuse the TTL cache'); + }); + + test('get_mensa_options serves stale cache when refresh fails', () async { + var now = DateTime.utc(2026, 7, 17, 12); + var calls = 0; + final runner = _runner( + now: () => now, + campusClient: MockClient((request) async { + calls += 1; + return calls == 1 + ? http.Response(_mensaPayload, 200, request: request) + : http.Response('upstream unavailable', 503, request: request); + }), + ); + addTearDown(runner.close); + + await runner.execute(getMensaOptionsToolName, '{}'); + now = now.add(const Duration(minutes: 16)); + final stale = _json(await runner.execute(getMensaOptionsToolName, '{}')); + + expect(stale['state'], 'stale'); + expect(_data(stale), isNotEmpty); + expect(stale['message'], contains('HTTP 503')); + }); + + test('search_campus_locations returns bounded stable results', () async { + Uri? requestedUri; + final runner = _runner( + now: () => DateTime.utc(2026, 7, 17, 12), + mapClient: MockClient((request) async { + requestedUri = request.url; + return http.Response( + jsonEncode([ + { + 'display_name': 'Neue Aula, Geschwister-Scholl-Platz, Tübingen', + 'lat': '48.52942', + 'lon': '9.04339', + 'type': 'university', + }, + { + 'display_name': 'Universitätsbibliothek, Tübingen', + 'lat': '48.52900', + 'lon': '9.04500', + 'type': 'library', + }, + ]), + 200, + request: request, + ); + }), + ); + addTearDown(runner.close); + + final result = _json( + await runner.execute( + searchCampusLocationsToolName, + '{"query":"Neue Aula","limit":1}', + ), + ); + + expect(result['state'], 'fresh'); + expect(_source(result)['id'], 'openstreetmap_nominatim'); + expect(_data(result), hasLength(1)); + expect(_data(result).single['id'], 'nominatim:48.529420,9.043390'); + expect(requestedUri!.queryParameters['bounded'], '1'); + expect(requestedUri!.queryParameters['q'], 'Neue Aula Tuebingen'); + expect(requestedUri!.queryParameters, isNot(contains('lat'))); + expect(requestedUri!.queryParameters, isNot(contains('lon'))); + }); + + test( + 'public tools return explicit failed states for invalid input', + () async { + final runner = _runner(now: DateTime.now); + addTearDown(runner.close); + + final badDate = _json( + await runner.execute(getMensaOptionsToolName, '{"date":"2026-02-30"}'), + ); + final badLocation = _json( + await runner.execute(searchCampusLocationsToolName, '{bad json'), + ); + + expect(badDate['state'], 'failed'); + expect(badDate['message'], contains('YYYY-MM-DD')); + expect(badLocation['state'], 'failed'); + expect(badLocation['message'], contains('JSON object')); + }, + ); + + test('capability error messages redact credential-shaped values', () { + expect( + boundedCapabilityMessage( + 'request failed password=hunter2 cookie=session-secret ' + 'token=abc Bearer def', + ), + 'request failed password=[redacted] cookie=[redacted] ' + 'token=[redacted] Bearer [redacted]', + ); + }); +} + +LivePublicStudyToolRunner _runner({ + required DateTime Function() now, + http.Client? campusClient, + http.Client? mapClient, +}) { + return LivePublicStudyToolRunner( + mensa: MensaOptionsCapability( + client: CampusClient(httpClient: campusClient ?? MockClient(_emptyMensa)), + clock: now, + ), + locations: CampusLocationCapability( + client: MapSearchClient(client: mapClient ?? MockClient(_emptyLocations)), + clock: now, + ), + ); +} + +Future _emptyMensa(http.Request request) async => + http.Response('{}', 200, request: request); + +Future _emptyLocations(http.Request request) async => + http.Response('[]', 200, request: request); + +Map _json(String value) => + Map.from(jsonDecode(value) as Map); + +Map _policy(Map result) => + Map.from(result['policy'] as Map); + +Map _source(Map result) => + Map.from(result['source'] as Map); + +List> _data(Map result) => + (result['data'] as List) + .map((item) => Map.from(item as Map)) + .toList(); + +final _mensaPayload = jsonEncode({ + '611': { + 'canteen': 'Mensa Wilhelmstraße', + 'menus': [ + { + 'id': 'vegan-1', + 'menuLine': 'Vegan', + 'menuDate': '2026-07-18', + 'menu': ['Lentil curry'], + 'icons': ['vegan'], + 'studentPrice': '3,20', + }, + { + 'id': 'classic-1', + 'menuLine': 'Classic', + 'menuDate': '2026-07-18', + 'menu': ['Pasta'], + 'icons': [], + 'studentPrice': '3,50', + }, + ], + }, +}); diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index 08f1f1a..32f4089 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -3,6 +3,7 @@ import 'package:studyos_agent/src/mail_repository.dart'; import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; +import 'package:studyos_agent/src/public_study_tools.dart'; import 'package:studyos_agent/src/studyos_tool_catalog.dart'; import 'package:studyos_agent/src/studyos_tool_executor.dart'; @@ -59,6 +60,20 @@ void main() { expect(requestedLimit, 20); }); + test('StudyOsToolExecutor routes public study tools', () async { + final publicTools = _FakePublicStudyToolRunner('Public results'); + + final response = await const StudyOsToolExecutor().execute( + getMensaOptionsToolName, + '{"preference":"vegan"}', + _context(publicStudyTools: publicTools), + ); + + expect(response, 'Public results'); + expect(publicTools.calls, [getMensaOptionsToolName]); + expect(publicTools.arguments, ['{"preference":"vegan"}']); + }); + test( 'StudyOsToolExecutor returns explicit errors for bad tool input', () async { @@ -80,6 +95,8 @@ void main() { final toolNames = studyOsTools.map((tool) => tool.name).toSet(); expect(toolNames, contains('search_talks')); + expect(toolNames, contains(getMensaOptionsToolName)); + expect(toolNames, contains(searchCampusLocationsToolName)); expect(toolNames, contains(nativeDeviceStatusToolName)); expect(toolNames, contains(nativeSetFlashlightToolName)); expect(toolNames, contains(nativeOpenInstalledAppToolName)); @@ -141,6 +158,7 @@ StudyOsToolContext _context({ Future Function()? readSchedule, Future Function(String query, int limit)? searchTalks, NativeToolRunner? nativeTools, + PublicStudyToolRunner? publicStudyTools, }) { return StudyOsToolContext( promptContext: const PromptContext( @@ -154,9 +172,28 @@ StudyOsToolContext _context({ searchTalks: searchTalks ?? (_, _) async => '', mailTools: MailToolRunner(repository: MailRepository(), profile: null), nativeTools: nativeTools, + publicStudyTools: publicStudyTools, ); } +class _FakePublicStudyToolRunner implements PublicStudyToolRunner { + _FakePublicStudyToolRunner(this.response); + + final String response; + final calls = []; + final arguments = []; + + @override + Future execute(String toolName, String arguments) async { + calls.add(toolName); + this.arguments.add(arguments); + return response; + } + + @override + void close() {} +} + class _FakeNativeToolRunner implements NativeToolRunner { _FakeNativeToolRunner(this.response);