Skip to content

Commit b6064d0

Browse files
authored
Iris: Synchronize lecture metadata and visibility (#708)
1 parent fff5ced commit b6064d0

27 files changed

Lines changed: 2879 additions & 226 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from pydantic import BaseModel, ConfigDict, Field
2+
3+
4+
class LectureUnitMetadataUpdateDTO(BaseModel):
5+
"""Lightweight lecture-unit metadata payload sent by Artemis."""
6+
7+
model_config = ConfigDict(populate_by_name=True)
8+
9+
lecture_unit_id: int = Field(alias="lectureUnitId")
10+
lecture_unit_name: str = Field(default="", alias="lectureUnitName")
11+
lecture_unit_link: str = Field(default="", alias="lectureUnitLink")
12+
lecture_id: int = Field(alias="lectureId")
13+
lecture_name: str = Field(default="", alias="lectureName")
14+
course_id: int = Field(alias="courseId")
15+
course_name: str = Field(default="", alias="courseName")
16+
course_description: str = Field(default="", alias="courseDescription")
17+
video_link: str = Field(default="", alias="videoLink")
18+
base_url: str = Field(alias="baseUrl")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
2+
3+
4+
class SlideVisibilityDTO(BaseModel):
5+
"""Visibility state for one PDF slide."""
6+
7+
model_config = ConfigDict(populate_by_name=True)
8+
9+
slide_number: int = Field(alias="slideNumber", ge=1)
10+
hidden_until: AwareDatetime | None = Field(default=None, alias="hiddenUntil")
11+
12+
13+
class LectureUnitVisibilityUpdateDTO(BaseModel):
14+
"""Lightweight lecture-unit visibility payload sent by Artemis."""
15+
16+
model_config = ConfigDict(populate_by_name=True)
17+
18+
lecture_unit_id: int = Field(alias="lectureUnitId")
19+
lecture_id: int = Field(alias="lectureId")
20+
course_id: int = Field(alias="courseId")
21+
base_url: str = Field(alias="baseUrl")
22+
release_date: AwareDatetime | None = Field(default=None, alias="releaseDate")
23+
slides: list[SlideVisibilityDTO] = Field(default_factory=list)

iris/src/iris/pipeline/chat/mcq_chat_mixin.py

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
from iris.domain.status.activity_dto import ActivityKind
1616
from iris.pipeline.shared.mcq_generation_pipeline import McqGenerationPipeline
1717
from iris.retrieval.lecture.lecture_retrieval_utils import should_allow_lecture_tool
18+
from iris.retrieval.lecture.lecture_visibility import (
19+
is_slide_visible,
20+
is_unit_released,
21+
)
1822
from iris.vector_database.lecture_unit_page_chunk_schema import (
1923
LectureUnitPageChunkSchema,
2024
)
@@ -23,6 +27,10 @@
2327
logger = get_logger(__name__)
2428

2529
_MAX_MCQ_COUNT = 10
30+
_MCQ_CHUNK_PAGE_SIZE = 100
31+
_MAX_MCQ_CANDIDATES = 10_000
32+
_MAX_MCQ_VISIBLE_CHUNKS = 50
33+
_MAX_MCQ_CONTENT_CHARS = 40_000
2634

2735

2836
def detect_mcq_intent(user_message: str) -> tuple[bool, int]:
@@ -74,6 +82,7 @@ def detect_mcq_intent(user_message: str) -> tuple[bool, int]:
7482
def retrieve_lecture_content_for_mcq(
7583
db: Any,
7684
course_id: int,
85+
base_url: str,
7786
lecture_id: Optional[int] = None,
7887
allow_lecture_tool: Optional[bool] = None,
7988
) -> tuple[Optional[str], list[dict]]:
@@ -85,6 +94,7 @@ def retrieve_lecture_content_for_mcq(
8594
Args:
8695
db: The Weaviate database client wrapper.
8796
course_id: ID of the course.
97+
base_url: Artemis instance URL used to isolate colliding local IDs.
8898
lecture_id: Optional lecture ID to narrow results.
8999
allow_lecture_tool: Pre-computed lecture availability flag (e.g. from
90100
``prepare_state``). Pass it to skip the redundant Weaviate check.
@@ -100,74 +110,118 @@ def retrieve_lecture_content_for_mcq(
100110
chunk_filter = Filter.by_property(
101111
LectureUnitPageChunkSchema.COURSE_ID.value
102112
).equal(course_id)
113+
chunk_filter &= Filter.by_property(
114+
LectureUnitPageChunkSchema.BASE_URL.value
115+
).equal(base_url)
103116

104117
if lecture_id is not None:
105118
chunk_filter &= Filter.by_property(
106119
LectureUnitPageChunkSchema.LECTURE_ID.value
107120
).equal(lecture_id)
108121

109-
chunks = db.lectures.query.fetch_objects(
110-
filters=chunk_filter,
111-
return_properties=[
112-
LectureUnitPageChunkSchema.LECTURE_UNIT_ID.value,
113-
LectureUnitPageChunkSchema.LECTURE_ID.value,
114-
LectureUnitPageChunkSchema.PAGE_NUMBER.value,
115-
LectureUnitPageChunkSchema.PAGE_TEXT_CONTENT.value,
116-
],
117-
)
118-
119-
if not chunks.objects:
120-
return None, []
121-
122122
unit_filter = Filter.by_property(LectureUnitSchema.COURSE_ID.value).equal(
123123
course_id
124124
)
125+
unit_filter &= Filter.by_property(LectureUnitSchema.BASE_URL.value).equal(
126+
base_url
127+
)
125128
unit_results = db.lecture_units.query.fetch_objects(
126129
filters=unit_filter,
130+
limit=10_000,
127131
return_properties=[
128132
LectureUnitSchema.LECTURE_UNIT_ID.value,
129133
LectureUnitSchema.LECTURE_NAME.value,
130134
LectureUnitSchema.LECTURE_UNIT_NAME.value,
135+
LectureUnitSchema.RELEASE_DATE.value,
136+
LectureUnitSchema.BASE_URL.value,
131137
],
132138
)
133-
unit_name_map: dict[int, dict] = {}
139+
unit_name_map: dict[tuple[str, int], dict] = {}
134140
for obj in unit_results.objects:
135141
props = obj.properties
136142
lu_id = props.get(LectureUnitSchema.LECTURE_UNIT_ID.value)
137-
if lu_id is not None:
138-
unit_name_map[lu_id] = {
143+
unit_base_url = props.get(LectureUnitSchema.BASE_URL.value)
144+
if lu_id is not None and unit_base_url == base_url:
145+
unit_name_map[(unit_base_url, lu_id)] = {
139146
"lecture_name": props.get(LectureUnitSchema.LECTURE_NAME.value, ""),
140147
"unit_name": props.get(
141148
LectureUnitSchema.LECTURE_UNIT_NAME.value, ""
142149
),
150+
"released": is_unit_released(props),
143151
}
144152

145-
content = ""
153+
content_parts: list[str] = []
154+
content_length = 0
155+
visible_chunk_count = 0
146156
units_data: dict[int, dict] = {}
147-
for obj in chunks.objects:
148-
props = obj.properties
149-
lu_id = props.get(LectureUnitPageChunkSchema.LECTURE_UNIT_ID.value)
150-
page = props.get(LectureUnitPageChunkSchema.PAGE_NUMBER.value, 1)
151-
text = props.get(LectureUnitPageChunkSchema.PAGE_TEXT_CONTENT.value, "")
152-
names = unit_name_map.get(lu_id, {})
153-
lecture_name = names.get("lecture_name", "")
154-
unit_name = names.get("unit_name", "")
155-
156-
if text:
157-
content += (
157+
offset = 0
158+
while (
159+
offset < _MAX_MCQ_CANDIDATES
160+
and visible_chunk_count < _MAX_MCQ_VISIBLE_CHUNKS
161+
and content_length < _MAX_MCQ_CONTENT_CHARS
162+
):
163+
page_limit = min(_MCQ_CHUNK_PAGE_SIZE, _MAX_MCQ_CANDIDATES - offset)
164+
chunks = db.lectures.query.fetch_objects(
165+
filters=chunk_filter,
166+
limit=page_limit,
167+
offset=offset,
168+
return_properties=[
169+
LectureUnitPageChunkSchema.LECTURE_UNIT_ID.value,
170+
LectureUnitPageChunkSchema.LECTURE_ID.value,
171+
LectureUnitPageChunkSchema.PAGE_NUMBER.value,
172+
LectureUnitPageChunkSchema.PAGE_TEXT_CONTENT.value,
173+
LectureUnitPageChunkSchema.HIDDEN_UNTIL.value,
174+
LectureUnitPageChunkSchema.BASE_URL.value,
175+
],
176+
)
177+
if not chunks.objects:
178+
break
179+
180+
for obj in chunks.objects:
181+
props = obj.properties
182+
lu_id = props.get(LectureUnitPageChunkSchema.LECTURE_UNIT_ID.value)
183+
chunk_base_url = props.get(LectureUnitPageChunkSchema.BASE_URL.value)
184+
if chunk_base_url != base_url:
185+
continue
186+
page = props.get(LectureUnitPageChunkSchema.PAGE_NUMBER.value, 1)
187+
text = props.get(LectureUnitPageChunkSchema.PAGE_TEXT_CONTENT.value, "")
188+
names = unit_name_map.get((chunk_base_url, lu_id), {})
189+
if (
190+
not text
191+
or not names.get("released", False)
192+
or not is_slide_visible(props)
193+
):
194+
continue
195+
lecture_name = names.get("lecture_name", "")
196+
unit_name = names.get("unit_name", "")
197+
fragment = (
158198
f"Lecture: {lecture_name}, Unit: {unit_name}, "
159199
f"Page {page}\n{text}\n\n"
160200
)
161-
162-
if lu_id is not None:
163-
if lu_id not in units_data:
164-
units_data[lu_id] = {
165-
"lecture_unit_id": lu_id,
166-
"lecture_name": lecture_name,
167-
"unit_name": unit_name,
168-
"pages": set(),
169-
}
170-
units_data[lu_id]["pages"].add(page)
201+
remaining = _MAX_MCQ_CONTENT_CHARS - content_length
202+
fragment = fragment[:remaining]
203+
content_parts.append(fragment)
204+
content_length += len(fragment)
205+
visible_chunk_count += 1
206+
207+
if lu_id is not None:
208+
if lu_id not in units_data:
209+
units_data[lu_id] = {
210+
"lecture_unit_id": lu_id,
211+
"lecture_name": lecture_name,
212+
"unit_name": unit_name,
213+
"pages": set(),
214+
}
215+
units_data[lu_id]["pages"].add(page)
216+
if (
217+
visible_chunk_count >= _MAX_MCQ_VISIBLE_CHUNKS
218+
or content_length >= _MAX_MCQ_CONTENT_CHARS
219+
):
220+
break
221+
222+
offset += len(chunks.objects)
223+
if len(chunks.objects) < page_limit:
224+
break
171225

172226
lecture_units_meta = []
173227
for data in units_data.values():
@@ -176,6 +230,7 @@ def retrieve_lecture_content_for_mcq(
176230
del data["pages"]
177231
lecture_units_meta.append(data)
178232

233+
content = "".join(content_parts)
179234
return (content if content.strip() else None), lecture_units_meta
180235
except Exception as e:
181236
logger.warning("Failed to fetch lecture summaries for MCQ: %s", str(e))
@@ -244,10 +299,12 @@ def mcq_pre_agent_hook(
244299

245300
user_message = get_text_of_latest_user_message(state)
246301
count = getattr(state, "mcq_count", 1)
302+
execution_settings = getattr(state.dto, "settings", None)
247303

248304
lecture_content, _ = retrieve_lecture_content_for_mcq(
249305
db,
250306
course_id,
307+
execution_settings.artemis_base_url if execution_settings else "",
251308
lecture_id=lecture_id,
252309
allow_lecture_tool=getattr(state, "allow_lecture_tool", None),
253310
)

iris/src/iris/pipeline/lecture_ingestion_pipeline.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
import tempfile
66
import threading
7+
from datetime import datetime
78
from typing import Optional
89

910
import fitz
@@ -36,6 +37,10 @@
3637
LectureUnitPageChunkSchema,
3738
init_lecture_unit_page_chunk_schema,
3839
)
40+
from ..vector_database.lecture_unit_schema import (
41+
LectureUnitSchema,
42+
init_lecture_unit_schema,
43+
)
3944
from ..web.status import ingestion_status_callback
4045
from . import Pipeline
4146

@@ -126,6 +131,7 @@ def create_page_data(
126131
course_language,
127132
base_url,
128133
display_page_number,
134+
hidden_until=None,
129135
):
130136
"""
131137
Create and return a list of dictionnaries to be ingested in the Vector Database.
@@ -141,6 +147,7 @@ def create_page_data(
141147
LectureUnitPageChunkSchema.PAGE_TEXT_CONTENT.value: page_split.page_content,
142148
LectureUnitPageChunkSchema.BASE_URL.value: base_url,
143149
LectureUnitPageChunkSchema.PAGE_VERSION.value: lecture_unit_dto.attachment_version,
150+
LectureUnitPageChunkSchema.HIDDEN_UNTIL.value: hidden_until,
144151
}
145152
for page_split in page_splits
146153
]
@@ -176,6 +183,7 @@ def __init__(
176183
):
177184
super().__init__(implementation_id=self.PIPELINE_ID)
178185
self.collection = init_lecture_unit_page_chunk_schema(client)
186+
self.lecture_unit_collection = init_lecture_unit_schema(client)
179187
self.dto = dto
180188
self.callback = callback
181189
chat_model = variant.model("chat", local)
@@ -190,6 +198,7 @@ def __init__(
190198
self.pipeline = self.llm | StrOutputParser()
191199
self.tokens = []
192200
self.course_language = None
201+
self._hidden_until_by_page: dict[int, object] = {}
193202

194203
@observe(name="Lecture Unit Page Ingestion Pipeline")
195204
def __call__(self) -> (str, []):
@@ -212,6 +221,7 @@ def __call__(self) -> (str, []):
212221
self.callback.update()
213222
return self.course_language, self.tokens
214223
self.callback.update()
224+
self._load_existing_slide_visibility()
215225
self.delete_lecture_unit(
216226
self.dto.lecture_unit.course_id,
217227
self.dto.lecture_unit.lecture_id,
@@ -283,6 +293,56 @@ def _get_page_chunk_filter(self):
283293
).equal(self.dto.lecture_unit.lecture_unit_id)
284294
return page_chunk_filter
285295

296+
def _load_existing_slide_visibility(self) -> None:
297+
"""Preserve visibility when full ingestion replaces page chunks."""
298+
units = self.lecture_unit_collection.query.fetch_objects(
299+
filters=Filter.all_of(
300+
[
301+
Filter.by_property(LectureUnitSchema.BASE_URL.value).equal(
302+
self.dto.settings.artemis_base_url
303+
),
304+
Filter.by_property(LectureUnitSchema.COURSE_ID.value).equal(
305+
self.dto.lecture_unit.course_id
306+
),
307+
Filter.by_property(LectureUnitSchema.LECTURE_ID.value).equal(
308+
self.dto.lecture_unit.lecture_id
309+
),
310+
Filter.by_property(LectureUnitSchema.LECTURE_UNIT_ID.value).equal(
311+
self.dto.lecture_unit.lecture_unit_id
312+
),
313+
]
314+
),
315+
limit=1,
316+
).objects
317+
if units:
318+
serialized = units[0].properties.get(
319+
LectureUnitSchema.SLIDE_VISIBILITY.value
320+
)
321+
if serialized is not None:
322+
snapshot = json.loads(serialized)
323+
self._hidden_until_by_page = {
324+
int(page_number): (
325+
datetime.fromisoformat(hidden_until)
326+
if hidden_until is not None
327+
else None
328+
)
329+
for page_number, hidden_until in snapshot.items()
330+
}
331+
return
332+
333+
chunks = self.collection.query.fetch_objects(
334+
filters=self._get_page_chunk_filter(), limit=10_000
335+
).objects
336+
self._hidden_until_by_page = {}
337+
for chunk in chunks:
338+
page_number = int(
339+
chunk.properties[LectureUnitPageChunkSchema.PAGE_NUMBER.value]
340+
)
341+
self._hidden_until_by_page.setdefault(
342+
page_number,
343+
chunk.properties.get(LectureUnitPageChunkSchema.HIDDEN_UNTIL.value),
344+
)
345+
286346
def restore_display_page_numbers_from_existing_chunks(self) -> None:
287347
chunks = self.collection.query.fetch_objects(
288348
filters=self._get_page_chunk_filter(), limit=10000
@@ -375,6 +435,7 @@ def chunk_data(
375435
self.course_language,
376436
base_url,
377437
vision_result.display_page_number,
438+
self._hidden_until_by_page.get(page_num + 1),
378439
)
379440
)
380441
old_page_text = page_text

0 commit comments

Comments
 (0)