|
| 1 | +function auto_reply() |
| 2 | +%AUTO_REPLY Automated discussion reply bot for MathWorks Challenge Projects Hub |
| 3 | +% Triggered by a GitHub Action when a new comment is posted on a GitHub |
| 4 | +% Discussion. Uses OpenAI ChatGPT API to generate helpful responses to |
| 5 | +% student questions about challenge projects and the program. |
| 6 | + |
| 7 | + %% Read environment variables |
| 8 | + githubToken = getenv('GH_TOKEN'); |
| 9 | + openaiKey = getenv('OPENAI_API_KEY'); |
| 10 | + repo = getenv('REPO_FULL_NAME'); |
| 11 | + discNumber = str2double(getenv('DISCUSSION_NUMBER')); |
| 12 | + commentId = getenv('COMMENT_NODE_ID'); |
| 13 | + |
| 14 | + repoParts = strsplit(repo, '/'); |
| 15 | + owner = repoParts{1}; |
| 16 | + repoName = repoParts{2}; |
| 17 | + |
| 18 | + %% 1. Fetch discussion info and comment details via GraphQL |
| 19 | + fprintf('Fetching discussion and comment info...\n'); |
| 20 | + query = buildInfoQuery(owner, repoName, discNumber, commentId); |
| 21 | + result = githubGraphQL(query, githubToken); |
| 22 | + |
| 23 | + discussion = result.data.repository.discussion; |
| 24 | + comment = result.data.node; |
| 25 | + |
| 26 | + %% 2. Build thread context (only the relevant thread, not full discussion) |
| 27 | + [threadContext, replyToId] = buildThreadContext(comment); |
| 28 | + |
| 29 | + %% 3. Read context files |
| 30 | + mainReadme = readFileIfExists('README.md'); |
| 31 | + projectDesc = extractProjectReadme(discussion.body); |
| 32 | + wikiContent = readWikiContent('wiki'); |
| 33 | + |
| 34 | + %% 4. Extract images from latest comment and convert to base64 data URIs |
| 35 | + imageUrls = extractImageUrls(comment.body); |
| 36 | + fprintf('Found %d image(s) in comment.\n', numel(imageUrls)); |
| 37 | + imageDataUris = downloadImagesAsDataUris(imageUrls); |
| 38 | + |
| 39 | + %% 5. Build prompt and call ChatGPT |
| 40 | + fprintf('Calling OpenAI API (gpt-5.4-mini)...\n'); |
| 41 | + systemPrompt = buildSystemPrompt(mainReadme, projectDesc, wikiContent); |
| 42 | + userPrompt = buildUserPrompt(discussion, threadContext, comment.body); |
| 43 | + |
| 44 | + try |
| 45 | + aiReply = callChatGPT(systemPrompt, userPrompt, imageDataUris, openaiKey); |
| 46 | + catch ME |
| 47 | + fprintf(2, 'OpenAI API error: %s\n', ME.message); |
| 48 | + return |
| 49 | + end |
| 50 | + |
| 51 | + %% 6. Check if we should reply |
| 52 | + if startsWith(strtrim(aiReply), 'NO_REPLY') |
| 53 | + fprintf('Skipping: comment is directed at a specific person.\n'); |
| 54 | + return |
| 55 | + end |
| 56 | + |
| 57 | + %% 7. Post reply with bot attribution footer |
| 58 | + footer = sprintf(['\n\n---\n' ... |
| 59 | + '*🤖 This is an automated response. For further assistance, ' ... |
| 60 | + 'please tag a project mentor or visit the ' ... |
| 61 | + '[wiki](https://github.com/%s/wiki).*'], repo); |
| 62 | + aiReply = aiReply + string(footer); |
| 63 | + |
| 64 | + fprintf('Posting reply...\n'); |
| 65 | + try |
| 66 | + postReply(discussion.id, replyToId, char(aiReply), githubToken); |
| 67 | + fprintf('Reply posted successfully.\n'); |
| 68 | + catch ME |
| 69 | + fprintf(2, 'GitHub API error: %s\n', ME.message); |
| 70 | + end |
| 71 | +end |
| 72 | + |
| 73 | +%% ---- GraphQL Query Builder ---- |
| 74 | + |
| 75 | +function query = buildInfoQuery(owner, repoName, discNumber, commentId) |
| 76 | + query = sprintf([ ... |
| 77 | + '{ repository(owner: "%s", name: "%s") {' ... |
| 78 | + ' discussion(number: %d) { id title body }' ... |
| 79 | + ' }' ... |
| 80 | + ' node(id: "%s") {' ... |
| 81 | + ' ... on DiscussionComment {' ... |
| 82 | + ' id body author { login }' ... |
| 83 | + ' replyTo {' ... |
| 84 | + ' id body author { login }' ... |
| 85 | + ' replies(first: 100) {' ... |
| 86 | + ' nodes { id body author { login } }' ... |
| 87 | + ' }' ... |
| 88 | + ' }' ... |
| 89 | + ' }' ... |
| 90 | + ' }' ... |
| 91 | + '}'], owner, repoName, discNumber, commentId); |
| 92 | +end |
| 93 | + |
| 94 | +%% ---- Thread Context ---- |
| 95 | + |
| 96 | +function [threadContext, replyToId] = buildThreadContext(comment) |
| 97 | + if isfield(comment, 'replyTo') && isstruct(comment.replyTo) |
| 98 | + % Comment is a reply — get parent + all sibling replies |
| 99 | + parent = comment.replyTo; |
| 100 | + lines = {sprintf('**%s**: %s', parent.author.login, parent.body)}; |
| 101 | + |
| 102 | + if isfield(parent, 'replies') && isstruct(parent.replies) ... |
| 103 | + && isfield(parent.replies, 'nodes') && isstruct(parent.replies.nodes) |
| 104 | + nodes = parent.replies.nodes; |
| 105 | + for k = 1:numel(nodes) |
| 106 | + lines{end+1} = sprintf('**%s**: %s', ... |
| 107 | + nodes(k).author.login, nodes(k).body); %#ok<AGROW> |
| 108 | + end |
| 109 | + end |
| 110 | + |
| 111 | + replyToId = parent.id; |
| 112 | + threadContext = strjoin(lines, sprintf('\n---\n')); |
| 113 | + else |
| 114 | + % Top-level comment — thread is just this comment |
| 115 | + threadContext = sprintf('**%s**: %s', comment.author.login, comment.body); |
| 116 | + replyToId = comment.id; |
| 117 | + end |
| 118 | +end |
| 119 | + |
| 120 | +%% ---- File Reading Helpers ---- |
| 121 | + |
| 122 | +function content = readFileIfExists(filepath) |
| 123 | + if isfile(filepath) |
| 124 | + content = fileread(filepath); |
| 125 | + else |
| 126 | + content = ''; |
| 127 | + end |
| 128 | +end |
| 129 | + |
| 130 | +function projectDesc = extractProjectReadme(discussionBody) |
| 131 | + projectDesc = ''; |
| 132 | + if isempty(discussionBody), return; end |
| 133 | + |
| 134 | + tokens = regexp(discussionBody, ... |
| 135 | + 'https://github\.com/[^/]+/[^/]+/(?:blob|tree)/main/(projects/[^\s\)\"'']+README\.md)', ... |
| 136 | + 'tokens', 'once'); |
| 137 | + |
| 138 | + if ~isempty(tokens) |
| 139 | + filePath = urlDecode(tokens{1}); |
| 140 | + if isfile(filePath) |
| 141 | + projectDesc = fileread(filePath); |
| 142 | + fprintf('Loaded project README: %s\n', filePath); |
| 143 | + else |
| 144 | + fprintf('Project README not found locally: %s\n', filePath); |
| 145 | + end |
| 146 | + else |
| 147 | + fprintf('No project README link found in discussion body.\n'); |
| 148 | + end |
| 149 | +end |
| 150 | + |
| 151 | +function decoded = urlDecode(str) |
| 152 | + decoded = regexprep(str, '%([0-9A-Fa-f]{2})', '${char(hex2dec($1))}'); |
| 153 | +end |
| 154 | + |
| 155 | +function content = readWikiContent(wikiDir) |
| 156 | + content = ''; |
| 157 | + if ~isfolder(wikiDir), return; end |
| 158 | + files = dir(fullfile(wikiDir, '*.md')); |
| 159 | + parts = cell(1, numel(files)); |
| 160 | + for k = 1:numel(files) |
| 161 | + parts{k} = fileread(fullfile(files(k).folder, files(k).name)); |
| 162 | + end |
| 163 | + if ~isempty(parts) |
| 164 | + content = strjoin(parts, newline); |
| 165 | + end |
| 166 | + fprintf('Loaded %d wiki page(s).\n', numel(files)); |
| 167 | +end |
| 168 | + |
| 169 | +%% ---- Image Extraction ---- |
| 170 | + |
| 171 | +function urls = extractImageUrls(text) |
| 172 | + % Markdown image syntax:  |
| 173 | + mdTokens = regexp(text, '!\[.*?\]\((https?://[^\s\)]+)\)', 'tokens'); |
| 174 | + urls = cellfun(@(c) c{1}, mdTokens, 'UniformOutput', false); |
| 175 | + |
| 176 | + % HTML img tags: <img src="url"> |
| 177 | + htmlTokens = regexp(text, '<img[^>]+src="(https?://[^"]+)"', 'tokens'); |
| 178 | + htmlUrls = cellfun(@(c) c{1}, htmlTokens, 'UniformOutput', false); |
| 179 | + |
| 180 | + % Raw GitHub user-content image URLs |
| 181 | + rawTokens = regexp(text, ... |
| 182 | + '(https://user-images\.githubusercontent\.com/[^\s\)]+)', 'tokens'); |
| 183 | + rawUrls = cellfun(@(c) c{1}, rawTokens, 'UniformOutput', false); |
| 184 | + |
| 185 | + % GitHub user-attachments (newer format) |
| 186 | + attachTokens = regexp(text, ... |
| 187 | + '(https://github\.com/user-attachments/assets/[^\s\)]+)', 'tokens'); |
| 188 | + attachUrls = cellfun(@(c) c{1}, attachTokens, 'UniformOutput', false); |
| 189 | + |
| 190 | + urls = unique([urls, htmlUrls, rawUrls, attachUrls]); |
| 191 | +end |
| 192 | + |
| 193 | +%% ---- Image Download ---- |
| 194 | + |
| 195 | +function dataUris = downloadImagesAsDataUris(urls) |
| 196 | + import matlab.net.http.* |
| 197 | + import matlab.net.http.field.* |
| 198 | + |
| 199 | + dataUris = {}; |
| 200 | + for k = 1:numel(urls) |
| 201 | + try |
| 202 | + fprintf('Downloading image: %s\n', urls{k}); |
| 203 | + request = RequestMessage(RequestMethod.GET); |
| 204 | + options = HTTPOptions('ConnectTimeout', 15); |
| 205 | + response = request.send(URI(urls{k}), options); |
| 206 | + |
| 207 | + if response.StatusCode ~= 200 |
| 208 | + fprintf(2, 'Failed to download image (HTTP %d), skipping.\n', ... |
| 209 | + int32(response.StatusCode)); |
| 210 | + continue |
| 211 | + end |
| 212 | + |
| 213 | + % Get MIME type from Content-Type header |
| 214 | + ctField = response.getFields('Content-Type'); |
| 215 | + if ~isempty(ctField) |
| 216 | + mimeType = char(ctField.Value); |
| 217 | + mimeType = strtok(mimeType, ';'); % strip charset etc. |
| 218 | + else |
| 219 | + mimeType = 'image/png'; |
| 220 | + end |
| 221 | + |
| 222 | + % Base64 encode |
| 223 | + imageBytes = response.Body.Data; |
| 224 | + if ~isa(imageBytes, 'uint8') |
| 225 | + imageBytes = uint8(imageBytes); |
| 226 | + end |
| 227 | + base64Str = matlab.net.base64encode(imageBytes); |
| 228 | + |
| 229 | + dataUris{end+1} = sprintf('data:%s;base64,%s', ... |
| 230 | + strtrim(mimeType), base64Str); %#ok<AGROW> |
| 231 | + catch ME |
| 232 | + fprintf(2, 'Error downloading image: %s\n', ME.message); |
| 233 | + end |
| 234 | + end |
| 235 | + fprintf('Successfully downloaded %d/%d image(s).\n', numel(dataUris), numel(urls)); |
| 236 | +end |
| 237 | + |
| 238 | +%% ---- Prompt Construction ---- |
| 239 | + |
| 240 | +function prompt = buildSystemPrompt(mainReadme, projectDesc, wikiContent) |
| 241 | + prompt = sprintf([ ... |
| 242 | + 'You are an automated assistant for the MathWorks Challenge Projects ' ... |
| 243 | + 'Hub on GitHub. You help students with questions about their challenge ' ... |
| 244 | + 'projects and about the MathWorks Challenge Projects program.\n\n' ... |
| 245 | + 'RULES:\n' ... |
| 246 | + '1. NEVER solve the project for the student. Only provide suggestions, ' ... |
| 247 | + 'guidance, hints, and point them to relevant MATLAB/Simulink resources ' ... |
| 248 | + 'or documentation.\n' ... |
| 249 | + '2. Only answer questions related to the project or the MathWorks ' ... |
| 250 | + 'Challenge Projects program. For unrelated questions, reply with a ' ... |
| 251 | + 'single short sentence declining (e.g., "This discussion is for ' ... |
| 252 | + 'questions about the challenge project. Please post general questions ' ... |
| 253 | + 'elsewhere.") — do not elaborate.\n' ... |
| 254 | + '3. If the comment is clearly directed at a specific person (e.g., ' ... |
| 255 | + '"@john can you review this?", "Thanks @jane for your help", or a ' ... |
| 256 | + 'private back-and-forth with another user), respond with exactly: NO_REPLY\n' ... |
| 257 | + '4. If the comment references another user but asks a general question ' ... |
| 258 | + '(e.g., "regarding @user''s approach, how would I..."), answer normally.\n' ... |
| 259 | + '5. Keep responses concise but complete — aim for 3-5 sentences (around 100-150 words). ' ... |
| 260 | + 'Never restate the question. Go straight to the answer. ' ... |
| 261 | + 'Use bullet points when they are necessary and useful (e.g., listing steps or options), ' ... |
| 262 | + 'but vary your format — do not default to bullet lists for every response.\n' ... |
| 263 | + '6. You are a bot — do not pretend to be a human. Do not make promises ' ... |
| 264 | + 'about rewards, project acceptance, or timelines.\n' ... |
| 265 | + '7. If images are provided, analyze them to better understand the ' ... |
| 266 | + 'student''s question (e.g., plots, error screenshots, diagrams).\n' ... |
| 267 | + '8. When relevant, suggest specific MATLAB/Simulink functions, ' ... |
| 268 | + 'toolboxes, or documentation links.\n' ... |
| 269 | + '9. Help students with questions about MathWorks tools, including MATLAB, ' ... |
| 270 | + 'Simulink, and related toolboxes. You can explain how specific functions work, ' ... |
| 271 | + 'suggest appropriate toolboxes for their task, and point them to relevant ' ... |
| 272 | + 'MathWorks documentation (mathworks.com/help).\n\n' ... |
| 273 | + 'CONTEXT:\n\n' ... |
| 274 | + '--- Main Program Description ---\n%s\n\n' ... |
| 275 | + '--- Project Description ---\n%s\n\n' ... |
| 276 | + '--- Program Wiki ---\n%s'], ... |
| 277 | + mainReadme, projectDesc, wikiContent); |
| 278 | +end |
| 279 | + |
| 280 | +function prompt = buildUserPrompt(discussion, threadContext, latestComment) |
| 281 | + prompt = sprintf([ ... |
| 282 | + 'Discussion title: %s\n\n' ... |
| 283 | + 'Discussion description:\n%s\n\n' ... |
| 284 | + 'Thread conversation:\n%s\n\n' ... |
| 285 | + 'Latest comment (answer this):\n%s'], ... |
| 286 | + discussion.title, discussion.body, threadContext, latestComment); |
| 287 | +end |
| 288 | + |
| 289 | +%% ---- HTTP Helper ---- |
| 290 | + |
| 291 | +function data = httpPost(url, bodyStruct, authValue, timeoutSec) |
| 292 | + import matlab.net.http.* |
| 293 | + import matlab.net.http.field.* |
| 294 | + |
| 295 | + header = [ |
| 296 | + ContentTypeField(MediaType('application/json')), ... |
| 297 | + GenericField('Authorization', authValue) |
| 298 | + ]; |
| 299 | + |
| 300 | + jsonBody = jsonencode(bodyStruct); |
| 301 | + request = RequestMessage(RequestMethod.POST, header); |
| 302 | + request.Body = matlab.net.http.MessageBody(); |
| 303 | + request.Body.Payload = unicode2native(jsonBody, 'UTF-8'); |
| 304 | + |
| 305 | + options = matlab.net.http.HTTPOptions('ConnectTimeout', timeoutSec); |
| 306 | + response = request.send(matlab.net.URI(url), options); |
| 307 | + |
| 308 | + statusCode = int32(response.StatusCode); |
| 309 | + |
| 310 | + if isa(response.Body.Data, 'uint8') |
| 311 | + responseText = native2unicode(response.Body.Data, 'UTF-8'); |
| 312 | + elseif ischar(response.Body.Data) || isstring(response.Body.Data) |
| 313 | + responseText = char(response.Body.Data); |
| 314 | + else |
| 315 | + responseText = jsonencode(response.Body.Data); |
| 316 | + end |
| 317 | + |
| 318 | + if statusCode >= 400 |
| 319 | + fprintf(2, 'HTTP %d from %s\nResponse: %s\n', statusCode, url, responseText); |
| 320 | + error('httpPost:failed', 'HTTP %d: %s', statusCode, responseText); |
| 321 | + end |
| 322 | + |
| 323 | + data = jsondecode(responseText); |
| 324 | +end |
| 325 | + |
| 326 | +%% ---- OpenAI API ---- |
| 327 | + |
| 328 | +function reply = callChatGPT(systemPrompt, userPrompt, imageUrls, apiKey) |
| 329 | + sysMsg = struct('role', 'system', 'content', systemPrompt); |
| 330 | + |
| 331 | + if isempty(imageUrls) |
| 332 | + userMsg = struct('role', 'user', 'content', userPrompt); |
| 333 | + else |
| 334 | + content = cell(1, 1 + numel(imageUrls)); |
| 335 | + content{1} = struct('type', 'text', 'text', userPrompt); |
| 336 | + for k = 1:numel(imageUrls) |
| 337 | + content{k+1} = struct('type', 'image_url', ... |
| 338 | + 'image_url', struct('url', imageUrls{k})); |
| 339 | + end |
| 340 | + userMsg = struct('role', 'user'); |
| 341 | + userMsg.content = content; |
| 342 | + end |
| 343 | + |
| 344 | + body = struct(); |
| 345 | + body.model = 'gpt-5.4-mini'; |
| 346 | + body.messages = {sysMsg, userMsg}; |
| 347 | + body.max_completion_tokens = 1024; |
| 348 | + |
| 349 | + response = httpPost('https://api.openai.com/v1/chat/completions', ... |
| 350 | + body, ['Bearer ' apiKey], 120); |
| 351 | + reply = response.choices(1).message.content; |
| 352 | +end |
| 353 | + |
| 354 | +%% ---- GitHub GraphQL API ---- |
| 355 | + |
| 356 | +function result = githubGraphQL(query, token) |
| 357 | + body = struct('query', query); |
| 358 | + result = httpPost('https://api.github.com/graphql', body, ['Bearer ' token], 30); |
| 359 | +end |
| 360 | + |
| 361 | +function postReply(discussionId, replyToId, replyBody, token) |
| 362 | + mutation = [ ... |
| 363 | + 'mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {' ... |
| 364 | + ' addDiscussionComment(input: {' ... |
| 365 | + ' discussionId: $discussionId,' ... |
| 366 | + ' replyToId: $replyToId,' ... |
| 367 | + ' body: $body' ... |
| 368 | + ' }) { comment { id } }' ... |
| 369 | + '}']; |
| 370 | + |
| 371 | + payload = struct( ... |
| 372 | + 'query', mutation, ... |
| 373 | + 'variables', struct( ... |
| 374 | + 'discussionId', discussionId, ... |
| 375 | + 'replyToId', replyToId, ... |
| 376 | + 'body', replyBody)); |
| 377 | + |
| 378 | + httpPost('https://api.github.com/graphql', payload, ['Bearer ' token], 30); |
| 379 | +end |
0 commit comments