Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions iris/src/iris/domain/data/course_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from iris.domain.data.exercise_with_submissions_dto import (
ExerciseWithSubmissionsDTO,
)
from iris.domain.data.lecture_dto import PyrisLectureDTO
from iris.domain.data.programming_exercise_dto import ProgrammingLanguage


Expand Down Expand Up @@ -37,6 +38,7 @@ class CourseDTO(BaseModel):
exercises: List[ExerciseWithSubmissionsDTO] = Field(alias="exercises", default=[])
exams: List[ExamDTO] = Field(alias="exams", default=[])
competencies: List[CompetencyDTO] = Field(alias="competencies", default=[])
lectures: List[PyrisLectureDTO] = Field(alias="lectures", default=[])
student_analytics_dashboard_enabled: bool = Field(
alias="studentAnalyticsDashboardEnabled", default=False
)
4 changes: 4 additions & 0 deletions iris/src/iris/domain/status/chat_status_update_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from iris.domain.status.activity_dto import ActivityDTO
from iris.domain.status.status_update_dto import StatusUpdateDTO
from iris.domain.status.suggested_context_dto import SuggestedContextDTO


class ChatStatusUpdateDTO(StatusUpdateDTO):
Expand All @@ -18,3 +19,6 @@ class ChatStatusUpdateDTO(StatusUpdateDTO):
created_memories: List[MemoryDTO] = Field(alias="createdMemories", default=[])
activities: Optional[List[ActivityDTO]] = None
activity_seq: Optional[int] = Field(alias="activitySeq", default=None)
suggested_context: Optional[SuggestedContextDTO] = Field(
alias="suggestedContext", default=None
)
17 changes: 17 additions & 0 deletions iris/src/iris/domain/status/suggested_context_dto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from pydantic import BaseModel, ConfigDict, Field

from iris.pipeline.chat.iris_chat_mode import IrisChatMode


class SuggestedContextDTO(BaseModel):
"""Context switch requested by the agent during a chat run.

Carried on the final result status update so Artemis can move the
session's active context to the entity the student asked about.
The mode values serialize to the Artemis IrisChatMode enum names.
"""

model_config = ConfigDict(populate_by_name=True)

mode: IrisChatMode
entity_id: int = Field(alias="entityId")
5 changes: 5 additions & 0 deletions iris/src/iris/pipeline/abstract_agent_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from iris.common.timing import timed_span
from iris.common.token_usage_dto import TokenUsageDTO
from iris.domain.data.text_message_content_dto import TextMessageContentDTO
from iris.domain.status.suggested_context_dto import SuggestedContextDTO
from iris.domain.variant.abstract_variant import AbstractVariant
from iris.llm import CompletionArguments, LlmRequestHandler
from iris.llm.langchain import IrisLangchainChatModel
Expand Down Expand Up @@ -72,6 +73,9 @@ class AgentPipelineExecutionState(Generic[DTO, VARIANT]):
# it is sent to the client with the next outgoing status callback.
deferred_session_title: Optional[str]
deferred_session_title_delivered: bool
# Context switch requested by the agent via the switch_chat_context tool;
# delivered to Artemis with the final result status update.
pending_context_switch: Optional[SuggestedContextDTO]
partial_result_sender: Optional[PartialResultSender]
activity_tracker: ActivityTracker

Expand Down Expand Up @@ -663,6 +667,7 @@ def __call__(
state.start_time = start_time
state.deferred_session_title = None
state.deferred_session_title_delivered = False
state.pending_context_switch = None
state.partial_result_sender = None
state.activity_tracker = ActivityTracker(
getattr(state.callback, "activity_snapshot", lambda _items, _seq: None)
Expand Down
1 change: 1 addition & 0 deletions iris/src/iris/pipeline/chat/chat_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ def post_agent_hook(
accessed_memories=state.accessed_memory_storage,
activities=activities,
activity_seq=activity_seq,
suggested_context=state.pending_context_switch,
)
logger.info(
"Chat first result delivered | mode=%s elapsed_ms=%.0f",
Expand Down
29 changes: 29 additions & 0 deletions iris/src/iris/pipeline/prompts/templates/chat_system_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,35 @@ You can use the tools to look up information to provide accurate responses. You
Think step-by-step and use the tools if necessary to look up information to provide accurate responses.
Before invoking tools, briefly tell the user what you are about to do in their language, using at most one short sentence; do not narrate when answering directly.

{# Context Switching Block #}
{% if not mcq_parallel %}
## AUTOMATIC CONTEXT SWITCHING:
------
Every chat has one active context: a specific exercise, a specific lecture, or the course itself.
{% if chat_mode == "COURSE_CHAT" %}
The currently active context is the course "{{ course_name }}".
{% elif chat_mode == "LECTURE_CHAT" %}
The currently active context is the lecture "{{ lecture_name }}".
{% elif exercise_title %}
The currently active context is the exercise "{{ exercise_title }}".
{% endif %}
When the student's message is about a DIFFERENT exercise or lecture than the active context, you MUST call the `switch_chat_context` tool BEFORE giving your answer. Answering a question about another exercise or lecture WITHOUT calling `switch_chat_context` first is a mistake.
Follow these steps every time the student asks about another exercise or lecture:
1. Find the target's ID: use the exercise list tool for exercises, or the lecture list tool for lectures. NEVER guess an ID. Lecture content retrieval is scoped to the active lecture and does not help you find another lecture.
2. Call `switch_chat_context` with the matching mode ("PROGRAMMING_EXERCISE_CHAT", "TEXT_EXERCISE_CHAT", "LECTURE_CHAT", or "COURSE_CHAT") and the entity ID.
3. Then answer the student's question about the new context in the same response.
Comment thread
Senan04 marked this conversation as resolved.

Example: The student asks "Can you explain the Quick Sort exercise to me?" while Quick Sort is not the active context. You call the exercise list tool, see that "Quick Sort" has ID 7 and type PROGRAMMING, call `switch_chat_context` with mode "PROGRAMMING_EXERCISE_CHAT" and entity_id 7, and then explain the exercise.

Example: The student asks "What did the lecture on hashing say about collisions?" while a different lecture is the active context. You call the lecture list tool, see that "Hashing" has lecture ID 4, call `switch_chat_context` with mode "LECTURE_CHAT" and entity_id 4, then call lecture content retrieval again (it now returns content of the Hashing lecture) and answer.

Rules:
• Switch when the student asks about the other exercise or lecture. Do not switch when they merely mention it in passing or compare it with the active context.
• When the student asks a general or course-level question (study progress, planning, organization) while an exercise or lecture context is active, call `switch_chat_context` with mode "COURSE_CHAT" to return to the course context.
• If it is ambiguous which exercise or lecture the student means, ask them instead of switching.
• Do not announce the switch with more than one short sentence; the chat shows a context switch divider automatically.
{% endif %}

{# Scenarios: Programming Exercise #}
{% if programming_language %}

Expand Down
7 changes: 7 additions & 0 deletions iris/src/iris/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

# Retrieval tools
from .lecture_content_retrieval import create_tool_lecture_content_retrieval
from .lecture_list import create_tool_get_lecture_list

# MCQ generation tool
from .mcq_generation import create_tool_generate_mcq_questions
Expand All @@ -31,6 +32,9 @@
# Exercise chat tools
from .submission_details import create_tool_get_submission_details

# Context switching tool
from .switch_chat_context import create_tool_switch_chat_context

__all__ = [
# Course-related tools
"create_tool_get_course_details",
Expand All @@ -47,6 +51,7 @@
"create_tool_file_lookup",
# Retrieval tools
"create_tool_lecture_content_retrieval",
"create_tool_get_lecture_list",
"create_tool_faq_content_retrieval",
# Tutor Suggestion tools
"create_tool_get_example_solution",
Expand All @@ -55,4 +60,6 @@
"create_tool_get_simple_course_details",
# MCQ generation tool
"create_tool_generate_mcq_questions",
# Context switching tool
"create_tool_switch_chat_context",
]
51 changes: 47 additions & 4 deletions iris/src/iris/tools/chat_tool_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from iris.domain.chat.chat_pipeline_execution_dto import ChatPipelineExecutionDTO
from iris.domain.variant.variant import Variant
from iris.pipeline.abstract_agent_pipeline import AgentPipelineExecutionState
from iris.pipeline.chat.iris_chat_mode import IrisChatMode
from iris.pipeline.chat.mcq_chat_mixin import retrieve_lecture_content_for_mcq
from iris.retrieval.faq_retrieval import FaqRetrieval
from iris.retrieval.lecture.lecture_retrieval import LectureRetrieval
Expand All @@ -27,10 +28,12 @@
create_tool_get_exercise_list,
create_tool_get_exercise_problem_statement,
create_tool_get_feedbacks,
create_tool_get_lecture_list,
create_tool_get_student_exercise_metrics,
create_tool_get_submission_details,
create_tool_lecture_content_retrieval,
create_tool_repository_files,
create_tool_switch_chat_context,
)

logger = get_logger(__name__)
Expand Down Expand Up @@ -128,8 +131,19 @@ def provide_lecture_retrieval(state: State) -> Optional[Callable]:
lecture_retriever = LectureRetrieval(state.db.client, local=state.local)
state.lecture_retriever = lecture_retriever
base_url = state.dto.settings.artemis_base_url if state.dto.settings else ""
lecture_id = state.dto.lecture.id if state.dto.lecture else None
lecture_unit_id = state.dto.lecture_unit_id if state.dto.lecture else None

def scope_supplier() -> tuple[Optional[int], Optional[int]]:
"""Scope retrieval to the lecture the agent switched to, if it switched."""
switch = state.pending_context_switch
if switch is not None:
if switch.mode == IrisChatMode.LECTURE:
# The new lecture has no unit selected yet, so retrieval covers it whole.
return switch.entity_id, None
# The chat left the lecture context, so retrieval goes course-wide.
return None, None
if not state.dto.lecture:
return None, None
return state.dto.lecture.id, state.dto.lecture_unit_id

return create_tool_lecture_content_retrieval(
lecture_retriever,
Expand All @@ -139,11 +153,26 @@ def provide_lecture_retrieval(state: State) -> Optional[Callable]:
state.query_text,
state.message_history,
state.lecture_content_storage,
lecture_id=lecture_id,
lecture_unit_id=lecture_unit_id,
scope_supplier=scope_supplier,
)


def provide_lecture_list(state: State) -> Optional[Callable]:
if not state.allow_lecture_tool:
return None
if not state.dto.course.lectures:
# Indexed lecture content exists, but Artemis sent no lectures field.
# The Artemis instance is probably not updated yet; without this log
# the version skew stays invisible.
logger.warning(
"Course %d has indexed lecture content but the DTO carries no "
"lectures. The Artemis instance probably does not send the course "
"lecture list yet.",
state.dto.course.id,
)
return create_tool_get_lecture_list(state.dto.course.lectures, state.callback)


def provide_faq_retrieval(state: State) -> Optional[Callable]:
if not state.dto.course.name:
return None
Expand Down Expand Up @@ -185,6 +214,18 @@ def provide_find_similar_memories(state: State) -> Optional[Callable]:
)


# ---------------------------------------------------------------------------
# Context switching provider
# ---------------------------------------------------------------------------


def provide_switch_chat_context(state: State) -> Optional[Callable]:
def record_switch(suggested_context) -> None:
state.pending_context_switch = suggested_context

return create_tool_switch_chat_context(state.dto, record_switch)


# ---------------------------------------------------------------------------
# MCQ generation provider
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -230,6 +271,7 @@ def lecture_content_supplier() -> Optional[str]:

CHAT_TOOL_PROVIDERS: list[Callable[[State], Optional[Callable]]] = [
provide_lecture_retrieval,
provide_lecture_list,
provide_faq_retrieval,
provide_course_details,
provide_exercise_list,
Expand All @@ -245,4 +287,5 @@ def lecture_content_supplier() -> Optional[str]:
provide_memory_search,
provide_find_similar_memories,
provide_mcq_generation,
provide_switch_chat_context,
]
31 changes: 25 additions & 6 deletions iris/src/iris/tools/lecture_content_retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def create_tool_lecture_content_retrieval(
lecture_content_storage: Dict[str, Any],
lecture_id: Optional[int] = None,
lecture_unit_id: Optional[int] = None,
scope_supplier: Optional[Callable[[], tuple[Optional[int], Optional[int]]]] = None,
) -> Callable[[], str]:
"""
Create a tool that retrieves lecture content using RAG.
Expand All @@ -28,6 +29,11 @@ def create_tool_lecture_content_retrieval(
query_text: The student's query text.
history: Chat history messages.
lecture_content_storage: Storage for retrieved content.
lecture_id: Lecture the retrieval is scoped to, if any.
lecture_unit_id: Lecture unit the retrieval is scoped to, if any.
scope_supplier: Resolves the scope at call time instead of at creation
time, so a context switch during the same run redirects retrieval
to the new lecture. Overrides lecture_id and lecture_unit_id.

Returns:
Callable[[], str]: Function that returns lecture content string.
Expand All @@ -43,17 +49,27 @@ def lecture_content_retrieval() -> str:
nd return the most relevant paragraphs.
Use this if you think it can be useful to answer the student's question, or if the student explicitly asks
a question about the lecture content or slides.
Only use this once.
In a lecture chat this returns content of the active lecture only, and it
never returns lecture IDs. Use the lecture list tool to find another
lecture and its ID.
Only use this once, unless you switched the chat context in between: after
a switch, call it again to retrieve content of the new lecture.

Returns:
str: Concatenated lecture slide, transcription, and segment content.
"""
scoped_lecture_id, scoped_lecture_unit_id = (
scope_supplier()
if scope_supplier is not None
else (lecture_id, lecture_unit_id)
)

lecture_content = lecture_retriever(
query=query_text,
course_id=course_id,
chat_history=history,
lecture_id=lecture_id,
lecture_unit_id=lecture_unit_id,
lecture_id=scoped_lecture_id,
lecture_unit_id=scoped_lecture_unit_id,
base_url=base_url,
)

Expand All @@ -63,22 +79,25 @@ def lecture_content_retrieval() -> str:
result = "Lecture slide content:\n"
for paragraph in lecture_content.lecture_unit_page_chunks:
result += (
f"Lecture: {paragraph.lecture_name}, Unit: {paragraph.lecture_unit_name}, "
f"Lecture: {paragraph.lecture_name} (lecture ID {paragraph.lecture_id}), "
f"Unit: {paragraph.lecture_unit_name}, "
f"Page: {paragraph.display_page_number}"
+ f"\nContent:\n---{paragraph.page_text_content}---\n\n"
)

result += "Lecture transcription content:\n"
for paragraph in lecture_content.lecture_transcriptions:
result += (
f"Lecture: {paragraph.lecture_name}, Unit: {paragraph.lecture_unit_name}, "
f"Lecture: {paragraph.lecture_name} (lecture ID {paragraph.lecture_id}), "
f"Unit: {paragraph.lecture_unit_name}, "
f"Page: {paragraph.page_number}\nContent:\n---{paragraph.segment_text}---\n\n"
)

result += "Lecture segment content:\n"
for paragraph in lecture_content.lecture_unit_segments:
result += (
f"Lecture: {paragraph.lecture_name}, Unit: {paragraph.lecture_unit_name}, "
f"Lecture: {paragraph.lecture_name} (lecture ID {paragraph.lecture_id}), "
f"Unit: {paragraph.lecture_unit_name}, "
f"Page: {paragraph.display_page_number}"
+ f"\nContent:\n---{paragraph.segment_summary}---\n\n"
)
Expand Down
52 changes: 52 additions & 0 deletions iris/src/iris/tools/lecture_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Tool that lists the lectures of the course together with their IDs.

Lecture content retrieval stays scoped to the active lecture and reports
lecture names only, so it cannot supply the ID a context switch needs. This
tool provides the course-wide name to ID mapping instead.
"""

from typing import Callable, List, Optional

from ..domain.data.lecture_dto import PyrisLectureDTO
from ..web.status.status_update import StatusCallback


def create_tool_get_lecture_list(
lectures: Optional[List[PyrisLectureDTO]], callback: StatusCallback
) -> Callable[[], List[dict]]:
"""
Create a tool that lists the lectures of the course.

Args:
lectures: Lectures of the course as sent by Artemis.
callback: Callback for status updates.

Returns:
Callable[[], List[dict]]: Function that returns the list of lectures.
"""
del callback

def get_lecture_list() -> list[dict]:
"""
Get the list of lectures in the course, each with its lecture ID, its
name and the names of its lecture units.
Use this to find the ID of a lecture, for example before switching the
chat context to another lecture. Lecture content retrieval only returns
content of the currently active lecture and never returns lecture IDs,
so this is the only way to identify another lecture.
The list covers every lecture of the course; lecture units that are not
yet released to students are omitted.

Returns:
list[dict]: Lecture ID, lecture name and lecture unit names per lecture.
"""
return [
{
"lecture_id": lecture.id,
"lecture_name": lecture.title,
"lecture_unit_names": [unit.name for unit in lecture.units],
}
for lecture in lectures or []
]

return get_lecture_list
Loading
Loading