Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ flutter run -d chrome \
--dart-define=STUDYOS_DEMO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
```

### Assistant tool privacy

StudyOS executes university tools in the app for both on-device and cloud
models. Portal credentials, cookies, SAML fields, session keys, and raw HTML
remain on the device. When a cloud model requests `get_tasks` or
`get_deadlines`, the app sends only the sanitized tool result (such as titles,
courses, dates, and source links) back to the configured AI service so it can
answer the question. Use the on-device model when those results must not leave
the device.

## Migration Notes

- Flutter owns the main chat UI, input bar, status display, navigation, and
Expand Down
8 changes: 8 additions & 0 deletions flutter_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ when the current SDK/device supports the `FoundationModels` framework.

Unsupported native features should return explicit platform errors rather than
mock responses.

## Assistant Tool Privacy

The shared Dart catalog exposes public Mensa and Tübingen location tools to
both local and cloud assistants. Authenticated `get_tasks` and `get_deadlines`
tools use ephemeral on-device ILIAS/Moodle sessions and are advertised only to
the local assistant. Passwords, cookies, SAML fields, Moodle session keys, and
raw portal HTML must never enter prompts, tool results, traces, or caches.
22 changes: 22 additions & 0 deletions flutter_app/lib/src/academic_models.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import 'private_study_models.dart' show safePortalTarget;

class AcademicEntry {
const AcademicEntry({
required this.category,
required this.title,
this.status,
this.detail,
this.eventType,
this.number,
this.semester,
this.scheduleText,
this.detailUrl,
this.attempt,
});

final String category;
final String title;
final String? status;
final String? detail;
final String? eventType;
final String? number;
final String? semester;
final String? scheduleText;
final String? detailUrl;
final String? attempt;

Map<String, Object?> toJson() => <String, Object?>{
'category': category,
'title': title,
if (status != null) 'status': status,
if (detail != null) 'detail': detail,
if (eventType != null) 'eventType': eventType,
if (number != null) 'number': number,
if (semester != null) 'semester': semester,
if (scheduleText != null) 'scheduleText': scheduleText,
if (detailUrl != null) 'detailUrl': safePortalTarget(detailUrl!),
if (attempt != null) 'attempt': attempt,
};
}

Expand All @@ -25,10 +45,12 @@ class AcademicStatusSnapshot {
required this.entries,
required this.refreshedAt,
this.notice,
this.availableTerms = const <String>[],
});

final String? term;
final List<AcademicEntry> entries;
final DateTime refreshedAt;
final String? notice;
final List<String> availableTerms;
}
5 changes: 5 additions & 0 deletions flutter_app/lib/src/agent_llm_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'models.dart';
import 'native_bridge.dart';
import 'native_tool_router.dart';
import 'prompt_context.dart';
import 'private_study_tools.dart';
import 'public_study_tools.dart';
import 'studyos_tool_catalog.dart';
import 'studyos_tool_executor.dart';
Expand All @@ -33,6 +34,7 @@ class AgentLlmRequest {
required this.mailTools,
required this.onToolTrace,
this.publicStudyTools,
this.privateStudyTools,
this.onDelta,
this.cancelToken,
});
Expand All @@ -50,6 +52,7 @@ class AgentLlmRequest {
final Future<String> Function(String query, int limit) searchTalks;
final MailToolRunner mailTools;
final PublicStudyToolRunner? publicStudyTools;
final PrivateStudyToolRunner? privateStudyTools;
final void Function(ToolTrace trace) onToolTrace;

/// Optional sink for streamed reply fragments. Cloud uses it for SSE; the
Expand Down Expand Up @@ -154,6 +157,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider {
mailTools: request.mailTools,
nativeTools: nativeTools,
publicStudyTools: request.publicStudyTools,
privateStudyTools: request.privateStudyTools,
);

for (var round = 0; round < _maxToolRounds; round += 1) {
Expand Down Expand Up @@ -342,6 +346,7 @@ class CloudLlmProvider implements AgentLlmProvider {
searchTalks: request.searchTalks,
mailTools: request.mailTools,
publicStudyTools: request.publicStudyTools,
privateStudyTools: request.privateStudyTools,
onToolTrace: request.onToolTrace,
onDelta: request.onDelta,
cancelToken: request.cancelToken,
Expand Down
3 changes: 3 additions & 0 deletions flutter_app/lib/src/agent_message_sender.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'memory_store.dart';
import 'models.dart';
import 'native_bridge.dart';
import 'prompt_context.dart';
import 'private_study_tools.dart';
import 'public_study_tools.dart';

Future<String> _unavailableTalks(String query, int limit) async =>
Expand All @@ -29,6 +30,7 @@ Future<String> sendAgentMessage({
_unavailableTalks,
required MailToolRunner mailTools,
required PublicStudyToolRunner publicStudyTools,
required PrivateStudyToolRunner privateStudyTools,
required void Function(ToolTrace trace) onToolTrace,
AgentStreamSink? onDelta,
AgentCancelToken? cancelToken,
Expand Down Expand Up @@ -57,6 +59,7 @@ Future<String> sendAgentMessage({
searchTalks: searchTalks,
mailTools: mailTools,
publicStudyTools: publicStudyTools,
privateStudyTools: privateStudyTools,
onDelta: onDelta,
cancelToken: cancelToken,
);
Expand Down
3 changes: 3 additions & 0 deletions flutter_app/lib/src/agent_request_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'models.dart';
import 'native_bridge.dart';
import 'native_tool_router.dart';
import 'prompt_context.dart';
import 'private_study_tools.dart';
import 'public_study_tools.dart';

Future<String> _unavailableAcademicStatus() async =>
Expand Down Expand Up @@ -55,6 +56,7 @@ class AgentRequestRunner {
_unavailableTalks,
required MailToolRunner mailTools,
PublicStudyToolRunner? publicStudyTools,
PrivateStudyToolRunner? privateStudyTools,
AgentStreamSink? onDelta,
AgentCancelToken? cancelToken,
}) async {
Expand All @@ -74,6 +76,7 @@ class AgentRequestRunner {
searchTalks: searchTalks,
mailTools: mailTools,
publicStudyTools: publicStudyTools,
privateStudyTools: privateStudyTools,
onToolTrace: onToolTrace,
onDelta: onDelta,
cancelToken: cancelToken,
Expand Down
177 changes: 163 additions & 14 deletions flutter_app/lib/src/alma_academic_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,34 +113,86 @@ class AlmaAcademicClient {
}
}

final _headingPattern = RegExp(r'^(Veranstaltung|Prüfung):\s*(.+)$');
final _codePattern = RegExp(
r'^([A-ZÄÖÜ]+[A-ZÄÖÜ0-9-]*\d+[A-Z]*|GTCNEURO)\s+(.+)$',
);
final _embeddedCodePattern = RegExp(
r'([A-ZÄÖÜ]+[A-ZÄÖÜ0-9-]*\d+[A-Z]*|GTCNEURO)\s+(.+)$',
);
final _scheduleNoisePattern = RegExp(
r'\b(?:Status|Aktionen|Details anzeigen|Informationen zu Belegzeiträumen|'
r'Ab-/Ummelden|Raumdetails für .+? anzeigen)\b',
);

AcademicStatusSnapshot parseAcademicStatus(
String html, {
required DateTime now,
}) {
final document = html_parser.parse(html);
final term = document
.querySelector('select[name*="termPeriodDropDownList"] option[selected]')
?.text
.trim();
final scope =
document.getElementById('studentOverviewForm') ??
document.documentElement;

final availableTerms = <String>[];
String? selectedTerm;
final select = scope?.querySelector('select[name*="termPeriodDropDownList"]');
if (select != null) {
for (final option in select.querySelectorAll('option')) {
final label = _clean(option.text);
final value = (option.attributes['value'] ?? '').trim();
if (label.isEmpty || value.isEmpty) continue;
availableTerms.add(label);
if (option.attributes.containsKey('selected')) selectedTerm = label;
}
}

final preorder = <Element>[];
void collect(Element element) {
preorder.add(element);
for (final child in element.children) {
collect(child);
}
}

if (scope != null) collect(scope);

final entries = <AcademicEntry>[];
for (final heading in document.querySelectorAll('h2')) {
final text = _clean(heading.text);
final match = RegExp(r'^(Veranstaltung|Prüfung):\s*(.+)$').firstMatch(text);
for (var index = 0; index < preorder.length; index++) {
final element = preorder[index];
if (element.localName != 'h2') continue;
final match = _headingPattern.firstMatch(_clean(element.text));
if (match == null) continue;
final tableText = _clean(
heading.parent?.querySelector('table')?.text ?? '',
final category = match.group(1)!;
final table = _followingTable(preorder, index);
if (table == null) continue;
final scheduleText = _scheduleText(table);
final (eventType, number, title) = _entryIdentity(
category,
match.group(2)!,
scheduleText,
);
final statusText = _statusText(table);
final semester = _afterLabel(statusText, 'Semester der Leistung');
entries.add(
AcademicEntry(
category: match.group(1)!,
title: match.group(2)!,
status: _afterLabel(tableText, 'Ihr aktueller Status'),
detail: _afterLabel(tableText, 'Semester der Leistung'),
category: category,
title: title,
eventType: eventType,
number: number,
status: _afterLabel(statusText, 'Ihr aktueller Status'),
semester: semester,
detail: semester,
scheduleText: scheduleText,
detailUrl: _detailUrl(table),
attempt: _afterLabel(statusText, 'Versuch (gilt nur für Prüfungen)'),
),
);
}

return AcademicStatusSnapshot(
term: term,
term: selectedTerm,
availableTerms: availableTerms,
entries: entries,
refreshedAt: now,
notice: entries.isEmpty
Expand All @@ -149,6 +201,103 @@ AcademicStatusSnapshot parseAcademicStatus(
);
}

Element? _followingTable(List<Element> preorder, int fromIndex) {
for (var index = fromIndex + 1; index < preorder.length; index++) {
if (preorder[index].localName == 'table') return preorder[index];
}
return null;
}

(String?, String?, String) _entryIdentity(
String category,
String headingTitle,
String? scheduleText,
) {
if (category == 'Prüfung') {
final (number, fallbackTitle) = _splitCode(headingTitle);
return (category, number, _examTitle(scheduleText) ?? fallbackTitle);
}
final cleaned = _clean(headingTitle);
final embedded = _embeddedCodePattern.firstMatch(cleaned);
if (embedded == null) {
final (number, title) = _splitCode(headingTitle);
return (category, number, title);
}
final eventType = _clean(cleaned.substring(0, embedded.start));
return (
eventType.isEmpty ? category : eventType,
embedded.group(1),
embedded.group(2)!,
);
}

(String?, String) _splitCode(String value) {
final cleaned = _clean(value);
final match = _codePattern.firstMatch(cleaned);
if (match == null) return (null, cleaned);
return (match.group(1), match.group(2)!);
}

String? _examTitle(String? scheduleText) {
if (scheduleText == null || scheduleText.isEmpty) return null;
var value = scheduleText.replaceFirst(
RegExp(r'^\d+\.\s*Parallelgruppe\s+'),
'',
);
value = value
.split(
RegExp(
r'\s+(?:Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)'
r'\s+\d{2}\.\d{2}\.\d{2}\b',
),
)
.first;
value = value
.split(
RegExp(r'\s+(?:Keine Uhrzeit festgelegt|Prüfungsform:|Prüfer/-in:)'),
)
.first;
final cleaned = _clean(value);
return cleaned.isEmpty ? null : cleaned;
}

String? _scheduleText(Element table) {
final candidates = table
.querySelectorAll('td')
.map((cell) => _clean(cell.text))
.toList();
if (candidates.isEmpty) return null;
final datePattern = RegExp(r'\b\d{2}\.\d{2}\.\d{2}\b');
final value = candidates.firstWhere(
(candidate) =>
!candidate.contains('Ihr aktueller Status:') &&
(candidate.contains('Parallelgruppe') ||
candidate.contains('Prüfungsform:') ||
datePattern.hasMatch(candidate)),
orElse: () => candidates.first,
);
final cleaned = _clean(value.replaceAll(_scheduleNoisePattern, ' '));
return cleaned.isEmpty ? null : cleaned;
}

String _statusText(Element table) {
for (final cell in table.querySelectorAll('td')) {
final value = _clean(cell.text);
if (value.contains('Ihr aktueller Status:')) return value;
}
return _clean(table.text);
}

String? _detailUrl(Element table) {
for (final anchor in table.querySelectorAll('a')) {
final href = anchor.attributes['href'];
if (href != null && href.contains('_flowId=detailView-flow')) {
return Uri.parse('${AlmaWebSession.baseUrl}/').resolve(href).toString();
}
}
return null;
}

AcademicStatusSnapshot parseAcademicReport(
String text, {
required DateTime now,
Expand Down
Loading