The Tutor

Building an AI Tutor That Answers From Course Content

⏱ 17 min

A tutor that answers from general knowledge is a worse product than no tutor at all, since a student can’t tell when it’s quietly answering from something other than the course they paid for. This lesson builds an ability that resolves a lesson or topic back to its course with learndash_get_course_id(), restricts Course 10’s retrieval pipeline to that course’s own indexed content, and only then hands the question to generate_text().

What you'll learn in this lesson
Resolving course context with learndash_get_course_id()
Turning "this lesson" into "this course" so retrieval knows what's in scope.
Tagging indexed chunks with a course_id
Extending Course 10's custom table so search can be restricted to one course.
Restricting semantic search to a single course
A one-line addition to Course 10's cosine similarity search.
Gating the ability on real enrollment
Using LearnDash's own sfwd_lms_has_access() inside permission_callback.
Prerequisites

LearnDash installed and active. Course 10’s Building a Custom Table for Embedding Storage, Semantic Search: Cosine Similarity in PHP, and Grounding an AI Assistant’s Answers in Retrieved Content already built and working against your own content.

Step 1: Add a course_id column to Course 10’s embeddings table

Course 10’s table indexes content generically. A tutor needs to know which course each chunk belongs to, so retrieval can be scoped to one course instead of the whole site:

// File: lms-tutor/class-tutor-index.php
function lms_tutor_upgrade_embeddings_table() {
	global $wpdb;
	$table = $wpdb->prefix . 'ai_chunk_embeddings';

	if ( ! in_array( 'course_id', $wpdb->get_col( "DESC {$table}", 0 ), true ) ) {
		$wpdb->query( "ALTER TABLE {$table} ADD COLUMN course_id BIGINT UNSIGNED NULL, ADD INDEX course_id (course_id)" );
	}
}

When indexing a lesson or topic, resolve and store its course alongside the chunk:

// File: lms-tutor/class-tutor-index.php
function lms_tutor_index_step( int $post_id ) {
	$course_id = learndash_get_course_id( $post_id );
	$post      = get_post( $post_id );

	if ( ! $course_id || ! $post ) {
		return;
	}

	$chunks = my_plugin_chunk_content( $post->post_content ); // From Course 10, Lesson 3.

	foreach ( $chunks as $chunk_text ) {
		$vector = WordPress\AiClient\AiClient::input( $chunk_text )->generateEmbedding()->getValues();
		my_plugin_store_chunk( $post_id, $chunk_text, $vector, $course_id ); // Extended to accept course_id.
	}
}

learndash_get_course_id( $post_id ) accepts the ID of anything belonging to a course, lesson, topic, or quiz, and returns that course’s ID, exactly the resolution step a tutor needs before it can decide what content is in scope for a given question.

Step 2: Restrict semantic search to one course

Course 10’s cosine similarity search compares a query vector against every stored row. Add a course filter so the tutor only ever searches within the course the student is actually in:

// File: lms-tutor/class-tutor-index.php
function lms_tutor_search_within_course( string $question, int $course_id, int $limit = 5 ): array {
	global $wpdb;

	$query_vector = WordPress\AiClient\AiClient::input( $question )->generateEmbedding()->getValues();
	$table        = $wpdb->prefix . 'ai_chunk_embeddings';

	$rows = $wpdb->get_results(
		$wpdb->prepare( "SELECT post_id, chunk_text, embedding FROM {$table} WHERE course_id = %d", $course_id ),
		ARRAY_A
	);

	foreach ( $rows as &$row ) {
		$row['score'] = my_plugin_cosine_similarity( $query_vector, json_decode( $row['embedding'], true ) );
	}
	unset( $row );

	usort( $rows, fn( $a, $b ) => $b['score'] <=> $a['score'] );

	return array_slice( $rows, 0, $limit );
}

The only real change from Course 10’s version is the WHERE course_id = %d clause. Everything else, embedding the question, scoring with my_plugin_cosine_similarity(), sorting and slicing, is the pipeline you already built.

Step 3: Register the ability, gated on real enrollment

sfwd_lms_has_access( int $post_id, ?int $user_id = null ) returns whether a user actually has access to a given course resource, which is the real check for “should this student be able to ask about this lesson at all”:

// File: lms-tutor/class-tutor-ability.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'lms-tutor/ask-tutor',
		array(
			'label'               => __( 'Ask the course tutor', 'lms-tutor' ),
			'description'         => __( 'Answers a student question using only that course\'s own indexed content.', 'lms-tutor' ),
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'lesson_id' => array( 'type' => 'integer' ),
					'question'  => array( 'type' => 'string' ),
				),
				'required'   => array( 'lesson_id', 'question' ),
			),
			'permission_callback' => function ( $input ) {
				return sfwd_lms_has_access( $input['lesson_id'], get_current_user_id() );
			},
			'execute_callback'    => 'lms_tutor_ask',
		)
	);
} );

Enrollment, not just being logged in, is the gate. A student with no access to a course’s lessons has no business getting AI-generated answers about its content either.

Step 4: Build the grounded, course-scoped answer

// File: lms-tutor/class-tutor-ability.php
function lms_tutor_ask( $input ) {
	$course_id = learndash_get_course_id( $input['lesson_id'] );

	if ( ! $course_id ) {
		return new WP_Error( 'no_course', 'Could not resolve a course for that lesson.' );
	}

	$retrieved = lms_tutor_search_within_course( $input['question'], $course_id );
	$context   = '';

	foreach ( $retrieved as $i => $chunk ) {
		$context .= "[Source " . ( $i + 1 ) . "]\n{$chunk['chunk_text']}\n\n";
	}

	$prompt = <<<PROMPT
You are this course's tutor, answering using only the course content below. If the
content doesn't cover the question, say plainly that this course doesn't cover that
topic. Do not use outside knowledge.

Course content:
{$context}
Question: {$input['question']}

Answer:
PROMPT;

	$answer = wp_ai_client_prompt( $prompt )->generate_text();

	return array(
		'answer'  => $answer,
		'sources' => array_unique( wp_list_pluck( $retrieved, 'post_id' ) ),
	);
}

This is Course 10’s grounded-prompt pattern with one deliberate change: the system instruction says “this course doesn’t cover that topic,” not a generic “I don’t have information,” since a student asking something genuinely outside the course material should be told that clearly rather than left guessing whether the tutor just failed.

Test it: ask an in-scope and an out-of-scope question

wp-env run cli wp eval '
$r1 = wp_get_ability( "lms-tutor/ask-tutor" )->execute( array( "lesson_id" => 12, "question" => "What does this lesson say about refunds?" ) );
echo "In scope: " . $r1["answer"] . "\n\n";

$r2 = wp_get_ability( "lms-tutor/ask-tutor" )->execute( array( "lesson_id" => 12, "question" => "What is the boiling point of water?" ) );
echo "Out of scope: " . $r2["answer"] . "\n";
'

The first should reflect real content from that course. The second should say plainly the course doesn’t cover it, not answer correctly from general knowledge, since an answer that happens to be right but didn’t come from the course material is exactly the failure mode this lesson exists to prevent.

Forgetting the course_id filter leaks other courses' paid content

If the WHERE course_id = %d clause in Step 2 is ever dropped or bypassed, the tutor silently starts retrieving chunks from every indexed course on the site, including ones the current student never paid for. This isn’t a cosmetic bug, it’s a paywall leak delivered through an AI answer instead of a page view. Test this directly: ask the tutor, scoped to a course the test student is not enrolled in some other course’s content, and confirm the retrieval step returns nothing for that course, not just that the final answer happens to look reasonable.

Recap

A tutor is only trustworthy if it answers from the specific course a student is actually in, not the whole site’s indexed content and not the model’s general training. This lesson resolved course context with learndash_get_course_id(), tagged Course 10’s embeddings with that course’s ID, restricted retrieval with a WHERE course_id = %d filter, gated the ability on real enrollment with sfwd_lms_has_access(), and built a grounded prompt that explicitly admits when a question falls outside the course. The next lesson uses the same LearnDash progress data to recommend what a student should do next.

Resources & further reading

← Where AI Actually Helps in Course and Membership Products Adaptive Learning Paths →