Skip to content

Commit cc550e3

Browse files
Add MATLAB-based auto-reply chatbot for project discussions using ChatGPT API
Amp-Thread-ID: https://ampcode.com/threads/T-019d4548-bea6-705d-9c5d-416de2efa97b Co-authored-by: Amp <amp@ampcode.com>
1 parent eab1c1c commit cc550e3

2 files changed

Lines changed: 414 additions & 0 deletions

File tree

.github/scripts/auto_reply.m

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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: ![alt](url)
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

Comments
 (0)