Skip to content

Commit 5a92837

Browse files
caswelltomclaude
andcommitted
Specific source attribution: link to exact activity page
Source pills now show the activity name (e.g. "From: Unit 3 Reading") and link directly to the activity page instead of generic "From: Course Materials" linking to the course overview. Changes: - context_builder: include cmid in course structure listing, add [SOURCE:activity:CMID] format to attribution instructions - sse.php: send modules map (cmid to url+title) in SSE meta event - chat.js: parse activity:CMID source tag, look up module in meta to build specific pill label and link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a16b0e4 commit 5a92837

File tree

5 files changed

+68
-29
lines changed

5 files changed

+68
-29
lines changed

amd/build/chat.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/chat.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/src/chat.js

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,14 @@ define([
9494
let voiceSessionRequestId = 0;
9595
/** @type {RegExp} SOLA follow-up marker parser */
9696
const NEXT_BLOCK_RE = /\n*\[SOLA_NEXT\]([\s\S]*?)\[\/SOLA_NEXT\]/;
97-
/** @type {RegExp} Source attribution tag parser */
98-
const SOURCE_TAG_RE = /\n*\[SOURCE:(page|course|general)\]/;
97+
/** @type {RegExp} Source attribution tag parser — matches [SOURCE:page], [SOURCE:course], [SOURCE:general], [SOURCE:activity:123] */
98+
const SOURCE_TAG_RE = /\n*\[SOURCE:(page|course|general|activity)(?::(\d+))?\]/;
9999
/** @type {Object<string, string>} */
100100
const SOURCE_LABELS = {
101101
page: 'From: Current Page',
102102
course: 'From: Course Materials',
103103
general: 'General Knowledge',
104+
activity: 'From: Course Materials',
104105
};
105106
/** @type {string} Local intro-dismiss key */
106107
const INTRO_DISMISSED_KEY = 'ai_course_assistant_intro_dismissed';
@@ -113,12 +114,13 @@ define([
113114
* Parse SOLA metadata markers from an assistant response.
114115
*
115116
* @param {string} text
116-
* @returns {{text:string,suggestions:Array<string>,sourceType:string|null}}
117+
* @returns {{text:string,suggestions:Array<string>,sourceType:string|null,sourceCmid:string|null}}
117118
*/
118119
const parseAssistantDecorators = function(text) {
119120
let cleanText = ((text || '') + '');
120121
let suggestions = [];
121122
let sourceType = null;
123+
let sourceCmid = null;
122124

123125
const nextMatch = cleanText.match(NEXT_BLOCK_RE);
124126
if (nextMatch) {
@@ -131,13 +133,15 @@ define([
131133
const sourceMatch = cleanText.match(SOURCE_TAG_RE);
132134
if (sourceMatch) {
133135
sourceType = sourceMatch[1];
136+
sourceCmid = sourceMatch[2] || null; // Numeric cmid for activity type.
134137
cleanText = cleanText.replace(SOURCE_TAG_RE, '').trimEnd();
135138
}
136139

137140
return {
138141
text: cleanText,
139142
suggestions: suggestions,
140143
sourceType: sourceType,
144+
sourceCmid: sourceCmid,
141145
};
142146
};
143147

@@ -192,20 +196,34 @@ define([
192196
/**
193197
* Create a source attribution pill element (clickable link or plain span).
194198
*
195-
* @param {string} sourceType 'page', 'course', or 'general'
196-
* @param {Object|null} meta SSE metadata with pageurl, courseurl, pagetitle
199+
* @param {string} sourceType 'page', 'course', 'general', or 'activity'
200+
* @param {Object|null} meta SSE metadata with pageurl, courseurl, pagetitle, modules
201+
* @param {string|null} cmid Course module ID for activity source type
197202
* @returns {HTMLElement}
198203
*/
199-
const createSourcePill = function(sourceType, meta) {
204+
const createSourcePill = function(sourceType, meta, cmid) {
200205
var href = '';
206+
var label = SOURCE_LABELS[sourceType] || sourceType;
201207
var title = '';
202-
if (sourceType === 'page' && meta && meta.pageurl) {
208+
209+
if (sourceType === 'activity' && cmid && meta && meta.modules && meta.modules[cmid]) {
210+
var mod = meta.modules[cmid];
211+
href = mod.url;
212+
label = 'From: ' + mod.title;
213+
title = mod.title;
214+
} else if (sourceType === 'page' && meta && meta.pageurl) {
203215
href = meta.pageurl;
204216
title = meta.pagetitle || '';
205-
} else if (sourceType === 'course' && meta && meta.courseurl) {
217+
} else if ((sourceType === 'course' || sourceType === 'activity') && meta && meta.courseurl) {
206218
href = meta.courseurl;
207-
title = '';
208219
}
220+
221+
var linkIcon = ' <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"'
222+
+ ' fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"'
223+
+ ' stroke-linejoin="round" style="vertical-align:-1px;margin-left:2px">'
224+
+ '<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>'
225+
+ '<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
226+
209227
var pill;
210228
if (href) {
211229
pill = document.createElement('a');
@@ -215,18 +233,12 @@ define([
215233
if (title) {
216234
pill.title = title;
217235
}
218-
// Small external link icon after label.
219-
pill.innerHTML = (SOURCE_LABELS[sourceType] || sourceType)
220-
+ ' <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"'
221-
+ ' fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"'
222-
+ ' stroke-linejoin="round" style="vertical-align:-1px;margin-left:2px">'
223-
+ '<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>'
224-
+ '<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
236+
pill.innerHTML = label + linkIcon;
225237
} else {
226238
pill = document.createElement('span');
227-
pill.textContent = SOURCE_LABELS[sourceType] || sourceType;
239+
pill.textContent = label;
228240
}
229-
pill.className = 'aica-source-pill aica-source-pill--' + sourceType;
241+
pill.className = 'aica-source-pill aica-source-pill--' + (sourceType === 'activity' ? 'course' : sourceType);
230242
return pill;
231243
};
232244

@@ -2812,9 +2824,11 @@ define([
28122824
text: (text || '') + '',
28132825
suggestions: [],
28142826
sourceType: options.sourceType || null,
2827+
sourceCmid: options.sourceCmid || null,
28152828
} : parseAssistantDecorators(text);
28162829
const displayText = parsed.text;
28172830
const sourceType = options.sourceType || parsed.sourceType;
2831+
const sourceCmid = options.sourceCmid || parsed.sourceCmid;
28182832
if (!options.skipHistory) {
28192833
recordConversationMessage('assistant', displayText, ts || Date.now());
28202834
}
@@ -2828,7 +2842,7 @@ define([
28282842
pageurl: '',
28292843
pagetitle: ''
28302844
};
2831-
el.appendChild(createSourcePill(sourceType, histMeta));
2845+
el.appendChild(createSourcePill(sourceType, histMeta, sourceCmid));
28322846
}
28332847
return el;
28342848
};
@@ -3734,6 +3748,7 @@ define([
37343748
addAssistantMsg(text, msg.timecreated ? msg.timecreated * 1000 : null, {
37353749
skipHistory: true,
37363750
sourceType: parsed.sourceType,
3751+
sourceCmid: parsed.sourceCmid,
37373752
alreadyClean: true,
37383753
});
37393754
} else {
@@ -3913,11 +3928,11 @@ define([
39133928
if (fullText) {
39143929
const parsed = parseAssistantDecorators(fullText);
39153930
UI.finishStreaming(parsed.text, (getTtsUrl() || Speech.isTTSSupported()) ? handleSpeak : null);
3916-
// Append clickable source pill using SSE metadata (page/course URLs).
3931+
// Append clickable source pill using SSE metadata (page/course URLs + modules map).
39173932
if (parsed.sourceType) {
39183933
var lastMsgEl = getLastAssistantMessageEl();
39193934
if (lastMsgEl) {
3920-
lastMsgEl.appendChild(createSourcePill(parsed.sourceType, streamMeta));
3935+
lastMsgEl.appendChild(createSourcePill(parsed.sourceType, streamMeta, parsed.sourceCmid));
39213936
}
39223937
}
39233938
recordConversationMessage('assistant', parsed.text, Date.now());
@@ -3957,7 +3972,7 @@ define([
39573972
if (parsed.sourceType) {
39583973
var errLastMsgEl = getLastAssistantMessageEl();
39593974
if (errLastMsgEl) {
3960-
errLastMsgEl.appendChild(createSourcePill(parsed.sourceType, streamMeta));
3975+
errLastMsgEl.appendChild(createSourcePill(parsed.sourceType, streamMeta, parsed.sourceCmid));
39613976
}
39623977
}
39633978
recordConversationMessage('assistant', parsed.text, Date.now());

classes/context_builder.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,13 @@ private static function build_course_topics(int $courseid): string {
299299
$line .= ": {$summary}";
300300
}
301301

302-
// Add visible activities.
302+
// Add visible activities with cmid for source attribution.
303303
$activities = [];
304304
if (!empty($modinfo->sections[$section->section])) {
305305
foreach ($modinfo->sections[$section->section] as $cmid) {
306306
$cm = $modinfo->get_cm($cmid);
307307
if ($cm->visible && $cm->has_view()) {
308-
$activities[] = "{$cm->name} ({$cm->modname})";
308+
$activities[] = "{$cm->name} ({$cm->modname}, id:{$cmid})";
309309
}
310310
}
311311
}
@@ -586,8 +586,11 @@ private static function get_source_attribution_instructions(): string {
586586
return "\n\n## Source Attribution\n"
587587
. "After your response (but BEFORE the [SOLA_NEXT] block), indicate the primary source "
588588
. "of your answer on its own line using exactly one of these tags:\n"
589-
. "[SOURCE:page] — your answer is primarily based on the current page content\n"
590-
. "[SOURCE:course] — your answer draws from other course materials (not the current page)\n"
589+
. "[SOURCE:page] — your answer is primarily based on the current page the student is viewing\n"
590+
. "[SOURCE:activity:ID] — your answer draws from a specific course activity/resource. "
591+
. "Use the numeric id from the course structure above (e.g. [SOURCE:activity:47]). "
592+
. "Always prefer this over [SOURCE:course] when you can identify the specific activity.\n"
593+
. "[SOURCE:course] — your answer draws from course materials generally but you cannot identify one specific activity\n"
591594
. "[SOURCE:general] — your answer uses general knowledge not found in the course materials\n\n"
592595
. "Always include exactly one [SOURCE:xxx] tag. Place it on its own line just before [SOLA_NEXT].";
593596
}

sse.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,28 @@ function sse_send(array $data): void {
293293
$pageurl = (new \moodle_url('/mod/' . $cm->modname . '/view.php', ['id' => $pageid]))->out(false);
294294
}
295295
}
296-
sse_send(['type' => 'meta', 'pageurl' => $pageurl, 'courseurl' => $courseurl, 'pagetitle' => $pagetitle ?? '']);
296+
// Build modules lookup map (cmid → url, title) for specific source attribution.
297+
$modulesmap = [];
298+
try {
299+
$modinfo = get_fast_modinfo($courseid);
300+
foreach ($modinfo->get_cms() as $cmobj) {
301+
if ($cmobj->uservisible && $cmobj->has_view() && !empty($cmobj->name)) {
302+
$modulesmap[(string) $cmobj->id] = [
303+
'url' => (new \moodle_url('/mod/' . $cmobj->modname . '/view.php', ['id' => $cmobj->id]))->out(false),
304+
'title' => $cmobj->name,
305+
];
306+
}
307+
}
308+
} catch (\Throwable $e) {
309+
// Non-critical; pill will fall back to generic course link.
310+
}
311+
sse_send([
312+
'type' => 'meta',
313+
'pageurl' => $pageurl,
314+
'courseurl' => $courseurl,
315+
'pagetitle' => $pagetitle ?? '',
316+
'modules' => $modulesmap,
317+
]);
297318

298319
$provider->chat_completion_stream($systemprompt, $history, function (string $chunk) use (&$fullresponse) {
299320
$fullresponse .= $chunk;

0 commit comments

Comments
 (0)