Spaces:
Running
Running
Upload 19 files
Browse files- .dockerignore +9 -0
- .gitignore +12 -0
- CUSTOM_QUIZ_DEPLOYMENT.md +49 -65
- app.py +1 -1
- note.txt +1 -0
- requirements.txt +11 -9
- retrieval/gdrive.py +11 -14
- retrieval/parser.py +102 -17
- retrieval/search.py +29 -7
- scripts/export_quiz_questions.py +27 -7
- shared_models.py +5 -1
.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
|
| 2 |
|
| 3 |
-
This
|
| 4 |
-
`custom-topic-quiz-generator`.
|
| 5 |
-
`app.py`, and the authoritative container definition is the root `Dockerfile`.
|
| 6 |
|
| 7 |
-
##
|
| 8 |
|
| 9 |
-
|
| 10 |
-
environment:
|
| 11 |
|
| 12 |
-
```
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
```
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
hydrates and validates every returned ID against the live database.
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
```bash
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
```
|
| 31 |
|
| 32 |
-
The
|
| 33 |
-
index loading happens without changing the HTTP entrypoint. A failed refresh
|
| 34 |
-
leaves the previous valid index active.
|
| 35 |
|
| 36 |
-
##
|
| 37 |
|
| 38 |
-
|
|
|
|
| 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 |
-
|
| 48 |
|
| 49 |
```text
|
| 50 |
-
|
| 51 |
-
QUIZ_CSV_PATH
|
| 52 |
-
QUIZ_SOURCE_DIR
|
| 53 |
-
QUIZ_INDEX_DIR
|
| 54 |
-
QUIZ_ALIASES_PATH
|
| 55 |
QUIZ_INDEX_REFRESH_MINUTES
|
|
|
|
| 56 |
```
|
| 57 |
|
| 58 |
-
|
| 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
|
| 90 |
|
| 91 |
```text
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
```
|
| 94 |
|
| 95 |
-
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 35 |
return False
|
| 36 |
|
| 37 |
destination = Path(target_path)
|
| 38 |
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 39 |
|
| 40 |
try:
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 61 |
parsed = int(numeric)
|
| 62 |
if parsed <= 0:
|
| 63 |
-
raise QuizCsvValidationError(
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
question = _text(raw.get("question"))
|
| 96 |
if not question:
|
| 97 |
-
raise QuizCsvValidationError(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
|
| 56 |
def matches(self, metadata: Dict[str, object]) -> bool:
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
|
@@ -17,7 +17,15 @@ from typing import Dict, Iterable, List
|
|
| 17 |
|
| 18 |
import requests
|
| 19 |
|
| 20 |
-
FIELDS = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|