From c3bbd42f6140f593ec156cee2ae9697bfb816942 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 11 Aug 2025 17:09:16 +0200 Subject: [PATCH] docs: add documentation --- .../lib/chat/starguide_chat_input.dart | 19 ++++++++++++ starguide_flutter/lib/main.dart | 15 +++++++++- starguide_server/lib/server.dart | 17 +++++++---- .../lib/src/business/data_fetcher.dart | 30 +++++++++++++++++-- starguide_server/lib/src/business/search.dart | 20 +++++++++---- .../lib/src/generative_ai/generative_ai.dart | 19 ++++++++++-- .../lib/src/recaptcha/recaptcha.dart | 3 ++ 7 files changed, 106 insertions(+), 17 deletions(-) diff --git a/starguide_flutter/lib/chat/starguide_chat_input.dart b/starguide_flutter/lib/chat/starguide_chat_input.dart index f894640..50c1387 100644 --- a/starguide_flutter/lib/chat/starguide_chat_input.dart +++ b/starguide_flutter/lib/chat/starguide_chat_input.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:starguide_flutter/config/constants.dart'; +/// Text input widget used to compose chat messages. +/// +/// Displays a single-line text field and a send button that triggers [onSend] +/// when pressed or when the user submits the input field. class StarguideChatInput extends StatefulWidget { const StarguideChatInput({ super.key, @@ -13,11 +17,22 @@ class StarguideChatInput extends StatefulWidget { required this.focusNode, }); + /// Callback invoked when the user sends a message. final void Function(String message) onSend; + + /// Controller that holds the current input text. final TextEditingController textController; + + /// Whether the send button is enabled. final bool enabled; + + /// Indicates if the server is currently generating an answer. final bool isGeneratingResponse; + + /// Number of chat requests made so far. final int numChatRequests; + + /// Focus node for controlling keyboard focus. final FocusNode focusNode; @override @@ -29,6 +44,7 @@ class _StarguideChatInputState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); + // Select an appropriate hint based on the number of requests made. final String hintText; if (widget.numChatRequests >= kMaxChatRequests) { @@ -67,6 +83,7 @@ class _StarguideChatInputState extends State { ), controller: widget.textController, onSubmitted: (value) { + // Send the message when the user hits enter. widget.onSend(widget.textController.text); widget.textController.clear(); widget.focusNode.requestFocus(); @@ -74,6 +91,7 @@ class _StarguideChatInputState extends State { ), ), if (widget.isGeneratingResponse) + // Show a spinner while the server generates a reply. Container( padding: const EdgeInsets.all(10), width: 40, @@ -96,6 +114,7 @@ class _StarguideChatInputState extends State { ), onPressed: widget.enabled ? () { + // Trigger message send and clear the input field. widget.onSend(widget.textController.text); widget.textController.clear(); } diff --git a/starguide_flutter/lib/main.dart b/starguide_flutter/lib/main.dart index 16a99bd..f8ee863 100644 --- a/starguide_flutter/lib/main.dart +++ b/starguide_flutter/lib/main.dart @@ -26,10 +26,14 @@ late final Highlighter highlighterDart; late final Highlighter highlighterYaml; late final Highlighter highlighterSql; +/// Entry point for the Starguide Flutter application. +/// +/// Initializes syntax highlighters and reCAPTCHA for web before launching the +/// app widget tree. void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize the highlighter. + // Initialize the syntax highlighters used for code snippets. await Highlighter.initialize(['dart', 'yaml', 'sql']); var theme = await HighlighterTheme.loadDarkTheme(); highlighterDart = Highlighter( @@ -45,6 +49,7 @@ void main() async { theme: theme, ); + // Prepare reCAPTCHA on web platforms. if (kIsWeb) { await GRecaptchaV3.hideBadge(); await GRecaptchaV3.ready( @@ -54,6 +59,7 @@ void main() async { runApp(const StarguideApp()); } +/// Root widget that sets up the application theme. class StarguideApp extends StatelessWidget { const StarguideApp({super.key}); @@ -67,6 +73,7 @@ class StarguideApp extends StatelessWidget { } } +/// Top level chat page widget containing the main application state. class StarguideChatPage extends StatefulWidget { const StarguideChatPage({ super.key, @@ -149,10 +156,12 @@ class StarguideChatPageState extends State { kIsWeb ? (await GRecaptchaV3.execute('create_chat_session'))! : '', ); + // Ask the server and stream the response as it is generated. final responseStream = client.starguide.ask(_chatSession!, text); var accumulatedText = ''; + // Insert a placeholder message that will be updated as chunks arrive. _currentResponse = TextMessage( id: _uuid.v4(), authorId: _model.id, @@ -161,6 +170,7 @@ class StarguideChatPageState extends State { ); await _chatController.insertMessage(_currentResponse!); + // Append each chunk to the message displayed to the user. await for (final chunk in responseStream) { accumulatedText += chunk; final newMessage = _currentResponse!.copyWith(text: accumulatedText); @@ -173,6 +183,7 @@ class StarguideChatPageState extends State { _isGeneratingResponse = false; }); } catch (e) { + // Flag a connection error so a reconnect UI can be shown. setState(() { _connectionError = true; }); @@ -181,6 +192,7 @@ class StarguideChatPageState extends State { } void _handleMessageSend(String text) async { + // First show the user's message in the chat list. await _chatController.insertMessage( TextMessage( id: _uuid.v4(), @@ -190,6 +202,7 @@ class StarguideChatPageState extends State { ), ); + // Then send the message to the server. _sendMessage(text); } diff --git a/starguide_server/lib/server.dart b/starguide_server/lib/server.dart index d2a5784..d06740d 100644 --- a/starguide_server/lib/server.dart +++ b/starguide_server/lib/server.dart @@ -7,29 +7,36 @@ import 'package:starguide_server/src/web/routes/root.dart'; import 'src/generated/protocol.dart'; import 'src/generated/endpoints.dart'; +/// Bootstraps and starts the Starguide Serverpod instance. +/// +/// Sets up routes, configures the data fetcher, and then starts the +/// Serverpod service. This function is the main entrypoint of the server +/// application. void run(List args) async { - // Initialize Serverpod and connect it with your generated code. + // Initialize Serverpod and connect it with the generated protocol and + // endpoints from the `serverpod generate` command. final pod = Serverpod( args, Protocol(), Endpoints(), ); + // Prepare the data fetcher that downloads and caches external content. configureDataFetcher(); DataFetcher.instance.register(pod); - // Setup a default page at the web root. + // Setup default routes for serving a simple landing page and the web app. pod.webServer.addRoute(RouteRoot(), '/'); pod.webServer.addRoute(RouteRoot(), '/index.html'); - // Serve all files in the /static directory. + // Serve all files in the /static directory such as Flutter web builds. pod.webServer.addRoute( RouteStaticDirectory(serverDirectory: 'app', basePath: '/'), '/*', ); - // Start the server. + // Start the Serverpod backend. await pod.start(); - // Start fetching data. + // Begin periodic data fetching jobs. await DataFetcher.instance.startFetching(pod); } diff --git a/starguide_server/lib/src/business/data_fetcher.dart b/starguide_server/lib/src/business/data_fetcher.dart index fbeeb7a..8d03c49 100644 --- a/starguide_server/lib/src/business/data_fetcher.dart +++ b/starguide_server/lib/src/business/data_fetcher.dart @@ -10,13 +10,25 @@ const _futureCallIdentifier = 'DataFetcher'; const _fetchRetryDelay = Duration(minutes: 1); +/// Handles scheduled fetching and caching of external documents. +/// +/// The [DataFetcher] orchestrates downloading content from configured +/// [DataSource]s, creates [RAGDocument]s, and stores them in the database. It +/// also periodically cleans up old data. class DataFetcher { static DataFetcher? _instance; + /// Registered data sources that will be crawled for content. final List dataSources; + + /// How long a fetched document is considered fresh. final Duration cacheDuration; + + /// Duration after which old documents are removed from storage. final Duration removeOldDataAfter; + /// Configures the singleton [DataFetcher] instance. Subsequent calls have no + /// effect once the instance has been created. static void configure( List dataSources, { Duration cacheDuration = const Duration(days: 1), @@ -29,6 +41,7 @@ class DataFetcher { ); } + /// Accessor for the configured [DataFetcher] singleton. static DataFetcher get instance => _instance!; DataFetcher._({ @@ -37,15 +50,17 @@ class DataFetcher { this.removeOldDataAfter = const Duration(days: 3), }); + /// Registers the background job with Serverpod so that it can be scheduled. void register(Serverpod pod) { pod.registerFutureCall(_FetchDataFutureCall(), _futureCallName); } + /// Starts the fetching process by scheduling the first job immediately. Future startFetching(Serverpod pod) async { - // Cancel any existing future calls, to avoid duplicates. + // Cancel any existing future calls to avoid duplicates after restarts. await pod.cancelFutureCall(_futureCallIdentifier); - // Kick off the data fetcher. + // Kick off the data fetcher by scheduling the first future call now. pod.futureCallWithDelay( _futureCallName, DataFetcherTask( @@ -74,23 +89,27 @@ class DataFetcher { ) async { final genAi = GenerativeAi(); + // Generate a short description used for listing the document. session.log('Summarizing document for description.'); final shortDescription = await genAi.generateSimpleAnswer( Prompts.instance.get('summarize_document_for_description')! + rawDocument.document, ); + // Summaries are embedded to allow similarity searches. session.log('Summarizing document for embedding.'); final embeddingSummary = await genAi.generateSimpleAnswer( Prompts.instance.get('summarize_document_for_embedding')! + rawDocument.document, ); + // Create an embedding vector for the summary text. session.log('Generating embedding for summary.'); final embedding = await genAi.generateEmbedding(embeddingSummary); session.log('Embeddings generated.'); + // Build the final document object to be stored in the database. return RAGDocument( title: rawDocument.title, sourceUrl: rawDocument.sourceUrl, @@ -129,10 +148,15 @@ class DataFetcher { } } + /// Determines if a given [sourceUrl] needs to be fetched again. + /// + /// Returns `true` when the URL hasn't been cached recently, meaning new + /// content should be downloaded. Future shouldFetchUrl(Session session, Uri sourceUrl) async { session.log('Checking if should fetch url: $sourceUrl'); - // Check if the url is already in the database. + // Look up the document in the database and check if it has been fetched + // within the allowed cache duration. final document = await RAGDocument.db.findFirstRow( session, where: (t) => diff --git a/starguide_server/lib/src/business/search.dart b/starguide_server/lib/src/business/search.dart index 9c55607..6772777 100644 --- a/starguide_server/lib/src/business/search.dart +++ b/starguide_server/lib/src/business/search.dart @@ -4,6 +4,10 @@ import 'package:starguide_server/src/generated/protocol.dart'; import 'package:starguide_server/src/generative_ai/generative_ai.dart'; import 'package:starguide_server/src/generative_ai/prompts.dart'; +/// Searches the documentation RAG store for entries relevant to [question]. +/// +/// Uses generative AI to pick the most relevant documentation URLs based on +/// [conversation] context and returns the matching [RAGDocument]s. Future> searchDocumentation( Session session, List conversation, @@ -12,7 +16,7 @@ Future> searchDocumentation( final genAi = GenerativeAi(); var documents = []; - // Search documentation for the most relevant URLs. + // Ask the AI to suggest the most relevant documentation URLs. final toc = await DocsTableOfContents.getTableOfContents(session); final urls = await genAi.generateUrlList( systemPrompt: Prompts.instance.get('search_toc')! + toc, @@ -26,6 +30,7 @@ Future> searchDocumentation( ], ); + // Load the referenced documents from the database. for (final url in urls) { var document = await RAGDocument.db.findFirstRow( session, @@ -40,6 +45,10 @@ Future> searchDocumentation( return documents; } +/// Searches GitHub discussions stored in the RAG database for similar topics. +/// +/// The [question] is transformed into the same style as stored discussions and +/// then embedded to find the closest matches. Future> searchDiscussions( Session session, List conversation, @@ -47,8 +56,7 @@ Future> searchDiscussions( ) async { final genAi = GenerativeAi(); - // Transform the question to a question to what it like looks like in the - // RAG database. + // Transform the question into the expected format stored in the database. String transformedQuestion; if (conversation.isEmpty) { transformedQuestion = await genAi.generateSimpleAnswer( @@ -62,7 +70,7 @@ Future> searchDiscussions( conversation: conversation, ); - // Concatenate the answer stream. + // Concatenate the streamed answer into a single string. var answer = ''; await for (var chunk in answerStream) { answer += chunk; @@ -70,10 +78,10 @@ Future> searchDiscussions( transformedQuestion = answer; } - // Create an embedding for the question. + // Create an embedding for the transformed question. final embedding = await genAi.generateEmbedding(transformedQuestion); - // Find the most similar question in the RAG database. + // Find the most similar discussion in the database using cosine distance. final documents = await RAGDocument.db.find( session, orderBy: (rag) => rag.embedding.distanceCosine(embedding), diff --git a/starguide_server/lib/src/generative_ai/generative_ai.dart b/starguide_server/lib/src/generative_ai/generative_ai.dart index 473ebd1..76483ec 100644 --- a/starguide_server/lib/src/generative_ai/generative_ai.dart +++ b/starguide_server/lib/src/generative_ai/generative_ai.dart @@ -7,12 +7,19 @@ import 'package:starguide_server/src/generated/protocol.dart'; const String _geminiModelName = 'gemini-2.0-flash'; const String _geminiEmbeddingModelName = 'gemini-embedding-exp-03-07'; +/// Utility class wrapping access to the Gemini generative AI APIs. class GenerativeAi { final String _geminiAPIKey; + /// Creates a new [GenerativeAi] using credentials stored in Serverpod. GenerativeAi() : _geminiAPIKey = Serverpod.instance.getPassword('geminiAPIKey')!; + /// Generates a streaming conversational answer using provided context. + /// + /// [question] is the user query, [systemPrompt] provides instructions for the + /// model, and [documents] and [conversation] give additional grounding + /// context. Stream generateConversationalAnswer({ required String question, required String systemPrompt, @@ -21,7 +28,7 @@ class GenerativeAi { }) async* { final messages = []; - // Add conversation history + // Convert the existing conversation to Gemini message format. for (final chatMessage in conversation) { messages.add( Message( @@ -33,22 +40,27 @@ class GenerativeAi { ); } + // Create an agent with the system prompt and document context. final agentWithSystem = _createAgent( systemPrompt: systemPrompt + documents.map((e) => _formatDocument(e)).join('\n'), ); final response = agentWithSystem.runStream(question, messages: messages); + + // Yield the streamed answer chunk by chunk. await for (final chunk in response) { yield chunk.output; } } + /// Generates a single, non-streaming answer to [question]. Future generateSimpleAnswer(String question) async { final agent = _createAgent(); final response = await agent.run(question); return response.output; } + /// Creates an embedding vector for the given [document] text. Future generateEmbedding(String document) async { final agent = _createAgent(); final embedding = await agent.createEmbedding( @@ -58,6 +70,8 @@ class GenerativeAi { return Vector(embedding.toList()); } + /// Generates a list of URL suggestions based on the [systemPrompt] and + /// conversation context. Future> generateUrlList({ required String systemPrompt, List conversation = const [], @@ -70,7 +84,7 @@ class GenerativeAi { final messages = []; - // Add conversation history + // Convert the conversation to the provider's message objects. for (final chatMessage in conversation) { messages.add( Message( @@ -82,6 +96,7 @@ class GenerativeAi { ); } + // Ask the model for a structured list of URLs. final response = await agent.runFor<_UrlList>( systemPrompt, messages: messages, diff --git a/starguide_server/lib/src/recaptcha/recaptcha.dart b/starguide_server/lib/src/recaptcha/recaptcha.dart index c16bf95..b525c4a 100644 --- a/starguide_server/lib/src/recaptcha/recaptcha.dart +++ b/starguide_server/lib/src/recaptcha/recaptcha.dart @@ -11,7 +11,9 @@ Future verifyRecaptchaToken( required String token, required String expectedAction, }) async { + // Endpoint used to validate tokens with Google. final uri = Uri.parse('https://www.google.com/recaptcha/api/siteverify'); + // Secret key configured in Serverpod passwords file. final secret = Serverpod.instance.getPassword('recaptchaSecretKey')!; final response = await http.post( @@ -33,6 +35,7 @@ Future verifyRecaptchaToken( throw RecaptchaException(); } + // Parse the JSON response and extract verification details. final Map result = json.decode(response.body); final bool success = result['success'] ?? false; final double score = (result['score'] ?? 0.0).toDouble();