SakibAhmed commited on
Commit
78c73e4
·
verified ·
1 Parent(s): 8f17023

Upload 19 files

Browse files
.dockerignore CHANGED
@@ -1,6 +1,15 @@
1
  .git
2
  .gitignore
 
 
 
 
 
3
  __pycache__/
4
  *.py[cod]
5
  *.log
 
 
 
6
  faiss_storage_custom-topic-quiz-generator/
 
 
1
  .git
2
  .gitignore
3
+
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+
8
  __pycache__/
9
  *.py[cod]
10
  *.log
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+
14
  faiss_storage_custom-topic-quiz-generator/
15
+ sources/custom-topic-quiz-generator/quiz_questions.csv
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.log
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+
11
+ faiss_storage_custom-topic-quiz-generator/
12
+ sources/custom-topic-quiz-generator/quiz_questions.csv
CUSTOM_QUIZ_DEPLOYMENT.md CHANGED
@@ -1,96 +1,80 @@
1
- # Custom Topic Quiz Generator Deployment
2
 
3
- This directory is the dedicated Hugging Face Docker Space for
4
- `custom-topic-quiz-generator`. The authoritative runtime entrypoint is the root
5
- `app.py`, and the authoritative container definition is the root `Dockerfile`.
6
 
7
- ## 1. Export the two-column source CSV
8
 
9
- Run the export job with service-role credentials only in the trusted export
10
- environment:
11
 
12
- ```bash
13
- export QUIZ_EXPORT_SUPABASE_URL="https://supabase.example.com"
14
- export QUIZ_EXPORT_SERVICE_ROLE_KEY="..."
15
- export QUIZ_EXPORT_OUTPUT="sources/custom-topic-quiz-generator/quiz_questions.csv"
16
- python scripts/export_quiz_questions.py
 
 
 
17
  ```
18
 
19
- The CSV contains only `id` and `question`. Upload the resulting file to the
20
- dedicated Google Drive file configured through `QUIZ_CSV_URL`. The retrieval
21
- API never receives options, answers, hints, explanations, source IDs, category
22
- IDs, difficulty, Flash flags, or deletion flags. The Supabase Edge Function
23
- hydrates and validates every returned ID against the live database.
24
 
25
- ## 2. Build and run the API
26
 
27
  ```bash
28
- docker build -t custom-topic-quiz-generator .
29
- docker run --rm -p 7860:7860 --env-file .env custom-topic-quiz-generator
 
 
 
 
 
 
 
 
 
 
 
30
  ```
31
 
32
- The container starts Gunicorn with `app:app` on port `7860`. Initial model and
33
- index loading happens without changing the HTTP entrypoint. A failed refresh
34
- leaves the previous valid index active.
35
 
36
- ## 3. Retrieval API environment
37
 
38
- Required credentials:
 
39
 
40
  ```text
 
41
  CUSTOM_QUIZ_API_USERNAME
42
  CUSTOM_QUIZ_API_PASSWORD
43
  CUSTOM_QUIZ_ADMIN_USERNAME
44
  CUSTOM_QUIZ_ADMIN_PASSWORD
45
  ```
46
 
47
- Source/index settings commonly used by this deployment:
48
 
49
  ```text
50
- QUIZ_CSV_URL
51
- QUIZ_CSV_PATH
52
- QUIZ_SOURCE_DIR
53
- QUIZ_INDEX_DIR
54
- QUIZ_ALIASES_PATH
55
  QUIZ_INDEX_REFRESH_MINUTES
 
56
  ```
57
 
58
- The public search endpoint is:
59
-
60
- ```text
61
- POST /v1/custom-topic-quiz-generator/search
62
- ```
63
-
64
- ## 4. Supabase Edge Function
65
-
66
- The only authoritative Edge Function folder is:
67
-
68
- ```text
69
- supabase/functions/custom-topic-quiz-generator
70
- ```
71
-
72
- Configure these values in `supabase/functions/.env`:
73
-
74
- ```text
75
- CUSTOM_QUIZ_API_BASE_URL
76
- CUSTOM_QUIZ_API_USERNAME
77
- CUSTOM_QUIZ_API_PASSWORD
78
- CUSTOM_QUIZ_API_TIMEOUT_MS
79
- CUSTOM_QUIZ_MAX_EXCLUDE_IDS
80
- CUSTOM_QUIZ_STALE_ID_ALERT_THRESHOLD
81
- ```
82
-
83
- `CUSTOM_QUIZ_API_USERNAME` and `CUSTOM_QUIZ_API_PASSWORD` must match the
84
- retrieval API values. The Edge Function authenticates the Flutter user itself,
85
- so its `config.toml` entry uses `verify_jwt = false`.
86
-
87
- ## 5. Flutter feature flag
88
 
89
- The feature is enabled by default. It can be disabled at build time with:
90
 
91
  ```text
92
- --dart-define=CUSTOM_TOPIC_QUIZ_ENABLED=false
 
 
 
 
 
93
  ```
94
 
95
- Flutter calls only the `custom-topic-quiz-generator` Supabase Edge Function. It
96
- never receives the retrieval API URL or credentials.
 
1
+ # Custom Topic Quiz Generator API
2
 
3
+ This is the standalone Hugging Face/Docker retrieval API. The Supabase Edge
4
+ Function is deployed separately as `custom-topic-quiz-generator`.
 
5
 
6
+ ## Source CSV
7
 
8
+ The CSV columns are:
 
9
 
10
+ ```text
11
+ id
12
+ question
13
+ source_id
14
+ category_id
15
+ difficulty
16
+ flash
17
+ is_deleted
18
  ```
19
 
20
+ Only `question` is embedded. All other columns are stored as retrieval metadata
21
+ for exhaustive source/category/difficulty/mode filtering before reranking. The
22
+ Edge Function still hydrates and validates every returned ID against the live
23
+ `quiz_questions` table.
 
24
 
25
+ Export with:
26
 
27
  ```bash
28
+ export QUIZ_EXPORT_SUPABASE_URL="https://supabase.sealednectar.org"
29
+ export QUIZ_EXPORT_SERVICE_ROLE_KEY="the service-role key"
30
+ python scripts/export_quiz_questions.py
31
+ ```
32
+
33
+ Upload the generated CSV to the Google Drive file configured by `QUIZ_CSV_URL`.
34
+ The downloader accepts any of these forms:
35
+
36
+ ```text
37
+ Google Drive file ID
38
+ https://drive.google.com/file/d/FILE_ID
39
+ https://drive.google.com/file/d/FILE_ID/view
40
+ https://drive.google.com/open?id=FILE_ID
41
  ```
42
 
43
+ The file must be shared so the Hugging Face Space can download it.
 
 
44
 
45
+ ## Hugging Face configuration
46
 
47
+ Store credentials as Hugging Face Space secrets. Do not commit or upload `.env`.
48
+ Required values:
49
 
50
  ```text
51
+ QUIZ_CSV_URL
52
  CUSTOM_QUIZ_API_USERNAME
53
  CUSTOM_QUIZ_API_PASSWORD
54
  CUSTOM_QUIZ_ADMIN_USERNAME
55
  CUSTOM_QUIZ_ADMIN_PASSWORD
56
  ```
57
 
58
+ Optional:
59
 
60
  ```text
61
+ HF_TOKEN
 
 
 
 
62
  QUIZ_INDEX_REFRESH_MINUTES
63
+ QUIZ_RERANKER_ENABLED
64
  ```
65
 
66
+ `HF_TOKEN` removes unauthenticated Hugging Face Hub download warnings and gives
67
+ normal authenticated download limits.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ The Docker runtime listens on port `7860` and exposes:
70
 
71
  ```text
72
+ GET /
73
+ GET /health
74
+ POST /v1/custom-topic-quiz-generator/search
75
+ GET /internal/index/status
76
+ POST /internal/index/rebuild
77
+ POST /internal/index/refresh-from-google
78
  ```
79
 
80
+ The search and admin endpoints use HTTP Basic Auth with separate credentials.
 
app.py CHANGED
@@ -286,7 +286,7 @@ def search():
286
  query=query,
287
  limit=limit,
288
  exclude_ids=_exclude_ids(data.get("exclude_ids")),
289
- filters=SearchFilters.from_payload(data.get("filters")),
290
  )
291
  except ValueError as exc:
292
  return jsonify({"error": str(exc)}), 400
 
286
  query=query,
287
  limit=limit,
288
  exclude_ids=_exclude_ids(data.get("exclude_ids")),
289
+ filters=SearchFilters.from_payload(data.get("filters"), mode=mode),
290
  )
291
  except ValueError as exc:
292
  return jsonify({"error": str(exc)}), 400
note.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ https://homemademirpur-ed-cad-ref.hf.space
requirements.txt CHANGED
@@ -1,9 +1,11 @@
1
- Flask
2
- python-dotenv
3
- gdown
4
- numpy
5
- faiss-cpu
6
- torch
7
- sentence-transformers
8
- gunicorn
9
- einops
 
 
 
1
+ Flask==3.0.3
2
+ python-dotenv==1.0.1
3
+ gdown==5.2.0
4
+ requests==2.32.3
5
+ numpy==1.26.4
6
+ faiss-cpu==1.8.0.post1
7
+ torch==2.3.1
8
+ transformers==4.40.0
9
+ sentence-transformers==3.0.1
10
+ einops==0.8.0
11
+ gunicorn==22.0.0
retrieval/gdrive.py CHANGED
@@ -31,27 +31,24 @@ def _file_id(value: str) -> Optional[str]:
31
  def download_gdrive_file(file_id_or_url: str, target_path: str) -> bool:
32
  file_id = _file_id(file_id_or_url)
33
  if not file_id:
34
- logger.error("[CUSTOM_TOPIC_QUIZ_GENERATOR] Invalid Google Drive file URL or ID.")
 
 
35
  return False
36
 
37
  destination = Path(target_path)
38
  destination.parent.mkdir(parents=True, exist_ok=True)
39
 
40
  try:
41
- try:
42
- result = gdown.download(
43
- id=file_id,
44
- output=str(destination),
45
- quiet=False,
46
- )
47
- except TypeError:
48
- result = gdown.download(
49
- url=f"https://drive.google.com/uc?id={file_id}",
50
- output=str(destination),
51
- quiet=False,
52
- )
53
  except Exception:
54
- logger.exception("[CUSTOM_TOPIC_QUIZ_GENERATOR] Google Drive download failed.")
 
 
55
  return False
56
 
57
  return bool(
 
31
  def download_gdrive_file(file_id_or_url: str, target_path: str) -> bool:
32
  file_id = _file_id(file_id_or_url)
33
  if not file_id:
34
+ logger.error(
35
+ "[CUSTOM_TOPIC_QUIZ_GENERATOR] Invalid Google Drive file URL or ID."
36
+ )
37
  return False
38
 
39
  destination = Path(target_path)
40
  destination.parent.mkdir(parents=True, exist_ok=True)
41
 
42
  try:
43
+ result = gdown.download(
44
+ id=file_id,
45
+ output=str(destination),
46
+ quiet=False,
47
+ )
 
 
 
 
 
 
 
48
  except Exception:
49
+ logger.exception(
50
+ "[CUSTOM_TOPIC_QUIZ_GENERATOR] Google Drive download failed."
51
+ )
52
  return False
53
 
54
  return bool(
retrieval/parser.py CHANGED
@@ -5,18 +5,21 @@ from pathlib import Path
5
  from typing import Dict, List, Optional
6
 
7
 
8
- # The retrieval index intentionally contains only the authoritative database ID
9
- # and exact question text. All mutable eligibility/filter metadata is checked by
10
- # the Supabase Edge Function against the live quiz_questions row.
11
- REQUIRED_COLUMNS = {"id", "question"}
 
 
 
 
 
12
 
13
 
14
  @dataclass(frozen=True)
15
  class QuestionRecord:
16
  question_id: int
17
  question: str
18
- # Retained for backwards-compatible index deserialization. New two-column
19
- # CSV builds leave these unset because Supabase owns live filtering.
20
  source_id: Optional[int] = None
21
  category_id: Optional[int] = None
22
  chapter_id: Optional[int] = None
@@ -24,9 +27,17 @@ class QuestionRecord:
24
  flash: bool = False
25
  event_seerah: bool = False
26
  updated_at: str = ""
 
27
 
28
  def metadata(self) -> Dict[str, object]:
29
- return {"question_id": self.question_id}
 
 
 
 
 
 
 
30
 
31
 
32
  @dataclass(frozen=True)
@@ -57,13 +68,39 @@ def _required_positive_int(value: object, field: str, row_number: int) -> int:
57
  f"Row {row_number}: {field} is not an integer."
58
  ) from exc
59
  if not numeric.is_integer():
60
- raise QuizCsvValidationError(f"Row {row_number}: {field} is not an integer.")
 
 
61
  parsed = int(numeric)
62
  if parsed <= 0:
63
- raise QuizCsvValidationError(f"Row {row_number}: {field} must be positive.")
 
 
64
  return parsed
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def file_sha256(path: Path) -> str:
68
  digest = hashlib.sha256()
69
  with path.open("rb") as handle:
@@ -79,10 +116,14 @@ def parse_questions_csv(path: Path) -> ParsedQuestions:
79
  records: List[QuestionRecord] = []
80
  seen_ids = set()
81
  invalid_count = 0
 
82
 
83
  with path.open("r", encoding="utf-8-sig", newline="") as handle:
84
  reader = csv.DictReader(handle)
85
- fieldnames = {str(name or "").strip() for name in (reader.fieldnames or [])}
 
 
 
86
  missing = sorted(REQUIRED_COLUMNS - fieldnames)
87
  if missing:
88
  raise QuizCsvValidationError(
@@ -91,30 +132,74 @@ def parse_questions_csv(path: Path) -> ParsedQuestions:
91
 
92
  for row_number, raw in enumerate(reader, start=2):
93
  try:
94
- question_id = _required_positive_int(raw.get("id"), "id", row_number)
 
 
 
 
95
  question = _text(raw.get("question"))
96
  if not question:
97
- raise QuizCsvValidationError(f"Row {row_number}: question is blank.")
 
 
98
  if question_id in seen_ids:
99
  raise QuizCsvValidationError(
100
  f"Row {row_number}: duplicate id {question_id}."
101
  )
 
 
 
 
 
 
102
  seen_ids.add(question_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  records.append(
104
- QuestionRecord(question_id=question_id, question=question)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  )
106
  except QuizCsvValidationError:
107
  invalid_count += 1
108
  raise
109
 
110
  if not records:
111
- raise QuizCsvValidationError("Quiz CSV contains no questions.")
112
 
113
  return ParsedQuestions(
114
  records=records,
115
  source_sha256=file_sha256(path),
116
  invalid_count=invalid_count,
117
- # Deleted rows cannot be represented in a two-column CSV. The export
118
- # excludes them, and the Edge Function rechecks is_deleted live.
119
- deleted_count=0,
120
  )
 
5
  from typing import Dict, List, Optional
6
 
7
 
8
+ REQUIRED_COLUMNS = {
9
+ "id",
10
+ "question",
11
+ "source_id",
12
+ "category_id",
13
+ "difficulty",
14
+ "flash",
15
+ "is_deleted",
16
+ }
17
 
18
 
19
  @dataclass(frozen=True)
20
  class QuestionRecord:
21
  question_id: int
22
  question: str
 
 
23
  source_id: Optional[int] = None
24
  category_id: Optional[int] = None
25
  chapter_id: Optional[int] = None
 
27
  flash: bool = False
28
  event_seerah: bool = False
29
  updated_at: str = ""
30
+ is_deleted: bool = False
31
 
32
  def metadata(self) -> Dict[str, object]:
33
+ return {
34
+ "question_id": self.question_id,
35
+ "source_id": self.source_id,
36
+ "category_id": self.category_id,
37
+ "difficulty": self.difficulty,
38
+ "flash": self.flash,
39
+ "is_deleted": self.is_deleted,
40
+ }
41
 
42
 
43
  @dataclass(frozen=True)
 
68
  f"Row {row_number}: {field} is not an integer."
69
  ) from exc
70
  if not numeric.is_integer():
71
+ raise QuizCsvValidationError(
72
+ f"Row {row_number}: {field} is not an integer."
73
+ )
74
  parsed = int(numeric)
75
  if parsed <= 0:
76
+ raise QuizCsvValidationError(
77
+ f"Row {row_number}: {field} must be positive."
78
+ )
79
  return parsed
80
 
81
 
82
+ def _optional_positive_int(
83
+ value: object,
84
+ field: str,
85
+ row_number: int,
86
+ ) -> Optional[int]:
87
+ text = _text(value)
88
+ if not text:
89
+ return None
90
+ return _required_positive_int(text, field, row_number)
91
+
92
+
93
+ def _boolean(value: object, field: str, row_number: int) -> bool:
94
+ text = _text(value).lower()
95
+ if text in {"1", "true", "t", "yes", "y"}:
96
+ return True
97
+ if text in {"0", "false", "f", "no", "n", ""}:
98
+ return False
99
+ raise QuizCsvValidationError(
100
+ f"Row {row_number}: {field} is not a boolean."
101
+ )
102
+
103
+
104
  def file_sha256(path: Path) -> str:
105
  digest = hashlib.sha256()
106
  with path.open("rb") as handle:
 
116
  records: List[QuestionRecord] = []
117
  seen_ids = set()
118
  invalid_count = 0
119
+ deleted_count = 0
120
 
121
  with path.open("r", encoding="utf-8-sig", newline="") as handle:
122
  reader = csv.DictReader(handle)
123
+ fieldnames = {
124
+ str(name or "").strip()
125
+ for name in (reader.fieldnames or [])
126
+ }
127
  missing = sorted(REQUIRED_COLUMNS - fieldnames)
128
  if missing:
129
  raise QuizCsvValidationError(
 
132
 
133
  for row_number, raw in enumerate(reader, start=2):
134
  try:
135
+ question_id = _required_positive_int(
136
+ raw.get("id"),
137
+ "id",
138
+ row_number,
139
+ )
140
  question = _text(raw.get("question"))
141
  if not question:
142
+ raise QuizCsvValidationError(
143
+ f"Row {row_number}: question is blank."
144
+ )
145
  if question_id in seen_ids:
146
  raise QuizCsvValidationError(
147
  f"Row {row_number}: duplicate id {question_id}."
148
  )
149
+
150
+ is_deleted = _boolean(
151
+ raw.get("is_deleted"),
152
+ "is_deleted",
153
+ row_number,
154
+ )
155
  seen_ids.add(question_id)
156
+ if is_deleted:
157
+ deleted_count += 1
158
+ continue
159
+
160
+ difficulty = _optional_positive_int(
161
+ raw.get("difficulty"),
162
+ "difficulty",
163
+ row_number,
164
+ )
165
+ if difficulty is not None and difficulty not in {1, 2, 3}:
166
+ raise QuizCsvValidationError(
167
+ f"Row {row_number}: difficulty must be 1, 2, or 3."
168
+ )
169
+
170
  records.append(
171
+ QuestionRecord(
172
+ question_id=question_id,
173
+ question=question,
174
+ source_id=_optional_positive_int(
175
+ raw.get("source_id"),
176
+ "source_id",
177
+ row_number,
178
+ ),
179
+ category_id=_optional_positive_int(
180
+ raw.get("category_id"),
181
+ "category_id",
182
+ row_number,
183
+ ),
184
+ difficulty=difficulty,
185
+ flash=_boolean(
186
+ raw.get("flash"),
187
+ "flash",
188
+ row_number,
189
+ ),
190
+ is_deleted=False,
191
+ )
192
  )
193
  except QuizCsvValidationError:
194
  invalid_count += 1
195
  raise
196
 
197
  if not records:
198
+ raise QuizCsvValidationError("Quiz CSV contains no active questions.")
199
 
200
  return ParsedQuestions(
201
  records=records,
202
  source_sha256=file_sha256(path),
203
  invalid_count=invalid_count,
204
+ deleted_count=deleted_count,
 
 
205
  )
retrieval/search.py CHANGED
@@ -15,12 +15,13 @@ logger = logging.getLogger(__name__)
15
 
16
  @dataclass(frozen=True)
17
  class SearchFilters:
 
18
  source_ids: Tuple[int, ...] = ()
19
  category_ids: Tuple[int, ...] = ()
20
  difficulty: Tuple[int, ...] = ()
21
 
22
  @classmethod
23
- def from_payload(cls, raw: object) -> "SearchFilters":
24
  data = raw if isinstance(raw, dict) else {}
25
 
26
  def positive_ints(
@@ -41,21 +42,42 @@ class SearchFilters:
41
  clean.add(parsed)
42
  return tuple(sorted(clean))
43
 
 
 
 
 
44
  return cls(
 
45
  source_ids=positive_ints(data.get("source_ids")),
46
  category_ids=positive_ints(data.get("category_ids")),
47
  difficulty=positive_ints(data.get("difficulty"), {1, 2, 3}),
48
  )
49
 
50
  def cache_key(self) -> Tuple[object, ...]:
51
- # The two-column RAG index has no live database metadata. Candidate
52
- # ranking is therefore query/index-version specific only. Supabase
53
- # applies source/category/difficulty eligibility after hydration.
54
- return ()
 
 
55
 
56
  def matches(self, metadata: Dict[str, object]) -> bool:
57
- # Intentionally do not filter in the retrieval service. The Edge
58
- # Function owns all authoritative live database filtering.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  return True
60
 
61
 
 
15
 
16
  @dataclass(frozen=True)
17
  class SearchFilters:
18
+ mode: str = "quiz"
19
  source_ids: Tuple[int, ...] = ()
20
  category_ids: Tuple[int, ...] = ()
21
  difficulty: Tuple[int, ...] = ()
22
 
23
  @classmethod
24
+ def from_payload(cls, raw: object, *, mode: str) -> "SearchFilters":
25
  data = raw if isinstance(raw, dict) else {}
26
 
27
  def positive_ints(
 
42
  clean.add(parsed)
43
  return tuple(sorted(clean))
44
 
45
+ clean_mode = str(mode or "quiz").strip().lower()
46
+ if clean_mode not in {"quiz", "exam", "flash"}:
47
+ raise ValueError("mode must be quiz, exam, or flash.")
48
+
49
  return cls(
50
+ mode=clean_mode,
51
  source_ids=positive_ints(data.get("source_ids")),
52
  category_ids=positive_ints(data.get("category_ids")),
53
  difficulty=positive_ints(data.get("difficulty"), {1, 2, 3}),
54
  )
55
 
56
  def cache_key(self) -> Tuple[object, ...]:
57
+ return (
58
+ self.mode,
59
+ self.source_ids,
60
+ self.category_ids,
61
+ self.difficulty,
62
+ )
63
 
64
  def matches(self, metadata: Dict[str, object]) -> bool:
65
+ if metadata.get("is_deleted") is True:
66
+ return False
67
+
68
+ if self.mode in {"exam", "flash"} and metadata.get("flash") is not True:
69
+ return False
70
+
71
+ source_id = metadata.get("source_id")
72
+ category_id = metadata.get("category_id")
73
+ difficulty = metadata.get("difficulty")
74
+
75
+ if self.source_ids and source_id not in self.source_ids:
76
+ return False
77
+ if self.category_ids and category_id not in self.category_ids:
78
+ return False
79
+ if self.difficulty and difficulty not in self.difficulty:
80
+ return False
81
  return True
82
 
83
 
scripts/export_quiz_questions.py CHANGED
@@ -1,9 +1,9 @@
1
  #!/usr/bin/env python3
2
- """Export the two-column custom-topic-quiz-generator CSV from Supabase.
3
 
4
- The retrieval index receives only the authoritative question ID and exact
5
- question text. The Supabase Edge Function hydrates and validates every returned
6
- ID against the live quiz_questions row.
7
  """
8
 
9
  from __future__ import annotations
@@ -17,7 +17,15 @@ from typing import Dict, Iterable, List
17
 
18
  import requests
19
 
20
- FIELDS = ("id", "question")
 
 
 
 
 
 
 
 
21
  PAGE_SIZE = 1000
22
 
23
 
@@ -41,7 +49,10 @@ def fetch_rows(base_url: str, service_key: str) -> Iterable[Dict[str, object]]:
41
  while True:
42
  response = requests.get(
43
  endpoint,
44
- headers={**headers, "Range": f"{offset}-{offset + PAGE_SIZE - 1}"},
 
 
 
45
  params={
46
  "select": ",".join(FIELDS),
47
  "is_deleted": "eq.false",
@@ -64,6 +75,7 @@ def fetch_rows(base_url: str, service_key: str) -> Iterable[Dict[str, object]]:
64
  def validate_rows(rows: List[Dict[str, object]]) -> None:
65
  if not rows:
66
  raise RuntimeError("The export contains no active quiz questions.")
 
67
  seen = set()
68
  for index, row in enumerate(rows, start=2):
69
  question_id = row.get("id")
@@ -74,6 +86,10 @@ def validate_rows(rows: List[Dict[str, object]]) -> None:
74
  raise RuntimeError(f"Row {index}: duplicate id {question_id}.")
75
  if not question:
76
  raise RuntimeError(f"Row {index}: blank question.")
 
 
 
 
77
  seen.add(question_id)
78
 
79
 
@@ -89,7 +105,11 @@ def write_atomic(rows: List[Dict[str, object]], output: Path) -> None:
89
  delete=False,
90
  ) as handle:
91
  temp_path = Path(handle.name)
92
- writer = csv.DictWriter(handle, fieldnames=FIELDS, extrasaction="ignore")
 
 
 
 
93
  writer.writeheader()
94
  writer.writerows(rows)
95
  os.replace(temp_path, output)
 
1
  #!/usr/bin/env python3
2
+ """Export custom-topic retrieval rows from Supabase.
3
 
4
+ Only the ``question`` field is embedded. The remaining fields are stored as
5
+ FAISS-side metadata so source/category/difficulty/mode filtering can happen
6
+ before reranking. The Edge Function still performs final live validation.
7
  """
8
 
9
  from __future__ import annotations
 
17
 
18
  import requests
19
 
20
+ FIELDS = (
21
+ "id",
22
+ "question",
23
+ "source_id",
24
+ "category_id",
25
+ "difficulty",
26
+ "flash",
27
+ "is_deleted",
28
+ )
29
  PAGE_SIZE = 1000
30
 
31
 
 
49
  while True:
50
  response = requests.get(
51
  endpoint,
52
+ headers={
53
+ **headers,
54
+ "Range": f"{offset}-{offset + PAGE_SIZE - 1}",
55
+ },
56
  params={
57
  "select": ",".join(FIELDS),
58
  "is_deleted": "eq.false",
 
75
  def validate_rows(rows: List[Dict[str, object]]) -> None:
76
  if not rows:
77
  raise RuntimeError("The export contains no active quiz questions.")
78
+
79
  seen = set()
80
  for index, row in enumerate(rows, start=2):
81
  question_id = row.get("id")
 
86
  raise RuntimeError(f"Row {index}: duplicate id {question_id}.")
87
  if not question:
88
  raise RuntimeError(f"Row {index}: blank question.")
89
+ if row.get("is_deleted") is True:
90
+ raise RuntimeError(
91
+ f"Row {index}: deleted question {question_id} was exported."
92
+ )
93
  seen.add(question_id)
94
 
95
 
 
105
  delete=False,
106
  ) as handle:
107
  temp_path = Path(handle.name)
108
+ writer = csv.DictWriter(
109
+ handle,
110
+ fieldnames=FIELDS,
111
+ extrasaction="ignore",
112
+ )
113
  writer.writeheader()
114
  writer.writerows(rows)
115
  os.replace(temp_path, output)
shared_models.py CHANGED
@@ -63,7 +63,11 @@ class SharedReranker:
63
  "[CUSTOM_TOPIC_QUIZ_GENERATOR] Loading reranker model %s",
64
  model_name,
65
  )
66
- self._model = CrossEncoder(model_name, trust_remote_code=True)
 
 
 
 
67
  logger.info(
68
  "[CUSTOM_TOPIC_QUIZ_GENERATOR] Reranker loaded in %.2fs",
69
  time.time() - started,
 
63
  "[CUSTOM_TOPIC_QUIZ_GENERATOR] Loading reranker model %s",
64
  model_name,
65
  )
66
+ self._model = CrossEncoder(
67
+ model_name,
68
+ trust_remote_code=True,
69
+ automodel_args={"use_flash_attn": False},
70
+ )
71
  logger.info(
72
  "[CUSTOM_TOPIC_QUIZ_GENERATOR] Reranker loaded in %.2fs",
73
  time.time() - started,